89 Commits
v0.1.6 ... main

Author SHA1 Message Date
Renovate Bot
ef1acd4581 chore(deps): update archlinux:base-devel docker digest to 06ab929
All checks were successful
Code check / Check code (push) Successful in 1m15s
2025-10-12 17:46:27 +00:00
96f884904c chore: bump ver to v0.1.7
Some checks failed
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m10s
Build AppImage, Arch and Fedora Packages / Build AppImage (push) Successful in 4m42s
Build AppImage, Arch and Fedora Packages / Build Arch Package (push) Successful in 1m21s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (41) (push) Successful in 1m0s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (42) (push) Successful in 50s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (43) (push) Successful in 51s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (rawhide) (push) Successful in 1m1s
Build AppImage, Arch and Fedora Packages / Create and Publish Release (push) Failing after 5m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:33:56 +05:00
b856a2afae chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:33:12 +05:00
55ef0030e6 feat: added version and commit on WindowTitle
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:31:23 +05:00
8aaeaa4824 chore(localization): add localization for auto-install progress status message
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:14:06 +05:00
f55372b480 fix(autoinstall): fix scrollbar sticking to the right edge
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:10:44 +05:00
4d6f32f053 chore(changelog): 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 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 15:25:04 +05:00
a2f5141b20 chore localization update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 15:21:14 +05:00
e3cb2857e7 fix(pyright): fix pyright errors
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 15:14:02 +05:00
efe8a35832 feat(autoinstall): rework gamepad navigation
Some checks failed
Code check / Check code (push) Has been cancelled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 14:57:43 +05:00
61fae97dad fix(autoinstall): fix virtual keyboard open
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 14:45:52 +05:00
5442100f64 feat: use GameCard on autonstall tab
Some checks failed
Code check / Check code (push) Failing after 1m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 13:56:18 +05:00
2d6ef84798 chore: rename metadata to use pw_create_unique_exe
Some checks failed
Code check / Check code (push) Failing after 1m12s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 12:14:31 +05:00
Renovate Bot
f4aee15b5d chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.14.0
Some checks failed
Code check / Check code (pull_request) Failing after 1m16s
Code check / Check code (push) Failing after 1m16s
2025-10-12 00:01:35 +00:00
87a65108a5 feat(autoinstall): added covers
Some checks failed
Code check / Check code (push) Failing after 1m18s
renovate / renovate (push) Successful in 1m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 00:48:09 +05:00
bb617708ac feat: initial add of autoinstall tab
Some checks failed
Code check / Check code (push) Failing after 4m6s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-11 19:19:47 +05:00
1cf332cd87 feat(winetab): added progress bar
All checks were successful
Code check / Check code (push) Successful in 1m7s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-11 13:24:58 +05:00
577ad4d3a3 feat: adapt WineTab to new cli
All checks were successful
Code check / Check code (push) Successful in 1m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-10 23:07:48 +05:00
ef3f2d6e96 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 21:01:30 +05:00
657d7728a6 fix(gamepad): exit fullscreen on disconnect only if auto-fullscreen enabled and fullscreen disabled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 20:59:51 +05:00
9452bfda2e chore(changelog): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (pull_request) Has been skipped
Code check / Check code (pull_request) Successful in 1m22s
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
7eb2db0d68 chore localization update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
6ef7a03366 feat: added search to controller hints
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
e5af354b56 fix(virtual-keyboard): turn off caps lock when disabling shift while caps is enabled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
e6e5f6c8ea feat(virtual_keyboard): make keyboard bigger
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
84306bb31b feat(virtual_keyboard): added dpad reapeat movement
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
60af4d1482 feat(virtual_keyboard): press X to backspace
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
692e11b21d chore(virtual_keyboard): move styles to style.py
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
b1a804811e chore(keyboard): drop connect_keyboard_to_lineedit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
9a30cfaea7 chore(keyboard): drop unneded key events
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
5dd2f71f5e feat: added virtual keyboard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
dba172361b fix(ui): resolve layout issues during search filtering
All checks were successful
Code check / Check code (push) Successful in 1m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 12:52:34 +05:00
a9c70b8818 chore(winetricks): use curl for download
All checks were successful
Code check / Check code (push) Successful in 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 08:59:03 +05:00
135ace732f chore(deps): added Winetricks deps copied from upstream control
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 08:47:22 +05:00
8b727f64e1 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:26:21 +05:00
a8eb591da5 fix: update ControlHints and NavButtons together
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:23:58 +05:00
fe4ca1ee87 fix: revert signals to pyside 6.9.1
All checks were successful
Code check / Check code (push) Successful in 1m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:12:37 +05:00
ffe3e9d3d6 chore(deps): revert Pyside6 to 6.9.1
Some checks failed
Code check / Check code (push) Failing after 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:04:45 +05:00
49d39b5d61 chore(pyright): fix code for new version
All checks were successful
Code check / Check code (push) Successful in 1m13s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 18:37:31 +05:00
Renovate Bot
03566da704 fix(deps): lock file maintenance python dependencies 2025-10-08 18:21:53 +05:00
Renovate Bot
7f996ab6a0 chore(deps): update archlinux:base-devel docker digest to b380991
All checks were successful
Code check / Check code (push) Successful in 1m3s
2025-10-08 12:09:42 +00:00
Renovate Bot
9e17978155 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 17c8966
Some checks failed
Code check / Check code (pull_request) Successful in 1m6s
Code check / Check code (push) Has been cancelled
2025-10-08 12:05:09 +00:00
5d0185b1b4 feat(winetricks): added preloader to tabble
All checks were successful
Code check / Check code (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 16:41:32 +05:00
5c134be04e chore(changelog): update
Some checks failed
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Failing after 4s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:54:05 +05:00
8c66695192 chore(winetricks): fix typo on translate and added forget icon
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:49:48 +05:00
7a141d8e46 fix(winetricks): resolve QProcess channel mode warning in install handler
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:43:16 +05:00
abb2377fb7 fix(winetricks): remove duplicate entries in winetricks.log
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:37:41 +05:00
75f4f346de chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:27:31 +05:00
87a9f85272 feat(wine settings): make winetricks work with gamepad
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:18:48 +05:00
240f685ece feat(wine settings): make winetricks work
All checks were successful
Code check / Check code (push) Successful in 1m10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 12:06:35 +05:00
af4e3e95bb 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-10-06 17:57:52 +05:00
017d9a42cf feat(wine settings): make prefix and wine delete work
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:55:24 +05:00
18b7c4054b chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:47:45 +05:00
dd7f71b70a feat(wine settings): make pfx_backup work
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:29:06 +05:00
8fd44c575b fix: expose gamesListWidget from GameLibraryManager to fix gamepad navigation
All checks were successful
Code check / Check code (push) Successful in 3m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 13:21:58 +05:00
65b43c1572 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 00:04:55 +05:00
f35276abfe fix: reject candidate if normalized name equals "game"
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 00:02:06 +05:00
6fea9a9a7e chore(wine settings): rework layout
All checks were successful
Code check / Check code (push) Successful in 1m10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-05 20:01:00 +05:00
5189474631 feat(wine settings): initial introdouce
All checks were successful
Code check / Check code (push) Successful in 1m36s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-05 16:19:06 +05:00
Renovate Bot
416cc6a268 chore(deps): update archlinux:base-devel docker digest to 5d95edc
All checks were successful
Code check / Check code (push) Successful in 1m12s
2025-10-05 08:20:07 +00:00
Renovate Bot
3b44ed5252 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to e459af1
Some checks failed
Code check / Check code (pull_request) Successful in 1m21s
Code check / Check code (push) Has been cancelled
2025-10-05 00:01:07 +00:00
c8c45dda06 chore(readme): drop Those Awesome Guys
All checks were successful
Code check / Check code (push) Successful in 1m8s
renovate / renovate (push) Successful in 1m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-04 20:54:57 +05:00
3f9f794e6f hint icons
All checks were successful
Code check / Check code (pull_request) Successful in 1m21s
2025-10-04 22:12:10 +07:00
ba9d8b76d8 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:31:01 +05:00
e99c71c1f8 feat: optimize search
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:29:18 +05:00
baec62d1cb chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:52 +05:00
cb76961e4f feat: optimize add and remove game
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:37 +05:00
Gitea Actions
081cd07253 chore: update steam apps list 2025-10-01T00:01:41Z 2025-10-01 00:01:42 +00:00
b5efee29ea chore: cleanup MainWindow class
All checks were successful
Code check / Check code (push) Successful in 1m8s
Fetch Data / build (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-30 15:43:11 +05:00
69360f7e7e touchscreen scrolling
All checks were successful
Code check / Check code (pull_request) Successful in 1m49s
Code check / Check code (push) Successful in 1m5s
2025-09-28 16:04:11 +03:00
Renovate Bot
39712f0591 chore(deps): update https://gitea.com/actions/setup-node action to v5
All checks were successful
Code check / Check code (push) Successful in 1m6s
2025-09-28 07:43:25 +00:00
Renovate Bot
60b508af18 chore(deps): update https://gitea.com/actions/checkout action to v5
Some checks failed
Code check / Check code (pull_request) Successful in 1m18s
Code check / Check code (push) Has been cancelled
2025-09-28 07:40:23 +00:00
Renovate Bot
b6637b4163 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to dd5721b
All checks were successful
Code check / Check code (push) Successful in 1m11s
2025-09-28 07:34:37 +00:00
Renovate Bot
6d9eed42f8 chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.13.2
All checks were successful
Code check / Check code (pull_request) Successful in 1m5s
Code check / Check code (push) Successful in 1m3s
2025-09-28 00:01:38 +00:00
7372e3b7f5 chore: added zstd comp to appimage
All checks were successful
Code check / Check code (push) Successful in 1m4s
renovate / renovate (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-27 17:52:32 +05:00
e0d5bd7993 chore: update appimage fork
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-27 07:11:11 +00:00
Renovate Bot
12f8067af1 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 2098143
All checks were successful
Code check / Check code (push) Successful in 1m6s
2025-09-24 17:38:45 +00:00
Renovate Bot
716a813ca9 chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.8.22
Some checks failed
Code check / Check code (push) Has been cancelled
Code check / Check code (pull_request) Successful in 1m11s
2025-09-24 17:37:21 +00:00
c62cc6853f chore(check-translation): disable untill yaspeller fixed
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:31:19 +05:00
2e018b4690 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:27:15 +05:00
ad5b25f713 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:17:29 +05:00
3fb8201305 feat(file explorer): added ThumbnailLoader class
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:14:13 +05:00
04d8302d6c chore(logs): start translate
All checks were successful
Code check / Check code (push) Successful in 2m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 21:05:58 +05:00
Renovate Bot
f868b21178 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 06348c5
All checks were successful
Code check / Check code (push) Successful in 1m8s
2025-09-23 12:10:57 +00:00
Renovate Bot
ebe25b41d8 chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.8.20
All checks were successful
Code check / Check code (pull_request) Successful in 1m8s
Code check / Check code (push) Successful in 1m5s
2025-09-23 12:07:17 +00:00
Renovate Bot
fae6cad52d chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to edaa35b
All checks were successful
Code check / Check code (push) Successful in 1m15s
2025-09-23 07:14:54 +00:00
Renovate Bot
42bce11ada chore(deps): update archlinux:base-devel docker digest to 0589aa8
Some checks failed
Code check / Check code (pull_request) Successful in 1m2s
Code check / Check code (push) Has been cancelled
2025-09-23 07:12:08 +00:00
f088c01768 chore(renovate): validate and fix renovate.json configuration
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 12:10:19 +05:00
e7eee85ed4 feat(dev-scripts): regenerate uv.lock on bump ver
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 11:58:10 +05:00
107 changed files with 17191 additions and 2369 deletions

View File

@@ -12,17 +12,27 @@ jobs:
name: Build AppImage
runs-on: ubuntu-22.04
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Install required dependencies
run: |
sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
- name: Install tools
- name: Upgrade pip toolchain
run: |
pip3 install git+https://github.com/Boria138/appimage-builder.git
pip3 install uv
python3 -m pip install --upgrade \
pip setuptools setuptools-scm wheel packaging build
- name: Install appimage-builder
run: |
git clone https://github.com/Boria138/appimage-builder
cd appimage-builder
pip install .
- name: Install uv
run: |
pip install uv
- name: Build AppImage
run: |
@@ -63,7 +73,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Copy fedora.spec
run: |
@@ -84,7 +94,7 @@ jobs:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
volumes:
- /usr:/usr-host
- /opt:/opt-host
@@ -124,7 +134,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- 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.6
VERSION: 0.1.7
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -23,12 +23,22 @@ jobs:
- name: Install required dependencies
run: |
sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
- name: Install tools
- name: Upgrade pip toolchain
run: |
pip3 install git+https://github.com/Boria138/appimage-builder.git
pip3 install uv
python3 -m pip install --upgrade \
pip setuptools setuptools-scm wheel packaging build
- name: Install appimage-builder
run: |
git clone https://github.com/Boria138/appimage-builder
cd appimage-builder
pip install .
- name: Install uv
run: |
pip install uv
- name: Build AppImage
run: |

View File

@@ -1,4 +1,4 @@
name: Check Translations
name: Check Translations (disabled until yaspeller is fixed)
run-name: Check spelling in translation files
on:
push:
@@ -12,10 +12,11 @@ on:
jobs:
check-translations:
if: false
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5

View File

@@ -18,7 +18,7 @@ jobs:
fedora: ${{ steps.check.outputs.fedora }}
arch: ${{ steps.check.outputs.arch }}
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- 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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- 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:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- 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@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 20

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5

View File

@@ -8,12 +8,12 @@ on:
jobs:
renovate:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:46b57bb9816dec6409e7be57e0e5f7b26d214281044f5aedd3b160be178475e2
container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 20

View File

@@ -11,12 +11,12 @@ repos:
- id: check-yaml
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.8.9
rev: 0.8.22
hooks:
- id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.12.8
rev: v0.14.0
hooks:
- id: ruff-check

View File

@@ -3,6 +3,37 @@
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [0.1.7] - 2025-10-12
### Added
- Возможность скроллинга библиотеки мышью или пальцем
- Импорт и экспорт бекапа префикса
- Диалог для управление Winetricks
- Кнопки для удаления префикса, wine или proton
- Все настройки Wine с оригинального PortProton
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
- Вкладка автоустановок
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
- В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку
### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
- При сохранении настроек теперь не меняется размер окна
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---
## [0.1.6] - 2025-09-23
### Added
@@ -16,12 +47,13 @@
### Changed
- Управления с геймпада теперь перехватывается только если окно в фокусе
### Fixed
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---

View File

@@ -54,8 +54,6 @@ PortProtonQt использует код и зависимости от след
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/)
Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING]

View File

@@ -1,16 +1,11 @@
version: 1
script:
# 1) чистим старый AppDir
- rm -rf AppDir || true
# 2) создаём структуру каталога
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
# 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
- uv venv
- uv pip install --no-cache-dir ../
# 4) копируем всё из .venv в AppDir
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr
# 5) чистим от ненужных модулей и бинарников
- 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*}
@@ -19,7 +14,6 @@ script:
AppDir:
path: ./AppDir
after_bundle:
# Документация, справка, примеры
- rm -rf $TARGET_APPDIR/usr/share/man || true
- rm -rf $TARGET_APPDIR/usr/share/doc || true
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true
@@ -35,17 +29,14 @@ AppDir:
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true
- rm -rf $TARGET_APPDIR/usr/include || true
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
# Статика и отладка
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
# Strip ELF бинарников (исключая Python extensions)
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
# Удаление пустых папок
- find $TARGET_APPDIR -type d -empty -delete || true
app_info:
id: ru.linux_gaming.PortProtonQt
name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt
version: 0.1.6
version: 0.1.7
exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@"
apt:
@@ -63,16 +54,18 @@ AppDir:
- libxcb-cursor0
- libimage-exiftool-perl
- xdg-utils
- cabextract
- curl
- 7zip
- unzip
- unrar
exclude:
# Документация и man-страницы
- "*-doc"
- "*-man"
- manpages
- mandb
# Статические библиотеки
- "*-dev"
- "*-static"
# Дебаг-символы
- "*-dbg"
- "*-dbgsym"
runtime:
@@ -83,3 +76,4 @@ AppDir:
AppImage:
sign-key: None
arch: x86_64
comp: zstd

