Compare commits
374 Commits
599644c4f6
...
v0.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
|
e9865da0fe
|
|||
|
b88b4e0652
|
|||
|
d0fad6a3c9
|
|||
|
468887110c
|
|||
|
32e4950a00
|
|||
|
b16074fa5c
|
|||
|
1bd7c23419
|
|||
|
f4275dd465
|
|||
|
c8b91c4687
|
|||
|
4aaeb2e809
|
|||
|
b6ea9350fa
|
|||
|
29d25cec01
|
|||
|
a634de5462
|
|||
|
1ba1781994
|
|||
|
0aae292f61
|
|||
|
3ef433af0c
|
|||
|
|
9fe33e02d8 | ||
|
2ac91a759d
|
|||
|
2c82bff204
|
|||
|
0889aa883e
|
|||
|
7780dcfc4d
|
|||
|
9ef39ae2b6
|
|||
|
86fb2b2d7c
|
|||
| 9d469f0a12 | |||
|
665a4df322
|
|||
|
3abaccb1e0
|
|||
|
77b025f580
|
|||
|
|
42e2025e54 | ||
|
|
8f84bbce31 | ||
|
3026e7da4e
|
|||
|
3522764c3e
|
|||
|
fd456e5330
|
|||
|
99a963d60c
|
|||
|
0b36e73bce
|
|||
|
4baa2e8684
|
|||
|
4344bbca70
|
|||
|
0a8a290d2d
|
|||
|
92652e8faa
|
|||
|
4f2afaed24
|
|||
|
1751e01e47
|
|||
|
0f74a47aed
|
|||
|
666ec654a0
|
|||
|
0c25cc9fd2
|
|||
|
5de83dbf49
|
|||
| 1821faadf6 | |||
|
17f0a6b0ea
|
|||
|
e9c75b998f
|
|||
|
bbfbc00c11
|
|||
|
b7804fdd01
|
|||
|
|
043da2cf5d | ||
|
2fa10e7db3
|
|||
|
b1b9706272
|
|||
|
9c11d33c0a
|
|||
|
173e1cb88e
|
|||
|
30606c7ec1
|
|||
|
873e8b050e
|
|||
|
59dad21945
|
|||
|
b0c4e943ae
|
|||
|
19e01bba17
|
|||
|
836e6cdd36
|
|||
|
b2a1046f9d
|
|||
|
80a2c06b5a
|
|||
|
f0a4ace735
|
|||
|
7dfaee6831
|
|||
|
5481cd80d7
|
|||
|
a016cfa810
|
|||
|
8fc097ccaf
|
|||
|
ad3eeb6e06
|
|||
|
92631cd2c6
|
|||
|
4477679f2d
|
|||
|
|
b6644eeee5
|
||
|
|
2e921226c4
|
||
|
|
4fc1ea73d3
|
||
|
|
3c15cbe495
|
||
|
fed6aafed5
|
|||
|
2e8be13437
|
|||
|
ea272c29b6
|
|||
|
17262f6c9f
|
|||
|
e07f3f06bc
|
|||
|
16a3f4e09a
|
|||
|
a448ba29b0
|
|||
|
06e55db54d
|
|||
|
5fce23f261
|
|||
|
|
96ad40d625 | ||
|
|
a30f6f2e74 | ||
|
0231073b19
|
|||
|
dec24429f5
|
|||
|
4a758f3b3c
|
|||
|
0853dd1579
|
|||
|
bbb87c0455
|
|||
|
b32a71a125
|
|||
|
|
bddf9f850a | ||
|
|
a9c3cfa167 | ||
|
7675bc4cdc
|
|||
|
ffa203f019
|
|||
|
3eed25ecee
|
|||
|
3736bb279e
|
|||
|
|
b59ee5ae8e | ||
|
33176590fd
|
|||
|
8046065929
|
|||
|
|
fbad5add6c | ||
|
438e9737ea
|
|||
|
2d39a4c740
|
|||
|
567203b0b0
|
|||
|
502cbc5030
|
|||
|
9b61215152
|
|||
|
10d3fe8ab4
|
|||
|
a568ad9ef8
|
|||
|
f074843fc8
|
|||
|
4ab078b93e
|
|||
|
7df6ad3b80
|
|||
|
464ad0fe9c
|
|||
|
cde92885d4
|
|||
|
120c7b319c
|
|||
|
596aed0077
|
|||
|
6fc6cb1e02
|
|||
|
186e28a19b
|
|||
|
28e4d1e77c
|
|||
|
fff1f888c4
|
|||
|
fdd5a0a3d5
|
|||
|
792e52d981
|
|||
|
84d5e46a74
|
|||
|
4bc764d568
|
|||
|
9a18aa037e
|
|||
|
ed62d2d1c4
|
|||
|
accc9b18b6
|
|||
|
82249d7eab
|
|||
|
476c896940
|
|||
|
b1047ba18e
|
|||
|
987199d8e6
|
|||
|
|
ef1acd4581 | ||
|
96f884904c
|
|||
|
b856a2afae
|
|||
|
55ef0030e6
|
|||
|
8aaeaa4824
|
|||
|
f55372b480
|
|||
|
4d6f32f053
|
|||
|
a2f5141b20
|
|||
|
e3cb2857e7
|
|||
|
efe8a35832
|
|||
|
61fae97dad
|
|||
|
5442100f64
|
|||
|
2d6ef84798
|
|||
|
|
f4aee15b5d | ||
|
87a65108a5
|
|||
|
bb617708ac
|
|||
|
1cf332cd87
|
|||
|
577ad4d3a3
|
|||
|
ef3f2d6e96
|
|||
|
657d7728a6
|
|||
| 9452bfda2e | |||
| 7eb2db0d68 | |||
| 6ef7a03366 | |||
| e5af354b56 | |||
| e6e5f6c8ea | |||
| 84306bb31b | |||
| 60af4d1482 | |||
| 692e11b21d | |||
| b1a804811e | |||
| 9a30cfaea7 | |||
| 5dd2f71f5e | |||
|
dba172361b
|
|||
|
a9c70b8818
|
|||
|
135ace732f
|
|||
|
8b727f64e1
|
|||
|
a8eb591da5
|
|||
|
fe4ca1ee87
|
|||
|
ffe3e9d3d6
|
|||
|
49d39b5d61
|
|||
|
|
03566da704
|
||
|
|
7f996ab6a0 | ||
|
|
9e17978155 | ||
|
5d0185b1b4
|
|||
|
5c134be04e
|
|||
|
8c66695192
|
|||
|
7a141d8e46
|
|||
|
abb2377fb7
|
|||
|
75f4f346de
|
|||
|
87a9f85272
|
|||
|
240f685ece
|
|||
|
af4e3e95bb
|
|||
|
017d9a42cf
|
|||
|
18b7c4054b
|
|||
|
dd7f71b70a
|
|||
|
8fd44c575b
|
|||
|
65b43c1572
|
|||
|
f35276abfe
|
|||
|
6fea9a9a7e
|
|||
|
5189474631
|
|||
|
|
416cc6a268 | ||
|
|
3b44ed5252 | ||
|
c8c45dda06
|
|||
|
3f9f794e6f
|
|||
|
ba9d8b76d8
|
|||
|
e99c71c1f8
|
|||
|
baec62d1cb
|
|||
|
cb76961e4f
|
|||
|
|
081cd07253 | ||
|
b5efee29ea
|
|||
| 69360f7e7e | |||
|
|
39712f0591 | ||
|
|
60b508af18 | ||
|
|
b6637b4163 | ||
|
|
6d9eed42f8 | ||
|
7372e3b7f5
|
|||
| e0d5bd7993 | |||
|
|
12f8067af1 | ||
|
|
716a813ca9 | ||
|
c62cc6853f
|
|||
|
2e018b4690
|
|||
|
ad5b25f713
|
|||
|
3fb8201305
|
|||
|
04d8302d6c
|
|||
|
|
f868b21178 | ||
|
|
ebe25b41d8 | ||
|
|
fae6cad52d | ||
|
|
42bce11ada | ||
|
f088c01768
|
|||
|
e7eee85ed4
|
|||
|
ecfe252ae3
|
|||
|
1ad19bff6a
|
|||
|
98f07a9792
|
|||
|
d5c53ed1aa
|
|||
|
5a2ab36b60
|
|||
|
8e25c04f56
|
|||
|
f249b01dc6
|
|||
|
9f32afe6a3
|
|||
|
f475e6e0b2
|
|||
|
43a7c37e91
|
|||
|
f1cf0ffd68
|
|||
|
70ed3abcb5
|
|||
|
f061b1597e
|
|||
|
0f37a8fc6f
|
|||
|
850bc57a16
|
|||
|
0dcc3ea13f
|
|||
|
1c82b34e36
|
|||
|
a8c4ae6f7b
|
|||
|
dd4f658b66
|
|||
|
bff6b7fd34
|
|||
|
1e191bbba3
|
|||
|
4356e653b8
|
|||
|
4fc95511f1
|
|||
|
4d4e14ea52
|
|||
|
c39f5ad83b
|
|||
|
f3325ca35f
|
|||
|
50645066dd
|
|||
|
7945dd8980
|
|||
|
59c38f9c57
|
|||
|
a2d5d28884
|
|||
|
16af4b410a
|
|||
|
e8e42b5a86
|
|||
|
d16e2cdf43
|
|||
|
|
b60fd0d593 | ||
|
d93f23fe8c
|
|||
|
5423ada8f1
|
|||
|
2547c7c78d
|
|||
|
2e93073446
|
|||
|
|
9657ff20d3 | ||
|
849333c283
|
|||
|
8e11dac987
|
|||
|
358afbdbdb
|
|||
|
83730499e2
|
|||
|
84f560ed30
|
|||
|
888c9ac387
|
|||
|
68d06ca05c
|
|||
|
6923a5f05c
|
|||
|
f3f85441d8
|
|||
|
eb90836710
|
|||
|
dd125c975b
|
|||
|
4521d3ca1c
|
|||
|
dd044dbd95
|
|||
|
0047b29cd2
|
|||
|
d0fbc79168
|
|||
|
57f6ac9c4b
|
|||
|
60271f7a13
|
|||
|
38ab4acc86
|
|||
|
8f54f4814c
|
|||
|
37254b89f1
|
|||
|
893e33bdce
|
|||
|
1ee784d890
|
|||
|
39f505079c
|
|||
|
46253115ff
|
|||
|
31a7ef3e7e
|
|||
|
|
cb07904c1b | ||
|
05e0d9d846
|
|||
|
81433d3c56
|
|||
|
0ff66e282b
|
|||
|
831b7739ba
|
|||
|
50e1dfda57
|
|||
|
fcf04e521d
|
|||
|
74d0700d7c
|
|||
|
0435c77630
|
|||
|
1cf93a60c8
|
|||
|
31247d21c3
|
|||
|
c6017a7dce
|
|||
|
c74d209dbd
|
|||
|
5b257d3b62
|
|||
|
4dcf1dbe6d
|
|||
|
8d6fe4aa65
|
|||
|
022eb3f1e9
|
|||
|
11b847ed05
|
|||
|
1e4e0127a4
|
|||
|
c045aa7a56
|
|||
|
f18e7bae6b
|
|||
|
dcf8904037
|
|||
|
f9d24e385d
|
|||
|
09028931be
|
|||
|
0294c90c54
|
|||
|
17dfef2d27
|
|||
|
|
f0690f8811
|
||
|
ac20447ba3
|
|||
|
ba143c15a8
|
|||
|
13068f3959
|
|||
|
|
c8360d08ca | ||
|
b070ff1fca
|
|||
|
b5a2f41bdf
|
|||
|
9a37f31841
|
|||
|
aeed0112cd
|
|||
|
027ae68d4d
|
|||
|
37d41fef8d
|
|||
|
e37422fc95
|
|||
|
d7951e8587
|
|||
|
556533785a
|
|||
|
a13aca4d84
|
|||
|
35736e1723
|
|||
|
|
24a7c2e657
|
||
|
|
279f7ec36b
|
||
|
41f6943998
|
|||
|
3bf10dc4cd
|
|||
|
33b96d3185
|
|||
|
3573b8e373
|
|||
|
582ddd2218
|
|||
|
2753e53a4d
|
|||
|
46973f35e1
|
|||
|
8e34c92385
|
|||
|
d50b63bca7
|
|||
|
6966253e9b
|
|||
|
13f3af7a42
|
|||
|
c7bed80570
|
|||
|
6fde7c18db
|
|||
|
37782d4375
|
|||
|
0a8a7c538c
|
|||
|
|
9cc4b8c51d | ||
|
397dede2be
|
|||
|
6a66f37ba1
|
|||
|
4db1cce32c
|
|||
|
edaeca4f11
|
|||
|
11d44f091d
|
|||
|
09d9c6510a
|
|||
|
272be51bb0
|
|||
|
63933172f9
|
|||
|
85e9aba836
|
|||
|
4d3499d2c1
|
|||
|
a13c15bc28
|
|||
|
83076d3dfc
|
|||
|
04aaf68e36
|
|||
|
e91037708a
|
|||
|
1b743026c2
|
|||
|
30b4cec4d1
|
|||
|
db68c9050c
|
|||
|
1a93d5b82c
|
|||
|
cc0690cf9e
|
|||
|
809ba2c976
|
|||
|
68c9636e10
|
|||
|
f0df1f89be
|
|||
|
f25224b668
|
|||
|
0cda47fdfd
|
|||
|
1a8c733580
|
|||
|
2476bea32a
|
|||
|
1bbc95a5c1
|
|||
|
d12b801191
|
|||
|
233dab1269
|
|||
|
700a478598
|
|||
|
0fe727331f
|
@@ -12,15 +12,27 @@ jobs:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- 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
|
||||
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
|
||||
run: pip3 install appimage-builder uv
|
||||
- name: Upgrade pip toolchain
|
||||
run: |
|
||||
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: |
|
||||
@@ -40,7 +52,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, rawhide]
|
||||
fedora_version: [41, 42, 43, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -50,7 +62,7 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
|
||||
python3-build pyproject-rpm-macros python3-setuptools \
|
||||
python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
|
||||
redhat-rpm-config nodejs npm
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
@@ -61,7 +73,7 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout repo
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Copy fedora.spec
|
||||
run: |
|
||||
@@ -82,7 +94,7 @@ jobs:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
@@ -91,7 +103,7 @@ jobs:
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||
yes | pacman -Scc
|
||||
@@ -122,7 +134,7 @@ jobs:
|
||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 0.1.3
|
||||
VERSION: 0.1.9
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -23,10 +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
|
||||
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
|
||||
run: pip3 install appimage-builder uv
|
||||
- name: Upgrade pip toolchain
|
||||
run: |
|
||||
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: |
|
||||
@@ -52,7 +64,7 @@ jobs:
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||
yes | pacman -Scc
|
||||
@@ -97,7 +109,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, rawhide]
|
||||
fedora_version: [41, 42, 43, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -107,7 +119,7 @@ jobs:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
|
||||
python3-build pyproject-rpm-macros python3-setuptools \
|
||||
python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
|
||||
redhat-rpm-config nodejs npm
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
@@ -157,6 +169,7 @@ jobs:
|
||||
mkdir -p extracted
|
||||
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
|
||||
find extracted/ -type f -exec mv {} release/ \;
|
||||
find release/ -name '*.zip' -delete
|
||||
rm -rf extracted/
|
||||
|
||||
- name: Extract changelog for version
|
||||
@@ -167,10 +180,12 @@ jobs:
|
||||
|
||||
- name: Release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
env:
|
||||
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
|
||||
with:
|
||||
body_path: changelog.txt
|
||||
token: ${{ env.GITEA_TOKEN }}
|
||||
tag_name: v${{ env.VERSION }}
|
||||
prerelease: true
|
||||
files: release/**/*
|
||||
sha256sum: true
|
||||
sha256sum: false
|
||||
|
||||
@@ -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,13 +12,14 @@ on:
|
||||
|
||||
jobs:
|
||||
check-translations:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@v5
|
||||
uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
187
.gitea/workflows/code-build.yml
Normal file
@@ -0,0 +1,187 @@
|
||||
name: Build Check - AppImage, Arch, Fedora
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'build-aux/**'
|
||||
|
||||
env:
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
appimage: ${{ steps.check.outputs.appimage }}
|
||||
fedora: ${{ steps.check.outputs.fedora }}
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure git is installed
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y git
|
||||
|
||||
- name: Check changed files
|
||||
id: check
|
||||
run: |
|
||||
# Get changed files
|
||||
git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} > changed_files.txt
|
||||
|
||||
echo "Changed files:"
|
||||
cat changed_files.txt
|
||||
|
||||
# Check AppImage files
|
||||
if grep -q "build-aux/AppImageBuilder.yml" changed_files.txt; then
|
||||
echo "appimage=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "appimage=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Check Fedora spec files (only fedora-git.spec)
|
||||
if grep -q "build-aux/fedora-git.spec" changed_files.txt; then
|
||||
echo "fedora=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "fedora=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Check Arch PKGBUILD-git
|
||||
if grep -q "build-aux/PKGBUILD-git" changed_files.txt; then
|
||||
echo "arch=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "arch=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-appimage:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- 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 zstd git
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
cd build-aux
|
||||
appimage-builder
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-AppImage
|
||||
path: build-aux/PortProtonQt*.AppImage
|
||||
|
||||
build-fedora:
|
||||
name: Build Fedora RPM
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.fedora == 'true' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
|
||||
python3-build pyproject-rpm-macros python3-setuptools \
|
||||
redhat-rpm-config nodejs npm
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
useradd rpmbuild -u 5002 -g users || true
|
||||
mkdir -p /home/rpmbuild/{BUILD,RPMS,SPECS,SRPMS,SOURCES}
|
||||
chown -R rpmbuild:users /home/rpmbuild
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout repo
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Copy fedora-git.spec
|
||||
run: |
|
||||
cp build-aux/fedora-git.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
|
||||
chown -R rpmbuild:users /home/rpmbuild
|
||||
|
||||
- name: Build RPM
|
||||
run: |
|
||||
su rpmbuild -c "rpmbuild -ba /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec"
|
||||
|
||||
- name: Upload RPM package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-RPM-Fedora-${{ matrix.fedora_version }}
|
||||
path: /home/rpmbuild/RPMS/**/*.rpm
|
||||
|
||||
build-arch:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||
yes | pacman -Scc
|
||||
pacman-key --init
|
||||
pacman -S --noconfirm archlinux-keyring
|
||||
mkdir -p /__w/portproton-repo
|
||||
pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com
|
||||
pacman-key --lsign-key 3056513887B78AEB
|
||||
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst'
|
||||
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst'
|
||||
cat << EOM >> /etc/pacman.conf
|
||||
|
||||
[chaotic-aur]
|
||||
Include = /etc/pacman.d/chaotic-mirrorlist
|
||||
EOM
|
||||
pacman -Syy
|
||||
useradd -m user -G wheel && echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||
echo "PACKAGER=\"Boris Yumankulov <boria138@altlinux.org>\"" >> /etc/makepkg.conf
|
||||
chown user -R /tmp
|
||||
chown user -R ..
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cd /__w/portproton-repo
|
||||
git clone https://git.linux-gaming.ru/Boria138/PortProtonQt.git
|
||||
cd /__w/portproton-repo/PortProtonQt/build-aux
|
||||
chown user -R ..
|
||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-Arch
|
||||
path: ${{ env.PKGDEST }}/*
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Code and build check
|
||||
name: Code check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -20,12 +20,18 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Install uv
|
||||
uses: https://github.com/astral-sh/setup-uv@v6
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
enable-cache: true
|
||||
node-version: 20
|
||||
|
||||
- name: Install uv manually
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
source $HOME/.local/bin/env
|
||||
uv --version
|
||||
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
@@ -35,20 +41,3 @@ jobs:
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
|
||||
build-uv:
|
||||
name: Build with uv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: https://github.com/astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Sync dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Build project
|
||||
run: uv build
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@v5
|
||||
uses: https://gitea.com/actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -8,11 +8,31 @@ on:
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:41.1.4
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- run: renovate
|
||||
- uses: https://gitea.com/actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install uv manually
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
. $HOME/.local/bin/env
|
||||
uv --version
|
||||
|
||||
- name: Download external renovate config
|
||||
run: |
|
||||
mkdir -p /tmp/renovate-config
|
||||
curl -fsSL "https://git.linux-gaming.ru/Linux-Gaming/renovate-config/raw/branch/main/config.js" \
|
||||
-o /tmp/renovate-config/config.js
|
||||
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
env:
|
||||
RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js"
|
||||
RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
|
||||
LOG_LEVEL: "debug"
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
@@ -11,15 +11,14 @@ repos:
|
||||
- id: check-yaml
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.6.14
|
||||
rev: 0.9.5
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.5
|
||||
rev: v0.14.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-check
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
|
||||
350
CHANGELOG.md
@@ -3,20 +3,184 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.1.9] - 2025-12-08
|
||||
|
||||
### Added
|
||||
- Переводы в переопределениях (за подробностями в документацию)
|
||||
- Обложки и описания для всех автоинсталлов
|
||||
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры
|
||||
- Добавлены основные и расширенные настройки для `.exe`-файлов
|
||||
- Добавлена кнопка обновления сетки без необходимости перезапуска PortProtonQt (F5 на клавиатуре, GUIDE + Select на геймпаде)
|
||||
- Добавлена эмуляция мыши по GUIDE (Xbox или PS) + Start для установки приложений или взаимодействия с инструментами Wine не адаптированные под геймпад (работает только если PortProtonQt вне фокуса)
|
||||
- При сворачивании приложения в трей оно теперь корректно восстанавливается, вместо запуска нового экземпляра
|
||||
- Добавлена поддержка SteamGridDB в качестве дополнительного источника обложек
|
||||
- При добавлении карточки в избранное она автоматически становится первой без необходимости перезапуска
|
||||
|
||||
### Changed
|
||||
- Оптимизированны обложки автоинсталлов
|
||||
- Папка custom_data исключена из сборки модуля для уменьшение его размера
|
||||
- Бейдж PortProton теперь открывает PortProtonDB
|
||||
- Изменено оформление виртуальной клавиатуры для лучшего соответствия общей теме
|
||||
- Ускорено чтение конфигов за счёт уменьшения количества обращений к файловой системе.
|
||||
- Из стандартной темы удалены неиспользуемые шрифты
|
||||
- Улучшена совместимость с Qt 6.10
|
||||
- Ускорен запуск программы
|
||||
- В диалог редактирования ярылыка добавлен placeholder с уточнением того что в качевстве обложки можно использовать и ссылку, а не только файл
|
||||
- Ссылку на обложку в диалоге редактирования ярлыка теперь можно указывать без протокола вроде http или https
|
||||
|
||||
### Fixed
|
||||
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
|
||||
- Добавлено больше проверок на None для избежания вылетов
|
||||
- Улучшена работа с потоками для избежания вылетов
|
||||
- Исправлен запуск PortProton из Flatpak: теперь используется `flatpak run`, а не `start.sh`
|
||||
- Исправлено применение обложки по ссылке например со steamgriddb.com/
|
||||
- Исправлено множественное открытие окон в X11
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
- @Dervart
|
||||
|
||||
---
|
||||
|
||||
## [0.1.8] - 2025-10-18
|
||||
|
||||
### Added
|
||||
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
|
||||
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
|
||||
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
|
||||
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
|
||||
|
||||
### Changed
|
||||
- При завершении автоустановки приложение больше не перезапускается
|
||||
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
|
||||
- Обновлены и дополнены скриншоты темы
|
||||
|
||||
### Fixed
|
||||
- Исправлено наложение карточек при смене фильтра игр
|
||||
- Исправлена невозможность запуска приложения без подключёного геймпада
|
||||
- Исправлена невозможность установки компонентов Winetricks через геймпад
|
||||
- Ресиверы и виртуальные устройства больше не считаются за геймпад
|
||||
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
- Кэширование шрифтов в load_theme_fonts для предотвращения повторной загрузки
|
||||
- Проверка безопасности в theme_manager.py для всех сторонних тем, с проверкой на запрещённые модули и функции (подробности см. в коде theme_manager под полями FORBIDDEN_MODULES и FORBIDDEN_FUNCTIONS)
|
||||
- Фильтрация ASRock LED контроллера, чтобы предотвратить его обнаружение как геймпада
|
||||
- Подсказки по управлению в интерфейсе
|
||||
- Поддержка боковой кнопки мыши, которая теперь работает как кнопка "назад"
|
||||
- Аргумент cli --debug-level для указания уровня дебага
|
||||
|
||||
### Changed
|
||||
- Управления с геймпада теперь перехватывается только если окно в фокусе
|
||||
|
||||
### Fixed
|
||||
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
|
||||
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
|
||||
|
||||
### Contributors
|
||||
- @wmigor (Igor Akulov)
|
||||
- @Vector_null
|
||||
|
||||
---
|
||||
|
||||
## [0.1.5] - 2025-08-31
|
||||
|
||||
### Added
|
||||
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
|
||||
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
|
||||
- Анимация при закрытии карточки игры (подробности см. в документации).
|
||||
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
|
||||
- Система быстрого доступа (избранного) в диалоге выбора файлов.
|
||||
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
|
||||
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
|
||||
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
|
||||
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
|
||||
- Пункт "Выход" в трей.
|
||||
- Пункт "Темы" в трей для быстрого переключения тем.
|
||||
- Двойной клик по иконке трея для показа/скрытия главного окна.
|
||||
- Запуск через трей показывает модальное окно для слежки за процессом запуска
|
||||
|
||||
### Changed
|
||||
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
|
||||
- Контекстное меню при открытии теперь сразу фокусируется на первом элементе.
|
||||
- Анимации теперь можно настраивать через темы (подробности см. в документации).
|
||||
- Общие JSON-файлы (`steam_apps` и `anticheat_games`) теперь перекачиваются, если они повреждены.
|
||||
- Временно удалена светлая тема.
|
||||
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
|
||||
- Обновлены все зависимости (затрагивает только AppImage).
|
||||
- Приложение теперь не закрывается полностью, а сворачивается в трей.
|
||||
- Карточки теперь все находятся друг под другом, а не в разнабой
|
||||
- Изменено соотношение сторон карточек
|
||||
|
||||
### Fixed
|
||||
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
|
||||
- Скриншоты тем больше не теряют качество при масштабе, отличном от 100%.
|
||||
- Данные от HLTB теперь не отображаются в карточке, если нет информации о времени прохождения.
|
||||
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
|
||||
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
|
||||
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
|
||||
- Заголовок окна диалога выбора файлов теперь можно перевести.
|
||||
- Трей теперь можно перевести.
|
||||
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
|
||||
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
|
||||
|
||||
### Contributors
|
||||
- @Alex Smith
|
||||
|
||||
---
|
||||
|
||||
## [0.1.4] - 2025-07-21
|
||||
|
||||
### Added
|
||||
- Переводы в переопределениях (подробности см. в документации).
|
||||
- Обложки и описания для всех автоинсталлов.
|
||||
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры.
|
||||
- Интеграция с howlongtobeat.com.
|
||||
|
||||
### Changed
|
||||
- Оптимизированы обложки автоинсталлов.
|
||||
- Папка `custom_data` исключена из сборки модуля для уменьшения его размера.
|
||||
- Бейдж PortProton теперь открывает PortProtonDB.
|
||||
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в Gamescope-сессии.
|
||||
- Удалён аргумент `--session`, так как тестирование Gamescope-сессии завершено.
|
||||
- В контекстном меню игр без exe-файла теперь отображается только пункт «Удалить из PortProton».
|
||||
|
||||
### Fixed
|
||||
- Запрос к GitHub API при загрузке legendary теперь учитывает настройки прокси.
|
||||
- Путь к `portprotonqt-session-select` в оверлее.
|
||||
- Работа `exiftool` в AppImage.
|
||||
- Открытие контекстного меню у игр без exe-файла.
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
@@ -26,32 +190,32 @@
|
||||
## [0.1.3] - 2025-07-05
|
||||
|
||||
### Added
|
||||
- Аргумент `--session` для запуска приложения в gamescope (Исключительно в целях тестирования)
|
||||
- Начальная поддержка EGS (Без EOS, скачивания игр и запуска игр из сторонних магазинов)
|
||||
- Автодополнение bash для комманды portprotonqt
|
||||
- Поддержка геймпадов в диалоге выбора игры
|
||||
- Быстрый запуск и остановка игры через контекстное меню
|
||||
- Иконки в контекстом меню
|
||||
- Обложки для части автоинсталлов
|
||||
- Аргумент `--session` для запуска приложения в Gamescope (исключительно в целях тестирования).
|
||||
- Начальная поддержка EGS (без EOS, скачивания и запуска игр из сторонних магазинов).
|
||||
- Автодополнение bash для команды `portprotonqt`.
|
||||
- Поддержка геймпадов в диалоге выбора игры.
|
||||
- Быстрый запуск и остановка игры через контекстное меню.
|
||||
- Иконки в контекстном меню.
|
||||
- Обложки для части автоинсталлов.
|
||||
|
||||
### Changed
|
||||
- Удалены сборки для Fedora 40
|
||||
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем.
|
||||
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга
|
||||
- Все desktop файлы создаются с коментарием "Запустить игру {название} через PortProton"
|
||||
- Заполнители в переводах теперь стали более осмысленными
|
||||
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope
|
||||
- Текст бейджей теперь обрезается через ... если не помещается
|
||||
- Удалены сборки для Fedora 40.
|
||||
- Параметры анимации GameCard перенесены в `styles.py` с подробной документацией для кастомизации тем.
|
||||
- Статусы выделения и наведения на карточки теперь взаимоисключающие.
|
||||
- Все desktop-файлы создаются с комментарием «Запустить игру {название} через PortProton».
|
||||
- Заполнители в переводах стали более осмысленными.
|
||||
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope.
|
||||
- Текст бейджей теперь обрезается троеточием, если не помещается.
|
||||
|
||||
### Fixed
|
||||
- Дублирование обводки выделения карточек при быстром перемешении мыши
|
||||
- Завершение приложения при закритие окна
|
||||
- Использование системной палитры в темах
|
||||
- Ошибки темы в нативном пакете
|
||||
- Ошибки темы в Gamescope
|
||||
- Размер иконок для desktop файлов теперь 128x128
|
||||
- Пустая область при обновлении сетки игр
|
||||
- Запуск игры при открытом оверлее
|
||||
- Дублирование обводки карточек при быстром перемещении мыши.
|
||||
- Завершение приложения при закрытии окна.
|
||||
- Использование системной палитры в темах.
|
||||
- Ошибки тем в нативном пакете.
|
||||
- Ошибки тем в Gamescope.
|
||||
- Размер иконок для desktop-файлов теперь 128x128.
|
||||
- Пустая область при обновлении сетки игр.
|
||||
- Запуск игры при открытом оверлее.
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
@@ -62,63 +226,63 @@
|
||||
## [0.1.2] - 2025-06-15
|
||||
|
||||
### Added
|
||||
- Кнопки сброса настроек и очистки кэша
|
||||
- Бейдж PortProton
|
||||
- Зависимость от `xdg-utils`
|
||||
- Интеграция статуса WeAntiCheatYet в карточку
|
||||
- Переключение полноэкршанного режима через F11 или кнопку Select на геймпаде
|
||||
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде
|
||||
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде
|
||||
- Закрытие окна приложения по комбинации клавиш Ctrl+Q
|
||||
- Сохранение и восстановление размера окна при перезапуске
|
||||
- Переключатель полноэкранного режима приложения
|
||||
- Пункт в контекстном меню «Открыть папку игры»
|
||||
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam»
|
||||
- Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного»
|
||||
- Метод сортировки «Сначала избранное»
|
||||
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена)
|
||||
- Поддержка управления геймпадом в `QMenu` и `QComboBox`
|
||||
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме
|
||||
- Оверлей на кнопку Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или переключения между сессиями
|
||||
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
||||
- Пресеты управления для DualShock 4 и DualSense
|
||||
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию выключена)
|
||||
- Переводы пунктов настроек
|
||||
- Кнопки сброса настроек и очистки кэша.
|
||||
- Бейдж PortProton.
|
||||
- Зависимость от `xdg-utils`.
|
||||
- Интеграция статуса WeAntiCheatYet в карточку.
|
||||
- Переключение полноэкранного режима через F11 или кнопку Select на геймпаде.
|
||||
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде.
|
||||
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде.
|
||||
- Закрытие приложения комбинацией клавиш Ctrl+Q.
|
||||
- Сохранение и восстановление размера окна при перезапуске.
|
||||
- Переключатель полноэкранного режима приложения.
|
||||
- Пункт в контекстном меню «Открыть папку игры».
|
||||
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam».
|
||||
- Пункты в контекстном меню «Добавить в избранное» и «Удалить из избранного».
|
||||
- Метод сортировки «Сначала избранное».
|
||||
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена).
|
||||
- Поддержка управления геймпадом в `QMenu` и `QComboBox`.
|
||||
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме.
|
||||
- Оверлей на кнопку Insert или Xbox/PS-кнопку на геймпаде для закрытия приложения, выключения, перезагрузки, перехода в спящий режим или переключения между сессиями.
|
||||
- [Gamescope-сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt).
|
||||
- Пресеты управления для DualShock 4 и DualSense.
|
||||
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию отключена).
|
||||
- Переводы пунктов настроек.
|
||||
|
||||
### Changed
|
||||
- Обновлены все иконки
|
||||
- Переименована функция `_get_steam_home` в `get_steam_home`
|
||||
- Переименован `steam_game` в `game_source`
|
||||
- Логика контекстного меню вынесена в `ContextMenuManager`
|
||||
- Бейдж Steam теперь открывает Steam Community
|
||||
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
|
||||
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна
|
||||
- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке
|
||||
- Установлена ширина бейджа в две трети ширины карточки
|
||||
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites`
|
||||
- Карточки теперь фокусируются в направлении движения стрелок или D-pad:
|
||||
- Поддерживается удержание D-pad для непрерывного переключения карточек
|
||||
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности
|
||||
- D-pad больше не переключает вкладки (только кнопки RB/LB)
|
||||
- Кнопка добавления игры больше не фокусируется
|
||||
- Диалог добавления игры теперь открывается только в библиотеке
|
||||
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt
|
||||
- Размер карточек теперь меняется только при отпускании слайдера
|
||||
- Слайдер теперь управляется через тригеры на геймпаде
|
||||
- Диалог добавления игры теперь открывается на X, а не на Y
|
||||
- Обновлены все иконки.
|
||||
- Функция `_get_steam_home` переименована в `get_steam_home`.
|
||||
- `steam_game` переименован в `game_source`.
|
||||
- Логика контекстного меню вынесена в `ContextMenuManager`.
|
||||
- Бейдж Steam теперь открывает Steam Community.
|
||||
- Лицензия изменена с MIT на GPL-3.0 для совместимости с кодом legendary.
|
||||
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна.
|
||||
- Бейджи с карточек теперь отображаются и на странице с деталями, а не только в библиотеке.
|
||||
- Установлена ширина бейджа в 2/3 ширины карточки.
|
||||
- Бейджи источников (`Steam`, `EGS`, `PortProton`) отображаются только при активном фильтре `all` или `favorites`.
|
||||
- Карточки теперь фокусируются в направлении движения стрелок или D-pad.
|
||||
- Поддерживается удержание D-pad для непрерывного переключения карточек.
|
||||
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности.
|
||||
- D-pad больше не переключает вкладки (только кнопки RB/LB).
|
||||
- Кнопка добавления игры больше не получает фокус.
|
||||
- Диалог добавления игры открывается только в библиотеке.
|
||||
- Все упоминания PortProtonQT заменены на PortProtonQt.
|
||||
- Размер карточек меняется только при отпускании слайдера.
|
||||
- Слайдер теперь управляется триггерами на геймпаде.
|
||||
- Диалог добавления игры теперь открывается на X, а не на Y.
|
||||
|
||||
### Fixed
|
||||
- Возврат к теме «standard» при выборе несуществующей темы
|
||||
- Корректное открытие контекстного меню
|
||||
- Запуск приложения при отсутствии `exiftool`
|
||||
- Предотвращено бесконечное обращение к `get_portproton_location`
|
||||
- Обновлены ссылки на документацию в README
|
||||
- Устранён traceback при отсутствии обложек (placeholder)
|
||||
- Устранены утечки памяти при загрузке обложек
|
||||
- Исправлены ошибки при подключении геймпада
|
||||
- Предотвращено многократное открытие диалога добавления игры через геймпад
|
||||
- Корректная обработка событий геймпада во время игры
|
||||
- Убийсво всех процессов "зомби" при закрытии программы
|
||||
- Возврат к теме «standard» при выборе несуществующей темы.
|
||||
- Корректное открытие контекстного меню.
|
||||
- Запуск приложения при отсутствии `exiftool`.
|
||||
- Предотвращено бесконечное обращение к `get_portproton_location`.
|
||||
- Обновлены ссылки на документацию в README.
|
||||
- Исправлено падение при отсутствии обложек (placeholder).
|
||||
- Устранены утечки памяти при загрузке обложек.
|
||||
- Исправлены ошибки при подключении геймпада.
|
||||
- Предотвращено многократное открытие диалога добавления игры через геймпад.
|
||||
- Корректная обработка событий геймпада во время игры.
|
||||
- Убийство всех процессов-зомби при закрытии программы.
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
@@ -129,20 +293,20 @@
|
||||
## [0.1.1] – 2025-05-17
|
||||
|
||||
### Added
|
||||
- Алфавитная сортировка библиотеки
|
||||
- Проверка переводов через yaspeller
|
||||
- Сборка Fedora-пакета
|
||||
- Сборка AppImage
|
||||
- Алфавитная сортировка библиотеки.
|
||||
- Проверка переводов через yaspeller.
|
||||
- Сборка Fedora-пакета.
|
||||
- Сборка AppImage.
|
||||
|
||||
### Changed
|
||||
- Удалён жёстко заданный размер окна
|
||||
- Использован `icoextract` как Python-модуль
|
||||
- Удалён жёстко заданный размер окна.
|
||||
- Использован `icoextract` как Python-модуль.
|
||||
|
||||
### Fixed
|
||||
- Скрытие статус-бара
|
||||
- Чтение списка Steam-игр
|
||||
- Зависание GUI
|
||||
- Сбой при повреждённом Steam
|
||||
- Скрытие статус-бара.
|
||||
- Чтение списка Steam-игр.
|
||||
- Зависание GUI.
|
||||
- Сбой при повреждённом Steam.
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
|
||||
13
LICENSE
@@ -73,6 +73,19 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
===============================
|
||||
= HowLongToBeat-Python-API : =
|
||||
===============================
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 JaeguKim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
==============
|
||||
= legendary: =
|
||||
==============
|
||||
|
||||
11
README.md
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
|
||||
</div>
|
||||
@@ -51,11 +51,10 @@ pre-commit run --all-files
|
||||
|
||||
PortProtonQt использует код и зависимости от следующих проектов:
|
||||
|
||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html).
|
||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://opensource.org/licenses/MIT).
|
||||
- [PortProton 2.0](https://git.linux-gaming.ru/CastroFidel/PortProton_2.0) — библиотека для взаимодействия с PortProton, лицензия [MIT](https://opensource.org/licenses/MIT).
|
||||
|
||||
Полный текст лицензий см. в файлах [LICENSE](LICENSE), [LICENSE-icoextract](documentation/licenses/icoextract), [LICENSE-portproton](documentation/licenses/portproton), [LICENSE-legendary](documentation/licenses/legendary).
|
||||
- [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).
|
||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
|
||||
|
||||
> [!WARNING]
|
||||
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
|
||||
|
||||
20
TODO.md
@@ -1,6 +1,6 @@
|
||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
|
||||
- [X] Добавить возможность управления с геймпада
|
||||
- [ ] Добавить возможность управления с тачскрина
|
||||
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
|
||||
- [X] Добавить возможность управления с мыши и клавиатуры
|
||||
- [X] Добавить систему тем [Документация](documentation/theme_guide)
|
||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
|
||||
@@ -11,19 +11,18 @@
|
||||
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
|
||||
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
|
||||
- [X] Получать описания и названия игр из базы данных Steam
|
||||
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
|
||||
- [X] Получать обложки для игр из CDN Steam
|
||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
|
||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
|
||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
||||
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
||||
- [X] Избавиться от вызовов yad
|
||||
- [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0))
|
||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
|
||||
- [X] Добавить экранную клавиатуру в поиск
|
||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
||||
- [X] Добавить индикацию запуска приложения
|
||||
- [X] Достигнуть паритета функциональности с Ingame
|
||||
- [ ] Достигнуть паритета функциональности с PortProton
|
||||
- [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов)
|
||||
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
|
||||
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
|
||||
- [X] Добавить переводы в переопределения
|
||||
@@ -42,6 +41,7 @@
|
||||
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
||||
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
|
||||
- [X] Добавить поддержку версий Steam для Flatpak и Snap
|
||||
- [X] Реализовать добавление игры как сторонней в Steam без перезапуска
|
||||
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
|
||||
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
|
||||
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
|
||||
@@ -49,7 +49,7 @@
|
||||
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
|
||||
- [X] Добавить систему избранного для карточек
|
||||
- [X] Заменить все `print` на `logging`
|
||||
- [ ] Привести все логи к единому языку
|
||||
- [X] Привести все логи к единому языку
|
||||
- [X] Уменьшить количество подстановок в переводах
|
||||
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
|
||||
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
|
||||
@@ -57,13 +57,11 @@
|
||||
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
|
||||
- [ ] Добавить поддержку GOG (?)
|
||||
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
||||
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?)
|
||||
- [X] Добавить данные с HowLongToBeat на страницу с деталями игры
|
||||
- [X] Добавить виброотдачу на геймпаде при запуске игры
|
||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||
- [ ] Доделать светлую тему
|
||||
- [ ] Добавить подсказки к управлению с геймпада
|
||||
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
|
||||
- [X] Добавить подсказки к управлению с геймпада
|
||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
||||
|
||||
@@ -1,42 +1,52 @@
|
||||
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) чистим от ненужных модулей и бинарников
|
||||
- cp -r lib AppDir/usr
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*}
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
|
||||
- shopt -s extglob
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*)
|
||||
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Network*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
|
||||
AppDir:
|
||||
path: ./AppDir
|
||||
|
||||
after_bundle:
|
||||
- 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
|
||||
- rm -rf $TARGET_APPDIR/usr/share/info || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/help || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/gtk-doc || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/devhelp || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/examples || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/pkgconfig || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/bash-completion || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/pixmaps || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/mime || true
|
||||
- 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
|
||||
- "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.3
|
||||
version: 0.1.9
|
||||
exec: usr/bin/python3
|
||||
exec_args: "-m portprotonqt.app $@"
|
||||
|
||||
apt:
|
||||
arch: amd64
|
||||
sources:
|
||||
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
|
||||
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
|
||||
|
||||
include:
|
||||
- python3
|
||||
- python3-minimal
|
||||
- python3-pkg-resources
|
||||
- libopengl0
|
||||
- libk5crypto3
|
||||
@@ -45,13 +55,26 @@ AppDir:
|
||||
- libxcb-cursor0
|
||||
- libimage-exiftool-perl
|
||||
- xdg-utils
|
||||
exclude: []
|
||||
|
||||
- cabextract
|
||||
- curl
|
||||
- 7zip
|
||||
- unzip
|
||||
- unrar
|
||||
exclude:
|
||||
- "*-doc"
|
||||
- "*-man"
|
||||
- manpages
|
||||
- mandb
|
||||
- "*-dev"
|
||||
- "*-static"
|
||||
- "*-dbg"
|
||||
- "*-dbgsym"
|
||||
runtime:
|
||||
env:
|
||||
PYTHONHOME: '${APPDIR}/usr'
|
||||
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
|
||||
|
||||
PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
|
||||
AppImage:
|
||||
sign-key: None
|
||||
arch: x86_64
|
||||
comp: zstd
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
pkgname=portprotonqt
|
||||
pkgver=0.1.3
|
||||
pkgver=0.1.9
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||
arch=('any')
|
||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils')
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'python-rapidfuzz' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
||||
sha256sums=('SKIP')
|
||||
@@ -20,4 +20,5 @@ package() {
|
||||
cd "$srcdir/PortProtonQt"
|
||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||
cp -r build-aux/share "$pkgdir/usr/"
|
||||
cp -r build-aux/lib "$pkgdir/usr/"
|
||||
}
|
||||
|
||||
@@ -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-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'python-rapidfuzz' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
||||
sha256sums=('SKIP')
|
||||
@@ -25,4 +25,5 @@ package() {
|
||||
cd "$srcdir/PortProtonQt"
|
||||
python -m installer --destdir="$pkgdir" dist/*.whl
|
||||
cp -r build-aux/share "$pkgdir/usr/"
|
||||
cp -r build-aux/lib "$pkgdir/usr/"
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ BuildRequires: python3-build
|
||||
BuildRequires: pyproject-rpm-macros
|
||||
BuildRequires: python3dist(setuptools)
|
||||
BuildRequires: git
|
||||
BuildRequires: systemd-rpm-macros
|
||||
|
||||
%description
|
||||
%{summary}
|
||||
@@ -33,6 +34,7 @@ Requires: python3-babel
|
||||
Requires: python3-evdev
|
||||
Requires: python3-icoextract
|
||||
Requires: python3-numpy
|
||||
Requires: python3-websocket-client
|
||||
Requires: python3-orjson
|
||||
Requires: python3-psutil
|
||||
Requires: python3-pyside6
|
||||
@@ -42,8 +44,15 @@ Requires: python3-tqdm
|
||||
Requires: python3-vdf
|
||||
Requires: python3-pefile
|
||||
Requires: python3-pillow
|
||||
Requires: python3-beautifulsoup4
|
||||
Requires: python3-rapidfuzz
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: xdg-utils
|
||||
Requires: 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.
|
||||
@@ -62,11 +71,13 @@ cd %{oname}
|
||||
%pyproject_install
|
||||
%pyproject_save_files %{pypi_name}
|
||||
cp -r build-aux/share %{buildroot}/usr/
|
||||
cp -r build-aux/lib %{buildroot}/usr/
|
||||
|
||||
%files -n python3-%{pypi_name}-git -f %{pyproject_files}
|
||||
%{_bindir}/%{pypi_name}
|
||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
|
||||
%{_udevrulesdir}/60-portprotonqt.rules
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
%{bash_completions_dir}/portprotonqt
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 0.1.3
|
||||
%global pypi_version 0.1.9
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
@@ -19,6 +19,7 @@ BuildRequires: python3-build
|
||||
BuildRequires: pyproject-rpm-macros
|
||||
BuildRequires: python3dist(setuptools)
|
||||
BuildRequires: git
|
||||
BuildRequires: systemd-rpm-macros
|
||||
|
||||
%description
|
||||
%{summary}
|
||||
@@ -30,6 +31,7 @@ Requires: python3-babel
|
||||
Requires: python3-evdev
|
||||
Requires: python3-icoextract
|
||||
Requires: python3-numpy
|
||||
Requires: python3-websocket-client
|
||||
Requires: python3-orjson
|
||||
Requires: python3-psutil
|
||||
Requires: python3-pyside6
|
||||
@@ -39,8 +41,15 @@ Requires: python3-tqdm
|
||||
Requires: python3-vdf
|
||||
Requires: python3-pefile
|
||||
Requires: python3-pillow
|
||||
Requires: python3-beautifulsoup4
|
||||
Requires: python3-rapidfuzz
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: xdg-utils
|
||||
Requires: 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.
|
||||
@@ -61,11 +70,13 @@ cd %{oname}
|
||||
%pyproject_install
|
||||
%pyproject_save_files %{pypi_name}
|
||||
cp -r build-aux/share %{buildroot}/usr/
|
||||
cp -r build-aux/lib %{buildroot}/usr/
|
||||
|
||||
%files -n python3-%{pypi_name} -f %{pyproject_files}
|
||||
%{_bindir}/%{pypi_name}
|
||||
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
|
||||
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
|
||||
%{_udevrulesdir}/60-portprotonqt.rules
|
||||
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
|
||||
%{bash_completions_dir}/portprotonqt
|
||||
|
||||
|
||||
1
build-aux/lib/udev/rules.d/60-portprotonqt.rules
Normal file
@@ -0,0 +1 @@
|
||||
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess"
|
||||
@@ -1,19 +1,30 @@
|
||||
_portprotonqt() {
|
||||
local cur prev
|
||||
_init_completion || return
|
||||
_portprotonqt_completions() {
|
||||
local cur prev opts
|
||||
COMPREPLY=()
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
case $prev in
|
||||
--help|-h)
|
||||
return
|
||||
# Available options
|
||||
opts="--fullscreen --debug-level --help -h"
|
||||
|
||||
# Debug level choices
|
||||
debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
|
||||
case "${prev}" in
|
||||
--debug-level)
|
||||
# Complete debug levels
|
||||
COMPREPLY=( $(compgen -W "${debug_levels}" -- ${cur}) )
|
||||
return 0
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$cur" == -* ]]; then
|
||||
COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) )
|
||||
# Complete options
|
||||
if [[ ${cur} == -* ]]; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
complete -F _portprotonqt portprotonqt
|
||||
complete -F _portprotonqt_completions portprotonqt
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
"endpoint": "https://git.linux-gaming.ru/api/v1",
|
||||
"gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
|
||||
"platform": "gitea",
|
||||
"onboardingConfigFileName": "renovate.json",
|
||||
"autodiscover": true,
|
||||
"optimizeForDisabled": true,
|
||||
};
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "watch_dogs 2",
|
||||
"status": "Broken"
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "zero hour",
|
||||
@@ -765,7 +765,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "lost ark",
|
||||
"status": "Broken"
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "archeage unchained",
|
||||
@@ -1021,7 +1021,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "farlight 84",
|
||||
"status": "Supported"
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "riders republic",
|
||||
@@ -1373,7 +1373,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "arena breakout infinite",
|
||||
"status": "Broken"
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "pixel gun 3d pc",
|
||||
@@ -1436,8 +1436,8 @@
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "blue protocol",
|
||||
"status": "Broken"
|
||||
"normalized_name": "blue protocol star resonance",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dark and darker",
|
||||
@@ -1777,7 +1777,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "supervive",
|
||||
"status": "Denied"
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "splitgate 2",
|
||||
@@ -4316,7 +4316,7 @@
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "solo leveling arise",
|
||||
"normalized_name": "solo leveling arise overdrive",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
@@ -4426,5 +4426,121 @@
|
||||
{
|
||||
"normalized_name": "carx street",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "warcos 2",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "karos classic",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dead island riptide",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "lineage",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "day of dragons",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "sonic rumble",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "black stigma",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "umamusume pretty derby",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dirt rally",
|
||||
"status": "Supported"
|
||||
},
|
||||
{
|
||||
"normalized_name": "minifighter",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "hide & hold out h2o",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "battlefield 6",
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "ghost of tsushima director's cut",
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "sword of justice",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "blade & soul neo",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "the finals (cn)",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "tom clancy's rainbow six siege x",
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dragonheir silent gods",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "the quinfall",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "redmatch 2",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "blade & soul heroes",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "blue archive",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "midnight murder club",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dungeon done",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "project wraith",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "freedom wars",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "open fortress",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "no more room in hell 2",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "call of duty black ops 7",
|
||||
"status": "Denied"
|
||||
}
|
||||
]
|
||||
59328
data/games_appid.json
@@ -1,12 +1,416 @@
|
||||
[
|
||||
{
|
||||
"normalized_title": "return alive",
|
||||
"slug": "return-alive"
|
||||
"normalized_title": "metal gear solid v the phantom pain",
|
||||
"slug": "metal-gear-solid-v-the-phantom-pain"
|
||||
},
|
||||
{
|
||||
"normalized_title": "battlefield bad company 2",
|
||||
"slug": "battlefield-bad-company-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty black ops",
|
||||
"slug": "call-of-duty-black-ops"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty modern warfare 2 (2009)",
|
||||
"slug": "call-of-duty-modern-warfare-2-2009"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty black ops cold war",
|
||||
"slug": "call-of-duty-black-ops-cold-war"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty infinite warfare",
|
||||
"slug": "call-of-duty-infinite-warfare"
|
||||
},
|
||||
{
|
||||
"normalized_title": "lost planet 2",
|
||||
"slug": "lost-planet-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "lost planet extreme condition colonies",
|
||||
"slug": "lost-planet-extreme-condition-colonies-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "starcraft",
|
||||
"slug": "starcraft-remastered"
|
||||
},
|
||||
{
|
||||
"normalized_title": "the entropy centre",
|
||||
"slug": "the-entropy-centre"
|
||||
},
|
||||
{
|
||||
"normalized_title": "metal gear solid v ground zeroes",
|
||||
"slug": "metal-gear-solid-v-ground-zeroes"
|
||||
},
|
||||
{
|
||||
"normalized_title": "escape from tarkov",
|
||||
"slug": "escape-from-tarkov"
|
||||
},
|
||||
{
|
||||
"normalized_title": "command & conquer generals",
|
||||
"slug": "command-conquer-generals"
|
||||
},
|
||||
{
|
||||
"normalized_title": "command & conquer generals zero hour",
|
||||
"slug": "command-conquer-generals-zero-hour"
|
||||
},
|
||||
{
|
||||
"normalized_title": "absolum",
|
||||
"slug": "absolum"
|
||||
},
|
||||
{
|
||||
"normalized_title": "tom clancy's splinter cell chaos theory",
|
||||
"slug": "tom-clancys-splinter-cell-chaos-theory"
|
||||
},
|
||||
{
|
||||
"normalized_title": "winter burrow",
|
||||
"slug": "winter-burrow"
|
||||
},
|
||||
{
|
||||
"normalized_title": "forager",
|
||||
"slug": "forager"
|
||||
},
|
||||
{
|
||||
"normalized_title": "wall world",
|
||||
"slug": "wall-world"
|
||||
},
|
||||
{
|
||||
"normalized_title": "grand theft auto iv the",
|
||||
"slug": "grand-theft-auto-iv-the-complete-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "voidtrain",
|
||||
"slug": "voidtrain"
|
||||
},
|
||||
{
|
||||
"normalized_title": "jdm japanese drift master",
|
||||
"slug": "jdm-japanese-drift-master"
|
||||
},
|
||||
{
|
||||
"normalized_title": "lego harry potter collection",
|
||||
"slug": "lego-harry-potter-collection"
|
||||
},
|
||||
{
|
||||
"normalized_title": "life is strange season",
|
||||
"slug": "life-is-strange-complete-season"
|
||||
},
|
||||
{
|
||||
"normalized_title": "земский собор [демо]",
|
||||
"slug": "zemskij-sobor-demo"
|
||||
},
|
||||
{
|
||||
"normalized_title": "syberia",
|
||||
"slug": "syberia-remastered"
|
||||
},
|
||||
{
|
||||
"normalized_title": "europa universalis v",
|
||||
"slug": "europa-universalis-v"
|
||||
},
|
||||
{
|
||||
"normalized_title": "no i'm not a human",
|
||||
"slug": "no-im-not-a-human"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dispatch digital deluxe",
|
||||
"slug": "dispatch-digital-deluxe-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "cossacks 3 digital deluxe",
|
||||
"slug": "cossacks-3-digital-deluxe"
|
||||
},
|
||||
{
|
||||
"normalized_title": "battlefield 2",
|
||||
"slug": "battlefield-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "split/second",
|
||||
"slug": "split-second"
|
||||
},
|
||||
{
|
||||
"normalized_title": "warzone 2100",
|
||||
"slug": "warzone-2100"
|
||||
},
|
||||
{
|
||||
"normalized_title": "foundation",
|
||||
"slug": "foundation"
|
||||
},
|
||||
{
|
||||
"normalized_title": "crusader kings 3",
|
||||
"slug": "crusader-kings-3"
|
||||
},
|
||||
{
|
||||
"normalized_title": "nadir a grimdark deck builder",
|
||||
"slug": "nadir-a-grimdark-deck-builder"
|
||||
},
|
||||
{
|
||||
"normalized_title": "oriental empires",
|
||||
"slug": "oriental-empires"
|
||||
},
|
||||
{
|
||||
"normalized_title": "vampire the masquerade bloodlines 2",
|
||||
"slug": "vampire-the-masquerade-bloodlines-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "escape from duckov",
|
||||
"slug": "escape-from-duckov"
|
||||
},
|
||||
{
|
||||
"normalized_title": "xiii",
|
||||
"slug": "xiii"
|
||||
},
|
||||
{
|
||||
"normalized_title": "saints row 2",
|
||||
"slug": "saints-row-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "frozenheim",
|
||||
"slug": "frozenheim"
|
||||
},
|
||||
{
|
||||
"normalized_title": "saints row (2022)",
|
||||
"slug": "saints-row-2022"
|
||||
},
|
||||
{
|
||||
"normalized_title": "iron harvest",
|
||||
"slug": "iron-harvest"
|
||||
},
|
||||
{
|
||||
"normalized_title": "tom clancy's splinter cell blacklist",
|
||||
"slug": "tom-clancys-splinter-cell-blacklist"
|
||||
},
|
||||
{
|
||||
"normalized_title": "painkiller overdose",
|
||||
"slug": "painkiller-overdose"
|
||||
},
|
||||
{
|
||||
"normalized_title": "ancestors legacy",
|
||||
"slug": "ancestors-legacy"
|
||||
},
|
||||
{
|
||||
"normalized_title": "bye sweet carole",
|
||||
"slug": "bye-sweet-carole"
|
||||
},
|
||||
{
|
||||
"normalized_title": "painkiller black",
|
||||
"slug": "painkiller-black-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "hogwarts legacy",
|
||||
"slug": "hogwarts-legacy"
|
||||
},
|
||||
{
|
||||
"normalized_title": "active matter",
|
||||
"slug": "active-matter"
|
||||
},
|
||||
{
|
||||
"normalized_title": "tom clancy's splinter cell",
|
||||
"slug": "tom-clancys-splinter-cell"
|
||||
},
|
||||
{
|
||||
"normalized_title": "sniper ghost warrior",
|
||||
"slug": "sniper-ghost-warrior"
|
||||
},
|
||||
{
|
||||
"normalized_title": "fate undiscovered realms",
|
||||
"slug": "fate-undiscovered-realms"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dying light the beast deluxe",
|
||||
"slug": "dying-light-the-beast-deluxe-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "spellforce platinum",
|
||||
"slug": "spellforce-platinum-edition"
|
||||
},
|
||||
{
|
||||
"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 director’s 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"
|
||||
},
|
||||
{
|
||||
"normalized_title": "anno 2205",
|
||||
"slug": "anno-2205"
|
||||
},
|
||||
{
|
||||
"normalized_title": "anno 2070",
|
||||
"slug": "anno-2070"
|
||||
},
|
||||
{
|
||||
"normalized_title": "kompas 3d v23 / компас 3d v23",
|
||||
"slug": "kompas-3d-v23-kompas-3d-v23"
|
||||
},
|
||||
{
|
||||
"normalized_title": "ultrakill (early access)",
|
||||
"slug": "ultrakill-early-access"
|
||||
},
|
||||
{
|
||||
"normalized_title": "vintage story",
|
||||
"slug": "vintage-story"
|
||||
},
|
||||
{
|
||||
"normalized_title": "disco elysium the finul cut",
|
||||
"slug": "disco-elysium-the-finul-cut"
|
||||
},
|
||||
{
|
||||
"normalized_title": "warcraft iii reign of chaos",
|
||||
"slug": "warcraft-iii-reign-of-chaos"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dying light",
|
||||
"slug": "dying-light"
|
||||
},
|
||||
{
|
||||
"normalized_title": "лихо одноглазое",
|
||||
"slug": "liho-odnoglazoe"
|
||||
},
|
||||
{
|
||||
"normalized_title": "indika",
|
||||
"slug": "indika"
|
||||
},
|
||||
{
|
||||
"normalized_title": "no sleep for kaname date from ai the somnium files",
|
||||
"slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dead island 2",
|
||||
"slug": "dead-island-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dead island",
|
||||
"slug": "dead-island-definitive-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "wuchang fallen feathers",
|
||||
"slug": "wuchang-fallen-feathers"
|
||||
},
|
||||
{
|
||||
"normalized_title": "mindseye",
|
||||
"slug": "mindseye"
|
||||
},
|
||||
{
|
||||
"normalized_title": "alan wake",
|
||||
"slug": "alan-wake"
|
||||
},
|
||||
{
|
||||
"normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a",
|
||||
"slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a"
|
||||
},
|
||||
{
|
||||
"normalized_title": "fifa 18",
|
||||
"slug": "fifa-18"
|
||||
},
|
||||
{
|
||||
"normalized_title": "eriksholm the stolen dream",
|
||||
"slug": "eriksholm-the-stolen-dream"
|
||||
},
|
||||
{
|
||||
"normalized_title": "caravan sandwitch",
|
||||
"slug": "caravan-sandwitch"
|
||||
},
|
||||
{
|
||||
"normalized_title": "expeditions a mudrunner game",
|
||||
"slug": "expeditions-a-mudrunner-game"
|
||||
},
|
||||
{
|
||||
"normalized_title": "#drive rally",
|
||||
"slug": "drive-rally"
|
||||
},
|
||||
{
|
||||
"normalized_title": "return alive",
|
||||
"slug": "return-alive"
|
||||
},
|
||||
{
|
||||
"normalized_title": "recore",
|
||||
"slug": "recore-definitive-edition"
|
||||
@@ -91,10 +495,6 @@
|
||||
"normalized_title": "steins;gate the distant valhalla",
|
||||
"slug": "steins-gate-the-distant-valhalla"
|
||||
},
|
||||
{
|
||||
"normalized_title": "hogwarts legacy",
|
||||
"slug": "hogwarts-legacy"
|
||||
},
|
||||
{
|
||||
"normalized_title": "osu!",
|
||||
"slug": "osu"
|
||||
@@ -107,10 +507,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"
|
||||
@@ -191,10 +587,6 @@
|
||||
"normalized_title": "cardlife creative survival",
|
||||
"slug": "cardlife-creative-survival"
|
||||
},
|
||||
{
|
||||
"normalized_title": "kompas 3d v23 / компас 3d v23",
|
||||
"slug": "kompas-3d-v23-kompas-3d-v23"
|
||||
},
|
||||
{
|
||||
"normalized_title": "kompas 3d v24 / компас 3d v24 beta",
|
||||
"slug": "kompas-3d-v24-kompas-3d-v24-beta"
|
||||
@@ -1139,10 +1531,6 @@
|
||||
"normalized_title": "world of sea battle",
|
||||
"slug": "world-of-sea-battle"
|
||||
},
|
||||
{
|
||||
"normalized_title": "escape from tarkov",
|
||||
"slug": "escape-from-tarkov"
|
||||
},
|
||||
{
|
||||
"normalized_title": "bayonetta",
|
||||
"slug": "bayonetta"
|
||||
@@ -1267,10 +1655,6 @@
|
||||
"normalized_title": "call of duty 2",
|
||||
"slug": "call-of-duty-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty infinite warfare",
|
||||
"slug": "call-of-duty-infinite-warfare"
|
||||
},
|
||||
{
|
||||
"normalized_title": "call of duty world at war",
|
||||
"slug": "call-of-duty-world-at-war"
|
||||
@@ -1463,10 +1847,6 @@
|
||||
"normalized_title": "elden ring",
|
||||
"slug": "elden-ring"
|
||||
},
|
||||
{
|
||||
"normalized_title": "starcraft",
|
||||
"slug": "starcraft-remastered"
|
||||
},
|
||||
{
|
||||
"normalized_title": "cataclismo",
|
||||
"slug": "cataclismo"
|
||||
|
||||
@@ -17,4 +17,36 @@ Generated-By:
|
||||
start.sh
|
||||
EGS
|
||||
Stop Game
|
||||
Fullscreen
|
||||
Fulscreen
|
||||
\t
|
||||
Горячая
|
||||
vkbasalt
|
||||
dgVoodoo2
|
||||
Zink
|
||||
Vulkan
|
||||
VKD3D
|
||||
DirectX12
|
||||
Prev Dir
|
||||
Forced
|
||||
GOverlay
|
||||
Glide
|
||||
all
|
||||
futex
|
||||
DLSS
|
||||
fullscreen
|
||||
ProtonGE
|
||||
window
|
||||
compositing
|
||||
Zink
|
||||
Use
|
||||
bundled
|
||||
dxvk
|
||||
older games
|
||||
versions
|
||||
DLL Overrides
|
||||
COMP
|
||||
VKD3D
|
||||
Select needed
|
||||
CPUs
|
||||
cores
|
||||
|
||||
474
dev-scripts/appimage_clean.py
Executable file
@@ -0,0 +1,474 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PySide6 Dependencies Analyzer with ldd support
|
||||
Анализирует зависимости PySide6 модулей используя ldd для определения
|
||||
реальных зависимостей скомпилированных библиотек.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict, List
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
class PySide6DependencyAnalyzer:
|
||||
def __init__(self, project_root: Path = None):
|
||||
# Системные библиотеки, которые нужно всегда оставлять
|
||||
self.system_libs = {
|
||||
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
|
||||
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus',
|
||||
'libQt6Svg'
|
||||
}
|
||||
|
||||
self.critical_modules = {
|
||||
'QtSvg',
|
||||
}
|
||||
|
||||
self.real_dependencies = {}
|
||||
self.used_modules_code = set()
|
||||
self.used_modules_ldd = set()
|
||||
self.all_required_modules = set()
|
||||
# Определяем корень проекта
|
||||
if project_root is None:
|
||||
# Корень проекта - две директории выше от скрипта
|
||||
self.project_root = Path(__file__).parent.parent
|
||||
else:
|
||||
self.project_root = project_root
|
||||
|
||||
self.venv_path = self.project_root / ".venv"
|
||||
self.build_path = self.project_root / "build-aux"
|
||||
|
||||
def find_python_files(self, directory: Path) -> List[Path]:
|
||||
"""Находит все Python файлы в директории"""
|
||||
python_files = []
|
||||
for root, dirs, files in os.walk(directory):
|
||||
dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git'}]
|
||||
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
python_files.append(Path(root) / file)
|
||||
return python_files
|
||||
|
||||
def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]:
|
||||
"""Находит все PySide6 библиотеки (.so файлы)"""
|
||||
libs = {}
|
||||
|
||||
# Ищем venv в корне проекта
|
||||
venv_candidates = [
|
||||
self.venv_path, # .venv
|
||||
self.project_root / "venv",
|
||||
self.project_root / ".virtualenv",
|
||||
]
|
||||
|
||||
pyside6_path = None
|
||||
|
||||
# Пробуем найти PySide6 в venv
|
||||
for venv in venv_candidates:
|
||||
if venv.exists():
|
||||
# Ищем Python версию
|
||||
lib_path = venv / "lib"
|
||||
if lib_path.exists():
|
||||
for python_dir in lib_path.iterdir():
|
||||
if python_dir.name.startswith('python'):
|
||||
candidate = python_dir / "site-packages" / "PySide6"
|
||||
if candidate.exists():
|
||||
pyside6_path = candidate
|
||||
print(f"Найден PySide6 в: {candidate}")
|
||||
break
|
||||
if pyside6_path:
|
||||
break
|
||||
|
||||
if not pyside6_path:
|
||||
print(f"Предупреждение: PySide6 не найден в venv, проверяем AppDir...")
|
||||
# Если не нашли в venv, пробуем в AppDir
|
||||
if base_path:
|
||||
appdir_candidate = base_path / "AppDir/usr/local/lib"
|
||||
if appdir_candidate.exists():
|
||||
for python_dir in appdir_candidate.iterdir():
|
||||
if python_dir.name.startswith('python'):
|
||||
candidate = python_dir / "dist-packages" / "PySide6"
|
||||
if candidate.exists():
|
||||
pyside6_path = candidate
|
||||
print(f"Найден PySide6 в AppDir: {candidate}")
|
||||
break
|
||||
|
||||
if not pyside6_path:
|
||||
return libs
|
||||
|
||||
# Ищем .so файлы модулей
|
||||
for so_file in pyside6_path.glob("Qt*.*.so"):
|
||||
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
|
||||
if module_name.startswith('Qt'):
|
||||
libs[module_name] = so_file
|
||||
|
||||
# Также ищем в подпапках
|
||||
for subdir in pyside6_path.iterdir():
|
||||
if subdir.is_dir() and subdir.name.startswith('Qt'):
|
||||
for so_file in subdir.glob("*.so*"):
|
||||
if 'Qt' in so_file.name:
|
||||
libs[subdir.name] = so_file
|
||||
break
|
||||
|
||||
return libs
|
||||
|
||||
def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]:
|
||||
"""Анализирует зависимости библиотеки с помощью ldd"""
|
||||
qt_deps = set()
|
||||
|
||||
try:
|
||||
result = subprocess.run(['ldd', str(lib_path)],
|
||||
capture_output=True, text=True, check=True)
|
||||
|
||||
# Парсим вывод ldd и ищем Qt библиотеки
|
||||
for line in result.stdout.split('\n'):
|
||||
# Ищем строки вида: libQt6Core.so.6 => /path/to/lib
|
||||
match = re.search(r'libQt6(\w+)\.so', line)
|
||||
if match:
|
||||
qt_module = f"Qt{match.group(1)}"
|
||||
qt_deps.add(qt_module)
|
||||
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}")
|
||||
|
||||
return qt_deps
|
||||
|
||||
def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]:
|
||||
"""Строит граф зависимостей на основе ldd анализа"""
|
||||
dependencies = {}
|
||||
|
||||
print("Анализ реальных зависимостей с помощью ldd...")
|
||||
for module, lib_path in pyside_libs.items():
|
||||
print(f" Анализируется {module}...")
|
||||
deps = self.analyze_ldd_dependencies(lib_path)
|
||||
dependencies[module] = deps
|
||||
|
||||
if deps:
|
||||
print(f" Зависимости: {', '.join(sorted(deps))}")
|
||||
|
||||
return dependencies
|
||||
|
||||
def analyze_file_imports(self, file_path: Path) -> Set[str]:
|
||||
"""Анализирует один Python файл и возвращает используемые PySide6 модули"""
|
||||
modules = set()
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
tree = ast.parse(content)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith('PySide6.'):
|
||||
module = alias.name.split('.', 2)[1]
|
||||
if module.startswith('Qt'):
|
||||
modules.add(module)
|
||||
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module and node.module.startswith('PySide6.'):
|
||||
module = node.module.split('.', 2)[1]
|
||||
if module.startswith('Qt'):
|
||||
modules.add(module)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при анализе {file_path}: {e}")
|
||||
|
||||
return modules
|
||||
|
||||
def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]:
|
||||
"""Получает все зависимости для набора модулей, используя граф зависимостей из ldd"""
|
||||
all_deps = set(modules)
|
||||
|
||||
if not dependency_graph:
|
||||
return all_deps
|
||||
|
||||
# Повторяем до тех пор, пока не найдем все транзитивные зависимости
|
||||
changed = True
|
||||
iteration = 0
|
||||
while changed and iteration < 10: # Защита от бесконечного цикла
|
||||
changed = False
|
||||
current_deps = set(all_deps)
|
||||
|
||||
for module in current_deps:
|
||||
if module in dependency_graph:
|
||||
new_deps = dependency_graph[module] - all_deps
|
||||
if new_deps:
|
||||
all_deps.update(new_deps)
|
||||
changed = True
|
||||
|
||||
iteration += 1
|
||||
|
||||
return all_deps
|
||||
|
||||
def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict:
|
||||
"""Анализирует весь проект"""
|
||||
python_files = self.find_python_files(project_path)
|
||||
print(f"Найдено {len(python_files)} Python файлов")
|
||||
|
||||
# Анализ статических импортов
|
||||
used_modules_code = set()
|
||||
file_modules = {}
|
||||
|
||||
for file_path in python_files:
|
||||
modules = self.analyze_file_imports(file_path)
|
||||
if modules:
|
||||
file_modules[str(file_path.relative_to(project_path))] = list(modules)
|
||||
used_modules_code.update(modules)
|
||||
|
||||
print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}")
|
||||
|
||||
# Поиск PySide6 библиотек
|
||||
search_base = appdir_path if appdir_path else project_path
|
||||
pyside_libs = self.find_pyside6_libs(search_base)
|
||||
|
||||
if not pyside_libs:
|
||||
print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.")
|
||||
return {
|
||||
'error': 'PySide6 библиотеки не найдены',
|
||||
'analysis_method': 'failed',
|
||||
'found_libraries': 0,
|
||||
'directly_used_code': sorted(used_modules_code),
|
||||
'all_required': [],
|
||||
'removable': [],
|
||||
'available_modules': [],
|
||||
'file_usage': file_modules
|
||||
}
|
||||
|
||||
print(f"Найдено {len(pyside_libs)} PySide6 библиотек")
|
||||
|
||||
# Анализ реальных зависимостей с ldd
|
||||
real_dependencies = self.build_real_dependency_graph(pyside_libs)
|
||||
|
||||
# Определяем модули, которые реально используются через ldd
|
||||
used_modules_ldd = set()
|
||||
for module in used_modules_code:
|
||||
if module in real_dependencies:
|
||||
used_modules_ldd.update(real_dependencies[module])
|
||||
used_modules_ldd.add(module)
|
||||
|
||||
print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}")
|
||||
|
||||
# Объединяем результаты анализа кода и ldd
|
||||
all_used_modules = used_modules_code | used_modules_ldd
|
||||
|
||||
# Получаем все необходимые модули включая зависимости
|
||||
all_required = self.get_all_dependencies(all_used_modules, real_dependencies)
|
||||
|
||||
# Все доступные PySide6 модули
|
||||
available_modules = set(pyside_libs.keys())
|
||||
|
||||
# Модули, которые можно удалить
|
||||
removable = available_modules - all_required
|
||||
|
||||
return {
|
||||
'analysis_method': 'ldd + static analysis',
|
||||
'found_libraries': len(pyside_libs),
|
||||
'directly_used_code': sorted(used_modules_code),
|
||||
'directly_used_ldd': sorted(used_modules_ldd),
|
||||
'all_required': sorted(all_required),
|
||||
'removable': sorted(removable),
|
||||
'available_modules': sorted(available_modules),
|
||||
'file_usage': file_modules,
|
||||
'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()},
|
||||
'library_paths': {k: str(v) for k, v in pyside_libs.items()},
|
||||
'analysis_summary': {
|
||||
'total_modules': len(available_modules),
|
||||
'required_modules': len(all_required),
|
||||
'removable_modules': len(removable),
|
||||
'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%"
|
||||
}
|
||||
}
|
||||
|
||||
def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str:
|
||||
"""Генерирует обновленный AppImage рецепт с командами очистки"""
|
||||
|
||||
# Читаем существующий рецепт
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
recipe_content = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Шаблон рецепта не найден: {template_path}")
|
||||
return ""
|
||||
|
||||
# Генерируем новые команды очистки
|
||||
cleanup_lines = []
|
||||
|
||||
# QML удаляем только если не используется
|
||||
qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'}
|
||||
if qml_modules.issubset(set(removable_modules)):
|
||||
cleanup_lines.append(" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/")
|
||||
|
||||
# Инструменты разработки (всегда удаляем)
|
||||
cleanup_lines.append(" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}")
|
||||
|
||||
# Модули для удаления
|
||||
if removable_modules:
|
||||
removable_filtered = [m for m in removable_modules if m not in self.critical_modules]
|
||||
if removable_filtered:
|
||||
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_filtered)])
|
||||
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
|
||||
|
||||
# Генерируем команду для удаления нативных библиотек с сохранением нужных
|
||||
required_libs = set()
|
||||
for module in sorted(set(self.all_required_modules)):
|
||||
required_libs.add(f"libQt6{module.replace('Qt', '')}*")
|
||||
|
||||
# Добавляем системные библиотеки
|
||||
for lib in self.system_libs:
|
||||
required_libs.add(f"{lib}*")
|
||||
|
||||
keep_pattern = '|'.join(sorted(required_libs))
|
||||
|
||||
cleanup_lines.extend([
|
||||
" - shopt -s extglob",
|
||||
f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
|
||||
])
|
||||
|
||||
import re
|
||||
|
||||
# Ищем весь блок команд очистки PySide6 (от первой rm до AppDir:)
|
||||
# Паттерн: после " - cp -r lib AppDir/usr\n" идут команды rm, а затем "AppDir:"
|
||||
pattern = r'( - cp -r lib AppDir/usr\n)((?: - (?:rm|shopt).*\n)*?)(?=AppDir:)'
|
||||
|
||||
match = re.search(pattern, recipe_content)
|
||||
|
||||
if not match:
|
||||
print("ПРЕДУПРЕЖДЕНИЕ: Не удалось найти блок очистки в рецепте")
|
||||
print("Добавляем команды очистки перед блоком AppDir:")
|
||||
|
||||
# Просто вставим команды перед AppDir:
|
||||
appdir_pos = recipe_content.find('AppDir:')
|
||||
if appdir_pos != -1:
|
||||
new_content = (
|
||||
recipe_content[:appdir_pos] +
|
||||
'\n'.join(cleanup_lines) + '\n' +
|
||||
recipe_content[appdir_pos:]
|
||||
)
|
||||
return new_content
|
||||
else:
|
||||
print("ОШИБКА: Не найден блок AppDir: в рецепте")
|
||||
return ""
|
||||
|
||||
# Создаем замену - группа 1 (cp -r lib) + новые команды очистки
|
||||
replacement = r'\1' + '\n'.join(cleanup_lines) + '\n'
|
||||
|
||||
updated_recipe = re.sub(pattern, replacement, recipe_content, count=1)
|
||||
|
||||
return updated_recipe
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
|
||||
parser.add_argument('project_path', nargs='?', default='.',
|
||||
help='Путь к проекту для анализа (по умолчанию: текущая директория)')
|
||||
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
|
||||
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
|
||||
parser.add_argument('--venv', help='Путь к виртуальному окружению (по умолчанию: .venv в корне проекта)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
project_path = Path(args.project_path).resolve()
|
||||
if not project_path.exists():
|
||||
print(f"Ошибка: путь {project_path} не существует")
|
||||
sys.exit(1)
|
||||
|
||||
appdir_path = Path(args.appdir).resolve() if args.appdir else None
|
||||
if appdir_path and not appdir_path.exists():
|
||||
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
|
||||
appdir_path = None
|
||||
|
||||
# Определяем корень проекта
|
||||
# Если запущен из подпапки проекта, ищем корень
|
||||
project_root = project_path
|
||||
if (project_path / ".git").exists() or (project_path / "pyproject.toml").exists():
|
||||
project_root = project_path
|
||||
else:
|
||||
# Пытаемся найти корень проекта
|
||||
current = project_path
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists() or (current / "pyproject.toml").exists():
|
||||
project_root = current
|
||||
break
|
||||
current = current.parent
|
||||
|
||||
print(f"Корень проекта: {project_root}")
|
||||
|
||||
analyzer = PySide6DependencyAnalyzer(project_root=project_root)
|
||||
|
||||
# Если указан custom venv путь
|
||||
if args.venv:
|
||||
analyzer.venv_path = Path(args.venv).resolve()
|
||||
print(f"Использую указанный venv: {analyzer.venv_path}")
|
||||
|
||||
results = analyzer.analyze_project(project_path, appdir_path)
|
||||
|
||||
# Сохраняем в анализатор для генерации команд
|
||||
analyzer.all_required_modules = set(results.get('all_required', []))
|
||||
|
||||
# Выводим результаты
|
||||
print("\n" + "="*60)
|
||||
print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)")
|
||||
print("="*60)
|
||||
|
||||
if 'error' in results:
|
||||
print(f"\nОШИБКА: {results['error']}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nМетод анализа: {results['analysis_method']}")
|
||||
print(f"Найдено библиотек: {results['found_libraries']}")
|
||||
|
||||
if results['directly_used_code']:
|
||||
print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):")
|
||||
for module in results['directly_used_code']:
|
||||
print(f" • {module}")
|
||||
|
||||
if results['directly_used_ldd']:
|
||||
print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):")
|
||||
for module in results['directly_used_ldd']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nВсе необходимые модули ({len(results['all_required'])}):")
|
||||
for module in results['all_required']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nМодули, которые можно удалить ({len(results['removable'])}):")
|
||||
for module in results['removable']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
|
||||
|
||||
if args.verbose and results['real_dependencies']:
|
||||
print(f"\nРеальные зависимости (ldd):")
|
||||
for module, deps in results['real_dependencies'].items():
|
||||
if deps:
|
||||
print(f" {module} → {', '.join(deps)}")
|
||||
|
||||
# Обновляем AppImage рецепт
|
||||
recipe_path = analyzer.build_path / "AppImageBuilder.yml"
|
||||
if recipe_path.exists():
|
||||
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
|
||||
if updated_recipe:
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_recipe)
|
||||
print(f"\nAppImage рецепт обновлен: {recipe_path}")
|
||||
else:
|
||||
print(f"\nОШИБКА: не удалось обновить рецепт")
|
||||
else:
|
||||
print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}")
|
||||
|
||||
# Сохраняем результаты в JSON
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
print(f"Результаты сохранены в: {args.output}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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.")
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ast
|
||||
|
||||
# Запрещенные свойства
|
||||
# Запрещенные QSS-свойства
|
||||
FORBIDDEN_PROPERTIES = {
|
||||
"box-shadow",
|
||||
"backdrop-filter",
|
||||
@@ -12,15 +13,55 @@ FORBIDDEN_PROPERTIES = {
|
||||
"text-shadow",
|
||||
}
|
||||
|
||||
# Запрещенные модули и функции
|
||||
FORBIDDEN_MODULES = {
|
||||
"os",
|
||||
"subprocess",
|
||||
"shutil",
|
||||
"sys",
|
||||
"socket",
|
||||
"ctypes",
|
||||
"pathlib",
|
||||
"glob",
|
||||
}
|
||||
FORBIDDEN_FUNCTIONS = {
|
||||
"exec",
|
||||
"eval",
|
||||
"open",
|
||||
"__import__",
|
||||
}
|
||||
|
||||
def check_qss_files():
|
||||
has_errors = False
|
||||
for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
|
||||
with open(qss_file, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Проверка на запрещённые QSS-свойства
|
||||
for prop in FORBIDDEN_PROPERTIES:
|
||||
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
|
||||
print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}")
|
||||
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
|
||||
has_errors = True
|
||||
|
||||
# Проверка на опасные импорты и функции
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
# Проверка импортов
|
||||
if isinstance(node, (ast.Import, ast.ImportFrom)):
|
||||
for name in node.names:
|
||||
if name.name in FORBIDDEN_MODULES:
|
||||
print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
|
||||
has_errors = True
|
||||
# Проверка вызовов функций
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
|
||||
print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
|
||||
has_errors = True
|
||||
except SyntaxError as e:
|
||||
print(f"ERROR: Syntax error in file {qss_file}: {e}")
|
||||
has_errors = True
|
||||
|
||||
return has_errors
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
---
|
||||
|
||||
## 📋 Contents
|
||||
- [Overview](#overview)
|
||||
- [Adding a New Translation](#adding-a-new-translation)
|
||||
- [Updating Existing Translations](#updating-existing-translations)
|
||||
- [Compiling Translations](#compiling-translations)
|
||||
- [Overview](#-overview)
|
||||
- [Adding a New Translation](#-adding-a-new-translation)
|
||||
- [Updating Existing Translations](#-updating-existing-translations)
|
||||
- [Compiling Translations](#-compiling-translations)
|
||||
- [Spell Check](#-spell-check)
|
||||
|
||||
---
|
||||
|
||||
@@ -20,9 +21,9 @@ Current translation status:
|
||||
|
||||
| Locale | Progress | Translated |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 194 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 194 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 of 194 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 341 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 341 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 341 of 341 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
---
|
||||
|
||||
## 📋 Содержание
|
||||
- [Обзор](#обзор)
|
||||
- [Добавление нового перевода](#добавление-нового-перевода)
|
||||
- [Обновление существующих переводов](#обновление-существующих-переводов)
|
||||
- [Компиляция переводов](#компиляция-переводов)
|
||||
- [Обзор](#-обзор)
|
||||
- [Добавление нового перевода](#-добавление-нового-перевода)
|
||||
- [Обновление существующих переводов](#-обновление-существующих-переводов)
|
||||
- [Компиляция переводов](#-компиляция-переводов)
|
||||
- [Проверка орфографии](#-проверка-орфографии)
|
||||
|
||||
---
|
||||
|
||||
@@ -20,9 +21,9 @@
|
||||
|
||||
| Локаль | Прогресс | Переведено |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 194 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 194 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 из 194 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 341 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 341 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 341 из 341 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
---
|
||||
|
||||
## 📋 Contents
|
||||
- [Overview](#overview)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Data Priorities](#data-priorities)
|
||||
- [File Structure](#file-structure)
|
||||
- [For Users](#for-users)
|
||||
- [Creating User Overrides](#creating-user-overrides)
|
||||
- [Example](#example)
|
||||
- [For Developers](#for-developers)
|
||||
- [Adding Built-In Overrides](#adding-built-in-overrides)
|
||||
- [Overview](#-overview)
|
||||
- [How It Works](#-how-it-works)
|
||||
- [For Users](#-for-users)
|
||||
- [For Developers](#-for-developers)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,15 +3,10 @@
|
||||
---
|
||||
|
||||
## 📋 Содержание
|
||||
- [Обзор](#обзор)
|
||||
- [Как это работает](#как-это-работает)
|
||||
- [Приоритеты данных](#приоритеты-данных)
|
||||
- [Структура файлов](#структура-файлов)
|
||||
- [Для пользователей](#для-пользователей)
|
||||
- [Создание пользовательских переопределений](#создание-пользовательских-переопределений)
|
||||
- [Пример](#пример)
|
||||
- [Для разработчиков](#для-разработчиков)
|
||||
- [Добавление встроенных переопределений](#добавление-встроенных-переопределений)
|
||||
- [Обзор](#-обзор)
|
||||
- [Как это работает](#-как-это-работает)
|
||||
- [Для пользователей](#-для-пользователей)
|
||||
- [Для разработчиков](#-для-разработчиков)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
---
|
||||
|
||||
## 📋 Contents
|
||||
- [Overview](#overview)
|
||||
- [Creating the Theme Folder](#creating-the-theme-folder)
|
||||
- [Style File](#style-file)
|
||||
- [Metadata](#metadata)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Fonts and Icons](#fonts-and-icons)
|
||||
- [Overview](#-overview)
|
||||
- [Creating the Theme Folder](#-creating-the-theme-folder)
|
||||
- [Style File](#-style-file-stylespy)
|
||||
- [Animation configuration](#-animation-configuration)
|
||||
- [Metadata](#-metadata-metainfoini)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Fonts and Icons](#-fonts-and-icons-optional)
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +46,163 @@ def custom_button_style(color1, color2):
|
||||
|
||||
---
|
||||
|
||||
## 🎥 Animation configuration
|
||||
|
||||
The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game cards:
|
||||
|
||||
```python
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Type of animation when entering or exiting the detail page
|
||||
# Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
|
||||
# Determines how the detail page appears and disappears
|
||||
"detail_page_animation_type": "fade",
|
||||
|
||||
# Border width of the card in idle state (no hover or focus)
|
||||
# Affects the thickness of the border around the card when it's not selected
|
||||
# Value in pixels
|
||||
"default_border_width": 2,
|
||||
|
||||
# Border width on hover
|
||||
# Increases the border thickness when the cursor is over the card
|
||||
# Value in pixels
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Border width on focus (e.g., when selected via keyboard)
|
||||
# Increases the border thickness when the card is focused
|
||||
# Value in pixels
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Minimum border width during pulsing animation
|
||||
# Determines the minimum border thickness during the "breathing" animation
|
||||
# Value in pixels
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Maximum border width during pulsing animation
|
||||
# Determines the maximum border thickness during pulsing
|
||||
# Value in pixels
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Duration of the border thickness animation (e.g., on hover or focus)
|
||||
# Affects the speed of transition from one border width to another
|
||||
# Value in milliseconds
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Duration of one pulsing animation cycle
|
||||
# Determines how fast the border "pulses" between min and max values
|
||||
# Value in milliseconds
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Duration of the gradient rotation animation
|
||||
# Affects how fast the gradient border rotates around the card
|
||||
# Value in milliseconds
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Starting angle of the gradient (in degrees)
|
||||
# Determines the initial rotation point of the gradient at animation start
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Ending angle of the gradient (in degrees)
|
||||
# Determines the final rotation point of the gradient
|
||||
# Value 0 means a full 360° rotation
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Type of card animation on hover or focus
|
||||
# Possible values: "gradient", "scale"
|
||||
# "gradient" enables a rotating gradient for the border, "scale" enlarges the card
|
||||
"card_animation_type": "gradient",
|
||||
|
||||
# Card scale in idle state
|
||||
# Determines the base size of the card (1.0 = 100% of original size)
|
||||
# Value as a fraction (e.g., 1.0 for normal size)
|
||||
"default_scale": 1.0,
|
||||
|
||||
# Card scale on hover
|
||||
# Increases the card size on hover
|
||||
# Value as a fraction (e.g., 1.1 = 110% of original size)
|
||||
"hover_scale": 1.1,
|
||||
|
||||
# Card scale on focus (e.g., when selected via keyboard)
|
||||
# Increases the card size on focus
|
||||
# Value as a fraction (e.g., 1.05 = 105% of original size)
|
||||
"focus_scale": 1.05,
|
||||
|
||||
# Duration of scale animation
|
||||
# Affects how fast the card changes size on hover or focus
|
||||
# Value in milliseconds
|
||||
"scale_anim_duration": 200,
|
||||
|
||||
# Easing curve type for border thickness increase animation (on hover/focus)
|
||||
# Affects the "feel" of the animation (e.g., smooth acceleration or deceleration)
|
||||
# Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad")
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Easing curve type for border thickness decrease animation (on hover/focus exit)
|
||||
# Affects the "feel" of returning to the default border width
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Easing curve type for scale increase animation (on hover/focus)
|
||||
# Affects the "feel" of the scaling animation (e.g., with a "bounce" effect)
|
||||
# Possible values: strings corresponding to QEasingCurve.Type
|
||||
"scale_easing_curve": "OutBack",
|
||||
|
||||
# Easing curve type for scale decrease animation (on hover/focus exit)
|
||||
# Affects the "feel" of returning to the original scale
|
||||
"scale_easing_curve_out": "InBack",
|
||||
|
||||
# Gradient colors for animated border
|
||||
# List of dictionaries, each specifying position (0.0–1.0) and color in hex format
|
||||
# Affects the appearance of the border on hover or focus if card_animation_type="gradient"
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Starting color (cyan)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Color at 33% (orange)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Color at 66% (purple)
|
||||
{"position": 1, "color": "#00fff5"} # Ending color (back to cyan)
|
||||
],
|
||||
|
||||
# Duration of fade animation when entering the detail page
|
||||
# Affects the speed of page appearance with fade animation
|
||||
# Value in milliseconds
|
||||
"detail_page_fade_duration": 350,
|
||||
|
||||
# Duration of slide animation when entering the detail page
|
||||
# Affects the speed of page sliding animation
|
||||
# Value in milliseconds
|
||||
"detail_page_slide_duration": 500,
|
||||
|
||||
# Duration of bounce animation when entering the detail page
|
||||
# Affects the speed of page "bounce" animation
|
||||
# Value in milliseconds
|
||||
"detail_page_bounce_duration": 400,
|
||||
|
||||
# Duration of fade animation when exiting the detail page
|
||||
# Affects the speed of page disappearance with fade animation
|
||||
# Value in milliseconds
|
||||
"detail_page_fade_duration_exit": 350,
|
||||
|
||||
# Duration of slide animation when exiting the detail page
|
||||
# Affects the speed of page sliding animation
|
||||
# Value in milliseconds
|
||||
"detail_page_slide_duration_exit": 500,
|
||||
|
||||
# Duration of bounce animation when exiting the detail page
|
||||
# Affects the speed of page "compression" animation
|
||||
# Value in milliseconds
|
||||
"detail_page_bounce_duration_exit": 400,
|
||||
|
||||
# Easing curve type for animations when entering the detail page
|
||||
# Applied to slide and bounce animations; affects the "feel" of movement
|
||||
# Possible values: strings corresponding to QEasingCurve.Type
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Easing curve type for animations when exiting the detail page
|
||||
# Applied to slide and bounce animations; affects the "feel" of movement
|
||||
# Possible values: strings corresponding to QEasingCurve.Type
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Metadata (`metainfo.ini`)
|
||||
|
||||
```ini
|
||||
|
||||
@@ -3,12 +3,13 @@
|
||||
---
|
||||
|
||||
## 📋 Содержание
|
||||
- [Обзор](#обзор)
|
||||
- [Создание папки темы](#создание-папки-темы)
|
||||
- [Файл стилей](#файл-стилей)
|
||||
- [Метаинформация](#метаинформация)
|
||||
- [Скриншоты](#скриншоты)
|
||||
- [Шрифты и иконки](#шрифты-и-иконки)
|
||||
- [Обзор](#-обзор)
|
||||
- [Создание папки темы](#-создание-папки-темы)
|
||||
- [Файл стилей](#-файл-стилей-stylespy)
|
||||
- [Конфигурация анимации](#-конфигурация-анимации)
|
||||
- [Метаинформация](#-метаинформация-metainfoini)
|
||||
- [Скриншоты](#-скриншоты)
|
||||
- [Шрифты и иконки](#-шрифты-и-иконки-опционально)
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +46,163 @@ def custom_button_style(color1, color2):
|
||||
|
||||
---
|
||||
|
||||
## 🎥 Конфигурация анимации
|
||||
|
||||
Словарь `GAME_CARD_ANIMATION` управляет всеми параметрами анимации для карточек игр:
|
||||
|
||||
```python
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Тип анимации при входе и выходе на детальную страницу
|
||||
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
|
||||
# Определяет, как детальная страница появляется и исчезает
|
||||
"detail_page_animation_type": "fade",
|
||||
|
||||
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
|
||||
# Влияет на толщину рамки вокруг карточки, когда она не выделена
|
||||
# Значение в пикселях
|
||||
"default_border_width": 2,
|
||||
|
||||
# Ширина обводки при наведении курсора
|
||||
# Увеличивает толщину рамки, когда курсор находится над карточкой
|
||||
# Значение в пикселях
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Ширина обводки при фокусе (например, при выборе с клавиатуры)
|
||||
# Увеличивает толщину рамки, когда карточка в фокусе
|
||||
# Значение в пикселях
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Минимальная ширина обводки во время пульсирующей анимации
|
||||
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
|
||||
# Значение в пикселях
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Максимальная ширина обводки во время пульсирующей анимации
|
||||
# Определяет максимальную толщину рамки при пульсации
|
||||
# Значение в пикселях
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
|
||||
# Влияет на скорость перехода от одной ширины обводки к другой
|
||||
# Значение в миллисекундах
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Длительность одного цикла пульсирующей анимации
|
||||
# Определяет, как быстро рамка "пульсирует" между min и max значениями
|
||||
# Значение в миллисекундах
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Длительность анимации вращения градиента
|
||||
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
|
||||
# Значение в миллисекундах
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Начальный угол градиента (в градусах)
|
||||
# Определяет начальную точку вращения градиента при старте анимации
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Конечный угол градиента (в градусах)
|
||||
# Определяет конечную точку вращения градиента
|
||||
# Значение 0 означает полный поворот на 360 градусов
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Тип анимации для карточки при наведении или фокусе
|
||||
# Возможные значения: "gradient", "scale"
|
||||
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
|
||||
"card_animation_type": "gradient",
|
||||
|
||||
# Масштаб карточки в состоянии покоя
|
||||
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
|
||||
# Значение в долях (например, 1.0 для нормального размера)
|
||||
"default_scale": 1.0,
|
||||
|
||||
# Масштаб карточки при наведении курсора
|
||||
# Увеличивает размер карточки при наведении
|
||||
# Значение в долях (например, 1.1 = 110% от исходного размера)
|
||||
"hover_scale": 1.1,
|
||||
|
||||
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
|
||||
# Увеличивает размер карточки при фокусе
|
||||
# Значение в долях (например, 1.05 = 105% от исходного размера)
|
||||
"focus_scale": 1.05,
|
||||
|
||||
# Длительность анимации масштабирования
|
||||
# Влияет на скорость изменения размера карточки при наведении или фокусе
|
||||
# Значение в миллисекундах
|
||||
"scale_anim_duration": 200,
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
|
||||
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"scale_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходному масштабу
|
||||
"scale_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex
|
||||
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||
],
|
||||
|
||||
# Длительность анимации fade при входе на детальную страницу
|
||||
# Влияет на скорость появления страницы при fade-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_fade_duration": 350,
|
||||
|
||||
# Длительность анимации slide при входе на детальную страницу
|
||||
# Влияет на скорость скольжения страницы при slide-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_slide_duration": 500,
|
||||
|
||||
# Длительность анимации bounce при входе на детальную страницу
|
||||
# Влияет на скорость "прыжка" страницы при bounce-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_bounce_duration": 400,
|
||||
|
||||
# Длительность анимации fade при выходе из детальной страницы
|
||||
# Влияет на скорость исчезновения страницы при fade-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_fade_duration_exit": 350,
|
||||
|
||||
# Длительность анимации slide при выходе из детальной страницы
|
||||
# Влияет на скорость скольжения страницы при slide-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_slide_duration_exit": 500,
|
||||
|
||||
# Длительность анимации bounce при выходе из детальной страницы
|
||||
# Влияет на скорость "сжатия" страницы при bounce-анимации
|
||||
# Значение в миллисекундах
|
||||
"detail_page_bounce_duration_exit": 400,
|
||||
|
||||
# Тип кривой сглаживания для анимации при входе на детальную страницу
|
||||
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Тип кривой сглаживания для анимации при выходе из детальной страницы
|
||||
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Метаинформация (`metainfo.ini`)
|
||||
|
||||
```ini
|
||||
|
||||
522
portprotonqt/animations.py
Normal file
@@ -0,0 +1,522 @@
|
||||
from typing import Any, cast
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
||||
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
|
||||
from collections.abc import Callable
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class SafeOpacityEffect(QGraphicsOpacityEffect):
|
||||
def __init__(self, parent=None, disable_at_full=True):
|
||||
super().__init__(parent)
|
||||
self.disable_at_full = disable_at_full
|
||||
|
||||
def setOpacity(self, opacity: float):
|
||||
opacity = max(0.0, min(1.0, opacity))
|
||||
super().setOpacity(opacity)
|
||||
if opacity < 1.0:
|
||||
self.setEnabled(True)
|
||||
elif self.disable_at_full:
|
||||
self.setEnabled(False)
|
||||
|
||||
class GameCardAnimations:
|
||||
def __init__(self, game_card, theme=None):
|
||||
self.game_card = game_card
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
self.thickness_anim: QPropertyAnimation | None = None
|
||||
self.gradient_anim: QPropertyAnimation | None = None
|
||||
self.scale_anim: QPropertyAnimation | None = None
|
||||
self.pulse_anim: QPropertyAnimation | None = None
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
def setup_animations(self):
|
||||
"""Initialize animation properties based on theme."""
|
||||
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
if animation_type == "gradient":
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
elif animation_type == "scale":
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
|
||||
def start_pulse_animation(self):
|
||||
"""Start pulse animation for border width when hovered or focused."""
|
||||
if not (self.game_card._hovered or self.game_card._focused):
|
||||
return
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.start()
|
||||
|
||||
def handle_enter_event(self):
|
||||
"""Handle mouse enter event animations."""
|
||||
self.game_card._hovered = True
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, True)
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self.game_card._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
|
||||
self.thickness_anim.finished.connect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if animation_type == "gradient":
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
elif animation_type == "scale":
|
||||
if self.scale_anim:
|
||||
self.scale_anim.stop()
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
|
||||
self.scale_anim.setStartValue(self.game_card._scale)
|
||||
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_scale"])
|
||||
self.scale_anim.start()
|
||||
|
||||
def handle_leave_event(self):
|
||||
"""Handle mouse leave event animations."""
|
||||
self.game_card._hovered = False
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._focused:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
if animation_type == "gradient":
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
elif animation_type == "scale":
|
||||
if self.scale_anim:
|
||||
self.scale_anim.stop()
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
|
||||
self.scale_anim.setStartValue(self.game_card._scale)
|
||||
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
|
||||
self.scale_anim.start()
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self.game_card._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
|
||||
def handle_focus_in_event(self):
|
||||
"""Handle focus in event animations."""
|
||||
if not self.game_card._hovered:
|
||||
self.game_card._focused = True
|
||||
self.game_card.focusChanged.emit(self.game_card.name, True)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self.game_card._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
|
||||
self.thickness_anim.finished.connect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if animation_type == "gradient":
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
elif animation_type == "scale":
|
||||
if self.scale_anim:
|
||||
self.scale_anim.stop()
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
|
||||
self.scale_anim.setStartValue(self.game_card._scale)
|
||||
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_scale"])
|
||||
self.scale_anim.start()
|
||||
|
||||
def handle_focus_out_event(self):
|
||||
"""Handle focus out event animations."""
|
||||
self.game_card._focused = False
|
||||
self.game_card.focusChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._hovered:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
if animation_type == "gradient":
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
elif animation_type == "scale":
|
||||
if self.scale_anim:
|
||||
self.scale_anim.stop()
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
|
||||
self.scale_anim.setStartValue(self.game_card._scale)
|
||||
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
|
||||
self.scale_anim.start()
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self.game_card._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
|
||||
def paint_border(self, painter: QPainter):
|
||||
if not painter.isActive():
|
||||
logger.debug("Painter is not active; skipping border paint")
|
||||
return
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen()
|
||||
pen.setWidth(self.game_card._borderWidth)
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
|
||||
center = self.game_card.rect().center()
|
||||
gradient = QConicalGradient(center, self.game_card._gradientAngle)
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
else:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
painter.setPen(pen)
|
||||
radius = 18 * self.game_card._scale
|
||||
bw = round(self.game_card._borderWidth / 2)
|
||||
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
|
||||
if rect.isEmpty():
|
||||
return
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
|
||||
class DetailPageAnimations:
|
||||
def __init__(self, main_window, theme=None):
|
||||
self.main_window = main_window
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
# Ensure the main window has an animations dict
|
||||
if not hasattr(main_window, '_animations'):
|
||||
main_window._animations = {}
|
||||
self.animations = main_window._animations
|
||||
|
||||
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
|
||||
"""Animate the detail page based on theme settings."""
|
||||
# Check if the detail page is still valid before proceeding
|
||||
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
|
||||
logger.warning("Detail page is not valid, skipping enter animation")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
return
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
||||
|
||||
if animation_type == "fade":
|
||||
# Check again if page is still valid before starting animation
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during fade setup, skipping animation")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
return
|
||||
|
||||
original_effect = detail_page.graphicsEffect()
|
||||
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
|
||||
opacity_effect.setOpacity(0.0)
|
||||
detail_page.setGraphicsEffect(opacity_effect)
|
||||
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(0.0)
|
||||
animation.setEndValue(0.999)
|
||||
|
||||
def restore_effect():
|
||||
try:
|
||||
# Check if page is still valid before restoring effect
|
||||
if detail_page and not detail_page.isHidden():
|
||||
detail_page.setGraphicsEffect(cast(Any, original_effect))
|
||||
except RuntimeError:
|
||||
logger.warning("Original effect already deleted")
|
||||
|
||||
# Only start animation if page is still valid
|
||||
if detail_page and not detail_page.isHidden():
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(restore_effect)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
else:
|
||||
logger.warning("Detail page invalid when starting fade, cleaning up")
|
||||
restore_effect()
|
||||
load_image_and_restore_effect()
|
||||
opacity_effect.deleteLater()
|
||||
cleanup_animation()
|
||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||
# Check again if page is still valid before starting animation
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during slide setup, skipping animation")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
return
|
||||
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||
start_pos = {
|
||||
"slide_left": QPoint(self.main_window.width(), 0),
|
||||
"slide_right": QPoint(-self.main_window.width(), 0),
|
||||
"slide_up": QPoint(0, self.main_window.height()),
|
||||
"slide_down": QPoint(0, -self.main_window.height())
|
||||
}[animation_type]
|
||||
detail_page.move(start_pos)
|
||||
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(start_pos)
|
||||
animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
|
||||
animation.setEasingCurve(easing_curve)
|
||||
|
||||
# Only start animation if page is still valid
|
||||
if detail_page and not detail_page.isHidden():
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(cleanup_animation)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
else:
|
||||
logger.warning("Detail page invalid when starting slide, cleaning up")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
elif animation_type == "bounce":
|
||||
# Check again if page is still valid before starting animation
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during bounce setup, skipping animation")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
return
|
||||
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||
detail_page.setWindowOpacity(0.0)
|
||||
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
|
||||
opacity_anim.setDuration(duration)
|
||||
opacity_anim.setStartValue(0.0)
|
||||
opacity_anim.setEndValue(1.0)
|
||||
initial_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
|
||||
detail_page.width() // 2, detail_page.height() // 2)
|
||||
final_rect = detail_page.geometry()
|
||||
geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
|
||||
geometry_anim.setDuration(duration)
|
||||
geometry_anim.setStartValue(initial_rect)
|
||||
geometry_anim.setEndValue(final_rect)
|
||||
geometry_anim.setEasingCurve(easing_curve)
|
||||
group_anim = QParallelAnimationGroup()
|
||||
group_anim.addAnimation(opacity_anim)
|
||||
group_anim.addAnimation(geometry_anim)
|
||||
|
||||
# Only start animation if page is still valid
|
||||
if detail_page and not detail_page.isHidden():
|
||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = group_anim
|
||||
group_anim.finished.connect(load_image_and_restore_effect)
|
||||
group_anim.finished.connect(cleanup_animation)
|
||||
else:
|
||||
logger.warning("Detail page invalid when starting bounce, cleaning up")
|
||||
load_image_and_restore_effect()
|
||||
cleanup_animation()
|
||||
|
||||
def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
|
||||
"""Animate the detail page exit based on theme settings."""
|
||||
try:
|
||||
# Check if the detail page is still valid before proceeding
|
||||
if not detail_page or detail_page.isHidden() or detail_page.parent() is None:
|
||||
logger.warning("Detail page is not valid, skipping exit animation")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||
|
||||
# Safely stop and remove any existing animation
|
||||
if detail_page in self.animations:
|
||||
try:
|
||||
animation = self.animations[detail_page]
|
||||
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
|
||||
animation.stop()
|
||||
except RuntimeError:
|
||||
logger.warning("Animation already deleted for page")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping existing animation: {e}", exc_info=True)
|
||||
finally:
|
||||
self.animations.pop(detail_page, None)
|
||||
|
||||
# Define animation based on type
|
||||
if animation_type == "fade":
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
|
||||
|
||||
# Check if page is still valid before accessing properties
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during fade exit setup, skipping animation")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
original_effect = detail_page.graphicsEffect()
|
||||
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
|
||||
opacity_effect.setOpacity(0.999)
|
||||
detail_page.setGraphicsEffect(opacity_effect)
|
||||
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(0.999)
|
||||
animation.setEndValue(0.0)
|
||||
|
||||
def restore_and_cleanup():
|
||||
try:
|
||||
# Check if page is still valid before restoring effect
|
||||
if detail_page and not detail_page.isHidden():
|
||||
detail_page.setGraphicsEffect(cast(Any, original_effect))
|
||||
except RuntimeError:
|
||||
logger.debug("Original effect already deleted")
|
||||
cleanup_callback()
|
||||
|
||||
# Check if animation is still valid before starting
|
||||
if animation and not detail_page.isHidden():
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(restore_and_cleanup)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
else:
|
||||
logger.warning("Animation or detail page invalid when starting fade exit, cleaning up")
|
||||
restore_and_cleanup()
|
||||
opacity_effect.deleteLater()
|
||||
|
||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
|
||||
|
||||
# Check if page is still valid before accessing properties
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during slide exit setup, skipping animation")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
||||
end_pos = {
|
||||
"slide_left": QPoint(-self.main_window.width(), 0),
|
||||
"slide_right": QPoint(self.main_window.width(), 0),
|
||||
"slide_up": QPoint(0, self.main_window.height()),
|
||||
"slide_down": QPoint(0, -self.main_window.height())
|
||||
}[animation_type]
|
||||
|
||||
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(detail_page.pos())
|
||||
animation.setEndValue(end_pos)
|
||||
animation.setEasingCurve(easing_curve)
|
||||
|
||||
def slide_cleanup():
|
||||
# Check if page is still valid before cleanup
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.debug("Detail page already cleaned up")
|
||||
cleanup_callback()
|
||||
|
||||
# Check if animation is still valid before starting
|
||||
if animation and not detail_page.isHidden():
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(slide_cleanup)
|
||||
else:
|
||||
logger.warning("Animation or detail page invalid when starting slide exit, cleaning up")
|
||||
slide_cleanup()
|
||||
|
||||
elif animation_type == "bounce":
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
|
||||
|
||||
# Check if page is still valid before accessing properties
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during bounce exit setup, skipping animation")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
||||
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
|
||||
opacity_anim.setDuration(duration)
|
||||
opacity_anim.setStartValue(1.0)
|
||||
opacity_anim.setEndValue(0.0)
|
||||
final_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
|
||||
detail_page.width() // 2, detail_page.height() // 2)
|
||||
geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
|
||||
geometry_anim.setDuration(duration)
|
||||
geometry_anim.setStartValue(detail_page.geometry())
|
||||
geometry_anim.setEndValue(final_rect)
|
||||
geometry_anim.setEasingCurve(easing_curve)
|
||||
|
||||
# Check if animations are still valid before creating group
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during bounce exit setup, cleaning up")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
group_anim = QParallelAnimationGroup()
|
||||
group_anim.addAnimation(opacity_anim)
|
||||
group_anim.addAnimation(geometry_anim)
|
||||
|
||||
# Check if group animation is still valid before connecting
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.warning("Detail page became invalid during group animation setup, cleaning up")
|
||||
cleanup_callback()
|
||||
return
|
||||
|
||||
def bounce_cleanup():
|
||||
# Check if page is still valid before cleanup
|
||||
if not detail_page or detail_page.isHidden():
|
||||
logger.debug("Detail page already cleaned up")
|
||||
cleanup_callback()
|
||||
|
||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = group_anim
|
||||
group_anim.finished.connect(bounce_cleanup)
|
||||
except RuntimeError:
|
||||
# Widget was already deleted, which is expected after deleteLater()
|
||||
logger.debug("Detail page already deleted during animation setup")
|
||||
cleanup_callback()
|
||||
except Exception as e:
|
||||
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
|
||||
if detail_page in self.animations:
|
||||
self.animations.pop(detail_page, None)
|
||||
cleanup_callback()
|
||||
@@ -1,86 +1,177 @@
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QIcon
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.tray import SystemTray
|
||||
from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.cli import parse_args
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
|
||||
logger = get_logger(__name__)
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.config_utils import (
|
||||
save_fullscreen_config,
|
||||
read_fullscreen_config,
|
||||
get_portproton_start_command
|
||||
)
|
||||
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.3"
|
||||
__app_version__ = "0.1.9"
|
||||
|
||||
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"
|
||||
|
||||
# Get the PortProton start command
|
||||
start_sh = get_portproton_start_command()
|
||||
|
||||
if start_sh is None:
|
||||
return
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
||||
app.setDesktopFileName(__app_id__)
|
||||
app.setApplicationName(__app_name__)
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
args = parse_args()
|
||||
setup_logger(args.debug_level)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# --- Single-instance logic ---
|
||||
server_name = __app_id__
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
if socket.waitForConnected(200):
|
||||
# Второй экземпляр — передаём команду первому
|
||||
fullscreen = args.fullscreen or read_fullscreen_config()
|
||||
msg = b"show:fullscreen" if fullscreen else b"show"
|
||||
socket.write(msg)
|
||||
socket.flush()
|
||||
socket.waitForBytesWritten(500)
|
||||
socket.disconnectFromServer()
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Если старый сокет остался — удалить
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
if not local_server.listen(server_name):
|
||||
logger.warning(f"Failed to start local server: {local_server.errorString()}")
|
||||
return
|
||||
|
||||
# --- Qt translations ---
|
||||
system_locale = QLocale.system()
|
||||
qt_translator = QTranslator()
|
||||
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
|
||||
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
|
||||
app.installTranslator(qt_translator)
|
||||
else:
|
||||
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
|
||||
logger.warning(
|
||||
f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
|
||||
)
|
||||
|
||||
args = parse_args()
|
||||
# --- Main Window ---
|
||||
version = get_version()
|
||||
window = MainWindow(app_name=__app_name__, version=version)
|
||||
|
||||
window = MainWindow()
|
||||
# --- Handle incoming connections ---
|
||||
def handle_new_connection():
|
||||
conn = local_server.nextPendingConnection()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
if args.session:
|
||||
gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2")
|
||||
cmd = f"{gamescope_cmd} -- portprotonqt"
|
||||
logger.info(f"Executing: {cmd}")
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
sys.exit(0)
|
||||
if conn.waitForReadyRead(1000):
|
||||
data = conn.readAll().data()
|
||||
msg = bytes(data).decode("utf-8", errors="ignore")
|
||||
logger.info(f"IPC message received: {msg}")
|
||||
|
||||
if args.fullscreen:
|
||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||
def restore_window():
|
||||
try:
|
||||
if msg.startswith("show"):
|
||||
# Ensure the window is visible and not minimized
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
|
||||
# Ensure window is in active state for systems with strict focus policies
|
||||
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
|
||||
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
save_fullscreen_config(True)
|
||||
window.showFullScreen()
|
||||
else:
|
||||
logger.info("Switching to normal window via IPC")
|
||||
save_fullscreen_config(False)
|
||||
window.showNormal()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to restore window: {e}")
|
||||
|
||||
# Выполняем в основном потоке
|
||||
QTimer.singleShot(0, restore_window)
|
||||
|
||||
conn.disconnectFromServer()
|
||||
|
||||
local_server.newConnection.connect(handle_new_connection)
|
||||
|
||||
# --- Initial fullscreen state ---
|
||||
launch_fullscreen = args.fullscreen or read_fullscreen_config()
|
||||
if launch_fullscreen:
|
||||
logger.info(
|
||||
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
|
||||
)
|
||||
save_fullscreen_config(True)
|
||||
window.showFullScreen()
|
||||
else:
|
||||
logger.info("Launching in normal mode")
|
||||
save_fullscreen_config(False)
|
||||
window.showNormal()
|
||||
|
||||
current_theme_name = read_theme_from_config()
|
||||
tray = SystemTray(app, current_theme_name)
|
||||
tray.show_action.triggered.connect(window.show)
|
||||
tray.hide_action.triggered.connect(window.hide)
|
||||
# Execute the initial PortProton command after the UI is set up
|
||||
def run_initial_command():
|
||||
nonlocal start_sh
|
||||
if start_sh:
|
||||
try:
|
||||
subprocess.run(start_sh + ["cli", "--initial"], timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Initial PortProton command timed out")
|
||||
except Exception as e:
|
||||
logger.error(f"Error running initial PortProton command: {e}")
|
||||
else:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
def recreate_tray():
|
||||
nonlocal tray
|
||||
if tray:
|
||||
logger.debug("Recreating system tray")
|
||||
tray.cleanup()
|
||||
tray = None
|
||||
current_theme = read_theme_from_config()
|
||||
tray = SystemTray(app, current_theme)
|
||||
# Ensure window is not None before connecting signals
|
||||
if window:
|
||||
tray.show_action.triggered.connect(window.show)
|
||||
tray.hide_action.triggered.connect(window.hide)
|
||||
# Run the initial command after the UI is displayed
|
||||
QTimer.singleShot(100, run_initial_command)
|
||||
|
||||
# --- Cleanup ---
|
||||
def cleanup_on_exit():
|
||||
nonlocal tray, window
|
||||
app.aboutToQuit.disconnect()
|
||||
if tray:
|
||||
tray.cleanup()
|
||||
tray = None
|
||||
if window:
|
||||
window.close()
|
||||
app.quit()
|
||||
try:
|
||||
local_server.close()
|
||||
QLocalServer.removeServer(server_name)
|
||||
if window:
|
||||
window.close()
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup error: {e}")
|
||||
|
||||
window.settings_saved.connect(recreate_tray)
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
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(
|
||||
"--session",
|
||||
action="store_true",
|
||||
help="Запустить приложение с использованием gamescope"
|
||||
"--debug-level",
|
||||
choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
|
||||
default='NOTSET',
|
||||
help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import os
|
||||
import configparser
|
||||
import shutil
|
||||
import subprocess
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_portproton_location = None
|
||||
_portproton_start_sh = None
|
||||
|
||||
# Пути к конфигурационным файлам
|
||||
# Configuration cache for performance optimization
|
||||
_config_cache = {}
|
||||
_config_last_modified = {}
|
||||
|
||||
# Paths to configuration files
|
||||
CONFIG_FILE = os.path.join(
|
||||
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
|
||||
"PortProtonQt.conf"
|
||||
@@ -18,17 +24,62 @@ PORTPROTON_CONFIG_FILE = os.path.join(
|
||||
"PortProton.conf"
|
||||
)
|
||||
|
||||
# Пути к папкам с темами
|
||||
# Paths to theme directories
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
THEMES_DIRS = [
|
||||
os.path.join(xdg_data_home, "PortProtonQt", "themes"),
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
|
||||
]
|
||||
|
||||
def read_config():
|
||||
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
|
||||
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails.
|
||||
Uses caching to avoid repeated file reads for better performance.
|
||||
"""
|
||||
Читает конфигурационный файл и возвращает словарь параметров.
|
||||
Пример строки в конфиге (без секций):
|
||||
# Check if file exists
|
||||
if not os.path.exists(config_file):
|
||||
logger.debug(f"Configuration file {config_file} not found")
|
||||
return None
|
||||
|
||||
# Get file modification time
|
||||
try:
|
||||
current_mtime = os.path.getmtime(config_file)
|
||||
except OSError:
|
||||
logger.warning(f"Failed to get modification time for {config_file}")
|
||||
return None
|
||||
|
||||
# Check if we have a cached version that's still valid
|
||||
if config_file in _config_cache and config_file in _config_last_modified:
|
||||
if _config_last_modified[config_file] == current_mtime:
|
||||
logger.debug(f"Using cached config for {config_file}")
|
||||
return _config_cache[config_file]
|
||||
|
||||
# Read and parse the config file
|
||||
cp = configparser.ConfigParser()
|
||||
try:
|
||||
cp.read(config_file, encoding="utf-8")
|
||||
# Update cache
|
||||
_config_cache[config_file] = cp
|
||||
_config_last_modified[config_file] = current_mtime
|
||||
logger.debug(f"Config file {config_file} loaded and cached")
|
||||
return cp
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.warning(f"Invalid configuration file format: {e}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read configuration file: {e}")
|
||||
return None
|
||||
|
||||
def invalidate_config_cache(config_file: str = CONFIG_FILE):
|
||||
"""Invalidates the cached configuration for the specified file."""
|
||||
if config_file in _config_cache:
|
||||
del _config_cache[config_file]
|
||||
if config_file in _config_last_modified:
|
||||
del _config_last_modified[config_file]
|
||||
logger.debug(f"Config cache invalidated for {config_file}")
|
||||
|
||||
def read_config():
|
||||
"""Reads the configuration file and returns a dictionary of parameters.
|
||||
Example line in config (no sections):
|
||||
detail_level = detailed
|
||||
"""
|
||||
config_dict = {}
|
||||
@@ -44,113 +95,139 @@ def read_config():
|
||||
return config_dict
|
||||
|
||||
def read_theme_from_config():
|
||||
"""Reads the theme from the [Appearance] section of the configuration file.
|
||||
Returns 'standart' if the parameter is not set.
|
||||
"""
|
||||
Читает из конфигурационного файла тему из секции [Appearance].
|
||||
Если параметр не задан, возвращает "standart".
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
return "standart"
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None:
|
||||
return "standart"
|
||||
return cp.get("Appearance", "theme", fallback="standart")
|
||||
|
||||
def save_theme_to_config(theme_name):
|
||||
"""
|
||||
Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
"""Saves the selected theme name to the [Appearance] section of the configuration file."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Appearance" not in cp:
|
||||
cp["Appearance"] = {}
|
||||
cp["Appearance"]["theme"] = theme_name
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_time_config():
|
||||
"""Reads time settings from the [Time] section of the configuration file.
|
||||
If the section or parameter is missing, saves and returns 'detailed' as default.
|
||||
"""
|
||||
Читает настройки времени из секции [Time] конфигурационного файла.
|
||||
Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
save_time_config("detailed")
|
||||
return "detailed"
|
||||
if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
|
||||
save_time_config("detailed")
|
||||
return "detailed"
|
||||
return cp.get("Time", "detail_level", fallback="detailed").lower()
|
||||
return "detailed"
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
|
||||
save_time_config("detailed")
|
||||
return "detailed"
|
||||
return cp.get("Time", "detail_level", fallback="detailed").lower()
|
||||
|
||||
def save_time_config(detail_level):
|
||||
"""
|
||||
Сохраняет настройку уровня детализации времени в секции [Time].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
"""Saves the time detail level to the [Time] section of the configuration file."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Time" not in cp:
|
||||
cp["Time"] = {}
|
||||
cp["Time"]["detail_level"] = detail_level
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_file_content(file_path):
|
||||
"""
|
||||
Читает содержимое файла и возвращает его как строку.
|
||||
"""
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
return f.read().strip()
|
||||
"""Reads the content of a file and returns it as a string."""
|
||||
try:
|
||||
# Add timeout protection for file operations using a simple approach
|
||||
with open(file_path, encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Error reading file {file_path}: {e}")
|
||||
raise # Re-raise the exception to be handled by the caller
|
||||
|
||||
def get_portproton_location():
|
||||
"""
|
||||
Возвращает путь к директории PortProton.
|
||||
Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
|
||||
наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
|
||||
используется директория по умолчанию.
|
||||
"""
|
||||
"""Возвращает путь к PortProton каталогу (строку) или None."""
|
||||
global _portproton_location
|
||||
|
||||
if _portproton_location is not None:
|
||||
return _portproton_location
|
||||
|
||||
# Попытка чтения пути из конфигурационного файла
|
||||
location = None
|
||||
|
||||
if os.path.isfile(PORTPROTON_CONFIG_FILE):
|
||||
try:
|
||||
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
|
||||
if location and os.path.isdir(location):
|
||||
_portproton_location = location
|
||||
logger.info(f"Путь PortProton из конфигурации: {location}")
|
||||
logger.info(f"PortProton path from configuration: {location}")
|
||||
return _portproton_location
|
||||
logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
|
||||
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
|
||||
logger.warning(f"Failed to read PortProton configuration file: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Unexpected error reading PortProton configuration file: {e}")
|
||||
|
||||
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||
if os.path.isdir(default_dir):
|
||||
_portproton_location = default_dir
|
||||
logger.info(f"Используется директория flatpak PortProton: {default_dir}")
|
||||
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||
if os.path.isdir(default_flatpak_dir):
|
||||
_portproton_location = default_flatpak_dir
|
||||
logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
|
||||
return _portproton_location
|
||||
|
||||
logger.warning("Конфигурация и директория flatpak PortProton не найдены")
|
||||
logger.warning("PortProton configuration and Flatpak directory not found")
|
||||
return None
|
||||
|
||||
def get_portproton_start_command():
|
||||
"""Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
|
||||
portproton_path = get_portproton_location()
|
||||
if not portproton_path:
|
||||
return None
|
||||
|
||||
# Check if flatpak command exists before trying to run it
|
||||
try:
|
||||
subprocess.run(
|
||||
["flatpak", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=5
|
||||
)
|
||||
flatpak_available = True
|
||||
except FileNotFoundError:
|
||||
flatpak_available = False
|
||||
except Exception:
|
||||
flatpak_available = False
|
||||
|
||||
if flatpak_available:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["flatpak", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=10
|
||||
)
|
||||
if "ru.linux_gaming.PortProton" in result.stdout:
|
||||
logger.info("Detected Flatpak installation")
|
||||
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Flatpak list command timed out")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking flatpak list: {e}")
|
||||
pass
|
||||
|
||||
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
|
||||
if os.path.exists(start_sh_path):
|
||||
return [start_sh_path]
|
||||
|
||||
logger.warning("Neither flatpak nor start.sh found for PortProton")
|
||||
return None
|
||||
|
||||
|
||||
def parse_desktop_entry(file_path):
|
||||
"""
|
||||
Читает и парсит .desktop файл с помощью configparser.
|
||||
Если секция [Desktop Entry] отсутствует, возвращается None.
|
||||
"""Reads and parses a .desktop file using configparser.
|
||||
Returns None if the [Desktop Entry] section is missing.
|
||||
"""
|
||||
cp = configparser.ConfigParser(interpolation=None)
|
||||
cp.read(file_path, encoding="utf-8")
|
||||
@@ -159,9 +236,8 @@ def parse_desktop_entry(file_path):
|
||||
return cp["Desktop Entry"]
|
||||
|
||||
def load_theme_metainfo(theme_name):
|
||||
"""
|
||||
Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
|
||||
Ожидаемые поля: author, author_link, description, name.
|
||||
"""Loads theme metadata from metainfo.ini in the theme's root directory.
|
||||
Expected fields: author, author_link, description, name.
|
||||
"""
|
||||
meta = {}
|
||||
for themes_dir in THEMES_DIRS:
|
||||
@@ -179,220 +255,182 @@ def load_theme_metainfo(theme_name):
|
||||
return meta
|
||||
|
||||
def read_card_size():
|
||||
"""Reads the card size (width) from the [Cards] section.
|
||||
Returns 250 if the parameter is not set.
|
||||
"""
|
||||
Читает размер карточек (ширину) из секции [Cards],
|
||||
Если параметр не задан, возвращает 250.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
save_card_size(250)
|
||||
return 250
|
||||
if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
|
||||
save_card_size(250)
|
||||
return 250
|
||||
return cp.getint("Cards", "card_width", fallback=250)
|
||||
return 250
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
|
||||
save_card_size(250)
|
||||
return 250
|
||||
return cp.getint("Cards", "card_width", fallback=250)
|
||||
|
||||
def save_card_size(card_width):
|
||||
"""
|
||||
Сохраняет размер карточек (ширину) в секцию [Cards].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
"""Saves the card size (width) to the [Cards] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Cards" not in cp:
|
||||
cp["Cards"] = {}
|
||||
cp["Cards"]["card_width"] = str(card_width)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_auto_card_size():
|
||||
"""Reads the card size (width) for Auto Install from the [Cards] section.
|
||||
Returns 250 if the parameter is not set.
|
||||
"""
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
|
||||
save_auto_card_size(250)
|
||||
return 250
|
||||
return cp.getint("Cards", "auto_card_width", fallback=250)
|
||||
|
||||
def save_auto_card_size(card_width):
|
||||
"""Saves the card size (width) for Auto Install to the [Cards] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Cards" not in cp:
|
||||
cp["Cards"] = {}
|
||||
cp["Cards"]["auto_card_width"] = str(card_width)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
|
||||
def read_sort_method():
|
||||
"""Reads the sort method from the [Games] section.
|
||||
Returns 'last_launch' if the parameter is not set.
|
||||
"""
|
||||
Читает метод сортировки из секции [Games].
|
||||
Если параметр не задан, возвращает last_launch.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
save_sort_method("last_launch")
|
||||
return "last_launch"
|
||||
if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
|
||||
save_sort_method("last_launch")
|
||||
return "last_launch"
|
||||
return cp.get("Games", "sort_method", fallback="last_launch").lower()
|
||||
return "last_launch"
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
|
||||
save_sort_method("last_launch")
|
||||
return "last_launch"
|
||||
return cp.get("Games", "sort_method", fallback="last_launch").lower()
|
||||
|
||||
def save_sort_method(sort_method):
|
||||
"""
|
||||
Сохраняет метод сортировки в секцию [Games].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
"""Saves the sort method to the [Games] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Games" not in cp:
|
||||
cp["Games"] = {}
|
||||
cp["Games"]["sort_method"] = sort_method
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_display_filter():
|
||||
"""Reads the display_filter parameter from the [Games] section.
|
||||
Returns 'all' if the parameter is missing.
|
||||
"""
|
||||
Читает параметр display_filter из секции [Games].
|
||||
Если параметр отсутствует, сохраняет и возвращает значение "all".
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
save_display_filter("all")
|
||||
return "all"
|
||||
if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
|
||||
save_display_filter("all")
|
||||
return "all"
|
||||
return cp.get("Games", "display_filter", fallback="all").lower()
|
||||
return "all"
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
|
||||
save_display_filter("all")
|
||||
return "all"
|
||||
return cp.get("Games", "display_filter", fallback="all").lower()
|
||||
|
||||
def save_display_filter(filter_value):
|
||||
"""
|
||||
Сохраняет параметр display_filter в секцию [Games] конфигурационного файла.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
"""Saves the display_filter parameter to the [Games] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Games" not in cp:
|
||||
cp["Games"] = {}
|
||||
cp["Games"]["display_filter"] = filter_value
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_favorites():
|
||||
"""Reads the list of favorite games from the [Favorites] section.
|
||||
The list is stored as a quoted string with comma-separated names.
|
||||
Returns an empty list if the section or parameter is missing.
|
||||
"""
|
||||
Читает список избранных игр из секции [Favorites] конфигурационного файла.
|
||||
Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми.
|
||||
Если секция или параметр отсутствуют, возвращает пустой список.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
return []
|
||||
if cp.has_section("Favorites") and cp.has_option("Favorites", "games"):
|
||||
favs = cp.get("Favorites", "games", fallback="").strip()
|
||||
# Если строка начинается и заканчивается кавычками, удаляем их
|
||||
if favs.startswith('"') and favs.endswith('"'):
|
||||
favs = favs[1:-1]
|
||||
return [s.strip() for s in favs.split(",") if s.strip()]
|
||||
return []
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Favorites") or not cp.has_option("Favorites", "games"):
|
||||
return []
|
||||
favs = cp.get("Favorites", "games", fallback="").strip()
|
||||
if favs.startswith('"') and favs.endswith('"'):
|
||||
favs = favs[1:-1]
|
||||
return [s.strip() for s in favs.split(",") if s.strip()]
|
||||
|
||||
def save_favorites(favorites):
|
||||
"""Saves the list of favorite games to the [Favorites] section.
|
||||
The list is stored as a quoted string with comma-separated names.
|
||||
"""
|
||||
Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла.
|
||||
Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Favorites" not in cp:
|
||||
cp["Favorites"] = {}
|
||||
fav_str = ", ".join(favorites)
|
||||
cp["Favorites"]["games"] = f'"{fav_str}"'
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_rumble_config():
|
||||
"""Reads the gamepad rumble setting from the [Gamepad] section.
|
||||
Returns False if the parameter is missing.
|
||||
"""
|
||||
Читает настройку виброотдачи геймпада из секции [Gamepad].
|
||||
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
save_rumble_config(False)
|
||||
return False
|
||||
if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
|
||||
save_rumble_config(False)
|
||||
return False
|
||||
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
|
||||
return False
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
|
||||
save_rumble_config(False)
|
||||
return False
|
||||
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
|
||||
|
||||
def save_rumble_config(rumble_enabled):
|
||||
"""
|
||||
Сохраняет настройку виброотдачи геймпада в секцию [Gamepad].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
"""Saves the gamepad rumble setting to the [Gamepad] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Gamepad" not in cp:
|
||||
cp["Gamepad"] = {}
|
||||
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_gamepad_type():
|
||||
"""Reads the gamepad type from the [Gamepad] section.
|
||||
Returns 'xbox' if the parameter is missing.
|
||||
"""
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"):
|
||||
save_gamepad_type("xbox")
|
||||
return "xbox"
|
||||
return cp.get("Gamepad", "type", fallback="xbox").lower()
|
||||
|
||||
def save_gamepad_type(gpad_type):
|
||||
"""Saves the gamepad type to the [Gamepad] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Gamepad" not in cp:
|
||||
cp["Gamepad"] = {}
|
||||
cp["Gamepad"]["type"] = gpad_type
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def ensure_default_proxy_config():
|
||||
"""Ensures the [Proxy] section exists in the configuration file.
|
||||
Creates it with empty values if missing.
|
||||
"""
|
||||
Проверяет наличие секции [Proxy] в конфигурационном файле.
|
||||
Если секция отсутствует, создаёт её с пустыми значениями.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
return
|
||||
if not cp.has_section("Proxy"):
|
||||
cp.add_section("Proxy")
|
||||
cp["Proxy"]["proxy_url"] = ""
|
||||
cp["Proxy"]["proxy_user"] = ""
|
||||
cp["Proxy"]["proxy_password"] = ""
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Proxy" not in cp:
|
||||
cp.add_section("Proxy")
|
||||
cp["Proxy"]["proxy_url"] = ""
|
||||
cp["Proxy"]["proxy_user"] = ""
|
||||
cp["Proxy"]["proxy_password"] = ""
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_proxy_config():
|
||||
"""
|
||||
Читает настройки прокси из секции [Proxy] конфигурационного файла.
|
||||
Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
|
||||
"""Reads proxy settings from the [Proxy] section.
|
||||
Returns an empty dict if proxy_url is not set or empty.
|
||||
"""
|
||||
ensure_default_proxy_config()
|
||||
cp = configparser.ConfigParser()
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None:
|
||||
return {}
|
||||
|
||||
proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
|
||||
if proxy_url:
|
||||
# Если указаны логин и пароль, добавляем их к URL
|
||||
proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
|
||||
proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
|
||||
if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
|
||||
@@ -402,16 +440,10 @@ def read_proxy_config():
|
||||
return {}
|
||||
|
||||
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
|
||||
"""Saves proxy settings to the [Proxy] section.
|
||||
Creates the section if it does not exist.
|
||||
"""
|
||||
Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла.
|
||||
Если секция отсутствует, создаёт её.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Proxy" not in cp:
|
||||
cp["Proxy"] = {}
|
||||
cp["Proxy"]["proxy_url"] = proxy_url
|
||||
@@ -419,133 +451,145 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
|
||||
cp["Proxy"]["proxy_password"] = proxy_password
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_fullscreen_config():
|
||||
"""Reads the fullscreen mode setting from the [Display] section.
|
||||
Returns False if the parameter is missing.
|
||||
"""
|
||||
Читает настройку полноэкранного режима приложения из секции [Display].
|
||||
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
save_fullscreen_config(False)
|
||||
return False
|
||||
if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
|
||||
save_fullscreen_config(False)
|
||||
return False
|
||||
return cp.getboolean("Display", "fullscreen", fallback=False)
|
||||
return False
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
|
||||
save_fullscreen_config(False)
|
||||
return False
|
||||
return cp.getboolean("Display", "fullscreen", fallback=False)
|
||||
|
||||
def save_fullscreen_config(fullscreen):
|
||||
"""
|
||||
Сохраняет настройку полноэкранного режима приложения в секцию [Display].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
"""Saves the fullscreen mode setting to the [Display] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Display" not in cp:
|
||||
cp["Display"] = {}
|
||||
cp["Display"]["fullscreen"] = str(fullscreen)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_window_geometry() -> tuple[int, int]:
|
||||
"""Reads the window width and height from the [MainWindow] section.
|
||||
Returns (0, 0) if the parameters are missing.
|
||||
"""
|
||||
Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
|
||||
Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0).
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
return (0, 0)
|
||||
if cp.has_section("MainWindow"):
|
||||
width = cp.getint("MainWindow", "width", fallback=0)
|
||||
height = cp.getint("MainWindow", "height", fallback=0)
|
||||
return (width, height)
|
||||
return (0, 0)
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("MainWindow"):
|
||||
return (0, 0)
|
||||
width = cp.getint("MainWindow", "width", fallback=0)
|
||||
height = cp.getint("MainWindow", "height", fallback=0)
|
||||
return (width, height)
|
||||
|
||||
def save_window_geometry(width: int, height: int):
|
||||
"""
|
||||
Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка в конфигурационном файле: %s", e)
|
||||
"""Saves the window width and height to the [MainWindow] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "MainWindow" not in cp:
|
||||
cp["MainWindow"] = {}
|
||||
cp["MainWindow"]["width"] = str(width)
|
||||
cp["MainWindow"]["height"] = str(height)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def reset_config():
|
||||
"""
|
||||
Сбрасывает конфигурационный файл, удаляя его.
|
||||
После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
|
||||
"""Resets the configuration file by deleting it.
|
||||
Subsequent reads will use default values.
|
||||
"""
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
os.remove(CONFIG_FILE)
|
||||
logger.info("Конфигурационный файл %s удалён", CONFIG_FILE)
|
||||
logger.info("Configuration file %s deleted", CONFIG_FILE)
|
||||
# Invalidate cache after deletion
|
||||
invalidate_config_cache()
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при удалении конфигурационного файла: %s", e)
|
||||
logger.warning(f"Failed to delete configuration file: {e}")
|
||||
|
||||
def clear_cache():
|
||||
"""
|
||||
Очищает кэш PortProtonQt, удаляя папку кэша.
|
||||
"""
|
||||
"""Clears the PortProtonQt cache by deleting the cache directory."""
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
|
||||
if os.path.exists(cache_dir):
|
||||
try:
|
||||
shutil.rmtree(cache_dir)
|
||||
logger.info("Кэш PortProtonQt удалён: %s", cache_dir)
|
||||
logger.info("PortProtonQt cache deleted: %s", cache_dir)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при удалении кэша: %s", e)
|
||||
logger.warning(f"Failed to delete cache: {e}")
|
||||
|
||||
# Also clear our internal config cache
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_auto_fullscreen_gamepad():
|
||||
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
|
||||
Returns False if the parameter is missing.
|
||||
"""
|
||||
Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [Display].
|
||||
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
save_auto_fullscreen_gamepad(False)
|
||||
return False
|
||||
if not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
|
||||
save_auto_fullscreen_gamepad(False)
|
||||
return False
|
||||
return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
|
||||
return False
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
|
||||
save_auto_fullscreen_gamepad(False)
|
||||
return False
|
||||
return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
|
||||
|
||||
def save_auto_fullscreen_gamepad(auto_fullscreen):
|
||||
"""
|
||||
Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [Display].
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
"""Saves the auto-fullscreen setting for gamepad to the [Display] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Display" not in cp:
|
||||
cp["Display"] = {}
|
||||
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_favorite_folders():
|
||||
"""Reads the list of favorite folders from the [FavoritesFolders] section.
|
||||
The list is stored as a quoted string with comma-separated paths.
|
||||
Returns an empty list if the section or parameter is missing.
|
||||
"""
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("FavoritesFolders") or not cp.has_option("FavoritesFolders", "folders"):
|
||||
return []
|
||||
favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
|
||||
if favs.startswith('"') and favs.endswith('"'):
|
||||
favs = favs[1:-1]
|
||||
return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
|
||||
|
||||
def save_favorite_folders(folders):
|
||||
"""Saves the list of favorite folders to the [FavoritesFolders] section.
|
||||
The list is stored as a quoted string with comma-separated paths.
|
||||
"""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "FavoritesFolders" not in cp:
|
||||
cp["FavoritesFolders"] = {}
|
||||
fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
|
||||
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
def read_minimize_to_tray():
|
||||
"""Reads the minimize-to-tray setting from the [Display] section.
|
||||
Returns True if the parameter is missing (default: minimize to tray).
|
||||
"""
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
|
||||
save_minimize_to_tray(True)
|
||||
return True
|
||||
return cp.getboolean("Display", "minimize_to_tray", fallback=True)
|
||||
|
||||
def save_minimize_to_tray(minimize_to_tray):
|
||||
"""Saves the minimize-to-tray setting to the [Display] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Display" not in cp:
|
||||
cp["Display"] = {}
|
||||
cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
# Invalidate cache after saving
|
||||
invalidate_config_cache()
|
||||
|
||||
@@ -4,7 +4,6 @@ import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import logging
|
||||
import orjson
|
||||
import psutil
|
||||
import signal
|
||||
@@ -12,13 +11,14 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command
|
||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
|
||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ContextMenuSignals(QObject):
|
||||
"""Signals for thread-safe UI updates from worker threads."""
|
||||
@@ -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:
|
||||
@@ -148,10 +149,85 @@ class ContextMenuManager:
|
||||
return False
|
||||
current_exe = os.path.basename(exe_path)
|
||||
|
||||
# Check if the current_exe matches the target_exe in MainWindow
|
||||
if hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe:
|
||||
return True
|
||||
return False
|
||||
return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
|
||||
|
||||
def show_folder_context_menu(self, file_explorer, pos):
|
||||
"""Shows the context menu for a folder in FileExplorer."""
|
||||
try:
|
||||
item = file_explorer.file_list.itemAt(pos)
|
||||
if not item:
|
||||
logger.debug("No folder selected at position %s", pos)
|
||||
return
|
||||
selected = item.text()
|
||||
if not selected.endswith("/"):
|
||||
logger.debug("Selected item is not a folder: %s", selected)
|
||||
return # Only for folders
|
||||
full_path = os.path.normpath(os.path.join(file_explorer.current_path, selected.rstrip("/")))
|
||||
if not os.path.isdir(full_path):
|
||||
logger.debug("Path is not a directory: %s", full_path)
|
||||
return
|
||||
|
||||
menu = QMenu(file_explorer)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
menu.setParent(file_explorer, Qt.WindowType.Popup) # Set transientParent for Wayland
|
||||
|
||||
favorite_folders = read_favorite_folders()
|
||||
is_favorite = full_path in favorite_folders
|
||||
action_text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
|
||||
favorite_action = menu.addAction(self._get_safe_icon("star" if is_favorite else "star_full"), action_text)
|
||||
favorite_action.triggered.connect(lambda: self.toggle_favorite_folder(file_explorer, full_path, not is_favorite))
|
||||
|
||||
# Disconnect file_list signals to prevent navigation during menu interaction
|
||||
try:
|
||||
file_explorer.file_list.itemClicked.disconnect(file_explorer.handle_item_click)
|
||||
file_explorer.file_list.itemDoubleClicked.disconnect(file_explorer.handle_item_double_click)
|
||||
except TypeError:
|
||||
pass # Signals may not be connected
|
||||
|
||||
# Reconnect signals after menu closes
|
||||
def reconnect_signals():
|
||||
try:
|
||||
file_explorer.file_list.itemClicked.connect(file_explorer.handle_item_click)
|
||||
file_explorer.file_list.itemDoubleClicked.connect(file_explorer.handle_item_double_click)
|
||||
except Exception as e:
|
||||
logger.error("Error reconnecting file list signals: %s", e)
|
||||
|
||||
menu.aboutToHide.connect(reconnect_signals)
|
||||
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
if actions:
|
||||
menu.setActiveAction(actions[0])
|
||||
|
||||
# Map local position to global for menu display
|
||||
global_pos = file_explorer.file_list.mapToGlobal(pos)
|
||||
menu.exec(global_pos)
|
||||
except Exception as 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."""
|
||||
favorite_folders = read_favorite_folders()
|
||||
if add:
|
||||
if folder_path not in favorite_folders:
|
||||
favorite_folders.append(folder_path)
|
||||
save_favorite_folders(favorite_folders)
|
||||
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("Removed folder from favorites: %s", folder_path)
|
||||
file_explorer.update_drives_list()
|
||||
|
||||
def _get_safe_icon(self, icon_name: str) -> QIcon:
|
||||
"""Returns a QIcon, ensuring it is valid."""
|
||||
icon = self.theme_manager.get_icon(icon_name)
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
elif isinstance(icon, str) and os.path.exists(icon):
|
||||
return QIcon(icon)
|
||||
return QIcon()
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
"""
|
||||
@@ -161,23 +237,25 @@ class ContextMenuManager:
|
||||
game_card: The GameCard instance requesting the context menu.
|
||||
pos: The position (in widget coordinates) where the menu should appear.
|
||||
"""
|
||||
|
||||
def get_safe_icon(icon_name: str) -> QIcon:
|
||||
icon = self.theme_manager.get_icon(icon_name)
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
elif isinstance(icon, str) and os.path.exists(icon):
|
||||
return QIcon(icon)
|
||||
return QIcon()
|
||||
|
||||
menu = QMenu(self.parent)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
# Check if the game is running
|
||||
# For non-Steam and non-Epic games, check if exe exists
|
||||
if game_card.game_source not in ("steam", "epic"):
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
|
||||
if not exe_path:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
# Normal menu for games with valid exe or from Steam/Epic
|
||||
is_running = self._is_game_running(game_card)
|
||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||
action_icon = "stop" if is_running else "play"
|
||||
launch_action = menu.addAction(get_safe_icon(action_icon), action_text)
|
||||
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
|
||||
launch_action.triggered.connect(
|
||||
lambda: self._launch_game(game_card)
|
||||
)
|
||||
@@ -186,11 +264,11 @@ class ContextMenuManager:
|
||||
is_favorite = game_card.name in favorites
|
||||
icon_name = "star_full" if is_favorite else "star"
|
||||
text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
|
||||
favorite_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
|
||||
|
||||
if game_card.game_source == "epic":
|
||||
import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary"))
|
||||
import_action = menu.addAction(self._get_safe_icon("epic_games"), _("Import to Legendary"))
|
||||
import_action.triggered.connect(
|
||||
lambda: self.import_to_legendary(game_card.name, game_card.appid)
|
||||
)
|
||||
@@ -198,13 +276,13 @@ class ContextMenuManager:
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
icon_name = "delete" if is_in_steam else "steam"
|
||||
text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
if is_in_steam
|
||||
else self.add_egs_to_steam(game_card.name, game_card.appid)
|
||||
)
|
||||
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
|
||||
open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
open_folder_action.triggered.connect(
|
||||
lambda: self.open_egs_game_folder(game_card.appid)
|
||||
)
|
||||
@@ -212,7 +290,7 @@ class ContextMenuManager:
|
||||
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
||||
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
|
||||
desktop_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
desktop_action.triggered.connect(
|
||||
lambda: self.remove_egs_from_desktop(game_card.name)
|
||||
if os.path.exists(desktop_path)
|
||||
@@ -221,7 +299,7 @@ class ContextMenuManager:
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||
menu_action = menu.addAction(
|
||||
get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
|
||||
self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
|
||||
_("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
|
||||
)
|
||||
menu_action.triggered.connect(
|
||||
@@ -235,19 +313,19 @@ class ContextMenuManager:
|
||||
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
||||
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
|
||||
desktop_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
desktop_action.triggered.connect(
|
||||
lambda: self.remove_from_desktop(game_card.name)
|
||||
if os.path.exists(desktop_path)
|
||||
else self.add_to_desktop(game_card.name, game_card.exec_line)
|
||||
)
|
||||
edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action.triggered.connect(
|
||||
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
|
||||
open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
open_folder_action.triggered.connect(
|
||||
lambda: self.open_game_folder(game_card.name, game_card.exec_line)
|
||||
)
|
||||
@@ -255,7 +333,7 @@ class ContextMenuManager:
|
||||
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||
icon_name = "delete" if os.path.exists(menu_path) else "menu"
|
||||
text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
|
||||
menu_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
menu_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
menu_action.triggered.connect(
|
||||
lambda: self.remove_from_menu(game_card.name)
|
||||
if os.path.exists(menu_path)
|
||||
@@ -264,7 +342,7 @@ class ContextMenuManager:
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
icon_name = "delete" if is_in_steam else "steam"
|
||||
text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: (
|
||||
self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
@@ -273,7 +351,12 @@ class ContextMenuManager:
|
||||
)
|
||||
)
|
||||
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
if actions:
|
||||
menu.setActiveAction(actions[0])
|
||||
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
@@ -323,16 +406,7 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
# Construct EGS launch command
|
||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||
start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||
if self.portproton_location and ".var" not in self.portproton_location:
|
||||
wrapper = start_sh_path
|
||||
if not os.path.exists(start_sh_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("start.sh not found at {path}").format(path=start_sh_path)
|
||||
)
|
||||
return
|
||||
wrapper = get_portproton_start_command()
|
||||
exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
|
||||
else:
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -410,7 +484,7 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
|
||||
# Используем FileExplorer с directory_only=True
|
||||
# Use FileExplorer with directory_only=True
|
||||
file_explorer = FileExplorer(
|
||||
parent=self.parent,
|
||||
theme=self.theme,
|
||||
@@ -440,10 +514,10 @@ class ContextMenuManager:
|
||||
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
|
||||
threading.Thread(target=run_import, daemon=True).start()
|
||||
|
||||
# Подключаем сигнал выбора файла/папки
|
||||
# Connect the file selection signal
|
||||
file_explorer.file_signal.file_selected.connect(on_folder_selected)
|
||||
|
||||
# Центрируем FileExplorer относительно родительского виджета
|
||||
# Center FileExplorer relative to the parent widget
|
||||
parent_widget = self.parent
|
||||
if parent_widget:
|
||||
parent_geometry = parent_widget.geometry()
|
||||
@@ -525,10 +599,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")
|
||||
@@ -668,7 +742,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:
|
||||
@@ -680,7 +754,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:
|
||||
@@ -697,15 +771,12 @@ Icon={icon_path}
|
||||
return None
|
||||
return exec_line
|
||||
|
||||
def _parse_exe_path(self, exec_line, game_name):
|
||||
def _parse_exe_path(self, exec_line: str, game_name: str) -> str | None:
|
||||
"""Parse the executable path from exec_line."""
|
||||
try:
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Invalid executable command: {exec_line}").format(exec_line=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]
|
||||
@@ -714,17 +785,11 @@ Icon={icon_path}
|
||||
else:
|
||||
exe_path = entry_exec_split[-1]
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Executable not found: {path}").format(path=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:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to parse executable: {error}").format(error=str(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=""):
|
||||
@@ -786,9 +851,16 @@ Icon={icon_path}
|
||||
_("Failed to delete custom data: {error}").format(error=str(e))
|
||||
)
|
||||
|
||||
# Перезагрузка списка игр и обновление сетки
|
||||
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."""
|
||||
@@ -863,7 +935,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)
|
||||
@@ -963,7 +1035,15 @@ Icon={icon_path}
|
||||
)
|
||||
return
|
||||
|
||||
if os.path.isfile(new_cover_path):
|
||||
# Check if new_cover_path is a URL by checking for common image extensions
|
||||
image_extensions = ('.jpg', '.jpeg', '.png', '.bmp', '.gif', '.webp')
|
||||
has_image_extension = any(new_cover_path.lower().endswith(ext) for ext in image_extensions)
|
||||
|
||||
# Consider it a URL if it has image extension and is not a local file
|
||||
is_url = has_image_extension and not os.path.isfile(new_cover_path)
|
||||
|
||||
# Use the downloaded file path if we have a URL and the file was downloaded, otherwise use the local file
|
||||
if os.path.isfile(new_cover_path) or (is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path)):
|
||||
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME",
|
||||
@@ -971,16 +1051,25 @@ Icon={icon_path}
|
||||
)
|
||||
custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
ext = os.path.splitext(new_cover_path)[1].lower()
|
||||
|
||||
# Use the actual cover file path (either from URL download or local file)
|
||||
cover_to_copy = dialog.last_cover_path if is_url and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path) else new_cover_path
|
||||
ext = os.path.splitext(cover_to_copy)[1].lower()
|
||||
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
|
||||
try:
|
||||
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
|
||||
shutil.copyfile(cover_to_copy, os.path.join(custom_folder, f"cover{ext}"))
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to copy cover image: {error}").format(error=str(e))
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Unsupported image format: {extension}").format(extension=ext)
|
||||
)
|
||||
return
|
||||
|
||||
def add_to_steam(self, game_name, exec_line, cover_path):
|
||||
"""
|
||||
@@ -999,7 +1088,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(
|
||||
@@ -1042,7 +1131,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(
|
||||
|
||||
|
Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB |
|
Before Width: | Height: | Size: 447 KiB |
@@ -1,3 +0,0 @@
|
||||
name=Pulse Online
|
||||
description_ru=Многопользовательская онлайн-игра в жанре MMORPG, действие которой происходит в научно-фантастическом мире с уникальной боевой системой и глубоким крафтом. Игроки могут исследовать обширные локации, выполнять квесты, сражаться с противниками и взаимодействовать с другими участниками игры.
|
||||
description_en=A multiplayer online game in the MMORPG genre set in a sci-fi world with a unique combat system and deep crafting mechanics. Players can explore vast locations, complete quests, battle enemies, and interact with other participants in the game.
|
||||
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB |
|
Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB |
@@ -5,30 +5,63 @@ 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.2).
|
||||
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 # 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 # 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
|
||||
temp_j = temp_i
|
||||
while temp_j < N:
|
||||
w = nat_sizes[temp_j, 0]
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
||||
break
|
||||
sum_width += w
|
||||
count += 1
|
||||
temp_j += 1
|
||||
|
||||
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
|
||||
# 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]
|
||||
# Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width:
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
||||
break
|
||||
sum_width += w
|
||||
count += 1
|
||||
@@ -36,13 +69,19 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
if h > row_max_height:
|
||||
row_max_height = h
|
||||
j += 1
|
||||
# Доступная ширина ряда с учетом обязательных отступов между элементами
|
||||
available_width = rect_width - spacing * (count - 1)
|
||||
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
|
||||
# Разрешаем увеличение карточек, но не более max_scale
|
||||
scale = desired_scale if desired_scale < max_scale else max_scale
|
||||
# Выравниваем по левому краю (offset = 0)
|
||||
x = 0
|
||||
|
||||
# Use global scale for all rows
|
||||
scale = global_scale
|
||||
scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
|
||||
|
||||
# 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):
|
||||
new_w = int(nat_sizes[k, 0] * scale)
|
||||
new_h = int(nat_sizes[k, 1] * scale)
|
||||
@@ -51,6 +90,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
result[k, 2] = new_w
|
||||
result[k, 3] = new_h
|
||||
x += new_w + spacing
|
||||
|
||||
y += int(row_max_height * scale) + spacing
|
||||
i = j
|
||||
return result, y
|
||||
@@ -59,18 +99,17 @@ class FlowLayout(QLayout):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.itemList = []
|
||||
# Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self._spacing = 3 # отступ между карточками
|
||||
self._max_scale = 1.2 # максимальное увеличение карточек (например, на 20%)
|
||||
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)
|
||||
self.itemList.append(item)
|
||||
|
||||
def takeAt(self, index: int) -> QLayoutItem:
|
||||
if 0 <= index < len(self.itemList):
|
||||
return self.itemList.pop(index)
|
||||
raise IndexError("Index out of range")
|
||||
if 0 <= index < len(self.itemList):
|
||||
return self.itemList.pop(index)
|
||||
raise IndexError("Index out of range")
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self.itemList)
|
||||
@@ -87,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)
|
||||
@@ -102,32 +155,50 @@ class FlowLayout(QLayout):
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
margins = self.contentsMargins()
|
||||
size += QSize(margins.left() + margins.right(),
|
||||
margins.top() + margins.bottom())
|
||||
margins.top() + margins.bottom())
|
||||
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
|
||||
|
||||
# Собираем натуральные размеры всех элементов в массив NumPy
|
||||
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
|
||||
|
||||
# Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
|
||||
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):
|
||||
@@ -152,7 +223,7 @@ class ClickableLabel(QLabel):
|
||||
self._icon_size = icon_size
|
||||
self._icon_space = icon_space
|
||||
self._font_scale_factor = font_scale_factor
|
||||
self._card_width = 250 # Значение по умолчанию
|
||||
self._card_width = 250
|
||||
if change_cursor:
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.updateFontSize()
|
||||
@@ -170,28 +241,23 @@ class ClickableLabel(QLabel):
|
||||
self.update()
|
||||
|
||||
def setCardWidth(self, card_width: int):
|
||||
"""Обновляет ширину карточки и пересчитывает размер шрифта."""
|
||||
self._card_width = card_width
|
||||
self.updateFontSize()
|
||||
|
||||
def updateFontSize(self):
|
||||
"""Обновляет размер шрифта на основе card_width и font_scale_factor."""
|
||||
font = self.font()
|
||||
font_size = int(self._card_width * self._font_scale_factor)
|
||||
font.setPointSize(max(8, font_size)) # Минимальный размер шрифта 8
|
||||
font.setPointSize(max(8, font_size))
|
||||
self.setFont(font)
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event):
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
rect = self.contentsRect()
|
||||
alignment = self.alignment()
|
||||
|
||||
icon_size = self._icon_size
|
||||
spacing = self._icon_space
|
||||
|
||||
text = self.text()
|
||||
|
||||
if self._icon:
|
||||
@@ -200,17 +266,11 @@ class ClickableLabel(QLabel):
|
||||
pixmap = None
|
||||
|
||||
fm = QFontMetrics(self.font())
|
||||
|
||||
# Считаем, сколько места остаётся под текст
|
||||
available_width = rect.width()
|
||||
if pixmap:
|
||||
available_width -= (icon_size + spacing)
|
||||
# Отступы по 2px с каждой стороны
|
||||
available_width = max(0, available_width - 4)
|
||||
|
||||
# Получаем «обрезанный» текст с многоточием
|
||||
display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
|
||||
|
||||
text_width = fm.horizontalAdvance(display_text)
|
||||
text_height = fm.height()
|
||||
total_width = text_width + (icon_size + spacing if pixmap else 0)
|
||||
@@ -280,8 +340,6 @@ class AutoSizeButton(QPushButton):
|
||||
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setFlat(True)
|
||||
|
||||
# Изначально выставляем минимальную ширину
|
||||
self.setMinimumWidth(50)
|
||||
self.adjustFontSize()
|
||||
|
||||
@@ -312,7 +370,6 @@ class AutoSizeButton(QPushButton):
|
||||
if not self._update_size:
|
||||
return
|
||||
|
||||
# Определяем доступную ширину внутри кнопки
|
||||
available_width = self.width()
|
||||
if self._icon:
|
||||
available_width -= self._icon_size
|
||||
@@ -323,7 +380,6 @@ class AutoSizeButton(QPushButton):
|
||||
font = QFont(self._original_font)
|
||||
text = self._original_text
|
||||
|
||||
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
|
||||
chosen_size = self._max_font_size
|
||||
for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
|
||||
font.setPointSize(font_size)
|
||||
@@ -336,14 +392,12 @@ class AutoSizeButton(QPushButton):
|
||||
font.setPointSize(chosen_size)
|
||||
self.setFont(font)
|
||||
|
||||
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
|
||||
fm = QFontMetrics(font)
|
||||
text_width = fm.horizontalAdvance(text)
|
||||
required_width = text_width + margins.left() + margins.right() + self._padding * 2
|
||||
if self._icon:
|
||||
required_width += self._icon_size
|
||||
|
||||
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
|
||||
if self.width() < required_width:
|
||||
self.setMinimumWidth(required_width)
|
||||
|
||||
@@ -353,7 +407,6 @@ class AutoSizeButton(QPushButton):
|
||||
if not self._update_size:
|
||||
return super().sizeHint()
|
||||
else:
|
||||
# Вычисляем оптимальный размер кнопки на основе текста и отступов
|
||||
font = self.font()
|
||||
fm = QFontMetrics(font)
|
||||
text_width = fm.horizontalAdvance(self._original_text)
|
||||
@@ -364,7 +417,6 @@ class AutoSizeButton(QPushButton):
|
||||
height = fm.height() + margins.top() + margins.bottom() + self._padding
|
||||
return QSize(width, height)
|
||||
|
||||
|
||||
class NavLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
@@ -376,7 +428,6 @@ class NavLabel(QLabel):
|
||||
self._isChecked = False
|
||||
self.setProperty("checked", self._isChecked)
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
# Explicitly enable focus
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
def setCheckable(self, checkable):
|
||||
@@ -395,7 +446,6 @@ class NavLabel(QLabel):
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
# Ensure widget can take focus on click
|
||||
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
if self._checkable:
|
||||
self.setChecked(not self._isChecked)
|
||||
|
||||
@@ -144,14 +144,21 @@ class Downloader(QObject):
|
||||
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
|
||||
return None
|
||||
if url in self._cache:
|
||||
return self._cache[url]
|
||||
cached_path = self._cache[url]
|
||||
if os.path.exists(cached_path):
|
||||
if os.path.abspath(cached_path) == os.path.abspath(local_path):
|
||||
return cached_path
|
||||
else:
|
||||
del self._cache[url]
|
||||
url_lock = self._get_url_lock(url)
|
||||
with url_lock:
|
||||
with self._global_lock:
|
||||
if url in self._last_error:
|
||||
return None
|
||||
if url in self._cache:
|
||||
return self._cache[url]
|
||||
cached_path = self._cache[url]
|
||||
if os.path.exists(cached_path) and os.path.abspath(cached_path) == os.path.abspath(local_path):
|
||||
return cached_path
|
||||
result = download_with_cache(url, local_path, timeout, self)
|
||||
with self._global_lock:
|
||||
if result:
|
||||
|
||||
@@ -13,16 +13,17 @@ from portprotonqt.localization import get_egs_language, _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
|
||||
from portprotonqt.steam_api import (
|
||||
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
|
||||
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail
|
||||
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
|
||||
)
|
||||
import vdf
|
||||
import shutil
|
||||
import zlib
|
||||
from portprotonqt.downloader import Downloader
|
||||
from PySide6.QtGui import QPixmap
|
||||
import base64
|
||||
|
||||
logger = get_logger(__name__)
|
||||
downloader = Downloader()
|
||||
@@ -66,7 +67,8 @@ def get_cache_dir() -> Path:
|
||||
|
||||
def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
||||
"""
|
||||
Removes an EGS game from Steam by modifying shortcuts.vdf and deleting the launch script.
|
||||
Removes an EGS game from Steam using CEF API or by modifying shortcuts.vdf and deleting the launch script.
|
||||
Also deletes associated cover files in the Steam grid directory.
|
||||
Calls the callback with (success, message).
|
||||
|
||||
Args:
|
||||
@@ -74,6 +76,7 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
||||
portproton_dir: Path to the PortProton directory.
|
||||
callback: Callback function to handle the result (success, message).
|
||||
"""
|
||||
|
||||
if not portproton_dir:
|
||||
logger.error("PortProton directory not found")
|
||||
callback((False, "PortProton directory not found"))
|
||||
@@ -101,51 +104,89 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
user_dir = os.path.join(userdata_dir, str(unsigned_id))
|
||||
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
grid_dir = os.path.join(user_dir, "config", "grid")
|
||||
|
||||
if not os.path.exists(steam_shortcuts_path):
|
||||
logger.error("Steam shortcuts file not found")
|
||||
callback((False, "Steam shortcuts file not found"))
|
||||
return
|
||||
|
||||
# Find appid for the shortcut
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'rb') as f:
|
||||
shortcuts_data = vdf.binary_load(f)
|
||||
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||
appid = None
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||
appid = convert_steam_id(int(entry.get("appid")))
|
||||
logger.info(f"Found matching shortcut for '{game_name}' with AppID {appid}")
|
||||
break
|
||||
if not appid:
|
||||
logger.info(f"Game '{game_name}' not found in Steam shortcuts")
|
||||
callback((False, f"Game '{game_name}' not found in Steam"))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to load shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
# Try CEF API first
|
||||
logger.info(f"Attempting to remove EGS game '{game_name}' via Steam CEF API with AppID {appid}")
|
||||
api_response = call_steam_api("removeShortcut", appid)
|
||||
if api_response is not None: # API responded, even if empty
|
||||
logger.info(f"Shortcut for AppID {appid} successfully removed via CEF API")
|
||||
|
||||
# Delete cover files
|
||||
cover_files = [
|
||||
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
try:
|
||||
os.remove(cover_file)
|
||||
logger.info(f"Deleted cover file: {cover_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete cover file {cover_file}: {e}")
|
||||
|
||||
# Delete launch script
|
||||
if os.path.exists(script_path):
|
||||
try:
|
||||
os.remove(script_path)
|
||||
logger.info(f"Removed EGS script: {script_path}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
|
||||
|
||||
callback((True, f"Game '{game_name}' was removed from Steam. Please restart Steam for changes to take effect."))
|
||||
return
|
||||
|
||||
# Fallback to VDF modification
|
||||
logger.warning("CEF API failed for EGS game removal; falling back to VDF modification")
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info("Created backup of shortcuts.vdf at %s", backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'rb') as f:
|
||||
shortcuts_data = vdf.binary_load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to load shortcuts.vdf: {e}"))
|
||||
return
|
||||
new_shortcuts = {}
|
||||
index = 0
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||
logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
|
||||
continue
|
||||
new_shortcuts[str(index)] = entry
|
||||
index += 1
|
||||
|
||||
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||
modified = False
|
||||
new_shortcuts = {}
|
||||
index = 0
|
||||
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||
modified = True
|
||||
logger.info("Removing EGS game '%s' from Steam shortcuts", game_name)
|
||||
continue
|
||||
new_shortcuts[str(index)] = entry
|
||||
index += 1
|
||||
|
||||
if not modified:
|
||||
logger.error("Game '%s' not found in Steam shortcuts", game_name)
|
||||
callback((False, f"Game '{game_name}' not found in Steam shortcuts"))
|
||||
return
|
||||
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
||||
logger.info("Updated shortcuts.vdf, removed '%s'", game_name)
|
||||
logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||
if os.path.exists(backup_path):
|
||||
@@ -157,10 +198,26 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
||||
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
# Delete cover files
|
||||
cover_files = [
|
||||
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
try:
|
||||
os.remove(cover_file)
|
||||
logger.info(f"Deleted cover file: {cover_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete cover file {cover_file}: {e}")
|
||||
|
||||
# Delete launch script
|
||||
if os.path.exists(script_path):
|
||||
try:
|
||||
os.remove(script_path)
|
||||
logger.info("Removed EGS script: %s", script_path)
|
||||
logger.info(f"Removed EGS script: {script_path}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
|
||||
|
||||
@@ -168,11 +225,17 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
||||
|
||||
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
||||
"""
|
||||
Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag.
|
||||
Asynchronously adds an EGS game to Steam via CEF API or shortcuts.vdf with PortProton tag.
|
||||
Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
|
||||
Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
|
||||
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
|
||||
Calls the callback with (success, message).
|
||||
|
||||
Args:
|
||||
app_name: The Legendary app_name (unique identifier for the game).
|
||||
game_title: The display name of the game.
|
||||
legendary_path: Path to the Legendary CLI executable.
|
||||
callback: Callback function to handle the result (success, message).
|
||||
"""
|
||||
if not app_name or not app_name.strip() or not game_title or not game_title.strip():
|
||||
logger.error("Invalid app_name or game_title: empty or whitespace")
|
||||
@@ -191,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
|
||||
return
|
||||
|
||||
# Determine wrapper
|
||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||
start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
|
||||
if portproton_dir is not None and ".var" not in portproton_dir:
|
||||
wrapper = start_sh_path
|
||||
if not os.path.exists(start_sh_path):
|
||||
logger.error(f"start.sh not found at {start_sh_path}")
|
||||
callback((False, f"start.sh not found at {start_sh_path}"))
|
||||
return
|
||||
wrapper = get_portproton_start_command()
|
||||
|
||||
# Create launch script
|
||||
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
|
||||
@@ -267,47 +323,47 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
grid_dir = user_dir / "config" / "grid"
|
||||
os.makedirs(grid_dir, exist_ok=True)
|
||||
|
||||
# Backup shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||
return
|
||||
# Try CEF API first
|
||||
logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API")
|
||||
api_response = call_steam_api(
|
||||
"createShortcut",
|
||||
game_title,
|
||||
script_path,
|
||||
str(Path(script_path).parent),
|
||||
icon_path,
|
||||
""
|
||||
)
|
||||
|
||||
# Generate unique appid
|
||||
unique_string = f"{script_path}{game_title}"
|
||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||
appid = baseid | 0x80000000
|
||||
if appid > 0x7FFFFFFF:
|
||||
aidvdf = appid - 0x100000000
|
||||
appid = None
|
||||
was_api_used = False
|
||||
|
||||
if api_response and isinstance(api_response, dict) and 'id' in api_response:
|
||||
appid = api_response['id']
|
||||
was_api_used = True
|
||||
logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}")
|
||||
else:
|
||||
aidvdf = appid
|
||||
logger.warning("CEF API failed for EGS game addition; falling back to VDF modification")
|
||||
# Backup shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
steam_appid = None
|
||||
downloaded_count = 0
|
||||
total_covers = 4
|
||||
download_lock = threading.Lock()
|
||||
# Generate unique appid
|
||||
unique_string = f"{script_path}{game_title}"
|
||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||
appid = baseid | 0x80000000
|
||||
if appid > 0x7FFFFFFF:
|
||||
aidvdf = appid - 0x100000000
|
||||
else:
|
||||
aidvdf = appid
|
||||
|
||||
def on_cover_download(cover_file: str, cover_type: str):
|
||||
nonlocal downloaded_count
|
||||
try:
|
||||
if cover_file and os.path.exists(cover_file):
|
||||
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
|
||||
else:
|
||||
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
|
||||
with download_lock:
|
||||
downloaded_count += 1
|
||||
if downloaded_count == total_covers:
|
||||
finalize_shortcut()
|
||||
|
||||
def finalize_shortcut():
|
||||
tags_dict = {'0': 'PortProton'}
|
||||
# Create shortcut entry
|
||||
shortcut = {
|
||||
"appid": aidvdf,
|
||||
"AppName": game_title,
|
||||
@@ -322,7 +378,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
"Devkit": 0,
|
||||
"DevkitGameID": "",
|
||||
"LastPlayTime": 0,
|
||||
"tags": tags_dict
|
||||
"tags": {'0': 'PortProton'}
|
||||
}
|
||||
logger.info(f"Shortcut entry for EGS game: {shortcut}")
|
||||
|
||||
@@ -353,6 +409,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
||||
logger.info(f"EGS game '{game_title}' added to Steam via VDF")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||
if os.path.exists(backup_path):
|
||||
@@ -364,8 +421,42 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
logger.info(f"EGS game '{game_title}' added to Steam")
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
if not appid:
|
||||
callback((False, "Failed to create shortcut via any method"))
|
||||
return
|
||||
|
||||
steam_appid = None
|
||||
downloaded_count = 0
|
||||
total_covers = 4
|
||||
download_lock = threading.Lock()
|
||||
|
||||
def on_cover_download(cover_file: str | None, cover_type: str, index: int):
|
||||
nonlocal downloaded_count
|
||||
try:
|
||||
if cover_file is None or not os.path.exists(cover_file):
|
||||
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
|
||||
with download_lock:
|
||||
downloaded_count += 1
|
||||
if downloaded_count == total_covers:
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
return
|
||||
|
||||
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
|
||||
if was_api_used:
|
||||
try:
|
||||
with open(cover_file, 'rb') as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
logger.info(f"Applying cover type '{cover_type}' via API for AppID {appid}")
|
||||
ext = Path(cover_type).suffix.lstrip('.')
|
||||
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying cover '{cover_type}' via API: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
|
||||
with download_lock:
|
||||
downloaded_count += 1
|
||||
if downloaded_count == total_covers:
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
|
||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||
nonlocal steam_appid
|
||||
@@ -375,24 +466,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
|
||||
if not steam_appid:
|
||||
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
|
||||
finalize_shortcut()
|
||||
callback((True, f"Game '{game_title}' added to Steam"))
|
||||
return
|
||||
|
||||
cover_types = [
|
||||
(".jpg", "header.jpg"),
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
("_hero.jpg", "library_hero.jpg"),
|
||||
("_logo.png", "logo.png")
|
||||
(".jpg", "header.jpg", 0),
|
||||
("p.jpg", "library_600x900_2x.jpg", 1),
|
||||
("_hero.jpg", "library_hero.jpg", 2),
|
||||
("_logo.png", "logo.png", 3)
|
||||
]
|
||||
|
||||
for suffix, cover_type in cover_types:
|
||||
for suffix, cover_type, index in cover_types:
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
|
||||
callback=lambda result, ctype=cover_type, idx=index: on_cover_download(result, ctype, idx)
|
||||
)
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps)
|
||||
@@ -486,7 +577,7 @@ def get_egs_game_description_async(
|
||||
"https://launcher.store.epicgames.com/graphql",
|
||||
json=search_query,
|
||||
headers=headers,
|
||||
timeout=5
|
||||
timeout=10
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = orjson.loads(response.content)
|
||||
@@ -506,7 +597,7 @@ def get_egs_game_description_async(
|
||||
def fetch_legacy_description(url: str) -> str:
|
||||
"""Fetches description from the legacy API, handling DNS failures."""
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=5)
|
||||
response = requests.get(url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = orjson.loads(response.content)
|
||||
if not isinstance(data, dict):
|
||||
@@ -528,6 +619,9 @@ def get_egs_game_description_async(
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
logger.error("DNS resolution failed for legacy API %s: %s", url, str(e))
|
||||
return ""
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("Request timeout for legacy API %s", url)
|
||||
return ""
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to fetch legacy API for %s: %s", app_name, str(e))
|
||||
return ""
|
||||
@@ -579,7 +673,7 @@ def get_egs_game_description_async(
|
||||
url = "https://graphql.epicgames.com/graphql"
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=search_query, headers=headers, timeout=5)
|
||||
response = requests.post(url, json=search_query, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
data = orjson.loads(response.content)
|
||||
if namespace:
|
||||
@@ -598,6 +692,9 @@ def get_egs_game_description_async(
|
||||
for substring in ["bundle", "pack", "edition", "dlc", "upgrade", "chapter", "набор", "пак", "дополнение"])):
|
||||
return element.get("description", ""), element.get("productSlug", "")
|
||||
return "", ""
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("GraphQL request timeout for %s with locale %s", app_name, locale)
|
||||
return "", ""
|
||||
except requests.RequestException as e:
|
||||
logger.warning("Failed to fetch GraphQL data for %s with locale %s: %s", app_name, locale, str(e))
|
||||
return "", ""
|
||||
@@ -626,6 +723,10 @@ def get_egs_game_description_async(
|
||||
logger.debug("Fetched description from legacy API for %s: %s", app_name, (description[:100] + "...") if len(description) > 100 else description)
|
||||
except requests.exceptions.ConnectionError:
|
||||
logger.error("Skipping legacy API due to DNS resolution failure for %s", app_name)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning("Legacy API request timed out for %s", app_name)
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error fetching legacy API for %s: %s", app_name, str(e))
|
||||
|
||||
# Step 3: If still no description and no namespace, try GraphQL with title
|
||||
if not description and not namespace:
|
||||
@@ -747,6 +848,11 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
games: list[tuple] = []
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
user_json_path = cache_dir / "user.json"
|
||||
if not user_json_path.exists():
|
||||
callback(games)
|
||||
return
|
||||
|
||||
def process_games(installed_games: list | None):
|
||||
if installed_games is None:
|
||||
logger.info("No installed Epic Games Store games found")
|
||||
@@ -855,12 +961,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
app_name,
|
||||
f"legendary:launch:{app_name}",
|
||||
"",
|
||||
last_launch, # Время последнего запуска
|
||||
formatted_playtime, # Форматированное время игры
|
||||
protondb_tier, # ProtonDB tier
|
||||
last_launch,
|
||||
formatted_playtime,
|
||||
protondb_tier,
|
||||
status or "",
|
||||
last_launch_timestamp, # Временная метка последнего запуска
|
||||
playtime_seconds, # Время игры в секундах
|
||||
last_launch_timestamp,
|
||||
playtime_seconds,
|
||||
"epic"
|
||||
)
|
||||
pending_images -= 1
|
||||
@@ -880,7 +986,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
get_protondb_tier_async(steam_appid, on_protondb_tier)
|
||||
else:
|
||||
logger.debug(f"No Steam app found for EGS game {title}")
|
||||
on_protondb_tier("") # Proceed with empty ProtonDB tier
|
||||
on_protondb_tier("")
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps)
|
||||
|
||||
|
||||
@@ -1,38 +1,34 @@
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices
|
||||
from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl
|
||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
|
||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
||||
from collections.abc import Callable
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
|
||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
from portprotonqt.custom_widgets import ClickableLabel
|
||||
from portprotonqt.portproton_api import PortProtonAPI
|
||||
from portprotonqt.downloader import Downloader
|
||||
import weakref
|
||||
from typing import cast
|
||||
from portprotonqt.animations import GameCardAnimations
|
||||
|
||||
class GameCard(QFrame):
|
||||
borderWidthChanged = Signal()
|
||||
gradientAngleChanged = Signal()
|
||||
# Signals for context menu actions
|
||||
editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path
|
||||
deleteGameRequested = Signal(str, str) # name, exec_line
|
||||
addToMenuRequested = Signal(str, str) # name, exec_line
|
||||
removeFromMenuRequested = Signal(str) # name
|
||||
addToDesktopRequested = Signal(str, str) # name, exec_line
|
||||
removeFromDesktopRequested = Signal(str) # name
|
||||
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path
|
||||
removeFromSteamRequested = Signal(str, str) # name, exec_line
|
||||
openGameFolderRequested = Signal(str, str) # name, exec_line
|
||||
scaleChanged = Signal()
|
||||
editShortcutRequested = Signal(str, str, str)
|
||||
deleteGameRequested = Signal(str, str)
|
||||
addToMenuRequested = Signal(str, str)
|
||||
removeFromMenuRequested = Signal(str)
|
||||
addToDesktopRequested = Signal(str, str)
|
||||
removeFromDesktopRequested = Signal(str)
|
||||
addToSteamRequested = Signal(str, str, str)
|
||||
removeFromSteamRequested = Signal(str, str)
|
||||
openGameFolderRequested = Signal(str, str)
|
||||
hoverChanged = Signal(str, bool)
|
||||
focusChanged = Signal(str, bool)
|
||||
|
||||
def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
|
||||
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
|
||||
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
|
||||
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
|
||||
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
|
||||
super().__init__(parent)
|
||||
self.name = name
|
||||
self.description = description
|
||||
@@ -47,14 +43,16 @@ class GameCard(QFrame):
|
||||
self.game_source = game_source
|
||||
self.last_launch_ts = last_launch_ts
|
||||
self.playtime_seconds = playtime_seconds
|
||||
self.card_width = card_width
|
||||
self.base_card_width = card_width
|
||||
self.base_pixmap = None
|
||||
self.base_font_size = None
|
||||
|
||||
self.select_callback = select_callback
|
||||
self.context_menu_manager = context_menu_manager
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else default_styles
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
|
||||
self.display_filter = read_display_filter()
|
||||
self.current_theme_name = read_theme_from_config()
|
||||
@@ -65,80 +63,46 @@ class GameCard(QFrame):
|
||||
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
||||
self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
|
||||
|
||||
# Дополнительное пространство для анимации
|
||||
extra_margin = 20
|
||||
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
|
||||
self.base_extra_margin = 20
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
||||
|
||||
# Параметры анимации обводки
|
||||
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
|
||||
# Анимации
|
||||
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
self.gradient_anim = None
|
||||
self.pulse_anim = None
|
||||
self.animations = GameCardAnimations(self, self.theme)
|
||||
self.animations.setup_animations()
|
||||
|
||||
# Флаг для отслеживания подключения слота startPulseAnimation
|
||||
self._isPulseAnimationConnected = False
|
||||
self.shadow = QGraphicsDropShadowEffect(self)
|
||||
self.shadow.setBlurRadius(20)
|
||||
self.shadow.setColor(QColor(0, 0, 0, 150))
|
||||
self.shadow.setOffset(0, 0)
|
||||
self.setGraphicsEffect(self.shadow)
|
||||
|
||||
# Тень
|
||||
shadow = QGraphicsDropShadowEffect(self)
|
||||
shadow.setBlurRadius(20)
|
||||
shadow.setColor(QColor(0, 0, 0, 150))
|
||||
shadow.setOffset(0, 0)
|
||||
self.setGraphicsEffect(shadow)
|
||||
self.layout_ = QVBoxLayout(self)
|
||||
self.layout_.setSpacing(5)
|
||||
self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
|
||||
|
||||
# Отступы
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2)
|
||||
layout.setSpacing(5)
|
||||
|
||||
# Контейнер обложки
|
||||
coverWidget = QWidget()
|
||||
coverWidget.setFixedSize(card_width, int(card_width * 1.2))
|
||||
coverLayout = QStackedLayout(coverWidget)
|
||||
self.coverWidget = QWidget()
|
||||
coverLayout = QStackedLayout(self.coverWidget)
|
||||
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||
coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
|
||||
|
||||
# Обложка
|
||||
self.coverLabel = QLabel()
|
||||
self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
|
||||
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
|
||||
coverLayout.addWidget(self.coverLabel)
|
||||
|
||||
# создаём слабую ссылку на label
|
||||
label_ref = weakref.ref(self.coverLabel)
|
||||
load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded)
|
||||
|
||||
def on_cover_loaded(pixmap):
|
||||
label = label_ref()
|
||||
if label is None:
|
||||
return
|
||||
label.setPixmap(round_corners(pixmap, 15))
|
||||
|
||||
# асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async)
|
||||
load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded)
|
||||
|
||||
# Значок избранного (звёздочка) в левом верхнем углу обложки
|
||||
self.favoriteLabel = ClickableLabel(coverWidget)
|
||||
self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize)
|
||||
self.favoriteLabel.move(8, 8)
|
||||
self.favoriteLabel = ClickableLabel(self.coverWidget)
|
||||
self.favoriteLabel.clicked.connect(self.toggle_favorite)
|
||||
self.is_favorite = self.name in read_favorites()
|
||||
self.is_favorite = self.name in set(read_favorites())
|
||||
self.update_favorite_icon()
|
||||
self.favoriteLabel.raise_()
|
||||
|
||||
# Определяем общие параметры для бейджей
|
||||
badge_width = int(card_width * 2/3)
|
||||
icon_size = int(card_width * 0.06) # 6% от ширины карточки
|
||||
icon_space = int(card_width * 0.012) # 1.2% от ширины карточки
|
||||
font_scale_factor = 0.06 # Шрифт будет 6% от card_width
|
||||
|
||||
# ProtonDB бейдж
|
||||
tier_text = self.getProtonDBText(protondb_tier)
|
||||
if tier_text:
|
||||
icon_filename = self.getProtonDBIconFilename(protondb_tier)
|
||||
@@ -146,67 +110,50 @@ class GameCard(QFrame):
|
||||
self.protondbLabel = ClickableLabel(
|
||||
tier_text,
|
||||
icon=icon,
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor
|
||||
parent=self.coverWidget,
|
||||
font_scale_factor=0.06
|
||||
)
|
||||
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
|
||||
self.protondbLabel.setFixedWidth(badge_width)
|
||||
self.protondbLabel.setCardWidth(card_width)
|
||||
else:
|
||||
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
|
||||
self.protondbLabel.setFixedWidth(badge_width)
|
||||
self.protondbLabel = ClickableLabel("", parent=self.coverWidget)
|
||||
self.protondbLabel.setVisible(False)
|
||||
|
||||
# Steam бейдж
|
||||
steam_icon = self.theme_manager.get_icon("steam")
|
||||
self.steamLabel = ClickableLabel(
|
||||
"Steam",
|
||||
icon=steam_icon,
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor
|
||||
parent=self.coverWidget,
|
||||
font_scale_factor=0.06
|
||||
)
|
||||
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||
self.steamLabel.setFixedWidth(badge_width)
|
||||
self.steamLabel.setCardWidth(card_width)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
|
||||
# Epic Games Store бейдж
|
||||
egs_icon = self.theme_manager.get_icon("epic_games")
|
||||
self.egsLabel = ClickableLabel(
|
||||
"Epic Games",
|
||||
icon=egs_icon,
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor,
|
||||
parent=self.coverWidget,
|
||||
font_scale_factor=0.06,
|
||||
change_cursor=False
|
||||
)
|
||||
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||
self.egsLabel.setFixedWidth(badge_width)
|
||||
self.egsLabel.setCardWidth(card_width)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
|
||||
# PortProton бейдж
|
||||
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
|
||||
portproton_icon = self.theme_manager.get_icon("portproton")
|
||||
self.portprotonLabel = ClickableLabel(
|
||||
"PortProton",
|
||||
icon=portproton_icon,
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor
|
||||
parent=self.coverWidget,
|
||||
font_scale_factor=0.06
|
||||
)
|
||||
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||
self.portprotonLabel.setFixedWidth(badge_width)
|
||||
self.portprotonLabel.setCardWidth(card_width)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
|
||||
|
||||
# WeAntiCheatYet бейдж
|
||||
anticheat_text = self.getAntiCheatText(anticheat_status)
|
||||
if anticheat_text:
|
||||
icon_filename = self.getAntiCheatIconFilename(anticheat_status)
|
||||
@@ -214,40 +161,71 @@ class GameCard(QFrame):
|
||||
self.anticheatLabel = ClickableLabel(
|
||||
anticheat_text,
|
||||
icon=icon,
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor
|
||||
parent=self.coverWidget,
|
||||
font_scale_factor=0.06
|
||||
)
|
||||
self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
|
||||
self.anticheatLabel.setFixedWidth(badge_width)
|
||||
self.anticheatLabel.setCardWidth(card_width)
|
||||
else:
|
||||
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
|
||||
self.anticheatLabel.setFixedWidth(badge_width)
|
||||
self.anticheatLabel = ClickableLabel("", parent=self.coverWidget)
|
||||
self.anticheatLabel.setVisible(False)
|
||||
|
||||
# Расположение бейджей
|
||||
self._position_badges(card_width)
|
||||
self.protondbLabel.clicked.connect(self.open_protondb_report)
|
||||
self.steamLabel.clicked.connect(self.open_steam_page)
|
||||
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
|
||||
|
||||
layout.addWidget(coverWidget)
|
||||
self.layout_.addWidget(self.coverWidget)
|
||||
|
||||
# Название игры
|
||||
nameLabel = QLabel(name)
|
||||
nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||
layout.addWidget(nameLabel)
|
||||
self.nameLabel = QLabel(name)
|
||||
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||
self.layout_.addWidget(self.nameLabel)
|
||||
|
||||
def _position_badges(self, card_width):
|
||||
"""Позиционирует бейджи на основе ширины карточки."""
|
||||
right_margin = 8
|
||||
badge_spacing = int(card_width * 0.02) # 2% от ширины карточки
|
||||
top_y = 10
|
||||
font_size = self.nameLabel.font().pointSizeF()
|
||||
self.base_font_size = font_size if font_size > 0 else 10.0
|
||||
|
||||
self.update_scale()
|
||||
|
||||
# Force initial layout update to ensure correct geometry
|
||||
self.updateGeometry()
|
||||
parent = self.parentWidget()
|
||||
if parent:
|
||||
layout = parent.layout()
|
||||
if layout:
|
||||
layout.invalidate()
|
||||
parent.updateGeometry()
|
||||
|
||||
def on_cover_loaded(self, pixmap):
|
||||
self.base_pixmap = pixmap
|
||||
self.update_cover_pixmap()
|
||||
|
||||
def update_cover_pixmap(self):
|
||||
# Check if the coverLabel still exists before trying to update it
|
||||
# This prevents the "Internal C++ object already deleted" error when
|
||||
# the widget has been destroyed but the async callback still executes
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
if self.base_pixmap and not self.base_pixmap.isNull():
|
||||
scaled_width = int(self.base_card_width * self._scale)
|
||||
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
||||
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
|
||||
try:
|
||||
self.coverLabel.setPixmap(rounded_pixmap)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted between the check and the call
|
||||
pass
|
||||
|
||||
def _position_badges(self, current_width):
|
||||
# Check if the card has been destroyed before updating
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
right_margin = int(8 * self._scale)
|
||||
badge_spacing = int(current_width * 0.02)
|
||||
top_y = int(10 * self._scale)
|
||||
badge_y_positions = []
|
||||
badge_width = int(card_width * 2/3)
|
||||
badge_width = int(current_width * 2/3)
|
||||
|
||||
badges = [
|
||||
(self.steam_visible, self.steamLabel),
|
||||
@@ -259,80 +237,139 @@ class GameCard(QFrame):
|
||||
|
||||
for is_visible, badge in badges:
|
||||
if is_visible:
|
||||
badge_x = card_width - badge_width - right_margin
|
||||
badge_x = current_width - badge_width - right_margin
|
||||
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
badge.move(badge_x, badge_y)
|
||||
badge_y_positions.append(badge_y + badge.height())
|
||||
try:
|
||||
badge.move(int(badge_x), int(badge_y))
|
||||
badge_y_positions.append(badge_y + badge.height())
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
# Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
|
||||
self.anticheatLabel.raise_()
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
try:
|
||||
self.anticheatLabel.raise_()
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
def update_card_size(self, new_width: int):
|
||||
"""Обновляет размер карточки, обложки и бейджей."""
|
||||
self.card_width = new_width
|
||||
extra_margin = 20
|
||||
self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin)
|
||||
|
||||
if self.coverLabel is None:
|
||||
def update_scale(self):
|
||||
# Check if the card has been destroyed before updating
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
coverWidget = self.coverLabel.parentWidget()
|
||||
if coverWidget is None:
|
||||
return
|
||||
scaled_width = int(self.base_card_width * self._scale)
|
||||
scaled_height = int(self.base_card_width * 1.8 * self._scale)
|
||||
scaled_extra = int(self.base_extra_margin * self._scale)
|
||||
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
|
||||
self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
|
||||
|
||||
coverWidget.setFixedSize(new_width, int(new_width * 1.2))
|
||||
self.coverLabel.setFixedSize(new_width, int(new_width * 1.2))
|
||||
self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
|
||||
self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
|
||||
|
||||
label_ref = weakref.ref(self.coverLabel)
|
||||
def on_cover_loaded(pixmap):
|
||||
label = label_ref()
|
||||
if label:
|
||||
scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
||||
rounded_pixmap = round_corners(scaled_pixmap, 15)
|
||||
label.setPixmap(rounded_pixmap)
|
||||
self.update_cover_pixmap()
|
||||
|
||||
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded)
|
||||
favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale))
|
||||
self.favoriteLabel.setFixedSize(*favorite_size)
|
||||
self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale))
|
||||
|
||||
# Обновляем размеры и шрифты бейджей
|
||||
badge_width = int(new_width * 2/3)
|
||||
icon_size = int(new_width * 0.06)
|
||||
icon_space = int(new_width * 0.012)
|
||||
badge_width = int(scaled_width * 2/3)
|
||||
icon_size = int(scaled_width * 0.06)
|
||||
icon_space = int(scaled_width * 0.012)
|
||||
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
|
||||
if label is not None:
|
||||
label.setFixedWidth(badge_width)
|
||||
label.setIconSize(icon_size, icon_space)
|
||||
label.setCardWidth(new_width) # Пересчитываем размер шрифта
|
||||
try:
|
||||
label.setFixedWidth(badge_width)
|
||||
label.setIconSize(icon_size, icon_space)
|
||||
label.setCardWidth(scaled_width)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
# Перепозиционируем бейджи
|
||||
self._position_badges(new_width)
|
||||
self._position_badges(scaled_width)
|
||||
|
||||
self.update()
|
||||
if self.base_font_size is not None:
|
||||
try:
|
||||
font = self.nameLabel.font()
|
||||
new_font_size = self.base_font_size * self._scale
|
||||
if new_font_size > 0:
|
||||
font.setPointSizeF(new_font_size)
|
||||
self.nameLabel.setFont(font)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
try:
|
||||
self.shadow.setBlurRadius(int(20 * self._scale))
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
try:
|
||||
self.updateGeometry()
|
||||
self.update()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
# Ensure parent layout is updated safely
|
||||
try:
|
||||
parent = self.parentWidget()
|
||||
if parent:
|
||||
layout = parent.layout()
|
||||
if layout:
|
||||
layout.invalidate()
|
||||
layout.activate()
|
||||
layout.update()
|
||||
parent.updateGeometry()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
def update_card_size(self, new_width: int):
|
||||
self.base_card_width = new_width
|
||||
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.5), self.on_cover_loaded)
|
||||
self.update_scale()
|
||||
|
||||
def update_badge_visibility(self, display_filter: str):
|
||||
"""Обновляет видимость бейджей на основе display_filter."""
|
||||
# Check if the card has been destroyed before updating
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
self.display_filter = display_filter
|
||||
self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites"))
|
||||
self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites"))
|
||||
self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
|
||||
self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
||||
self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
||||
self.portproton_visible = (str(self.game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
|
||||
protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
|
||||
|
||||
# Обновляем видимость бейджей
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.protondbLabel.setVisible(protondb_visible)
|
||||
self.anticheatLabel.setVisible(anticheat_visible)
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.protondbLabel.setVisible(protondb_visible)
|
||||
self.anticheatLabel.setVisible(anticheat_visible)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
return
|
||||
|
||||
# Перепозиционируем бейджи
|
||||
self._position_badges(self.card_width)
|
||||
scaled_width = int(self.base_card_width * self._scale)
|
||||
self._position_badges(scaled_width)
|
||||
|
||||
# Update layout after visibility changes
|
||||
self.updateGeometry()
|
||||
parent = self.parentWidget()
|
||||
if parent:
|
||||
layout = parent.layout()
|
||||
if layout:
|
||||
layout.invalidate()
|
||||
layout.update()
|
||||
parent.updateGeometry()
|
||||
|
||||
def _show_context_menu(self, pos):
|
||||
"""Delegate context menu display to ContextMenuManager."""
|
||||
if self.context_menu_manager:
|
||||
self.context_menu_manager.show_context_menu(self, pos)
|
||||
|
||||
@@ -390,7 +427,6 @@ class GameCard(QFrame):
|
||||
return ""
|
||||
|
||||
def open_portproton_forum_topic(self):
|
||||
"""Open the PortProton forum topic or search page for this game."""
|
||||
result = self.portproton_api.get_forum_topic_slug(self.name)
|
||||
base_url = "https://linux-gaming.ru/"
|
||||
if result.startswith("search?q="):
|
||||
@@ -413,20 +449,43 @@ class GameCard(QFrame):
|
||||
QDesktopServices.openUrl(url)
|
||||
|
||||
def update_favorite_icon(self):
|
||||
if self.is_favorite:
|
||||
self.favoriteLabel.setText("★")
|
||||
else:
|
||||
self.favoriteLabel.setText("☆")
|
||||
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||
# Check if the card has been destroyed before updating
|
||||
if not hasattr(self, 'coverLabel') or self.coverLabel is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if self.is_favorite:
|
||||
self.favoriteLabel.setText("★")
|
||||
else:
|
||||
self.favoriteLabel.setText("☆")
|
||||
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
return
|
||||
|
||||
try:
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
if hasattr(parent, 'game_library_manager'):
|
||||
# Access using getattr with default to avoid Ruff B009 warning
|
||||
manager = getattr(parent, 'game_library_manager', None)
|
||||
if manager is not None:
|
||||
QTimer.singleShot(0, manager.update_game_grid)
|
||||
break
|
||||
parent = parent.parent()
|
||||
except RuntimeError:
|
||||
# Handle the case where the Qt object was deleted
|
||||
pass
|
||||
|
||||
def toggle_favorite(self):
|
||||
favorites = read_favorites()
|
||||
favorites_set = set(favorites)
|
||||
if self.is_favorite:
|
||||
if self.name in favorites:
|
||||
if self.name in favorites_set:
|
||||
favorites.remove(self.name)
|
||||
self.is_favorite = False
|
||||
else:
|
||||
if self.name not in favorites:
|
||||
if self.name not in favorites_set:
|
||||
favorites.append(self.name)
|
||||
self.is_favorite = True
|
||||
save_favorites(favorites)
|
||||
@@ -450,138 +509,38 @@ class GameCard(QFrame):
|
||||
self.gradientAngleChanged.emit()
|
||||
self.update()
|
||||
|
||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
|
||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
|
||||
def getScale(self) -> float:
|
||||
return self._scale
|
||||
|
||||
def setScale(self, value: float):
|
||||
if self._scale != value:
|
||||
self._scale = value
|
||||
self.update_scale()
|
||||
self.scaleChanged.emit()
|
||||
|
||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=borderWidthChanged)
|
||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=gradientAngleChanged)
|
||||
scale = Property(float, getScale, setScale, None, "", notify=scaleChanged)
|
||||
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
pen = QPen()
|
||||
pen.setWidth(self._borderWidth)
|
||||
if self._hovered or self._focused:
|
||||
center = self.rect().center()
|
||||
gradient = QConicalGradient(center, self._gradientAngle)
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
else:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
|
||||
painter.setPen(pen)
|
||||
radius = 18
|
||||
bw = round(self._borderWidth / 2)
|
||||
rect = self.rect().adjusted(bw, bw, -bw, -bw)
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
|
||||
def startPulseAnimation(self):
|
||||
if not (self._hovered or self._focused):
|
||||
return
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.start()
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hovered = True
|
||||
self.hoverChanged.emit(self.name, True)
|
||||
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hovered = False
|
||||
self.hoverChanged.emit(self.name, False)
|
||||
if not self._focused:
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
if not self._hovered:
|
||||
self._focused = True
|
||||
self.focusChanged.emit(self.name, True)
|
||||
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event):
|
||||
self._focused = False
|
||||
self.focusChanged.emit(self.name, False)
|
||||
if not self._hovered:
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
self.animations.handle_focus_out_event()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
@@ -601,6 +560,7 @@ class GameCard(QFrame):
|
||||
)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
def keyPressEvent(self, event):
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
self.select_callback(
|
||||
|
||||
656
portprotonqt/game_library_manager.py
Normal file
@@ -0,0 +1,656 @@
|
||||
from typing import Protocol
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.search_utils import SearchOptimizer, ThreadedSearch
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from portprotonqt.custom_widgets import FlowLayout
|
||||
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
|
||||
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
|
||||
# Initialize search optimizer
|
||||
self.search_optimizer = SearchOptimizer()
|
||||
self.search_thread: ThreadedSearch | None = None
|
||||
|
||||
def create_games_library_widget(self):
|
||||
"""Creates the games library widget with search, grid, and slider."""
|
||||
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)
|
||||
self.main_window.card_width = self.card_width
|
||||
self.main_window._last_card_width = self.card_width
|
||||
for card in self.game_card_cache.values():
|
||||
card.update_card_size(self.card_width)
|
||||
self.update_game_grid()
|
||||
|
||||
def 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:
|
||||
try:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = None
|
||||
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
||||
try:
|
||||
self.main_window.current_focused_card._focused = False
|
||||
self.main_window.current_focused_card.clearFocus()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_focused_card = card
|
||||
else:
|
||||
if self.main_window.current_focused_card == card:
|
||||
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:
|
||||
try:
|
||||
if self.main_window.current_focused_card:
|
||||
self.main_window.current_focused_card._focused = False
|
||||
self.main_window.current_focused_card.clearFocus()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||
try:
|
||||
if self.main_window.current_hovered_card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = card
|
||||
else:
|
||||
if self.main_window.current_hovered_card == card:
|
||||
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
|
||||
else:
|
||||
# When filtering, we want to update with the current filtered_games
|
||||
# which has already been set by _perform_search
|
||||
pass
|
||||
self.is_filtering = is_filter
|
||||
self._pending_update = True
|
||||
|
||||
if self._update_timer is not None:
|
||||
self._update_timer.start()
|
||||
else:
|
||||
self._update_game_grid_immediate()
|
||||
|
||||
def force_update_cards_library(self):
|
||||
if self.gamesListWidget and self.gamesListLayout:
|
||||
# Use singleShot to ensure UI updates happen after all other operations complete
|
||||
# This prevents potential freezing in PySide 6.10.1
|
||||
QTimer.singleShot(0, self._perform_force_update)
|
||||
|
||||
def _perform_force_update(self):
|
||||
"""Perform the actual force update on the layout."""
|
||||
if self.gamesListLayout:
|
||||
self.gamesListLayout.invalidate()
|
||||
if self.gamesListWidget:
|
||||
self.gamesListWidget.adjustSize()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
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: use the pre-computed filtered_games from optimized search
|
||||
# This means we already have the exact games to show
|
||||
self._update_search_results()
|
||||
else:
|
||||
# Full update: sorting, removal/addition, reorganization
|
||||
games_list = self.filtered_games if self.filtered_games else self.games
|
||||
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
|
||||
favorites_set = set(favorites) # Convert to set for O(1) lookup
|
||||
fav_games = [g for g in games_list if g[0] in favorites_set]
|
||||
non_fav_games = [g for g in games_list if g[0] not in favorites_set]
|
||||
sorted_fav = sorted(fav_games, key=partition_sort_key)
|
||||
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
|
||||
sorted_games = sorted_fav + sorted_non_fav
|
||||
|
||||
# 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.force_update_cards_library()
|
||||
|
||||
self.is_filtering = False # Reset flag in any case
|
||||
|
||||
def _update_search_results(self):
|
||||
"""Update the grid with pre-computed search results."""
|
||||
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||
return
|
||||
|
||||
# Batch layout updates
|
||||
self.gamesListWidget.setUpdatesEnabled(False)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(False) # Disable layout during batch
|
||||
|
||||
try:
|
||||
# Create set of keys for current filtered games for fast lookup
|
||||
filtered_keys = {(game[0], game[4]) for game in self.filtered_games} # (name, exec_line)
|
||||
|
||||
# Process existing cards: show cards that are in filtered results, hide others
|
||||
cards_to_hide = []
|
||||
for card_key, card in self.game_card_cache.items():
|
||||
if card_key in filtered_keys:
|
||||
# Card should be visible
|
||||
if not card.isVisible():
|
||||
card.setVisible(True)
|
||||
else:
|
||||
# Card should be hidden
|
||||
if card.isVisible():
|
||||
card.setVisible(False)
|
||||
cards_to_hide.append(card_key)
|
||||
|
||||
# Now add any missing cards that are in filtered results but not in cache
|
||||
cards_to_add = []
|
||||
for game_data in self.filtered_games:
|
||||
game_name = game_data[0]
|
||||
exec_line = game_data[4]
|
||||
game_key = (game_name, exec_line)
|
||||
|
||||
if game_key not in self.game_card_cache:
|
||||
if self.context_menu_manager is None:
|
||||
continue
|
||||
|
||||
card = self._create_game_card(game_data)
|
||||
self.game_card_cache[game_key] = card
|
||||
card.setVisible(True) # New cards should be visible
|
||||
cards_to_add.append((game_key, card))
|
||||
|
||||
# Add new cards to layout
|
||||
for _game_key, card in cards_to_add:
|
||||
self.gamesListLayout.addWidget(card)
|
||||
|
||||
# Remove cards that are no longer needed (if any)
|
||||
# Note: we're not removing them completely as they might be needed later
|
||||
# Instead, we just hide them and they'll be reused if needed
|
||||
|
||||
finally:
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self.gamesListWidget.setUpdatesEnabled(True)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.update()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
self.main_window._last_card_width = self.card_width
|
||||
|
||||
self.force_update_cards_library()
|
||||
|
||||
def _apply_filter_visibility(self, search_text: str):
|
||||
"""Applies visibility to cards based on search, without changing the layout."""
|
||||
# This method is used for simple substring matching
|
||||
# For the new optimized search, we'll use a different approach in update_game_grid
|
||||
# when is_filter=True
|
||||
visible_count = 0
|
||||
for game_key, card in self.game_card_cache.items():
|
||||
game_name = card.name # Assume GameCard has 'name' attribute
|
||||
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,
|
||||
parent=self.gamesListWidget,
|
||||
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):
|
||||
# Clear any references to this card if it's currently focused/hovered
|
||||
if self.main_window.current_focused_card == card:
|
||||
self.main_window.current_focused_card = None
|
||||
if self.main_window.current_hovered_card == card:
|
||||
self.main_window.current_hovered_card = None
|
||||
card.deleteLater()
|
||||
self.pending_deletions.remove(card)
|
||||
|
||||
def clear_layout(self, layout):
|
||||
"""Clears all widgets from the layout."""
|
||||
if layout is None:
|
||||
return
|
||||
# Remove all widgets from the layout and clean up caches
|
||||
while layout.count():
|
||||
child = layout.takeAt(0)
|
||||
if child.widget():
|
||||
widget = child.widget()
|
||||
# Clean up cache if widget exists in it
|
||||
for key, card in list(self.game_card_cache.items()):
|
||||
if card == widget:
|
||||
del self.game_card_cache[key]
|
||||
if key in self.pending_images:
|
||||
del self.pending_images[key]
|
||||
break
|
||||
# Always schedule widget for deletion regardless of cache state
|
||||
widget.deleteLater()
|
||||
|
||||
# Also clear the cache completely if needed (in case layout wasn't in sync)
|
||||
self.game_card_cache.clear()
|
||||
self.pending_images.clear()
|
||||
|
||||
def set_games(self, games: list[tuple]):
|
||||
"""Sets the games list and updates the filtered games."""
|
||||
self.games = games
|
||||
self.filtered_games = self.games
|
||||
|
||||
# Build search indices for fast searching
|
||||
self._build_search_indices(games)
|
||||
|
||||
self.dirty = True # Full resort needed
|
||||
self.update_game_grid()
|
||||
|
||||
def _build_search_indices(self, games: list[tuple]):
|
||||
"""Build search indices for fast searching."""
|
||||
# Prepare items for indexing: (search_key, game_data)
|
||||
# We'll index by game name (index 0) and potentially other fields
|
||||
items = []
|
||||
for game in games:
|
||||
# game is a tuple: (name, description, cover, appid, exec_line, controller_support,
|
||||
# last_launch, formatted_playtime, protondb_tier, anticheat_status,
|
||||
# last_played_timestamp, playtime_seconds, game_source)
|
||||
name = str(game[0]).lower() if game[0] else ""
|
||||
description = str(game[1]).lower() if game[1] else ""
|
||||
|
||||
# Create multiple search entries for better matching
|
||||
items.append((name, game)) # Exact name
|
||||
# Add other searchable fields if needed
|
||||
if description:
|
||||
items.append((description, game))
|
||||
|
||||
# Also add individual words from the name for partial matching
|
||||
for word in name.split():
|
||||
if len(word) > 2: # Only index words longer than 2 characters
|
||||
items.append((word, game))
|
||||
|
||||
self.search_optimizer.build_indices(items)
|
||||
|
||||
def add_game_incremental(self, game_data: tuple):
|
||||
"""Add a single game without full reload."""
|
||||
self.games.append(game_data)
|
||||
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."""
|
||||
search_text = self.main_window.searchEdit.text().strip().lower()
|
||||
|
||||
if not search_text:
|
||||
# If search is empty, show all games
|
||||
self.filtered_games = self.games
|
||||
self.update_game_grid(is_filter=True)
|
||||
else:
|
||||
# Use the optimized search
|
||||
self._perform_search(search_text)
|
||||
|
||||
def _perform_search(self, search_text: str):
|
||||
"""Perform the actual search using optimized search algorithms."""
|
||||
if not search_text:
|
||||
self.filtered_games = self.games
|
||||
self.update_game_grid(is_filter=True)
|
||||
return
|
||||
|
||||
# Use exact search first
|
||||
exact_result = self.search_optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
# If exact match found, show only that game
|
||||
self.filtered_games = [exact_result]
|
||||
self.update_game_grid(is_filter=True)
|
||||
return
|
||||
|
||||
# Try prefix search
|
||||
prefix_results = self.search_optimizer.prefix_search(search_text)
|
||||
if prefix_results:
|
||||
# Get the actual game data from the prefix matches
|
||||
filtered_games = []
|
||||
for _match_text, game_data in prefix_results:
|
||||
if game_data not in filtered_games: # Avoid duplicates
|
||||
filtered_games.append(game_data)
|
||||
self.filtered_games = filtered_games
|
||||
self.update_game_grid(is_filter=True)
|
||||
return
|
||||
|
||||
# Finally, try fuzzy search
|
||||
fuzzy_results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=60.0)
|
||||
if fuzzy_results:
|
||||
# Get the actual game data from the fuzzy matches
|
||||
filtered_games = []
|
||||
for _match_text, game_data, _score in fuzzy_results:
|
||||
if game_data not in filtered_games: # Avoid duplicates
|
||||
filtered_games.append(game_data)
|
||||
self.filtered_games = filtered_games
|
||||
self.update_game_grid(is_filter=True)
|
||||
else:
|
||||
# If no results found, show empty list
|
||||
self.filtered_games = []
|
||||
self.update_game_grid(is_filter=True)
|
||||
373
portprotonqt/howlongtobeat_api.py
Normal file
@@ -0,0 +1,373 @@
|
||||
import orjson
|
||||
import re
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from difflib import SequenceMatcher
|
||||
from threading import Thread
|
||||
import requests
|
||||
from bs4 import BeautifulSoup, Tag
|
||||
from portprotonqt.config_utils import read_proxy_config
|
||||
from portprotonqt.time_utils import format_playtime
|
||||
from PySide6.QtCore import QObject, Signal
|
||||
|
||||
@dataclass
|
||||
class GameEntry:
|
||||
"""Информация об игре из HowLongToBeat."""
|
||||
game_id: int = -1
|
||||
game_name: str | None = None
|
||||
main_story: float | None = None
|
||||
main_extra: float | None = None
|
||||
completionist: float | None = None
|
||||
similarity: float = -1.0
|
||||
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@dataclass
|
||||
class SearchConfig:
|
||||
"""Конфигурация для поиска."""
|
||||
api_key: str | None = None
|
||||
search_url: str | None = None
|
||||
|
||||
class APIKeyExtractor:
|
||||
"""Извлекает API ключ и URL поиска из скриптов сайта."""
|
||||
@staticmethod
|
||||
def extract_from_script(script_content: str) -> SearchConfig:
|
||||
config = SearchConfig()
|
||||
config.api_key = APIKeyExtractor._extract_api_key(script_content)
|
||||
config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key)
|
||||
return config
|
||||
|
||||
@staticmethod
|
||||
def _extract_api_key(script_content: str) -> str | None:
|
||||
user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"'
|
||||
matches = re.findall(user_id_pattern, script_content)
|
||||
if matches:
|
||||
return ''.join(matches)
|
||||
concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+'
|
||||
matches = re.findall(concat_pattern, script_content)
|
||||
if matches:
|
||||
parts = str(matches).split('.concat')
|
||||
cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]]
|
||||
return ''.join(cleaned_parts)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _extract_search_url(script_content: str, api_key: str | None) -> str | None:
|
||||
if not api_key:
|
||||
return None
|
||||
pattern = re.compile(
|
||||
r'fetch\(\s*["\'](\/api\/[^"\']*)["\']'
|
||||
r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)'
|
||||
r'\s*,',
|
||||
re.DOTALL
|
||||
)
|
||||
for match in pattern.finditer(script_content):
|
||||
endpoint = match.group(1)
|
||||
concat_calls = match.group(2)
|
||||
concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls)
|
||||
concatenated_str = ''.join(concat_strings)
|
||||
if concatenated_str == api_key:
|
||||
return endpoint
|
||||
return None
|
||||
|
||||
class HTTPClient:
|
||||
"""HTTP клиент для работы с API HowLongToBeat."""
|
||||
BASE_URL = 'https://howlongtobeat.com/'
|
||||
SEARCH_URL = BASE_URL + "api/s/"
|
||||
|
||||
def __init__(self, timeout: int = 60):
|
||||
self.timeout = timeout
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||
'referer': self.BASE_URL
|
||||
})
|
||||
proxy_config = read_proxy_config()
|
||||
if proxy_config:
|
||||
self.session.proxies.update(proxy_config)
|
||||
|
||||
def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None:
|
||||
try:
|
||||
response = self.session.get(self.BASE_URL, timeout=self.timeout)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
scripts = soup.find_all('script', src=True)
|
||||
script_urls = []
|
||||
for script in scripts:
|
||||
if isinstance(script, Tag):
|
||||
src = script.get('src')
|
||||
if src is not None and isinstance(src, str):
|
||||
if parse_all_scripts or '_app-' in src:
|
||||
script_urls.append(src)
|
||||
for script_url in script_urls:
|
||||
full_url = self.BASE_URL + script_url
|
||||
script_response = self.session.get(full_url, timeout=self.timeout)
|
||||
if script_response.status_code == 200:
|
||||
config = APIKeyExtractor.extract_from_script(script_response.text)
|
||||
if config.api_key:
|
||||
return config
|
||||
except requests.RequestException:
|
||||
pass
|
||||
return None
|
||||
|
||||
def search_games(self, game_name: str, page: int = 1, config: SearchConfig | None = None) -> str | None:
|
||||
if not config:
|
||||
config = self.get_search_config()
|
||||
if not config:
|
||||
config = self.get_search_config(parse_all_scripts=True)
|
||||
if not config or not config.api_key:
|
||||
return None
|
||||
search_url = self.SEARCH_URL
|
||||
if config.search_url:
|
||||
search_url = self.BASE_URL + config.search_url.lstrip('/')
|
||||
payload = self._build_search_payload(game_name, page, config)
|
||||
headers = {
|
||||
'content-type': 'application/json',
|
||||
'accept': '*/*'
|
||||
}
|
||||
try:
|
||||
response = self.session.post(
|
||||
search_url + config.api_key,
|
||||
headers=headers,
|
||||
data=orjson.dumps(payload),
|
||||
timeout=self.timeout
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.text
|
||||
except requests.RequestException:
|
||||
pass
|
||||
try:
|
||||
response = self.session.post(
|
||||
search_url,
|
||||
headers=headers,
|
||||
data=orjson.dumps(payload),
|
||||
timeout=self.timeout
|
||||
)
|
||||
if response.status_code == 200:
|
||||
return response.text
|
||||
except requests.RequestException:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _build_search_payload(self, game_name: str, page: int, config: SearchConfig) -> dict[str, Any]:
|
||||
payload = {
|
||||
'searchType': "games",
|
||||
'searchTerms': game_name.split(),
|
||||
'searchPage': page,
|
||||
'size': 1, # Limit to 1 result
|
||||
'searchOptions': {
|
||||
'games': {
|
||||
'userId': 0,
|
||||
'platform': "",
|
||||
'sortCategory': "popular",
|
||||
'rangeCategory': "main",
|
||||
'rangeTime': {'min': 0, 'max': 0},
|
||||
'gameplay': {
|
||||
'perspective': "",
|
||||
'flow': "",
|
||||
'genre': "",
|
||||
"difficulty": ""
|
||||
},
|
||||
'rangeYear': {'max': "", 'min': ""},
|
||||
'modifier': "" # Hardcoded to empty string for SearchModifiers.NONE
|
||||
},
|
||||
'users': {'sortCategory': "postcount"},
|
||||
'lists': {'sortCategory': "follows"},
|
||||
'filter': "",
|
||||
'sort': 0,
|
||||
'randomizer': 0
|
||||
},
|
||||
'useCache': True,
|
||||
'fields': ["game_id", "game_name", "comp_main", "comp_plus", "comp_100"] # Request only needed fields
|
||||
}
|
||||
if config.api_key:
|
||||
payload['searchOptions']['users']['id'] = config.api_key
|
||||
return payload
|
||||
|
||||
class ResultParser:
|
||||
"""Парсер результатов поиска."""
|
||||
def __init__(self, search_query: str, minimum_similarity: float = 0.4, case_sensitive: bool = True):
|
||||
self.search_query = search_query
|
||||
self.minimum_similarity = minimum_similarity
|
||||
self.case_sensitive = case_sensitive
|
||||
self.search_numbers = self._extract_numbers(search_query)
|
||||
|
||||
def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]:
|
||||
try:
|
||||
data = orjson.loads(json_response)
|
||||
games = []
|
||||
# Only process the first result
|
||||
if data.get("data"):
|
||||
game_data = data["data"][0]
|
||||
game = self._parse_game_entry(game_data)
|
||||
if target_game_id is not None:
|
||||
if game.game_id == target_game_id:
|
||||
games.append(game)
|
||||
elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity:
|
||||
games.append(game)
|
||||
return games
|
||||
except (orjson.JSONDecodeError, KeyError, IndexError):
|
||||
return []
|
||||
|
||||
def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry:
|
||||
game = GameEntry()
|
||||
game.game_id = game_data.get("game_id", -1)
|
||||
game.game_name = game_data.get("game_name")
|
||||
game.raw_data = game_data
|
||||
time_fields = [
|
||||
("comp_main", "main_story"),
|
||||
("comp_plus", "main_extra"),
|
||||
("comp_100", "completionist")
|
||||
]
|
||||
all_zero = all(game_data.get(json_field, 0) == 0 for json_field, _ in time_fields)
|
||||
for json_field, attr_name in time_fields:
|
||||
if json_field in game_data:
|
||||
time_seconds = game_data[json_field]
|
||||
time_hours = None if all_zero else round(time_seconds / 3600, 2)
|
||||
setattr(game, attr_name, time_hours)
|
||||
game.similarity = self._calculate_similarity(game)
|
||||
return game
|
||||
|
||||
def _calculate_similarity(self, game: GameEntry) -> float:
|
||||
return self._compare_strings(self.search_query, game.game_name)
|
||||
|
||||
def _compare_strings(self, a: str | None, b: str | None) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if self.case_sensitive:
|
||||
similarity = SequenceMatcher(None, a, b).ratio()
|
||||
else:
|
||||
similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio()
|
||||
if self.search_numbers and not self._contains_numbers(b, self.search_numbers):
|
||||
similarity -= 0.1
|
||||
return max(0.0, similarity)
|
||||
|
||||
@staticmethod
|
||||
def _extract_numbers(text: str) -> list[str]:
|
||||
return [word for word in text.split() if word.isdigit()]
|
||||
|
||||
@staticmethod
|
||||
def _contains_numbers(text: str, numbers: list[str]) -> bool:
|
||||
if not numbers:
|
||||
return True
|
||||
cleaned_text = re.sub(r'([^\s\w]|_)+', '', text)
|
||||
text_numbers = [word for word in cleaned_text.split() if word.isdigit()]
|
||||
return any(num in text_numbers for num in numbers)
|
||||
|
||||
def get_cache_dir():
|
||||
"""Возвращает путь к каталогу кэша, создаёт его при необходимости."""
|
||||
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)
|
||||
return cache_dir
|
||||
|
||||
class HowLongToBeat(QObject):
|
||||
"""Основной класс для работы с API HowLongToBeat."""
|
||||
searchCompleted = Signal(list)
|
||||
|
||||
def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None):
|
||||
super().__init__(parent)
|
||||
self.minimum_similarity = minimum_similarity
|
||||
self.http_client = HTTPClient(timeout)
|
||||
self.cache_dir = get_cache_dir()
|
||||
|
||||
def _get_cache_file_path(self, game_name: str) -> str:
|
||||
"""Возвращает путь к файлу кэша для заданного имени игры."""
|
||||
safe_game_name = re.sub(r'[^\w\s-]', '', game_name).replace(' ', '_').lower()
|
||||
cache_file = f"hltb_{safe_game_name}.json"
|
||||
return os.path.join(self.cache_dir, cache_file)
|
||||
|
||||
def _load_from_cache(self, game_name: str) -> str | None:
|
||||
"""Пытается загрузить данные из кэша, если они существуют."""
|
||||
cache_file = self._get_cache_file_path(game_name)
|
||||
try:
|
||||
if os.path.exists(cache_file):
|
||||
with open(cache_file, 'rb') as f:
|
||||
return f.read().decode('utf-8')
|
||||
except (OSError, UnicodeDecodeError):
|
||||
pass
|
||||
return None
|
||||
|
||||
def _save_to_cache(self, game_name: str, json_response: str):
|
||||
"""Сохраняет данные в кэш, храня только первую игру и необходимые поля."""
|
||||
cache_file = self._get_cache_file_path(game_name)
|
||||
try:
|
||||
# Парсим JSON и берем только первую игру
|
||||
data = orjson.loads(json_response)
|
||||
if data.get("data"):
|
||||
first_game = data["data"][0]
|
||||
simplified_data = {
|
||||
"data": [{
|
||||
"game_id": first_game.get("game_id", -1),
|
||||
"game_name": first_game.get("game_name"),
|
||||
"comp_main": first_game.get("comp_main", 0),
|
||||
"comp_plus": first_game.get("comp_plus", 0),
|
||||
"comp_100": first_game.get("comp_100", 0)
|
||||
}]
|
||||
}
|
||||
with open(cache_file, 'wb') as f:
|
||||
f.write(orjson.dumps(simplified_data))
|
||||
except (OSError, orjson.JSONDecodeError, IndexError):
|
||||
pass
|
||||
|
||||
def search(self, game_name: str, case_sensitive: bool = True) -> list[GameEntry] | None:
|
||||
if not game_name or not game_name.strip():
|
||||
return None
|
||||
# Проверяем кэш
|
||||
cached_response = self._load_from_cache(game_name)
|
||||
if cached_response:
|
||||
try:
|
||||
cached_data = orjson.loads(cached_response)
|
||||
full_json = {
|
||||
"data": [
|
||||
{
|
||||
"game_id": game["game_id"],
|
||||
"game_name": game["game_name"],
|
||||
"comp_main": game["comp_main"],
|
||||
"comp_plus": game["comp_plus"],
|
||||
"comp_100": game["comp_100"]
|
||||
}
|
||||
for game in cached_data.get("data", [])
|
||||
]
|
||||
}
|
||||
parser = ResultParser(
|
||||
game_name,
|
||||
self.minimum_similarity,
|
||||
case_sensitive
|
||||
)
|
||||
return parser.parse_results(orjson.dumps(full_json).decode('utf-8'))
|
||||
except orjson.JSONDecodeError:
|
||||
pass
|
||||
# Если нет в кэше, делаем запрос
|
||||
json_response = self.http_client.search_games(game_name)
|
||||
if not json_response:
|
||||
return None
|
||||
# Сохраняем в кэш только первую игру
|
||||
self._save_to_cache(game_name, json_response)
|
||||
parser = ResultParser(
|
||||
game_name,
|
||||
self.minimum_similarity,
|
||||
case_sensitive
|
||||
)
|
||||
return parser.parse_results(json_response)
|
||||
|
||||
def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None:
|
||||
time_value = getattr(game_entry, time_field, None)
|
||||
if time_value is None:
|
||||
return None
|
||||
time_seconds = int(time_value * 3600)
|
||||
return format_playtime(time_seconds)
|
||||
|
||||
def search_with_callback(self, game_name: str, case_sensitive: bool = True):
|
||||
"""Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами."""
|
||||
def search_thread():
|
||||
try:
|
||||
results = self.search(game_name, case_sensitive)
|
||||
self.searchCompleted.emit(results if results else [])
|
||||
except Exception as e:
|
||||
print(f"Error in search_with_callback: {e}")
|
||||
self.searchCompleted.emit([])
|
||||
|
||||
thread = Thread(target=search_thread)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
@@ -3,7 +3,6 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
|
||||
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
|
||||
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
|
||||
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.downloader import Downloader
|
||||
@@ -21,6 +20,13 @@ image_load_queue = Queue()
|
||||
image_executor = ThreadPoolExecutor(max_workers=4)
|
||||
queue_lock = threading.Lock()
|
||||
|
||||
def get_device_pixel_ratio() -> float:
|
||||
"""
|
||||
Retrieves the device pixel ratio from QApplication, with a fallback of 1.0 if not available.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
return app.devicePixelRatio() if isinstance(app, QApplication) else 1.0
|
||||
|
||||
def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""):
|
||||
"""
|
||||
Асинхронно загружает обложку через очередь задач.
|
||||
@@ -30,11 +36,22 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
current_theme_name = read_theme_from_config()
|
||||
|
||||
def finish_with(pixmap: QPixmap):
|
||||
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
||||
x = (scaled.width() - width) // 2
|
||||
y = (scaled.height() - height) // 2
|
||||
cropped = scaled.copy(x, y, width, height)
|
||||
callback(cropped)
|
||||
# Check if pixmap is valid before attempting to scale it
|
||||
if pixmap.isNull():
|
||||
# Create a default placeholder pixmap instead of trying to scale a null pixmap
|
||||
placeholder_pixmap = QPixmap(width, height)
|
||||
placeholder_pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(placeholder_pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
callback(placeholder_pixmap)
|
||||
else:
|
||||
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
||||
x = (scaled.width() - width) // 2
|
||||
y = (scaled.height() - height) // 2
|
||||
cropped = scaled.copy(x, y, width, height)
|
||||
callback(cropped)
|
||||
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
|
||||
@@ -52,6 +69,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
local_path = os.path.join(image_folder, f"{appid}.jpg")
|
||||
if os.path.exists(local_path):
|
||||
pixmap = QPixmap(local_path)
|
||||
# Check if the pixmap loaded successfully
|
||||
if pixmap.isNull():
|
||||
logger.warning(f"Failed to load image from {local_path}")
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
@@ -63,6 +83,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
if pixmap.isNull():
|
||||
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
@@ -77,11 +99,19 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки URL {cover}: {e}")
|
||||
|
||||
if cover and cover.startswith(("http://", "https://")):
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
try:
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg")
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
# SGDB ссылки содержат уникальный хеш в названии — используем как имя
|
||||
local_path = os.path.join(image_folder, filename)
|
||||
|
||||
if os.path.exists(local_path):
|
||||
pixmap = QPixmap(local_path)
|
||||
# Check if the pixmap loaded successfully
|
||||
if pixmap.isNull():
|
||||
logger.warning(f"Failed to load image from {local_path}")
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
@@ -93,6 +123,45 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
if pixmap.isNull():
|
||||
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
finish_with(pixmap)
|
||||
|
||||
logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки SGDB URL {cover}: {e}")
|
||||
|
||||
if cover and cover.startswith(("http://", "https://")):
|
||||
try:
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg")
|
||||
if os.path.exists(local_path):
|
||||
pixmap = QPixmap(local_path)
|
||||
# Check if the pixmap loaded successfully
|
||||
if pixmap.isNull():
|
||||
logger.warning(f"Failed to load image from {local_path}")
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
def on_downloaded(result: str | None):
|
||||
pixmap = QPixmap()
|
||||
if result and os.path.exists(result):
|
||||
pixmap.load(result)
|
||||
if pixmap.isNull():
|
||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
if pixmap.isNull():
|
||||
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
@@ -109,6 +178,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
|
||||
if cover and QFile.exists(cover):
|
||||
pixmap = QPixmap(cover)
|
||||
# Check if the pixmap loaded successfully
|
||||
if pixmap.isNull():
|
||||
logger.warning(f"Failed to load image from {cover}")
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
@@ -116,6 +188,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
pixmap = QPixmap()
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
if pixmap.isNull():
|
||||
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
@@ -125,9 +199,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
painter.end()
|
||||
finish_with(pixmap)
|
||||
|
||||
with queue_lock:
|
||||
image_load_queue.put(process_image)
|
||||
image_executor.submit(lambda: image_load_queue.get()())
|
||||
# Submit the process_image function directly to the executor
|
||||
# This avoids the potential blocking issue with queue.get() in PySide 6.10.1
|
||||
image_executor.submit(process_image)
|
||||
|
||||
def round_corners(pixmap, radius):
|
||||
"""
|
||||
@@ -135,7 +209,15 @@ def round_corners(pixmap, radius):
|
||||
"""
|
||||
if pixmap.isNull():
|
||||
return pixmap
|
||||
|
||||
# Check if radius is valid to prevent issues
|
||||
if radius <= 0:
|
||||
return pixmap
|
||||
|
||||
size = pixmap.size()
|
||||
if size.width() <= 0 or size.height() <= 0:
|
||||
return pixmap
|
||||
|
||||
rounded = QPixmap(size)
|
||||
rounded.fill(QColor(0, 0, 0, 0))
|
||||
painter = QPainter(rounded)
|
||||
@@ -164,23 +246,21 @@ class FullscreenDialog(QDialog):
|
||||
:param theme: Объект темы для стилизации (если None, используется default_styles)
|
||||
"""
|
||||
super().__init__(parent)
|
||||
# Удаление диалога после закрытия
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setFocus()
|
||||
|
||||
self.images = images
|
||||
self.current_index = current_index
|
||||
self.theme = theme if theme else default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
|
||||
# Убираем стандартные элементы управления окна
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
self.init_ui()
|
||||
self.update_display()
|
||||
|
||||
# Фильтруем события для закрытия диалога по клику
|
||||
self.imageLabel.installEventFilter(self)
|
||||
self.captionLabel.installEventFilter(self)
|
||||
|
||||
@@ -190,32 +270,28 @@ class FullscreenDialog(QDialog):
|
||||
self.mainLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.mainLayout.setSpacing(0)
|
||||
|
||||
# Контейнер для изображения и стрелок
|
||||
self.imageContainer = QWidget()
|
||||
self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
|
||||
self.imageContainerLayout = QHBoxLayout(self.imageContainer)
|
||||
self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.imageContainerLayout.setSpacing(0)
|
||||
|
||||
# Левая стрелка
|
||||
self.prevButton = QToolButton()
|
||||
self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
|
||||
self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE)
|
||||
self.prevButton.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
|
||||
self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.prevButton.setFixedSize(40, 40)
|
||||
self.prevButton.clicked.connect(self.show_prev)
|
||||
self.imageContainerLayout.addWidget(self.prevButton)
|
||||
|
||||
# Метка для изображения
|
||||
self.imageLabel = QLabel()
|
||||
self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
|
||||
self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
|
||||
|
||||
# Правая стрелка
|
||||
self.nextButton = QToolButton()
|
||||
self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
|
||||
self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE)
|
||||
self.nextButton.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
|
||||
self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.nextButton.setFixedSize(40, 40)
|
||||
self.nextButton.clicked.connect(self.show_next)
|
||||
@@ -223,16 +299,14 @@ class FullscreenDialog(QDialog):
|
||||
|
||||
self.mainLayout.addWidget(self.imageContainer)
|
||||
|
||||
# Небольшой отступ между изображением и подписью
|
||||
spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
self.mainLayout.addItem(spacer)
|
||||
|
||||
# Подпись
|
||||
self.captionLabel = QLabel()
|
||||
self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.captionLabel.setFixedHeight(40)
|
||||
self.captionLabel.setWordWrap(True)
|
||||
self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE)
|
||||
self.captionLabel.setStyleSheet(getattr(self.theme, "CAPTION_LABEL_STYLE", ""))
|
||||
self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.mainLayout.addWidget(self.captionLabel)
|
||||
|
||||
@@ -241,28 +315,48 @@ class FullscreenDialog(QDialog):
|
||||
if not self.images:
|
||||
return
|
||||
|
||||
# Очищаем старое содержимое
|
||||
self.imageLabel.clear()
|
||||
self.captionLabel.clear()
|
||||
QApplication.processEvents()
|
||||
|
||||
pixmap, caption = self.images[self.current_index]
|
||||
# Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера
|
||||
scaled_pixmap = pixmap.scaled(
|
||||
self.FIXED_WIDTH - 80, # учитываем ширину стрелок
|
||||
self.FIXED_HEIGHT,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
self.imageLabel.setPixmap(scaled_pixmap)
|
||||
# Check if pixmap is valid before attempting to scale it
|
||||
if pixmap.isNull():
|
||||
# Create a default placeholder pixmap instead of trying to scale a null pixmap
|
||||
placeholder_pixmap = QPixmap(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
|
||||
placeholder_pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(placeholder_pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
self.imageLabel.setPixmap(placeholder_pixmap)
|
||||
else:
|
||||
# Учитываем devicePixelRatio для масштабирования высокого качества
|
||||
device_pixel_ratio = get_device_pixel_ratio()
|
||||
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
|
||||
target_height = int(self.FIXED_HEIGHT * device_pixel_ratio)
|
||||
|
||||
# Масштабируем изображение из оригинального pixmap
|
||||
scaled_pixmap = pixmap.scaled(
|
||||
target_width,
|
||||
target_height,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
|
||||
self.imageLabel.setPixmap(scaled_pixmap)
|
||||
self.captionLabel.setText(caption)
|
||||
self.setWindowTitle(caption)
|
||||
|
||||
# Принудительная перерисовка виджетов
|
||||
self.imageLabel.repaint()
|
||||
self.captionLabel.repaint()
|
||||
self.repaint()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Обновляет изображение при изменении размера окна."""
|
||||
super().resizeEvent(event)
|
||||
self.update_display() # Перерисовываем изображение с учетом нового размера
|
||||
|
||||
def show_prev(self):
|
||||
"""Показывает предыдущее изображение."""
|
||||
if self.images:
|
||||
@@ -292,7 +386,6 @@ class FullscreenDialog(QDialog):
|
||||
def mousePressEvent(self, event):
|
||||
"""Закрывает диалог при клике на пустую область."""
|
||||
pos = event.pos()
|
||||
# Проверяем, находится ли клик вне imageContainer и captionLabel
|
||||
if not (self.imageContainer.geometry().contains(pos) or
|
||||
self.captionLabel.geometry().contains(pos)):
|
||||
self.close()
|
||||
@@ -305,15 +398,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
||||
"""
|
||||
def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
|
||||
"""
|
||||
:param pixmap: QPixmap для отображения в карусели
|
||||
:param pixmap: QPixmap для отображения в карусели (оригинальное, высокое разрешение)
|
||||
:param caption: Подпись к изображению
|
||||
:param images_list: Список всех изображений (кортежей (QPixmap, caption)),
|
||||
чтобы в диалоге можно было перелистывать.
|
||||
Если не передан, будет использован только текущее изображение.
|
||||
:param index: Индекс текущего изображения в images_list.
|
||||
:param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками.
|
||||
:param images_list: Список всех изображений (кортежей (QPixmap, caption))
|
||||
:param index: Индекс текущего изображения в images_list
|
||||
:param carousel: Ссылка на родительскую карусель (ImageCarousel)
|
||||
"""
|
||||
super().__init__(pixmap)
|
||||
super().__init__()
|
||||
self.original_pixmap = pixmap # Store original high-resolution pixmap
|
||||
self.caption = caption
|
||||
self.images_list = images_list if images_list is not None else [(pixmap, caption)]
|
||||
self.index = index
|
||||
@@ -323,6 +415,20 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
||||
self._click_start_position = None
|
||||
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self.update_pixmap() # Set initial pixmap
|
||||
|
||||
def update_pixmap(self, height=300):
|
||||
"""Update the displayed pixmap by scaling from the original high-resolution pixmap."""
|
||||
if self.original_pixmap.isNull():
|
||||
return
|
||||
# Scale pixmap to desired height, considering device pixel ratio
|
||||
device_pixel_ratio = get_device_pixel_ratio()
|
||||
scaled_pixmap = self.original_pixmap.scaledToHeight(
|
||||
int(height * device_pixel_ratio),
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
|
||||
self.setPixmap(scaled_pixmap)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
@@ -339,17 +445,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
||||
event.accept()
|
||||
|
||||
def show_fullscreen(self):
|
||||
# Скрываем стрелки карусели перед открытием FullscreenDialog
|
||||
if self.carousel:
|
||||
self.carousel.prevArrow.hide()
|
||||
self.carousel.nextArrow.hide()
|
||||
dialog = FullscreenDialog(self.images_list, current_index=self.index)
|
||||
dialog.exec()
|
||||
# После закрытия диалога обновляем видимость стрелок
|
||||
if self.carousel:
|
||||
self.carousel.update_arrows_visibility()
|
||||
|
||||
|
||||
class ImageCarousel(QGraphicsView):
|
||||
"""
|
||||
Карусель изображений с адаптивностью, возможностью увеличения по клику
|
||||
@@ -357,19 +460,17 @@ class ImageCarousel(QGraphicsView):
|
||||
"""
|
||||
def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Аннотируем тип scene как QGraphicsScene
|
||||
self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
|
||||
self.setScene(self.carousel_scene)
|
||||
|
||||
self.images = images # Список кортежей: (QPixmap, caption)
|
||||
self.image_items = []
|
||||
self._animation = None
|
||||
self.theme = theme if theme else default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
self.max_height = 300 # Default height for images
|
||||
self.init_ui()
|
||||
self.create_arrows()
|
||||
|
||||
# Переменные для поддержки перетаскивания
|
||||
self._drag_active = False
|
||||
self._drag_start_position = None
|
||||
self._scroll_start_value = None
|
||||
@@ -380,30 +481,38 @@ class ImageCarousel(QGraphicsView):
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QFrame.Shape.NoFrame)
|
||||
|
||||
x_offset = 10 # Отступ между изображениями
|
||||
max_height = 300 # Фиксированная высота изображений
|
||||
self.update_scene()
|
||||
|
||||
def update_scene(self):
|
||||
"""Update the scene with scaled images based on current size and scale."""
|
||||
self.carousel_scene.clear()
|
||||
self.image_items.clear()
|
||||
|
||||
x_offset = 10
|
||||
x = 0
|
||||
device_pixel_ratio = get_device_pixel_ratio()
|
||||
|
||||
for i, (pixmap, caption) in enumerate(self.images):
|
||||
item = ClickablePixmapItem(
|
||||
pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation),
|
||||
pixmap, # Pass original pixmap
|
||||
caption,
|
||||
images_list=self.images,
|
||||
index=i,
|
||||
carousel=self # Передаем ссылку на карусель
|
||||
carousel=self
|
||||
)
|
||||
item.update_pixmap(self.max_height) # Scale to current height
|
||||
item.setPos(x, 0)
|
||||
self.carousel_scene.addItem(item)
|
||||
self.image_items.append(item)
|
||||
x += item.pixmap().width() + x_offset
|
||||
x += item.pixmap().width() / device_pixel_ratio + x_offset
|
||||
|
||||
self.setSceneRect(0, 0, x, max_height)
|
||||
self.setSceneRect(0, 0, x, self.max_height)
|
||||
|
||||
def create_arrows(self):
|
||||
"""Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
|
||||
self.prevArrow = QToolButton(self)
|
||||
self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
|
||||
self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore
|
||||
self.prevArrow.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
|
||||
self.prevArrow.setFixedSize(40, 40)
|
||||
self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.prevArrow.setAutoRepeat(True)
|
||||
@@ -414,7 +523,7 @@ class ImageCarousel(QGraphicsView):
|
||||
|
||||
self.nextArrow = QToolButton(self)
|
||||
self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
|
||||
self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore
|
||||
self.nextArrow.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
|
||||
self.nextArrow.setFixedSize(40, 40)
|
||||
self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.nextArrow.setAutoRepeat(True)
|
||||
@@ -423,14 +532,9 @@ class ImageCarousel(QGraphicsView):
|
||||
self.nextArrow.clicked.connect(self.scroll_right)
|
||||
self.nextArrow.raise_()
|
||||
|
||||
# Проверяем видимость стрелок при создании
|
||||
self.update_arrows_visibility()
|
||||
|
||||
def update_arrows_visibility(self):
|
||||
"""
|
||||
Показывает стрелки, если контент шире видимой области.
|
||||
Иначе скрывает их.
|
||||
"""
|
||||
if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
|
||||
if self.horizontalScrollBar().maximum() == 0:
|
||||
self.prevArrow.hide()
|
||||
@@ -444,7 +548,8 @@ class ImageCarousel(QGraphicsView):
|
||||
margin = 10
|
||||
self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
|
||||
self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
|
||||
(self.height() - self.nextArrow.height()) // 2)
|
||||
(self.height() - self.nextArrow.height()) // 2)
|
||||
self.update_scene() # Re-scale images on resize
|
||||
self.update_arrows_visibility()
|
||||
|
||||
def animate_scroll(self, end_value):
|
||||
@@ -469,19 +574,15 @@ class ImageCarousel(QGraphicsView):
|
||||
self.animate_scroll(new_value)
|
||||
|
||||
def update_images(self, new_images):
|
||||
self.carousel_scene.clear()
|
||||
self.images = new_images
|
||||
self.image_items.clear()
|
||||
self.init_ui()
|
||||
self.update_scene()
|
||||
self.update_arrows_visibility()
|
||||
|
||||
# Обработка событий мыши для перетаскивания
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._drag_active = True
|
||||
self._drag_start_position = event.pos()
|
||||
self._scroll_start_value = self.horizontalScrollBar().value()
|
||||
# Скрываем стрелки при начале перетаскивания
|
||||
if hasattr(self, "prevArrow"):
|
||||
self.prevArrow.hide()
|
||||
if hasattr(self, "nextArrow"):
|
||||
@@ -497,6 +598,5 @@ class ImageCarousel(QGraphicsView):
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._drag_active = False
|
||||
# Показываем стрелки после завершения перетаскивания (с проверкой видимости)
|
||||
self.update_arrows_visibility()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
73
portprotonqt/keyboard_layouts.py
Normal 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', ';', ':', '_']
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
||||
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -23,13 +23,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -38,6 +32,15 @@ msgstr ""
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import to Legendary"
|
||||
msgstr ""
|
||||
|
||||
@@ -65,9 +68,6 @@ msgstr ""
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Stopped '{game_name}'"
|
||||
msgstr ""
|
||||
@@ -76,10 +76,6 @@ msgstr ""
|
||||
msgid "Legendary executable not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "start.sh not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
@@ -124,6 +120,10 @@ msgstr ""
|
||||
msgid "Removed '{game_name}' from favorites"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "start.sh not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch game \"{name}\" with PortProton"
|
||||
msgstr ""
|
||||
@@ -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,25 +163,13 @@ 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
|
||||
msgid "No .desktop file found for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Invalid executable command: {exec_line}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse executable: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr ""
|
||||
|
||||
@@ -203,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 ""
|
||||
|
||||
@@ -225,6 +217,10 @@ msgstr ""
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Unsupported image format: {extension}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -260,15 +256,56 @@ msgstr ""
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Dir"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prev Dir"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Toggle"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prev Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -287,6 +324,9 @@ msgstr ""
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
@@ -305,6 +345,78 @@ 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 "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 "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Main"
|
||||
msgstr ""
|
||||
|
||||
msgid "Advanced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Setting"
|
||||
msgstr ""
|
||||
|
||||
msgid "Value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Info"
|
||||
msgstr ""
|
||||
|
||||
msgid "No changes to apply."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings updated successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -347,15 +459,15 @@ msgstr ""
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting PortProton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -365,6 +477,37 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
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 "Game library refreshed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -377,13 +520,122 @@ msgstr ""
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgid "A refresh is already in progress..."
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgid "Refreshing..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgid "Refreshing game library..."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Added '{name}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Compatibility tool:"
|
||||
msgstr ""
|
||||
|
||||
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 ""
|
||||
|
||||
msgid "Clearing prefix..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start prefix clear process."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix cleared successfully."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix clear failed with exit code {}."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to run clear prefix command: {}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start backup process."
|
||||
msgstr ""
|
||||
|
||||
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..."
|
||||
@@ -419,6 +671,9 @@ msgstr ""
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -443,6 +698,12 @@ msgstr ""
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Close Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
@@ -455,21 +716,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 ""
|
||||
|
||||
@@ -479,28 +725,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 ""
|
||||
|
||||
@@ -554,7 +778,8 @@ msgstr ""
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
@@ -563,6 +788,15 @@ msgstr ""
|
||||
msgid "PLAY TIME"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN + SIDES"
|
||||
msgstr ""
|
||||
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr ""
|
||||
|
||||
msgid "full"
|
||||
msgstr ""
|
||||
|
||||
@@ -606,6 +840,262 @@ msgstr ""
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Using FPS and system load monitoring (Turns on and off by the key "
|
||||
"combination - right Shift + F12)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Enable vkBasalt by default to improve graphics in games running on "
|
||||
"Vulkan. (The HOME hotkey disables vkbasalt)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
|
||||
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Super + F : Toggle fullscreen\n"
|
||||
"Super + N : Toggle nearest neighbour filtering\n"
|
||||
"Super + U : Toggle FSR upscaling\n"
|
||||
"Super + Y : Toggle NIS upscaling\n"
|
||||
"Super + I : Increase FSR sharpness by 1\n"
|
||||
"Super + O : Decrease FSR sharpness by 1\n"
|
||||
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
|
||||
"Super + G : Toggle keyboard grab\n"
|
||||
"Super + C : Update clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable in-process synchronization primitives based on eventfd."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable futex-based in-process synchronization primitives."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable in-process synchronization via the Linux ntsync driver."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable vkd3d support - Ray Tracing"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable DLSS on supported NVIDIA graphics cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable Lossless Scaling frame generation (experimental)"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disguise all NVIDIA GPU features"
|
||||
msgstr ""
|
||||
|
||||
msgid "Run the application in WINE virtual desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Run the application in a terminal"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system GameMode for performance optimization"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable forced use of third-party DirectX libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fix pink-tinted video playback in some games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reduce PulseAudio latency to fix intermittent sound"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force US keyboard layout"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use WINE shader caching"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force use of built-in DXGI library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable OBS Studio capture via obs-vkcapture"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable desktop compositing for performance"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use container launch mode (recommended default)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force DirectInput protocol instead of XInput"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable experimental native Wayland support"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable HDR settings under native Wayland"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Gallium Zink (OpenGL via Vulkan)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use WineD3D Vulkan backend (Damavand)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use async dxvk-sarek (experimental)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select the Wine or Proton version to use for this executable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Specify the Wine prefix to run this game with"
|
||||
msgstr ""
|
||||
|
||||
msgid "Newest"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Vulkan Backend"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
"\n"
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||
"driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||
"Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Windows version"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Changing the WINDOWS emulation version may be required to run older "
|
||||
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
|
||||
msgstr ""
|
||||
|
||||
msgid "DLL Overrides"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Forced to use/disable the library only for the given application.\n"
|
||||
"\n"
|
||||
"A brief instruction:\n"
|
||||
"* libraries are written WITHOUT the .dll file extension\n"
|
||||
"* libraries are separated by semicolons - ;\n"
|
||||
"* library=n - use the WINDOWS (third-party) library\n"
|
||||
"* library=b - use WINE (built-in) library\n"
|
||||
"* library=n,b - use WINDOWS library and then WINE\n"
|
||||
"* library=b,n - use WINE library and then WINDOWS\n"
|
||||
"* library= - disable the use of this library\n"
|
||||
"\n"
|
||||
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Arguments"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Adding an argument after the .exe file, just like you would add an "
|
||||
"argument in a shortcut on a WINDOWS system.\n"
|
||||
"\n"
|
||||
"Example: -dx11 -skipintro 1"
|
||||
msgstr ""
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||
"recommended to set the value equal to 8)"
|
||||
msgstr ""
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"You can select the required OpenGL version, some games require a forced "
|
||||
"Compatibility Profile (COMP)."
|
||||
msgstr ""
|
||||
|
||||
msgid "VKD3D Feature Level"
|
||||
msgstr ""
|
||||
|
||||
msgid "You can set a forced feature level VKD3D for games on DirectX12"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locale"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
|
||||
msgstr ""
|
||||
|
||||
msgid "Window Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Window mode (for Vulkan and OpenGL):\n"
|
||||
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
|
||||
"immediate - Unlimited frame rate + tearing.\n"
|
||||
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
|
||||
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
|
||||
" rate."
|
||||
msgstr ""
|
||||
|
||||
msgid "AMD Vulkan Driver"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select needed AMD vulkan implementation. Choosing which implementation of"
|
||||
" vulkan will be used to run the game"
|
||||
msgstr ""
|
||||
|
||||
msgid "NUMA Node"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
|
||||
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
|
||||
" single node reduces memory-access latency and limits costly core-to-core"
|
||||
" switches."
|
||||
msgstr ""
|
||||
|
||||
msgid "Reboot"
|
||||
msgstr ""
|
||||
|
||||
@@ -651,3 +1141,24 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
msgid "Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide"
|
||||
msgstr ""
|
||||
|
||||
msgid "No favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
||||
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -23,13 +23,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -38,6 +32,15 @@ msgstr ""
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import to Legendary"
|
||||
msgstr ""
|
||||
|
||||
@@ -65,9 +68,6 @@ msgstr ""
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Stopped '{game_name}'"
|
||||
msgstr ""
|
||||
@@ -76,10 +76,6 @@ msgstr ""
|
||||
msgid "Legendary executable not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "start.sh not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
@@ -124,6 +120,10 @@ msgstr ""
|
||||
msgid "Removed '{game_name}' from favorites"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "start.sh not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch game \"{name}\" with PortProton"
|
||||
msgstr ""
|
||||
@@ -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,25 +163,13 @@ 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
|
||||
msgid "No .desktop file found for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Invalid executable command: {exec_line}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse executable: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr ""
|
||||
|
||||
@@ -203,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 ""
|
||||
|
||||
@@ -225,6 +217,10 @@ msgstr ""
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Unsupported image format: {extension}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -260,15 +256,56 @@ msgstr ""
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Dir"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prev Dir"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Toggle"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prev Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -287,6 +324,9 @@ msgstr ""
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
@@ -305,6 +345,78 @@ 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 "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 "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Main"
|
||||
msgstr ""
|
||||
|
||||
msgid "Advanced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Setting"
|
||||
msgstr ""
|
||||
|
||||
msgid "Value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Info"
|
||||
msgstr ""
|
||||
|
||||
msgid "No changes to apply."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings updated successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -347,15 +459,15 @@ msgstr ""
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting PortProton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -365,6 +477,37 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
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 "Game library refreshed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -377,13 +520,122 @@ msgstr ""
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgid "A refresh is already in progress..."
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgid "Refreshing..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgid "Refreshing game library..."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Added '{name}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Compatibility tool:"
|
||||
msgstr ""
|
||||
|
||||
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 ""
|
||||
|
||||
msgid "Clearing prefix..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start prefix clear process."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix cleared successfully."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix clear failed with exit code {}."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to run clear prefix command: {}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start backup process."
|
||||
msgstr ""
|
||||
|
||||
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..."
|
||||
@@ -419,6 +671,9 @@ msgstr ""
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -443,6 +698,12 @@ msgstr ""
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Close Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
@@ -455,21 +716,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 ""
|
||||
|
||||
@@ -479,28 +725,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 ""
|
||||
|
||||
@@ -554,7 +778,8 @@ msgstr ""
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
@@ -563,6 +788,15 @@ msgstr ""
|
||||
msgid "PLAY TIME"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN + SIDES"
|
||||
msgstr ""
|
||||
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr ""
|
||||
|
||||
msgid "full"
|
||||
msgstr ""
|
||||
|
||||
@@ -606,6 +840,262 @@ msgstr ""
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Using FPS and system load monitoring (Turns on and off by the key "
|
||||
"combination - right Shift + F12)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Enable vkBasalt by default to improve graphics in games running on "
|
||||
"Vulkan. (The HOME hotkey disables vkbasalt)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
|
||||
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Super + F : Toggle fullscreen\n"
|
||||
"Super + N : Toggle nearest neighbour filtering\n"
|
||||
"Super + U : Toggle FSR upscaling\n"
|
||||
"Super + Y : Toggle NIS upscaling\n"
|
||||
"Super + I : Increase FSR sharpness by 1\n"
|
||||
"Super + O : Decrease FSR sharpness by 1\n"
|
||||
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
|
||||
"Super + G : Toggle keyboard grab\n"
|
||||
"Super + C : Update clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable in-process synchronization primitives based on eventfd."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable futex-based in-process synchronization primitives."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable in-process synchronization via the Linux ntsync driver."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable vkd3d support - Ray Tracing"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable DLSS on supported NVIDIA graphics cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable Lossless Scaling frame generation (experimental)"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disguise all NVIDIA GPU features"
|
||||
msgstr ""
|
||||
|
||||
msgid "Run the application in WINE virtual desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Run the application in a terminal"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system GameMode for performance optimization"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable forced use of third-party DirectX libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fix pink-tinted video playback in some games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reduce PulseAudio latency to fix intermittent sound"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force US keyboard layout"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use WINE shader caching"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force use of built-in DXGI library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable OBS Studio capture via obs-vkcapture"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable desktop compositing for performance"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use container launch mode (recommended default)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force DirectInput protocol instead of XInput"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable experimental native Wayland support"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable HDR settings under native Wayland"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Gallium Zink (OpenGL via Vulkan)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use WineD3D Vulkan backend (Damavand)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use async dxvk-sarek (experimental)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select the Wine or Proton version to use for this executable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Specify the Wine prefix to run this game with"
|
||||
msgstr ""
|
||||
|
||||
msgid "Newest"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Vulkan Backend"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
"\n"
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||
"driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||
"Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Windows version"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Changing the WINDOWS emulation version may be required to run older "
|
||||
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
|
||||
msgstr ""
|
||||
|
||||
msgid "DLL Overrides"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Forced to use/disable the library only for the given application.\n"
|
||||
"\n"
|
||||
"A brief instruction:\n"
|
||||
"* libraries are written WITHOUT the .dll file extension\n"
|
||||
"* libraries are separated by semicolons - ;\n"
|
||||
"* library=n - use the WINDOWS (third-party) library\n"
|
||||
"* library=b - use WINE (built-in) library\n"
|
||||
"* library=n,b - use WINDOWS library and then WINE\n"
|
||||
"* library=b,n - use WINE library and then WINDOWS\n"
|
||||
"* library= - disable the use of this library\n"
|
||||
"\n"
|
||||
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Arguments"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Adding an argument after the .exe file, just like you would add an "
|
||||
"argument in a shortcut on a WINDOWS system.\n"
|
||||
"\n"
|
||||
"Example: -dx11 -skipintro 1"
|
||||
msgstr ""
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||
"recommended to set the value equal to 8)"
|
||||
msgstr ""
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"You can select the required OpenGL version, some games require a forced "
|
||||
"Compatibility Profile (COMP)."
|
||||
msgstr ""
|
||||
|
||||
msgid "VKD3D Feature Level"
|
||||
msgstr ""
|
||||
|
||||
msgid "You can set a forced feature level VKD3D for games on DirectX12"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locale"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
|
||||
msgstr ""
|
||||
|
||||
msgid "Window Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Window mode (for Vulkan and OpenGL):\n"
|
||||
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
|
||||
"immediate - Unlimited frame rate + tearing.\n"
|
||||
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
|
||||
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
|
||||
" rate."
|
||||
msgstr ""
|
||||
|
||||
msgid "AMD Vulkan Driver"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select needed AMD vulkan implementation. Choosing which implementation of"
|
||||
" vulkan will be used to run the game"
|
||||
msgstr ""
|
||||
|
||||
msgid "NUMA Node"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
|
||||
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
|
||||
" single node reduces memory-access latency and limits costly core-to-core"
|
||||
" switches."
|
||||
msgstr ""
|
||||
|
||||
msgid "Reboot"
|
||||
msgstr ""
|
||||
|
||||
@@ -651,3 +1141,24 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
msgid "Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide"
|
||||
msgstr ""
|
||||
|
||||
msgid "No favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -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-07-06 17:56+0500\n"
|
||||
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -21,13 +21,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -36,6 +30,15 @@ msgstr ""
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import to Legendary"
|
||||
msgstr ""
|
||||
|
||||
@@ -63,9 +66,6 @@ msgstr ""
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Stopped '{game_name}'"
|
||||
msgstr ""
|
||||
@@ -74,10 +74,6 @@ msgstr ""
|
||||
msgid "Legendary executable not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "start.sh not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Success"
|
||||
msgstr ""
|
||||
|
||||
@@ -122,6 +118,10 @@ msgstr ""
|
||||
msgid "Removed '{game_name}' from favorites"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "start.sh not found at {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch game \"{name}\" with PortProton"
|
||||
msgstr ""
|
||||
@@ -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,25 +161,13 @@ 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
|
||||
msgid "No .desktop file found for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Invalid executable command: {exec_line}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse executable: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr ""
|
||||
|
||||
@@ -201,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 ""
|
||||
|
||||
@@ -223,6 +215,10 @@ msgstr ""
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Unsupported image format: {extension}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -258,15 +254,56 @@ msgstr ""
|
||||
msgid "Select All"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Dir"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prev Dir"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
msgid "Toggle"
|
||||
msgstr ""
|
||||
|
||||
msgid "Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prev Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select"
|
||||
msgstr ""
|
||||
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -285,6 +322,9 @@ msgstr ""
|
||||
msgid "Custom Cover:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr ""
|
||||
|
||||
@@ -303,6 +343,78 @@ 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 "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 "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Main"
|
||||
msgstr ""
|
||||
|
||||
msgid "Advanced"
|
||||
msgstr ""
|
||||
|
||||
msgid "Setting"
|
||||
msgstr ""
|
||||
|
||||
msgid "Value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Info"
|
||||
msgstr ""
|
||||
|
||||
msgid "No changes to apply."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings updated successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -345,15 +457,15 @@ msgstr ""
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting PortProton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -363,6 +475,37 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refresh Grid"
|
||||
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 "Game library refreshed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -375,13 +518,122 @@ msgstr ""
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgid "A refresh is already in progress..."
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgid "Refreshing..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgid "Refreshing game library..."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Added '{name}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Compatibility tool:"
|
||||
msgstr ""
|
||||
|
||||
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 ""
|
||||
|
||||
msgid "Clearing prefix..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start prefix clear process."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix cleared successfully."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix clear failed with exit code {}."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to run clear prefix command: {}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start backup process."
|
||||
msgstr ""
|
||||
|
||||
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..."
|
||||
@@ -417,6 +669,9 @@ msgstr ""
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -441,6 +696,12 @@ msgstr ""
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Application Close Mode:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
@@ -453,21 +714,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 ""
|
||||
|
||||
@@ -477,28 +723,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 ""
|
||||
|
||||
@@ -552,7 +776,8 @@ msgstr ""
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
@@ -561,6 +786,15 @@ msgstr ""
|
||||
msgid "PLAY TIME"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN + SIDES"
|
||||
msgstr ""
|
||||
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr ""
|
||||
|
||||
msgid "full"
|
||||
msgstr ""
|
||||
|
||||
@@ -604,6 +838,262 @@ msgstr ""
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Using FPS and system load monitoring (Turns on and off by the key "
|
||||
"combination - right Shift + F12)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Enable vkBasalt by default to improve graphics in games running on "
|
||||
"Vulkan. (The HOME hotkey disables vkbasalt)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
|
||||
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Super + F : Toggle fullscreen\n"
|
||||
"Super + N : Toggle nearest neighbour filtering\n"
|
||||
"Super + U : Toggle FSR upscaling\n"
|
||||
"Super + Y : Toggle NIS upscaling\n"
|
||||
"Super + I : Increase FSR sharpness by 1\n"
|
||||
"Super + O : Decrease FSR sharpness by 1\n"
|
||||
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
|
||||
"Super + G : Toggle keyboard grab\n"
|
||||
"Super + C : Update clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable in-process synchronization primitives based on eventfd."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable futex-based in-process synchronization primitives."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable in-process synchronization via the Linux ntsync driver."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable vkd3d support - Ray Tracing"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable DLSS on supported NVIDIA graphics cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable Lossless Scaling frame generation (experimental)"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disguise all NVIDIA GPU features"
|
||||
msgstr ""
|
||||
|
||||
msgid "Run the application in WINE virtual desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Run the application in a terminal"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system GameMode for performance optimization"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable forced use of third-party DirectX libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fix pink-tinted video playback in some games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reduce PulseAudio latency to fix intermittent sound"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force US keyboard layout"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use WINE shader caching"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force use of built-in DXGI library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable OBS Studio capture via obs-vkcapture"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable desktop compositing for performance"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use container launch mode (recommended default)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force DirectInput protocol instead of XInput"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable experimental native Wayland support"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable HDR settings under native Wayland"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Gallium Zink (OpenGL via Vulkan)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use WineD3D Vulkan backend (Damavand)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use async dxvk-sarek (experimental)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Version"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select the Wine or Proton version to use for this executable."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix Name"
|
||||
msgstr ""
|
||||
|
||||
msgid "Specify the Wine prefix to run this game with"
|
||||
msgstr ""
|
||||
|
||||
msgid "Newest"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Vulkan Backend"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
"\n"
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||
"driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||
"Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Windows version"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Changing the WINDOWS emulation version may be required to run older "
|
||||
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
|
||||
msgstr ""
|
||||
|
||||
msgid "DLL Overrides"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Forced to use/disable the library only for the given application.\n"
|
||||
"\n"
|
||||
"A brief instruction:\n"
|
||||
"* libraries are written WITHOUT the .dll file extension\n"
|
||||
"* libraries are separated by semicolons - ;\n"
|
||||
"* library=n - use the WINDOWS (third-party) library\n"
|
||||
"* library=b - use WINE (built-in) library\n"
|
||||
"* library=n,b - use WINDOWS library and then WINE\n"
|
||||
"* library=b,n - use WINE library and then WINDOWS\n"
|
||||
"* library= - disable the use of this library\n"
|
||||
"\n"
|
||||
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Arguments"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Adding an argument after the .exe file, just like you would add an "
|
||||
"argument in a shortcut on a WINDOWS system.\n"
|
||||
"\n"
|
||||
"Example: -dx11 -skipintro 1"
|
||||
msgstr ""
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||
"recommended to set the value equal to 8)"
|
||||
msgstr ""
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"You can select the required OpenGL version, some games require a forced "
|
||||
"Compatibility Profile (COMP)."
|
||||
msgstr ""
|
||||
|
||||
msgid "VKD3D Feature Level"
|
||||
msgstr ""
|
||||
|
||||
msgid "You can set a forced feature level VKD3D for games on DirectX12"
|
||||
msgstr ""
|
||||
|
||||
msgid "Locale"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
|
||||
msgstr ""
|
||||
|
||||
msgid "Window Mode"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Window mode (for Vulkan and OpenGL):\n"
|
||||
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
|
||||
"immediate - Unlimited frame rate + tearing.\n"
|
||||
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
|
||||
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
|
||||
" rate."
|
||||
msgstr ""
|
||||
|
||||
msgid "AMD Vulkan Driver"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Select needed AMD vulkan implementation. Choosing which implementation of"
|
||||
" vulkan will be used to run the game"
|
||||
msgstr ""
|
||||
|
||||
msgid "NUMA Node"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
|
||||
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
|
||||
" single node reduces memory-access latency and limits costly core-to-core"
|
||||
" switches."
|
||||
msgstr ""
|
||||
|
||||
msgid "Reboot"
|
||||
msgstr ""
|
||||
|
||||
@@ -649,3 +1139,24 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Show"
|
||||
msgstr ""
|
||||
|
||||
msgid "Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide"
|
||||
msgstr ""
|
||||
|
||||
msgid "No favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
||||
"PO-Revision-Date: 2025-07-06 17:56+0500\n"
|
||||
"POT-Creation-Date: 2025-11-30 13:20+0500\n"
|
||||
"PO-Revision-Date: 2025-11-30 13:18+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
@@ -24,14 +24,8 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr "Ошибка"
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgstr "PortProton не найден"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Остановить игру"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Запустить игру"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr "Не найден каталог PortProton"
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr "Удалить из Избранного"
|
||||
@@ -39,6 +33,15 @@ msgstr "Удалить из Избранного"
|
||||
msgid "Add to Favorites"
|
||||
msgstr "Добавить в Избранное"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Удалить из PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Остановить игру"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Запустить игру"
|
||||
|
||||
msgid "Import to Legendary"
|
||||
msgstr "Импортировать игру"
|
||||
|
||||
@@ -66,9 +69,6 @@ msgstr "Добавить в меню"
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Редактировать"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Удалить из PortProton"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Stopped '{game_name}'"
|
||||
msgstr "Остановлен(а) '{game_name}'"
|
||||
@@ -77,10 +77,6 @@ msgstr "Остановлен(а) '{game_name}'"
|
||||
msgid "Legendary executable not found at {path}"
|
||||
msgstr "Legendary не найден по пути {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "start.sh not found at {path}"
|
||||
msgstr "start.sh не найден по адресу {path}"
|
||||
|
||||
msgid "Success"
|
||||
msgstr "Успешно"
|
||||
|
||||
@@ -127,6 +123,10 @@ msgstr "'{game_name}' был(а) добавлен(а) в избранное"
|
||||
msgid "Removed '{game_name}' from favorites"
|
||||
msgstr "'{game_name}' был(а) удалён(а) из избранного"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "start.sh not found at {path}"
|
||||
msgstr "start.sh не найден по адресу {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch game \"{name}\" with PortProton"
|
||||
msgstr "Запустить игру \"{name}\" с помощью PortProton"
|
||||
@@ -158,33 +158,21 @@ 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}'"
|
||||
msgstr "Файл .desktop для '{game_name}' не найден"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Invalid executable command: {exec_line}"
|
||||
msgstr "Недопустимая исполняемая команда: {exec_line}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {path}"
|
||||
msgstr "Исполняемый файл не найден: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse executable: {error}"
|
||||
msgstr "Не удалось разобрать исполняемый файл: {error}"
|
||||
|
||||
msgid "Confirm Deletion"
|
||||
msgstr "Подтвердите удаление"
|
||||
|
||||
@@ -208,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 "Требуются название игры и путь к исполняемому файлу"
|
||||
|
||||
@@ -230,6 +222,10 @@ msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Не удалось скопировать обложку: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Unsupported image format: {extension}"
|
||||
msgstr "Неподдерживаемый формат изображения: {extension}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
@@ -267,15 +263,56 @@ msgstr "Удалить"
|
||||
msgid "Select All"
|
||||
msgstr "Выбрать всё"
|
||||
|
||||
msgid "Select"
|
||||
msgstr "Выбрать"
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
|
||||
msgid "Select Dir"
|
||||
msgstr "Выбрать папку"
|
||||
|
||||
msgid "Prev Dir"
|
||||
msgstr "Предыдущий каталог"
|
||||
|
||||
msgid "Cancel"
|
||||
msgstr "Отмена"
|
||||
|
||||
msgid "Toggle"
|
||||
msgstr "Переключить"
|
||||
|
||||
msgid "Install"
|
||||
msgstr "Установить"
|
||||
|
||||
msgid "Force Install"
|
||||
msgstr "Принудительно установить"
|
||||
|
||||
msgid "Prev Tab"
|
||||
msgstr "Предыдущая вкладка"
|
||||
|
||||
msgid "Next Tab"
|
||||
msgstr "Следующая вкладка"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launching {0}"
|
||||
msgstr "Идёт запуск {0}"
|
||||
|
||||
msgid "File Explorer"
|
||||
msgstr "Проводник"
|
||||
|
||||
msgid "Select"
|
||||
msgstr "Выбрать"
|
||||
|
||||
msgid "Path: "
|
||||
msgstr "Путь: "
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr "Доступ запрещён: %s"
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr "Редактировать игру"
|
||||
|
||||
@@ -294,6 +331,9 @@ msgstr "Обзор..."
|
||||
msgid "Custom Cover:"
|
||||
msgstr "Обложка:"
|
||||
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
msgid "Cover Preview:"
|
||||
msgstr "Предпросмотр обложки:"
|
||||
|
||||
@@ -312,6 +352,78 @@ 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 "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 "Exe Settings"
|
||||
msgstr "Настройки EXE"
|
||||
|
||||
msgid "Search:"
|
||||
msgstr "Поиск:"
|
||||
|
||||
msgid "Search settings..."
|
||||
msgstr "Поиск настроек..."
|
||||
|
||||
msgid "Main"
|
||||
msgstr "Основные"
|
||||
|
||||
msgid "Advanced"
|
||||
msgstr "Расширенные"
|
||||
|
||||
msgid "Setting"
|
||||
msgstr "Параметр"
|
||||
|
||||
msgid "Value"
|
||||
msgstr "Значение"
|
||||
|
||||
msgid "Description"
|
||||
msgstr "Описание"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "отключено"
|
||||
|
||||
msgid "Info"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "No changes to apply."
|
||||
msgstr "Изменений для применения нет."
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Не удалось применить изменения. Проверьте логи."
|
||||
|
||||
msgid "Settings updated successfully."
|
||||
msgstr "Настройки успешно обновлены."
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr "Загрузка игр из Epic Games Store..."
|
||||
|
||||
@@ -354,15 +466,15 @@ msgstr "В ожидании"
|
||||
msgid "Unknown Game"
|
||||
msgstr "Неизвестная игра"
|
||||
|
||||
msgid "Starting PortProton..."
|
||||
msgstr "Инициализация PortProton"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Библиотека"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Автоустановка"
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr "Эмуляторы"
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr "Настройки wine"
|
||||
|
||||
@@ -372,6 +484,37 @@ msgstr "Настройки PortProton"
|
||||
msgid "Themes"
|
||||
msgstr "Темы"
|
||||
|
||||
msgid "Back"
|
||||
msgstr "Назад"
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr "Полный экран"
|
||||
|
||||
msgid "Refresh Grid"
|
||||
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 "Game library refreshed"
|
||||
msgstr "Игровая библиотека обновлена"
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr "Загрузка игр из Steam..."
|
||||
|
||||
@@ -384,14 +527,123 @@ msgstr "Игровая библиотека"
|
||||
msgid "Find Games ..."
|
||||
msgstr "Найти игры..."
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgstr "Здесь можно настроить автоматическую установку игр..."
|
||||
msgid "A refresh is already in progress..."
|
||||
msgstr "Обновление уже выполняется..."
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgstr "Список доступных эмуляторов и их настройка..."
|
||||
msgid "Refreshing..."
|
||||
msgstr "Обновление..."
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgstr "Различные параметры и версии wine..."
|
||||
msgid "Refreshing game library..."
|
||||
msgstr "Обновление игровой библиотеки..."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Added '{name}'"
|
||||
msgstr "'{name}' добавлен(а)"
|
||||
|
||||
msgid "Compatibility tool:"
|
||||
msgstr "Инструмент совместимости:"
|
||||
|
||||
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 "Вы уверены, что хотите очистить префикс «{}»?"
|
||||
|
||||
msgid "Clearing prefix..."
|
||||
msgstr "Очистка префикса..."
|
||||
|
||||
msgid "Failed to start prefix clear process."
|
||||
msgstr "Не удалось запустить процесс очистки префикса."
|
||||
|
||||
msgid "Prefix cleared successfully."
|
||||
msgstr "Префикс удален успешно."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix clear failed with exit code {}."
|
||||
msgstr "Очистка префикса завершилась с кодом завершения {}."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to run clear prefix command: {}"
|
||||
msgstr "Не удалось выполнить команду очистки префикса: {}"
|
||||
|
||||
msgid "Failed to start backup process."
|
||||
msgstr "Не удалось запустить процесс резервного копирования."
|
||||
|
||||
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..."
|
||||
@@ -426,6 +678,9 @@ msgstr "все"
|
||||
msgid "Games Display Filter:"
|
||||
msgstr "Фильтр игр:"
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr "Тип геймпада:"
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr "Адрес прокси"
|
||||
|
||||
@@ -450,6 +705,12 @@ msgstr "Запуск приложения в полноэкранном режи
|
||||
msgid "Application Fullscreen Mode:"
|
||||
msgstr "Режим полноэкранного отображения приложения:"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Сворачивать в трей при закрытии"
|
||||
|
||||
msgid "Application Close Mode:"
|
||||
msgstr "Режим закрытия приложения:"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
|
||||
|
||||
@@ -462,21 +723,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 "Сохранить настройки"
|
||||
|
||||
@@ -486,28 +732,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 "Подтвердите удаление"
|
||||
|
||||
@@ -563,8 +787,9 @@ msgstr "Тема '{0}' применена успешно"
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr "Ошибка при применение темы '{0}'"
|
||||
|
||||
msgid "Back"
|
||||
msgstr "Назад"
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Исполняемый файл не найден: {0}"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "Последний запуск"
|
||||
@@ -572,6 +797,15 @@ msgstr "Последний запуск"
|
||||
msgid "PLAY TIME"
|
||||
msgstr "Время игры"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "СЮЖЕТ"
|
||||
|
||||
msgid "MAIN + SIDES"
|
||||
msgstr "СЮЖЕТ + ПОБОЧКИ"
|
||||
|
||||
msgid "COMPLETIONIST"
|
||||
msgstr "100%"
|
||||
|
||||
msgid "full"
|
||||
msgstr "полная"
|
||||
|
||||
@@ -615,6 +849,329 @@ msgstr "Неправильный формат команды (flatpak)"
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Файл не найден: {0}"
|
||||
|
||||
msgid ""
|
||||
"Using FPS and system load monitoring (Turns on and off by the key "
|
||||
"combination - right Shift + F12)"
|
||||
msgstr ""
|
||||
"Использование мониторинга FPS и нагрузки системы (включается и "
|
||||
"выключается комбинацией клавиш - правая Shift + F12)"
|
||||
|
||||
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
|
||||
msgstr "Принудительное использование системных настроек MANGOHUD (GOverlay и т.д.)"
|
||||
|
||||
msgid ""
|
||||
"Enable vkBasalt by default to improve graphics in games running on "
|
||||
"Vulkan. (The HOME hotkey disables vkbasalt)"
|
||||
msgstr ""
|
||||
"Включить vkBasalt по умолчанию для улучшения графики в играх на Vulkan. "
|
||||
"(Горячая клавиша HOME отключает vkbasalt)"
|
||||
|
||||
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
|
||||
msgstr "Принудительное использование системных настроек VKBASALT (GOverlay и т.д.)"
|
||||
|
||||
msgid ""
|
||||
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
|
||||
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
|
||||
msgstr ""
|
||||
"Включить dgVoodoo2. Принудительное использование всех библиотек dgVoodoo2"
|
||||
" (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) на всех 3D API."
|
||||
|
||||
msgid ""
|
||||
"Super + F : Toggle fullscreen\n"
|
||||
"Super + N : Toggle nearest neighbour filtering\n"
|
||||
"Super + U : Toggle FSR upscaling\n"
|
||||
"Super + Y : Toggle NIS upscaling\n"
|
||||
"Super + I : Increase FSR sharpness by 1\n"
|
||||
"Super + O : Decrease FSR sharpness by 1\n"
|
||||
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
|
||||
"Super + G : Toggle keyboard grab\n"
|
||||
"Super + C : Update clipboard"
|
||||
msgstr ""
|
||||
"Super + F: Переключить полноэкранный режим\n"
|
||||
"Super + N: Переключить фильтрацию ближайшего соседа\n"
|
||||
"Super + U: Переключить апскейлинг FSR\n"
|
||||
"Super + Y: Переключить апскейлинг NIS\n"
|
||||
"Super + I: Увеличить резкость FSR на 1\n"
|
||||
"Super + O: Уменьшить резкость FSR на 1\n"
|
||||
"Super + S: Сделать скриншот (сейчас сохраняется в "
|
||||
"/tmp/gamescope_DATE.png)\n"
|
||||
"Super + G: Переключить захват клавиатуры\n"
|
||||
"Super + C: Обновить буфер обмена"
|
||||
|
||||
msgid "Enable in-process synchronization primitives based on eventfd."
|
||||
msgstr "Включить примитивы синхронизации в процессе на основе eventfd."
|
||||
|
||||
msgid "Enable futex-based in-process synchronization primitives."
|
||||
msgstr "Включить примитивы синхронизации в процессе на основе futex."
|
||||
|
||||
msgid "Enable in-process synchronization via the Linux ntsync driver."
|
||||
msgstr "Включить синхронизацию в процессе через драйвер ntsync в Linux."
|
||||
|
||||
msgid "Enable vkd3d support - Ray Tracing"
|
||||
msgstr "Включить поддержку vkd3d — трассировка лучей"
|
||||
|
||||
msgid "Enable DLSS on supported NVIDIA graphics cards"
|
||||
msgstr "Включить DLSS на поддерживаемых видеокартах NVIDIA"
|
||||
|
||||
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
|
||||
msgstr "Включить OptiScaler (замена апскейлера / генератора кадров)"
|
||||
|
||||
msgid "Enable Lossless Scaling frame generation (experimental)"
|
||||
msgstr "Включить генерацию кадров Lossless Scaling (экспериментально)"
|
||||
|
||||
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
|
||||
msgstr "Апскейлинг FSR в полноэкранном режиме с ProtonGE ниже родного разрешения"
|
||||
|
||||
msgid "Disguise all NVIDIA GPU features"
|
||||
msgstr "Маскировать все функции GPU NVIDIA"
|
||||
|
||||
msgid "Run the application in WINE virtual desktop"
|
||||
msgstr "Запускать приложение в виртуальном рабочем столе WINE"
|
||||
|
||||
msgid "Run the application in a terminal"
|
||||
msgstr "Запускать приложение в терминале"
|
||||
|
||||
msgid "Use system GameMode for performance optimization"
|
||||
msgstr "Использовать системный GameMode для оптимизации производительности"
|
||||
|
||||
msgid "Enable forced use of third-party DirectX libraries"
|
||||
msgstr "Включить принудительное использование сторонних библиотек DirectX"
|
||||
|
||||
msgid "Fix pink-tinted video playback in some games"
|
||||
msgstr "Исправить розовый оттенок видео в некоторых играх"
|
||||
|
||||
msgid "Reduce PulseAudio latency to fix intermittent sound"
|
||||
msgstr "Уменьшить задержку PulseAudio для исправления прерывистого звука"
|
||||
|
||||
msgid "Force US keyboard layout"
|
||||
msgstr "Принудительно использовать раскладку клавиатуры US"
|
||||
|
||||
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||
msgstr "Использовать GStreamer для внутриигровых клипов (поддержка WMF)"
|
||||
|
||||
msgid "Use WINE shader caching"
|
||||
msgstr "Использовать кэширование шейдеров WINE"
|
||||
|
||||
msgid "Force use of built-in DXGI library"
|
||||
msgstr "Принудительно использовать встроенную библиотеку DXGI"
|
||||
|
||||
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
|
||||
msgstr "Включить среды выполнения Easy Anti-Cheat и BattlEye"
|
||||
|
||||
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
|
||||
msgstr "Использовать системные слои Vulkan (MangoHud, vkBasalt, OBS и т.д.)"
|
||||
|
||||
msgid "Enable OBS Studio capture via obs-vkcapture"
|
||||
msgstr "Включить захват OBS Studio через obs-vkcapture"
|
||||
|
||||
msgid "Disable desktop compositing for performance"
|
||||
msgstr "Отключить композицию рабочего стола для производительности"
|
||||
|
||||
msgid "Use container launch mode (recommended default)"
|
||||
msgstr "Использовать режим запуска в контейнере (рекомендуемый по умолчанию)"
|
||||
|
||||
msgid "Force DirectInput protocol instead of XInput"
|
||||
msgstr "Принудительно использовать протокол DirectInput вместо XInput"
|
||||
|
||||
msgid "Enable experimental native Wayland support"
|
||||
msgstr "Включить экспериментальную нативную поддержку Wayland"
|
||||
|
||||
msgid "Enable HDR settings under native Wayland"
|
||||
msgstr "Включить настройки HDR под нативным Wayland"
|
||||
|
||||
msgid "Use Gallium Zink (OpenGL via Vulkan)"
|
||||
msgstr "Использовать Gallium Zink (OpenGL через Vulkan)"
|
||||
|
||||
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
|
||||
msgstr "Использовать Gallium Nine (нативный DirectX 9 для Mesa)"
|
||||
|
||||
msgid "Use WineD3D Vulkan backend (Damavand)"
|
||||
msgstr "Использовать бэкенд Vulkan WineD3D (Damavand)"
|
||||
|
||||
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
|
||||
msgstr "Использовать встроенные dxvk/vkd3d из Wine/Proton"
|
||||
|
||||
msgid "Use async dxvk-sarek (experimental)"
|
||||
msgstr "Использовать асинхронный dxvk-sarek (экспериментально)"
|
||||
|
||||
msgid "Wine Version"
|
||||
msgstr "Версия Wine"
|
||||
|
||||
msgid "Select the Wine or Proton version to use for this executable."
|
||||
msgstr "Выбор версии Wine или Proton для использования с этим исполняемым файлом."
|
||||
|
||||
msgid "Prefix Name"
|
||||
msgstr "Имя префикса"
|
||||
|
||||
msgid "Specify the Wine prefix to run this game with"
|
||||
msgstr "Укажите префикс Wine для запуска этой игры"
|
||||
|
||||
msgid "Newest"
|
||||
msgstr "Новейший"
|
||||
|
||||
msgid "Stable"
|
||||
msgstr "Стабильный"
|
||||
|
||||
msgid "Vulkan Backend"
|
||||
msgstr "Vulkan рендеринг"
|
||||
|
||||
msgid ""
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
"\n"
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires "
|
||||
"modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ "
|
||||
"driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, "
|
||||
"Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
msgstr ""
|
||||
"Выберите бэкэнд DirectX → Vulkan/OpenGL:\n"
|
||||
"\n"
|
||||
"• Новейший — последние версии DXVK + VKD3D (наилучшая "
|
||||
"совместимость/производительность, требует современных драйверов: AMD Mesa"
|
||||
" 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Стабильный — более старая, хорошо протестированная версия DXVK + VKD3D "
|
||||
"(работает с любыми драйверами Vulkan 1.3+)\n"
|
||||
"• Sarek — экспериментальная версия DXVK-Sarek + VKD3D-Sarek (поддерживает"
|
||||
" более старые драйверы, Vulkan 1.1+)\n"
|
||||
"• WINED3D — резервный вариант OpenGL (наименьшая производительность, "
|
||||
"используйте только в случае сбоя других вариантов)"
|
||||
|
||||
msgid "Windows version"
|
||||
msgstr "Версия Windows"
|
||||
|
||||
msgid ""
|
||||
"Changing the WINDOWS emulation version may be required to run older "
|
||||
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
|
||||
msgstr ""
|
||||
"Изменение версии эмуляции WINDOWS может потребоваться для запуска старых "
|
||||
"игр. Версии WINDOWS ниже 10 не поддерживают новые игры с DirectX 12"
|
||||
|
||||
msgid "DLL Overrides"
|
||||
msgstr "Переопределения DLL"
|
||||
|
||||
msgid ""
|
||||
"Forced to use/disable the library only for the given application.\n"
|
||||
"\n"
|
||||
"A brief instruction:\n"
|
||||
"* libraries are written WITHOUT the .dll file extension\n"
|
||||
"* libraries are separated by semicolons - ;\n"
|
||||
"* library=n - use the WINDOWS (third-party) library\n"
|
||||
"* library=b - use WINE (built-in) library\n"
|
||||
"* library=n,b - use WINDOWS library and then WINE\n"
|
||||
"* library=b,n - use WINE library and then WINDOWS\n"
|
||||
"* library= - disable the use of this library\n"
|
||||
"\n"
|
||||
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
|
||||
msgstr ""
|
||||
"Принудительное использование/отключение библиотеки только для данного "
|
||||
"приложения.\n"
|
||||
"\n"
|
||||
"Краткая инструкция:\n"
|
||||
"* библиотеки пишутся БЕЗ расширения .dll\n"
|
||||
"* библиотеки разделяются точкой с запятой - ;\n"
|
||||
"* library=n — использовать библиотеку WINDOWS (стороннюю)\n"
|
||||
"* library=b — использовать библиотеку WINE (встроенную)\n"
|
||||
"* library=n,b — использовать библиотеку WINDOWS, затем WINE\n"
|
||||
"* library=b,n — использовать библиотеку WINE, затем WINDOWS\n"
|
||||
"* library= — отключить использование этой библиотеки\n"
|
||||
"\n"
|
||||
"Пример: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
|
||||
|
||||
msgid "Launch Arguments"
|
||||
msgstr "Аргументы запуска"
|
||||
|
||||
msgid ""
|
||||
"Adding an argument after the .exe file, just like you would add an "
|
||||
"argument in a shortcut on a WINDOWS system.\n"
|
||||
"\n"
|
||||
"Example: -dx11 -skipintro 1"
|
||||
msgstr ""
|
||||
"Добавление аргумента после файла .exe, как вы бы добавили аргумент в "
|
||||
"ярлыке на системе WINDOWS.\n"
|
||||
"\n"
|
||||
"Пример: -dx11 -skipintro 1"
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr "Ограничение ядер CPU"
|
||||
|
||||
msgid ""
|
||||
"Limiting the number of CPU cores is useful for Unity games (It is "
|
||||
"recommended to set the value equal to 8)"
|
||||
msgstr ""
|
||||
"Ограничение количества ядер CPU полезно для игр на Unity (рекомендуется "
|
||||
"установить значение равным 8)"
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr "Версия OpenGL"
|
||||
|
||||
msgid ""
|
||||
"You can select the required OpenGL version, some games require a forced "
|
||||
"Compatibility Profile (COMP)."
|
||||
msgstr ""
|
||||
"Вы можете выбрать требуемую версию OpenGL, некоторые игры требуют "
|
||||
"принудительного профиля совместимости (COMP)."
|
||||
|
||||
msgid "VKD3D Feature Level"
|
||||
msgstr "Уровень возможностей VKD3D"
|
||||
|
||||
msgid "You can set a forced feature level VKD3D for games on DirectX12"
|
||||
msgstr ""
|
||||
"Вы можете установить принудительный уровень возможностей VKD3D для игр на"
|
||||
" DirectX12"
|
||||
|
||||
msgid "Locale"
|
||||
msgstr "Локаль"
|
||||
|
||||
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
|
||||
msgstr ""
|
||||
"Принудительно установить определённую локаль для приложения. Исправляет "
|
||||
"проблемы с кодировкой в устаревшем ПО"
|
||||
|
||||
msgid "Window Mode"
|
||||
msgstr "Режим окна"
|
||||
|
||||
msgid ""
|
||||
"Window mode (for Vulkan and OpenGL):\n"
|
||||
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
|
||||
"immediate - Unlimited frame rate + tearing.\n"
|
||||
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
|
||||
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
|
||||
" rate."
|
||||
msgstr ""
|
||||
"Режим окна (для Vulkan и OpenGL):\n"
|
||||
"fifo — Первый вошёл, первый вышел. Ограничивает частоту кадров + без "
|
||||
"разрывов. (VSync)\n"
|
||||
"immediate — Неограниченная частота кадров + разрывы.\n"
|
||||
"mailbox — Трёхбуферная. Неограниченная частота кадров + без разрывов.\n"
|
||||
"relaxed — То же, что fifo, но позволяет разрывы при частоте ниже частоты "
|
||||
"обновления монитора."
|
||||
|
||||
msgid "AMD Vulkan Driver"
|
||||
msgstr "Драйвер Vulkan AMD"
|
||||
|
||||
msgid ""
|
||||
"Select needed AMD vulkan implementation. Choosing which implementation of"
|
||||
" vulkan will be used to run the game"
|
||||
msgstr ""
|
||||
"Выберите нужную реализацию Vulkan AMD. Выбор, какая реализация Vulkan "
|
||||
"будет использоваться для запуска игры"
|
||||
|
||||
msgid "NUMA Node"
|
||||
msgstr "Узел NUMA"
|
||||
|
||||
msgid ""
|
||||
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
|
||||
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
|
||||
" single node reduces memory-access latency and limits costly core-to-core"
|
||||
" switches."
|
||||
msgstr ""
|
||||
"Узел NUMA для аффинности CPU. В многоядерных системах CPU разделены на "
|
||||
"узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка игры к "
|
||||
"одному узлу уменьшает задержку доступа к памяти и ограничивает "
|
||||
"дорогостоящие переключения между ядрами."
|
||||
|
||||
msgid "Reboot"
|
||||
msgstr "Перезагрузить"
|
||||
|
||||
@@ -660,3 +1217,24 @@ msgstr "мин."
|
||||
msgid "sec."
|
||||
msgstr "сек."
|
||||
|
||||
msgid "Show"
|
||||
msgstr "Показать"
|
||||
|
||||
msgid "Favorites"
|
||||
msgstr "Избранное"
|
||||
|
||||
msgid "Recent Games"
|
||||
msgstr "Недавние"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Выход"
|
||||
|
||||
msgid "Hide"
|
||||
msgstr "Скрыть"
|
||||
|
||||
msgid "No favorites"
|
||||
msgstr "Нет избранных"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Нет недавних игр"
|
||||
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import logging
|
||||
|
||||
def setup_logger():
|
||||
def setup_logger(level='NOTSET'):
|
||||
"""Настройка базовой конфигурации логирования."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(levelname)s] %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
# Clear existing handlers to prevent duplicates
|
||||
root_logger = logging.getLogger()
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Convert string level to logging level constant, map ALL to DEBUG
|
||||
if level.upper() == 'ALL':
|
||||
log_level = logging.DEBUG
|
||||
else:
|
||||
log_level = getattr(logging, level.upper(), logging.NOTSET)
|
||||
|
||||
# Configure logging with null handler if level is NOTSET
|
||||
if log_level == logging.NOTSET:
|
||||
logging.basicConfig(
|
||||
level=logging.NOTSET,
|
||||
handlers=[logging.NullHandler()]
|
||||
)
|
||||
else:
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format='[%(levelname)s] %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
|
||||
def get_logger(name):
|
||||
"""Возвращает логгер для указанного модуля."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
# Инициализация логгера при импорте модуля
|
||||
# Инициализация логгера при импорте модуля (без логов по умолчанию)
|
||||
setup_logger()
|
||||
|
||||
@@ -4,12 +4,18 @@ import orjson
|
||||
import requests
|
||||
import urllib.parse
|
||||
import time
|
||||
import glob
|
||||
import re
|
||||
import hashlib
|
||||
from collections.abc import Callable
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
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
|
||||
AUTOINSTALL_CACHE_DURATION = 3600 # 1 hour for autoinstall cache
|
||||
|
||||
def normalize_name(s):
|
||||
"""
|
||||
@@ -52,7 +58,11 @@ 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
|
||||
self._autoinstall_cache = None # New: In-memory cache
|
||||
|
||||
def _get_game_dir(self, exe_name: str) -> str:
|
||||
game_dir = os.path.join(self.custom_data_dir, exe_name)
|
||||
@@ -68,40 +78,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 +139,247 @@ 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 _compute_scripts_signature(self, auto_dir: str) -> str:
|
||||
"""Compute a hash-based signature of the autoinstall scripts to detect changes."""
|
||||
if not os.path.exists(auto_dir):
|
||||
return ""
|
||||
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
|
||||
# Simple hash: concatenate sorted filenames and hash
|
||||
filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
|
||||
return hashlib.md5(filenames_str.encode()).hexdigest()
|
||||
|
||||
def _load_autoinstall_cache(self):
|
||||
"""Load cached autoinstall games if fresh and scripts unchanged."""
|
||||
if self._autoinstall_cache is not None:
|
||||
return self._autoinstall_cache
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
|
||||
if os.path.exists(cache_file):
|
||||
try:
|
||||
mod_time = os.path.getmtime(cache_file)
|
||||
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
|
||||
# Add timeout protection for file operations
|
||||
start_time = time.time()
|
||||
with open(cache_file, "rb") as f:
|
||||
data = orjson.loads(f.read())
|
||||
# Check signature
|
||||
cached_signature = data.get("scripts_signature", "")
|
||||
current_signature = self._compute_scripts_signature(
|
||||
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
|
||||
)
|
||||
# Check for timeout during signature computation
|
||||
if time.time() - start_time > 3: # 3 second timeout
|
||||
logger.warning("Cache loading took too long, skipping cache")
|
||||
return None
|
||||
if cached_signature != current_signature:
|
||||
logger.info("Scripts signature mismatch; invalidating cache")
|
||||
return None
|
||||
self._autoinstall_cache = data["games"]
|
||||
logger.info(f"Loaded {len(self._autoinstall_cache)} cached autoinstall games")
|
||||
return self._autoinstall_cache
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load autoinstall cache: {e}")
|
||||
return None
|
||||
|
||||
def _save_autoinstall_cache(self, games):
|
||||
"""Save parsed autoinstall games to cache with scripts signature."""
|
||||
try:
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
|
||||
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
|
||||
scripts_signature = self._compute_scripts_signature(auto_dir)
|
||||
data = {"games": games, "scripts_signature": scripts_signature, "timestamp": time.time()}
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(orjson.dumps(data))
|
||||
logger.debug(f"Saved {len(games)} autoinstall games to cache with signature {scripts_signature}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save autoinstall cache: {e}")
|
||||
|
||||
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
|
||||
"""Start loading auto-install games in a background thread. Returns the thread for management."""
|
||||
class AutoinstallWorker(QThread):
|
||||
finished = Signal(list)
|
||||
api: "PortProtonAPI"
|
||||
portproton_location: str | None
|
||||
|
||||
def run(self):
|
||||
import time
|
||||
# Check cache in this background thread, not in main thread
|
||||
start_time = time.time()
|
||||
cached_games = self.api._load_autoinstall_cache()
|
||||
# If cache loading took too long (>2 seconds), skip cache and load directly
|
||||
if time.time() - start_time > 2:
|
||||
logger.warning("Cache loading took too long, proceeding without cache")
|
||||
cached_games = None
|
||||
|
||||
if cached_games is not None:
|
||||
self.finished.emit(cached_games)
|
||||
return
|
||||
|
||||
# No cache: Load games from scratch
|
||||
games = []
|
||||
auto_dir = os.path.join(
|
||||
self.portproton_location or "", "data", "scripts", "pw_autoinstall"
|
||||
) if self.portproton_location else ""
|
||||
if not os.path.exists(auto_dir):
|
||||
self.finished.emit(games)
|
||||
return
|
||||
|
||||
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
|
||||
if not scripts:
|
||||
self.finished.emit(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.api.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]
|
||||
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
|
||||
os.makedirs(user_game_folder, exist_ok=True)
|
||||
|
||||
# Find cover
|
||||
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}")
|
||||
|
||||
game_tuple = (
|
||||
display_name, "", cover_path, "", f"autoinstall:{script_name}",
|
||||
"", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
|
||||
)
|
||||
games.append(game_tuple)
|
||||
|
||||
self.api._save_autoinstall_cache(games)
|
||||
self.api._autoinstall_cache = games
|
||||
self.finished.emit(games)
|
||||
|
||||
worker = AutoinstallWorker()
|
||||
worker.api = self
|
||||
worker.portproton_location = self.portproton_location
|
||||
worker.finished.connect(lambda games: callback(games))
|
||||
worker.start()
|
||||
logger.info("Started background load of autoinstall games")
|
||||
return worker
|
||||
|
||||
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
@@ -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
|
||||
379
portprotonqt/search_utils.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
Utility module for search optimizations including Trie, hash tables, and fuzzy matching.
|
||||
"""
|
||||
import concurrent.futures
|
||||
import threading
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from rapidfuzz import fuzz
|
||||
from threading import Lock
|
||||
from portprotonqt.logger import get_logger
|
||||
from PySide6.QtCore import QThread, QRunnable, Signal, QObject, QTimer
|
||||
import requests
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class TrieNode:
|
||||
"""Node in the Trie data structure."""
|
||||
def __init__(self):
|
||||
self.children = {}
|
||||
self.is_end_word = False
|
||||
self.payload = None # Store the original data in leaf nodes
|
||||
|
||||
class Trie:
|
||||
"""Trie data structure for efficient prefix-based searching."""
|
||||
def __init__(self):
|
||||
self.root = TrieNode()
|
||||
self._lock = Lock() # Thread safety for concurrent access
|
||||
|
||||
def insert(self, key: str, payload: Any):
|
||||
"""Insert a key with payload into the Trie."""
|
||||
with self._lock:
|
||||
node = self.root
|
||||
for char in key.lower():
|
||||
if char not in node.children:
|
||||
node.children[char] = TrieNode()
|
||||
node = node.children[char]
|
||||
node.is_end_word = True
|
||||
node.payload = payload
|
||||
|
||||
def search_prefix(self, prefix: str) -> list[tuple[str, Any]]:
|
||||
"""Find all entries with the given prefix."""
|
||||
with self._lock:
|
||||
node = self.root
|
||||
for char in prefix.lower():
|
||||
if char not in node.children:
|
||||
return []
|
||||
node = node.children[char]
|
||||
|
||||
results = []
|
||||
self._collect_all(node, prefix.lower(), results)
|
||||
return results
|
||||
|
||||
def _collect_all(self, node: TrieNode, current_prefix: str, results: list[tuple[str, Any]]):
|
||||
"""Collect all entries from the current node."""
|
||||
if node.is_end_word:
|
||||
results.append((current_prefix, node.payload))
|
||||
|
||||
for char, child_node in node.children.items():
|
||||
self._collect_all(child_node, current_prefix + char, results)
|
||||
|
||||
class FuzzySearchIndex:
|
||||
"""Index for fuzzy string matching with rapidfuzz."""
|
||||
def __init__(self, items: list[tuple[str, Any]] | None = None):
|
||||
self.items: list[tuple[str, Any]] = items or []
|
||||
self.normalized_items: list[tuple[str, Any]] = []
|
||||
self._lock = Lock()
|
||||
self._build_normalized_index()
|
||||
|
||||
def _build_normalized_index(self):
|
||||
"""Build a normalized index for fuzzy matching."""
|
||||
with self._lock:
|
||||
self.normalized_items = [(self._normalize(item[0]), item[1]) for item in self.items]
|
||||
|
||||
def _normalize(self, s: str) -> str:
|
||||
"""Normalize string for fuzzy matching."""
|
||||
s = s.lower()
|
||||
for ch in ["™", "®"]:
|
||||
s = s.replace(ch, "")
|
||||
for ch in ["-", ":", ","]:
|
||||
s = s.replace(ch, " ")
|
||||
s = " ".join(s.split())
|
||||
for suffix in ["bin", "app"]:
|
||||
if s.endswith(suffix):
|
||||
s = s[:-len(suffix)].strip()
|
||||
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
|
||||
words = s.split()
|
||||
filtered_words = [word for word in words if word not in keywords_to_remove]
|
||||
return " ".join(filtered_words)
|
||||
|
||||
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
|
||||
"""Perform fuzzy search using rapidfuzz."""
|
||||
with self._lock:
|
||||
if not query or not self.normalized_items:
|
||||
return []
|
||||
|
||||
query_normalized = self._normalize(query)
|
||||
results = []
|
||||
|
||||
for i, (item_text, item_data) in enumerate(self.normalized_items):
|
||||
score = fuzz.ratio(query_normalized, item_text)
|
||||
if score >= min_score:
|
||||
results.append((self.items[i][0], item_data, score))
|
||||
|
||||
# Sort by score descending
|
||||
results.sort(key=lambda x: x[2], reverse=True)
|
||||
return results[:limit]
|
||||
|
||||
class SearchOptimizer:
|
||||
"""Main search optimization class combining multiple approaches."""
|
||||
def __init__(self):
|
||||
self.hash_index: dict[str, Any] = {}
|
||||
self.trie_index = Trie()
|
||||
self.fuzzy_index = None
|
||||
self._lock = Lock()
|
||||
|
||||
def build_indices(self, items: list[tuple[str, Any]]):
|
||||
"""Build all search indices from items."""
|
||||
with self._lock:
|
||||
self.hash_index = {item[0].lower(): item[1] for item in items}
|
||||
self.trie_index = Trie()
|
||||
for key, value in self.hash_index.items():
|
||||
self.trie_index.insert(key, value)
|
||||
self.fuzzy_index = FuzzySearchIndex(items)
|
||||
|
||||
def exact_search(self, key: str) -> Any | None:
|
||||
"""Perform exact hash-based lookup."""
|
||||
with self._lock:
|
||||
return self.hash_index.get(key.lower())
|
||||
|
||||
def prefix_search(self, prefix: str) -> list[tuple[str, Any]]:
|
||||
"""Perform prefix search using Trie."""
|
||||
with self._lock:
|
||||
return self.trie_index.search_prefix(prefix)
|
||||
|
||||
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
|
||||
"""Perform fuzzy search."""
|
||||
if self.fuzzy_index:
|
||||
return self.fuzzy_index.fuzzy_search(query, limit, min_score)
|
||||
return []
|
||||
|
||||
|
||||
class RequestRunnable(QRunnable):
|
||||
"""Runnable for executing HTTP requests in a thread."""
|
||||
|
||||
def __init__(self, method: str, url: str, on_success=None, on_error=None, **kwargs):
|
||||
super().__init__()
|
||||
self.method = method
|
||||
self.url = url
|
||||
self.kwargs = kwargs
|
||||
self.result = None
|
||||
self.error = None
|
||||
self.on_success: Callable | None = on_success
|
||||
self.on_error: Callable | None = on_error
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
if self.method.lower() == 'get':
|
||||
self.result = requests.get(self.url, **self.kwargs)
|
||||
elif self.method.lower() == 'post':
|
||||
self.result = requests.post(self.url, **self.kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unsupported HTTP method: {self.method}")
|
||||
|
||||
# Execute success callback if provided
|
||||
if self.on_success is not None:
|
||||
success_callback = self.on_success # Capture the callback
|
||||
def success_handler():
|
||||
if success_callback is not None: # Re-check to satisfy Pyright
|
||||
success_callback(self.result)
|
||||
QTimer.singleShot(0, success_handler)
|
||||
except Exception as e:
|
||||
self.error = e
|
||||
# Execute error callback if provided
|
||||
if self.on_error is not None:
|
||||
error_callback = self.on_error # Capture the callback
|
||||
captured_error = e # Capture the exception
|
||||
def error_handler():
|
||||
error_callback(captured_error)
|
||||
QTimer.singleShot(0, error_handler)
|
||||
|
||||
|
||||
def run_request_in_thread(method: str, url: str, on_success: Callable | None = None, on_error: Callable | None = None, **kwargs):
|
||||
"""Run HTTP request in a separate thread using Qt's thread system."""
|
||||
runnable = RequestRunnable(method, url, on_success=on_success, on_error=on_error, **kwargs)
|
||||
|
||||
# Use QThreadPool to execute the runnable
|
||||
from PySide6.QtCore import QThreadPool
|
||||
thread_pool = QThreadPool.globalInstance()
|
||||
thread_pool.start(runnable)
|
||||
|
||||
return runnable # Return the runnable to allow for potential cancellation if needed
|
||||
|
||||
|
||||
def run_function_in_thread(func, *args, on_success: Callable | None = None, on_error: Callable | None = None, **kwargs):
|
||||
"""Run a function in a separate thread."""
|
||||
def execute():
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
if on_success:
|
||||
on_success(result)
|
||||
except Exception as e:
|
||||
if on_error:
|
||||
on_error(e)
|
||||
|
||||
thread = threading.Thread(target=execute)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def run_in_thread(func, *args, **kwargs):
|
||||
"""Run a function in a separate thread."""
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(func, *args, **kwargs)
|
||||
return future.result()
|
||||
|
||||
|
||||
def run_in_thread_async(func, *args, callback: Callable | None = None, **kwargs):
|
||||
"""Run a function in a separate thread asynchronously."""
|
||||
import threading
|
||||
def target():
|
||||
try:
|
||||
result = func(*args, **kwargs)
|
||||
if callback:
|
||||
callback(result)
|
||||
except Exception as e:
|
||||
if callback:
|
||||
callback(None) # or handle error in callback
|
||||
logger.error(f"Error in threaded operation: {e}")
|
||||
|
||||
thread = threading.Thread(target=target)
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
# Threaded search implementation using QThread for performance optimization
|
||||
|
||||
|
||||
class ThreadedSearchWorker(QObject):
|
||||
"""
|
||||
A threaded worker for performing search operations without blocking the UI.
|
||||
"""
|
||||
search_started = Signal()
|
||||
search_finished = Signal(list)
|
||||
search_error = Signal(str)
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.search_optimizer = SearchOptimizer()
|
||||
self.games_data = []
|
||||
|
||||
def set_games_data(self, games_data: list):
|
||||
"""Set the games data to be searched."""
|
||||
self.games_data = games_data
|
||||
# Build indices from the games data (name, description, etc.)
|
||||
items = [(game[0], game) for game in games_data] # game[0] is the name
|
||||
self.search_optimizer.build_indices(items)
|
||||
|
||||
def execute_search(self, search_text: str, search_type: str = "auto"):
|
||||
"""
|
||||
Execute search in a separate thread.
|
||||
|
||||
Args:
|
||||
search_text: Text to search for
|
||||
search_type: Type of search ("exact", "prefix", "fuzzy", "auto")
|
||||
"""
|
||||
try:
|
||||
self.search_started.emit()
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
results = []
|
||||
|
||||
if search_type == "exact" or (search_type == "auto" and len(search_text) > 2):
|
||||
exact_result = self.search_optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
results = [exact_result]
|
||||
elif search_type == "prefix":
|
||||
results = self.search_optimizer.prefix_search(search_text)
|
||||
elif search_type == "fuzzy" or search_type == "auto":
|
||||
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
|
||||
else:
|
||||
# Auto-detect search type based on input
|
||||
if len(search_text) < 3:
|
||||
results = self.search_optimizer.prefix_search(search_text)
|
||||
else:
|
||||
# Try exact first, then fuzzy
|
||||
exact_result = self.search_optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
results = [exact_result]
|
||||
else:
|
||||
results = self.search_optimizer.fuzzy_search(search_text, limit=20, min_score=50.0)
|
||||
|
||||
end_time = time.time()
|
||||
print(f"Search completed in {end_time - start_time:.4f} seconds")
|
||||
|
||||
self.search_finished.emit(results)
|
||||
except Exception as e:
|
||||
self.search_error.emit(str(e))
|
||||
|
||||
|
||||
class ThreadedSearch(QThread):
|
||||
"""
|
||||
QThread implementation for running search operations in the background.
|
||||
"""
|
||||
search_started = Signal()
|
||||
search_finished = Signal(list)
|
||||
search_error = Signal(str)
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.worker = ThreadedSearchWorker()
|
||||
self.search_text = ""
|
||||
self.search_type = "auto"
|
||||
self.games_data = []
|
||||
|
||||
# Connect worker signals to thread signals
|
||||
self.worker.search_started.connect(self.search_started)
|
||||
self.worker.search_finished.connect(self.search_finished)
|
||||
self.worker.search_error.connect(self.search_error)
|
||||
|
||||
def set_search_params(self, search_text: str, games_data: list, search_type: str = "auto"):
|
||||
"""Set parameters for the search operation."""
|
||||
self.search_text = search_text
|
||||
self.games_data = games_data
|
||||
self.search_type = search_type
|
||||
|
||||
def set_games_data(self, games_data: list):
|
||||
"""Set the games data to be searched."""
|
||||
self.games_data = games_data
|
||||
self.worker.set_games_data(games_data)
|
||||
|
||||
def run(self):
|
||||
"""Run the search operation in the thread."""
|
||||
self.worker.execute_search(self.search_text, self.search_type)
|
||||
|
||||
|
||||
class SearchThreadPool:
|
||||
"""
|
||||
A simple thread pool for managing multiple search operations.
|
||||
"""
|
||||
def __init__(self, max_threads: int = 3):
|
||||
self.max_threads = max_threads
|
||||
self.active_threads = []
|
||||
self.thread_queue = []
|
||||
|
||||
def submit_search(self, search_text: str, games_data: list, search_type: str = "auto",
|
||||
on_start: Callable | None = None, on_finish: Callable | None = None, on_error: Callable | None = None):
|
||||
"""
|
||||
Submit a search operation to the pool.
|
||||
|
||||
Args:
|
||||
search_text: Text to search for
|
||||
games_data: List of game data tuples to search in
|
||||
search_type: Type of search ("exact", "prefix", "fuzzy", "auto")
|
||||
on_start: Callback when search starts
|
||||
on_finish: Callback when search finishes (receives results)
|
||||
on_error: Callback when search errors (receives error message)
|
||||
"""
|
||||
search_thread = ThreadedSearch()
|
||||
search_thread.set_search_params(search_text, games_data, search_type)
|
||||
|
||||
# Connect callbacks if provided
|
||||
if on_start:
|
||||
search_thread.search_started.connect(on_start)
|
||||
if on_finish:
|
||||
search_thread.search_finished.connect(on_finish)
|
||||
if on_error:
|
||||
search_thread.search_error.connect(on_error)
|
||||
|
||||
# Start the thread
|
||||
search_thread.start()
|
||||
self.active_threads.append(search_thread)
|
||||
|
||||
# Clean up finished threads
|
||||
self.active_threads = [thread for thread in self.active_threads if thread.isRunning()]
|
||||
|
||||
return search_thread
|
||||
229
portprotonqt/settings_manager.py
Normal file
@@ -0,0 +1,229 @@
|
||||
def get_toggle_settings():
|
||||
"""Get predefined toggle settings with descriptions."""
|
||||
from portprotonqt.localization import _
|
||||
|
||||
return {
|
||||
'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"),
|
||||
'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"),
|
||||
'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"),
|
||||
'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"),
|
||||
'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."),
|
||||
'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"),
|
||||
'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."),
|
||||
'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."),
|
||||
'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."),
|
||||
'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"),
|
||||
'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"),
|
||||
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
|
||||
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
|
||||
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
|
||||
'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
|
||||
'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
|
||||
'PW_USE_TERMINAL': _("Run the application in a terminal"),
|
||||
'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
|
||||
'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
|
||||
'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
|
||||
'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"),
|
||||
'PW_USE_US_LAYOUT': _("Force US keyboard layout"),
|
||||
'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"),
|
||||
'PW_USE_SHADER_CACHE': _("Use WINE shader caching"),
|
||||
'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"),
|
||||
'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"),
|
||||
'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"),
|
||||
'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"),
|
||||
'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"),
|
||||
'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"),
|
||||
'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"),
|
||||
'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"),
|
||||
'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"),
|
||||
'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"),
|
||||
'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"),
|
||||
'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"),
|
||||
'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"),
|
||||
'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)")
|
||||
}
|
||||
|
||||
|
||||
def get_advanced_settings(disabled_text, logical_core_options, locale_options,
|
||||
amd_vulkan_drivers, is_amd, numa_nodes, dist_options=None, prefix_options=None):
|
||||
"""Get advanced settings configuration."""
|
||||
from portprotonqt.localization import _
|
||||
|
||||
advanced_settings = []
|
||||
if dist_options is None:
|
||||
dist_options = []
|
||||
if prefix_options is None:
|
||||
prefix_options = []
|
||||
|
||||
# 1. Wine Version
|
||||
advanced_settings.append({
|
||||
'key': 'PW_WINE_USE',
|
||||
'name': _("Wine Version"),
|
||||
'description': _("Select the Wine or Proton version to use for this executable."),
|
||||
'type': 'combo',
|
||||
'options': dist_options,
|
||||
'default': ''
|
||||
})
|
||||
|
||||
# 2. Prefix Name
|
||||
advanced_settings.append({
|
||||
'key': 'PW_PREFIX_NAME',
|
||||
'name': _("Prefix Name"),
|
||||
'description': _("Specify the Wine prefix to run this game with"),
|
||||
'type': 'combo',
|
||||
'options': prefix_options,
|
||||
'default': 'DEFAULT'
|
||||
})
|
||||
|
||||
# 3. Vulkan Backend
|
||||
vulkan_options = [
|
||||
_("Newest"), # → 6
|
||||
_("Stable"), # → 2
|
||||
("Sarek"), # → 1
|
||||
("WINED3D – OpenGL") # → 0
|
||||
]
|
||||
|
||||
# Маппинг: отображаемый текст → реальное значение в ppdb
|
||||
vulkan_value_map = {
|
||||
vulkan_options[0]: "6",
|
||||
vulkan_options[1]: "2",
|
||||
vulkan_options[2]: "1",
|
||||
vulkan_options[3]: "0",
|
||||
}
|
||||
|
||||
advanced_settings.append({
|
||||
'key': 'PW_VULKAN_USE',
|
||||
'name': _("Vulkan Backend"),
|
||||
'description': _(
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n\n"
|
||||
"• Newest – latest DXVK + VKD3D (best compatibility/performance, requires modern drivers: AMD Mesa 25+, NVIDIA 550.54.14+, Intel Mesa 24.2+)\n"
|
||||
"• Stable – older, well-tested DXVK + VKD3D (works on any Vulkan 1.3+ driver)\n"
|
||||
"• Sarek – experimental DXVK-Sarek + VKD3D-Sarek (supports older drivers, Vulkan 1.1+)\n"
|
||||
"• WINED3D – OpenGL fallback (lowest performance, use only if others fail)"
|
||||
),
|
||||
'type': 'combo',
|
||||
'options': vulkan_options,
|
||||
'default': '6',
|
||||
'_value_map': vulkan_value_map
|
||||
})
|
||||
|
||||
# 4. Windows version
|
||||
advanced_settings.append({
|
||||
'key': 'PW_WINDOWS_VER',
|
||||
'name': _("Windows version"),
|
||||
'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
|
||||
'type': 'combo',
|
||||
'options': ['11', '10', '7', 'XP'],
|
||||
'default': '10'
|
||||
})
|
||||
|
||||
# 5. DLL Overrides
|
||||
advanced_settings.append({
|
||||
'key': 'WINEDLLOVERRIDES',
|
||||
'name': _("DLL Overrides"),
|
||||
'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"),
|
||||
'type': 'text',
|
||||
'default': ''
|
||||
})
|
||||
|
||||
# 6. Launch arguments
|
||||
advanced_settings.append({
|
||||
'key': 'LAUNCH_PARAMETERS',
|
||||
'name': _("Launch Arguments"),
|
||||
'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"),
|
||||
'type': 'text',
|
||||
'default': ''
|
||||
})
|
||||
|
||||
# 7. CPU cores limit
|
||||
advanced_settings.append({
|
||||
'key': 'PW_WINE_CPU_TOPOLOGY',
|
||||
'name': _("CPU Cores Limit"),
|
||||
'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text] + logical_core_options,
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 8. OpenGL version
|
||||
advanced_settings.append({
|
||||
'key': 'PW_MESA_GL_VERSION_OVERRIDE',
|
||||
'name': _("OpenGL Version"),
|
||||
'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'],
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 9. VKD3D feature level
|
||||
advanced_settings.append({
|
||||
'key': 'PW_VKD3D_FEATURE_LEVEL',
|
||||
'name': _("VKD3D Feature Level"),
|
||||
'description': _("You can set a forced feature level VKD3D for games on DirectX12"),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'],
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 10. Locale
|
||||
advanced_settings.append({
|
||||
'key': 'PW_LOCALE_SELECT',
|
||||
'name': _("Locale"),
|
||||
'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text] + locale_options,
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 11. Present mode
|
||||
advanced_settings.append({
|
||||
'key': 'PW_MESA_VK_WSI_PRESENT_MODE',
|
||||
'name': _("Window Mode"),
|
||||
'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."),
|
||||
'type': 'combo',
|
||||
'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'],
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 12. AMD Vulkan driver
|
||||
amd_options = [disabled_text] + amd_vulkan_drivers if is_amd and amd_vulkan_drivers else [disabled_text]
|
||||
advanced_settings.append({
|
||||
'key': 'PW_AMD_VULKAN_USE',
|
||||
'name': _("AMD Vulkan Driver"),
|
||||
'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"),
|
||||
'type': 'combo',
|
||||
'options': amd_options,
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
# 13. NUMA node
|
||||
numa_ids = sorted(numa_nodes.keys())
|
||||
numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text]
|
||||
advanced_settings.append({
|
||||
'key': 'PW_CPU_NUMA_NODE_INDEX',
|
||||
'name': _("NUMA Node"),
|
||||
'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."),
|
||||
'type': 'combo',
|
||||
'options': numa_options,
|
||||
'default': disabled_text
|
||||
})
|
||||
|
||||
return advanced_settings
|
||||
|
||||
|
||||
# Keys that should be recognized as advanced settings
|
||||
ADVANCED_SETTING_KEYS = [
|
||||
'PW_WINE_USE',
|
||||
'PW_PREFIX_NAME',
|
||||
'PW_VULKAN_USE',
|
||||
'PW_WINDOWS_VER',
|
||||
'WINEDLLOVERRIDES',
|
||||
'LAUNCH_PARAMETERS',
|
||||
'PW_WINE_CPU_TOPOLOGY',
|
||||
'PW_MESA_GL_VERSION_OVERRIDE',
|
||||
'PW_VKD3D_FEATURE_LEVEL',
|
||||
'PW_LOCALE_SELECT',
|
||||
'PW_MESA_VK_WSI_PRESENT_MODE',
|
||||
'PW_AMD_VULKAN_USE',
|
||||
'PW_CPU_NUMA_NODE_INDEX',
|
||||
]
|
||||
@@ -20,6 +20,8 @@ class SystemOverlay(QDialog):
|
||||
self.theme_manager = ThemeManager()
|
||||
self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE)
|
||||
|
||||
self.script_path = "/usr/bin/portprotonqt-session-select"
|
||||
|
||||
# Make window stay on top and frameless
|
||||
self.setWindowFlags(
|
||||
Qt.WindowType.FramelessWindowHint |
|
||||
@@ -79,8 +81,7 @@ class SystemOverlay(QDialog):
|
||||
desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||
desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
desktop_button.clicked.connect(self.return_to_desktop)
|
||||
script_path = "/usr/bin/portprotonqt-session-select"
|
||||
script_exists = os.path.isfile(script_path)
|
||||
script_exists = os.path.isfile(self.script_path)
|
||||
desktop_button.setEnabled(script_exists)
|
||||
if not script_exists:
|
||||
desktop_button.setToolTip(_("portprotonqt-session-select file not found at /usr/bin/"))
|
||||
@@ -139,8 +140,8 @@ class SystemOverlay(QDialog):
|
||||
|
||||
def return_to_desktop(self):
|
||||
try:
|
||||
script_path = os.path.join(os.path.dirname(__file__), "portprotonqt-session-select")
|
||||
subprocess.run([script_path, "desktop"], check=True)
|
||||
QApplication.quit()
|
||||
subprocess.run([self.script_path, "desktop"], check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Failed to return to desktop: {e}")
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to return to desktop"))
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import ast
|
||||
from portprotonqt.logger import get_logger
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
|
||||
|
||||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
|
||||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
|
||||
|
||||
# Icon caching for performance optimization
|
||||
_icon_cache = {}
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Папка, где располагаются все дополнительные темы
|
||||
@@ -14,6 +16,59 @@ THEMES_DIRS = [
|
||||
os.path.join(xdg_data_home, "PortProtonQt", "themes"),
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
|
||||
]
|
||||
_loaded_theme = None
|
||||
|
||||
# Запрещенные модули и функции
|
||||
FORBIDDEN_MODULES = {
|
||||
"os",
|
||||
"subprocess",
|
||||
"shutil",
|
||||
"sys",
|
||||
"socket",
|
||||
"ctypes",
|
||||
"pathlib",
|
||||
"glob",
|
||||
}
|
||||
FORBIDDEN_FUNCTIONS = {
|
||||
"exec",
|
||||
"eval",
|
||||
"open",
|
||||
"__import__",
|
||||
}
|
||||
|
||||
def check_theme_safety(theme_file: str) -> bool:
|
||||
"""
|
||||
Проверяет файл темы на наличие запрещённых модулей и функций.
|
||||
Возвращает True, если файл безопасен, иначе False.
|
||||
"""
|
||||
has_errors = False
|
||||
try:
|
||||
with open(theme_file) as f:
|
||||
content = f.read()
|
||||
|
||||
# Проверка на опасные импорты и функции
|
||||
try:
|
||||
tree = ast.parse(content)
|
||||
for node in ast.walk(tree):
|
||||
# Проверка импортов
|
||||
if isinstance(node, ast.Import | ast.ImportFrom):
|
||||
for name in node.names:
|
||||
if name.name in FORBIDDEN_MODULES:
|
||||
logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
|
||||
has_errors = True
|
||||
# Проверка вызовов функций
|
||||
if isinstance(node, ast.Call):
|
||||
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
|
||||
logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
|
||||
has_errors = True
|
||||
except SyntaxError as e:
|
||||
logger.error(f"Syntax error in file {theme_file}: {e}")
|
||||
has_errors = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to check theme safety for {theme_file}: {e}")
|
||||
has_errors = True
|
||||
|
||||
return not has_errors
|
||||
|
||||
def list_themes():
|
||||
"""
|
||||
@@ -49,55 +104,72 @@ def load_theme_screenshots(theme_name):
|
||||
|
||||
def load_theme_fonts(theme_name):
|
||||
"""
|
||||
Загружает все шрифты выбранной темы.
|
||||
:param theme_name: Имя темы.
|
||||
Загружает все шрифты выбранной темы, если они ещё не были загружены.
|
||||
"""
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
fonts_folder = None
|
||||
if theme_name == "standart":
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
|
||||
else:
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
possible_fonts_folder = os.path.join(theme_folder, "fonts")
|
||||
if os.path.exists(possible_fonts_folder):
|
||||
fonts_folder = possible_fonts_folder
|
||||
break
|
||||
|
||||
if not fonts_folder or not os.path.exists(fonts_folder):
|
||||
logger.error(f"Папка fonts не найдена для темы '{theme_name}'")
|
||||
global _loaded_theme
|
||||
if _loaded_theme == theme_name:
|
||||
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
|
||||
return
|
||||
|
||||
for filename in os.listdir(fonts_folder):
|
||||
if filename.lower().endswith((".ttf", ".otf")):
|
||||
font_path = os.path.join(fonts_folder, filename)
|
||||
font_id = QFontDatabase.addApplicationFont(font_path)
|
||||
if font_id != -1:
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
logger.info(f"Шрифт {filename} успешно загружен: {families}")
|
||||
def load_fonts_delayed():
|
||||
global _loaded_theme
|
||||
try:
|
||||
# Only remove fonts if this is a theme change (not initial load)
|
||||
current_loaded_theme = _loaded_theme # Capture the current value
|
||||
if current_loaded_theme is not None and current_loaded_theme != theme_name:
|
||||
# Run font removal in the GUI thread with delay
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
|
||||
import time
|
||||
import os
|
||||
start_time = time.time()
|
||||
timeout = 3 # Reduced timeout to 3 seconds for faster loading
|
||||
|
||||
fonts_folder = None
|
||||
if theme_name == "standart":
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
|
||||
else:
|
||||
logger.error(f"Ошибка загрузки шрифта: {filename}")
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
possible_fonts_folder = os.path.join(theme_folder, "fonts")
|
||||
if os.path.exists(possible_fonts_folder):
|
||||
fonts_folder = possible_fonts_folder
|
||||
break
|
||||
|
||||
def load_logo():
|
||||
logo_path = None
|
||||
if not fonts_folder or not os.path.exists(fonts_folder):
|
||||
logger.error(f"Fonts folder not found for theme '{theme_name}'")
|
||||
return
|
||||
|
||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg")
|
||||
font_files = []
|
||||
for filename in os.listdir(fonts_folder):
|
||||
if filename.lower().endswith((".ttf", ".otf")):
|
||||
font_files.append(filename)
|
||||
|
||||
file_extension = os.path.splitext(logo_path)[1].lower()
|
||||
# Limit number of fonts loaded to prevent too much blocking
|
||||
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
|
||||
|
||||
if file_extension == ".svg":
|
||||
renderer = QSvgRenderer(logo_path)
|
||||
if not renderer.isValid():
|
||||
logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}")
|
||||
return None
|
||||
pixmap = QPixmap(128, 128)
|
||||
pixmap.fill(QColor(0, 0, 0, 0))
|
||||
painter = QPainter(pixmap)
|
||||
renderer.render(painter)
|
||||
painter.end()
|
||||
return pixmap
|
||||
for filename in font_files:
|
||||
if time.time() - start_time > timeout:
|
||||
logger.warning(f"Font loading timed out for theme '{theme_name}' after loading {len(font_files)} fonts")
|
||||
break
|
||||
|
||||
font_path = os.path.join(fonts_folder, filename)
|
||||
font_id = QFontDatabase.addApplicationFont(font_path)
|
||||
if font_id != -1:
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
logger.info(f"Font {filename} successfully loaded: {families}")
|
||||
else:
|
||||
logger.error(f"Error loading font: {filename}")
|
||||
|
||||
# Update the global variable in the main thread
|
||||
_loaded_theme = theme_name
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
|
||||
|
||||
# Use QTimer to delay font loading until after the UI is rendered
|
||||
from PySide6.QtCore import QTimer
|
||||
QTimer.singleShot(100, load_fonts_delayed) # Delay font loading by 100ms
|
||||
|
||||
class ThemeWrapper:
|
||||
"""
|
||||
@@ -109,69 +181,83 @@ class ThemeWrapper:
|
||||
self.custom_theme = custom_theme
|
||||
self.metainfo = metainfo or {}
|
||||
self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
|
||||
self._default_theme = None # Lazy-loaded default theme
|
||||
|
||||
def __getattr__(self, name):
|
||||
if hasattr(self.custom_theme, name):
|
||||
return getattr(self.custom_theme, name)
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
return getattr(default_styles, name)
|
||||
if self._default_theme is None:
|
||||
self._default_theme = load_theme("standart") # Dynamically load standard theme
|
||||
return getattr(self._default_theme, name)
|
||||
|
||||
def load_theme(theme_name):
|
||||
"""
|
||||
Динамически загружает модуль стилей выбранной темы и метаинформацию.
|
||||
Если выбрана стандартная тема, импортируется оригинальный styles.py.
|
||||
Все темы, включая стандартную, проходят проверку безопасности.
|
||||
Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
|
||||
"""
|
||||
if theme_name == "standart":
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
return default_styles
|
||||
|
||||
for themes_dir in THEMES_DIRS:
|
||||
theme_folder = os.path.join(themes_dir, theme_name)
|
||||
styles_file = os.path.join(theme_folder, "styles.py")
|
||||
if os.path.exists(styles_file):
|
||||
# Проверяем безопасность темы перед загрузкой
|
||||
if not check_theme_safety(styles_file):
|
||||
logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'")
|
||||
raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions")
|
||||
|
||||
spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
custom_theme = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(custom_theme)
|
||||
if theme_name == "standart":
|
||||
return custom_theme
|
||||
meta = load_theme_metainfo(theme_name)
|
||||
wrapper = ThemeWrapper(custom_theme, metainfo=meta)
|
||||
wrapper.screenshots = load_theme_screenshots(theme_name)
|
||||
return wrapper
|
||||
raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'")
|
||||
raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
|
||||
|
||||
class ThemeManager:
|
||||
"""
|
||||
Класс для управления темами приложения.
|
||||
|
||||
Позволяет получить список доступных тем, загрузить и применить выбранную тему.
|
||||
Реализует паттерн Singleton для единого экземпляра.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.current_theme_name = None
|
||||
self.current_theme_module = None
|
||||
_instance = None
|
||||
|
||||
def get_available_themes(self):
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.current_theme_name = None
|
||||
cls._instance.current_theme_module = None
|
||||
return cls._instance
|
||||
|
||||
def get_available_themes(self) -> list:
|
||||
"""Возвращает список доступных тем."""
|
||||
return list_themes()
|
||||
|
||||
def get_theme_logo(self):
|
||||
"""Возвращает логотип для текущей или указанной темы."""
|
||||
return load_logo()
|
||||
def apply_theme(self, theme_name: str):
|
||||
"""
|
||||
Применяет указанную тему, если она ещё не применена.
|
||||
Возвращает модуль темы или обёртку.
|
||||
"""
|
||||
if self.current_theme_name == theme_name and self.current_theme_module is not None:
|
||||
logger.debug(f"Theme '{theme_name}' is already applied, skipping")
|
||||
return self.current_theme_module
|
||||
|
||||
try:
|
||||
theme_module = load_theme(theme_name)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
|
||||
theme_module = load_theme("standart")
|
||||
theme_name = "standart"
|
||||
save_theme_to_config("standart")
|
||||
|
||||
def apply_theme(self, theme_name):
|
||||
"""
|
||||
Применяет выбранную тему: загружает модуль стилей, шрифты и логотип.
|
||||
Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации.
|
||||
:param theme_name: Имя темы.
|
||||
:return: Загруженный модуль темы (или обёртка).
|
||||
"""
|
||||
theme_module = load_theme(theme_name)
|
||||
load_theme_fonts(theme_name)
|
||||
self.current_theme_name = theme_name
|
||||
self.current_theme_module = theme_module
|
||||
save_theme_to_config(theme_name)
|
||||
logger.info(f"Тема '{theme_name}' успешно применена")
|
||||
logger.info(f"Theme '{theme_name}' successfully applied")
|
||||
return theme_module
|
||||
|
||||
def get_icon(self, icon_name, theme_name=None, as_path=False):
|
||||
@@ -180,6 +266,14 @@ class ThemeManager:
|
||||
а если файл не найден, то из стандартной темы.
|
||||
Если as_path=True, возвращает путь к иконке вместо QIcon.
|
||||
"""
|
||||
# Create cache key
|
||||
cache_key = f"{icon_name}_{theme_name or self.current_theme_name}_{as_path}"
|
||||
|
||||
# Check if we already have this icon cached
|
||||
if cache_key in _icon_cache:
|
||||
logger.debug(f"Using cached icon for {icon_name}")
|
||||
return _icon_cache[cache_key]
|
||||
|
||||
icon_path = None
|
||||
theme_name = theme_name or self.current_theme_name
|
||||
supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
|
||||
@@ -226,13 +320,21 @@ class ThemeManager:
|
||||
|
||||
# Если иконка всё равно не найдена
|
||||
if not icon_path or not os.path.exists(icon_path):
|
||||
logger.error(f"Предупреждение: иконка '{icon_name}' не найдена")
|
||||
return QIcon() if not as_path else None
|
||||
logger.error(f"Warning: icon '{icon_name}' not found")
|
||||
result = QIcon() if not as_path else None
|
||||
# Cache the result even if it's None
|
||||
_icon_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
if as_path:
|
||||
# Cache the path
|
||||
_icon_cache[cache_key] = icon_path
|
||||
return icon_path
|
||||
|
||||
return QIcon(icon_path)
|
||||
# Create QIcon and cache it
|
||||
icon = QIcon(icon_path)
|
||||
_icon_cache[cache_key] = icon
|
||||
return icon
|
||||
|
||||
def get_theme_image(self, image_name, theme_name=None):
|
||||
"""
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 734 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 213 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
|
||||
|
Before Width: | Height: | Size: 622 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 164 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
|
||||
|
Before Width: | Height: | Size: 570 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 367 B |
@@ -1 +0,0 @@
|
||||
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z"/></svg>
|
||||
|
Before Width: | Height: | Size: 392 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 660 B |
|
Before Width: | Height: | Size: 7.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 208 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#fff"/></svg>
|
||||
|
Before Width: | Height: | Size: 165 B |