View File

@@ -1,12 +1,12 @@
pkgname=portprotonqt
pkgver=0.1.6
pkgver=0.1.7
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')
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' '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')

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')
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' '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.git")
sha256sums=('SKIP')

View File

@@ -46,6 +46,11 @@ Requires: python3-pillow
Requires: perl-Image-ExifTool
Requires: xdg-utils
Requires: python3-beautifulsoup4
Requires: cabextract
Requires: gzip
Requires: unzip
Requires: curl
Requires: unrar
%description -n python3-%{pypi_name}-git
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 0.1.6
%global pypi_version 0.1.7
%global oname PortProtonQt
%global _python_no_extras_requires 1
@@ -43,6 +43,11 @@ Requires: python3-pillow
Requires: perl-Image-ExifTool
Requires: xdg-utils
Requires: python3-beautifulsoup4
Requires: cabextract
Requires: gzip
Requires: unzip
Requires: curl
Requires: unrar
%description -n python3-%{pypi_name}
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.

View File

@@ -217,7 +217,7 @@
},
{
"normalized_name": "watch_dogs 2",
"status": "Broken"
"status": "Running"
},
{
"normalized_name": "zero hour",

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,96 @@
[
{
"normalized_title": "dirt rally 2.0 game of the year",
"slug": "dirt-rally-2-0-game-of-the-year-edition"
},
{
"normalized_title": "deus ex human revolution directors cut",
"slug": "deus-ex-human-revolution-director-s-cut"
},
{
"normalized_title": "freelancer",
"slug": "freelancer"
},
{
"normalized_title": "everspace",
"slug": "everspace"
},
{
"normalized_title": "blades of time limited",
"slug": "blades-of-time-limited-edition"
},
{
"normalized_title": "chorus",
"slug": "chorus"
},
{
"normalized_title": "tom clancy's splinter cell pandora tomorrow",
"slug": "tom-clancys-splinter-cell-pandora-tomorrow"
},
{
"normalized_title": "the alters",
"slug": "the-alters"
},
{
"normalized_title": "hard reset redux",
"slug": "hard-reset-redux"
},
{
"normalized_title": "far cry 5",
"slug": "far-cry-5"
},
{
"normalized_title": "metal eden",
"slug": "metal-eden"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "old world",
"slug": "old-world"
},
{
"normalized_title": "witchfire",
"slug": "witchfire"
},
{
"normalized_title": "prototype",
"slug": "prototype"
},
{
"normalized_title": "mandragora whispers of the witch tree",
"slug": "mandragora-whispers-of-the-witch-tree"
},
{
"normalized_title": "grand theft auto v (gta 5)",
"slug": "grand-theft-auto-v-gta-5"
},
{
"normalized_title": "lifeless planet premier",
"slug": "lifeless-planet-premier-edition"
},
{
"normalized_title": "warcraft iii the frozen throne",
"slug": "warcraft-iii-the-frozen-throne"
},
{
"normalized_title": "star wars republic commando",
"slug": "star-wars-republic-commando"
},
{
"normalized_title": "hollow knight silksong",
"slug": "hollow-knight-silksong"
},
{
"normalized_title": "arma reforger",
"slug": "arma-reforger"
},
{
"normalized_title": "arma 3",
"slug": "arma-3"
},
{
"normalized_title": "astroneer",
"slug": "astroneer"
@@ -195,10 +287,6 @@
"normalized_title": "slitterhead",
"slug": "slitterhead"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "crossout",
"slug": "crossout"

Binary file not shown.

View File

@@ -2,6 +2,7 @@
import argparse
import re
import subprocess
from pathlib import Path
from datetime import date
@@ -134,6 +135,12 @@ def main():
print(f"Updated version from {old} to {new} in {len(updated)} files:")
for p in sorted(updated):
print(f" - {p}")
try:
subprocess.run(["uv", "lock"], check=True)
print("Regenerated uv.lock")
except subprocess.CalledProcessError as e:
print(f"Failed to regenerate uv.lock: {e}")
else:
print(f"No occurrences of version {old} found in specified files.")

View File

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

View File

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

View File

@@ -1,17 +1,41 @@
import sys
import os
import subprocess
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow
from portprotonqt.config_utils import save_fullscreen_config
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location
from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.cli import parse_args
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
__app_version__ = "0.1.6"
__app_version__ = "0.1.7"
def get_version():
try:
commit = subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'],
stderr=subprocess.DEVNULL
).decode('utf-8').strip()
return f"{__app_version__} ({commit})"
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return __app_version__
def main():
os.environ['PW_CLI'] = '1'
os.environ['PROCESS_LOG'] = '1'
os.environ['START_FROM_STEAM'] = '1'
portproton_path = get_portproton_location()
if portproton_path is None:
return
script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh')
subprocess.run([script_path, 'cli', '--initial'])
app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__)
@@ -34,7 +58,8 @@ def main():
else:
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
window = MainWindow(app_name=__app_name__)
version = get_version()
window = MainWindow(app_name=__app_name__, version=version)
if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag")

View File

@@ -1,17 +1,15 @@
import argparse
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def parse_args():
"""
Парсит аргументы командной строки.
Parses command-line arguments.
"""
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
parser.add_argument(
"--fullscreen",
action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
help="Launch the application in fullscreen mode and save this setting"
)
parser.add_argument(
"--debug-level",

View File

@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
class ContextMenuManager:
"""Manages context menu actions for game management in PortProtonQt."""
def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
"""
Initialize the ContextMenuManager.
@@ -45,7 +45,8 @@ class ContextMenuManager:
self.theme = theme
self.theme_manager = ThemeManager()
self.load_games = load_games_callback
self.update_game_grid = update_game_grid_callback
self.game_library_manager = game_library_manager
self.update_game_grid = game_library_manager.update_game_grid
self.legendary_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache", "legendary"
@@ -62,7 +63,7 @@ class ContextMenuManager:
self.parent.statusBar().showMessage,
Qt.ConnectionType.QueuedConnection
)
logger.debug("Connected show_status_message signal to statusBar")
logger.debug("Connected show_status_message signal to status bar")
self.signals.show_warning_dialog.connect(
self._show_warning_dialog,
Qt.ConnectionType.QueuedConnection
@@ -74,28 +75,28 @@ class ContextMenuManager:
def _show_warning_dialog(self, title: str, message: str):
"""Show a warning dialog in the main thread."""
logger.debug("Showing warning dialog: %s - %s", title, message)
logger.debug("Displaying warning dialog: %s - %s", title, message)
QMessageBox.warning(self.parent, title, message)
def _show_info_dialog(self, title: str, message: str):
"""Show an info dialog in the main thread."""
logger.debug("Showing info dialog: %s - %s", title, message)
logger.debug("Displaying info dialog: %s - %s", title, message)
QMessageBox.information(self.parent, title, message)
def _show_status_message(self, message: str, timeout: int = 3000):
"""Show a status message on the status bar if available."""
if self.parent.statusBar():
self.parent.statusBar().showMessage(message, timeout)
logger.debug("Direct status message: %s", message)
logger.debug("Displayed status message: %s", message)
else:
logger.warning("Status bar not available for message: %s", message)
logger.warning("Status bar unavailable for message: %s", message)
def _check_portproton(self):
"""Check if PortProton is available."""
if self.portproton_location is None:
self.signals.show_warning_dialog.emit(
_("Error"),
_("PortProton is not found")
_("PortProton directory not found")
)
return False
return True
@@ -119,7 +120,7 @@ class ContextMenuManager:
installed_games = orjson.loads(f.read())
return app_name in installed_games
except (OSError, orjson.JSONDecodeError) as e:
logger.error("Failed to read installed.json: %s", e)
logger.error("Error reading installed.json: %s", e)
return False
def _is_game_running(self, game_card) -> bool:
@@ -155,7 +156,7 @@ class ContextMenuManager:
try:
item = file_explorer.file_list.itemAt(pos)
if not item:
logger.debug("No item selected at position %s", pos)
logger.debug("No folder selected at position %s", pos)
return
selected = item.text()
if not selected.endswith("/"):
@@ -202,7 +203,7 @@ class ContextMenuManager:
global_pos = file_explorer.file_list.mapToGlobal(pos)
menu.exec(global_pos)
except Exception as e:
logger.error("Error showing folder context menu: %s", e)
logger.error("Error displaying folder context menu: %s", e)
def toggle_favorite_folder(self, file_explorer, folder_path, add):
"""Adds or removes a folder from favorites."""
@@ -211,12 +212,12 @@ class ContextMenuManager:
if folder_path not in favorite_folders:
favorite_folders.append(folder_path)
save_favorite_folders(favorite_folders)
logger.info(f"Folder added to favorites: {folder_path}")
logger.info("Added folder to favorites: %s", folder_path)
else:
if folder_path in favorite_folders:
favorite_folders.remove(folder_path)
save_favorite_folders(favorite_folders)
logger.info(f"Folder removed from favorites: {folder_path}")
logger.info("Removed folder from favorites: %s", folder_path)
file_explorer.update_drives_list()
def _get_safe_icon(self, icon_name: str) -> QIcon:
@@ -607,10 +608,10 @@ class ContextMenuManager:
exe_path = get_egs_executable(app_name, self.legendary_config_path)
if exe_path and os.path.exists(exe_path):
if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail from exe: {exe_path}")
logger.error("Failed to generate thumbnail for EGS game: %s", exe_path)
icon_path = ""
else:
logger.error(f"No executable found for EGS game: {app_name}")
logger.error("No executable found for EGS game: %s", app_name)
icon_path = ""
egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
@@ -750,7 +751,7 @@ Icon={icon_path}
if not exec_line:
self.signals.show_warning_dialog.emit(
_("Error"),
_("No executable command in .desktop file for '{game_name}'").format(game_name=game_name)
_("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name)
)
return None
else:
@@ -762,7 +763,7 @@ Icon={icon_path}
except Exception as e:
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to read .desktop file: {error}").format(error=str(e))
_("Error reading .desktop file: {error}").format(error=str(e))
)
return None
else:
@@ -784,7 +785,7 @@ Icon={icon_path}
try:
entry_exec_split = shlex.split(exec_line)
if not entry_exec_split:
logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line)
return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2]
@@ -793,11 +794,11 @@ Icon={icon_path}
else:
exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path):
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None")
return None
return exe_path
except Exception as e:
logger.debug("Failed to parse executable for '%s': %s", game_name, e)
logger.debug("Error parsing executable for game '%s': %s", game_name, e)
return None
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
@@ -859,9 +860,16 @@ Icon={icon_path}
_("Failed to delete custom data: {error}").format(error=str(e))
)
# Reload games list and update grid
self.load_games()
self.update_game_grid()
self.update_game_grid = self.game_library_manager.remove_game_incremental
self.game_library_manager.remove_game_incremental(game_name, exec_line)
def add_game_incremental(self, game_data: tuple):
"""Add game after .desktop creation."""
if not self._check_portproton():
return
# Assume game_data is built from new .desktop (name, desc, cover, etc.)
self.game_library_manager.add_game_incremental(game_data)
self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0]))
def add_to_menu(self, game_name, exec_line):
"""Copy the .desktop file to ~/.local/share/applications."""
@@ -936,7 +944,7 @@ Icon={icon_path}
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
if not os.path.exists(icon_path):
if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail for {exe_path}")
logger.error("Failed to generate thumbnail for game: %s", exe_path)
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
os.makedirs(desktop_dir, exist_ok=True)
@@ -1072,7 +1080,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
logger.debug("Adding '%s' to Steam", game_name)
logger.debug("Adding game '%s' to Steam", game_name)
try:
success, message = add_to_steam(game_name, exec_line, cover_path)
self.signals.show_info_dialog.emit(
@@ -1115,7 +1123,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
logger.debug("Removing non-EGS game '%s' from Steam", game_name)
logger.debug("Removing game '%s' from Steam", game_name)
try:
success, message = remove_from_steam(game_name, exec_line)
self.signals.show_info_dialog.emit(

View File

Before

Width:  |  Height:  |  Size: 634 KiB

After

Width:  |  Height:  |  Size: 634 KiB

View File

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 315 KiB

View File

Before

Width:  |  Height:  |  Size: 978 KiB

After

Width:  |  Height:  |  Size: 978 KiB

View File

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 650 KiB

View File

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 391 KiB

View File

Before

Width:  |  Height:  |  Size: 710 KiB

After

Width:  |  Height:  |  Size: 710 KiB

View File

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 627 KiB

View File

@@ -5,29 +5,29 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
"""
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
rect_width: доступная ширина контейнера.
spacing: отступ между элементами (горизонтальный и вертикальный).
max_scale: максимальный коэффициент масштабирования (например, 1.0).
Computes the layout of elements considering spacing and potential scaling of cards.
nat_sizes: Array (N, 2) with natural sizes of elements (width, height).
rect_width: Available container width.
spacing: Spacing between elements (horizontal and vertical).
max_scale: Maximum scaling factor (e.g., 1.0).
Возвращает:
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
total_height: итоговая высота всех рядов.
Returns:
result: Array (N, 4), where each row contains [x, y, new_width, new_height].
total_height: Total height of all rows.
"""
N = nat_sizes.shape[0]
result = np.zeros((N, 4), dtype=np.int32)
y = 0
i = 0
min_margin = 20 # Минимальный отступ по краям
min_margin = 20 # Minimum margin on edges
# Определяем максимальное количество элементов в ряду и общий масштаб
# Determine the maximum number of items per row and overall scale
max_items_per_row = 0
global_scale = 1.0
max_row_x_start = min_margin # Начальная позиция x самого длинного ряда
max_row_x_start = min_margin # Starting x position of the widest row
temp_i = 0
# Первый проход: находим максимальное количество элементов в ряду
# First pass: Find the maximum number of items in a row
while temp_i < N:
sum_width = 0
count = 0
@@ -42,23 +42,23 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
if count > max_items_per_row:
max_items_per_row = count
# Вычисляем масштаб для самого заполненного ряда
# Calculate scale for the most populated row
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
global_scale = desired_scale if desired_scale < max_scale else max_scale
# Сохраняем начальную позицию x для самого длинного ряда
# Store starting x position for the widest row
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
temp_i = temp_j
# Второй проход: размещаем элементы
# Second pass: Place elements
while i < N:
sum_width = 0
row_max_height = 0
count = 0
j = i
# Подбираем количество элементов для текущего ряда
# Determine the number of items for the current row
while j < N:
w = nat_sizes[j, 0]
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
@@ -70,16 +70,16 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
row_max_height = h
j += 1
# Используем глобальный масштаб для всех рядов
# Use global scale for all rows
scale = global_scale
scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
# Определяем начальную координату x
# Determine starting x coordinate
if count == max_items_per_row:
# Центрируем полный ряд
# Center the full row
x = max(min_margin, (rect_width - scaled_row_width) // 2)
else:
# Выравниваем неполный ряд по левому краю, совмещая с началом самого длинного ряда
# Align incomplete row to the left, matching the widest row's start
x = max_row_x_start
for k in range(i, j):
@@ -99,9 +99,9 @@ class FlowLayout(QLayout):
def __init__(self, parent=None):
super().__init__(parent)
self.itemList = []
self.setContentsMargins(20, 20, 20, 20) # Отступы по краям
self._spacing = 20 # Отступ для анимации и предотвращения перекрытий
self._max_scale = 1.0 # Отключено масштабирование в layout
self.setContentsMargins(20, 20, 20, 20) # Margins around the layout
self._spacing = 20 # Spacing for animation and overlap prevention
self._max_scale = 1.0 # Scaling disabled in layout
def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item)
@@ -126,7 +126,21 @@ class FlowLayout(QLayout):
return True
def heightForWidth(self, width):
return self.doLayout(QRect(0, 0, width, 0), True)
# Аналогично фильтруем видимые для тестового расчёта высоты
visible_items = []
nat_sizes = np.empty((0, 2), dtype=np.int32)
for item in self.itemList:
if item.widget() and item.widget().isVisible():
visible_items.append(item)
s = item.sizeHint()
new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
if len(visible_items) == 0:
return 0
_, total_height = compute_layout(nat_sizes, width, self._spacing, self._max_scale)
return total_height
def setGeometry(self, rect):
super().setGeometry(rect)
@@ -145,26 +159,46 @@ class FlowLayout(QLayout):
return size
def doLayout(self, rect, testOnly):
N = len(self.itemList)
if N == 0:
N_total = len(self.itemList)
if N_total == 0:
return 0
nat_sizes = np.empty((N, 2), dtype=np.int32)
# Фильтруем только видимые элементы
visible_items = []
visible_indices = [] # Индексы в оригинальном itemList для установки геометрии
nat_sizes = np.empty((0, 2), dtype=np.int32)
for i, item in enumerate(self.itemList):
s = item.sizeHint()
nat_sizes[i, 0] = s.width()
nat_sizes[i, 1] = s.height()
if item.widget() and item.widget().isVisible():
visible_items.append(item)
visible_indices.append(i)
s = item.sizeHint()
new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
N = len(visible_items)
if N == 0:
# Если все скрыты, устанавливаем нулевые геометрии для всех
if not testOnly:
for item in self.itemList:
item.setGeometry(QRect())
return 0
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
if not testOnly:
for i, item in enumerate(self.itemList):
x = geom_array[i, 0] + rect.x()
y = geom_array[i, 1] + rect.y()
w = geom_array[i, 2]
h = geom_array[i, 3]
# Устанавливаем геометрии только для видимых
for idx, (_vis_idx, item) in enumerate(zip(visible_indices, visible_items, strict=True)):
x = geom_array[idx, 0] + rect.x()
y = geom_array[idx, 1] + rect.y()
w = geom_array[idx, 2]
h = geom_array[idx, 3]
item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
# Для невидимых — нулевая геометрия
for i in range(N_total):
if i not in visible_indices:
self.itemList[i].setGeometry(QRect())
return total_height
class ClickableLabel(QLabel):

File diff suppressed because it is too large Load Diff

View File

@@ -12,6 +12,7 @@ from portprotonqt.downloader import Downloader
from portprotonqt.animations import GameCardAnimations
from typing import cast
class GameCard(QFrame):
borderWidthChanged = Signal()
gradientAngleChanged = Signal()
@@ -447,6 +448,7 @@ class GameCard(QFrame):
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
def paintEvent(self, event):
super().paintEvent(event)
self.animations.paint_border(QPainter(self))

View File

@@ -0,0 +1,463 @@
from typing import Protocol
from portprotonqt.game_card import GameCard
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
from PySide6.QtCore import Qt, QTimer
from portprotonqt.custom_widgets import FlowLayout
from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from collections import deque
class MainWindowProtocol(Protocol):
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
def openGameDetailPage(
self,
name: str,
description: str,
cover_path: str | None = None,
appid: str = "",
exec_line: str = "",
controller_support: str = "",
last_launch: str = "",
formatted_playtime: str = "",
protondb_tier: str = "",
game_source: str = "",
anticheat_status: str = "",
) -> None: ...
def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ...
def on_slider_released(self) -> None: ...
# Required attributes
searchEdit: CustomLineEdit
_last_card_width: int
current_hovered_card: GameCard | None
current_focused_card: GameCard | None
gamesListWidget: QWidget | None
class GameLibraryManager:
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
self.main_window = main_window
self.theme = theme
self.context_menu_manager: ContextMenuManager | None = context_menu_manager
self.games: list[tuple] = []
self.filtered_games: list[tuple] = []
self.game_card_cache = {}
self.pending_images = {}
self.card_width = read_card_size()
self.gamesListWidget: QWidget | None = None
self.gamesListLayout: FlowLayout | None = None
self.sizeSlider: QSlider | None = None
self._update_timer: QTimer | None = None
self._pending_update = False
self.pending_deletions = deque()
self.is_filtering = False
self.dirty = False
def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider."""
self.gamesLibraryWidget = QWidget()
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
layout = QVBoxLayout(self.gamesLibraryWidget)
layout.setSpacing(15)
# Search widget
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
layout.addWidget(searchWidget)
# Scroll area for game grid
scrollArea = QScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.gamesListWidget = QWidget()
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
layout.addWidget(scrollArea)
# Slider for card size
sliderLayout = QHBoxLayout()
sliderLayout.addStretch()
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250)
self.sizeSlider.setValue(self.card_width)
self.sizeSlider.setTickInterval(10)
self.sizeSlider.setFixedWidth(150)
self.sizeSlider.setToolTip(f"{self.card_width} px")
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout)
# Initialize update timer
self._update_timer = QTimer()
self._update_timer.setSingleShot(True)
self._update_timer.setInterval(100) # 100ms debounce
self._update_timer.timeout.connect(self._perform_update)
# Calculate initial card width
def calculate_card_width():
if self.gamesListLayout is None:
return
available_width = scrollArea.width() - 20
spacing = self.gamesListLayout._spacing
target_cards_per_row = 8
calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
calculated_width = max(200, min(calculated_width, 250))
QTimer.singleShot(0, calculate_card_width)
# Connect scroll event for lazy loading
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
return self.gamesLibraryWidget
def on_slider_released(self):
"""Handles slider release to update card size."""
if self.sizeSlider is None:
return
self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width)
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.update_game_grid()
def load_visible_images(self):
"""Loads images for visible game cards."""
if self.gamesListWidget is None:
return
visible_region = self.gamesListWidget.visibleRegion()
max_concurrent_loads = 5
loaded_count = 0
for card_key, card in self.game_card_cache.items():
if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads:
cover_path, width, height, callback = self.pending_images.pop(card_key)
load_pixmap_async(cover_path, width, height, callback)
loaded_count += 1
def _on_card_focused(self, game_name: str, is_focused: bool):
"""Handles card focus events."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_focused:
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card = None
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
self.main_window.current_focused_card = card
else:
if self.main_window.current_focused_card == card:
self.main_window.current_focused_card = None
def _on_card_hovered(self, game_name: str, is_hovered: bool):
"""Handles card hover events."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_hovered:
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card = card
else:
if self.main_window.current_hovered_card == card:
self.main_window.current_hovered_card = None
def _perform_update(self):
"""Performs the actual grid update."""
if not self._pending_update:
return
self._pending_update = False
self._update_game_grid_immediate()
def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False):
"""Schedules a game grid update with debouncing."""
if not is_filter:
if games_list is not None:
self.filtered_games = games_list
self.dirty = True # Full rebuild only for non-filter
self.is_filtering = is_filter
self._pending_update = True
if self._update_timer is not None:
self._update_timer.start()
else:
self._update_game_grid_immediate()
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
if self.gamesListLayout is None or self.gamesListWidget is None:
return
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)
else:
# Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games if self.filtered_games else self.games
favorites = read_favorites()
sort_method = read_sort_method()
# Batch layout updates (extended scope)
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Optimized sorting: Partition favorites first, then sort subgroups
def partition_sort_key(game):
name = game[0]
is_fav = name in favorites
fav_order = 0 if is_fav else 1
if sort_method == "playtime":
return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0)
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
else:
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]
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
# Build set of current game keys for faster lookup
current_game_keys = {(game[0], game[4]) for game in sorted_games}
# Remove cards that no longer exist (batch)
cards_to_remove = []
for card_key in list(self.game_card_cache.keys()):
if card_key not in current_game_keys:
cards_to_remove.append(card_key)
for card_key in cards_to_remove:
card = self.game_card_cache.pop(card_key)
if self.gamesListLayout is not None:
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer
if card_key in self.pending_images:
del self.pending_images[card_key]
# Track current layout order (only if dirty/full update needed)
if self.dirty and self.gamesListLayout is not None:
current_layout_order = []
for i in range(self.gamesListLayout.count()):
item = self.gamesListLayout.itemAt(i)
if item is not None:
widget = item.widget()
if widget:
for key, card in self.game_card_cache.items():
if card == widget:
current_layout_order.append(key)
break
else:
current_layout_order = None # Skip reorg if not dirty
new_card_order = []
cards_to_add = []
for game_data in sorted_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
should_be_visible = not search_text or search_text in game_name.lower()
if game_key in self.game_card_cache:
card = self.game_card_cache[game_key]
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
new_card_order.append(game_key)
else:
if self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(should_be_visible)
new_card_order.append(game_key)
cards_to_add.append((game_key, card))
# Only reorganize if order changed AND dirty
if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order):
# Remove all widgets from layout (batch)
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
# Add widgets in new order (batch)
for game_key in new_card_order:
card = self.game_card_cache[game_key]
self.gamesListLayout.addWidget(card)
self.dirty = False # Reset flag
# Deferred deletions (run in timer to avoid stack overflow)
if self.pending_deletions:
QTimer.singleShot(0, lambda: self._flush_deletions())
# Load visible images for new cards only
if cards_to_add:
self.load_visible_images()
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.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str):
"""Applies visibility to cards based on search, without changing the layout."""
visible_count = 0
for game_key, card in self.game_card_cache.items():
game_name = card.name # Assume GameCard has 'name' attribute
should_be_visible = not search_text or search_text in game_name.lower()
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
if should_be_visible:
visible_count += 1
# Load image only for newly visible cards
if game_key in self.pending_images:
cover_path, width, height, callback = self.pending_images.pop(game_key)
load_pixmap_async(cover_path, width, height, callback)
# Force full relayout after visibility changes
if self.gamesListLayout is not None:
self.gamesListLayout.invalidate() # Принудительно инвалидируем для пересчёта
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
# If search is empty, load images for visible ones
if not search_text:
self.load_visible_images()
def _create_game_card(self, game_data: tuple) -> GameCard:
"""Creates a new game card with all necessary connections."""
card = GameCard(
*game_data,
select_callback=self.main_window.openGameDetailPage,
theme=self.theme,
card_width=self.card_width,
context_menu_manager=self.context_menu_manager
)
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
if self.context_menu_manager:
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu)
card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop)
card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop)
card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
return card
def _flush_deletions(self):
"""Delete pending widgets off the main update cycle."""
for card in list(self.pending_deletions):
card.deleteLater()
self.pending_deletions.remove(card)
def clear_layout(self, layout):
"""Clears all widgets from the layout."""
if layout is None:
return
while layout.count():
child = layout.takeAt(0)
if child.widget():
widget = child.widget()
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]
widget.deleteLater()
def set_games(self, games: list[tuple]):
"""Sets the games list and updates the filtered games."""
self.games = games
self.filtered_games = self.games
self.dirty = True # Full resort needed
self.update_game_grid()
def add_game_incremental(self, game_data: tuple):
"""Add a single game without full reload."""
self.games.append(game_data)
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
self.dirty = True
self.update_game_grid()
def remove_game_incremental(self, game_name: str, exec_line: str):
"""Remove a single game without full reload."""
key = (game_name, exec_line)
self.games = [g for g in self.games if (g[0], g[4]) != key]
self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key]
if key in self.game_card_cache and self.gamesListLayout is not None:
card = self.game_card_cache.pop(key)
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer deleteLater
if key in self.pending_images:
del self.pending_images[key]
self.dirty = True
self.update_game_grid()
def filter_games_delayed(self):
"""Filters games based on search text and updates the grid."""
self.update_game_grid(is_filter=True)
def calculate_columns(self, card_width: int) -> int:
"""Calculate the number of columns based on card width and assumed container width."""
# Assuming a typical container width; adjust as needed
available_width = 1200 # Example width, can be dynamic if widget access is added
spacing = 15 # Assumed spacing between cards
columns = max(1, (available_width - spacing) // (card_width + spacing))
return min(columns, 8) # Cap at reasonable max

View File

@@ -5,7 +5,7 @@ from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
from enum import Enum
from pyudev import Context, Monitor, MonitorObserver, Device
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
from PySide6.QtGui import QKeyEvent, QMouseEvent
from portprotonqt.logger import get_logger
@@ -13,7 +13,8 @@ from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
from portprotonqt.game_card import GameCard
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config
from portprotonqt.dialogs import AddGameDialog
from portprotonqt.dialogs import AddGameDialog, WinetricksDialog
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
@@ -37,6 +38,7 @@ class MainWindowProtocol(Protocol):
stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget]
gamesListWidget: QWidget
autoInstallContainer: QWidget | None
currentDetailPage: QWidget | None
current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None
@@ -71,7 +73,7 @@ class InputManager(QObject):
for seamless UI interaction.
"""
# Signals for gamepad events
button_pressed = Signal(int) # Signal for button presses
button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release)
dpad_moved = Signal(int, int, float) # Signal for D-pad movements
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
@@ -90,6 +92,7 @@ class InputManager(QObject):
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None)
self._parent.autoInstallContainer = getattr(self._parent, 'autoInstallContainer', None)
self.axis_deadzone = axis_deadzone
self.initial_axis_move_delay = initial_axis_move_delay
self.repeat_axis_move_delay = repeat_axis_move_delay
@@ -130,7 +133,7 @@ class InputManager(QObject):
self.current_dpad_value = 0 # Tracks the current D-pad direction value (e.g., -1, 1)
# Connect signals to slots
self.button_pressed.connect(self.handle_button_slot)
self.button_event.connect(self.handle_button_slot)
self.dpad_moved.connect(self.handle_dpad_slot)
self.toggle_fullscreen.connect(self.handle_fullscreen_slot)
@@ -142,6 +145,132 @@ class InputManager(QObject):
# Initialize evdev + hotplug
self.init_gamepad()
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
"""Common navigation logic for game cards in a container."""
if container is None:
return
focused = QApplication.focusWidget()
game_cards = container.findChildren(GameCard)
if not game_cards:
return
scroll_area = container.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
# If no focused widget or not a GameCard, focus the first card
if not isinstance(focused, GameCard) or focused not in game_cards:
game_cards[0].setFocus()
if scroll_area:
scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
return
cards = container.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively)
if not cards:
return
# Group cards by rows with tolerance for y-position
rows = {}
y_tolerance = 10 # Allow slight variations in y-position
for card in cards:
y = card.pos().y()
matched = False
for row_y in rows:
if abs(y - row_y) <= y_tolerance:
rows[row_y].append(card)
matched = True
break
if not matched:
rows[y] = [card]
sorted_rows = sorted(rows.items(), key=lambda x: x[0])
if not sorted_rows:
return
current_row_idx = None
current_col_idx = None
for row_idx, (_y, row_cards) in enumerate(sorted_rows):
for idx, card in enumerate(row_cards):
if card == focused:
current_row_idx = row_idx
current_col_idx = idx
break
if current_row_idx is not None:
break
# Fallback: if focused card not found, select closest row by y-position
if current_row_idx is None:
if not sorted_rows: # Additional safety check
return
focused_y = focused.pos().y()
current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y))
if current_row_idx >= len(sorted_rows): # Safety check
return
current_row = sorted_rows[current_row_idx][1]
focused_x = focused.pos().x() + focused.width() / 2
current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore
# Add null checks before using current_row_idx and current_col_idx
if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
return
current_row = sorted_rows[current_row_idx][1]
if code == ecodes.ABS_HAT0X and value != 0:
if value < 0: # Left
if current_col_idx > 0:
next_card = current_row[current_col_idx - 1]
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
else:
if current_row_idx > 0:
prev_row = sorted_rows[current_row_idx - 1][1]
next_card = prev_row[-1] if prev_row else None
if next_card:
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value > 0: # Right
if current_col_idx < len(current_row) - 1:
next_card = current_row[current_col_idx + 1]
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
else:
if current_row_idx < len(sorted_rows) - 1:
next_row = sorted_rows[current_row_idx + 1][1]
next_card = next_row[0] if next_row else None
if next_card:
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif code == ecodes.ABS_HAT0Y and value != 0:
if value > 0: # Down
if current_row_idx < len(sorted_rows) - 1:
next_row = sorted_rows[current_row_idx + 1][1]
current_x = focused.pos().x() + focused.width() / 2
next_card = min(
next_row,
key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None
)
if next_card:
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value < 0: # Up
if current_row_idx > 0:
prev_row = sorted_rows[current_row_idx - 1][1]
current_x = focused.pos().x() + focused.width() / 2
next_card = min(
prev_row,
key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None
)
if next_card:
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif current_row_idx == 0:
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
def detect_gamepad_type(self, device: InputDevice) -> GamepadType:
"""
Определяет тип геймпада по capabilities
@@ -201,7 +330,9 @@ class InputManager(QObject):
except Exception as e:
logger.error(f"Error restoring gamepad handlers: {e}")
def handle_file_explorer_button(self, button_code):
def handle_file_explorer_button(self, button_code, value):
if value == 0: # Ignore releases
return
try:
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
@@ -441,8 +572,33 @@ class InputManager(QObject):
except Exception as e:
logger.error(f"Error stopping rumble: {e}", exc_info=True)
@Slot(int)
def handle_button_slot(self, button_code: int) -> None:
@Slot(int, int)
def handle_button_slot(self, button_code: int, value: int) -> None:
active_window = QApplication.activeWindow()
# Обработка виртуальной клавиатуры в AddGameDialog (handle both press and release)
if isinstance(active_window, AddGameDialog):
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit):
# Показываем клавиатуру при нажатии A на поле ввода (only on press)
active_window.show_keyboard_for_widget(focused)
return
# Если клавиатура видима, обрабатываем её кнопки (including release)
if hasattr(active_window, 'keyboard') and active_window.keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return
# Main window keyboard handling (including release)
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code, value)
return
# Ignore releases for all other (non-keyboard) button handling
if value == 0:
return
if not self._gamepad_handling_enabled:
return
try:
@@ -455,6 +611,31 @@ class InputManager(QObject):
if not app or not active:
return
current_tab_index = self._parent.stackedWidget.currentIndex()
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
search_edit = None
if current_tab_index == 0:
search_edit = getattr(self._parent, 'searchEdit', None)
elif current_tab_index == 1:
search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None)
if focused == search_edit:
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard:
keyboard.show_for_widget(focused)
return
# Handle Y button to focus search
if button_code in BUTTONS['prev_dir']: # Y button
search_edit = None
if current_tab_index == 0:
search_edit = getattr(self._parent, 'searchEdit', None)
elif current_tab_index == 1:
search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None)
if search_edit:
search_edit.setFocus()
return
# Handle Guide button to open system overlay
if button_code in BUTTONS['guide']:
if not popup and not isinstance(active, QDialog):
@@ -551,6 +732,39 @@ class InputManager(QObject):
self._parent.toggleGame(self._parent.current_exec_line, None)
return
if isinstance(active, WinetricksDialog):
if button_code in BUTTONS['confirm']: # A button - toggle checkbox
current_table = active.tab_widget.currentWidget()
if isinstance(current_table, QTableWidget):
current_row = current_table.currentRow()
if current_row >= 0:
checkbox = current_table.item(current_row, 0)
if checkbox:
checkbox.setCheckState(
Qt.CheckState.Unchecked if checkbox.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked
)
return
elif button_code in BUTTONS['add_game']: # X button - install
active.install_selected(force=False)
return
elif button_code in BUTTONS['prev_dir']: # Y button - force install
active.install_selected(force=True)
return
elif button_code in BUTTONS['back']: # B button - close dialog
active.reject()
return
elif button_code in BUTTONS['prev_tab']: # LB - previous tab
current_idx = active.tab_widget.currentIndex()
new_idx = (current_idx - 1) % active.tab_widget.count()
active.tab_widget.setCurrentIndex(new_idx)
return
elif button_code in BUTTONS['next_tab']: # RB - next tab
current_idx = active.tab_widget.currentIndex()
new_idx = (current_idx + 1) % active.tab_widget.count()
active.tab_widget.setCurrentIndex(new_idx)
return
# Standard navigation
if button_code in BUTTONS['confirm']:
self._parent.activateFocusedWidget()
@@ -595,8 +809,83 @@ class InputManager(QObject):
@Slot(int, int, float)
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
keyboard = None
active_window = QApplication.activeWindow()
# Проверяем клавиатуру в активном окне (AddGameDialog или главном окне)
if isinstance(active_window, AddGameDialog):
keyboard = getattr(active_window, 'keyboard', None)
else:
keyboard = getattr(self._parent, 'keyboard', None)
# Handle release early
if value == 0:
self.current_dpad_code = None
self.current_dpad_value = 0
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.stop()
return
# Update D-pad state for continuous movement
self.current_dpad_code = code
self.current_dpad_value = value
if not self.axis_moving:
self.axis_moving = True
self.last_move_time = current_time
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))
if keyboard and keyboard.isVisible():
# Обработка горизонтального перемещения (LEFT/RIGHT)
if code in (ecodes.ABS_HAT0X, ecodes.ABS_X):
normalized_value = 0
if code == ecodes.ABS_X: # Левый стик
# Применяем мертвую зону
if abs(value) < self.dead_zone:
self.current_dpad_code = None
self.current_dpad_value = 0
self.axis_moving = False
self.dpad_timer.stop()
return
normalized_value = 1 if value > self.dead_zone else -1
else: # D-pad
normalized_value = value # D-pad уже дает -1, 0, 1
if normalized_value != 0:
if normalized_value > 0: # Вправо
keyboard.move_focus_right()
elif normalized_value < 0: # Влево
keyboard.move_focus_left()
return
# Обработка вертикального перемещения (UP/DOWN)
elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
normalized_value = 0
if code == ecodes.ABS_Y: # Левый стик
# Применяем мертвую зону
if abs(value) < self.dead_zone:
self.current_dpad_code = None
self.current_dpad_value = 0
self.axis_moving = False
self.dpad_timer.stop()
return
normalized_value = 1 if value > self.dead_zone else -1
else: # D-pad
normalized_value = value # D-pad уже дает -1, 0, 1
if normalized_value != 0:
if normalized_value > 0: # Вниз
keyboard.move_focus_down()
elif normalized_value < 0: # Вверх
keyboard.move_focus_up()
return
if not self._gamepad_handling_enabled:
return
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
logger.error("gamesListWidget not available yet, skipping D-pad navigation")
return
try:
app = QApplication.instance()
@@ -606,23 +895,6 @@ class InputManager(QObject):
if not app or not active:
return
# Update D-pad state
if value != 0:
self.current_dpad_code = code
self.current_dpad_value = value
if not self.axis_moving:
self.axis_moving = True
self.last_move_time = current_time
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000)) # Start timer (in milliseconds)
else:
self.current_dpad_code = None
self.current_dpad_value = 0
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.stop() # Stop timer when D-pad is released
return
# Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad
if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0:
if isinstance(active, QMessageBox): # Specific handling for QMessageBox
@@ -638,7 +910,7 @@ class InputManager(QObject):
elif value < 0: # Left
active.focusPreviousChild()
return
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0: # Keep up/down for other dialogs
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget): # Keep up/down for other dialogs
if not focused or not active.focusWidget():
# If no widget is focused, focus the first focusable widget
focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
@@ -691,132 +963,90 @@ class InputManager(QObject):
active.show_next()
return
# Library tab navigation (index 0)
if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
focused = QApplication.focusWidget()
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
if not game_cards:
# Table navigation
if isinstance(focused, QTableWidget):
row_count = focused.rowCount()
if row_count <= 0:
return
current_row = focused.currentRow()
if current_row < 0:
current_row = 0
focused.setCurrentCell(0, 0)
scroll_area = self._parent.gamesListWidget.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
# If no focused widget or not a GameCard, focus the first card
if not isinstance(focused, GameCard) or focused not in game_cards:
game_cards[0].setFocus()
if scroll_area:
scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
return
cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively)
if not cards:
return
# Group cards by rows with tolerance for y-position
rows = {}
y_tolerance = 10 # Allow slight variations in y-position
for card in cards:
y = card.pos().y()
matched = False
for row_y in rows:
if abs(y - row_y) <= y_tolerance:
rows[row_y].append(card)
matched = True
break
if not matched:
rows[y] = [card]
sorted_rows = sorted(rows.items(), key=lambda x: x[0])
if not sorted_rows:
return
current_row_idx = None
current_col_idx = None
for row_idx, (_y, row_cards) in enumerate(sorted_rows):
for idx, card in enumerate(row_cards):
if card == focused:
current_row_idx = row_idx
current_col_idx = idx
break
if current_row_idx is not None:
break
# Fallback: if focused card not found, select closest row by y-position
if current_row_idx is None:
if not sorted_rows: # Additional safety check
return
focused_y = focused.pos().y()
current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y))
if current_row_idx >= len(sorted_rows): # Safety check
return
current_row = sorted_rows[current_row_idx][1]
focused_x = focused.pos().x() + focused.width() / 2
current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore
# Add null checks before using current_row_idx and current_col_idx
if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
return
current_row = sorted_rows[current_row_idx][1]
if code == ecodes.ABS_HAT0X and value != 0:
if value < 0: # Left
if current_col_idx > 0:
next_card = current_row[current_col_idx - 1]
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
else:
if current_row_idx > 0:
prev_row = sorted_rows[current_row_idx - 1][1]
next_card = prev_row[-1] if prev_row else None
if next_card:
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value > 0: # Right
if current_col_idx < len(current_row) - 1:
next_card = current_row[current_col_idx + 1]
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
else:
if current_row_idx < len(sorted_rows) - 1:
next_row = sorted_rows[current_row_idx + 1][1]
next_card = next_row[0] if next_row else None
if next_card:
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif code == ecodes.ABS_HAT0Y and value != 0:
if code == ecodes.ABS_HAT0Y and value != 0:
# Vertical navigation
if value > 0: # Down
if current_row_idx < len(sorted_rows) - 1:
next_row = sorted_rows[current_row_idx + 1][1]
current_x = focused.pos().x() + focused.width() / 2
next_card = min(
next_row,
key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None
)
if next_card:
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
new_row = min(current_row + 1, row_count - 1)
elif value < 0: # Up
if current_row_idx > 0:
prev_row = sorted_rows[current_row_idx - 1][1]
current_x = focused.pos().x() + focused.width() / 2
next_card = min(
prev_row,
key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None
)
if next_card:
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif current_row_idx == 0:
self._parent.tabButtons[0].setFocus(Qt.FocusReason.OtherFocusReason)
new_row = max(current_row - 1, 0)
else:
return
focused.setCurrentCell(new_row, focused.currentColumn())
item = focused.item(new_row, focused.currentColumn())
if item:
focused.scrollToItem(
item,
QAbstractItemView.ScrollHint.PositionAtCenter
)
focused.setFocus(Qt.FocusReason.OtherFocusReason)
return
elif code == ecodes.ABS_HAT0X and value != 0:
# Horizontal navigation
col_count = focused.columnCount()
current_col = focused.currentColumn()
if current_col < 0:
current_col = 0
if value < 0: # Left
new_col = max(current_col - 1, 0)
elif value > 0: # Right
new_col = min(current_col + 1, col_count - 1)
else:
return
focused.setCurrentCell(focused.currentRow(), new_col)
focused.setFocus(Qt.FocusReason.OtherFocusReason)
return
# Search focus logic for tabs 0 and 1
if code == ecodes.ABS_HAT0Y and value < 0:
focused = QApplication.focusWidget()
current_index = self._parent.stackedWidget.currentIndex()
if current_index in (0, 1) and isinstance(focused, GameCard):
if current_index == 0:
container = self._parent.gamesListWidget
search_edit = getattr(self._parent, 'searchEdit', None)
else:
container = self._parent.autoInstallContainer
search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None)
if container and search_edit:
game_cards = container.findChildren(GameCard)
if game_cards:
current_card_pos = focused.pos()
current_row_y = current_card_pos.y()
is_first_row = True
for card in game_cards:
if card.pos().y() < current_row_y and card.isVisible():
is_first_row = False
break
if is_first_row:
search_edit.setFocus()
return
# Game cards navigation for tabs 0 and 1
if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
current_index = self._parent.stackedWidget.currentIndex()
if current_index in (0, 1):
container = self._parent.gamesListWidget if current_index == 0 else self._parent.autoInstallContainer
if container is None:
return
self._navigate_game_cards(container, current_index, code, value)
return
# Vertical navigation in other tabs
elif code == ecodes.ABS_HAT0Y and value != 0:
if code == ecodes.ABS_HAT0Y and value != 0:
focused = QApplication.focusWidget()
page = self._parent.stackedWidget.currentWidget()
if value > 0: # Down
@@ -836,6 +1066,52 @@ class InputManager(QObject):
except Exception as e:
logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True)
def handle_virtual_keyboard(self, button_code: int, value: int) -> None:
# Проверяем клавиатуру в активном окне
active_window = QApplication.activeWindow()
keyboard = None
# Сначала проверяем AddGameDialog
if isinstance(active_window, AddGameDialog):
keyboard = getattr(active_window, 'keyboard', None)
else:
# Если это не AddGameDialog, проверяем клавиатуру в главном окне
keyboard = getattr(self._parent, 'keyboard', None)
if not keyboard or not isinstance(keyboard, VirtualKeyboard) or not keyboard.isVisible():
return
# Обработка кнопок геймпада
if button_code in BUTTONS['confirm']: # Кнопка A/Cross - подтверждение
if value == 1:
keyboard.activateFocusedKey()
elif button_code in BUTTONS['back']: # Кнопка B/Circle - скрыть клавиатуру
if value == 1:
keyboard.hide()
# Возвращаем фокус на поле ввода
if keyboard.current_input_widget:
keyboard.current_input_widget.setFocus()
elif button_code in BUTTONS['prev_tab']: # LB/L1 - переключение раскладки
if value == 1:
keyboard.on_lang_click()
elif button_code in BUTTONS['next_tab']: # RB/R1 - переключение Shift
if value == 1:
keyboard.on_shift_click(not keyboard.shift_pressed)
elif button_code in BUTTONS['context_menu']: # Кнопка Start - подтверждение
if value == 1:
keyboard.activateFocusedKey()
elif button_code in BUTTONS['menu']: # Кнопка Select - скрыть клавиатуру
if value == 1:
keyboard.hide()
# Возвращаем фокус на поле ввода
if keyboard.current_input_widget:
keyboard.current_input_widget.setFocus()
elif button_code in BUTTONS['add_game']: # Кнопка X - Backspace (now holdable)
if value == 1:
keyboard.on_backspace_pressed()
elif value == 0:
keyboard.stop_backspace_repeat()
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
app = QApplication.instance()
if not app:
@@ -1083,8 +1359,8 @@ class InputManager(QObject):
self.gamepad = None
if self.gamepad_thread:
self.gamepad_thread.join()
# Signal to exit fullscreen mode
self.toggle_fullscreen.emit(False)
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
self.toggle_fullscreen.emit(False)
except Exception as e:
logger.error(f"Error handling udev event: {e}", exc_info=True)
@@ -1142,11 +1418,12 @@ class InputManager(QObject):
if not app or not active:
continue
if event.type == ecodes.EV_KEY and event.value == 1:
if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
if event.type == ecodes.EV_KEY:
# Emit on both press (1) and release (0)
self.button_event.emit(event.code, event.value)
# Special handling for menu on press only
if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen)
else:
self.button_pressed.emit(event.code)
elif event.type == ecodes.EV_ABS:
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
# Проверяем, достаточно ли времени прошло с последнего срабатывания
@@ -1155,17 +1432,19 @@ class InputManager(QObject):
if event.code == ecodes.ABS_Z: # LT/L2
if event.value > 128 and not self.lt_pressed:
self.lt_pressed = True
self.button_pressed.emit(event.code)
self.button_event.emit(event.code, 1) # Emit as press
self.last_trigger_time = now
elif event.value <= 128 and self.lt_pressed:
self.lt_pressed = False
self.button_event.emit(event.code, 0) # Emit as release
elif event.code == ecodes.ABS_RZ: # RT/R2
if event.value > 128 and not self.rt_pressed:
self.rt_pressed = True
self.button_pressed.emit(event.code)
self.button_event.emit(event.code, 1) # Emit as press
self.last_trigger_time = now
elif event.value <= 128 and self.rt_pressed:
self.rt_pressed = False
self.button_event.emit(event.code, 0) # Emit as release
else:
self.dpad_moved.emit(event.code, event.value, now)
except OSError as e:

View File

@@ -0,0 +1,73 @@
# keyboard_layouts.py
keyboard_layouts = {
'en': {
'normal': [
['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"],
['', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/']
],
'shift': [
['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'],
['', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?']
]
},
'ru': {
'normal': [
['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'],
['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'],
['', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.']
],
'shift': [
['Ё', '!', '"', '', ';', '%', ':', '?', '*', '(', ')', '_', '+'],
['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'],
['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'],
['', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ',']
]
},
'fr': {
'normal': [
['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='],
['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'],
['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'],
['', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!']
],
'shift': [
['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'],
['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'],
['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'],
['', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§']
]
},
'es': {
'normal': [
['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'],
['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'],
['', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
],
'shift': [
['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'],
['', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
]
},
'de': {
'normal': [
['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'],
['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'],
['', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
],
'shift': [
['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'],
['', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
]
}
}

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-09-13 11:51+0500\n"
"POT-Creation-Date: 2025-10-12 17:14+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -23,7 +23,7 @@ msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found"
msgid "PortProton directory not found"
msgstr ""
msgid "Remove from Favorites"
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr ""
#, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'"
msgid "No executable command found in .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to read .desktop file: {error}"
msgid "Error reading .desktop file: {error}"
msgstr ""
#, python-brace-format
@@ -191,6 +191,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -264,6 +268,10 @@ msgstr ""
msgid "Path: "
msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game"
msgstr ""
@@ -300,6 +308,45 @@ msgstr ""
msgid "No cover selected"
msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -348,9 +395,6 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -366,6 +410,28 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -378,13 +444,106 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "Here you can configure automatic game installation..."
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
msgid "List of available emulators and their configuration..."
msgid "Compatibility tool:"
msgstr ""
msgid "Various Wine parameters and versions..."
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."
@@ -456,21 +615,6 @@ msgstr ""
msgid "Gamepad haptic feedback:"
msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings"
msgstr ""
@@ -480,28 +624,6 @@ msgstr ""
msgid "Clear Cache"
msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset"
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-09-13 11:51+0500\n"
"POT-Creation-Date: 2025-10-12 17:14+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -23,7 +23,7 @@ msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found"
msgid "PortProton directory not found"
msgstr ""
msgid "Remove from Favorites"
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr ""
#, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'"
msgid "No executable command found in .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to read .desktop file: {error}"
msgid "Error reading .desktop file: {error}"
msgstr ""
#, python-brace-format
@@ -191,6 +191,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -264,6 +268,10 @@ msgstr ""
msgid "Path: "
msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game"
msgstr ""
@@ -300,6 +308,45 @@ msgstr ""
msgid "No cover selected"
msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -348,9 +395,6 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -366,6 +410,28 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -378,13 +444,106 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "Here you can configure automatic game installation..."
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
msgid "List of available emulators and their configuration..."
msgid "Compatibility tool:"
msgstr ""
msgid "Various Wine parameters and versions..."
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."
@@ -456,21 +615,6 @@ msgstr ""
msgid "Gamepad haptic feedback:"
msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings"
msgstr ""
@@ -480,28 +624,6 @@ msgstr ""
msgid "Clear Cache"
msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset"
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-09-13 11:51+0500\n"
"POT-Creation-Date: 2025-10-12 17:14+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"
@@ -21,7 +21,7 @@ msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found"
msgid "PortProton directory not found"
msgstr ""
msgid "Remove from Favorites"
@@ -153,7 +153,7 @@ msgid "Menu"
msgstr ""
#, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'"
msgid "No executable command found in .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
@@ -161,7 +161,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to read .desktop file: {error}"
msgid "Error reading .desktop file: {error}"
msgstr ""
#, python-brace-format
@@ -189,6 +189,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -262,6 +266,10 @@ msgstr ""
msgid "Path: "
msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game"
msgstr ""
@@ -298,6 +306,45 @@ msgstr ""
msgid "No cover selected"
msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -346,9 +393,6 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -364,6 +408,28 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -376,13 +442,106 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "Here you can configure automatic game installation..."
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
msgid "List of available emulators and their configuration..."
msgid "Compatibility tool:"
msgstr ""
msgid "Various Wine parameters and versions..."
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."
@@ -454,21 +613,6 @@ msgstr ""
msgid "Gamepad haptic feedback:"
msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings"
msgstr ""
@@ -478,28 +622,6 @@ msgstr ""
msgid "Clear Cache"
msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset"
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-09-13 11:51+0500\n"
"PO-Revision-Date: 2025-09-13 11:47+0500\n"
"POT-Creation-Date: 2025-10-12 17:14+0500\n"
"PO-Revision-Date: 2025-10-12 17:13+0500\n"
"Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
@@ -24,8 +24,8 @@ msgstr ""
msgid "Error"
msgstr "Ошибка"
msgid "PortProton is not found"
msgstr "PortProton не найден"
msgid "PortProton directory not found"
msgstr "Не найден каталог PortProton"
msgid "Remove from Favorites"
msgstr "Удалить из Избранного"
@@ -158,16 +158,16 @@ msgid "Menu"
msgstr "Меню"
#, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'"
msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда"
msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'"
#, python-brace-format
msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "Не удалось разобрать файл .desktop для '{game_name}'"
#, python-brace-format
msgid "Failed to read .desktop file: {error}"
msgstr "Не удалось прочитать файл .desktop: {error}"
msgid "Error reading .desktop file: {error}"
msgstr "Ошибка при чтении файла .desktop: {error}"
#, python-brace-format
msgid "No .desktop file found for '{game_name}'"
@@ -196,6 +196,10 @@ msgstr "'{game_name}' был(а) успешно удалён(а)"
msgid "Failed to delete custom data: {error}"
msgstr "Не удалось удалить пользовательские данные: {error}"
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr "'{game_name}' успешно добавлен(а)"
msgid "Game name and executable path are required"
msgstr "Требуются название игры и путь к исполняемому файлу"
@@ -271,6 +275,10 @@ msgstr "Выбрать"
msgid "Path: "
msgstr "Путь: "
#, python-format
msgid "Access denied: %s"
msgstr "Доступ запрещён: %s"
msgid "Edit Game"
msgstr "Редактировать игру"
@@ -307,6 +315,45 @@ msgstr "Скачивание обложки..."
msgid "No cover selected"
msgstr "Обложка не выбрана"
msgid "Prefix Manager"
msgstr "Менеджер префиксов"
msgid "Set"
msgstr "Выбор"
msgid "Libraries"
msgstr "Библиотеки"
msgid "Information"
msgstr "Описание"
msgid "Fonts"
msgstr "Шрифты"
msgid "Settings"
msgstr "Настройки"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Install"
msgstr "Установить"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку."
msgid "Warning"
msgstr "Предупреждение"
msgid "No components selected."
msgstr "Не выбрано ни одного компонента."
msgid "Installation failed. Check logs."
msgstr "Установка не удалась. Проверьте журналы."
msgid "Components installed successfully."
msgstr "Компоненты успешно установлены."
msgid "Loading Epic Games Store games..."
msgstr "Загрузка игр из Epic Games Store..."
@@ -355,9 +402,6 @@ msgstr "Библиотека"
msgid "Auto Install"
msgstr "Автоустановка"
msgid "Emulators"
msgstr "Эмуляторы"
msgid "Wine Settings"
msgstr "Настройки wine"
@@ -370,10 +414,31 @@ msgstr "Темы"
msgid "Back"
msgstr "Назад"
#, fuzzy
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Search"
msgstr "Поиск"
msgid "Installation already in progress."
msgstr "Установка уже выполняется."
msgid "Failed to start installation."
msgstr "Не удалось запустить установку."
#, python-brace-format
msgid "Processed {} installation..."
msgstr "В процессе установки {}..."
msgid "Installation completed successfully."
msgstr "Установка завершена успешно."
msgid "Installation failed."
msgstr "Установка не удалась."
msgid "Installation error."
msgstr "Ошибка установки."
msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..."
@@ -386,14 +451,109 @@ msgstr "Игровая библиотека"
msgid "Find Games ..."
msgstr "Найти игры..."
msgid "Here you can configure automatic game installation..."
msgstr "Здесь можно настроить автоматическую установку игр..."
#, python-brace-format
msgid "Added '{name}'"
msgstr "'{name}' добавлен(а)"
msgid "List of available emulators and their configuration..."
msgstr "Список доступных эмуляторов и их настройка..."
msgid "Compatibility tool:"
msgstr "Инструмент совместимости:"
msgid "Various Wine parameters and versions..."
msgstr "Различные параметры и версии wine..."
msgid "Prefix:"
msgstr "Префикс:"
msgid "Wine Configuration"
msgstr "Конфигурация Wine"
msgid "Registry Editor"
msgstr "Редактор реестра"
msgid "Command Prompt"
msgstr "Командная строка"
msgid "Uninstaller"
msgstr "Удаление программ"
msgid "Create Prefix Backup"
msgstr "Создать резервную копию префикса"
msgid "Load Prefix Backup"
msgstr "Загрузить резервную копию префикса"
msgid "Delete Compatibility Tool"
msgstr "Удалить Инструмент совместимости"
msgid "Delete Prefix"
msgstr "Удалить Префикс"
msgid "Clear Prefix"
msgstr "Очистить Префикс"
msgid "Launching tool..."
msgstr "Запуск инструмента..."
msgid "Failed to start process."
msgstr "Не удалось запустить процесс."
msgid "Confirm Clear"
msgstr "Подтвердите очистку"
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr "Префикс '{}' успешно удален."
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
"Префикс '{}' очищен с ошибками:\n"
"{}"
msgid "Failed to start backup process."
msgstr "Не удалось запустить процесс резервного копирования."
msgid "Failed to start restore process."
msgstr "Не удалось запустить процесс восстановления."
msgid "Prefix backup completed."
msgstr "Резервное копирование префикса завершено."
msgid "Prefix backup failed."
msgstr "Сбой резервного копирования префикса."
msgid "Prefix restore completed."
msgstr "Восстановление префикса завершено."
msgid "Prefix restore failed."
msgstr "Восстановление префикса не удалось."
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr "Префикс «{}» удален."
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr "Не удалось удалить префикс: {}"
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?"
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr "Инструмент совместимости «{}» удален."
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr "Не удалось удалить инструмент совместимости: {}"
msgid "Main PortProton parameters..."
msgstr "Основные параметры PortProton..."
@@ -464,21 +624,6 @@ msgstr "Тактильная отдача на геймпаде"
msgid "Gamepad haptic feedback:"
msgstr "Тактильная отдача на геймпаде:"
msgid "Open Legendary Login"
msgstr "Открыть браузер для входа в Legendary"
msgid "Legendary Authentication:"
msgstr "Авторизация в Legendary:"
msgid "Enter Legendary Authorization Code"
msgstr "Введите код авторизации Legendary"
msgid "Authorization Code:"
msgstr "Код авторизации:"
msgid "Submit Code"
msgstr "Отправить код"
msgid "Save Settings"
msgstr "Сохранить настройки"
@@ -488,28 +633,6 @@ msgstr "Сбросить настройки"
msgid "Clear Cache"
msgstr "Очистить кэш"
msgid "Opened Legendary login page in browser"
msgstr "Открытие страницы входа в Legendary в браузере"
msgid "Failed to open Legendary login page"
msgstr "Не удалось открыть страницу входа в Legendary"
msgid "Please enter an authorization code"
msgstr "Пожалуйста, введите код авторизации"
msgid "Successfully authenticated with Legendary"
msgstr "Успешная аутентификация в Legendary"
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr "Не удалось выполнить аутентификацию Legendary: {0}"
msgid "Legendary executable not found"
msgstr "Не найден исполняемый файл Legendary"
msgid "Unexpected error during authentication"
msgstr "Неожиданная ошибка при аутентификации"
msgid "Confirm Reset"
msgstr "Подтвердите удаление"

File diff suppressed because it is too large Load Diff

View File

@@ -4,9 +4,12 @@ import orjson
import requests
import urllib.parse
import time
import glob
import re
from collections.abc import Callable
from portprotonqt.downloader import Downloader
from portprotonqt.logger import get_logger
from portprotonqt.config_utils import get_portproton_location
logger = get_logger(__name__)
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
@@ -52,6 +55,9 @@ class PortProtonAPI:
self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
os.makedirs(self.custom_data_dir, exist_ok=True)
self.portproton_location = get_portproton_location()
self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
self._topics_data = None
def _get_game_dir(self, exe_name: str) -> str:
@@ -68,40 +74,6 @@ class PortProtonAPI:
logger.debug(f"Failed to check file at {url}: {e}")
return False
def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]:
game_dir = self._get_game_dir(exe_name)
results: dict[str, str | None] = {"cover": None, "metadata": None}
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
cover_url_base = f"{self.base_url}/{exe_name}/cover"
metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
for ext in cover_extensions:
cover_url = f"{cover_url_base}{ext}"
if self._check_file_exists(cover_url, timeout):
local_cover_path = os.path.join(game_dir, f"cover{ext}")
result = self.downloader.download(cover_url, local_cover_path, timeout=timeout)
if result:
results["cover"] = result
logger.info(f"Downloaded cover for {exe_name} to {result}")
break
else:
logger.error(f"Failed to download cover for {exe_name} from {cover_url}")
else:
logger.debug(f"No cover found for {exe_name} with extension {ext}")
if self._check_file_exists(metadata_url, timeout):
local_metadata_path = os.path.join(game_dir, "metadata.txt")
result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout)
if result:
results["metadata"] = result
logger.info(f"Downloaded metadata for {exe_name} to {result}")
else:
logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}")
else:
logger.debug(f"No metadata found for {exe_name}")
return results
def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None:
game_dir = self._get_game_dir(exe_name)
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
@@ -163,6 +135,164 @@ class PortProtonAPI:
if callback:
callback(results)
def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
"""Download only autoinstall cover image (PNG only, no metadata)."""
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
user_game_folder = os.path.join(autoinstall_root, exe_name)
if not os.path.isdir(user_game_folder):
try:
os.mkdir(user_game_folder)
except FileExistsError:
pass
cover_url = f"{self.base_url}/{exe_name}/cover.png"
local_cover_path = os.path.join(user_game_folder, "cover.png")
def on_cover_downloaded(local_path: str | None):
if local_path:
logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
else:
logger.debug(f"No autoinstall cover downloaded for {exe_name}")
if callback:
callback(local_path)
if self._check_file_exists(cover_url, timeout):
self.downloader.download_async(
cover_url,
local_cover_path,
timeout=timeout,
callback=on_cover_downloaded
)
else:
logger.debug(f"No autoinstall cover found for {exe_name}")
if callback:
callback(None)
def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]:
"""Extract display_name from # name comment and exe_name from autoinstall bash script."""
try:
with open(file_path, encoding='utf-8') as f:
content = f.read()
# Skip emulators
if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE):
return None, None
display_name = None
exe_name = None
# Extract display_name from "# name:" comment
name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE)
if name_match:
display_name = name_match.group(1).strip()
# --- pw_create_unique_exe ---
pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content)
if pw_match:
arg = pw_match.group(1)
if arg:
exe_name = arg.strip()
if not exe_name.lower().endswith(".exe"):
exe_name += ".exe"
else:
export_match = re.search(
r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']',
content, re.IGNORECASE)
if export_match:
exe_name = f"{export_match.group(1).strip()}.exe"
else:
portwine_match = None
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
if "portwine_exe" in stripped and "=" in stripped:
portwine_match = stripped
break
if portwine_match:
exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ")
exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr)
if exe_candidates:
exe_name = os.path.basename(exe_candidates[-1].strip())
# Fallback
if not display_name and exe_name:
display_name = exe_name
return display_name, exe_name
except Exception as e:
logger.error(f"Failed to parse {file_path}: {e}")
return None, None
def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
"""Load auto-install games with user/builtin covers (no async download here)."""
games = []
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else ""
if not os.path.exists(auto_dir):
callback(games)
return
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
if not scripts:
callback(games)
return
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
os.makedirs(base_autoinstall_dir, exist_ok=True)
for script_path in scripts:
display_name, exe_name = self.parse_autoinstall_script(script_path)
script_name = os.path.splitext(os.path.basename(script_path))[0]
if not (display_name and exe_name):
continue
exe_name = os.path.splitext(exe_name)[0] # Без .exe
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
os.makedirs(user_game_folder, exist_ok=True)
# Поиск обложки
cover_path = ""
user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set()
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
candidate = f"cover{ext}"
if candidate in user_files:
cover_path = os.path.join(user_game_folder, candidate)
break
if not cover_path:
logger.debug(f"No local cover found for autoinstall {exe_name}")
# Формируем кортеж игры (добавлен exe_name в конец)
game_tuple = (
display_name, # name
"", # description
cover_path, # cover
"", # appid
f"autoinstall:{script_name}", # exec_line
"", # controller_support
"Never", # last_launch
"0h 0m", # formatted_playtime
"", # protondb_tier
"", # anticheat_status
0, # last_played
0, # playtime_seconds
"autoinstall", # game_source
exe_name # exe_name
)
games.append(game_tuple)
callback(games)
def _load_topics_data(self):
"""Load and cache linux_gaming_topics_min.json from the archive."""
if self._topics_data is not None:

49
portprotonqt/preloader.py Normal file
View File

@@ -0,0 +1,49 @@
import time
from PySide6.QtCore import QRect
from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient
from PySide6.QtWidgets import QWidget
class Preloader(QWidget):
def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None):
super().__init__(parent)
self.setFixedSize(150, 150)
self._speed = speed
self._line_width = line_line_width
self._color1 = color
self._color2 = QColor(color.red(), color.green(), color.blue(), 0)
self._start_time = time.time()
def showEvent(self, event):
self._start_time = time.time()
def paintEvent(self, event):
rect = self._get_preloader_rect()
center = rect.center()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(self._get_pen())
painter.translate(center)
painter.rotate(self._get_angle())
painter.translate(-center)
painter.drawArc(rect, 0, 270 * 16)
self.update()
def _get_pen(self) -> QPen:
gradient = QConicalGradient()
gradient.setCenter(self.rect().center())
gradient.setColorAt(0, self._color1)
gradient.setColorAt(1, self._color2)
pen = QPen(QBrush(gradient), self._line_width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
return pen
def _get_angle(self) -> float:
duration = time.time() - self._start_time
return (self._speed * duration) % 360.0
def _get_preloader_rect(self) -> QRect:
size = self._line_width // 2
rect = self.rect()
rect.adjust(size, size, -size, -size)
return rect

View File

@@ -45,14 +45,14 @@ def safe_vdf_load(path: str | Path) -> dict:
def decode_text(text: str) -> str:
"""
Декодирует HTML-сущности в строке.
Например, "&amp;quot;" преобразуется в '"'.
Остальные символы и HTML-теги остаются без изменений.
Decodes HTML entities in a string.
For example, "&amp;quot;" is converted to '"'.
Other characters and HTML tags remain unchanged.
"""
return html.unescape(text)
def get_cache_dir():
"""Возвращает путь к каталогу кэша, создаёт его при необходимости."""
"""Returns the path to the cache directory, creating it if necessary."""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
os.makedirs(cache_dir, exist_ok=True)
@@ -65,7 +65,7 @@ STEAM_DATA_DIRS = (
)
def get_steam_home():
"""Возвращает путь к директории Steam, используя список возможных директорий."""
"""Returns the path to the Steam directory using a list of possible directories."""
for dir_path in STEAM_DATA_DIRS:
expanded_path = Path(os.path.expanduser(dir_path))
if expanded_path.exists():
@@ -73,7 +73,7 @@ def get_steam_home():
return None
def get_last_steam_user(steam_home: Path) -> dict | None:
"""Возвращает данные последнего пользователя Steam из loginusers.vdf."""
"""Returns data for the last Steam user from loginusers.vdf."""
loginusers_path = steam_home / "config/loginusers.vdf"
data = safe_vdf_load(loginusers_path)
if not data:
@@ -84,20 +84,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
try:
return {'SteamID': int(user_id)}
except ValueError:
logger.error(f"Неверный формат SteamID: {user_id}")
logger.error(f"Invalid SteamID format: {user_id}")
return None
logger.info("Не найден пользователь с MostRecent=1")
logger.info("No user found with MostRecent=1")
return None
def convert_steam_id(steam_id: int) -> int:
"""
Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число.
Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения.
Converts a signed 32-bit integer to an unsigned 32-bit integer.
Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values.
"""
return steam_id & 0xFFFFFFFF
def get_steam_libs(steam_dir: Path) -> set[Path]:
"""Возвращает набор директорий Steam libraryfolders."""
"""Returns a set of Steam library folders."""
libs = set()
libs_vdf = steam_dir / "steamapps/libraryfolders.vdf"
data = safe_vdf_load(libs_vdf)
@@ -113,7 +113,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]:
return libs
def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]:
"""Возвращает данные о времени игры для последнего пользователя."""
"""Returns playtime data for the last user."""
play_data: dict[int, tuple[int, int]] = {}
if steam_home is None:
steam_home = get_steam_home()
@@ -133,14 +133,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
return play_data
if not last_user:
logger.info("Не удалось определить последнего пользователя Steam")
logger.info("Could not identify the last Steam user")
return play_data
user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id)
user_dir = userdata_dir / str(unsigned_id)
if not user_dir.exists():
logger.info(f"Директория пользователя {unsigned_id} не найдена")
logger.info(f"User directory {unsigned_id} not found")
return play_data
localconfig = user_dir / "config/localconfig.vdf"
@@ -154,11 +154,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
playtime = int(info.get('Playtime', 0))
play_data[appid] = (last_played, playtime)
except ValueError:
logger.warning(f"Некорректные данные playtime для app {appid_str}")
logger.warning(f"Invalid playtime data for app {appid_str}")
return play_data
def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
"""Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec)."""
"""Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec)."""
games: list[tuple[str, int, int, int]] = []
steam_home = get_steam_home()
if steam_home is None or not steam_home.exists():
@@ -187,13 +187,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
def normalize_name(s):
"""
Приведение строки к нормальному виду:
- перевод в нижний регистр,
- удаление символов ™ и ®,
- замена разделителей (-, :, ,) на пробел,
- удаление лишних пробелов,
- удаление суффиксов 'bin' или 'app' в конце строки,
- удаление ключевых слов типа 'ultimate', 'edition' и т.п.
Normalizes a string by:
- converting to lowercase,
- removing ™ and ® symbols,
- replacing separators (-, :, ,) with spaces,
- removing extra spaces,
- removing 'bin' or 'app' suffixes,
- removing keywords like 'ultimate', 'edition', etc.
"""
s = s.lower()
for ch in ["", "®"]:
@@ -211,14 +211,28 @@ def normalize_name(s):
def is_valid_candidate(candidate):
"""
Проверяет, содержит ли кандидат запрещённые подстроки:
- win32
- win64
- gamelauncher
Для проверки дополнительно используется строка без пробелов.
Возвращает True, если кандидат допустим, иначе False.
Determines whether a given candidate string is valid for use as a game name.
The function performs the following checks:
1. Normalizes the candidate using `normalize_name()`.
2. Rejects the candidate if the normalized name is exactly "game"
(to avoid overly generic names).
3. Removes spaces and checks for forbidden substrings:
- "win32"
- "win64"
- "gamelauncher"
These are checked in the space-free version of the string.
4. Returns True only if none of the forbidden conditions are met.
Args:
candidate (str): The candidate string to validate.
Returns:
bool: True if the candidate is valid, False otherwise.
"""
normalized_candidate = normalize_name(candidate)
if normalized_candidate == "game":
return False
normalized_no_space = normalized_candidate.replace(" ", "")
forbidden = ["win32", "win64", "gamelauncher"]
for token in forbidden:
@@ -228,7 +242,7 @@ def is_valid_candidate(candidate):
def filter_candidates(candidates):
"""
Фильтрует список кандидатов, отбрасывая недопустимые.
Filters a list of candidates, discarding invalid ones.
"""
valid = []
dropped = []
@@ -238,18 +252,18 @@ def filter_candidates(candidates):
else:
dropped.append(cand)
if dropped:
logger.info("Отбрасываю кандидатов: %s", dropped)
logger.info("Discarding candidates: %s", dropped)
return valid
def remove_duplicates(candidates):
"""
Удаляет дубликаты из списка, сохраняя порядок.
Removes duplicates from a list while preserving order.
"""
return list(dict.fromkeys(candidates))
@functools.lru_cache(maxsize=256)
def get_exiftool_data(game_exe):
"""Получает метаданные через exiftool"""
"""Retrieves metadata using exiftool."""
try:
proc = subprocess.run(
["exiftool", "-j", game_exe],
@@ -258,12 +272,12 @@ def get_exiftool_data(game_exe):
check=False
)
if proc.returncode != 0:
logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}")
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
return {}
meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
return meta_data_list[0] if meta_data_list else {}
except Exception as e:
logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}")
return {}
def delete_cached_app_files(cache_dir: str, pattern: str):
@@ -305,14 +319,14 @@ def load_steam_apps_async(callback: Callable[[list], None]):
f.write(orjson.dumps(data))
if os.path.exists(cache_tar):
os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar)
logger.info("Deleted archive: %s", cache_tar)
# Delete all cached app detail files (steam_app_*.json)
delete_cached_app_files(cache_dir, "steam_app_*.json")
steam_apps = data if isinstance(data, list) else []
logger.info("Loaded %d apps from archive", len(steam_apps))
callback(steam_apps)
except Exception as e:
logger.error("Error extracting Steam apps archive: %s", e)
logger.error("Failed to extract Steam apps archive: %s", e)
callback([])
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
@@ -322,18 +336,18 @@ def load_steam_apps_async(callback: Callable[[list], None]):
data = orjson.loads(f.read())
# Validate JSON structure
if not isinstance(data, list):
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure")
# Validate each app entry
for app in data:
if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json)
logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json)
raise ValueError("Invalid app entry structure")
steam_apps = data
logger.info("Loaded %d apps from cache", len(steam_apps))
callback(steam_apps)
except Exception as e:
logger.error("Error reading or validating cached JSON %s: %s", cache_json, e)
logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e)
# Attempt to re-download if cache is invalid or corrupted
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
@@ -351,12 +365,12 @@ def load_steam_apps_async(callback: Callable[[list], None]):
def build_index(steam_apps):
"""
Строит индекс приложений по полю normalized_name.
Builds an index of applications by normalized_name field.
"""
steam_apps_index = {}
if not steam_apps:
return steam_apps_index
logger.info("Построение индекса Steam приложений:")
logger.info("Building Steam apps index")
for app in steam_apps:
normalized = app["normalized_name"]
steam_apps_index[normalized] = app
@@ -364,25 +378,24 @@ def build_index(steam_apps):
def search_app(candidate, steam_apps_index):
"""
Ищет приложение по кандидату: сначала пытается точное совпадение, затем ищет подстроку.
Searches for an application by candidate: tries exact match first, then substring match.
"""
candidate_norm = normalize_name(candidate)
logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm)
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
if candidate_norm in steam_apps_index:
logger.info(" Найдено точное совпадение: '%s'", candidate_norm)
logger.info("Found exact match: '%s'", candidate_norm)
return steam_apps_index[candidate_norm]
for name_norm, app in steam_apps_index.items():
if candidate_norm in name_norm:
ratio = len(candidate_norm) / len(name_norm)
if ratio > 0.8:
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)",
candidate_norm, name_norm, ratio)
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
return app
logger.info(" Приложение для кандидата '%s' не найдено", candidate_norm)
logger.info("No app found for candidate '%s'", candidate_norm)
return None
def load_app_details(app_id):
"""Загружает кэшированные данные для игры по appid, если они не устарели."""
"""Loads cached game data by appid if not outdated."""
cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
if os.path.exists(cache_file):
@@ -392,7 +405,7 @@ def load_app_details(app_id):
return None
def save_app_details(app_id, data):
"""Сохраняет данные по appid в файл кэша."""
"""Saves appid data to a cache file."""
cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
with open(cache_file, "wb") as f:
@@ -435,7 +448,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
save_app_details(app_id, app_data)
callback(app_data)
except Exception as e:
logger.error("Error processing Steam app info for appid %s: %s", app_id, e)
logger.error("Failed to process Steam app info for appid %s: %s", app_id, e)
callback(None)
downloader.download_async(url, cache_file, timeout=5, callback=process_response)
@@ -470,12 +483,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
f.write(orjson.dumps(data))
if os.path.exists(cache_tar):
os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar)
logger.info("Deleted archive: %s", cache_tar)
anti_cheat_data = data or []
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
callback(anti_cheat_data)
except Exception as e:
logger.error("Error extracting WeAntiCheatYet archive: %s", e)
logger.error("Failed to extract WeAntiCheatYet archive: %s", e)
callback([])
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
@@ -485,41 +498,37 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
data = orjson.loads(f.read())
# Validate JSON structure
if not isinstance(data, list):
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure")
# Validate each anti-cheat entry
for entry in data:
if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json)
logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json)
raise ValueError("Invalid anti-cheat entry structure")
anti_cheat_data = data
logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
callback(anti_cheat_data)
except Exception as e:
logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e)
logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e)
# Attempt to re-download if cache is invalid or corrupted
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
)
# Delete cached anti-cheat files before re-downloading
delete_cached_app_files(cache_dir, "anticheat_*.json") # Adjust pattern if app-specific files are added
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else:
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
)
# Delete cached anti-cheat files before downloading
delete_cached_app_files(cache_dir, "anticheat_*.json") # Adjust pattern if app-specific files are added
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
def build_weanticheatyet_index(anti_cheat_data):
"""
Строит индекс античит-данных по полю normalized_name.
Builds an index of anti-cheat data by normalized_name field.
"""
anti_cheat_index = {}
if not anti_cheat_data:
return anti_cheat_index
logger.info("Построение индекса WeAntiCheatYet данных:")
logger.info("Building WeAntiCheatYet data index")
for entry in anti_cheat_data:
normalized = entry["normalized_name"]
anti_cheat_index[normalized] = entry
@@ -527,20 +536,19 @@ def build_weanticheatyet_index(anti_cheat_data):
def search_anticheat_status(candidate, anti_cheat_index):
candidate_norm = normalize_name(candidate)
logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm)
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
if candidate_norm in anti_cheat_index:
status = anti_cheat_index[candidate_norm]["status"]
logger.info(" Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status)
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
return status
for name_norm, entry in anti_cheat_index.items():
if candidate_norm in name_norm:
ratio = len(candidate_norm) / len(name_norm)
if ratio > 0.8:
status = entry["status"]
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'",
candidate_norm, name_norm, ratio, status)
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
return status
logger.info(" Античит-статус для кандидата '%s' не найден", candidate_norm)
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
return ""
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
@@ -556,7 +564,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No
load_weanticheatyet_data_async(on_anticheat_data)
def load_protondb_status(appid):
"""Загружает закешированные данные ProtonDB для игры по appid, если они не устарели."""
"""Loads cached ProtonDB data for a game by appid if not outdated."""
cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
if os.path.exists(cache_file):
@@ -565,18 +573,18 @@ def load_protondb_status(appid):
with open(cache_file, "rb") as f:
return orjson.loads(f.read())
except Exception as e:
logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e)
logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e)
return None
def save_protondb_status(appid, data):
"""Сохраняет данные ProtonDB для игры по appid в файл кэша."""
"""Saves ProtonDB data for a game by appid to a cache file."""
cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
try:
with open(cache_file, "wb") as f:
f.write(orjson.dumps(data))
except Exception as e:
logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e)
logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e)
def get_protondb_tier_async(appid: int, callback: Callable[[str], None]):
"""
@@ -664,7 +672,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
if game_exe.lower().endswith('.exe'):
break
except Exception as e:
logger.error("Error processing bat file %s: %s", game_exe, e)
logger.error("Failed to process bat file %s: %s", game_exe, e)
else:
logger.error("Bat file not found: %s", game_exe)
@@ -799,55 +807,55 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
def enable_steam_cef() -> tuple[bool, str]:
"""
Проверяет и при необходимости активирует режим удаленной отладки Steam CEF.
Checks and enables Steam CEF remote debugging if necessary.
Создает файл .cef-enable-remote-debugging в директории Steam.
Steam необходимо перезапустить после первого создания этого файла.
Creates a .cef-enable-remote-debugging file in the Steam directory.
Steam must be restarted after the file is first created.
Возвращает кортеж:
- (True, "already_enabled") если уже было активно.
- (True, "restart_needed") если было только что активировано и нужен перезапуск Steam.
- (False, "steam_not_found") если директория Steam не найдена.
Returns a tuple:
- (True, "already_enabled") if already enabled.
- (True, "restart_needed") if just enabled and Steam restart is needed.
- (False, "steam_not_found") if Steam directory is not found.
"""
steam_home = get_steam_home()
if not steam_home:
return (False, "steam_not_found")
cef_flag_file = steam_home / ".cef-enable-remote-debugging"
logger.info(f"Проверка CEF флага: {cef_flag_file}")
logger.info(f"Checking CEF flag: {cef_flag_file}")
if cef_flag_file.exists():
logger.info("CEF Remote Debugging уже активирован.")
logger.info("CEF Remote Debugging is already enabled")
return (True, "already_enabled")
else:
try:
os.makedirs(cef_flag_file.parent, exist_ok=True)
cef_flag_file.touch()
logger.info("CEF Remote Debugging активирован. Steam необходимо перезапустить.")
logger.info("Enabled CEF Remote Debugging. Steam restart required")
return (True, "restart_needed")
except Exception as e:
logger.error(f"Не удалось создать CEF флаг {cef_flag_file}: {e}")
logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}")
return (False, str(e))
def call_steam_api(js_cmd: str, *args) -> dict | None:
"""
Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging.
Executes a JavaScript function in the Steam context via CEF Remote Debugging.
Args:
js_cmd: Имя JS функции для вызова (напр. 'createShortcut').
*args: Аргументы для передачи в JS функцию.
js_cmd: Name of the JS function to call (e.g., 'createShortcut').
*args: Arguments to pass to the JS function.
Returns:
Словарь с результатом выполнения или None в случае ошибки.
Dictionary with the result or None if an error occurs.
"""
status, message = enable_steam_cef()
if not (status is True and message == "already_enabled"):
if message == "restart_needed":
logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.")
logger.warning("Steam CEF API is available but requires Steam restart for full activation")
elif message == "steam_not_found":
logger.error("Не удалось найти директорию Steam для проверки CEF API.")
logger.error("Could not find Steam directory to check CEF API")
else:
logger.error(f"Steam CEF API недоступен или не готов: {message}")
logger.error(f"Steam CEF API is unavailable or not ready: {message}")
return None
steam_debug_url = "http://localhost:8080/json"
@@ -858,10 +866,10 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
contexts = response.json()
ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
if not ws_url:
logger.warning("Не удалось найти SharedJSContext. Steam запущен с флагом -cef-enable-remote-debugging?")
logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?")
return None
except Exception as e:
logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}")
logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {e}")
return None
js_code = """
@@ -906,15 +914,15 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
response_data = orjson.loads(response_str)
if "error" in response_data:
logger.error(f"Ошибка выполнения JS в Steam: {response_data['error']['message']}")
logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}")
return None
result = response_data.get('result', {}).get('result', {})
if result.get('type') == 'object' and result.get('subtype') == 'error':
logger.error(f"Ошибка выполнения JS в Steam: {result.get('description')}")
logger.error(f"JavaScript execution error in Steam: {result.get('description')}")
return None
return result.get('value')
except Exception as e:
logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}")
logger.error(f"WebSocket interaction error with Steam: {e}")
return None
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
@@ -991,24 +999,24 @@ export START_FROM_STEAM=1
else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if not success or not os.path.exists(generated_icon_path):
logger.warning(f"generate_thumbnail failed to create icon for {exe_path}")
logger.warning(f"Failed to generate thumbnail for {exe_path}")
icon_path = ""
else:
logger.info(f"Generated thumbnail: {generated_icon_path}")
icon_path = generated_icon_path
except Exception as e:
logger.error(f"Error generating thumbnail for {exe_path}: {e}")
logger.error(f"Failed to generate thumbnail for {exe_path}: {e}")
icon_path = ""
steam_home = get_steam_home()
if not steam_home:
logger.error("Steam home directory not found")
return (False, "Steam directory not found.")
return (False, "Steam directory not found")
last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID")
return (False, "Failed to get Steam user ID.")
return (False, "Failed to get Steam user ID")
userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID']
@@ -1021,7 +1029,7 @@ export START_FROM_STEAM=1
appid = None
was_api_used = False
logger.info("Попытка добавления ярлыка через Steam CEF API...")
logger.info("Attempting to add shortcut via Steam CEF API")
api_response = call_steam_api(
"createShortcut",
game_name,
@@ -1034,9 +1042,9 @@ export START_FROM_STEAM=1
if api_response and isinstance(api_response, dict) and 'id' in api_response:
appid = api_response['id']
was_api_used = True
logger.info(f"Ярлык успешно добавлен через API. AppID: {appid}")
logger.info(f"Shortcut successfully added via API. AppID: {appid}")
else:
logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).")
logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf")
backup_path = f"{steam_shortcuts_path}.backup"
if os.path.exists(steam_shortcuts_path):
try:
@@ -1110,7 +1118,7 @@ export START_FROM_STEAM=1
appid = None
if not appid:
return (False, "Не удалось создать ярлык ни одним из способов.")
return (False, "Failed to create shortcut using any method")
steam_appid = None
@@ -1120,7 +1128,7 @@ export START_FROM_STEAM=1
if not steam_appid or not isinstance(steam_appid, int):
logger.info("No valid Steam appid found, skipping cover download")
return
logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.")
logger.info(f"Found Steam AppID {steam_appid} for cover download")
cover_types = [
("p.jpg", "library_600x900_2x.jpg"),
@@ -1137,15 +1145,15 @@ export START_FROM_STEAM=1
try:
with open(result_path, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"Применение обложки типа '{steam_name}' через API для AppID {appid}")
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
ext = Path(steam_name).suffix.lstrip('.')
call_steam_api("setGrid", appid, index, ext, img_b64)
except Exception as e:
logger.error(f"Ошибка при применении обложки '{steam_name}' через API: {e}")
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
else:
logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
except Exception as e:
logger.error(f"Error processing cover {steam_name} for appid {steam_appid}: {e}")
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
for i, (suffix, steam_name) in enumerate(cover_types):
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
@@ -1186,13 +1194,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
steam_home = get_steam_home()
if not steam_home:
logger.error("Steam home directory not found")
return (False, "Steam directory not found.")
return (False, "Steam directory not found")
# Get current Steam user ID
last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID")
return (False, "Failed to get Steam user ID.")
return (False, "Failed to get Steam user ID")
userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id)
@@ -1238,10 +1246,10 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
return (False, f"Game '{game_name}' not found in Steam")
api_response = call_steam_api("removeShortcut", appid)
if api_response is not None: # API ответил, даже если ответ пустой
logger.info(f"Ярлык для AppID {appid} успешно удален через API.")
if api_response is not None: # API responded, even if response is empty
logger.info(f"Shortcut for AppID {appid} successfully removed via API")
else:
logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).")
logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf")
# Create backup of shortcuts.vdf
backup_path = f"{steam_shortcuts_path}.backup"
@@ -1320,5 +1328,5 @@ def is_game_in_steam(game_name: str) -> bool:
if entry.get("AppName") == game_name:
return True
except Exception as e:
logger.error(f"Error checking if game {game_name} is in Steam: {e}")
logger.error(f"Failed to check if game {game_name} is in Steam: {e}")
return False

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 B

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"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="48"
height="48"
version="1.1"
viewBox="0 0 48 48"
xml:space="preserve"
id="svg2"
sodipodi:docname="key_context.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs2" /><sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="8.6915209"
inkscape:cx="72.311855"
inkscape:cy="22.780823"
inkscape:window-width="2560"
inkscape:window-height="1406"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000"
d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z"
id="path10"
sodipodi:nodetypes="csccscc" /><path
fill="#000000"
d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z"
id="path2"
style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g
id="g15"
transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z"
id="path14" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z"
id="path15" /></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

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="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg>

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

Before

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="m6 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg>

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 B

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="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg>

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

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"><defs><style>.cls-1{fill:none;}</style></defs><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="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

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"><defs><style>.cls-1{fill:none;}</style></defs><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="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 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="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 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="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></svg>

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 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="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></svg>

After

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

Before

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="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 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="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 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="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></svg>

After

Width:  |  Height:  |  Size: 1.5 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="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg>

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 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="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></svg>

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 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="m21.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></svg>

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 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="m15.973 13.476h8.5299q3.0163 0 4.6379 0.45406 1.6541 0.42163 2.3352 1.3946 0.68109 0.94056 0.68109 2.6595v2.5946q0 0.87569-0.71353 1.6541-0.68109 0.77839-1.6216 1.0703v0.16216q1.2325 0.12973 2.2379 1.0703 1.0379 0.90812 1.0379 2.0433v3.2433q0 2.5622-2.0433 3.6325t-6.3244 1.0703h-8.7569zm8.5299 8.5623q1.2 0 1.7838-0.1946t0.77839-0.61623q0.22703-0.45406 0.22703-1.2973v-1.0379q0-0.74596-0.1946-1.1027-0.1946-0.3892-0.81082-0.55136-0.58379-0.16216-1.8811-0.16216h-3.373v4.9622zm0.12973 8.8866q1.8487 0 2.6271-0.42163t0.77839-1.3622v-1.6865q0-1.1676-0.61623-1.6541-0.58379-0.4865-2.1081-0.4865h-4.2812v5.6109z" fill="#3f424d" stroke-width=".67675" aria-label="B"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 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="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m13.757 18h2.8844v9.9476h5.8983v2.0524h-8.7827zm10.724 0h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.0169-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="LB"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 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="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m12.943 18h5.3991q2.4037 0 3.4761 0.59168 1.0724 0.57319 1.0724 1.9414v2.1079q0 1.0539-0.75809 1.6641-0.7396 0.59168-1.9784 0.75809l3.4761 4.9368h-3.3282l-3.1433-4.7149h-1.3313v4.7149h-2.8844zm5.3621 5.3806q1.0169 0 1.3867-0.25886 0.3698-0.27735 0.3698-1.0539v-0.85054q0-0.66564-0.40678-0.90601-0.38829-0.25886-1.3498-0.25886h-2.4777v3.3282zm6.9892-5.3806h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.017-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="RB"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 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="m16.169 14.061c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395z" fill="#3f424d" fill-rule="evenodd" stroke-width=".19943"/></svg>

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.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="m12.75 14.492c-0.62128 0-1.1257 0.38721-1.1257 0.86456v12.1c0 0.47737 0.50442 0.86456 1.1257 0.86456h3.3753v-1.7274h-2.2496v-10.373h13.498v1.7291h2.2496v-2.5937c0-0.47735-0.50268-0.86456-1.1239-0.86456zm6.7489 5.1874c-0.62128 0-1.1239 0.38721-1.1239 0.86456v12.1c0 0.47737 0.50266 0.86456 1.1239 0.86456h15.749c0.62125 0 1.1239-0.3872 1.1239-0.86456v-12.1c0-0.47735-0.50268-0.86456-1.1239-0.86456zm1.1257 1.7291h13.498v10.371h-13.498z" clip-rule="evenodd" fill="#3f424d" fill-rule="evenodd" stroke-width=".98604"/></svg>

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Some files were not shown because too many files have changed in this diff Show More