Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca321ac0ae
|
||
|
|
5048244ed7
|
||
|
|
e96550d155
|
||
|
|
eef9824596
|
||
|
|
c7acdd2ef4
|
||
|
|
4fb74c1d15
|
||
|
|
78dab249c1
|
||
|
|
9e91eeb0dc
|
||
|
|
cb8d149b2a
|
||
|
|
98475c0e59
|
||
|
|
0391b894ee
|
||
|
|
ca352d0ae9
|
||
|
|
b0b22481c1
|
||
|
|
147187a02a
|
||
|
|
a874ea6979
|
||
|
|
ca658fa84f
|
||
|
|
3bd78f5c73
|
||
|
|
b3824a3556
|
||
|
|
4d9e39d9e3
|
||
|
|
9c148c2710 | ||
|
|
0dae987520
|
||
|
|
00ec0baf35 | ||
|
|
28726a5cbd | ||
|
|
36a1994e4f
|
||
|
|
6bae4616ec
|
||
|
|
e1080d0185
|
||
|
|
e224904d4f | ||
|
|
2b4a0ede44 | ||
|
|
13079b3a50
|
||
|
|
6eae0612b8
|
||
|
|
d77d972da6
|
||
|
|
0e9bfa9d44
|
||
|
|
00c1d086b1
|
||
|
|
a3a7f61f8b
|
||
|
|
8adc0ebda7
|
||
|
|
6f73fa2b67
|
||
|
|
c4f172faf1 | ||
|
|
974802c409 | ||
|
|
4d4c392d85
|
||
|
|
10d034031b
|
||
|
|
04d1e44447
|
||
|
|
10cc22a88a
|
||
|
|
b8e06201e2
|
||
|
|
7f8c31fbb8
|
||
|
|
c4d28f8f72 | ||
|
|
06c3bf3ff4 | ||
|
|
45341a35a9 | ||
|
|
6c0da73df4 | ||
|
|
43f9219ecd | ||
|
|
635038c7c4 | ||
|
|
c5326efc48
|
||
|
|
a27cbf57d2
|
||
|
|
6fdcfb212e
|
||
|
|
78bb8391bf
|
||
|
|
2bb47c0d0e
|
||
|
|
4ef0184c81
|
||
|
|
1eb9800a30
|
||
|
|
2caab288dd
|
||
|
|
e80773c601
|
||
|
|
4b42d19bea
|
||
|
|
d04f8fdc9e
|
||
|
|
40a3cacc36
|
||
|
|
d54c84f723
|
||
|
|
2cfbe08323
|
||
|
|
d6684691a4
|
||
|
|
0bf87e172d
|
||
|
|
07bfa9611f
|
||
|
|
1f4ee890cf
|
||
|
|
8feb58c5e0
|
||
|
|
f47ff16e97
|
||
|
|
47f8faeaf2 | ||
|
|
2085fff8c3 | ||
|
|
e8ddd4372e | ||
|
|
1bfacb887b | ||
|
|
461527eb0c | ||
|
|
eb3d507f9d
|
||
|
|
d501dfcecf
|
||
|
|
e21bb09081
|
||
|
|
fa8e8478e5
|
||
|
|
51b78b506c
|
||
|
|
1776dd9e94
|
||
|
|
7f75d5386d
|
||
|
|
64aedb4a12
|
||
|
|
bc41211ab1
|
||
|
|
bf93eff67d
|
||
|
|
5d2df85d19 | ||
|
|
68ba818d74
|
||
|
|
68c89eccbc
|
||
|
|
5109c50c99
|
||
|
|
9f194fd33f
|
||
|
|
d7838eceb1 | ||
|
|
94af16ceac
|
||
|
|
d2163b84b4
|
||
|
|
a19f446b44 | ||
|
|
d1c812c5bd | ||
|
|
e76abf430c
|
||
|
|
e9473ccaeb
|
||
|
|
63997f8e1a
|
||
|
|
45235891c8
|
||
|
|
682363ded2
|
||
|
|
522e52f032
|
||
|
|
c30be1214a
|
||
|
|
a99e752ec0
|
||
|
|
91eddcb161
|
||
|
|
7f68494a57
|
||
|
|
135d53df31
|
||
|
|
893ed8f057
|
||
|
|
aaeb550fd6
|
||
|
|
0210773642
|
||
|
|
6cf0fc4650
|
||
|
|
404d7c0fd0
|
||
|
|
fd2b0fca61 | ||
|
|
5fa734c2d1
|
||
|
|
9b602181cc
|
||
|
|
7a2be0c289
|
||
|
|
7704308d63
|
||
|
|
1f9882cec8
|
||
|
|
2f8d73e074
|
||
|
|
2b36fe0a92
|
||
|
|
cf1ed31f04
|
||
|
|
066b3b6767
|
||
|
|
ebb80a3602
|
||
|
|
1c37b4c961
|
||
|
|
16060274d6
|
||
|
|
197b5266ba
|
||
|
|
3787e9b2bf
|
||
|
|
4f5499b56d
|
||
|
|
e1b8bfde7f
|
||
|
|
311039a543 | ||
|
|
fc73d80e43
|
||
|
|
498a26c00e
|
||
|
|
2234066479
|
||
|
|
d44f561c4c
|
||
|
|
1d7602bb9e
|
||
|
|
facedf6c1a
|
||
|
|
78c5302a99 | ||
|
|
3fb29751ec | ||
|
|
910c18ad37 | ||
|
|
a050c2ed35 | ||
|
|
dc7e672ec8 | ||
|
|
ee093d727c | ||
|
|
a4f4f7e60f
|
||
|
|
d1ea3d28f7
|
||
|
|
c2dca6eda9
|
||
|
|
f9c3e52275 | ||
|
|
7091d5ccbd | ||
|
|
b88567a3ae
|
||
|
|
40b3644f80
|
||
|
|
636c535a33 | ||
|
|
e01076524c
|
||
|
|
ac84f3738b | ||
|
|
49f2d044b9
|
||
|
|
21603e32a4
|
||
|
|
4a35f05893
|
||
|
|
29362b3413
|
||
|
|
166667f7cf | ||
|
|
6c560b9123
|
||
|
|
595f58195e
|
||
|
|
e58d470960
|
||
|
|
760dd359b8
|
||
|
|
27f1247a1b | ||
|
|
e25b52f3d5
|
||
|
|
523446e827
|
||
|
|
a6610e8087
|
||
|
|
4dc09df895 | ||
|
|
20c8dbe350 | ||
|
|
839b4b7523 | ||
|
|
4860c232ea | ||
|
|
704c514ba4 | ||
|
|
68b77657d6 |
No files matched your search
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 1.4.1
|
||||
VERSION: 1.5.0
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -31,6 +31,26 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
|
||||
|
||||
@@ -69,6 +89,26 @@ jobs:
|
||||
image: archlinux:base-devel
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
@@ -128,8 +168,13 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf install -y --disablerepo=fedora-cisco-openh264 \
|
||||
# Select nearby mirrors and abandon slow downloads after 20 seconds.
|
||||
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
|
||||
--setopt=minrate=100k --setopt=timeout=20 \
|
||||
upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
|
||||
--setopt=minrate=100k --setopt=timeout=20 \
|
||||
install -y --disablerepo=fedora-cisco-openh264 \
|
||||
git rpmdevtools meson ninja-build python3-devel \
|
||||
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
@@ -172,8 +217,22 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
|
||||
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
|
||||
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
apt-get update
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
apt-get install -y --no-install-recommends nala
|
||||
if timeout --kill-after=5s 120s nala fetch --auto --fetches=3 &&
|
||||
test -s /etc/apt/sources.list.d/fetch.sources; then
|
||||
# Keep updates and security; use only ranked mirrors for the base suite.
|
||||
sed -i 's/^Suites: trixie trixie-updates$/Suites: trixie-updates/' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
else
|
||||
echo "::warning::Mirror selection failed; keeping the bootstrap Debian sources"
|
||||
fi
|
||||
nala update
|
||||
nala install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
|
||||
pkgconf python3
|
||||
|
||||
|
||||
@@ -22,21 +22,40 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout
|
||||
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Install CI dependencies
|
||||
run: |
|
||||
pacman -Syu --noconfirm --needed git nodejs npm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
- name: Activate .venv & run pre-commit
|
||||
- name: Install project and check dependencies
|
||||
shell: bash
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
source build-aux/PKGBUILD-git
|
||||
pacman -S --noconfirm --needed "${depends[@]}" python-qrcode libpulse mesa pre-commit pyright python-pytest python-pytest-mock
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- name: Run pre-commit
|
||||
run: pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@6d4fd22b20465ccc0f7354476588e8df7c9ca0e0 # v7
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -80,11 +80,31 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7ff2bdc2fe97f9a4c6a4007f5f42fb4941f49fee # v4
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@b2d1ce4216f71162e00ae58fa11ee9aeb723d0d5 # v5
|
||||
|
||||
- name: Install CI dependencies
|
||||
run: pacman -S --noconfirm --needed nodejs npm
|
||||
@@ -135,8 +155,13 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf install -y --disablerepo=fedora-cisco-openh264 \
|
||||
# Select nearby mirrors and abandon slow downloads after 20 seconds.
|
||||
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
|
||||
--setopt=minrate=100k --setopt=timeout=20 \
|
||||
upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf --setopt=fastestmirror=True --setopt=max_parallel_downloads=10 \
|
||||
--setopt=minrate=100k --setopt=timeout=20 \
|
||||
install -y --disablerepo=fedora-cisco-openh264 \
|
||||
git rpmdevtools meson ninja-build python3-devel \
|
||||
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
@@ -185,12 +210,32 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
|
||||
image: archlinux:base-devel@sha256:3cbf69d2d5958d6a294f3722c5668e6187c4df8e00fa27f72352f0bf83e495e1
|
||||
|
||||
steps:
|
||||
- name: Configure Arch mirrors
|
||||
run: |
|
||||
if curl -fL --connect-timeout 10 --max-time 20 \
|
||||
https://cdn.lenuma.ru/archlinux-mirror/core/os/x86_64/core.db -o /dev/null; then
|
||||
echo 'Server = https://cdn.lenuma.ru/archlinux-mirror/$repo/os/$arch' > /etc/pacman.d/mirrorlist
|
||||
elif curl -fL --retry 2 --connect-timeout 10 --max-time 60 \
|
||||
https://github.com/westandskif/rate-mirrors/releases/download/v0.33.0/rate-mirrors-v0.33.0-x86_64-unknown-linux-musl.tar.gz \
|
||||
-o /tmp/rate-mirrors.tar.gz &&
|
||||
echo 'e0ffb221649b7e1ce6b67ffaabe7cfd2b619c8bf8d6971e07ff9507d4e19a4b3 /tmp/rate-mirrors.tar.gz' | sha256sum -c - &&
|
||||
tar -xzf /tmp/rate-mirrors.tar.gz -C /tmp --strip-components=1 \
|
||||
rate-mirrors-v0.33.0-x86_64-unknown-linux-musl/rate_mirrors &&
|
||||
timeout --kill-after=5s 120s /tmp/rate_mirrors --allow-root --protocol=https --disable-untested-fallback \
|
||||
--save=/tmp/arch-mirrorlist arch --max-delay=21600 &&
|
||||
grep -q '^Server = https://' /tmp/arch-mirrorlist; then
|
||||
install -m 644 /tmp/arch-mirrorlist /etc/pacman.d/mirrorlist
|
||||
else
|
||||
echo '::warning::Mirror ranking failed; keeping the original Arch mirrorlist'
|
||||
fi
|
||||
sed -i -E 's/^#?[[:space:]]*ParallelDownloads[[:space:]]*=.*/ParallelDownloads = 5/' /etc/pacman.conf
|
||||
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
pacman -Syu --noconfirm --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
|
||||
@@ -251,14 +296,28 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
|
||||
image: debian:trixie-20261005@sha256:913f6706df59a68922d1dd08f78c2476560a8d367897200a6005b00e5f67c2d5
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
# Bootstrap Nala before mirror ranking without the slow CDN used by this runner.
|
||||
sed -i -e 's|http://deb.debian.org/debian-security|http://security.debian.org/debian-security|g' \
|
||||
-e 's|http://deb.debian.org/debian|http://ftp.ru.debian.org/debian|g' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
apt-get update
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
apt-get install -y --no-install-recommends nala
|
||||
if timeout --kill-after=5s 120s nala fetch --auto --fetches=3 &&
|
||||
test -s /etc/apt/sources.list.d/fetch.sources; then
|
||||
# Keep updates and security; use only ranked mirrors for the base suite.
|
||||
sed -i 's/^Suites: trixie trixie-updates$/Suites: trixie-updates/' \
|
||||
/etc/apt/sources.list.d/debian.sources
|
||||
else
|
||||
echo "::warning::Mirror selection failed; keeping the bootstrap Debian sources"
|
||||
fi
|
||||
nala update
|
||||
nala install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
|
||||
pkgconf python3
|
||||
|
||||
|
||||
@@ -9,11 +9,14 @@ jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
|
||||
env:
|
||||
UV_PYTHON_INSTALL_MIRROR: https://mirror.nju.edu.cn/github-release/astral-sh/python-build-standalone/
|
||||
UV_DEFAULT_INDEX: https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple/
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
@@ -45,3 +45,4 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
|
||||
.codex
|
||||
CODE_QUALITY_PLAN.md
|
||||
.repowise
|
||||
.pz
|
||||
@@ -24,7 +24,7 @@ repos:
|
||||
hooks:
|
||||
- id: pyright
|
||||
name: pyright
|
||||
entry: uv run pyright
|
||||
entry: pyright
|
||||
language: system
|
||||
types_or: [python, pyi]
|
||||
require_serial: true
|
||||
@@ -62,7 +62,7 @@ repos:
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: pytest
|
||||
entry: uv run pytest tests/ -x -q --tb=line
|
||||
entry: pytest tests/ -x -q --tb=line
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^(tests/|portprotonqt/)
|
||||
|
||||
@@ -275,11 +275,13 @@ AI agents MAY:
|
||||
|
||||
- No blocking I/O in UI thread
|
||||
- No synchronous HTTP in UI thread
|
||||
- Cache API responses in `~/.cache/PortProtonQt`
|
||||
- Cache API responses in `$XDG_DATA_HOME/PortProtonQt/cache` (default: `~/.local/share/PortProtonQt/cache`)
|
||||
- Avoid repeated disk reads in loops
|
||||
- Avoid O(n²) in game lists
|
||||
- Lazy load images
|
||||
- Limit threads to CPU cores
|
||||
- Skip worker creation for valid cached downloads and URLs already marked failed; still complete callbacks exactly once through the owner's event loop
|
||||
- Reuse cached download paths only while the file exists and matches the requested destination
|
||||
|
||||
### Clarification: "No Shared Mutable Global State" Does NOT Mean Removing Caches
|
||||
|
||||
@@ -288,10 +290,10 @@ Rule intent: avoid shared mutable globals that hold application state or cause h
|
||||
This does not prohibit:
|
||||
- Module-level cache constants (e.g., cache TTL values)
|
||||
- Dedicated cache managers or classes with explicit lifecycle
|
||||
- Disk caches under `~/.cache/PortProtonQt`
|
||||
- Disk caches under `$XDG_DATA_HOME/PortProtonQt/cache` (default: `~/.local/share/PortProtonQt/cache`)
|
||||
- `requests.Session()` objects if they are encapsulated and not mutated unpredictably
|
||||
|
||||
Do not delete or disable caching or downloaders to satisfy this rule. Caching is required by the Performance Rules ("Cache API responses in `~/.cache/PortProtonQt`" and "Avoid repeated disk reads in loops").
|
||||
Do not delete or disable caching or downloaders to satisfy this rule. Caching is required by the Performance Rules ("Cache API responses in `$XDG_DATA_HOME/PortProtonQt/cache`" and "Avoid repeated disk reads in loops").
|
||||
|
||||
---
|
||||
|
||||
@@ -412,23 +414,29 @@ tests/
|
||||
├── test_appimage_updater.py # AppImage auto-update worker, mirrors, changelog parsing
|
||||
├── test_dark_theme.py # Dark theme detection (XFCE/MATE/Cinnamon/GNOME)
|
||||
├── test_steam_cache.py # exiftool skip, cache eviction, delete_cached_app_files
|
||||
├── test_downloader.py # Cache/error callbacks, worker ownership, and shutdown regressions
|
||||
├── test_base_config.py # BaseConfig read/write, caching, versioning
|
||||
├── test_cli.py # normalize_launch_path, URL/resolution parsing
|
||||
├── test_debug_env_utils.py # Debug environment helpers, runtime variables
|
||||
├── test_easyterm.py # Terminal final output, exit status, keyboard and gamepad closing
|
||||
├── test_input_manager.py # Gamepad input navigation and focus regressions
|
||||
├── test_input_manager.py # Gamepad input navigation, dialog keyboards, and focus regressions
|
||||
├── test_sound_manager.py # UI sound playback, widget events, gamepad connection, theme sound files
|
||||
├── test_boot_animation.py # Theme video inheritance, fullscreen boot rendering, gamepad skip, loading transitions, and shutdown
|
||||
├── test_main_window.py # Main window data processing and callback regressions
|
||||
├── test_library_resize.py # Library resize batching, cover reuse, and flow layout regressions
|
||||
├── test_library_loading.py # Incremental loading, source switching, first-paint focus, and cover callbacks
|
||||
├── test_store_games.py # GOG and EGS library, launch, install, and stop regressions
|
||||
├── test_portproton_config.py # exec_line parsing, launcher tail, extensions
|
||||
├── test_portproton_scripts.py # Shell script launch, second exe, exit codes, user.conf
|
||||
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
|
||||
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
|
||||
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
|
||||
├── test_file_explorer.py # File explorer themed icon state regressions
|
||||
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
|
||||
├── test_exe_metadata.py # PE version strings, shell output, and ExifTool fallback
|
||||
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
|
||||
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
|
||||
├── test_shortcuts.py # Desktop shortcut creation, paths with spaces, .desktop entry
|
||||
├── test_shortcuts.py # Desktop shortcuts, folder import, edited exe paths, and gamepad navigation
|
||||
├── test_theme_store.py # Theme store UI worker lifecycle and race regressions
|
||||
├── test_theme_manager.py # Theme AST injection, parent resolution, ThemeWrapper, style integrity
|
||||
├── test_theme_security.py # Theme security checker (allowlist AST, forbidden modules/methods, SVG/font/image safety)
|
||||
@@ -462,6 +470,15 @@ pre-commit run pytest
|
||||
- Keep tests locale-independent: assert stable identifiers or numbers instead of translated strings; resolve values created through `_()` from the same application mapping using stable identifiers; use `monkeypatch` to provide deterministic text when testing translated search or matching
|
||||
- Test regression scenarios from git fix commits
|
||||
|
||||
### Library Refresh Regressions
|
||||
|
||||
- Late metadata/download completion must preserve scroll position and focused card in an already populated library; first-card focus belongs to initial loading
|
||||
- Cover both library layouts and refresh completion while the autoinstall tab is active
|
||||
- For reported scrolling regressions, supplement unit tests with a full application cycle: isolated empty cache, forced ProtonDB connection failure, scroll and refresh both tabs, switch back, then close the application
|
||||
- Record scroll values and visible cards around late updates; distinguish an intentional refresh reset from an unsolicited rollback after the user resumes scrolling
|
||||
- Use temporary XDG config/data directories for GUI reproduction; never clear the user's real cache or alter system-wide network settings
|
||||
- Report baseline reproduction and final-code results separately; passing callback tests alone does not prove the visible regression is fixed
|
||||
|
||||
### Key Regression Areas
|
||||
|
||||
| Module | Regression | Commit |
|
||||
@@ -990,7 +1007,7 @@ PortProtonQt/
|
||||
│ ├── tabs/ # Main window tabs and workers
|
||||
│ │ ├── autoinstall_tab.py
|
||||
│ │ ├── control_hints.py
|
||||
│ │ ├── gog_tab.py
|
||||
│ │ ├── download_tab.py
|
||||
│ │ ├── library_tab.py
|
||||
│ │ ├── settings_tab.py
|
||||
│ │ ├── system_tab.py
|
||||
@@ -1041,6 +1058,8 @@ PortProtonQt/
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Use the existing `orjson` dependency instead of standard-library `json` for faster JSON parsing and serialization. For formatted output, use `orjson.OPT_INDENT_2`.
|
||||
|
||||
### Core
|
||||
|
||||
| Package | Purpose | License |
|
||||
@@ -1067,8 +1086,6 @@ PortProtonQt/
|
||||
|
||||
- [Theme documentation](documentation/theme_guide)
|
||||
- [Localization guide](documentation/localization_guide)
|
||||
- [Metadata override guide](documentation/metadata_override)
|
||||
- [TODO list](TODO.md)
|
||||
- [Changelog](CHANGELOG.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -7,6 +7,79 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен CLI для получения локальных данных библиотеки
|
||||
- Для Run After / Wrapper добавлен раскрываемый список с отдельным полем для каждой записи, удалением записей и кнопкой полной очистки
|
||||
- В фильтр библиотеки добавлен выбор нескольких источников одновременно с сохранением выбранной комбинации
|
||||
- При добавлении игр в Steam добавлена загрузка обложек из SteamGridDB, если Steam AppID не найден
|
||||
- В диалоги добавления и редактирования EXE добавлен выбор пользовательской иконки
|
||||
- Добавлена возможность добавлять установленные игры GOG и EGS в Steam
|
||||
- Добавлен массовый импорт игр из выбранной папки
|
||||
- Добавлен режим множественного выделения карточек через контекстное меню: пробел включает и отменяет режим с очисткой выделения, ЛКМ и кнопка A геймпада переключают выделение карточек
|
||||
- Добавлены установка и применение темы перетаскиванием скачанного архива в окно приложени
|
||||
- В диалог вылета добавлена кнопка скачивания PPDB с linux-gaming.ru с заменой существующего файла, если PPDB доступен и автоматическая загрузка отключена
|
||||
- Добавлено предупреждение о кириллице в путях к игре или PortProton
|
||||
- Добавлен резервный запрет сна через logind/elogind при недоступности ScreenSaver и портала, в том числе для дистрибутивов без systemd
|
||||
|
||||
### Changed
|
||||
|
||||
- В Run After / Wrapper выбор файлов ограничен `.exe` и файлами с правом исполнения в Linux
|
||||
- В режиме CLOUD данные ProtonDB и ссылки на обложки SteamGridDB загружаются через `deckyloader.ru`
|
||||
- Повторный запуск с `--silent` теперь запрашивает подтверждение: при согласии текущий PortProtonQt и запущенные игры завершаются, затем приложение запускается заново для новой игры: при отказе новый запуск полностью отменяется, текущая сессия продолжает работать
|
||||
- Найденные Steam AppID сохраняются в кеше, чтобы при повторной загрузке библиотеки пропускать чтение метаданных EXE и поиск по каталогу Steam
|
||||
- При определении языка Steam_EMU теперь учитывается переменная `LANGUAGE`
|
||||
- При возврате из карточки игры библиотека сохраняет прокрутку и фокус на этой карточке
|
||||
- Подсказки списков настроек обновляются при наведении мышью и перемещении геймпадом между пунктами
|
||||
- После добавления или удаления ярлыков в Steam через CEF без необходимости перезапуска больше не показывается диалог
|
||||
- В скриптах PortProton ускорено чтение метаданных exe с помощью pefile
|
||||
- Разбор ответа PPDB в диалоге отчёта и запись форматированного JSON-кеша переведены на orjson
|
||||
- Настройки exe и `user.conf` применяются автоматически при изменении
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Китайский (упрощённый) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Виртуальная мышь создаётся только при включении эмуляции геймпадом и удаляется при её выключении
|
||||
- При ошибке загрузки каталога WINE/Proton отображаются локально установленные версии
|
||||
- Исправлен запуск команд с функциями PortProton во встроенном терминале при использовании zsh или fish
|
||||
- Исправлено зависание при смене сетки библиотеки
|
||||
- Исправлено открытие контекстного меню в файловом проводнике кнопкой геймпада
|
||||
- Исправлена блокировка установки игр GOG и EGS сообщением «Другая загрузка уже выполняется» после отмены выбора DLC
|
||||
- При включённом фильтре установленных игр метаданные GOG и EGS загружаются только для установленных игр
|
||||
- Исправлено отображение хвостиков источника игр в вертикальном режиме библиотеки
|
||||
- В избранных настройках исправлены пустой пункт списка Wine/Proton, навигация геймпадом и выход подсказок за границы окна
|
||||
- Исправлено перезаписывание исходной записи Steam более новой игрой с тем же названием при построении поискового индекса
|
||||
- При ошибке загрузки подробных сведений Steam сохраняются найденный AppID и возможность загрузки обложки
|
||||
- Наведение на карточки больше не перехватывает фокус из строки поиска при вводе текста
|
||||
- Исправлена ошибка форматирования даты последнего запуска и времени игры при системной локали `C` или `POSIX`
|
||||
- Отключена проверка обновлений AppImage при запуске с `--silent`, чтобы не показывать диалог обновления
|
||||
- Исправлена рекурсивная ошибка фильтра событий клавиатуры при закрытии приложения
|
||||
- Исправлена высокая нагрузка на CPU из-за непрерывной перерисовки таблиц в настройках exe
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
- Исправлена гонка при запуске игры с `--silent`
|
||||
- Исправлено обрезание значений в избранных настройках
|
||||
- При выключенной опции «Fix video in game» больше не блокируется создание DXGI Device Manager в Wine/Proton
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @adem4ik
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @iodnoorov
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен поиск настроек игры вводом с клавиатуры без предварительного выбора поля поиска
|
||||
- Добавлена навигация геймпадом к кнопкам под таблицами
|
||||
- Добавлена настройка скрытия курсора в полноэкранном режиме, включённая по умолчанию
|
||||
- Добавлена настройка отключения поиска установленных Proton в Steam для списков выбора версий и окна управления WINE/Proton
|
||||
- Добавлена возможность добавления загрузочной WebM-заставки из тем при переходе в fullscreen, включая автоматический переход при подключении геймпада
|
||||
- Добавлен переход к загрузке Wine/Proton из списка версий в настройках игры
|
||||
- Добавлены выбор и установка DLC при установке игр GOG
|
||||
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
|
||||
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
|
||||
@@ -23,9 +96,19 @@
|
||||
- При открытии карточки с отсутствующим исполняемым файлом добавлен выбор: удалить карточку или указать другой файл
|
||||
- Добавлен отдельный раздел настройки шрифтов MangoHud с масштабом, размером и выбором шрифта
|
||||
- Добавлен графический редактор глобальных настроек игр из `user.conf`
|
||||
- В режиме `PW_USE_SUPPLIED_DXVK_VKD3D` добавлена поддержка D7VK из сборки Proton
|
||||
|
||||
### Changed
|
||||
|
||||
- Настройка `WINE_FULLSCREEN_FSR` по умолчанию отключена
|
||||
- Кнопки менеджера Wine/Proton приведены к общему стилю приложения и дополнены иконками
|
||||
- Списки DLL и шрифтов в Winetricks теперь сортируются по алфавиту
|
||||
- Оформление Winetricks приведено к общему стилю приложения
|
||||
- В поле запуска второго `.exe` / враппера теперь можно указать тот же исполняемый файл, что и основной
|
||||
- Описания приложения в README, метаданных и пакетах сделаны нейтральными, без перечисления источников игр и привязки к Windows
|
||||
- Ускорено изменение размера карточек библиотеки
|
||||
- Удалён GDK Proton из списка доступных сборок
|
||||
- Улучшено отображение описаний параметров в настройках игры: они размещаются под названием, а текущая строка выделяется рамкой
|
||||
- После завершения загрузки показывается системное уведомление, а строка результата открывает карточку игры мышью или кнопкой A геймпада
|
||||
- Улучшено определение вылетов игр: учитывается код завершения команды Wine/Proton и время работы игровых процессов, включая дочерний движок, без учёта времени завершения скрипта запуска
|
||||
- Каталоги пользовательских данных игр переведены на единый формат `источник-ID`
|
||||
@@ -47,11 +130,18 @@
|
||||
- `minazuki` добавлен в список конфликтующих с GameMode процессов
|
||||
- Встроенный терминал после ошибки остаётся открытым и закрывается клавишей Enter или кнопкой A геймпада
|
||||
- В список советов при вылете добавлено отключение Optiscaler
|
||||
- Повышен статус разработки в `pyproject.toml` с Alpha до Production
|
||||
- DXVK и VKD3D-Proton теперь загружаются только для выбранного режима Vulkan, а D7VK — только при его включении
|
||||
|
||||
### Fixed
|
||||
|
||||
- Исправлена запись `MANGOHUD_CONFIG` в `.ppdb` при выключенном MangoHud
|
||||
- Исправлены ложный статус установки и открытие чужой карточки для Rockstar Games Launcher при совпадении имени `launcher.exe`
|
||||
- Исправлен запуск второго `.exe` через `PW_RUN_AFTER_EXE` из пути с кириллицей
|
||||
- Исправлены мерцание сетки и задержка выделения карточек при открытии библиотеки
|
||||
- Исправлено обновление сетки комбинацией Guide + Select
|
||||
- Исправлены создание и восстановление `.desktop`-файлов с переносами строк в названии игры
|
||||
- Исправлено появление дубля карточки при добавлении игры
|
||||
- Исправлено появление дубля карточки при добавлении и после установки игры
|
||||
- Исправлено отсутствие новых `.exe` в поиске библиотеки до обновления сетки
|
||||
- Исправлено обновление названия на детальной странице после редактирования ярлыка без повторного открытия страницы
|
||||
- Исправлено отображение дополнений и наборов GOG как отдельных игр в библиотеке
|
||||
@@ -59,9 +149,8 @@
|
||||
- Исправлена синтаксическая ошибка в `functions_helper`, препятствовавшая запуску PortProton с Bash 5.1
|
||||
- Исправлено управление интерфейсом при одновременном подключении нескольких геймпадов
|
||||
- Исправлена коллизия пользовательских данных автоустановок с одинаковыми именами исходных исполняемых файлов
|
||||
- Исправлен поиск скриптов и локалей через `XDG_DATA_DIRS` в NixOS
|
||||
- Исправлен запуск PortProtonQt на NixOS через `steam-run`
|
||||
- Исправлена проверка и установка недостающих i586-зависимостей в ALT Linux перед запуском игр
|
||||
- Исправлено игнорирование проверки 32-битных пакетов при запуске игр GOG
|
||||
- Исправлено отсутствие иконки у окна встроенного терминала
|
||||
- Исправлена потеря финального вывода при ошибке встроенного терминала
|
||||
- Исправлено открытие карточек GOG через URI при старте приложения
|
||||
@@ -74,8 +163,11 @@
|
||||
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
|
||||
- Исправлено создание резервной копии префикса с `libarchive-c 5.1`
|
||||
- Исправлено завершение приложения при активных фоновых операциях
|
||||
- Исправлено автоматическое включение NVAPI, DLSS и трассировки лучей для видеокарт NVIDIA RTX
|
||||
- Исправлено применение принудительно выбранного языка в конфигурациях эмуляторов Steam
|
||||
|
||||
### Contributors
|
||||
- @dark_siders
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @adem4ik
|
||||
|
||||
@@ -3,10 +3,21 @@
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-12.8k-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, Epic Games Store, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
|
||||
|
||||
<p align="center">An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Release" alt="Releases"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-17.9k-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-GPL--3.0-blue?style=flat-square" alt="License: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11%2B-blue?style=flat-square&logo=python" alt="Python 3.11+"></a>
|
||||
<a href="https://github.com/linux-gaming-ru/PortProtonQt"><img src="https://img.shields.io/badge/Platform-Linux-blue?style=flat-square&logo=linux" alt="Platform: Linux"></a>
|
||||
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Translations-Weblate-2eccaa?style=flat-square&logo=weblate" alt="Translate on Weblate"></a>
|
||||
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg" alt="Packaging status on Repology"></a>
|
||||
</p>
|
||||
|
||||
<p><a href="https://github.com/linux-gaming-ru/PortProtonQt/releases">Releases</a> · <a href="https://github.com/linux-gaming-ru/PortProtonQt/issues">Issue tracker</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Translations</a> · <a href="https://repology.org/project/portprotonqt/versions">Packages</a></p>
|
||||
</div>
|
||||
|
||||
### Installation (devel)
|
||||
@@ -57,7 +68,7 @@ pre-commit run --all-files
|
||||
|
||||
### Contributors
|
||||
|
||||
We thank everyone who has contributed to the development of PortProtonQt, including those who participate through commits as well as those who help in other ways (testing, ideas, translations, documentation, etc.). A full list of participants can be found in the [repository activity list](https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/activity/contributors). Additional participants are also listed in the [CHANGELOG.md](CHANGELOG.md) file. If you have contributed but are not listed, please contact the lead developers so we can acknowledge you!
|
||||
We thank everyone who has contributed to the development of PortProtonQt, including those who participate through commits as well as those who help in other ways (testing, ideas, translations, documentation, etc.). A full list of participants can be found in the [repository activity list](https://github.com/linux-gaming-ru/PortProtonQt/graphs/contributors). Additional participants are also listed in the [CHANGELOG.md](CHANGELOG.md) file. If you have contributed but are not listed, please contact the lead developers so we can acknowledge you!
|
||||
|
||||
## Dependencies and Licenses
|
||||
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
📖 This documentation is also available in [English](README.md)
|
||||
📘 This documentation is also available in [English](README.md)
|
||||
|
||||
---
|
||||
|
||||
<div align="center">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-12.8k-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam, Epic Games Store и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="128">
|
||||
|
||||
<p align="center">Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/github/v/release/linux-gaming-ru/PortProtonQt?style=flat-square&label=Релиз" alt="Релизы"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Загрузки-17.9k-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/Лицензия-GPL--3.0-blue?style=flat-square" alt="Лицензия: GPL-3.0"></a>
|
||||
<a href="pyproject.toml"><img src="https://img.shields.io/badge/Python-3.11+-blue?style=flat-square&logo=python" alt="Python 3.11+"></a>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"><img src="https://img.shields.io/badge/Платформа-Linux-blue?style=flat-square&logo=linux" alt="Платформа: Linux"></a>
|
||||
<a href="https://translate.codeberg.org/engage/portprotonqt/"><img src="https://img.shields.io/badge/Переводы-Weblate-2eccaa?style=flat-square&logo=weblate" alt="Перевести на Weblate"></a>
|
||||
<a href="https://repology.org/project/portprotonqt/versions"><img src="https://repology.org/badge/tiny-repos/portprotonqt.svg?header=Репозитории" alt="Пакеты в Repology"></a>
|
||||
</p>
|
||||
|
||||
<p><a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases">Релизы</a> · <a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/issues">Сообщить о проблеме</a> · <a href="https://translate.codeberg.org/engage/portprotonqt/">Переводы</a> · <a href="https://repology.org/project/portprotonqt/versions">Пакеты</a></p>
|
||||
</div>
|
||||
|
||||
### Установка (devel)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Do not publish suspected vulnerability details in public issues, discussions,
|
||||
or pull requests. Send reports to [support@linux-gaming.ru](mailto:support@linux-gaming.ru).
|
||||
|
||||
Please include enough information to reproduce and assess the issue:
|
||||
|
||||
- PortProtonQt version or commit and installation source.
|
||||
- Linux distribution and relevant launcher or runtime details.
|
||||
- Affected feature and clear reproduction steps.
|
||||
- Expected behavior, observed behavior, and potential security impact.
|
||||
- A minimal proof of concept or sanitized logs, if available.
|
||||
- A suggested mitigation or fix, if known.
|
||||
|
||||
Do not include passwords, tokens, private keys, or personal data. Redact
|
||||
usernames, home directory paths, and other sensitive details from logs where
|
||||
practical. Test only with systems, accounts, and files you own or are
|
||||
authorized to use.
|
||||
|
||||
## Scope
|
||||
|
||||
This policy covers security issues in PortProtonQt, including its handling of
|
||||
local files and archives, launcher data, network requests, downloaded content,
|
||||
and application configuration. Issues in third-party launchers, libraries,
|
||||
compatibility tools, or distribution packages should also be reported to their
|
||||
maintainers. Please report cases where PortProtonQt's handling creates or
|
||||
increases the risk.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Please check whether the issue affects the latest published release and
|
||||
include the affected version in your report. Older releases may not receive
|
||||
security fixes; users should update when a fix becomes available.
|
||||
|
||||
## Coordinated disclosure
|
||||
|
||||
Keep vulnerability details private while maintainers investigate and prepare a
|
||||
fix or mitigation. Coordinate public disclosure with the maintainers, allowing
|
||||
reasonable time for a fix to reach users. Response and remediation times depend
|
||||
on the issue; no fixed response time is promised.
|
||||
@@ -1,16 +1,26 @@
|
||||
pkgname=('portprotonqt')
|
||||
pkgver=1.4.1
|
||||
# Maintainer: Boris Yumankulov <boriabloger[at]protonmail[dot]com>
|
||||
|
||||
pkgname=portprotonqt
|
||||
pkgver=1.5.0
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
|
||||
pkgdesc="Game library manager and launcher for Linux"
|
||||
arch=('x86_64')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'sdl3')
|
||||
conflicts=("portprotonqt-steam-compat")
|
||||
provides=('portprotonqt-steam-compat')
|
||||
replaces=('portprotonqt-steam-compat')
|
||||
optdepends=(
|
||||
'networkmanager: System tab network management'
|
||||
'bluez: System tab bluetooth management'
|
||||
'upower: System tab bluetooth battery level'
|
||||
'libpulse: System tab audio volume/output management'
|
||||
'python-qrcode: Wi-Fi QR code generation'
|
||||
'squashfs-tools: for legacy PortProton prefix backup support'
|
||||
)
|
||||
source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt#tag=v$pkgver")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
@@ -19,17 +29,7 @@ build() {
|
||||
meson compile -C build
|
||||
}
|
||||
|
||||
package_portprotonqt() {
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
|
||||
optdepends=(
|
||||
'networkmanager: System tab network management'
|
||||
'bluez: System tab bluetooth management'
|
||||
'upower: System tab bluetooth battery level'
|
||||
'libpulse: System tab audio volume/output management'
|
||||
'python-qrcode: Wi-Fi QR code generation'
|
||||
'squashfs-tools: for legacy PortProton prefix backup support'
|
||||
)
|
||||
|
||||
package() {
|
||||
DESTDIR="$pkgdir" meson install -C build --skip-subprojects
|
||||
|
||||
bash "$srcdir/PortProtonQt/dev-scripts/generate-completions.sh"
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
pkgname=('portprotonqt-git')
|
||||
# Maintainer: Boris Yumankulov <boriabloger[at]protonmail[dot]com>
|
||||
|
||||
pkgname=portprotonqt-git
|
||||
pkgver=.
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
|
||||
pkgdesc="Game library manager and launcher for Linux (development build)"
|
||||
arch=('x86_64')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'python-libarchive-c' 'pyside6' 'python-rapidfuzz' 'python-pefile' 'python-pillow' 'perl-image-exiftool' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'file' 'findutils' 'gawk' 'grep' 'tar' 'xz' 'zstd' 'gzip' 'unrar' 'qt6-svg' 'qt6-imageformats' 'qt6-multimedia' 'pciutils' 'mesa-utils' 'vulkan-icd-loader' 'procps-ng' 'psmisc' '7zip' 'python-dbus-fast' 'sdl3' 'desktop-file-utils')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext')
|
||||
makedepends=('meson' 'ninja' 'vulkan-headers' 'gettext' 'sdl3')
|
||||
conflicts=("portprotonqt-steam-compat")
|
||||
provides=('portprotonqt-steam-compat')
|
||||
replaces=('portprotonqt-steam-compat')
|
||||
optdepends=(
|
||||
'networkmanager: System tab network management'
|
||||
'bluez: System tab bluetooth management'
|
||||
'upower: System tab bluetooth battery level'
|
||||
'libpulse: System tab audio volume/output management'
|
||||
'python-qrcode: Wi-Fi QR code generation'
|
||||
'squashfs-tools: for legacy PortProton prefix backup support'
|
||||
)
|
||||
source=("git+https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt.git")
|
||||
sha256sums=('SKIP')
|
||||
|
||||
@@ -24,17 +34,7 @@ build() {
|
||||
meson compile -C build
|
||||
}
|
||||
|
||||
package_portprotonqt-git() {
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
|
||||
optdepends=(
|
||||
'networkmanager: System tab network management'
|
||||
'bluez: System tab bluetooth management'
|
||||
'upower: System tab bluetooth battery level'
|
||||
'libpulse: System tab audio volume/output management'
|
||||
'python-qrcode: Wi-Fi QR code generation'
|
||||
'squashfs-tools: for legacy PortProton prefix backup support'
|
||||
)
|
||||
|
||||
package() {
|
||||
DESTDIR="$pkgdir" meson install -C build --skip-subprojects
|
||||
|
||||
bash "$srcdir/PortProtonQt/dev-scripts/generate-completions.sh"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.4.1
|
||||
%global pypi_version 1.5.0
|
||||
%global oname PortProtonQt
|
||||
%global build_timestamp %(date +"%Y%m%d")
|
||||
%global _python_no_extras_requires 1
|
||||
@@ -9,7 +9,7 @@
|
||||
Name: %{pypi_name}-git
|
||||
Version: %{pypi_version}
|
||||
Release: %{rel_build}
|
||||
Summary: Modern GUI for managing and launching games from PortProton and Steam (development build)
|
||||
Summary: Game library manager and launcher for Linux (development build)
|
||||
|
||||
License: GPL-3.0
|
||||
URL: https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt
|
||||
@@ -81,7 +81,7 @@ Obsoletes: %{pypi_name}-steam-compat < %{?epoch:%{epoch}:}%{version}-%{rele
|
||||
Provides: %{pypi_name}-steam-compat = %{?epoch:%{epoch}:}%{version}-%{release}
|
||||
|
||||
%description
|
||||
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
|
||||
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
|
||||
|
||||
%{?python_disable_dependency_generator}
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.4.1
|
||||
%global pypi_version 1.5.0
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
Name: %{pypi_name}
|
||||
Version: %{pypi_version}
|
||||
Release: 1%{?dist}
|
||||
Summary: Modern GUI for managing and launching games from PortProton and Steam
|
||||
Summary: Game library manager and launcher for Linux
|
||||
|
||||
License: GPL-3.0
|
||||
URL: https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt
|
||||
@@ -78,7 +78,7 @@ Obsoletes: %{pypi_name}-steam-compat < %{?epoch:%{epoch}:}%{version}-%{rele
|
||||
Provides: %{pypi_name}-steam-compat = %{?epoch:%{epoch}:}%{version}-%{release}
|
||||
|
||||
%description
|
||||
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
|
||||
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
|
||||
|
||||
%{?python_disable_dependency_generator}
|
||||
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
Name=PortProtonQt
|
||||
Exec=portprotonqt %u
|
||||
Type=Application
|
||||
Comment=A modern GUI for PortProton project
|
||||
Comment[ru]=Современный графический интерфейс для проекта PortProton
|
||||
Comment[es]=Una interfaz gráfica moderna para el proyecto PortProton
|
||||
Comment[pt]=Uma interface gráfica moderna para o projeto PortProton
|
||||
Comment[de]=Eine moderne grafische Benutzeroberfläche für das PortProton-Projekt
|
||||
Comment=Game library manager and launcher for Linux
|
||||
Comment[ru]=Приложение для управления игровой библиотекой и запуска игр на Linux
|
||||
Comment[es]=Gestor de biblioteca de juegos y lanzador para Linux
|
||||
Comment[pt]=Gerenciador de biblioteca de jogos e inicializador para Linux
|
||||
Comment[de]=Spielebibliotheksverwaltung und Spielestarter für Linux
|
||||
Terminal=false
|
||||
Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
|
||||
@@ -5,15 +5,15 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<summary>Modern GUI for managing and launching games from PortProton, Steam, and GOG</summary>
|
||||
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton, Steam и GOG</summary>
|
||||
<summary>Game library manager and launcher for Linux</summary>
|
||||
<summary xml:lang="ru">Приложение для управления игровой библиотекой и запуска игр на Linux</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
A modern and intuitive interface for managing and launching games from PortProton, Steam, and GOG. Combines libraries in one place and simplifies running Windows games on Linux.
|
||||
An application for managing a game library and launching games on Linux. It brings games from different sources together in one place.
|
||||
</p>
|
||||
<p xml:lang="ru">
|
||||
Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
|
||||
Приложение для управления игровой библиотекой и запуска игр на Linux. Объединяет игры из разных источников в одном месте.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
@@ -24,9 +24,12 @@
|
||||
</developer>
|
||||
|
||||
<releases>
|
||||
<release version="1.4.1" date="2026-08-26">
|
||||
<release version="1.5.0" date="2026-10-14">
|
||||
<description></description>
|
||||
</release>
|
||||
<release version="1.4.1" date="2026-08-26">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.4.0" date="2026-08-23">
|
||||
<description/>
|
||||
</release>
|
||||
|
||||
@@ -20,7 +20,7 @@ python_module () {
|
||||
local python3_module="$1"
|
||||
shift
|
||||
print_info "Running python3 module: $python3_module $@" >&2
|
||||
python3 -m portprotonqt.scripts_utils.$python3_module "$@"
|
||||
"${PORTPROTONQT_PYTHON:-python3}" -m portprotonqt.scripts_utils.$python3_module "$@"
|
||||
}
|
||||
|
||||
pw_alt_i586_dependencies () {
|
||||
@@ -177,7 +177,7 @@ pw_enable_idle_inhibit() {
|
||||
return
|
||||
fi
|
||||
|
||||
PW_INHIBIT_SLR="python3 -m portprotonqt.scripts_utils.idle_inhibit"
|
||||
PW_INHIBIT_SLR="${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.idle_inhibit"
|
||||
print_info "Screensaver will be inhibited using D-Bus"
|
||||
}
|
||||
|
||||
@@ -1214,7 +1214,7 @@ pw_init_runtime () {
|
||||
export PW_LD_LIBRARY_PATH="${LD_LIBRARY_PATH}:${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32"
|
||||
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
|
||||
if [[ -z "$PW_TERM" ]] ; then
|
||||
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" python3 -m portprotonqt.scripts_utils.easyterm -e"
|
||||
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
|
||||
fi
|
||||
export pw_runtime="env"
|
||||
return 0
|
||||
@@ -1305,7 +1305,7 @@ ${PW_PV_OVERRIDES}/i386-linux-gnu/aliases:\
|
||||
for PWRTMRW in "${PW_RT_MOUNT_RW[@]}" ; do
|
||||
export PRESSURE_VESSEL_FILESYSTEMS_RW="${PRESSURE_VESSEL_FILESYSTEMS_RW:+$PRESSURE_VESSEL_FILESYSTEMS_RW:}${PWRTMRW}"
|
||||
done
|
||||
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" python3 -m portprotonqt.scripts_utils.easyterm -e"
|
||||
export PW_TERM="env LD_LIBRARY_PATH=\"${PW_LD_LIBRARY_PATH}\" ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
|
||||
|
||||
print_info "RUNTIME is enabled"
|
||||
}
|
||||
@@ -1514,14 +1514,25 @@ kill_portwine () {
|
||||
bwrap_pids=$(pgrep -f wrap | grep PortProton | head -n 1)
|
||||
else
|
||||
wine_pids=$(ls -l /proc/*/exe 2>/dev/null | grep -ie PortProton | grep -E 'wine(64)?-preloader|wineserver' | awk -F/ '{print $3}')
|
||||
bwrap_pids="$(pgrep -a wrap | grep PortProton | head -n 1 | awk '{print $1}')"
|
||||
bwrap_pids="$(pgrep -a wrap | grep PortProton | awk '{print $1}')"
|
||||
fi
|
||||
else
|
||||
wine_pids=$(ls -l /proc/*/exe 2>/dev/null | grep -E 'wine(64)?-preloader|wineserver' | awk -F/ '{print $3}')
|
||||
unset bwrap_pids
|
||||
fi
|
||||
|
||||
# bwrap hosts steam-run's FHS container on NixOS.
|
||||
# Killing an ancestor would terminate this launch with the container.
|
||||
local pw_ancestor_pids=" " pw_ancestor_pid=$BASHPID
|
||||
if [[ -n "${bwrap_pids:-}" ]] ; then
|
||||
while [[ -n "$pw_ancestor_pid" && "$pw_ancestor_pid" -gt 1 ]] ; do
|
||||
pw_ancestor_pids+="$pw_ancestor_pid "
|
||||
pw_ancestor_pid=$(ps -o ppid= -p "$pw_ancestor_pid" 2>/dev/null | tr -d '[:space:]')
|
||||
done
|
||||
fi
|
||||
|
||||
for pw_kill_pids in ${bwrap_pids} ${wine_pids}; do
|
||||
[[ "$pw_ancestor_pids" == *" $pw_kill_pids "* ]] && continue
|
||||
if ps cax | grep "${pw_kill_pids}" ; then
|
||||
kill -n 9 "${pw_kill_pids}" &>/dev/null
|
||||
fi
|
||||
@@ -1640,8 +1651,15 @@ pw_check_and_download_runtime () {
|
||||
}
|
||||
|
||||
pw_check_and_download_dxvk_and_vkd3d () {
|
||||
case "${PW_VULKAN_USE}" in
|
||||
1) DXVK_VAR_VER="${DXVK_SAREK_VER}"; VKD3D_VAR_VER="${VKD3D_SAREK_VER}" ;;
|
||||
2) DXVK_VAR_VER="${DXVK_OLD_VER}"; VKD3D_VAR_VER="${VKD3D_OLD_VER}" ;;
|
||||
6) DXVK_VAR_VER="${DXVK_NEW_VER}"; VKD3D_VAR_VER="${VKD3D_NEW_VER}" ;;
|
||||
*) DXVK_VAR_VER=""; VKD3D_VAR_VER="" ;;
|
||||
esac
|
||||
|
||||
# Download DXVK
|
||||
for DXVK_VAR_VER in "${DXVK_SAREK_VER}" "${DXVK_OLD_VER}" "${DXVK_NEW_VER}" ; do
|
||||
if [[ -n "${DXVK_VAR_VER}" ]] ; then
|
||||
if [[ ! -d "${PW_VULKAN_DIR}/dxvk-${DXVK_VAR_VER}" ]] ; then
|
||||
print_info "Download and install DXVK v.${DXVK_VAR_VER}"
|
||||
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/dxvk-${DXVK_VAR_VER}/dxvk-${DXVK_VAR_VER}.tar.xz" \
|
||||
@@ -1668,10 +1686,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
|
||||
fatal "Failed to download and unpack dxvk"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Download VKD3D
|
||||
for VKD3D_VAR_VER in "${VKD3D_SAREK_VER}" "${VKD3D_OLD_VER}" "${VKD3D_NEW_VER}" ; do
|
||||
if [[ -n "${VKD3D_VAR_VER}" ]] ; then
|
||||
if [[ ! -d "${PW_VULKAN_DIR}/vkd3d-proton-${VKD3D_VAR_VER}" ]] ; then
|
||||
print_info "Download and install VKD3D-PROTON v.${VKD3D_VAR_VER}"
|
||||
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/vkd3d-proton-${VKD3D_VAR_VER}/vkd3d-proton-${VKD3D_VAR_VER}.tar.xz" \
|
||||
@@ -1696,10 +1714,10 @@ pw_check_and_download_dxvk_and_vkd3d () {
|
||||
fatal "Failed to download and unpack VKD3D"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Download D7VK
|
||||
if [[ ! -d "${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}" ]] ; then
|
||||
if [[ "${PW_USE_D7VK}" == "1" && ! -d "${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}" ]] ; then
|
||||
print_info "Download and install D7VK v.${D7VK_VAR_VER}"
|
||||
if try_download "https://github.com/Castro-Fidel/vulkan/releases/download/d7vk-${D7VK_VAR_VER}/d7vk-${D7VK_VAR_VER}.tar.xz" \
|
||||
"${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}.tar.xz" ; then
|
||||
@@ -1843,11 +1861,6 @@ pw_init_db () {
|
||||
[[ -n "${PW_DEFAULT_PREFIX_NAME:-}" ]] && export PW_PREFIX_NAME="${PW_DEFAULT_PREFIX_NAME}"
|
||||
[[ -n "${PW_DEFAULT_VULKAN_USE:-}" ]] && export PW_VULKAN_USE="${PW_DEFAULT_VULKAN_USE}"
|
||||
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
|
||||
export PW_USE_NVAPI_AND_DLSS="1"
|
||||
export PW_USE_RAY_TRACING="1"
|
||||
fi
|
||||
|
||||
if [[ -f "${PW_EXE_FILE}" ]] ; then
|
||||
PORTWINE_DB="$(basename "${PW_EXE_FILE%.[Ee][Xx][Ee]}")"
|
||||
export PORTWINE_DB
|
||||
@@ -2259,9 +2272,14 @@ manage_user_conf_value () {
|
||||
|
||||
use_exiftool () {
|
||||
if [[ ! -f "${PW_TMPFS_PATH}/exiftool.tmp" ]] \
|
||||
|| ! grep -q "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
|| ! grep -Fqx -- "$1" "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
echo "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
printf '%s\n' "$1" > "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
if [[ "${1,,}" == *.exe ]] \
|
||||
&& python_module exe_metadata "$1" >> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
return 0
|
||||
fi
|
||||
if ! command -v exiftool &>/dev/null \
|
||||
|| ! timeout 3 exiftool "$1" &>> "${PW_TMPFS_PATH}/exiftool.tmp"
|
||||
then
|
||||
@@ -2606,21 +2624,16 @@ pw_enable_gamemode_basic () {
|
||||
if check_flatpak ; then
|
||||
export GAMEMODERUN=1
|
||||
PW_GAMEMODERUN_SLR="gamemoderun"
|
||||
elif command -v gamemoderun &>/dev/null ; then
|
||||
export GAMEMODERUN=1
|
||||
PW_GAMEMODERUN_SLR="gamemoderun"
|
||||
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
|
||||
gamemoded &>/dev/null &
|
||||
sleep 0.1
|
||||
fi
|
||||
else
|
||||
export GAMEMODERUN=1
|
||||
if [[ -n "${PW_LD_PRELOAD}" ]]; then
|
||||
export PW_LD_PRELOAD="${PW_LD_PRELOAD}:libgamemodeauto.so.0"
|
||||
else
|
||||
export PW_LD_PRELOAD="libgamemodeauto.so.0"
|
||||
fi
|
||||
if ! pw_is_process_active gamemoded ; then
|
||||
local GAMEMODEAUTO_NAME="libgamemodeauto.so.0"
|
||||
export PW_LD_PRELOAD="${GAMEMODEAUTO_NAME}${PW_LD_PRELOAD:+:$PW_LD_PRELOAD}"
|
||||
if command -v gamemoderun &>/dev/null ; then
|
||||
if command -v gamemoded &>/dev/null && ! pw_is_process_active gamemoded ; then
|
||||
gamemoded &>/dev/null &
|
||||
sleep 0.1
|
||||
fi
|
||||
elif ! pw_is_process_active gamemoded ; then
|
||||
env LD_LIBRARY_PATH="${PW_PLUGINS_PATH}/portable/lib/lib64:${PW_PLUGINS_PATH}/portable/lib/lib32" \
|
||||
"${PW_PLUGINS_PATH}/portable/bin/gamemoded" &>/dev/null &
|
||||
sleep 0.1
|
||||
@@ -2643,6 +2656,10 @@ start_portproton () {
|
||||
pw_check_and_download_dxvk_and_vkd3d
|
||||
|
||||
pw_skip_get_info
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia.*rtx' ; then
|
||||
[[ -z "${PW_USE_NVAPI_AND_DLSS:-}" ]] && export PW_USE_NVAPI_AND_DLSS="1"
|
||||
[[ -z "${PW_USE_RAY_TRACING:-}" ]] && export PW_USE_RAY_TRACING="1"
|
||||
fi
|
||||
if [[ "${PW_LOCALE_SELECT}" != "disabled" ]] && [[ -n "${PW_LOCALE_SELECT}" ]] ; then
|
||||
export LC_ALL="${PW_LOCALE_SELECT}"
|
||||
if [[ "${PW_USE_RUNTIME}" == "1" ]] && [[ "${HOST_LC_ALL}" != "${LC_ALL}" ]] ; then
|
||||
@@ -2912,7 +2929,9 @@ start_portproton () {
|
||||
|
||||
# export WINE_HIDE_AMD_GPU="1"
|
||||
export DXVK_NVAPI_DRIVER_VERSION="99999"
|
||||
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
|
||||
if [[ $(check_vendor_gpu) != "nvidia" ]] ; then
|
||||
export DXVK_NVAPI_ALLOW_OTHER_DRIVERS="1"
|
||||
fi
|
||||
export PW_USE_NVAPI_AND_DLSS="1"
|
||||
export PW_USE_RAY_TRACING="1"
|
||||
;;
|
||||
@@ -2955,9 +2974,6 @@ start_portproton () {
|
||||
|
||||
if [[ "${PW_USE_NVAPI_AND_DLSS}" == "1" ]] ; then
|
||||
export DXVK_ENABLE_NVAPI="1"
|
||||
if [[ -f "${WINEDIR}/share/dxvk-nvapi-vkreflex-layer/implicit_layer.d/VkLayer_DXVK_NVAPI_reflex.json" ]] ; then
|
||||
PW_VKREFLEX=1
|
||||
fi
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia' ; then
|
||||
FIND_NVNGX="$(dirname $(find /usr/* -type f -name "nvngx.dll" 2>/dev/null | head -n 1 | awk '{print $1}'))"
|
||||
if [[ -n "$FIND_NVNGX" ]] ; then
|
||||
@@ -2969,7 +2985,6 @@ start_portproton () {
|
||||
fi
|
||||
else
|
||||
export DXVK_ENABLE_NVAPI="0"
|
||||
unset PW_VKREFLEX DXVK_NVAPI_VKREFLEX
|
||||
fi
|
||||
|
||||
if [[ "${PW_HEAP_DELAY_FREE}" == 1 ]]
|
||||
@@ -2984,7 +2999,7 @@ start_portproton () {
|
||||
|
||||
if [[ "${PW_FIX_VIDEO_IN_GAME}" == 1 ]]
|
||||
then export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="1"
|
||||
else export WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER="0"
|
||||
else unset WINE_DO_NOT_CREATE_DXGI_DEVICE_MANAGER
|
||||
fi
|
||||
|
||||
if [[ -f "$PATH_TO_GAME/MangoHud.conf" ]] ; then
|
||||
@@ -3483,10 +3498,17 @@ start_portproton () {
|
||||
|
||||
if [[ "${PW_USE_D7VK}" == "1" ]] ; then
|
||||
PATH_TO_D7VK_FILES="${PW_VULKAN_DIR}/d7vk-${D7VK_VAR_VER}"
|
||||
print_info "D7VK ${D7VK_VAR_VER} in use"
|
||||
D7VK_FILE="${PATH_TO_D7VK_FILES}/x32/ddraw.dll"
|
||||
if [[ "$PW_USE_SUPPLIED_DXVK_VKD3D" == "2" ]] \
|
||||
&& [[ -f "${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll" ]] ; then
|
||||
D7VK_FILE="${WINEDIR}/lib/wine/d7vk/i386-windows/ddraw.dll"
|
||||
print_info "Bundled D7VK in use"
|
||||
else
|
||||
print_info "D7VK ${D7VK_VAR_VER} in use"
|
||||
fi
|
||||
print_info "Try create symlink D7VK files..."
|
||||
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw_.dll"
|
||||
try_force_link_file "${PATH_TO_D7VK_FILES}/x32/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
try_force_link_file "${D7VK_FILE}" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
var_winedlloverride_update "ddraw=n,b"
|
||||
else
|
||||
try_force_link_file "${WINEDIR}/lib/wine/i386-windows/ddraw.dll" "${WINEPREFIX}/drive_c/windows/syswow64/ddraw.dll"
|
||||
@@ -3777,10 +3799,19 @@ start_portproton () {
|
||||
|
||||
add_in_start_portproton
|
||||
|
||||
if [[ "$FULL_LN" == russian ]] ; then
|
||||
local steam_emu_language=""
|
||||
case "${PW_LOCALE_SELECT}" in
|
||||
ru_*) steam_emu_language="russian" ;;
|
||||
en_*) steam_emu_language="english" ;;
|
||||
zh_*) steam_emu_language="schinese" ;;
|
||||
ja_*) steam_emu_language="japanese" ;;
|
||||
ko_*) steam_emu_language="koreana" ;;
|
||||
disabled|"") steam_emu_language="$FULL_LN" ;;
|
||||
esac
|
||||
if [[ -n "$steam_emu_language" ]] ; then
|
||||
for chk_lang_and_uname in "steam_emu.ini" "steam_api.ini" "steam_api64.ini" "SteamConfig.ini" ; do
|
||||
if [[ -f "${PATH_TO_GAME}/${chk_lang_and_uname}" ]] ; then
|
||||
sed -i "/^Language/c\Language=${FULL_LN}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^Language/c\Language=${steam_emu_language}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^UserName/c\UserName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
sed -i "/^PlayerName/c\PlayerName=${USER}" "${PATH_TO_GAME}/${chk_lang_and_uname}"
|
||||
fi
|
||||
@@ -3981,6 +4012,25 @@ pw_launch_wrapper () {
|
||||
return
|
||||
fi
|
||||
|
||||
local -a wrapper_scripts=()
|
||||
local wrapper_script
|
||||
local wrapper_scripts_valid=1
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == *","* ]] ; then
|
||||
IFS=',' read -r -a wrapper_scripts <<< "${PW_RUN_AFTER_EXE}"
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
if [[ ! -f "${wrapper_script}" || "${wrapper_script,,}" == *.exe ]] ; then
|
||||
wrapper_scripts_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ "${wrapper_scripts_valid}" == 1 ]] ; then
|
||||
for wrapper_script in "${wrapper_scripts[@]}" ; do
|
||||
"${wrapper_script}"
|
||||
done
|
||||
"$@"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
|
||||
local -a wrapper_cmd
|
||||
eval "wrapper_cmd=(${PW_RUN_AFTER_EXE})"
|
||||
if [[ ${#wrapper_cmd[@]} -eq 0 ]] ; then
|
||||
@@ -4049,10 +4099,6 @@ steamplay_launch () {
|
||||
else
|
||||
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
|
||||
fi
|
||||
if [[ "${PW_VKREFLEX}" == "1" ]] ; then
|
||||
export DXVK_NVAPI_VKREFLEX="1"
|
||||
export PW_VK_LAYER_PATH="${PW_VK_LAYER_PATH:+${PW_VK_LAYER_PATH}:}${WINEDIR}/share/dxvk-nvapi-vkreflex-layer/implicit_layer.d"
|
||||
fi
|
||||
|
||||
pw_mangohud_check
|
||||
pw_vkbasalt_check
|
||||
@@ -4166,15 +4212,27 @@ portwine_launch () {
|
||||
fi
|
||||
done
|
||||
else
|
||||
local -a run_after_exes=() run_after_environment=()
|
||||
local run_after_entry run_after_index
|
||||
local run_after_valid=1
|
||||
if [[ -f "${PW_RUN_AFTER_EXE:-}" ]] ; then
|
||||
run_after_exes=("${PW_RUN_AFTER_EXE}")
|
||||
else
|
||||
IFS=',' read -r -a run_after_exes <<< "${PW_RUN_AFTER_EXE:-}"
|
||||
fi
|
||||
for run_after_entry in "${run_after_exes[@]}" ; do
|
||||
if [[ ! -f "${run_after_entry}" || "${run_after_entry,,}" != *.exe ]] ; then
|
||||
run_after_valid=0
|
||||
fi
|
||||
done
|
||||
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]] \
|
||||
&& [[ "${run_after_valid}" == 1 ]] \
|
||||
&& [[ ${#run_after_exes[@]} -gt 0 ]] \
|
||||
&& [[ "${PW_EXE_FILE,,}" =~ \.exe$ ]]
|
||||
then
|
||||
local wait_delay wait_delay_int run_after_bat
|
||||
local run_after_dir run_after_name
|
||||
local pw_exe_file_win
|
||||
local pw_exe_file_win run_after_exe_win
|
||||
local wine_drive_c
|
||||
wait_delay="${PW_RUN_AFTER_DELAY:-3}"
|
||||
wait_delay_int="${wait_delay%%.*}"
|
||||
@@ -4193,17 +4251,49 @@ portwine_launch () {
|
||||
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
|
||||
fi
|
||||
pw_exe_file_win="${pw_exe_file_win//\//\\}"
|
||||
local PW_RUN_AFTER_EXE="${run_after_exes[0]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${PW_RUN_AFTER_EXE}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${PW_RUN_AFTER_EXE}" == "${wine_drive_c}/"* ]] ; then
|
||||
run_after_exe_win="c:\\${PW_RUN_AFTER_EXE#"${wine_drive_c}/"}"
|
||||
else
|
||||
run_after_exe_win="z:${PW_RUN_AFTER_EXE//\//\\}"
|
||||
fi
|
||||
fi
|
||||
run_after_exe_win="${run_after_exe_win//\//\\}"
|
||||
|
||||
cat > "${run_after_bat}" <<EOF
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
start "" "${pw_exe_file_win}" ${LAUNCH_PARAMETERS}
|
||||
start "" "%PW_RUN_AFTER_MAIN_PATH%" ${LAUNCH_PARAMETERS}
|
||||
timeout /t ${wait_delay_int} /nobreak
|
||||
start "" /unix "${PW_RUN_AFTER_EXE}"
|
||||
start "" "%PW_RUN_AFTER_SECOND_PATH%"
|
||||
EOF
|
||||
|
||||
LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
local run_after_first_win="${run_after_exe_win}"
|
||||
for ((run_after_index=1; run_after_index<${#run_after_exes[@]}; run_after_index++)) ; do
|
||||
run_after_entry="${run_after_exes[run_after_index]}"
|
||||
run_after_exe_win="$("${WINELOADER}" winepath -w "${run_after_entry}" 2>/dev/null | tr -d '\r')"
|
||||
if [[ -z "${run_after_exe_win}" ]] ; then
|
||||
if [[ "${run_after_entry}" == "${wine_drive_c}/"* ]] ; then
|
||||
run_after_exe_win="c:\\${run_after_entry#"${wine_drive_c}/"}"
|
||||
else
|
||||
run_after_exe_win="z:${run_after_entry//\//\\}"
|
||||
fi
|
||||
fi
|
||||
run_after_environment+=("PW_RUN_AFTER_PATH_${run_after_index}=${run_after_exe_win//\//\\}")
|
||||
printf 'start "" "%%PW_RUN_AFTER_PATH_%s%%"\r\n' "${run_after_index}" >> "${run_after_bat}"
|
||||
done
|
||||
|
||||
(
|
||||
if [[ ${#run_after_environment[@]} -gt 0 ]] ; then
|
||||
export "${run_after_environment[@]}"
|
||||
fi
|
||||
PW_RUN_AFTER_EXE="" LAUNCH_PARAMETERS="" proxy_launch_parameters="" \
|
||||
PW_RUN_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_first_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
)
|
||||
try_remove_file "${run_after_bat}"
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
@@ -4399,7 +4489,9 @@ gui_edit_db () {
|
||||
|
||||
edit_db_from_gui "${PW_EDIT_DB_FINAL_LIST[@]}"
|
||||
|
||||
if [[ -z "$MANGOHUD_CONFIG" ]] ; then
|
||||
if [[ "${PW_MANGOHUD}" == "1" ]] \
|
||||
&& [[ "${PW_MANGOHUD_USER_CONF}" != "1" ]] \
|
||||
&& [[ -z "$MANGOHUD_CONFIG" ]] ; then
|
||||
MONITOR_HEIGHT="$(echo "$PW_SCREEN_RESOLUTION" | awk -F'x' '{print $2}')"
|
||||
MH_FONT_SIZE="font_size=$(( MONITOR_HEIGHT / 45 ))"
|
||||
|
||||
@@ -4943,7 +5035,7 @@ pw_create_prefix_backup_cli () {
|
||||
PW_PREFIX_NAME="$1"
|
||||
PW_PREFIX_TO_BACKUP="$2"
|
||||
|
||||
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
backup "${PW_PREFIX_NAME}" "${PW_PREFIX_TO_BACKUP}"
|
||||
return $?
|
||||
}
|
||||
@@ -4997,7 +5089,7 @@ pw_unpack_prefix () {
|
||||
return $?
|
||||
fi
|
||||
|
||||
python3 -m portprotonqt.scripts_utils.prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
python_module prefix_backup --port-data-path "${PORT_DATA_PATH}" \
|
||||
restore "${PW_BACKUP_FILE}"
|
||||
return $?
|
||||
}
|
||||
@@ -222,8 +222,8 @@ fi
|
||||
print_info "The first mirror in used: $MIRROR"
|
||||
|
||||
if check_gamescope_session
|
||||
then PW_TERM="env LANG=C python3 -m portprotonqt.scripts_utils.easyterm --fullscreen -e"
|
||||
else PW_TERM="env LANG=C python3 -m portprotonqt.scripts_utils.easyterm -e"
|
||||
then PW_TERM="env LANG=C ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm --fullscreen -e"
|
||||
else PW_TERM="env LANG=C ${PORTPROTONQT_PYTHON:-python3} -m portprotonqt.scripts_utils.easyterm -e"
|
||||
fi
|
||||
|
||||
pw_cleanup () {
|
||||
|
||||
@@ -62,7 +62,7 @@ check_variables PW_USE_GALLIUM_NINE "0"
|
||||
check_variables PW_USE_GALLIUM_ZINK "0"
|
||||
check_variables PW_USE_WINED3D_VULKAN "0"
|
||||
check_variables PW_USE_SUPPLIED_DXVK_VKD3D "1"
|
||||
check_variables PW_WINE_FULLSCREEN_FSR "1"
|
||||
check_variables PW_WINE_FULLSCREEN_FSR "0"
|
||||
check_variables WINE_FULLSCREEN_FSR_MODE "0"
|
||||
|
||||
###DXVK_AND_VKD3D###
|
||||
|
||||
@@ -421,7 +421,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "call of duty black ops cold war",
|
||||
"status": "Broken",
|
||||
"status": "Running",
|
||||
"slug": "call-of-duty-black-ops-cold-war"
|
||||
},
|
||||
{
|
||||
@@ -1286,7 +1286,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "nikke goddess of victory",
|
||||
"status": "Broken",
|
||||
"status": "Running",
|
||||
"slug": "nikke"
|
||||
},
|
||||
{
|
||||
@@ -1899,6 +1899,11 @@
|
||||
"status": "Broken",
|
||||
"slug": "tekken-7-for-esl"
|
||||
},
|
||||
{
|
||||
"normalized_name": "rocket league",
|
||||
"status": "Supported",
|
||||
"slug": "rocket-league"
|
||||
},
|
||||
{
|
||||
"normalized_name": "rocket league (faceit)",
|
||||
"status": "Broken",
|
||||
@@ -3341,7 +3346,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "the crew motorfest",
|
||||
"status": "Broken",
|
||||
"status": "Supported",
|
||||
"slug": "the-crew-motorfest"
|
||||
},
|
||||
{
|
||||
@@ -3776,7 +3781,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "exoborne",
|
||||
"status": "Broken",
|
||||
"status": "Denied",
|
||||
"slug": "exoborne"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
portprotonqt (1.5.0) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Wed, 14 Oct 2026 21:30:22 +0500
|
||||
|
||||
portprotonqt (1.4.1) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
@@ -18,8 +18,8 @@ _portprotonqt_completions() {
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
# All available options
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help -h"
|
||||
long_opts="--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
|
||||
# Values for options with arguments
|
||||
local debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
@@ -73,6 +73,14 @@ _portprotonqt() {
|
||||
'--add-steam-compat-tool[Add as Steam compatibility tool]'
|
||||
'--reinstall-steam-compat-tool[Reinstall Steam compatibility tool]'
|
||||
'--remove-steam-compat-tool[Remove Steam compatibility tool]'
|
||||
'--list-games[Print local game libraries as JSON]'
|
||||
'--game-info[Print one game as JSON]:ID:'
|
||||
'--edit-game[Edit game settings]:ID:'
|
||||
'*--set[Set a game setting]:KEY=VALUE:'
|
||||
'--all[Include uninstalled games in the library list]'
|
||||
'--fields[Select JSON fields]:FIELD,...:'
|
||||
'--pretty[Format JSON with indentation]'
|
||||
'--raw[Print compact JSON]'
|
||||
'--clear-cache[Clear PortProtonQt cache and exit]'
|
||||
'--reset-settings[Reset PortProtonQt settings and exit]'
|
||||
'--ppqtos[Show the system tab]'
|
||||
@@ -94,13 +102,21 @@ cat > "$OUTPUT_DIR/portprotonqt.fish" << 'EOF'
|
||||
# Fish completion for portprotonqt
|
||||
|
||||
complete -c portprotonqt -f
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -n "test -z (commandline -ct)" -a "--fullscreen --resolution --debug-level --add-steam-compat-tool --reinstall-steam-compat-tool --remove-steam-compat-tool --list-games --all --game-info --edit-game --set --fields --pretty --raw --clear-cache --reset-settings --ppqtos --silent --log --restore-prefix --create-backup --help"
|
||||
complete -c portprotonqt -l fullscreen -d "Launch in fullscreen mode"
|
||||
complete -c portprotonqt -l resolution -d "Launch with specific resolution" -r -f -a "1920x1080 1280x720 2560x1440 3840x2160"
|
||||
complete -c portprotonqt -l debug-level -d "Set logging level" -r -f -a "ALL DEBUG INFO WARNING ERROR CRITICAL"
|
||||
complete -c portprotonqt -l add-steam-compat-tool -d "Add as Steam compatibility tool"
|
||||
complete -c portprotonqt -l reinstall-steam-compat-tool -d "Reinstall Steam compatibility tool"
|
||||
complete -c portprotonqt -l remove-steam-compat-tool -d "Remove Steam compatibility tool"
|
||||
complete -c portprotonqt -l list-games -d "Print local game libraries as JSON"
|
||||
complete -c portprotonqt -l game-info -d "Print one game as JSON" -r -f
|
||||
complete -c portprotonqt -l edit-game -d "Edit game settings" -r -f
|
||||
complete -c portprotonqt -l set -d "Set a game setting (KEY=VALUE)" -r -f
|
||||
complete -c portprotonqt -l all -d "Include uninstalled games in the library list"
|
||||
complete -c portprotonqt -l fields -d "Select JSON fields (comma-separated)" -r -f
|
||||
complete -c portprotonqt -l pretty -d "Format JSON with indentation"
|
||||
complete -c portprotonqt -l raw -d "Print compact JSON"
|
||||
complete -c portprotonqt -l clear-cache -d "Clear PortProtonQt cache and exit"
|
||||
complete -c portprotonqt -l reset-settings -d "Reset PortProtonQt settings and exit"
|
||||
complete -c portprotonqt -l ppqtos -d "Show the system tab"
|
||||
|
||||
@@ -958,6 +958,20 @@ When no `ICON_COLORS` entry matches an `AutoSizeButton` state, the button falls
|
||||
|
||||
---
|
||||
|
||||
## Boot animation (optional)
|
||||
|
||||
Place `bootanimation.webm` beside the theme's `styles.py`; missing videos are
|
||||
inherited from parent themes. It plays with audio inside the main window only
|
||||
in fullscreen, including the automatic transition when a gamepad is connected.
|
||||
Minimized and windowed launches skip it, including windowed ppqtos.
|
||||
The “Disable Boot Animation” checkbox in settings disables playback.
|
||||
A key press, mouse click, or gamepad button skips playback. `bootAnimationTimeoutMs` defaults to
|
||||
30000; `color_boot_animation` sets the background and letterbox color.
|
||||
|
||||
Video specifications: WebM container, VP9 video, optional Opus audio;
|
||||
1920×1080 or 1280×800, 30 or 60 FPS. Recommended duration: 3–5 seconds;
|
||||
the default playback timeout is 30 seconds. Aspect ratio is preserved.
|
||||
|
||||
## Sound Effects (optional)
|
||||
|
||||
Place UI sound effects in the theme's `sounds/` directory. File names must match an event name; do not add numeric suffixes such as `_001`:
|
||||
|
||||
@@ -960,6 +960,21 @@ ICON_COLORS = {
|
||||
|
||||
---
|
||||
|
||||
## Загрузочная анимация (опционально)
|
||||
|
||||
Положите `bootanimation.webm` рядом со `styles.py` темы. При отсутствии файла
|
||||
используется видео родительской темы. Ролик со звуком показывается внутри
|
||||
основного окна только в fullscreen, включая автоматический переход при
|
||||
подключении геймпада. Оконный и свёрнутый запуск пропускают видео, в том числе
|
||||
оконный ppqtos. Клавиша, щелчок мыши или кнопка геймпада пропускают ролик.
|
||||
`bootAnimationTimeoutMs` по умолчанию равен 30000; `color_boot_animation`
|
||||
задаёт цвет фона и свободных краёв.
|
||||
Галочка «Disable Boot Animation» в настройках отключает воспроизведение.
|
||||
|
||||
Требования к видео: контейнер WebM, видеокодек VP9, звук Opus при необходимости;
|
||||
1920×1080 или 1280×800, 30 или 60 FPS. Рекомендуемая длительность — 3–5 секунд;
|
||||
по умолчанию воспроизведение ограничено 30 секундами. Пропорции сохраняются.
|
||||
|
||||
## Звуковые эффекты (опционально)
|
||||
|
||||
Поместите звуковые эффекты интерфейса в директорию `sounds/` темы. Имя файла должно совпадать с именем события; не добавляйте числовые суффиксы наподобие `_001`:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
project('portprotonqt', 'c',
|
||||
version: '1.4.1',
|
||||
version: '1.5.0',
|
||||
meson_version: '>= 0.61.2',
|
||||
license: 'GPL-3.0',
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ import warnings
|
||||
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -167,7 +168,8 @@ class GameCardAnimations:
|
||||
"""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 isinstance(QApplication.focusWidget(), QLineEdit):
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
@@ -2,23 +2,25 @@ import argparse
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import shlex
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from urllib.parse import quote, unquote
|
||||
from logging import Logger
|
||||
|
||||
import psutil
|
||||
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSystemTrayIcon,
|
||||
)
|
||||
from PySide6.QtGui import QAction, QIcon
|
||||
from PySide6.QtGui import QIcon
|
||||
|
||||
from portprotonqt.config import (
|
||||
consume_download_counter_skip,
|
||||
@@ -52,7 +54,7 @@ from portprotonqt.localization import _, get_steam_language
|
||||
APP_VERSION = "@APP_VERSION@"
|
||||
APP_COMMIT = "@APP_COMMIT@"
|
||||
if APP_VERSION == "@APP" "_VERSION@":
|
||||
APP_VERSION = "1.4.1"
|
||||
APP_VERSION = "1.5.0"
|
||||
try:
|
||||
APP_COMMIT = subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
@@ -63,6 +65,8 @@ if APP_VERSION == "@APP" "_VERSION@":
|
||||
|
||||
__app_version__ = APP_VERSION
|
||||
|
||||
RESTART_STOP_TIMEOUT_SECONDS = 30
|
||||
|
||||
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
|
||||
|
||||
def get_version():
|
||||
@@ -79,70 +83,6 @@ def get_version():
|
||||
return __app_version__
|
||||
|
||||
|
||||
def stop_portproton_game(start_sh: list[str], logger: Logger) -> None:
|
||||
try:
|
||||
subprocess.run(
|
||||
start_sh + ["cli", "--stop"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as e:
|
||||
logger.warning("Failed to execute PortProton stop command: %s", e)
|
||||
|
||||
|
||||
def get_portproton_tray_icon() -> QIcon:
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
icon = ThemeManager().get_icon("tray_portproton", ui_config.get_theme())
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
return QIcon()
|
||||
|
||||
|
||||
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
|
||||
"""Launch a game with a minimal tray stop action."""
|
||||
logger = get_logger(__name__)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
|
||||
tray_icon.setToolTip(__app_name__)
|
||||
|
||||
from datetime import datetime
|
||||
from portprotonqt.time_utils import save_last_launch, save_playtime
|
||||
save_last_launch(os.path.splitext(os.path.basename(exe_path))[0], datetime.now())
|
||||
start_time = datetime.now()
|
||||
|
||||
process = subprocess.Popen(start_sh + [exe_path], env=os.environ.copy(), shell=False)
|
||||
tray_menu = QMenu()
|
||||
|
||||
def end_silent_run():
|
||||
monitor_timer.stop()
|
||||
elapsed = int((datetime.now() - start_time).total_seconds())
|
||||
if elapsed > 0:
|
||||
save_playtime(exe_path, elapsed)
|
||||
tray_icon.hide()
|
||||
app.quit()
|
||||
|
||||
def stop_game() -> None:
|
||||
stop_portproton_game(start_sh, logger)
|
||||
end_silent_run()
|
||||
|
||||
def close_when_game_exits(_tray_menu: QMenu = tray_menu) -> None:
|
||||
if process.poll() is not None:
|
||||
end_silent_run()
|
||||
|
||||
stop_action = QAction(_("Stop Game"), tray_menu)
|
||||
stop_action.triggered.connect(stop_game)
|
||||
tray_menu.addAction(stop_action)
|
||||
tray_icon.setContextMenu(tray_menu)
|
||||
tray_icon.show()
|
||||
|
||||
monitor_timer = QTimer(app)
|
||||
monitor_timer.timeout.connect(close_when_game_exits)
|
||||
monitor_timer.start(1000)
|
||||
|
||||
|
||||
def restore_prefix_backup(start_sh: list[str], backup_path: str) -> int:
|
||||
"""Restore a PortProton prefix backup."""
|
||||
logger = get_logger(__name__)
|
||||
@@ -200,6 +140,39 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
if parsed_args.list_games or parsed_args.game_info or parsed_args.edit_game:
|
||||
import orjson
|
||||
|
||||
from portprotonqt.cli import edit_game, list_games
|
||||
|
||||
try:
|
||||
if parsed_args.edit_game:
|
||||
result = edit_game(parsed_args.edit_game, parsed_args.settings)
|
||||
elif parsed_args.game_info:
|
||||
games = list_games()
|
||||
result = next((game for game in games if game["id"] == parsed_args.game_info), None)
|
||||
if result is None:
|
||||
get_logger(__name__).error("Game not found: %s", parsed_args.game_info)
|
||||
return 1
|
||||
else:
|
||||
result = [game for game in list_games() if parsed_args.all_games or game.get("installed", False)]
|
||||
if parsed_args.fields:
|
||||
entries = result if isinstance(result, list) else [result]
|
||||
selected = [{field: game.get(field) for field in parsed_args.fields} for game in entries]
|
||||
result = selected if isinstance(result, list) else selected[0]
|
||||
option = orjson.OPT_INDENT_2 if parsed_args.pretty else 0
|
||||
sys.stdout.write(orjson.dumps(result, option=option).decode("utf-8") + "\n")
|
||||
except OSError as error:
|
||||
get_logger(__name__).error("Library command failed: %s", error)
|
||||
return 1
|
||||
except ValueError as error:
|
||||
get_logger(__name__).error("Invalid library command: %s", error)
|
||||
return 1
|
||||
except RuntimeError as error:
|
||||
get_logger(__name__).error("Failed to edit game settings: %s", error)
|
||||
return 1
|
||||
return 0
|
||||
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
from portprotonqt.appimage_integration import integrate_appimage
|
||||
@@ -356,7 +329,7 @@ def main():
|
||||
if theme_store_id is not None:
|
||||
ipc_message = f"theme:{theme_store_id}"
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtCore import QLockFile, QStandardPaths, QThread, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from requests import RequestException
|
||||
from portprotonqt.appimage_updater import (
|
||||
@@ -382,7 +355,10 @@ def main():
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
|
||||
# --- Single-instance logic ---
|
||||
server_name = __app_id__
|
||||
runtime_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.RuntimeLocation)
|
||||
server_name = os.path.join(runtime_dir, __app_id__)
|
||||
instance_lock = QLockFile(server_name + ".lock")
|
||||
owns_instance = instance_lock.tryLock(0)
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
@@ -395,7 +371,11 @@ def main():
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Remove old socket if it exists
|
||||
if not owns_instance:
|
||||
logger.warning("PortProtonQt is already running but its IPC server is unavailable")
|
||||
return
|
||||
|
||||
# Only the lock owner can remove a stale socket.
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
@@ -511,10 +491,7 @@ def main():
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
if exe_path:
|
||||
window.tray_manager.tray_icon.hide()
|
||||
else:
|
||||
window.tray_manager.set_minimal_mode()
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = f"{source}://launch/{app_id}"
|
||||
@@ -539,11 +516,32 @@ def main():
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
loader(open_card)
|
||||
|
||||
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
|
||||
restart_target = None
|
||||
|
||||
def handle_game_request(
|
||||
target: str, silent: bool, log_mode: bool = False, confirm: bool = False,
|
||||
) -> None:
|
||||
nonlocal restart_target
|
||||
if restart_target is not None:
|
||||
return
|
||||
if silent and (confirm or window.game_processes):
|
||||
message_box = QMessageBox(window)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(__app_name__)
|
||||
message_box.setText(_("PortProtonQt is already running. Stop it and all games, then restart for the new launch?"))
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
restart_target = target
|
||||
app.quit()
|
||||
return
|
||||
if is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
run_silent_tray(app, start_sh, path)
|
||||
window.toggleGame(shlex.quote(path))
|
||||
return
|
||||
window.handle_launch_exe(path, log_mode=log_mode)
|
||||
return
|
||||
@@ -619,16 +617,16 @@ def main():
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
display_config.set_fullscreen(True)
|
||||
window.showFullScreen()
|
||||
window.input_manager.handle_fullscreen_slot(True)
|
||||
else:
|
||||
if msg.startswith("show"):
|
||||
logger.info("Switching to normal window via IPC")
|
||||
display_config.set_fullscreen(False)
|
||||
window.showNormal()
|
||||
window.input_manager.handle_fullscreen_slot(False)
|
||||
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True)
|
||||
handle_game_request(target, True, confirm=True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
@@ -723,7 +721,7 @@ def main():
|
||||
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
|
||||
)
|
||||
display_config.set_fullscreen(True)
|
||||
window.showFullScreen()
|
||||
window.input_manager.handle_fullscreen_slot(True)
|
||||
elif launch_auto_fullscreen:
|
||||
logger.info("Launching in fullscreen mode (gamepad)")
|
||||
window.input_manager.handle_fullscreen_slot(True)
|
||||
@@ -770,13 +768,14 @@ def main():
|
||||
return
|
||||
QMessageBox.warning(window, _("Error"), _("Failed to update AppImage."))
|
||||
|
||||
window.appimageUpdateWorker = AppImageUpdateWorker("check")
|
||||
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
|
||||
window.appimageUpdateWorker.finished.connect(
|
||||
lambda: setattr(window, "appimageUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
if not is_silent_launch:
|
||||
window.appimageUpdateWorker = AppImageUpdateWorker("check")
|
||||
window.appimageUpdateWorker.update_available.connect(show_appimage_update_prompt)
|
||||
window.appimageUpdateWorker.finished.connect(
|
||||
lambda: setattr(window, "appimageUpdateWorker", None)
|
||||
)
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
|
||||
class GOGDLUpdateWorker(QThread):
|
||||
"""Update gogdl without blocking the UI thread."""
|
||||
@@ -863,14 +862,15 @@ def main():
|
||||
finally:
|
||||
self.finished.emit()
|
||||
|
||||
if start_sh:
|
||||
# Initial setup can stop Wine and must not race a silent game launch.
|
||||
if start_sh and not silent_game_request:
|
||||
worker = InitialCommandWorker(start_sh)
|
||||
window.initialCommandWorker = worker
|
||||
worker.finished.connect(
|
||||
lambda: setattr(window, "initialCommandWorker", None)
|
||||
)
|
||||
worker.start()
|
||||
else:
|
||||
elif not start_sh:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
# --- Cleanup ---
|
||||
@@ -885,7 +885,44 @@ def main():
|
||||
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
sys.exit(app.exec())
|
||||
exit_code = app.exec()
|
||||
if restart_target is not None:
|
||||
if window.game_processes:
|
||||
window._terminate_game_processes()
|
||||
window.resetPlayButton()
|
||||
window._stopBackgroundWorkers()
|
||||
try:
|
||||
children = psutil.Process().children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(children, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
for child in alive:
|
||||
try:
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
continue
|
||||
alive = psutil.wait_procs(alive, timeout=RESTART_STOP_TIMEOUT_SECONDS)[1]
|
||||
if alive:
|
||||
logger.error("Cannot restart while child processes are still running")
|
||||
sys.exit(1)
|
||||
if start_sh:
|
||||
subprocess.run(start_sh + ["cli", "--stop"], check=True,
|
||||
timeout=RESTART_STOP_TIMEOUT_SECONDS)
|
||||
instance_lock.unlock()
|
||||
os.execv(sys.executable, [sys.executable, "-m", "portprotonqt.app", "--silent", restart_target])
|
||||
except OSError as error:
|
||||
logger.error("Failed to restart PortProtonQt: %s", error)
|
||||
exit_code = 1
|
||||
except psutil.Error as error:
|
||||
logger.error("Failed to stop PortProtonQt child processes: %s", error)
|
||||
exit_code = 1
|
||||
except subprocess.SubprocessError as error:
|
||||
logger.error("Failed to stop PortProton before restart: %s", error)
|
||||
exit_code = 1
|
||||
sys.exit(exit_code)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Theme boot video rendered inside the application's startup page."""
|
||||
import os
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QPoint, QRect, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor, QImage, QPainter, QPaintEvent
|
||||
from PySide6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import _find_theme_folder, _read_theme_parent_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtMultimedia import QVideoFrame
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def find_boot_video(theme_name: str) -> str | None:
|
||||
"""Find the theme's WebM, following its existing inheritance chain."""
|
||||
visited = set()
|
||||
while theme_name and theme_name not in visited:
|
||||
visited.add(theme_name)
|
||||
folder = _find_theme_folder(theme_name)
|
||||
if folder is not None:
|
||||
path = os.path.join(folder, "bootanimation.webm")
|
||||
if os.path.isfile(path):
|
||||
if os.path.dirname(os.path.realpath(path)) == os.path.realpath(folder):
|
||||
return path
|
||||
logger.warning("Boot video outside theme directory blocked: %s", path)
|
||||
return None
|
||||
theme_name = _read_theme_parent_name(theme_name) or ""
|
||||
return None
|
||||
|
||||
|
||||
class BootAnimation(QWidget):
|
||||
finished = Signal()
|
||||
|
||||
def __init__(self, theme: Any, video_path: str, parent: QWidget) -> None:
|
||||
from PySide6.QtMultimedia import QAudioOutput, QMediaPlayer, QVideoSink
|
||||
|
||||
super().__init__(parent)
|
||||
self._background = QColor(theme.color_boot_animation)
|
||||
self._video_path = video_path
|
||||
self._frame = QImage()
|
||||
self._stopped = False
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.sink = QVideoSink(self)
|
||||
self.sink.videoFrameChanged.connect(self._receive_frame)
|
||||
self.audio = QAudioOutput(self)
|
||||
self.player = QMediaPlayer(self)
|
||||
self.player.setAudioOutput(self.audio)
|
||||
self.player.setVideoSink(self.sink)
|
||||
self.player.mediaStatusChanged.connect(self._media_status_changed)
|
||||
self.player.errorOccurred.connect(self._playback_error)
|
||||
self.timeout = QTimer(self)
|
||||
self.timeout.setSingleShot(True)
|
||||
self.timeout.setInterval(theme.bootAnimationTimeoutMs)
|
||||
self.timeout.timeout.connect(self.finish)
|
||||
|
||||
def start(self) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.installEventFilter(self)
|
||||
self.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
self.timeout.start()
|
||||
self.player.setSource(QUrl.fromLocalFile(self._video_path))
|
||||
if not self._stopped:
|
||||
self.player.play()
|
||||
|
||||
def _receive_frame(self, frame: "QVideoFrame") -> None:
|
||||
if self._stopped or not frame.isValid():
|
||||
return
|
||||
image = frame.toImage()
|
||||
if not image.isNull():
|
||||
self._frame = image
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.fillRect(self.rect(), self._background)
|
||||
if not self._frame.isNull():
|
||||
size = self._frame.size().scaled(self.size(), Qt.AspectRatioMode.KeepAspectRatio)
|
||||
target = QRect(QPoint(), size)
|
||||
target.moveCenter(self.rect().center())
|
||||
painter.drawImage(target, self._frame)
|
||||
|
||||
def _media_status_changed(self, status: object) -> None:
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
|
||||
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
||||
self.finish()
|
||||
|
||||
def _playback_error(self, error: object, message: str) -> None:
|
||||
logger.warning("Boot video playback failed: %s", message)
|
||||
self.finish()
|
||||
|
||||
def finish(self) -> None:
|
||||
if not self._stopped:
|
||||
self.stop()
|
||||
self.finished.emit()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._stopped:
|
||||
return
|
||||
self._stopped = True
|
||||
self.timeout.stop()
|
||||
self.player.stop()
|
||||
self.sink.videoFrameChanged.disconnect(self._receive_frame)
|
||||
self._frame = QImage()
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.removeEventFilter(self)
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
if watched is self and event.type() in (QEvent.Type.KeyPress, QEvent.Type.MouseButtonPress):
|
||||
self.finish()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
@@ -2,6 +2,7 @@ import argparse
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -17,6 +18,27 @@ def parse_args():
|
||||
Parses command-line arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
|
||||
library = parser.add_mutually_exclusive_group()
|
||||
library.add_argument(
|
||||
"--list-games", action="store_true",
|
||||
help="Print local PortProton, Steam, GOG and EGS library data as JSON and exit"
|
||||
)
|
||||
library.add_argument(
|
||||
"--game-info", metavar="ID",
|
||||
help="Print one game as JSON using its source-qualified ID from --list-games"
|
||||
)
|
||||
library.add_argument("--edit-game", metavar="ID", help="Edit a game's PortProton settings and print its updated data")
|
||||
parser.add_argument("--set", dest="settings", action="append", default=[], metavar="KEY=VALUE",
|
||||
help="Set a game setting; repeat to change multiple values with --edit-game")
|
||||
parser.add_argument("--all", dest="all_games", action="store_true",
|
||||
help="Include uninstalled games from cached libraries in --list-games")
|
||||
parser.add_argument("--fields", metavar="FIELD,...",
|
||||
help="Select JSON fields for --list-games or --game-info (e.g. executable,icon,name)")
|
||||
formatting = parser.add_mutually_exclusive_group()
|
||||
formatting.add_argument("--pretty", dest="pretty", action="store_true", default=True,
|
||||
help="Format JSON with indentation (default)")
|
||||
formatting.add_argument("--raw", dest="pretty", action="store_false",
|
||||
help="Print compact JSON without indentation")
|
||||
parser.add_argument(
|
||||
"--fullscreen",
|
||||
action="store_true",
|
||||
@@ -106,10 +128,276 @@ def parse_args():
|
||||
args = parser.parse_args()
|
||||
launch_args = []
|
||||
|
||||
if args.settings and not args.edit_game:
|
||||
parser.error("--set requires --edit-game")
|
||||
if args.edit_game and not args.settings:
|
||||
parser.error("--edit-game requires at least one --set KEY=VALUE")
|
||||
if args.all_games and not args.list_games:
|
||||
parser.error("--all requires --list-games")
|
||||
if args.fields is not None:
|
||||
if not (args.list_games or args.game_info):
|
||||
parser.error("--fields requires --list-games or --game-info")
|
||||
args.fields = [field.strip() for field in args.fields.split(",")]
|
||||
if any(not re.fullmatch(r"[a-z][a-z_0-9]*", field) for field in args.fields):
|
||||
parser.error("--fields requires comma-separated field names without empty entries")
|
||||
args.launch_args = launch_args
|
||||
return args
|
||||
|
||||
|
||||
def _read_game_settings(path: Path) -> dict[str, str]:
|
||||
"""Read literal shell assignments without executing configuration code."""
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
settings: dict[str, str] = {}
|
||||
if not path.is_file():
|
||||
return settings
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
get_logger(__name__).warning("Failed to read %s: %s", path, error)
|
||||
return settings
|
||||
for line in content.splitlines():
|
||||
match = re.fullmatch(r"(?:export\s+)?([A-Za-z_][A-Za-z_0-9]*)=(.*)", line)
|
||||
if not match:
|
||||
match = re.fullmatch(r"check_variables ([A-Za-z_][A-Za-z_0-9]*) (.*)", line)
|
||||
if not match:
|
||||
continue
|
||||
value = match[2]
|
||||
if any(token in value for token in ("$", "`")):
|
||||
continue
|
||||
try:
|
||||
lexer = shlex.shlex(value, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
parts = list(lexer)
|
||||
except ValueError:
|
||||
continue
|
||||
if len(parts) <= 1:
|
||||
settings[match[1]] = parts[0] if parts else ""
|
||||
return settings
|
||||
|
||||
|
||||
def _get_game_metadata(name: str, exec_line: str, executable: str | None) -> dict:
|
||||
"""Read custom artwork and cached metadata without fetching remote resources."""
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.steam_api.api import get_cached_steam_game_info
|
||||
from portprotonqt.time_utils import (
|
||||
get_last_launch_timestamp, get_playtime_for_exe, get_statistics_path,
|
||||
)
|
||||
|
||||
metadata = get_cached_steam_game_info(name, exec_line)
|
||||
metadata.setdefault("name", name)
|
||||
metadata.setdefault("description", "")
|
||||
metadata.setdefault("cover", "")
|
||||
if not executable:
|
||||
return metadata
|
||||
data_home = Path(os.environ.get("XDG_DATA_HOME", str(Path.home() / ".local/share")))
|
||||
folder = Path(resolve_custom_data_dir(str(data_home / "PortProtonQt/custom_data"), executable))
|
||||
metadata["custom_data_directory"] = str(folder)
|
||||
metadata_file = folder / "metadata.txt"
|
||||
if metadata_file.is_file():
|
||||
translations = read_metadata_translations(str(metadata_file), get_metadata_language())
|
||||
metadata.update({key: value for key, value in translations.items() if value})
|
||||
for extension in COVER_IMAGE_EXTENSIONS:
|
||||
cover = folder / f"cover{extension}"
|
||||
if cover.is_file():
|
||||
metadata["cover"] = str(cover)
|
||||
break
|
||||
metadata["custom_icon"] = get_custom_icon_path(executable)
|
||||
metadata["playtime_seconds"] = get_playtime_for_exe(get_statistics_path(), executable)
|
||||
metadata["last_launch_timestamp"] = get_last_launch_timestamp(Path(executable).stem)
|
||||
return metadata
|
||||
|
||||
|
||||
def _get_game_runtime(
|
||||
location: Path, executable: str | None, variables: dict[str, str], overrides: dict[str, str]
|
||||
) -> dict:
|
||||
"""Export configured runtime values; never source PPDB files or launch Wine."""
|
||||
user_conf = location / "data/user.conf"
|
||||
ppdb = Path(f"{executable}.ppdb") if executable else None
|
||||
settings = _read_game_settings(ppdb) if ppdb else {}
|
||||
runner = settings.get("PW_WINE_USE", overrides.get("PW_DEFAULT_WINE_USE", variables.get("PW_WINE_USE")))
|
||||
prefix = settings.get("PW_PREFIX_NAME", overrides.get("PW_DEFAULT_PREFIX_NAME", variables.get("PW_PREFIX_NAME")))
|
||||
settings.update(overrides)
|
||||
runner = overrides.get("PW_WINE_VER") or overrides.get("PW_WINE_USE", runner)
|
||||
prefix = overrides.get("PW_PREFIX_NAME", prefix)
|
||||
resolved_runner = runner
|
||||
if runner == "PROTON_LG":
|
||||
resolved_runner = variables.get("PW_PROTON_LG_VER")
|
||||
elif runner and (runner == "WINE_LG" or runner.startswith("WINE_") and runner.endswith("_LG")):
|
||||
resolved_runner = variables.get("PW_WINE_LG_VER")
|
||||
runner_path = None
|
||||
if resolved_runner and resolved_runner != "USE_SYSTEM_WINE":
|
||||
runner_path = Path(resolved_runner) if Path(resolved_runner).is_absolute() else (
|
||||
location / "data/dist" / resolved_runner.upper()
|
||||
)
|
||||
elif resolved_runner == "USE_SYSTEM_WINE":
|
||||
wine = shutil.which("wine")
|
||||
runner_path = Path(wine).parent.parent if wine else None
|
||||
wine_directory = runner_path
|
||||
if wine_directory and (wine_directory / "files").is_dir() and not (wine_directory / "dist").is_dir():
|
||||
wine_directory /= "files"
|
||||
wine_bin = "bin-wow64" if settings.get("PW_USE_WOW64") == "1" and wine_directory and (
|
||||
wine_directory / "bin-wow64"
|
||||
).is_dir() else "bin"
|
||||
prefix_path = location / "data/prefixes" / prefix if prefix else None
|
||||
return {
|
||||
"wine_path": str(wine_directory / wine_bin / "wine") if wine_directory else None,
|
||||
"wineserver_path": str(wine_directory / wine_bin / "wineserver") if wine_directory else None,
|
||||
"runner": resolved_runner,
|
||||
"runner_path": str(runner_path) if runner_path else None,
|
||||
"runner_installed": runner_path.is_dir() if runner_path else False,
|
||||
"prefix": prefix, "prefix_path": str(prefix_path) if prefix_path else None,
|
||||
"settings": settings, "user_config": str(user_conf),
|
||||
"ppdb_exists": ppdb.is_file() if ppdb else False,
|
||||
}
|
||||
|
||||
|
||||
def _list_store_games(source: str, location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Export a cached GOG or Epic library using the existing store backends."""
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
api = GOGAPI() if source == "gog" else EGSAPI()
|
||||
installed = api.load_installed()
|
||||
games = []
|
||||
for record in api.load_library():
|
||||
app_id = str(record.get("app_id", ""))
|
||||
if not app_id:
|
||||
continue
|
||||
is_installed = api.is_game_installed(app_id, installed)
|
||||
target = api.get_launch_target(app_id) if is_installed else None
|
||||
name, cover = MainWindow._get_custom_store_data(source, app_id, str(record.get("title", app_id)))
|
||||
metadata = _get_game_metadata(name, target, target) if target else {}
|
||||
metadata.pop("name", None)
|
||||
icon = metadata.pop("custom_icon", "") or record.get("icon", "")
|
||||
games.append({
|
||||
**{key: value for key, value in record.items() if key not in {"app_id", "title"}},
|
||||
**metadata,
|
||||
"id": f"{source}:{app_id}", "source": source, "name": name,
|
||||
"description": record.get("description") or metadata.get("description", ""),
|
||||
"cover": cover or record.get("cover") or metadata.get("cover", ""),
|
||||
"icon": icon, "installed": is_installed,
|
||||
"exec": f"{source}://{'launch' if is_installed else 'install'}/{app_id}",
|
||||
"executable": target, "directory": str(Path(target).parent) if target else None,
|
||||
"runtime": _get_game_runtime(Path(location), target, variables, overrides) if location and target else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_steam_games() -> list[dict]:
|
||||
"""Export installed Steam games and their locally cached metadata."""
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import get_steam_installed_games
|
||||
from portprotonqt.steam_api.api import get_cached_full_steam_game_info
|
||||
|
||||
games = []
|
||||
for name, appid, last_played, playtime in get_steam_installed_games():
|
||||
metadata = get_cached_full_steam_game_info(appid, name)
|
||||
name, cover = MainWindow._get_custom_store_data("steam", appid, name)
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"steam:{appid}", "source": "steam", "name": name,
|
||||
"cover": cover or metadata.get("cover", ""),
|
||||
"installed": True, "exec": f"steam://rungameid/{appid}",
|
||||
"last_launch_timestamp": last_played, "playtime_seconds": playtime,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def _list_portproton_games(location: str | None, variables: dict, overrides: dict) -> list[dict]:
|
||||
"""Return local PortProton shortcuts without launching games or downloading metadata."""
|
||||
from portprotonqt.config.portproton import extract_exec_target_path, parse_desktop_entry
|
||||
|
||||
if not location:
|
||||
return []
|
||||
games = []
|
||||
for desktop_path in sorted(Path(location).glob("*.desktop")):
|
||||
entry = parse_desktop_entry(str(desktop_path))
|
||||
if entry is None:
|
||||
continue
|
||||
exec_line = entry.get("Exec", "")
|
||||
executable = extract_exec_target_path(exec_line)
|
||||
metadata = _get_game_metadata(entry.get("Name", desktop_path.stem), exec_line, executable)
|
||||
icon = metadata.pop("custom_icon", "") or entry.get("Icon", "")
|
||||
games.append({
|
||||
**metadata,
|
||||
"id": f"portproton:{desktop_path.name}", "source": "portproton",
|
||||
"installed": bool(executable and Path(executable).is_file()),
|
||||
"name": metadata.get("name") or entry.get("Name", desktop_path.stem),
|
||||
"runtime": _get_game_runtime(Path(location), executable, variables, overrides),
|
||||
"desktop_file": str(desktop_path),
|
||||
"exec": exec_line,
|
||||
"executable": executable,
|
||||
"directory": str(Path(executable).parent) if executable else None,
|
||||
"icon": icon,
|
||||
"ppdb": f"{executable}.ppdb" if executable else None,
|
||||
})
|
||||
return games
|
||||
|
||||
|
||||
def list_games() -> list[dict]:
|
||||
"""Return locally available library data from all supported game sources."""
|
||||
from portprotonqt.config.portproton import get_portproton_location, get_portproton_scripts_path
|
||||
|
||||
location = get_portproton_location()
|
||||
scripts = get_portproton_scripts_path()
|
||||
variables = _read_game_settings(Path(scripts) / "var") if scripts else {}
|
||||
overrides = _read_game_settings(Path(location) / "data/user.conf") if location else {}
|
||||
games = _list_portproton_games(location, variables, overrides)
|
||||
games.extend(_list_steam_games())
|
||||
for source in ("gog", "egs"):
|
||||
games.extend(_list_store_games(source, location, variables, overrides))
|
||||
return games
|
||||
|
||||
|
||||
def edit_game(game_id: str, changes: list[str]) -> dict:
|
||||
"""Update a game's PPDB through PortProton and verify the saved settings."""
|
||||
import subprocess
|
||||
|
||||
from portprotonqt.config.portproton import WINDOWS_LAUNCH_EXTENSIONS, get_portproton_start_command
|
||||
from portprotonqt.settings_manager import ADVANCED_SETTING_KEYS, get_toggle_settings
|
||||
|
||||
game = next((game for game in list_games() if game["id"] == game_id), None)
|
||||
if game is None:
|
||||
raise ValueError(f"Game not found: {game_id}")
|
||||
executable = game.get("executable")
|
||||
if (game.get("source") not in {"portproton", "gog", "egs"} or not executable
|
||||
or not Path(executable).is_file() or Path(executable).suffix.lower() not in WINDOWS_LAUNCH_EXTENSIONS):
|
||||
raise ValueError("Editing requires an installed Windows game managed by PortProton, GOG or EGS")
|
||||
toggles = set(get_toggle_settings()) | {"PW_MANGOHUD", "PW_VKBASALT", "PW_GAMESCOPE"}
|
||||
allowed = set(ADVANCED_SETTING_KEYS) | toggles | set((game.get("runtime") or {}).get("settings", {}))
|
||||
values: dict[str, str] = {}
|
||||
for change in changes:
|
||||
key, separator, value = change.partition("=")
|
||||
if not separator or key not in allowed or not re.fullmatch(r"[A-Z_][A-Z_0-9]*", key):
|
||||
raise ValueError(f"Unknown or invalid game setting: {key}")
|
||||
if any(character in value for character in ("$", "`", "|", "\n", "\r", "\0")):
|
||||
raise ValueError(f"Unsupported shell syntax in setting: {key}")
|
||||
if key in toggles and value not in {"0", "1"}:
|
||||
raise ValueError(f"{key} must be 0 or 1")
|
||||
if key == "PW_PREFIX_NAME" and (not value or value in {".", ".."} or "/" in value or "\\" in value):
|
||||
raise ValueError("PW_PREFIX_NAME must be a prefix name, not a path")
|
||||
values[key] = value
|
||||
if not values:
|
||||
raise ValueError("At least one setting is required")
|
||||
command = get_portproton_start_command()
|
||||
if not command:
|
||||
raise ValueError("PortProton start.sh was not found")
|
||||
result = subprocess.run(
|
||||
[*command, "cli", "--edit-db", executable, *[f"{key}={value}" for key, value in values.items()]],
|
||||
capture_output=True, text=True, errors="replace", check=False,
|
||||
)
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"PortProton failed to edit game settings: {result.stderr.strip()}")
|
||||
saved = _read_game_settings(Path(f"{executable}.ppdb"))
|
||||
if any(saved.get(key) != value for key, value in values.items()):
|
||||
raise RuntimeError("PortProton did not save the requested settings; check PPDB permissions")
|
||||
return next(game for game in list_games() if game["id"] == game_id)
|
||||
|
||||
|
||||
def add_steam_compat_tool(force_install: bool = False) -> bool:
|
||||
"""
|
||||
Add PortProtonQt as a Steam compatibility tool.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Cache configuration and management."""
|
||||
import json
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
@@ -118,7 +117,7 @@ class CacheManager:
|
||||
temp_file = cache_file.with_name(f".{cache_file.name}.{time.time_ns()}.tmp")
|
||||
try:
|
||||
if pretty:
|
||||
content = json.dumps(data, indent=2).encode("utf-8")
|
||||
content = orjson.dumps(data, option=orjson.OPT_INDENT_2)
|
||||
else:
|
||||
content = orjson.dumps(data)
|
||||
with _CACHE_FILE_LOCK:
|
||||
|
||||
@@ -33,6 +33,24 @@ class DisplayConfig(BaseConfig):
|
||||
validate_bool(auto, "auto_fullscreen_gamepad")
|
||||
self._save_value("auto_fullscreen_gamepad", auto, "bool")
|
||||
|
||||
def get_hide_cursor(self) -> bool:
|
||||
"""Return whether fullscreen cursor hiding is enabled."""
|
||||
return self._get_bool("hide_cursor", True)
|
||||
|
||||
def set_hide_cursor(self, enabled: bool) -> None:
|
||||
"""Enable or disable fullscreen cursor hiding."""
|
||||
validate_bool(enabled, "hide_cursor")
|
||||
self._save_value("hide_cursor", enabled, "bool")
|
||||
|
||||
def get_boot_animation_enabled(self) -> bool:
|
||||
"""Return whether fullscreen boot videos are enabled."""
|
||||
return self._get_bool("boot_animation_enabled", True)
|
||||
|
||||
def set_boot_animation_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable fullscreen boot videos."""
|
||||
validate_bool(enabled, "boot_animation_enabled")
|
||||
self._save_value("boot_animation_enabled", enabled, "bool")
|
||||
|
||||
def get_minimize_to_tray(self) -> bool:
|
||||
"""Get minimize-to-tray setting."""
|
||||
return self._get_bool("minimize_to_tray", False)
|
||||
|
||||
@@ -581,6 +581,14 @@ def resolve_custom_data_dir(custom_data_root: str, exe_path: str) -> str:
|
||||
return hashed_dir
|
||||
|
||||
|
||||
def get_custom_icon_path(exe_path: str) -> str:
|
||||
"""Return the existing custom icon for an executable."""
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
path = os.path.join(folder, "icon.png")
|
||||
return path if os.path.isfile(path) else ""
|
||||
|
||||
|
||||
def create_desktop_file(
|
||||
exe_path: str,
|
||||
game_name: str | None = None,
|
||||
@@ -606,6 +614,7 @@ def create_desktop_file(
|
||||
"applications-games" if is_store_uri and not has_icon_source
|
||||
else os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
)
|
||||
icon_path = get_custom_icon_path(exe_path) or icon_path
|
||||
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
|
||||
if icon_path != "applications-games":
|
||||
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
|
||||
|
||||
@@ -453,6 +453,15 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "download_wine_to_steam")
|
||||
self._save_download_value("download_wine_to_steam", enabled)
|
||||
|
||||
def get_disable_steam_proton_scanning(self) -> bool:
|
||||
"""Get Steam Proton scanning setting."""
|
||||
return self._get_bool("disable_steam_proton_scanning", False)
|
||||
|
||||
def set_disable_steam_proton_scanning(self, enabled: bool) -> None:
|
||||
"""Set Steam Proton scanning setting."""
|
||||
validate_bool(enabled, "disable_steam_proton_scanning")
|
||||
self._save_value("disable_steam_proton_scanning", enabled, "bool")
|
||||
|
||||
def get_disable_runtime_download(self) -> bool:
|
||||
"""Get PortProton runtime download setting."""
|
||||
default = bool(os.getenv("FLATPAK_ID"))
|
||||
@@ -510,6 +519,15 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "crash_reports_enabled")
|
||||
self._save_value("crash_reports_enabled", enabled, "bool")
|
||||
|
||||
def get_disable_cyrillic_path_check(self) -> bool:
|
||||
"""Get Cyrillic launch path check setting."""
|
||||
return self._get_bool("disable_cyrillic_path_check", False)
|
||||
|
||||
def set_disable_cyrillic_path_check(self, enabled: bool) -> None:
|
||||
"""Set Cyrillic launch path check setting."""
|
||||
validate_bool(enabled, "disable_cyrillic_path_check")
|
||||
self._save_value("disable_cyrillic_path_check", enabled, "bool")
|
||||
|
||||
def get_disable_alt_i586_dependency_check(self) -> bool:
|
||||
"""Get ALT i586 dependency check setting."""
|
||||
return self._get_bool("disable_alt_i586_dependency_check", False)
|
||||
|
||||
@@ -4,8 +4,8 @@ import shutil
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess, QThread
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence, QAction
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config import (
|
||||
create_desktop_file,
|
||||
@@ -68,6 +68,14 @@ class ContextMenuManager:
|
||||
self.theme = theme
|
||||
self.theme_manager = ThemeManager()
|
||||
self.game_library_manager = game_library_manager
|
||||
self._batch_action = False
|
||||
self.card_selection_mode = False
|
||||
self._card_actions: list[QAction] = []
|
||||
self._card_action_menus: list[QMenu] = []
|
||||
self._card_actions_stopping = False
|
||||
self._card_action_timer = QTimer(parent)
|
||||
self._card_action_timer.setSingleShot(True)
|
||||
self._card_action_timer.timeout.connect(self._run_next_card_action)
|
||||
self.update_game_grid = game_library_manager.update_game_grid
|
||||
self.signals = ContextMenuSignals()
|
||||
self.signals.show_warning_dialog.connect(
|
||||
@@ -355,8 +363,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -384,8 +392,8 @@ class ContextMenuManager:
|
||||
)
|
||||
msg_box.setIcon(QMessageBox.Icon.Warning)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
@@ -449,7 +457,92 @@ class ContextMenuManager:
|
||||
return self.theme_manager.get_colored_icon_path(icon_name, color)
|
||||
return None
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
def clear_card_selection(self) -> None:
|
||||
self.card_selection_mode = False
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if card.batch_selected:
|
||||
card.batch_selected = False
|
||||
card.update()
|
||||
|
||||
def start_card_selection(self, game_card: "GameCard") -> None:
|
||||
self.clear_card_selection()
|
||||
self.card_selection_mode = True
|
||||
game_card.batch_selected = True
|
||||
game_card.update()
|
||||
game_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _start_card_actions(self, actions: list[QAction], menus: list[QMenu]) -> None:
|
||||
if self._batch_action or self._card_actions_stopping:
|
||||
return
|
||||
self.clear_card_selection()
|
||||
self._batch_action = True
|
||||
self._card_actions = list(reversed(actions))
|
||||
self._card_action_menus = menus
|
||||
for menu in menus:
|
||||
menu.setParent(self.parent, menu.windowFlags())
|
||||
self._run_next_card_action()
|
||||
|
||||
def _run_next_card_action(self) -> None:
|
||||
if not self._batch_action:
|
||||
return
|
||||
jobs = [getattr(self.parent, name, None) for name in (
|
||||
"egs_process", "gog_process", "gog_repair_worker", "gog_delete_worker", "store_dlc_worker",
|
||||
)]
|
||||
jobs.extend(getattr(self.parent, "gog_support_workers", []))
|
||||
for job in jobs:
|
||||
if job is None or not isinstance(job, (QProcess, QThread)):
|
||||
continue
|
||||
job.finished.connect(lambda *args: self._card_action_timer.start() if not self._card_actions_stopping else None)
|
||||
if ((isinstance(job, QProcess) and job.state() == QProcess.ProcessState.NotRunning)
|
||||
or (isinstance(job, QThread) and not job.isRunning())):
|
||||
self._card_action_timer.start()
|
||||
return
|
||||
if self._card_actions_stopping or not self._card_actions:
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
self._card_actions = []
|
||||
return
|
||||
self._card_actions.pop().trigger()
|
||||
self._card_action_timer.start()
|
||||
|
||||
def stop_card_actions(self) -> None:
|
||||
self._card_actions_stopping = True
|
||||
self._card_action_timer.stop()
|
||||
self._card_actions = []
|
||||
self._batch_action = False
|
||||
for menu in self._card_action_menus:
|
||||
menu.deleteLater()
|
||||
self._card_action_menus = []
|
||||
|
||||
def _show_card_selection_menu(self, menu: QMenu, game_card: "GameCard", pos: QPoint) -> None:
|
||||
grouped: dict[str, list[QAction]] = {}
|
||||
menus: list[QMenu] = []
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
if not card.batch_selected:
|
||||
continue
|
||||
card_menu = self.show_context_menu(card, pos, build_only=True)
|
||||
if card_menu is None:
|
||||
continue
|
||||
card_menu.setParent(menu, card_menu.windowFlags())
|
||||
menus.append(card_menu)
|
||||
for action in card_menu.actions():
|
||||
if not action.isSeparator() and action.isEnabled():
|
||||
grouped.setdefault(action.text(), []).append(action)
|
||||
for text, actions in grouped.items():
|
||||
action = menu.addAction(actions[0].icon(), text)
|
||||
action.setEnabled(not self._batch_action)
|
||||
action.triggered.connect(
|
||||
lambda checked=False, actions=actions: self._start_card_actions(actions, menus)
|
||||
)
|
||||
menu.addSeparator()
|
||||
cancel_action = menu.addAction(self._get_safe_icon("cancel"), _("Cancel"))
|
||||
cancel_action.triggered.connect(self.clear_card_selection)
|
||||
menu.setActiveAction(next(action for action in menu.actions() if action.isEnabled() and not action.isSeparator()))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint, build_only: bool = False):
|
||||
"""
|
||||
Show the context menu for a game card at the specified position.
|
||||
|
||||
@@ -463,6 +556,16 @@ class ContextMenuManager:
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
if not build_only:
|
||||
if self.card_selection_mode or game_card.batch_selected:
|
||||
self._show_card_selection_menu(menu, game_card, pos)
|
||||
return
|
||||
self.clear_card_selection()
|
||||
selection_action = menu.addAction(self._get_safe_icon("edit"), _("Select multiple"))
|
||||
selection_action.triggered.connect(lambda: self.start_card_selection(game_card))
|
||||
menu.setActiveAction(selection_action)
|
||||
menu.addSeparator()
|
||||
|
||||
# For non-Steam games, check if exe exists
|
||||
if game_card.game_source == "portproton":
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
@@ -471,6 +574,8 @@ class ContextMenuManager:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
if build_only:
|
||||
return menu
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
@@ -698,16 +803,24 @@ class ContextMenuManager:
|
||||
if os.path.exists(menu_path)
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
if get_steam_home() is not None:
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
steam_icon_name = "delete" if is_in_steam else "menu_steam"
|
||||
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_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_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if (
|
||||
game_card.game_source in ("portproton", "gog", "egs")
|
||||
and not is_uninstalled_store_game
|
||||
and get_steam_home() is not None
|
||||
):
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
steam_icon_name = "delete" if is_in_steam else "menu_steam"
|
||||
steam_text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(steam_icon_name), steam_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_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
|
||||
if build_only:
|
||||
return menu
|
||||
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
@@ -805,7 +918,9 @@ class ContextMenuManager:
|
||||
if not entry:
|
||||
continue
|
||||
exec_line = entry.get("Exec", entry.get("exec", ""))
|
||||
if script_name.lower() in exec_line.lower():
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if (exe_path and resolve_custom_data_dir(custom_root, exe_path)
|
||||
== os.path.dirname(metadata_path)) or script_name.lower() in exec_line.lower():
|
||||
return file_path
|
||||
except OSError:
|
||||
continue
|
||||
@@ -1054,14 +1169,17 @@ class ContextMenuManager:
|
||||
self.game_library_manager.remove_game_incremental(game_name, card_exec_line or resolved_exec_line)
|
||||
|
||||
def delete_game(self, game_name, exec_line):
|
||||
if self._batch_action:
|
||||
self._delete_game_without_confirm(game_name, exec_line)
|
||||
return
|
||||
msg_box = QMessageBox(self.parent)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
msg_box.setWindowTitle(_("Confirm Deletion"))
|
||||
msg_box.setText(_("Are you sure you want to delete '{game_name}'? This will remove the .desktop file and custom data.").format(game_name=game_name))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -1311,7 +1429,8 @@ class ContextMenuManager:
|
||||
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
|
||||
return
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id,
|
||||
exe_path=f"{source}://launch/{app_id}" if source in {"gog", "egs"} else None)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
@@ -1425,6 +1544,27 @@ class ContextMenuManager:
|
||||
except OSError as error:
|
||||
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
|
||||
|
||||
def _save_local_shortcut_name(self, exe_path: str, name: str) -> None:
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
|
||||
game_dir = resolve_custom_data_dir(custom_root, exe_path)
|
||||
os.makedirs(game_dir, exist_ok=True)
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
metadata_lines = []
|
||||
if os.path.isfile(metadata_path):
|
||||
with open(metadata_path, encoding="utf-8") as metadata_file:
|
||||
metadata_lines = [line for line in metadata_file if not line.startswith(("name=", "name_"))]
|
||||
clean_name = " ".join(name.splitlines()).strip()
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile(mode="w", encoding="utf-8", dir=game_dir, delete=False) as metadata_file:
|
||||
temp_path = metadata_file.name
|
||||
metadata_file.write(f"name={clean_name}\n" + "".join(metadata_lines))
|
||||
os.replace(temp_path, metadata_path)
|
||||
finally:
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.unlink(temp_path)
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
|
||||
"""
|
||||
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
|
||||
@@ -1465,7 +1605,12 @@ class ContextMenuManager:
|
||||
_("Game name and executable path are required")
|
||||
)
|
||||
return
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
old_entry = parse_desktop_entry(old_path)
|
||||
original_name = old_entry.get("Name", game_name) if old_entry else game_name
|
||||
dialog.nameEdit.setText(original_name)
|
||||
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
||||
dialog.nameEdit.setText(new_name)
|
||||
if not desktop_entry or not new_desktop_path:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1473,10 +1618,15 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
new_desktop_path = old_path
|
||||
moved_paths = []
|
||||
try:
|
||||
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
|
||||
if exe_path == new_exe_path and os.path.isfile(old_path):
|
||||
with open(old_path, encoding="utf-8") as source:
|
||||
desktop_entry = source.read()
|
||||
if new_name != game_name:
|
||||
self._save_local_shortcut_name(new_exe_path, new_name)
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
@@ -1495,13 +1645,6 @@ class ContextMenuManager:
|
||||
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
|
||||
if exe_path != new_exe_path:
|
||||
self._relocate_shortcut_statistics(exe_path, new_exe_path)
|
||||
if game_name != new_name and os.path.exists(old_path):
|
||||
self._remove_file(
|
||||
old_path,
|
||||
_("Failed to delete old .desktop file: {error}"),
|
||||
_("Removed old .desktop file for '{game_name}'"),
|
||||
game_name
|
||||
)
|
||||
saved_entry = parse_desktop_entry(new_desktop_path)
|
||||
updated_exec_line = new_exe_path
|
||||
if saved_entry:
|
||||
@@ -1521,11 +1664,9 @@ class ContextMenuManager:
|
||||
"XDG_DATA_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
custom_folder = os.path.join(
|
||||
xdg_data_home,
|
||||
"PortProtonQt",
|
||||
"custom_data",
|
||||
get_custom_data_dir_name(new_exe_path),
|
||||
custom_folder = resolve_custom_data_dir(
|
||||
os.path.join(xdg_data_home, "PortProtonQt", "custom_data"),
|
||||
new_exe_path,
|
||||
)
|
||||
os.makedirs(custom_folder, exist_ok=True)
|
||||
|
||||
@@ -1616,23 +1757,26 @@ class ContextMenuManager:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
if not exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Adding game '%s' to Steam", game_name)
|
||||
try:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path, icon_source)
|
||||
else:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was added to Steam.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
@@ -1654,16 +1798,14 @@ class ContextMenuManager:
|
||||
def on_remove_from_steam_result(result: tuple[bool, str]):
|
||||
success, message = result
|
||||
if success:
|
||||
if self._batch_action:
|
||||
return
|
||||
if "restart" in message.lower():
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam. Please restart Steam for changes to take effect.").format(game_name=game_name)
|
||||
)
|
||||
else:
|
||||
self.signals.show_info_dialog.emit(
|
||||
_("Success"),
|
||||
_("'{game_name}' was removed from Steam.").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
else:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import os
|
||||
from itertools import accumulate
|
||||
from PySide6.QtWidgets import QLabel, QPushButton, QStyle, QStyleOptionButton, QWidget, QLayout, QLayoutItem, QScrollArea, QGraphicsOpacityEffect, QComboBox
|
||||
from PySide6.QtCore import Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtCore import QObject, Qt, Signal, QRect, QRectF, QSize, Property, QPropertyAnimation, QEasingCurve, QTimer, QEvent
|
||||
from PySide6.QtGui import QMouseEvent, QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -17,6 +18,19 @@ class CustomComboBox(QComboBox):
|
||||
super().__init__(parent)
|
||||
self.theme = theme
|
||||
|
||||
def eventFilter(self, watched: QObject, event: QEvent) -> bool:
|
||||
line_edit = self.lineEdit()
|
||||
if (
|
||||
watched is line_edit and line_edit is not None and line_edit.isReadOnly()
|
||||
and isinstance(event, QMouseEvent) and event.button() == Qt.MouseButton.LeftButton
|
||||
):
|
||||
if event.type() == QEvent.Type.MouseButtonPress:
|
||||
return True
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
self.showPopup()
|
||||
return True
|
||||
return super().eventFilter(watched, event)
|
||||
|
||||
def contextMenuEvent(self, event):
|
||||
line_edit = self.lineEdit()
|
||||
if line_edit is None:
|
||||
@@ -40,6 +54,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
result = [[0, 0, 0, 0] for _ in range(N)]
|
||||
min_margin = 20
|
||||
available_width = rect_width - 2 * min_margin
|
||||
width_sums = list(accumulate((size[0] for size in nat_sizes), initial=0))
|
||||
|
||||
# Fast search for max items per row
|
||||
max_items_per_row = 1
|
||||
@@ -55,7 +70,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
end_idx = min(i + mid, N)
|
||||
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
|
||||
sum_w = width_sums[end_idx] - width_sums[i]
|
||||
needed_width = sum_w + spacing * (mid - 1)
|
||||
|
||||
if needed_width <= available_width:
|
||||
@@ -65,7 +80,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
right = mid - 1
|
||||
|
||||
count = best_count
|
||||
sum_width = sum(nat_sizes[j][0] for j in range(i, i + count))
|
||||
sum_width = width_sums[i + count] - width_sums[i]
|
||||
|
||||
if count > max_items_per_row:
|
||||
max_items_per_row = count
|
||||
@@ -89,7 +104,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
while left <= right:
|
||||
mid = (left + right) // 2
|
||||
end_idx = min(i + mid, N)
|
||||
sum_w = sum(nat_sizes[j][0] for j in range(i, end_idx))
|
||||
sum_w = width_sums[end_idx] - width_sums[i]
|
||||
needed_width = sum_w + spacing * (mid - 1)
|
||||
|
||||
if needed_width <= available_width:
|
||||
@@ -151,9 +166,13 @@ class FlowLayout(QLayout):
|
||||
self._cache_width = None
|
||||
self._cache_visible_hash = None
|
||||
self._cache_result = None
|
||||
self._cache_visible_data = None
|
||||
self._cache_minimum_size = None
|
||||
|
||||
def _get_visible_data(self):
|
||||
"""Return list of visible items and their sizes"""
|
||||
if self._cache_visible_data is not None:
|
||||
return self._cache_visible_data
|
||||
visible_items = []
|
||||
visible_indices = []
|
||||
visible_sizes = []
|
||||
@@ -163,10 +182,13 @@ class FlowLayout(QLayout):
|
||||
if widget and not widget.isHidden():
|
||||
visible_items.append(item)
|
||||
visible_indices.append(i)
|
||||
s = item.sizeHint()
|
||||
s = widget.minimumSize()
|
||||
if s != widget.maximumSize():
|
||||
s = item.sizeHint()
|
||||
visible_sizes.append((s.width(), s.height()))
|
||||
|
||||
return visible_items, visible_indices, visible_sizes
|
||||
self._cache_visible_data = (visible_items, visible_indices, visible_sizes)
|
||||
return self._cache_visible_data
|
||||
|
||||
def _make_visible_hash(self, visible_sizes):
|
||||
"""Create hash for change detection"""
|
||||
@@ -176,6 +198,12 @@ class FlowLayout(QLayout):
|
||||
self.itemList.append(item)
|
||||
self._invalidate_cache()
|
||||
|
||||
def insertWidget(self, index: int, widget: QWidget) -> None:
|
||||
self.addWidget(widget)
|
||||
item = self.takeAt(self.count() - 1)
|
||||
self.itemList.insert(index, item)
|
||||
self.invalidate()
|
||||
|
||||
def takeAt(self, index: int) -> QLayoutItem:
|
||||
if 0 <= index < len(self.itemList):
|
||||
self._invalidate_cache()
|
||||
@@ -186,6 +214,8 @@ class FlowLayout(QLayout):
|
||||
self._cache_width = None
|
||||
self._cache_visible_hash = None
|
||||
self._cache_result = None
|
||||
self._cache_visible_data = None
|
||||
self._cache_minimum_size = None
|
||||
|
||||
def invalidate(self) -> None:
|
||||
self._invalidate_cache()
|
||||
@@ -247,12 +277,20 @@ class FlowLayout(QLayout):
|
||||
return self.minimumSize()
|
||||
|
||||
def minimumSize(self):
|
||||
if self._cache_minimum_size is not None:
|
||||
return QSize(self._cache_minimum_size)
|
||||
size = QSize()
|
||||
for item in self.itemList:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
widget = item.widget()
|
||||
if (widget is not None and not widget.isHidden()
|
||||
and widget.minimumSize() == widget.maximumSize()):
|
||||
size = size.expandedTo(widget.minimumSize())
|
||||
else:
|
||||
size = size.expandedTo(item.minimumSize())
|
||||
margins = self.contentsMargins()
|
||||
size += QSize(margins.left() + margins.right(),
|
||||
margins.top() + margins.bottom())
|
||||
self._cache_minimum_size = QSize(size)
|
||||
return size
|
||||
|
||||
def doLayout(self, rect, testOnly):
|
||||
@@ -291,10 +329,18 @@ class FlowLayout(QLayout):
|
||||
|
||||
if not testOnly:
|
||||
rx, ry = rect.x(), rect.y()
|
||||
row_bounds = {}
|
||||
if self.alignment() & Qt.AlignmentFlag.AlignHCenter:
|
||||
for x, y, w, _ in geom_array:
|
||||
left = row_bounds.get(y, (x, x))[0]
|
||||
row_bounds[y] = (left, x + w)
|
||||
|
||||
# Set geometry for visible items
|
||||
for idx, item in enumerate(visible_items):
|
||||
x, y, w, h = geom_array[idx]
|
||||
if y in row_bounds:
|
||||
left, right = row_bounds[y]
|
||||
x += max(0, (rect.width() - (right - left)) // 2) - left
|
||||
item.setGeometry(QRect(x + rx, y + ry, w, h))
|
||||
|
||||
# Hide invisible items
|
||||
@@ -840,6 +886,8 @@ class NavLabel(QLabel):
|
||||
super().mousePressEvent(event)
|
||||
|
||||
class AutoHideScrollArea(QScrollArea):
|
||||
viewportResized = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
theme,
|
||||
@@ -1011,7 +1059,13 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
|
||||
def wheelEvent(self, event: QWheelEvent) -> None:
|
||||
if self._v_scrollbar.maximum() == 0 and self._h_scrollbar.maximum() > 0:
|
||||
widget = self.widget()
|
||||
horizontal_library = widget is not None and widget.property(
|
||||
"library_layout_mode"
|
||||
) in {"horizontal", "horizontal_top"}
|
||||
if self._h_scrollbar.maximum() > 0 and (
|
||||
horizontal_library or self._v_scrollbar.maximum() == 0
|
||||
):
|
||||
self._h_scrollbar.event(event)
|
||||
return
|
||||
super().wheelEvent(event)
|
||||
@@ -1022,6 +1076,8 @@ class AutoHideScrollArea(QScrollArea):
|
||||
|
||||
if event.type() == QEvent.Type.Resize:
|
||||
self._update_scroll_needed()
|
||||
if obj == self.viewport():
|
||||
self.viewportResized.emit()
|
||||
elif obj == self._v_scrollbar:
|
||||
if event.type() == QEvent.Type.Enter:
|
||||
if self._scroll_needed:
|
||||
|
||||
@@ -93,6 +93,9 @@ class DetailPageManager:
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open detailed game information page."""
|
||||
self._current_detail_source = ("game", dict(game_data))
|
||||
self._return_to_library_card_key = (
|
||||
game_data.get("name", ""), game_data.get("exec_line", "")
|
||||
)
|
||||
detail_page = QWidget()
|
||||
detail_page.setProperty("coverCacheKey", str(game_data.get("appid", "")))
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback(game_data)
|
||||
@@ -1023,6 +1026,9 @@ class DetailPageManager:
|
||||
)
|
||||
)
|
||||
content_frame_layout.addLayout(page_data["buttons_layout"])
|
||||
content_frame_layout.addSpacing(
|
||||
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
|
||||
)
|
||||
|
||||
def _get_compact_content_spacing(self) -> int:
|
||||
return getattr(
|
||||
@@ -1198,6 +1204,9 @@ class DetailPageManager:
|
||||
)
|
||||
)
|
||||
content_frame_layout.addLayout(page_data["buttons_layout"])
|
||||
content_frame_layout.addSpacing(
|
||||
self.main_window.theme.DETAIL_COMPACT["buttons_bottom_spacing"]
|
||||
)
|
||||
|
||||
main_layout = self._get_main_layout(detail_page)
|
||||
if main_layout:
|
||||
@@ -1474,7 +1483,7 @@ class DetailPageManager:
|
||||
msg_box.setWindowTitle(_("Log Saved"))
|
||||
msg_box.setText(_("Debug log saved to:") + f"\n{log_file}")
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Open, _("Open Folder"))
|
||||
msg_box.button(QMessageBox.StandardButton.Open).setText(_("Open Folder"))
|
||||
|
||||
result = msg_box.exec()
|
||||
|
||||
@@ -1632,10 +1641,15 @@ class DetailPageManager:
|
||||
container = getattr(self.main_window, "gamesListWidget", None)
|
||||
if container is None:
|
||||
return
|
||||
manager = getattr(self.main_window, "game_library_manager", None)
|
||||
card_key = getattr(self, "_return_to_library_card_key", None)
|
||||
card = getattr(manager, "game_card_cache", {}).get(card_key)
|
||||
cards = [
|
||||
widget for widget in container.findChildren(QWidget)
|
||||
if widget.__class__.__name__ == "GameCard"
|
||||
]
|
||||
if card is not None and card.isVisible():
|
||||
cards = [card]
|
||||
if not cards:
|
||||
return
|
||||
cards.sort(key=lambda card: (card.pos().y(), card.pos().x()))
|
||||
|
||||
@@ -745,14 +745,14 @@ def _extract_exe_name(line: str) -> str:
|
||||
|
||||
|
||||
def _extract_exe_path(line: str) -> str:
|
||||
match = re.search(r"['\"]([^'\"]+\.exe)['\"]", line.replace("\\", "/"), re.IGNORECASE)
|
||||
if not match:
|
||||
return ""
|
||||
path = match.group(1)
|
||||
drive_index = path.lower().find("drive_c/")
|
||||
if drive_index < 0:
|
||||
return ""
|
||||
return path[drive_index:].lower()
|
||||
paths = re.findall(r"['\"]([^'\"]+\.exe)['\"]", line.replace("\\", "/"), re.IGNORECASE)
|
||||
for path in paths:
|
||||
drive_index = path.lower().find("drive_c/")
|
||||
if drive_index >= 0:
|
||||
return path[drive_index:].lower()
|
||||
if "/" in path and not path.startswith("$"):
|
||||
return path.lower()
|
||||
return ""
|
||||
|
||||
|
||||
def _path_matches_autoinstall(exec_path: str, autoinstall_path: str) -> bool:
|
||||
|
||||
@@ -1,25 +1,36 @@
|
||||
"""Base dialogs for PortProtonQt."""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from functools import partial
|
||||
import tempfile
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from typing import Any, cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QIcon, QPixmap
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout,
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox
|
||||
QProgressBar, QSizePolicy, QWidget, QCheckBox, QProgressDialog, QScrollArea, QLineEdit, QApplication
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QTimer, QStandardPaths, QSize, QThread
|
||||
import psutil
|
||||
import orjson
|
||||
import pefile
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
from portprotonqt.config import (
|
||||
LAUNCH_FILE_EXTENSIONS,
|
||||
THEMED_LAUNCH_ICON_NAMES,
|
||||
create_desktop_file,
|
||||
extract_exec_target_path,
|
||||
get_portproton_location,
|
||||
parse_desktop_entry,
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.config.portproton import get_custom_icon_path, resolve_custom_data_dir
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
@@ -28,6 +39,7 @@ from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS, set_animated_cover
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
|
||||
logger = get_logger(__name__)
|
||||
theme_manager = ThemeManager()
|
||||
@@ -125,16 +137,241 @@ class GameLaunchDialog(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
class GameFolderScanWorker(QThread):
|
||||
"""Find executable files recursively and report scanned directory counts."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, folder: str, parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.folder = folder
|
||||
self.games: list[tuple[str, str]] = []
|
||||
self.errors: list[str] = []
|
||||
|
||||
MAX_SCAN_DEPTH = 5
|
||||
MAX_SCAN_DIRECTORIES = 400
|
||||
GAME_SUBDIRECTORIES = frozenset((
|
||||
"bin", "bin32", "bin64", "binaries", "win32", "win64", "x64", "x86", "game", "disccontentpc", "api",
|
||||
))
|
||||
|
||||
def run(self) -> None:
|
||||
for directory, paths in self._find_executables(self.folder).items():
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
executable = max(paths, key=lambda path: self._executable_score(path, directory))
|
||||
self.games.append((self._game_name(executable, directory), executable))
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
self.games.sort(key=lambda game: game[1].casefold())
|
||||
|
||||
def _executable_score(self, executable: str, directory: str) -> tuple[int, int, int]:
|
||||
name = Path(executable).stem.casefold()
|
||||
folder = re.sub(r"[\W_]", "", os.path.basename(directory).casefold())
|
||||
key = re.sub(r"[\W_]", "", name)
|
||||
match = int(key == folder) + int(bool(key and folder and (key in folder or folder in key)))
|
||||
launcher = name.endswith(("launcher", "redirector")) or name in ("play", "start", "run")
|
||||
depth = len(Path(executable).relative_to(directory).parts)
|
||||
return match - int(launcher), -depth, os.path.getsize(executable)
|
||||
|
||||
def _executable_name(self, executable: str) -> str:
|
||||
try:
|
||||
with pefile.PE(executable, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
tables = [table for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", [])]
|
||||
metadata = {key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for table in tables for key, value in table.entries.items()}
|
||||
except pefile.PEFormatError:
|
||||
return ""
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", executable, error)
|
||||
return ""
|
||||
for field in ("ProductName", "FileDescription"):
|
||||
value = metadata.get(field, "").strip()
|
||||
if value and not re.fullmatch(
|
||||
r"(?i)(?:BootstrapPackagedGame|game|application|launcher|.*launcher.*|.*redistributable.*)", value,
|
||||
):
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _game_name(self, executable: str, directory: str) -> str:
|
||||
name = ""
|
||||
for info in sorted(Path(directory).glob("goggame-*.info")):
|
||||
try:
|
||||
data = orjson.loads(info.read_bytes().removeprefix(b"\xef\xbb\xbf"))
|
||||
if (isinstance(data, dict) and isinstance(data.get("name"), str)
|
||||
and data.get("gameId") == data.get("rootGameId")):
|
||||
name = data["name"]
|
||||
break
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read game metadata %s: %s", info, error)
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid game metadata %s: %s", info, error)
|
||||
if not name:
|
||||
name = self._executable_name(executable) or Path(directory).name
|
||||
name = re.sub(r"(?i)[._ ]+(?:v?\d+(?:[._]\d+)+|build[._ ]?\d+).*$", "", name)
|
||||
name = name.replace(".", " ")
|
||||
name = re.sub(r"[\\/\r\n]", " ", name).replace(":", " - ")
|
||||
return " ".join(name.replace("_", " ").split()).strip(" .") or Path(executable).stem
|
||||
|
||||
def _record_error(self, error: OSError) -> None:
|
||||
logger.warning("Failed to scan game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _is_runtime_directory(self, path: str) -> bool:
|
||||
name = os.path.basename(path).casefold()
|
||||
parent = os.path.basename(os.path.dirname(path)).casefold()
|
||||
runtime = r"(?:i386-windows|x86_64-windows|mono|monobleedingedge|wine-mono.*|jre[\d._-]*|jdk[\d._-]*|java-runtime.*|_?commonredist|__installer)"
|
||||
if re.fullmatch(runtime, name) or name in ("$recycle.bin", "system volume information", "config.msi", "msdownld.tmp"):
|
||||
return True
|
||||
if name == "windows" and parent == "drive_c":
|
||||
return True
|
||||
if name in ("windows media player", "internet explorer", "windows nt") and parent.startswith("program files"):
|
||||
return True
|
||||
if "proton" in name or name.startswith("wine"):
|
||||
return os.path.isfile(os.path.join(path, "proton")) or os.path.isdir(os.path.join(path, "lib", "wine"))
|
||||
return False
|
||||
|
||||
def _find_executables(self, folder: str) -> dict[str, list[str]]:
|
||||
games: dict[str, list[str]] = {}
|
||||
pending = deque([] if self._is_runtime_directory(folder) else [(folder, 0, folder)])
|
||||
visited: dict[str, int] = {}
|
||||
completed, total = 0, len(pending)
|
||||
ignored = r"(?i)(?:unins.*|setup.*|install.*|.*redist.*|dxsetup.*|directx.*|dotnet.*|.*crash.*|.*report.*|.*prereq.*|.*updater?.*|.*config.*|.*settings.*|.*helper.*|.*benchmark.*|.*-win(?:32|64)-shipping|.*easyanticheat.*|.*battleye.*|oalinst|overlay_test.*|.*[-_](?:setup|installer).*|.*errormonitor|pluginhost|.*_api)"
|
||||
skipped = {"engine", "melonloader", "mods", "plugins", "userdata", "userlibs", "reshade_shaders",
|
||||
"assets", "data", "resources", "res", "res_mods", "replays", "movies", "cache", "logs"}
|
||||
while pending:
|
||||
if self.isInterruptionRequested():
|
||||
return {}
|
||||
root, depth, branch = pending.popleft()
|
||||
label = _("Scanning: {folder}").format(folder=root)
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
try:
|
||||
with os.scandir(root) as entries:
|
||||
entries_list = sorted(entries, key=lambda entry: entry.name.casefold())
|
||||
files = [entry.path for entry in entries_list if entry.name.casefold().endswith(".exe")
|
||||
and not re.fullmatch(ignored, entry.name[:-len(".exe")])
|
||||
and not entry.is_symlink() and entry.is_file(follow_symlinks=False)]
|
||||
directory = root
|
||||
while directory != folder and Path(directory).name.casefold() in self.GAME_SUBDIRECTORIES:
|
||||
directory = os.path.dirname(directory)
|
||||
if files:
|
||||
games.setdefault(directory, []).extend(files)
|
||||
for entry in entries_list:
|
||||
name = entry.name.casefold()
|
||||
if (depth >= self.MAX_SCAN_DEPTH or entry.is_symlink() or not entry.is_dir(follow_symlinks=False)
|
||||
or name.startswith((".", "$")) or name in skipped or name.endswith("_data")
|
||||
or re.fullmatch(ignored, name) or self._is_runtime_directory(entry.path)):
|
||||
continue
|
||||
if files and root != folder and name not in self.GAME_SUBDIRECTORIES:
|
||||
continue
|
||||
owner = entry.path if root == folder or Path(root).name.casefold() == "common" else branch
|
||||
if visited.get(owner, 0) >= self.MAX_SCAN_DIRECTORIES:
|
||||
self._record_error(OSError(f"Game folder scan directory limit reached: {owner}"))
|
||||
break
|
||||
visited[owner] = visited.get(owner, 0) + 1
|
||||
next_depth = 0 if owner == entry.path or name in ("steamapps", "common") else depth + 1
|
||||
pending.append((entry.path, next_depth, owner))
|
||||
total += 1
|
||||
except OSError as error:
|
||||
self._record_error(error)
|
||||
completed += 1
|
||||
self.progressChanged.emit((completed, total, label))
|
||||
return games
|
||||
|
||||
|
||||
class GameFolderImportWorker(QThread):
|
||||
"""Create selected shortcuts without overwriting existing entries."""
|
||||
|
||||
progressChanged = Signal(object)
|
||||
|
||||
def __init__(self, games: list[tuple[str, str]], parent: QObject) -> None:
|
||||
super().__init__(parent)
|
||||
self.games = games
|
||||
self.added = 0
|
||||
self.skipped = 0
|
||||
self.errors: list[str] = []
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
location = get_portproton_location()
|
||||
if not location:
|
||||
raise FileNotFoundError("PortProton directory not found")
|
||||
existing = set()
|
||||
with os.scandir(location) as entries:
|
||||
paths = [entry.path for entry in entries if entry.name.endswith(".desktop") and entry.is_file()]
|
||||
for index, path in enumerate(paths):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(paths), _("Checking existing shortcuts: {path}").format(path=path)))
|
||||
entry = parse_desktop_entry(path)
|
||||
target = extract_exec_target_path(entry.get("Exec", "")) if entry else None
|
||||
if target:
|
||||
existing.add(os.path.realpath(target))
|
||||
for index, (name, executable) in enumerate(self.games):
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
self.progressChanged.emit((index, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
self._import_game(name, executable, existing)
|
||||
self.progressChanged.emit((index + 1, len(self.games), _("Adding: {name}").format(name=name)))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import game folder: %s", error)
|
||||
self.errors.append(str(error))
|
||||
|
||||
def _import_game(self, name: str, executable: str, existing: set[str]) -> None:
|
||||
target = os.path.realpath(executable)
|
||||
if target in existing:
|
||||
self.skipped += 1
|
||||
return
|
||||
created = False
|
||||
desktop_path = ""
|
||||
try:
|
||||
name = " ".join(re.sub(r"[\\/]", " ", name).split()).strip(" .")
|
||||
result = create_desktop_file(executable, name)
|
||||
if result is None:
|
||||
raise OSError(_("Failed to create shortcut: {name}").format(name=name))
|
||||
content, desktop_path, icon_path = result
|
||||
if os.path.exists(desktop_path):
|
||||
self.skipped += 1
|
||||
return
|
||||
if not os.path.exists(icon_path) and not generate_thumbnail(executable, icon_path, size=128):
|
||||
content = content.replace(f"Icon={icon_path}\n", "Icon=\n")
|
||||
with open(desktop_path, "x", encoding="utf-8") as desktop:
|
||||
created = True
|
||||
desktop.write(content)
|
||||
os.chmod(desktop_path, 0o755)
|
||||
existing.add(target)
|
||||
self.added += 1
|
||||
except FileExistsError:
|
||||
self.skipped += 1
|
||||
except OSError as error:
|
||||
logger.warning("Failed to import '%s': %s", executable, error)
|
||||
self.errors.append(f"{name}: {error}")
|
||||
if created:
|
||||
try:
|
||||
os.unlink(desktop_path)
|
||||
except OSError as cleanup_error:
|
||||
logger.warning("Failed to remove incomplete shortcut: %s", cleanup_error)
|
||||
|
||||
|
||||
class AddGameDialog(DraggableDialog):
|
||||
"""Dialog for adding or editing a game."""
|
||||
|
||||
gamesAdded = Signal()
|
||||
|
||||
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None, steam_appid=None):
|
||||
super().__init__(parent)
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
self.theme = theme if theme else theme_manager.apply_theme(ui_config.get_theme())
|
||||
self.edit_mode = edit_mode
|
||||
if exe_path is None and steam_appid is not None:
|
||||
exe_path = f"steam://rungameid/{steam_appid}"
|
||||
self.last_exe_path = exe_path
|
||||
self.last_cover_path = cover_path
|
||||
refresh_games = getattr(parent, "refreshGames", None)
|
||||
if callable(refresh_games):
|
||||
self.gamesAdded.connect(refresh_games)
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
|
||||
self.setWindowTitle(_("Edit Shortcut") if edit_mode else _("Add a shortcut"))
|
||||
@@ -195,6 +432,19 @@ class AddGameDialog(DraggableDialog):
|
||||
cover_button_label.setFixedWidth(cover_label.sizeHint().width())
|
||||
layout.addRow(cover_button_label, coverBrowseButton)
|
||||
|
||||
self.iconEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.iconEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.iconEdit.setText(get_custom_icon_path(exe_path) if exe_path else "")
|
||||
icon_label = QLabel(_("Custom Icon:"))
|
||||
icon_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||
icon_row = QHBoxLayout()
|
||||
icon_row.addWidget(self.iconEdit)
|
||||
icon_browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("search", as_path=True))
|
||||
icon_browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
icon_browse.clicked.connect(self.browseIcon)
|
||||
icon_row.addWidget(icon_browse)
|
||||
layout.addRow(icon_label, icon_row)
|
||||
|
||||
self.add_to_steam_checkbox = QCheckBox()
|
||||
self.add_to_menu_checkbox = QCheckBox()
|
||||
self.add_to_desktop_checkbox = QCheckBox()
|
||||
@@ -327,10 +577,44 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
def accept(self):
|
||||
"""Handle Apply button."""
|
||||
icon_path = self.iconEdit.text().strip()
|
||||
exe_path = self.last_exe_path if self.edit_mode else self.exeEdit.text().strip()
|
||||
if icon_path and exe_path and icon_path != get_custom_icon_path(exe_path):
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
image = QPixmap(icon_path)
|
||||
if image.isNull():
|
||||
QMessageBox.warning(self, _("Error"), _("Invalid image"))
|
||||
return
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
folder = resolve_custom_data_dir(os.path.join(data_home, "PortProtonQt", "custom_data"), exe_path)
|
||||
temporary_path = None
|
||||
try:
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=folder, suffix=".png", delete=False) as temporary:
|
||||
temporary_path = temporary.name
|
||||
if not image.save(temporary_path, "PNG"):
|
||||
raise OSError("Failed to save icon")
|
||||
os.replace(temporary_path, os.path.join(folder, "icon.png"))
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save custom icon: %s", error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
finally:
|
||||
if temporary_path:
|
||||
Path(temporary_path).unlink(missing_ok=True)
|
||||
if hasattr(self, 'keyboard'):
|
||||
self.keyboard.hide()
|
||||
super().accept()
|
||||
|
||||
def browseIcon(self) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(self, file_filter=COVER_IMAGE_EXTENSIONS + (".ico",),
|
||||
initial_path=os.path.dirname(self.iconEdit.text()) or None)
|
||||
explorer.file_signal.file_selected.connect(self.iconEdit.setText)
|
||||
explorer.show()
|
||||
|
||||
def browseExe(self):
|
||||
"""Open file manager to select executable or disc image."""
|
||||
try:
|
||||
@@ -338,6 +622,12 @@ class AddGameDialog(DraggableDialog):
|
||||
initial_path = os.path.dirname(self.last_exe_path) if self.last_exe_path and os.path.isfile(self.last_exe_path) else None
|
||||
file_explorer = FileExplorer(self, file_filter=LAUNCH_FILE_EXTENSIONS, initial_path=initial_path)
|
||||
file_explorer.file_signal.file_selected.connect(self.onExeSelected)
|
||||
if not self.edit_mode:
|
||||
folder_button = AutoSizeButton(_("Select folder"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
folder_button.setObjectName("gamepadAddButton")
|
||||
folder_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
folder_button.clicked.connect(partial(self._select_game_folder, file_explorer))
|
||||
file_explorer.button_layout.insertWidget(0, folder_button)
|
||||
|
||||
parent_widget = self.parentWidget()
|
||||
if parent_widget:
|
||||
@@ -351,6 +641,195 @@ class AddGameDialog(DraggableDialog):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in browseExe: {e}")
|
||||
|
||||
def _select_game_folder(self, explorer: "FileExplorer") -> None:
|
||||
folder = explorer.current_path
|
||||
item = explorer.file_list.currentItem()
|
||||
if item and item.text() != "../":
|
||||
selected = os.path.normpath(os.path.join(folder, item.text()))
|
||||
if os.path.isdir(selected):
|
||||
folder = selected
|
||||
self._game_folder_root = folder
|
||||
explorer.accept()
|
||||
self._start_folder_worker(GameFolderScanWorker(folder, self.parentWidget() or self))
|
||||
|
||||
def _start_folder_worker(self, worker: GameFolderScanWorker | GameFolderImportWorker) -> None:
|
||||
owner = cast(Any, self.parentWidget() or self)
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
owner._gameFolderWorkers = workers
|
||||
workers.append(worker)
|
||||
progress = QProgressDialog(_("Processing games…"), _("Cancel"), 0, 1, self)
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
progress.setAutoClose(False)
|
||||
progress.setAutoReset(False)
|
||||
progress.setValue(0)
|
||||
progress.canceled.connect(worker.requestInterruption)
|
||||
worker.progressChanged.connect(partial(self._update_folder_progress, progress), Qt.ConnectionType.QueuedConnection)
|
||||
worker.finished.connect(partial(self._finish_folder_worker, worker, progress), Qt.ConnectionType.QueuedConnection)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _update_folder_progress(self, progress: QProgressDialog, state: tuple[int, int, str]) -> None:
|
||||
if progress.wasCanceled():
|
||||
return
|
||||
completed, total, current = state
|
||||
progress.setLabelText(_("Processed {completed}/{total}\n{current}").format(
|
||||
completed=completed, total=total, current=current,
|
||||
))
|
||||
progress.setRange(0, max(total, 1))
|
||||
progress.setValue(completed)
|
||||
|
||||
def _finish_folder_worker(self, worker: QThread, progress: QProgressDialog) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
owner = self.parentWidget() or self
|
||||
workers = getattr(owner, "_gameFolderWorkers", [])
|
||||
cancelled = progress.wasCanceled()
|
||||
progress.close()
|
||||
progress.deleteLater()
|
||||
worker.deleteLater()
|
||||
if worker not in workers:
|
||||
return
|
||||
workers.remove(worker)
|
||||
if isinstance(worker, GameFolderImportWorker):
|
||||
if worker.added:
|
||||
self.gamesAdded.emit()
|
||||
if cancelled:
|
||||
return
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
self.reject()
|
||||
elif isinstance(worker, GameFolderScanWorker) and not cancelled:
|
||||
if worker.errors:
|
||||
QMessageBox.warning(self, _("Error"), "\n".join(worker.errors))
|
||||
if worker.games:
|
||||
self._show_folder_games(worker.games)
|
||||
else:
|
||||
QMessageBox.information(self, _("Information"), _("No games found in the selected folder."))
|
||||
|
||||
def _create_folder_game_row(
|
||||
self, layout: QVBoxLayout, game: tuple[str, str],
|
||||
) -> tuple[QLineEdit, QLineEdit, QCheckBox]:
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
|
||||
name, executable = game
|
||||
content = layout.parentWidget()
|
||||
row = QHBoxLayout()
|
||||
checkbox = QCheckBox(content)
|
||||
checkbox.setObjectName("gameFolderExecutable")
|
||||
checkbox.setAccessibleName(executable)
|
||||
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
checkbox.setChecked(True)
|
||||
title = CustomLineEdit(name, content, theme=self.theme)
|
||||
title.setAccessibleName(_("Name:"))
|
||||
title.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
path = CustomLineEdit(executable, content, theme=self.theme)
|
||||
path.setObjectName("gameFolderPath")
|
||||
path.setAccessibleName(_("Path to Executable:"))
|
||||
path.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
browse = AutoSizeButton(_("Browse…"), icon=theme_manager.get_icon("folder", as_path=True))
|
||||
browse.setAutoDefault(False)
|
||||
browse.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
browse.clicked.connect(partial(self._browse_folder_exe, path))
|
||||
path_row = QHBoxLayout()
|
||||
path_row.addWidget(path)
|
||||
path_row.addWidget(browse)
|
||||
details = QVBoxLayout()
|
||||
details.addWidget(title)
|
||||
details.addLayout(path_row)
|
||||
row.addWidget(checkbox)
|
||||
row.addLayout(details)
|
||||
layout.addLayout(row)
|
||||
QWidget.setTabOrder(checkbox, title)
|
||||
QWidget.setTabOrder(title, path)
|
||||
QWidget.setTabOrder(path, browse)
|
||||
return title, path, checkbox
|
||||
|
||||
def _browse_folder_exe(self, field: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
explorer = FileExplorer(
|
||||
field.window(), theme=self.theme, file_filter=(".exe",),
|
||||
initial_path=os.path.dirname(os.path.expanduser(field.text().strip())),
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(field.setText)
|
||||
explorer.exec()
|
||||
field.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
explorer.deleteLater()
|
||||
|
||||
def _accept_folder_games(
|
||||
self, dialog: QDialog, rows: list[tuple[QLineEdit, QLineEdit, QCheckBox]],
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
|
||||
for title, path, checkbox in rows:
|
||||
if not checkbox.isChecked():
|
||||
continue
|
||||
if not title.text().strip():
|
||||
QMessageBox.warning(dialog, _("Error"), _("Game name and executable path are required"))
|
||||
title.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
executable = os.path.expanduser(path.text().strip())
|
||||
if not os.path.isfile(executable):
|
||||
QMessageBox.warning(dialog, _("Error"), _("Executable not found: {0}").format(executable))
|
||||
path.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
path.setText(executable)
|
||||
dialog.accept()
|
||||
|
||||
def _scroll_folder_focus(self, scroll: QScrollArea, _old: QWidget | None, focused: QWidget | None) -> None:
|
||||
if focused is not None and scroll.isAncestorOf(focused):
|
||||
scroll.ensureWidgetVisible(focused)
|
||||
|
||||
def _show_folder_games(self, games: list[tuple[str, str]]) -> None:
|
||||
dialog = cast(Any, DraggableDialog(self))
|
||||
dialog.setWindowTitle(_("Select games"))
|
||||
dialog.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
dialog.resize(*self.theme.GAME_FOLDER_IMPORT_DIALOG_SIZE)
|
||||
layout = QVBoxLayout(dialog)
|
||||
scroll = QScrollArea(dialog)
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget(scroll)
|
||||
rows_layout = QVBoxLayout(content)
|
||||
rows = [self._create_folder_game_row(rows_layout, game) for game in games]
|
||||
rows_layout.addStretch()
|
||||
scroll.setWidget(content)
|
||||
layout.addWidget(scroll)
|
||||
buttons = QHBoxLayout()
|
||||
apply_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply", as_path=True))
|
||||
apply_button.setObjectName("gamepadAddButton")
|
||||
cancel_button = AutoSizeButton(_("Close"), icon=theme_manager.get_icon("cancel", as_path=True))
|
||||
for button in (apply_button, cancel_button):
|
||||
button.setAutoDefault(False)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
buttons.addWidget(button)
|
||||
apply_button.clicked.connect(partial(self._accept_folder_games, dialog, rows))
|
||||
cancel_button.clicked.connect(dialog.reject)
|
||||
layout.addLayout(buttons)
|
||||
main_window = self.parentWidget()
|
||||
input_manager = getattr(main_window, "input_manager", None)
|
||||
hints, labels = create_dialog_hints_widget(self.theme, main_window, input_manager, context='folder_import')
|
||||
layout.addWidget(hints)
|
||||
update_dialog_hints(labels, main_window, input_manager, theme_manager, ui_config.get_theme())
|
||||
dialog.keyboard = VirtualKeyboard(dialog, theme=self.theme)
|
||||
dialog.finished.connect(dialog.keyboard.hide)
|
||||
if rows:
|
||||
QTimer.singleShot(0, rows[0][2].setFocus)
|
||||
app = cast(QApplication, QApplication.instance())
|
||||
scroll_focus = partial(self._scroll_folder_focus, scroll)
|
||||
app.focusChanged.connect(scroll_focus)
|
||||
try:
|
||||
accepted = dialog.exec() == QDialog.DialogCode.Accepted
|
||||
finally:
|
||||
app.focusChanged.disconnect(scroll_focus)
|
||||
if accepted:
|
||||
selected = [(title.text().strip(), path.text().strip()) for title, path, checkbox in rows
|
||||
if checkbox.isChecked()]
|
||||
if selected:
|
||||
self._start_folder_worker(GameFolderImportWorker(selected, self.parentWidget() or self))
|
||||
dialog.deleteLater()
|
||||
|
||||
def onExeSelected(self, file_path):
|
||||
"""Handle file selection in FileExplorer."""
|
||||
self.exeEdit.setText(file_path)
|
||||
@@ -497,6 +976,8 @@ class AddGameDialog(DraggableDialog):
|
||||
|
||||
file_icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(launch_path)[1].lower())
|
||||
file_icon_path = theme_manager.get_icon(file_icon_name, as_path=True) if file_icon_name else ""
|
||||
if get_custom_icon_path(exe_path):
|
||||
return desktop_entry, desktop_path
|
||||
if not self.last_cover_path and file_icon_path:
|
||||
desktop_entry = desktop_entry.replace(f"Icon={icon_path}\n", f"Icon={file_icon_path}\n")
|
||||
elif not self.last_cover_path and not generate_thumbnail(launch_path, icon_path, size=128):
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import QPoint, Qt
|
||||
import orjson
|
||||
|
||||
from PySide6.QtCore import QIODevice, QPoint, QSaveFile, Qt, QUrl
|
||||
from PySide6.QtNetwork import (
|
||||
QNetworkAccessManager, QNetworkDiskCache, QNetworkProxy, QNetworkReply, QNetworkRequest,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
@@ -14,8 +20,12 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.config import CACHE_DIR, proxy_config, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class CompatibilityReportDialog(DraggableDialog):
|
||||
@@ -70,6 +80,11 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
def _create_buttons(self, theme) -> QHBoxLayout:
|
||||
layout = QHBoxLayout()
|
||||
layout.addStretch()
|
||||
self.ppdb_button = AutoSizeButton(_("Download") + " PPDB")
|
||||
self.ppdb_button.setStyleSheet(theme.ACTION_BUTTON_STYLE)
|
||||
self.ppdb_button.hide()
|
||||
self.ppdb_button.clicked.connect(self._download_ppdb)
|
||||
layout.addWidget(self.ppdb_button)
|
||||
close_button = AutoSizeButton(_("Close"))
|
||||
close_button.setStyleSheet(theme.ACTION_BUTTON_ACTIVE_STYLE)
|
||||
close_button.clicked.connect(self.accept)
|
||||
@@ -77,6 +92,81 @@ class CompatibilityReportDialog(DraggableDialog):
|
||||
layout.addWidget(close_button)
|
||||
return layout
|
||||
|
||||
def check_ppdb(self, executable: str) -> None:
|
||||
if ui_config.get_auto_download_ppdb() or not os.path.isfile(executable):
|
||||
return
|
||||
self.ppdb_path = executable + ".ppdb"
|
||||
self.ppdb_network = QNetworkAccessManager(self)
|
||||
self.finished.connect(self.ppdb_network.deleteLater)
|
||||
self.ppdb_network.setTransferTimeout(
|
||||
QNetworkRequest.TransferTimeoutConstant.DefaultTransferTimeoutConstant.value
|
||||
)
|
||||
cache = QNetworkDiskCache(self.ppdb_network)
|
||||
cache.setCacheDirectory(str(CACHE_DIR / "ppdb_lookup"))
|
||||
self.ppdb_network.setCache(cache)
|
||||
proxies = proxy_config.get_proxy() or {}
|
||||
proxy_url = QUrl(proxies.get("https") or proxies.get("http") or "")
|
||||
if proxy_url.host():
|
||||
proxy_type = (QNetworkProxy.ProxyType.Socks5Proxy
|
||||
if proxy_url.scheme().startswith("socks")
|
||||
else QNetworkProxy.ProxyType.HttpProxy)
|
||||
self.ppdb_network.setProxy(QNetworkProxy(
|
||||
proxy_type, proxy_url.host(), max(proxy_url.port(), 0),
|
||||
proxy_url.userName(), proxy_url.password(),
|
||||
))
|
||||
url = QUrl("https://linux-gaming.ru/api/lookup/exe/")
|
||||
url.setPath(url.path() + os.path.basename(executable))
|
||||
reply = self.ppdb_network.get(QNetworkRequest(url))
|
||||
reply.finished.connect(lambda: self._ppdb_lookup_finished(reply))
|
||||
|
||||
def _ppdb_lookup_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB lookup failed: %s", reply.errorString())
|
||||
return
|
||||
try:
|
||||
metadata = orjson.loads(bytes(reply.readAll().data()))
|
||||
except ValueError as error:
|
||||
logger.warning("Invalid PPDB metadata: %s", error)
|
||||
return
|
||||
if not isinstance(metadata, dict):
|
||||
return
|
||||
url = metadata.get("ppdb_url")
|
||||
if not isinstance(url, str):
|
||||
return
|
||||
self.ppdb_url = QUrl(url)
|
||||
if self.ppdb_url.scheme() in {"https", "http"} and self.ppdb_url.host():
|
||||
self.ppdb_button.show()
|
||||
|
||||
def _download_ppdb(self) -> None:
|
||||
self.ppdb_button.setEnabled(False)
|
||||
self.ppdb_button.setText(_("Downloading…"))
|
||||
request = QNetworkRequest(self.ppdb_url)
|
||||
request.setAttribute(
|
||||
QNetworkRequest.Attribute.CacheLoadControlAttribute,
|
||||
QNetworkRequest.CacheLoadControl.AlwaysNetwork,
|
||||
)
|
||||
reply = self.ppdb_network.get(request)
|
||||
reply.finished.connect(lambda: self._ppdb_download_finished(reply))
|
||||
|
||||
def _ppdb_download_finished(self, reply: QNetworkReply) -> None:
|
||||
reply.deleteLater()
|
||||
self.ppdb_button.setText(_("Download") + " PPDB")
|
||||
self.ppdb_button.setEnabled(True)
|
||||
if reply.error() != QNetworkReply.NetworkError.NoError:
|
||||
logger.warning("PPDB download failed: %s", reply.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
destination = QSaveFile(self.ppdb_path)
|
||||
data = reply.readAll()
|
||||
if (data.isEmpty() or not destination.open(QIODevice.OpenModeFlag.WriteOnly)
|
||||
or destination.write(data) != data.size() or not destination.commit()):
|
||||
logger.warning("Failed to save PPDB: %s", destination.errorString())
|
||||
self.ppdb_button.setText(_("Download process failed"))
|
||||
return
|
||||
self.ppdb_button.setText(_("Downloading Complete"))
|
||||
self.ppdb_button.setEnabled(False)
|
||||
|
||||
def _show_context_menu(
|
||||
self, report_view: QPlainTextEdit, theme: Any, position: QPoint
|
||||
) -> None:
|
||||
|
||||
@@ -25,7 +25,12 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
|
||||
dialog_actions = []
|
||||
|
||||
if context == 'file_explorer':
|
||||
if context == 'folder_import':
|
||||
dialog_actions = [
|
||||
("add_game", _("Apply")),
|
||||
("back", _("Close")),
|
||||
]
|
||||
elif context == 'file_explorer':
|
||||
dialog_actions = [
|
||||
("confirm", _("Open")),
|
||||
("add_game", _("Select Dir")),
|
||||
@@ -45,10 +50,9 @@ def create_dialog_hints_widget(theme, main_window, input_manager, context='defau
|
||||
elif context == 'settings':
|
||||
dialog_actions = [
|
||||
("confirm", _("Toggle")),
|
||||
("add_game", _("Save")),
|
||||
("prev_dir", _("Search")),
|
||||
("context_menu", _("Menu")),
|
||||
("back", _("Cancel")),
|
||||
("back", _("Close")),
|
||||
("prev_tab", _("Prev Tab")),
|
||||
("next_tab", _("Next Tab")),
|
||||
]
|
||||
|
||||
@@ -607,7 +607,9 @@ class FileExplorer(DraggableDialog):
|
||||
if not self.directory_only:
|
||||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||||
if self.file_filter:
|
||||
if isinstance(self.file_filter, str):
|
||||
if callable(self.file_filter):
|
||||
files = [f for f in files if self.file_filter(os.path.join(self.current_path, f))]
|
||||
elif isinstance(self.file_filter, str):
|
||||
files = [f for f in files if f.lower().endswith(self.file_filter)]
|
||||
elif isinstance(self.file_filter, tuple):
|
||||
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
@@ -10,7 +9,7 @@ from PySide6.QtWidgets import (
|
||||
QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout,
|
||||
QWidget, QCheckBox, QHeaderView, QMessageBox, QLabel, QTextEdit,
|
||||
QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView,
|
||||
QStackedWidget, QPushButton
|
||||
QStackedWidget
|
||||
)
|
||||
from PySide6.QtCore import QEvent, QMimeData, QObject, Qt, QTimer, QUrl
|
||||
from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QGuiApplication
|
||||
@@ -18,6 +17,7 @@ from shiboken6 import isValid
|
||||
|
||||
from portprotonqt.config import get_portproton_start_command, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.localization import _
|
||||
@@ -37,40 +37,6 @@ WINE_TABLE_ROW_BATCH_SIZE = 8
|
||||
WINE_ARCHIVE_EXTENSIONS = ('.tar.xz', '.tar.gz')
|
||||
|
||||
|
||||
def sort_wine_entries(entries: list[dict], source_name: str) -> list[dict]:
|
||||
"""Sort Wine entries, alternating CachyOS and WineLand releases."""
|
||||
if source_name.lower() != 'proton_cachyos':
|
||||
return sorted(entries, key=version_sort_key)
|
||||
|
||||
release_groups = []
|
||||
for is_wineland in (False, True):
|
||||
family_entries = sorted(
|
||||
(
|
||||
entry for entry in entries
|
||||
if ('proton-cachyos-wineland-' in entry.get('name', '').lower())
|
||||
== is_wineland
|
||||
),
|
||||
key=version_sort_key,
|
||||
)
|
||||
groups = []
|
||||
for entry in family_entries:
|
||||
release_name = re.sub(
|
||||
r'-(?:x86_64|arm64|aarch64)(?:_v\d+|-wow64)?$',
|
||||
'', entry.get('name', '').lower(),
|
||||
)
|
||||
if not groups or groups[-1][0] != release_name:
|
||||
groups.append((release_name, []))
|
||||
groups[-1][1].append(entry)
|
||||
release_groups.append(groups)
|
||||
|
||||
sorted_entries = []
|
||||
for index in range(max(map(len, release_groups), default=0)):
|
||||
for groups in release_groups:
|
||||
if index < len(groups):
|
||||
sorted_entries.extend(groups[index][1])
|
||||
return sorted_entries
|
||||
|
||||
|
||||
class ProtonManager(DraggableDialog):
|
||||
"""Dialog for managing Proton/Wine versions."""
|
||||
|
||||
@@ -93,7 +59,7 @@ class ProtonManager(DraggableDialog):
|
||||
self.main_window = None
|
||||
parent_widget = self.parent()
|
||||
while parent_widget:
|
||||
if hasattr(parent_widget, 'input_manager'):
|
||||
if hasattr(parent_widget, 'get_button_icon'):
|
||||
self.main_window = parent_widget
|
||||
break
|
||||
parent_widget = parent_widget.parent()
|
||||
@@ -103,6 +69,14 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
if self.input_manager:
|
||||
self.enable_proton_manager_mode()
|
||||
if self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_hints()
|
||||
|
||||
@staticmethod
|
||||
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
|
||||
@@ -274,7 +248,7 @@ class ProtonManager(DraggableDialog):
|
||||
self.download_progress = QProgressBar()
|
||||
self.download_progress.setMinimum(0)
|
||||
self.download_progress.setMaximum(100)
|
||||
self.cancel_btn = QPushButton(_('Cancel'))
|
||||
self.cancel_btn = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
|
||||
self.cancel_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_btn.clicked.connect(self.cancel_current_download)
|
||||
progress_layout.addWidget(self.download_progress, 4)
|
||||
@@ -290,18 +264,18 @@ class ProtonManager(DraggableDialog):
|
||||
layout.addWidget(self.hints_widget)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.download_btn = QPushButton(_('Download Selected'))
|
||||
self.download_btn = AutoSizeButton(_('Download Selected'), icon=theme_manager.get_icon('apply', as_path=True))
|
||||
self.download_btn.clicked.connect(self.download_selected)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.download_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.clear_btn = QPushButton(_('Clear All'))
|
||||
self.clear_btn = AutoSizeButton(_('Clear All'), icon=theme_manager.get_icon('delete', as_path=True))
|
||||
self.clear_btn.clicked.connect(self.clear_selection)
|
||||
self.clear_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.open_folder_btn = QPushButton(_('Open Folder'))
|
||||
self.open_folder_btn = AutoSizeButton(_('Open Folder'), icon=theme_manager.get_icon('folder', as_path=True))
|
||||
self.open_folder_btn.clicked.connect(self._open_wine_folder)
|
||||
self.open_folder_btn.setEnabled(bool(self.portproton_location))
|
||||
self.open_folder_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_btn_dialog = QPushButton(_('Cancel'))
|
||||
self.cancel_btn_dialog = AutoSizeButton(_('Cancel'), icon=theme_manager.get_icon('cancel', as_path=True))
|
||||
self.cancel_btn_dialog.clicked.connect(self.reject)
|
||||
self.cancel_btn_dialog.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button_layout.addWidget(self.download_btn)
|
||||
@@ -312,18 +286,6 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
self.tab_widget.currentChanged.connect(self.tab_changed)
|
||||
|
||||
if self.input_manager and self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
|
||||
def _open_wine_folder(self) -> None:
|
||||
if not self.portproton_location:
|
||||
return
|
||||
@@ -352,7 +314,7 @@ class ProtonManager(DraggableDialog):
|
||||
tabs = []
|
||||
for source_name, entries in metadata.items():
|
||||
filtered_entries = self.filter_entries_by_cpu_level(entries, source_name)
|
||||
tabs.append((source_name, sort_wine_entries(filtered_entries, source_name)))
|
||||
tabs.append((source_name, sorted(filtered_entries, key=version_sort_key)))
|
||||
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
|
||||
self.selected_assets.clear()
|
||||
self.tab_widget.clear()
|
||||
@@ -390,14 +352,9 @@ class ProtonManager(DraggableDialog):
|
||||
logger.error(f"Wine data loading failed: {error_msg}")
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
error_label = QLabel(_("Error loading WINE/Proton: {error}").format(error=error_msg))
|
||||
error_label.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||||
error_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
if hasattr(self, 'tab_widget'):
|
||||
error_tab = QWidget()
|
||||
error_layout = QVBoxLayout(error_tab)
|
||||
error_layout.addWidget(error_label)
|
||||
self.tab_widget.addTab(error_tab, _("Error"))
|
||||
self.tab_widget.clear()
|
||||
self.create_installed_tab()
|
||||
|
||||
def process_metadata(self, metadata):
|
||||
self.cpu_level = get_cpu_level()
|
||||
@@ -425,7 +382,7 @@ class ProtonManager(DraggableDialog):
|
||||
if entry_is_arm == is_arm:
|
||||
filtered_entries.append(entry)
|
||||
entries = filtered_entries
|
||||
if source_name.lower() != 'proton_cachyos':
|
||||
if source_name.lower() not in ('proton_cachyos', 'proton_wineland'):
|
||||
return entries
|
||||
if self.cpu_level >= 4:
|
||||
return entries
|
||||
@@ -482,7 +439,7 @@ class ProtonManager(DraggableDialog):
|
||||
if url_filename:
|
||||
entry['filename'] = url_filename
|
||||
all_entries.append(entry)
|
||||
all_entries = sort_wine_entries(all_entries, source_name)
|
||||
all_entries.sort(key=version_sort_key)
|
||||
table = self._create_empty_wine_tab(source_name, len(all_entries))
|
||||
for row_index, entry in enumerate(all_entries):
|
||||
self.add_asset_row_from_json(table, row_index, entry, source_name)
|
||||
@@ -672,15 +629,16 @@ class ProtonManager(DraggableDialog):
|
||||
installed_versions = []
|
||||
seen_paths = set()
|
||||
version_dirs = [dist_path]
|
||||
steam_dir = get_steam_compatibilitytools_dir()
|
||||
if steam_dir is not None:
|
||||
version_dirs.append(str(steam_dir))
|
||||
steam_home = get_steam_home()
|
||||
if steam_home is not None:
|
||||
for steam_lib in get_steam_libs(steam_home):
|
||||
common_dir = steam_lib / "steamapps" / "common"
|
||||
if common_dir.is_dir():
|
||||
version_dirs.append(str(common_dir))
|
||||
if not ui_config.get_disable_steam_proton_scanning():
|
||||
steam_dir = get_steam_compatibilitytools_dir()
|
||||
if steam_dir is not None:
|
||||
version_dirs.append(str(steam_dir))
|
||||
steam_home = get_steam_home()
|
||||
if steam_home is not None:
|
||||
for steam_lib in get_steam_libs(steam_home):
|
||||
common_dir = steam_lib / "steamapps" / "common"
|
||||
if common_dir.is_dir():
|
||||
version_dirs.append(str(common_dir))
|
||||
|
||||
for version_dir in version_dirs:
|
||||
for version_name in os.listdir(version_dir):
|
||||
@@ -716,7 +674,7 @@ class ProtonManager(DraggableDialog):
|
||||
version_item = QTableWidgetItem(version_name)
|
||||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 1, version_item)
|
||||
size_item = QTableWidgetItem(_("Unknown"))
|
||||
size_item = QTableWidgetItem(_("Loading…"))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
@@ -995,8 +953,8 @@ class ProtonManager(DraggableDialog):
|
||||
msg_box.setText(_("Are you sure you want to delete {} selected WINE/Proton?\n\nThis action cannot be undone.").format(len(versions_to_remove)))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -3,13 +3,24 @@
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from html import escape
|
||||
from typing import cast, TYPE_CHECKING
|
||||
|
||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, QProcess, QTimer, QUrl
|
||||
from PySide6.QtGui import QColor, QContextMenuEvent, QDesktopServices, QGuiApplication, QIcon
|
||||
from PySide6.QtGui import (
|
||||
QColor,
|
||||
QContextMenuEvent,
|
||||
QDesktopServices,
|
||||
QGuiApplication,
|
||||
QHideEvent,
|
||||
QIcon,
|
||||
QKeyEvent,
|
||||
QPalette,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QAbstractItemView,
|
||||
QAbstractSpinBox,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QHBoxLayout,
|
||||
@@ -18,12 +29,16 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QSlider,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QTabWidget,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QPlainTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QTextEdit,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -39,6 +54,7 @@ from portprotonqt.config import (
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
from portprotonqt.dialogs.proton_manager import show_proton_manager
|
||||
from portprotonqt.dialogs.settings_mangohud import MANGOHUD_ENV_KEYS, MangoHudSettingsMixin
|
||||
from portprotonqt.dialogs.settings_gamescope import GAMESCOPE_ENV_KEYS, GamescopeSettingsMixin
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
|
||||
@@ -184,6 +200,12 @@ class ExeSettingsDialog(
|
||||
_normalize_prefix_directories(prefixes_dir)
|
||||
self.prefix_options = get_available_prefix_options(self.portproton_path)
|
||||
|
||||
self.saving = False
|
||||
self.settings_loading = True
|
||||
self.close_after_save = False
|
||||
self.save_timer = QTimer(self)
|
||||
self.save_timer.setSingleShot(True)
|
||||
self.save_timer.timeout.connect(self.apply_changes)
|
||||
self.current_settings = {}
|
||||
self.value_widgets = {}
|
||||
self.original_values = {}
|
||||
@@ -222,6 +244,7 @@ class ExeSettingsDialog(
|
||||
app = QApplication.instance()
|
||||
if isinstance(app, QApplication):
|
||||
app.focusChanged.connect(self._on_focus_changed)
|
||||
app.installEventFilter(self)
|
||||
|
||||
self.input_manager = None
|
||||
self.main_window = None
|
||||
@@ -267,6 +290,11 @@ class ExeSettingsDialog(
|
||||
"""Resolve run-after executable relative to the main executable."""
|
||||
if not exe_path:
|
||||
return exe_path
|
||||
if "," in exe_path and not os.path.isfile(os.path.expanduser(exe_path)):
|
||||
return ",".join(
|
||||
self._resolve_run_after_exe_path(path.strip())
|
||||
for path in exe_path.split(",") if path.strip()
|
||||
)
|
||||
normalized = os.path.normpath(os.path.expanduser(exe_path))
|
||||
if os.path.isabs(normalized):
|
||||
return normalized
|
||||
@@ -277,6 +305,70 @@ class ExeSettingsDialog(
|
||||
return normalized
|
||||
return os.path.normpath(os.path.join(game_dir, normalized))
|
||||
|
||||
def _toggle_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
row = next(index for index in range(table.rowCount())
|
||||
if table.cellWidget(index, 1) is target.parentWidget())
|
||||
expanded = bool(target.property("ppqt_run_after_expanded"))
|
||||
target.setProperty("ppqt_run_after_expanded", not expanded)
|
||||
if expanded:
|
||||
while row + 1 < table.rowCount():
|
||||
item = table.item(row + 1, 0)
|
||||
if item is None or item.data(Qt.ItemDataRole.UserRole) != "run_after_entry":
|
||||
break
|
||||
table.removeRow(row + 1)
|
||||
return
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
if not target.text():
|
||||
entries = [""]
|
||||
for index, path in enumerate(entries):
|
||||
entry_row = row + index + 1
|
||||
table.insertRow(entry_row)
|
||||
item = QTableWidgetItem(path)
|
||||
item.setData(Qt.ItemDataRole.UserRole, "run_after_entry")
|
||||
item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
table.setItem(entry_row, 0, item)
|
||||
entry_edit = QLineEdit(path)
|
||||
entry_edit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
entry_edit.installEventFilter(self)
|
||||
entry_edit.textChanged.connect(
|
||||
lambda value, offset=index, widget=target: self._update_run_after_entry(widget, offset, value)
|
||||
)
|
||||
table.setCellWidget(entry_row, 0, entry_edit)
|
||||
button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False, offset=index, widget=target: self._remove_run_after_entry(widget, offset)
|
||||
)
|
||||
table.setCellWidget(entry_row, 1, button)
|
||||
table.resizeRowsToContents()
|
||||
|
||||
def _update_run_after_entry(self, target: QLineEdit, index: int, value: str) -> None:
|
||||
entries = target.text().split(",")
|
||||
if index < len(entries):
|
||||
entries[index] = value
|
||||
target.setText(",".join(entries))
|
||||
|
||||
def _remove_run_after_entry(self, target: QLineEdit, index: int) -> None:
|
||||
entries = [target.text()] if os.path.isfile(target.text()) else target.text().split(",")
|
||||
entries.pop(index)
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setText(",".join(entries))
|
||||
if entries:
|
||||
self._toggle_run_after_entries(target)
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _clear_run_after_entries(self, target: QLineEdit) -> None:
|
||||
table = self.advanced_table
|
||||
for row in reversed(range(table.rowCount())):
|
||||
item = table.item(row, 0)
|
||||
if item and item.data(Qt.ItemDataRole.UserRole) == "run_after_entry":
|
||||
table.removeRow(row)
|
||||
target.setProperty("ppqt_run_after_expanded", False)
|
||||
target.clear()
|
||||
if target.isVisible():
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _get_setting_file_selector_path(self, current_path: str) -> str:
|
||||
"""Get initial path for setting file selectors."""
|
||||
initial_path = os.path.expanduser("~")
|
||||
@@ -326,7 +418,6 @@ class ExeSettingsDialog(
|
||||
self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt")
|
||||
if self.gamescope_available:
|
||||
self.tab_widget.addTab(self.gamescope_tab, "Gamescope")
|
||||
self.tab_widget.currentChanged.connect(self.on_table_selection_changed)
|
||||
|
||||
self.settings_table = QTableWidget()
|
||||
self.settings_table.setAlternatingRowColors(True)
|
||||
@@ -368,8 +459,6 @@ class ExeSettingsDialog(
|
||||
self.settings_container.addWidget(settings_preloader_container)
|
||||
self.settings_container.addWidget(self.settings_table)
|
||||
self.main_tab_layout.addWidget(self.settings_container)
|
||||
self.settings_table.currentCellChanged.connect(self.on_table_selection_changed)
|
||||
self.settings_table.cellEntered.connect(self.on_table_cell_hovered)
|
||||
self.settings_table.installEventFilter(self)
|
||||
|
||||
self.advanced_table = QTableWidget()
|
||||
@@ -383,7 +472,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, 230)
|
||||
self.advanced_table.horizontalHeader().resizeSection(1, self.theme.RUN_AFTER_VALUE_WIDTH)
|
||||
self.advanced_table.setWordWrap(True)
|
||||
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
|
||||
@@ -421,9 +510,13 @@ class ExeSettingsDialog(
|
||||
self.favorites_table.customContextMenuRequested.connect(
|
||||
lambda pos: self.show_setting_context_menu(self.favorites_table, pos)
|
||||
)
|
||||
for table in (self.settings_table, self.advanced_table, self.favorites_table):
|
||||
table.viewport().setProperty("ppqt_settings_values", True)
|
||||
table.viewport().installEventFilter(self)
|
||||
table.setColumnHidden(2, True)
|
||||
table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Fixed)
|
||||
self.favorites_tab_layout.addWidget(self.favorites_table)
|
||||
self.favorites_table.currentCellChanged.connect(self.on_table_selection_changed)
|
||||
self.favorites_table.cellEntered.connect(self.on_table_cell_hovered)
|
||||
|
||||
self.advanced_preloader = Preloader()
|
||||
advanced_preloader_container = QWidget()
|
||||
@@ -442,8 +535,6 @@ class ExeSettingsDialog(
|
||||
self.advanced_container.addWidget(advanced_preloader_container)
|
||||
self.advanced_container.addWidget(self.advanced_table)
|
||||
self.advanced_tab_layout.addWidget(self.advanced_container)
|
||||
self.advanced_table.currentCellChanged.connect(self.on_table_selection_changed)
|
||||
self.advanced_table.cellEntered.connect(self.on_table_cell_hovered)
|
||||
self.advanced_table.installEventFilter(self)
|
||||
|
||||
self.setup_mangohud_tab()
|
||||
@@ -458,7 +549,6 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip.setStyleSheet(self.theme.TOOLTIP_STYLE)
|
||||
self.gamepad_tooltip.setVisible(False)
|
||||
self.gamepad_tooltip.setParent(self)
|
||||
self.gamepad_tooltip.setWindowFlags(Qt.WindowType.ToolTip)
|
||||
self.gamepad_tooltip.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
|
||||
self.gamepad_tooltip.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
self.gamepad_tooltip_timer = QTimer(self)
|
||||
@@ -466,25 +556,20 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip_timer.timeout.connect(lambda: self.gamepad_tooltip.setVisible(False))
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply", as_path=True))
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel", as_path=True))
|
||||
self.cancel_button = AutoSizeButton(_("Close"), icon=ThemeManager().get_icon("cancel", as_path=True))
|
||||
self.open_ppdb_button = AutoSizeButton(_("Edit PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
self.clear_ppdb_button = AutoSizeButton(_("Clear PPDB"), icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.open_ppdb_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.clear_ppdb_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.apply_button.installEventFilter(self)
|
||||
self.cancel_button.installEventFilter(self)
|
||||
self.open_ppdb_button.installEventFilter(self)
|
||||
self.clear_ppdb_button.installEventFilter(self)
|
||||
button_layout.addWidget(self.apply_button)
|
||||
button_layout.addWidget(self.cancel_button)
|
||||
button_layout.addWidget(self.open_ppdb_button)
|
||||
button_layout.addWidget(self.clear_ppdb_button)
|
||||
self.main_layout.addLayout(button_layout)
|
||||
|
||||
self.apply_button.clicked.connect(self.apply_changes)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
self.open_ppdb_button.clicked.connect(self.open_ppdb_file)
|
||||
self.clear_ppdb_button.clicked.connect(self.clear_ppdb_file)
|
||||
@@ -492,6 +577,7 @@ class ExeSettingsDialog(
|
||||
|
||||
def load_current_settings(self):
|
||||
"""Load available toggles and current settings."""
|
||||
self.settings_loading = True
|
||||
self.settings_container.setCurrentIndex(0)
|
||||
self.advanced_container.setCurrentIndex(0)
|
||||
|
||||
@@ -611,6 +697,8 @@ class ExeSettingsDialog(
|
||||
|
||||
self.settings_container.setCurrentIndex(1)
|
||||
self.advanced_container.setCurrentIndex(1)
|
||||
self.settings_loading = False
|
||||
self._connect_autosave()
|
||||
|
||||
def open_ppdb_file(self):
|
||||
"""Open the PPDB file for the current executable."""
|
||||
@@ -653,8 +741,8 @@ class ExeSettingsDialog(
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -674,8 +762,8 @@ class ExeSettingsDialog(
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
@@ -697,8 +785,8 @@ class ExeSettingsDialog(
|
||||
self.user_conf_changes = [
|
||||
change for change in self.user_conf_changes if change.split('=', 1)[1]
|
||||
]
|
||||
self.saving = True
|
||||
self.close_after_user_conf_changes = False
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
|
||||
def populate_table(self):
|
||||
@@ -769,6 +857,7 @@ class ExeSettingsDialog(
|
||||
desc_item.setForeground(QColor(self.theme.color_disabled_text))
|
||||
self.settings_table.setItem(row, 2, desc_item)
|
||||
|
||||
name_item.setText(f"{name_item.text()}\n{description}")
|
||||
self.settings_table.setItem(row, 0, name_item)
|
||||
self.value_widgets[(row, 1)] = value_widget
|
||||
|
||||
@@ -782,7 +871,6 @@ class ExeSettingsDialog(
|
||||
else:
|
||||
self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
self.on_table_selection_changed()
|
||||
|
||||
def populate_advanced(self):
|
||||
"""Populate the advanced tab with table format."""
|
||||
@@ -831,19 +919,33 @@ class ExeSettingsDialog(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
combo.addItems(setting['options'])
|
||||
if setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
|
||||
combo.insertSeparator(combo.count())
|
||||
combo.addItem(_('Download Wine/Proton...'), 'download_wine')
|
||||
combo.activated.connect(
|
||||
lambda index, c=combo, k=setting['key']: self._open_wine_manager_from_combo(
|
||||
index, c, k
|
||||
)
|
||||
)
|
||||
if setting['key'] in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
|
||||
combo.setEditable(True)
|
||||
combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||||
prefix_line_edit = combo.lineEdit()
|
||||
if prefix_line_edit is not None:
|
||||
prefix_line_edit.setPlaceholderText(_("Enter prefix name"))
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
|
||||
current_raw = current.get(setting['key'], '') if self.user_conf else (
|
||||
current.get(setting['key'], setting['default'])
|
||||
@@ -865,7 +967,9 @@ class ExeSettingsDialog(
|
||||
current_val = reverse_map[current_raw]
|
||||
|
||||
current_val_text = current_val if isinstance(current_val, str) else ''
|
||||
if current_val_text and current_val_text not in setting['options']:
|
||||
if setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE') and current_val_text == _('Download Wine/Proton...'):
|
||||
current_val_text = setting['options'][0]
|
||||
if current_val_text and combo.findText(current_val_text) < 0:
|
||||
combo.addItem(current_val_text)
|
||||
combo.setCurrentText(current_val_text)
|
||||
|
||||
@@ -916,10 +1020,12 @@ class ExeSettingsDialog(
|
||||
if setting['key'] == 'PW_RUN_AFTER_EXE':
|
||||
text_container = QWidget()
|
||||
text_container.setProperty("ppqt_run_after_exe_widget", True)
|
||||
text_layout = QHBoxLayout(text_container)
|
||||
text_layout.setContentsMargins(0, 0, 0, 0)
|
||||
text_layout.setSpacing(6)
|
||||
text_layout.addWidget(line_edit)
|
||||
container_layout = QVBoxLayout(text_container)
|
||||
container_layout.setContentsMargins(0, 0, 0, 0)
|
||||
container_layout.addWidget(line_edit)
|
||||
line_edit.hide()
|
||||
text_layout = QHBoxLayout()
|
||||
container_layout.addLayout(text_layout)
|
||||
|
||||
browse_button = AutoSizeButton("...", icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
browse_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
@@ -931,26 +1037,38 @@ class ExeSettingsDialog(
|
||||
def open_run_after_exe_selector(
|
||||
_checked: bool = False,
|
||||
target_line_edit=line_edit,
|
||||
file_filter="",
|
||||
file_filter=lambda path: path.lower().endswith(".exe") or os.access(path, os.X_OK),
|
||||
):
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
initial_path = self._get_setting_file_selector_path(
|
||||
target_line_edit.text().strip()
|
||||
)
|
||||
|
||||
file_explorer = FileExplorer(
|
||||
self,
|
||||
theme=self.theme,
|
||||
file_filter=file_filter,
|
||||
initial_path=initial_path,
|
||||
initial_path=self._get_setting_file_selector_path(
|
||||
target_line_edit.text().rsplit(",", 1)[-1].strip()
|
||||
),
|
||||
)
|
||||
file_explorer.file_signal.file_selected.connect(
|
||||
lambda file_path, target=target_line_edit: target.setText(os.path.normpath(file_path))
|
||||
lambda path, target=target_line_edit: target.setText(
|
||||
",".join(filter(None, [target.text().strip(), os.path.normpath(path)]))
|
||||
)
|
||||
)
|
||||
file_explorer.exec()
|
||||
|
||||
browse_button.clicked.connect(open_run_after_exe_selector)
|
||||
expand_button = AutoSizeButton("≡")
|
||||
expand_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
expand_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._toggle_run_after_entries(target)
|
||||
)
|
||||
clear_button = AutoSizeButton("", icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
clear_button.clicked.connect(
|
||||
lambda _checked=False, target=line_edit: self._clear_run_after_entries(target)
|
||||
)
|
||||
text_layout.addWidget(clear_button)
|
||||
text_layout.addWidget(expand_button)
|
||||
text_layout.addWidget(browse_button)
|
||||
self.advanced_table.setCellWidget(row, 1, text_container)
|
||||
else:
|
||||
@@ -958,6 +1076,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_widgets[setting['key']] = line_edit
|
||||
self.original_display_values[setting['key']] = current_val
|
||||
|
||||
name_item.setText(f"{setting['name']}\n{setting['description']}")
|
||||
desc_item = QTableWidgetItem(setting['description'])
|
||||
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
|
||||
@@ -965,8 +1084,7 @@ class ExeSettingsDialog(
|
||||
desc_item.setForeground(QColor(self.theme.color_disabled_text))
|
||||
self.advanced_table.setItem(row, 2, desc_item)
|
||||
|
||||
if self.advanced_table.rowCount() > 0:
|
||||
self.on_table_selection_changed()
|
||||
self.advanced_table.resizeRowsToContents()
|
||||
self._install_line_edit_event_filters()
|
||||
|
||||
def _get_current_settings_table(self) -> QTableWidget | None:
|
||||
@@ -1073,8 +1191,10 @@ class ExeSettingsDialog(
|
||||
break
|
||||
|
||||
source_header = source_table.horizontalHeader()
|
||||
if source_header.sectionResizeMode(1) == QHeaderView.ResizeMode.ResizeToContents:
|
||||
source_table.resizeColumnToContents(1)
|
||||
target_header = self.favorites_table.horizontalHeader()
|
||||
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
target_header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
target_header.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
|
||||
target_header.setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
target_header.resizeSection(0, source_header.sectionSize(0))
|
||||
@@ -1148,7 +1268,7 @@ class ExeSettingsDialog(
|
||||
self._add_favorite_line_edit(row, source_widget)
|
||||
|
||||
def _set_favorite_text_cells(self, row: int, key: str, name: str, description: str) -> None:
|
||||
name_item = QTableWidgetItem(name)
|
||||
name_item = QTableWidgetItem(f"{name}\n{description}")
|
||||
name_item.setData(Qt.ItemDataRole.UserRole, key)
|
||||
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
desc_item = QTableWidgetItem(description)
|
||||
@@ -1178,8 +1298,8 @@ class ExeSettingsDialog(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
combo.setEditable(source_widget.isEditable())
|
||||
for index in range(source_widget.count()):
|
||||
combo.addItem(source_widget.itemText(index), source_widget.itemData(index))
|
||||
combo.setInsertPolicy(source_widget.insertPolicy())
|
||||
combo.setModel(source_widget.model())
|
||||
combo.setCurrentText(source_widget.currentText())
|
||||
combo.setEnabled(source_widget.isEnabled())
|
||||
settings_combo_style = getattr(self.theme, "SETTINGS_TABLE_COMBOBOX_STYLE", "")
|
||||
@@ -1189,9 +1309,14 @@ class ExeSettingsDialog(
|
||||
+ self.theme.SCROLL_STYLE
|
||||
)
|
||||
combo.currentTextChanged.connect(source_widget.setCurrentText)
|
||||
combo.highlighted.connect(
|
||||
lambda row_idx, c=combo: self._on_combo_highlighted(row_idx, c),
|
||||
source_widget.currentTextChanged.connect(combo.setCurrentText)
|
||||
combo.activated.connect(source_widget.activated.emit)
|
||||
combo.view().setMouseTracking(True)
|
||||
combo.view().installEventFilter(self)
|
||||
combo.view().selectionModel().currentChanged.connect(
|
||||
lambda index, _previous, c=combo: self._on_combo_highlighted(index.row(), c),
|
||||
)
|
||||
combo.view().entered.connect(lambda index, c=combo: self._on_combo_highlighted(index.row(), c))
|
||||
self.favorites_table.setCellWidget(row, 1, combo)
|
||||
|
||||
def _add_favorite_line_edit(self, row: int, source_widget: QLineEdit) -> None:
|
||||
@@ -1227,6 +1352,8 @@ class ExeSettingsDialog(
|
||||
|
||||
def _on_combo_highlighted(self, row: int, combo: QComboBox) -> None:
|
||||
view = combo.view()
|
||||
if not self.isVisible() or not combo.isVisible() or not view.isVisible():
|
||||
return
|
||||
index = view.model().index(row, 0)
|
||||
if not index.isValid():
|
||||
self.gamepad_tooltip.hide()
|
||||
@@ -1236,28 +1363,77 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip.hide()
|
||||
return
|
||||
fm = view.fontMetrics()
|
||||
text_rect = fm.boundingRect(0, 0, 480, 1000, Qt.TextFlag.TextWordWrap, text)
|
||||
w = min(500, text_rect.width() + 25)
|
||||
h = min(300, text_rect.height() + 25)
|
||||
item_rect = view.visualRect(index)
|
||||
text_width = max(fm.horizontalAdvance(text), view.sizeHintForIndex(index).width())
|
||||
if text_width <= item_rect.intersected(view.viewport().rect()).width():
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
self.gamepad_tooltip.setText(text)
|
||||
self.gamepad_tooltip.setWordWrap(False)
|
||||
w = min(500, self.gamepad_tooltip.sizeHint().width())
|
||||
self.gamepad_tooltip.setWordWrap(True)
|
||||
h = min(300, self.gamepad_tooltip.heightForWidth(w))
|
||||
item_center_y = view.viewport().mapToGlobal(item_rect.center()).y()
|
||||
combo_right_x = combo.mapToGlobal(combo.rect().topRight()).x()
|
||||
pos = QPoint(combo_right_x + 4, item_center_y - h // 2)
|
||||
screen = QGuiApplication.screenAt(pos) or QGuiApplication.primaryScreen()
|
||||
screen = combo.screen()
|
||||
if screen:
|
||||
ar = screen.availableGeometry()
|
||||
ar = screen.availableGeometry().intersected(self.rect().translated(self.mapToGlobal(QPoint())))
|
||||
w, h = min(w, ar.width()), min(h, ar.height())
|
||||
if pos.x() + w > ar.right():
|
||||
pos.setX(ar.right() - w)
|
||||
pos.setX(combo.mapToGlobal(combo.rect().topLeft()).x() - w)
|
||||
if pos.x() < ar.left():
|
||||
pos.setX(ar.left())
|
||||
if pos.y() + h > ar.bottom():
|
||||
pos.setY(ar.bottom() - h)
|
||||
if pos.y() < ar.top():
|
||||
pos.setY(ar.top())
|
||||
self.gamepad_tooltip.setText(text)
|
||||
self.gamepad_tooltip.hide()
|
||||
self.gamepad_tooltip.setFixedSize(w, h)
|
||||
self.gamepad_tooltip.move(pos)
|
||||
self.gamepad_tooltip.move(self.mapFromGlobal(pos))
|
||||
self.gamepad_tooltip.setVisible(True)
|
||||
self.gamepad_tooltip.raise_()
|
||||
self.gamepad_tooltip_timer.start(max(2500, min(12000, 1500 + len(text) * 30)))
|
||||
|
||||
def _open_wine_manager_from_combo(self, index: int, combo: QComboBox, key: str) -> None:
|
||||
if combo.itemData(index) != 'download_wine':
|
||||
return
|
||||
combo.setCurrentText(self.original_display_values.get(key, ''))
|
||||
show_proton_manager(
|
||||
self, self.portproton_path, input_manager=self.input_manager
|
||||
)
|
||||
|
||||
def refresh_wine_combo(self) -> None:
|
||||
wine_key = 'PW_DEFAULT_WINE_USE' if self.user_conf else 'PW_WINE_USE'
|
||||
combo = self.advanced_widgets.get(wine_key)
|
||||
if not self.portproton_path or not isinstance(combo, QComboBox):
|
||||
return
|
||||
|
||||
current_text = combo.currentText()
|
||||
self.dist_options = get_available_wine_options(
|
||||
self.portproton_path,
|
||||
'' if self.game_source == 'steam' else _('System WINE'),
|
||||
self.game_source == 'steam',
|
||||
)
|
||||
options = next(setting['options'] for setting in get_advanced_settings(
|
||||
disabled_text=_('disabled'),
|
||||
logical_core_options=self.logical_core_options,
|
||||
numa_nodes=self.numa_nodes,
|
||||
dist_options=self.dist_options,
|
||||
prefix_options=self.prefix_options,
|
||||
) if setting['key'] == 'PW_WINE_USE')
|
||||
if self.user_conf:
|
||||
options = [_('Default')] + options
|
||||
combo.blockSignals(True)
|
||||
combo.clear()
|
||||
combo.addItems(options)
|
||||
if current_text and current_text not in options:
|
||||
combo.addItem(current_text)
|
||||
combo.insertSeparator(combo.count())
|
||||
combo.addItem(_('Download Wine/Proton...'), 'download_wine')
|
||||
combo.setCurrentText(current_text)
|
||||
combo.blockSignals(False)
|
||||
|
||||
def _select_checkbox_row(self, widget: QCheckBox) -> bool:
|
||||
for (row, column), checkbox in self.value_widgets.items():
|
||||
if checkbox != widget:
|
||||
@@ -1291,7 +1467,7 @@ class ExeSettingsDialog(
|
||||
desc_item = self.favorites_table.item(row, 2)
|
||||
should_show = False
|
||||
|
||||
if name_item and search_text in name_item.text().lower():
|
||||
if name_item and search_text in (name_item.data(Qt.ItemDataRole.AccessibleTextRole) or name_item.text()).lower():
|
||||
should_show = True
|
||||
elif desc_item and search_text in desc_item.text().lower():
|
||||
should_show = True
|
||||
@@ -1303,7 +1479,7 @@ class ExeSettingsDialog(
|
||||
desc_item = self.settings_table.item(row, 2)
|
||||
should_show = False
|
||||
|
||||
if name_item and search_text in name_item.text().lower():
|
||||
if name_item and search_text in (name_item.data(Qt.ItemDataRole.AccessibleTextRole) or name_item.text()).lower():
|
||||
should_show = True
|
||||
elif desc_item and search_text in desc_item.text().lower():
|
||||
should_show = True
|
||||
@@ -1315,7 +1491,7 @@ class ExeSettingsDialog(
|
||||
desc_item = self.advanced_table.item(row, 2)
|
||||
should_show = False
|
||||
|
||||
if name_item and search_text in name_item.text().lower():
|
||||
if name_item and search_text in (name_item.data(Qt.ItemDataRole.AccessibleTextRole) or name_item.text()).lower():
|
||||
should_show = True
|
||||
elif desc_item and search_text in desc_item.text().lower():
|
||||
should_show = True
|
||||
@@ -1326,8 +1502,59 @@ class ExeSettingsDialog(
|
||||
self._filter_vkbasalt_settings(search_text)
|
||||
self._filter_gamescope_settings(search_text)
|
||||
|
||||
def _connect_autosave(self) -> None:
|
||||
for widget in self.findChildren(QWidget):
|
||||
if widget.property("ppqt_autosave") or widget is self.search_edit:
|
||||
continue
|
||||
if isinstance(widget, QLineEdit):
|
||||
if isinstance(widget.parent(), QComboBox):
|
||||
continue
|
||||
widget.textChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QComboBox):
|
||||
widget.currentTextChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QCheckBox):
|
||||
widget.toggled.connect(self._schedule_save)
|
||||
elif isinstance(widget, QSlider):
|
||||
widget.valueChanged.connect(self._schedule_save)
|
||||
elif isinstance(widget, QPushButton):
|
||||
widget.clicked.connect(self._schedule_save)
|
||||
else:
|
||||
continue
|
||||
widget.setProperty("ppqt_autosave", True)
|
||||
|
||||
def _schedule_save(self) -> None:
|
||||
if not self.settings_loading:
|
||||
self.save_timer.start(0)
|
||||
|
||||
def _on_save_error(self, error: QProcess.ProcessError) -> None:
|
||||
if error != QProcess.ProcessError.FailedToStart:
|
||||
return
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
logger.error("Failed to start settings save process: %s", cast(QProcess, self.sender()).errorString())
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
|
||||
def _finish_save(self) -> None:
|
||||
for key, value in self.saved_values.items():
|
||||
if self.current_settings.get(key) == self.saved_current_settings.get(key):
|
||||
self.current_settings[key] = value
|
||||
self.original_values.update(self.saved_values)
|
||||
self.original_display_values.update(self.saved_display_values)
|
||||
self.mangohud_original_values.update(self.saved_values)
|
||||
self.vkbasalt_original_values.update(self.saved_values)
|
||||
self.gamescope_original_values.update(self.saved_values)
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
self.saving = False
|
||||
self.apply_changes()
|
||||
if self.close_after_save and not self.saving:
|
||||
self.close()
|
||||
|
||||
def apply_changes(self):
|
||||
"""Apply changes by collecting diffs from both main and advanced tabs."""
|
||||
if self.settings_loading or self.saving:
|
||||
return
|
||||
self.save_timer.stop()
|
||||
changes = []
|
||||
mangohud_enabled = False
|
||||
|
||||
@@ -1359,6 +1586,8 @@ class ExeSettingsDialog(
|
||||
for key, widget in self.advanced_widgets.items():
|
||||
orig_val = self.original_display_values.get(key, '')
|
||||
if isinstance(widget, QComboBox):
|
||||
if widget.currentData() == 'download_wine':
|
||||
continue
|
||||
new_val = widget.currentText()
|
||||
if self.user_conf and new_val == _('Default'):
|
||||
new_val = ''
|
||||
@@ -1443,26 +1672,42 @@ class ExeSettingsDialog(
|
||||
if not changes:
|
||||
return
|
||||
|
||||
self.saving = True
|
||||
self.clear_ppdb_button.setEnabled(False)
|
||||
self.saved_current_settings = self.current_settings.copy()
|
||||
self.saved_values = dict(change.split('=', 1) for change in changes)
|
||||
self.saved_display_values = {
|
||||
key: widget.currentText() if isinstance(widget, QComboBox) else widget.text().strip()
|
||||
for key, widget in self.advanced_widgets.items()
|
||||
if isinstance(widget, (QComboBox, QLineEdit))
|
||||
}
|
||||
for key in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
|
||||
if key in self.saved_display_values:
|
||||
self.saved_display_values[key] = re.sub(
|
||||
r"[ \t]", "_", self.saved_display_values[key].strip()
|
||||
).upper()
|
||||
if 'PW_RUN_AFTER_EXE' in self.saved_values:
|
||||
self.saved_display_values['PW_RUN_AFTER_EXE'] = self.saved_values['PW_RUN_AFTER_EXE']
|
||||
if self.user_conf:
|
||||
self.user_conf_changes = changes
|
||||
self.close_after_user_conf_changes = True
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
return
|
||||
|
||||
process = QProcess(self)
|
||||
self.save_process = QProcess(self)
|
||||
process = self.save_process
|
||||
process.finished.connect(self.on_edit_db_finished)
|
||||
process.errorOccurred.connect(self._on_save_error)
|
||||
process_args = ["cli", "--edit-db", self.exe_path] + changes
|
||||
args = self._get_process_args(process_args)
|
||||
process.start(args[0], args[1:])
|
||||
self.apply_button.setEnabled(False)
|
||||
|
||||
def _apply_next_user_conf_change(self) -> None:
|
||||
if not self.user_conf_changes:
|
||||
self.apply_button.setEnabled(True)
|
||||
if self.close_after_user_conf_changes:
|
||||
self.close()
|
||||
self._finish_save()
|
||||
else:
|
||||
self.saving = False
|
||||
self.load_current_settings()
|
||||
return
|
||||
|
||||
@@ -1473,33 +1718,48 @@ class ExeSettingsDialog(
|
||||
command.append(value)
|
||||
self.user_conf_process = QProcess(self)
|
||||
self.user_conf_process.finished.connect(self._on_user_conf_change_finished)
|
||||
self.user_conf_process.errorOccurred.connect(self._on_save_error)
|
||||
args = self._get_process_args(command)
|
||||
self.user_conf_process.start(args[0], args[1:])
|
||||
|
||||
def _on_user_conf_change_finished(self, exit_code, exit_status) -> None:
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
error_output = bytes(self.user_conf_process.readAllStandardError().data()).decode(
|
||||
'utf-8', 'ignore'
|
||||
)
|
||||
self.apply_button.setEnabled(True)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
logger.error("Failed to update user.conf: %s", error_output)
|
||||
return
|
||||
self.user_conf_process.deleteLater()
|
||||
self._apply_next_user_conf_change()
|
||||
|
||||
def on_edit_db_finished(self, exit_code, exit_status):
|
||||
"""Handle --edit-db output."""
|
||||
process = cast(QProcess, self.sender())
|
||||
self.apply_button.setEnabled(True)
|
||||
process.deleteLater()
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
self.saving = False
|
||||
self.close_after_save = False
|
||||
self.clear_ppdb_button.setEnabled(True)
|
||||
error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
logger.error(f"Failed to apply changes: {error_output}")
|
||||
else:
|
||||
self.load_current_settings()
|
||||
self.close()
|
||||
self._finish_save()
|
||||
|
||||
def closeEvent(self, event):
|
||||
if hasattr(self, 'save_timer'):
|
||||
self.apply_changes()
|
||||
if self.saving:
|
||||
self.close_after_save = True
|
||||
event.ignore()
|
||||
return
|
||||
app = QApplication.instance()
|
||||
if isinstance(app, QApplication):
|
||||
app.removeEventFilter(self)
|
||||
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
|
||||
self.keyboard.hide()
|
||||
if self.input_manager:
|
||||
@@ -1507,11 +1767,120 @@ class ExeSettingsDialog(
|
||||
super().closeEvent(event)
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
if (
|
||||
event.type() == QEvent.Type.KeyPress
|
||||
and self.isVisible()
|
||||
and QApplication.activeWindow() is self
|
||||
):
|
||||
focused_widget = QApplication.focusWidget()
|
||||
if focused_widget is None or focused_widget.window() is self:
|
||||
text_input = isinstance(
|
||||
focused_widget,
|
||||
(QLineEdit, QTextEdit, QPlainTextEdit, QAbstractSpinBox, QComboBox),
|
||||
)
|
||||
if focused_widget is not self.search_edit and not text_input:
|
||||
key_event = cast(QKeyEvent, event)
|
||||
modifiers = (
|
||||
Qt.KeyboardModifier.ControlModifier
|
||||
| Qt.KeyboardModifier.AltModifier
|
||||
| Qt.KeyboardModifier.MetaModifier
|
||||
)
|
||||
if not key_event.modifiers() & modifiers:
|
||||
if key_event.key() == Qt.Key.Key_Backspace:
|
||||
search_text = self.search_edit.text()
|
||||
if search_text:
|
||||
self.search_edit.setText(search_text[:-1])
|
||||
return True
|
||||
if key_event.text() and key_event.text().isprintable():
|
||||
self.search_edit.setText(self.search_edit.text() + key_event.text())
|
||||
return True
|
||||
|
||||
if isinstance(obj, QAbstractItemView) and event.type() == QEvent.Type.Show:
|
||||
combo = obj.window().parentWidget()
|
||||
if isinstance(combo, QComboBox):
|
||||
self._on_combo_highlighted(obj.currentIndex().row(), combo)
|
||||
return False
|
||||
|
||||
if isinstance(obj, QAbstractItemView) and event.type() == QEvent.Type.Hide:
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return False
|
||||
|
||||
if event.type() == QEvent.Type.MouseButtonPress and obj.property("ppqt_settings_values"):
|
||||
cast(QTableWidget, obj.parent()).clearFocus()
|
||||
return False
|
||||
|
||||
if event.type() == QEvent.Type.Paint and obj.property("ppqt_settings_values"):
|
||||
table = cast(QTableWidget, obj.parent())
|
||||
for row in range(table.rowCount()):
|
||||
item = table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
widget = table.cellWidget(row, 0)
|
||||
if widget is not None and not isinstance(widget, QLabel):
|
||||
continue
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
if text is None:
|
||||
text = item.text()
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
label = cast(QLabel | None, table.cellWidget(row, 0))
|
||||
if label is None:
|
||||
label = QLabel()
|
||||
label.setTextFormat(Qt.TextFormat.RichText)
|
||||
label.setWordWrap(True)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
table.setCellWidget(row, 0, label)
|
||||
label.setAutoFillBackground(False)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_NoSystemBackground)
|
||||
selected = row == table.currentRow()
|
||||
palette = table.palette()
|
||||
if selected:
|
||||
palette.setColor(QPalette.ColorRole.Text, QColor(self.theme.color_text_accent_hover))
|
||||
palette.setColor(QPalette.ColorRole.Window, QColor(self.theme.color_accent))
|
||||
elif item.data(Qt.ItemDataRole.ForegroundRole) is not None:
|
||||
palette.setBrush(QPalette.ColorRole.Text, item.foreground())
|
||||
label.setAutoFillBackground(selected)
|
||||
label.setPalette(palette)
|
||||
if label.foregroundRole() != QPalette.ColorRole.Text:
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
name, separator, description = text.partition("\n")
|
||||
formatted_text = escape(name)
|
||||
if separator:
|
||||
description_style = (
|
||||
self.theme.SETTINGS_SELECTED_DESCRIPTION_HTML if selected
|
||||
else self.theme.SETTINGS_DESCRIPTION_HTML
|
||||
)
|
||||
formatted_text += "<br>" + description_style.format(
|
||||
description=escape(description).replace("\n", "<br>"),
|
||||
)
|
||||
if selected:
|
||||
formatted_text = self.theme.SETTINGS_SELECTED_TEXT_HTML.format(text=formatted_text)
|
||||
if label.text() != formatted_text:
|
||||
label.setText(formatted_text)
|
||||
width = max(1, table.columnWidth(0))
|
||||
height_key = [width, formatted_text, label.font().toString()]
|
||||
if label.property("ppqt_height_key") != height_key:
|
||||
height = label.heightForWidth(width) + table.fontMetrics().height()
|
||||
label.setProperty("ppqt_height_key", height_key)
|
||||
table.setRowHeight(row, height)
|
||||
for row in range(table.rowCount()):
|
||||
widget = table.cellWidget(row, 1)
|
||||
if widget is None:
|
||||
continue
|
||||
rect = table.visualRect(table.model().index(row, 1))
|
||||
height = widget.sizeHint().height()
|
||||
widget.setFixedHeight(height)
|
||||
widget.setGeometry(
|
||||
rect.left(), rect.top() + (rect.height() - height) // 2,
|
||||
rect.width(), height,
|
||||
)
|
||||
return False
|
||||
|
||||
if isinstance(obj, QWidget) and self._handle_vkbasalt_key_button_event(obj, event):
|
||||
self._schedule_save()
|
||||
return True
|
||||
|
||||
action_buttons = (
|
||||
getattr(self, "apply_button", None),
|
||||
getattr(self, "cancel_button", None),
|
||||
getattr(self, "open_ppdb_button", None),
|
||||
getattr(self, "clear_ppdb_button", None),
|
||||
@@ -1589,8 +1958,9 @@ class ExeSettingsDialog(
|
||||
y = available_rect.top()
|
||||
|
||||
self.gamepad_tooltip.setFixedSize(required_width, required_height)
|
||||
self.gamepad_tooltip.move(x, y)
|
||||
self.gamepad_tooltip.move(self.mapFromGlobal(QPoint(x, y)))
|
||||
self.gamepad_tooltip.setVisible(True)
|
||||
self.gamepad_tooltip.raise_()
|
||||
self.gamepad_tooltip_timer.start(tooltip_timeout_ms)
|
||||
|
||||
if anchor_global_pos is not None:
|
||||
@@ -1615,94 +1985,16 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip_timer.stop()
|
||||
self.gamepad_tooltip.setVisible(False)
|
||||
|
||||
def get_current_description(self):
|
||||
"""Get the description text for the currently selected row."""
|
||||
current_table = self._get_current_settings_table()
|
||||
if current_table is None:
|
||||
return ""
|
||||
current_row = current_table.currentRow()
|
||||
if current_row >= 0:
|
||||
desc_item = current_table.item(current_row, 2)
|
||||
if desc_item:
|
||||
return desc_item.text()
|
||||
return ""
|
||||
|
||||
def _is_description_clipped(self, table: QTableWidget, row: int) -> bool:
|
||||
"""Check whether description text is clipped in the table cell."""
|
||||
if row < 0:
|
||||
return False
|
||||
|
||||
desc_item = table.item(row, 2)
|
||||
if not desc_item:
|
||||
return False
|
||||
|
||||
description = desc_item.text()
|
||||
if not description:
|
||||
return False
|
||||
|
||||
item_rect = table.visualRect(table.model().index(row, 2))
|
||||
if not item_rect.isValid() or item_rect.width() <= 0 or item_rect.height() <= 0:
|
||||
return False
|
||||
|
||||
wrap_rect = table.fontMetrics().boundingRect(
|
||||
0,
|
||||
0,
|
||||
max(1, item_rect.width() - 12),
|
||||
10000,
|
||||
Qt.TextFlag.TextWordWrap | Qt.TextFlag.TextExpandTabs,
|
||||
description
|
||||
)
|
||||
if wrap_rect.height() > (item_rect.height() - 6):
|
||||
return True
|
||||
|
||||
single_line_width = table.fontMetrics().horizontalAdvance(description)
|
||||
return single_line_width > (item_rect.width() - 12)
|
||||
|
||||
def on_table_selection_changed(self):
|
||||
"""Called when table selection changes to update the gamepad tooltip."""
|
||||
current_table = self._get_current_settings_table()
|
||||
if current_table is None:
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
|
||||
current_column = current_table.currentColumn() if current_table else -1
|
||||
if current_column != 2:
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
|
||||
current_row = current_table.currentRow()
|
||||
if not self._is_description_clipped(current_table, current_row):
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
|
||||
description = self.get_current_description()
|
||||
if description:
|
||||
self.show_gamepad_tooltip(show=True, text=description)
|
||||
else:
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
|
||||
def on_table_cell_hovered(self, row, column):
|
||||
"""Show custom tooltip on hover for description cells."""
|
||||
if column != 2:
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
|
||||
table = cast(QTableWidget | None, self.sender())
|
||||
if table is None:
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
|
||||
desc_item = table.item(row, 2)
|
||||
description = desc_item.text() if desc_item else ""
|
||||
should_show_tooltip = self._is_description_clipped(table, row) or len(description) > 80
|
||||
if description and should_show_tooltip:
|
||||
item_rect = table.visualRect(table.model().index(row, 2))
|
||||
cell_pos = table.mapToGlobal(item_rect.bottomLeft())
|
||||
self.show_gamepad_tooltip(show=True, text=description, anchor_global_pos=cell_pos)
|
||||
else:
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
def hideEvent(self, event: QHideEvent) -> None:
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
super().hideEvent(event)
|
||||
|
||||
def reject(self):
|
||||
if hasattr(self, 'save_timer'):
|
||||
self.apply_changes()
|
||||
if self.saving:
|
||||
self.close_after_save = True
|
||||
return
|
||||
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
|
||||
self.keyboard.hide()
|
||||
self.gamepad_tooltip.setVisible(False)
|
||||
|
||||
@@ -1291,6 +1291,9 @@ class MangoHudSettingsMixin:
|
||||
if new_user_conf != orig_user_conf:
|
||||
changes.append(f"PW_MANGOHUD_USER_CONF={new_user_conf}")
|
||||
|
||||
if not mangohud_enabled:
|
||||
return changes
|
||||
|
||||
config_value = self._build_mangohud_config()
|
||||
if config_value != self.mangohud_original_values.get('MANGOHUD_CONFIG', ''):
|
||||
changes.append(f"MANGOHUD_CONFIG={config_value}")
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import cast, TYPE_CHECKING
|
||||
from PySide6.QtWidgets import (
|
||||
QVBoxLayout, QHBoxLayout, QTextEdit, QTabWidget,
|
||||
QTableWidget, QHeaderView, QTableWidgetItem, QAbstractItemView,
|
||||
QStackedWidget, QWidget, QMessageBox
|
||||
QStackedWidget, QWidget, QMessageBox, QCheckBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QProcess, QProcessEnvironment
|
||||
|
||||
@@ -109,7 +109,7 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.tab_widget = QTabWidget()
|
||||
self.tab_widget.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE + self.theme.TAB_STYLE)
|
||||
|
||||
table_base_style = self.theme.WINETRICKS_TABBLE_STYLE + self.theme.SCROLL_STYLE + self.theme.CHECKBOX_STYLE
|
||||
table_base_style = self.theme.GETWINE_WINDOW_STYLE + self.theme.SCROLL_STYLE + self.theme.CHECKBOX_STYLE
|
||||
|
||||
self.dll_table = QTableWidget()
|
||||
self.dll_table.setAlternatingRowColors(True)
|
||||
@@ -120,7 +120,8 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.dll_table.setColumnCount(3)
|
||||
self.dll_table.setHorizontalHeaderLabels([_("Set"), _("Libraries"), _("Information")])
|
||||
self.dll_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
self.dll_table.horizontalHeader().resizeSection(0, 50)
|
||||
self.dll_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
|
||||
self.dll_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
|
||||
self.dll_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
self.dll_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.dll_table.setStyleSheet(table_base_style)
|
||||
@@ -152,7 +153,8 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.fonts_table.setColumnCount(3)
|
||||
self.fonts_table.setHorizontalHeaderLabels([_("Set"), _("Fonts"), _("Information")])
|
||||
self.fonts_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
self.fonts_table.horizontalHeader().resizeSection(0, 50)
|
||||
self.fonts_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
|
||||
self.fonts_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
|
||||
self.fonts_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
self.fonts_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.fonts_table.setStyleSheet(table_base_style)
|
||||
@@ -184,7 +186,8 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.settings_table.setColumnCount(3)
|
||||
self.settings_table.setHorizontalHeaderLabels([_("Set"), _("Settings"), _("Information")])
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
|
||||
self.settings_table.horizontalHeader().resizeSection(0, 50)
|
||||
self.settings_table.horizontalHeader().resizeSection(0, self.theme.WINETRICKS_CHECK_COLUMN_WIDTH)
|
||||
self.settings_table.verticalHeader().setDefaultSectionSize(self.theme.WINETRICKS_ROW_HEIGHT)
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
|
||||
self.settings_table.setStyleSheet(table_base_style)
|
||||
@@ -207,6 +210,13 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.settings_container.addWidget(self.settings_table)
|
||||
self.tab_widget.addTab(self.settings_container, _("Settings"))
|
||||
|
||||
for table in (self.dll_table, self.fonts_table, self.settings_table):
|
||||
table.currentCellChanged.connect(
|
||||
lambda row, *_previous, table=table: table.setFocusProxy(
|
||||
cast(QCheckBox, table.cellWidget(row, 0).findChild(QCheckBox))
|
||||
) if table.cellWidget(row, 0) else None
|
||||
)
|
||||
|
||||
self.containers = {
|
||||
"dlls": self.dll_container,
|
||||
"fonts": self.fonts_container,
|
||||
@@ -359,14 +369,19 @@ class WinetricksDialog(DraggableDialog):
|
||||
if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'):
|
||||
continue
|
||||
|
||||
checked = Qt.CheckState.Checked if name in installed else Qt.CheckState.Unchecked
|
||||
checked = name in installed
|
||||
|
||||
row = table.rowCount()
|
||||
table.insertRow(row)
|
||||
|
||||
checkbox = QTableWidgetItem()
|
||||
checkbox.setCheckState(checked)
|
||||
table.setItem(row, 0, checkbox)
|
||||
checkbox_widget = QWidget()
|
||||
checkbox_layout = QHBoxLayout(checkbox_widget)
|
||||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setChecked(checked)
|
||||
checkbox_layout.addWidget(checkbox)
|
||||
table.setCellWidget(row, 0, checkbox_widget)
|
||||
|
||||
name_item = QTableWidgetItem(name)
|
||||
table.setItem(row, 1, name_item)
|
||||
@@ -374,13 +389,17 @@ class WinetricksDialog(DraggableDialog):
|
||||
info_item = QTableWidgetItem(info)
|
||||
table.setItem(row, 2, info_item)
|
||||
|
||||
if table is self.dll_table or table is self.fonts_table:
|
||||
table.sortItems(1, Qt.SortOrder.AscendingOrder)
|
||||
|
||||
def install_selected(self, force=False):
|
||||
"""Install selected components."""
|
||||
selected = []
|
||||
for table in [self.dll_table, self.fonts_table, self.settings_table]:
|
||||
for row in range(table.rowCount()):
|
||||
checkbox = table.item(row, 0)
|
||||
if checkbox is not None and checkbox.checkState() == Qt.CheckState.Checked:
|
||||
checkbox_widget = table.cellWidget(row, 0)
|
||||
checkbox = checkbox_widget.findChild(QCheckBox) if checkbox_widget else None
|
||||
if checkbox is not None and checkbox.isChecked():
|
||||
name_item = table.item(row, 1)
|
||||
if name_item is not None:
|
||||
name = name_item.text()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from PySide6.QtCore import QObject, Signal, QThread
|
||||
from PySide6.QtCore import QObject, Signal, QThread, Qt, QCoreApplication
|
||||
import threading
|
||||
import os
|
||||
import time
|
||||
@@ -70,7 +70,7 @@ class Downloader(QObject):
|
||||
self._locks = {}
|
||||
self._active_threads: list[QThread] = []
|
||||
self._global_lock = threading.Lock()
|
||||
self.callback_requested.connect(self._run_callback)
|
||||
self.callback_requested.connect(self._run_callback, Qt.ConnectionType.QueuedConnection)
|
||||
|
||||
def _run_callback(self, callback, result):
|
||||
if callback:
|
||||
@@ -240,11 +240,14 @@ class Downloader(QObject):
|
||||
on_result(url, None)
|
||||
emitted_urls.add(url)
|
||||
continue
|
||||
if url in self._cache:
|
||||
cached_path = self._cache.get(url)
|
||||
if (cached_path and os.path.exists(cached_path)
|
||||
and os.path.abspath(cached_path) == os.path.abspath(path)):
|
||||
if on_result and url not in emitted_urls:
|
||||
on_result(url, self._cache[url])
|
||||
on_result(url, cached_path)
|
||||
emitted_urls.add(url)
|
||||
continue
|
||||
self._cache.pop(url, None)
|
||||
filtered_urls.append(url)
|
||||
filtered_paths.append(path)
|
||||
|
||||
@@ -261,7 +264,7 @@ class Downloader(QObject):
|
||||
throttle_delay,
|
||||
workers,
|
||||
on_result=emit_result,
|
||||
)
|
||||
) if filtered_urls else {}
|
||||
|
||||
with self._global_lock:
|
||||
for url, path in results.items():
|
||||
@@ -270,7 +273,9 @@ class Downloader(QObject):
|
||||
final_results = {}
|
||||
with self._global_lock:
|
||||
for url in urls:
|
||||
if url in self._cache:
|
||||
if url in self._last_error:
|
||||
final_results[url] = None
|
||||
elif url in self._cache:
|
||||
final_results[url] = self._cache[url]
|
||||
else:
|
||||
final_results[url] = None
|
||||
@@ -281,7 +286,18 @@ class Downloader(QObject):
|
||||
return final_results
|
||||
|
||||
|
||||
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread:
|
||||
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread | None:
|
||||
with self._global_lock:
|
||||
previous_error = url in self._last_error
|
||||
cached = not previous_error and os.path.exists(local_path)
|
||||
if cached:
|
||||
self._cache[url] = local_path
|
||||
if previous_error or cached:
|
||||
result = local_path if cached else None
|
||||
self.download_completed.emit(url, result or "", cached)
|
||||
self.callback_requested.emit(callback, result)
|
||||
return None
|
||||
|
||||
class DownloadThread(QThread):
|
||||
def __init__(self, downloader: 'Downloader', url: str, local_path: str, timeout: int, parallel: bool):
|
||||
super().__init__()
|
||||
@@ -308,7 +324,11 @@ class Downloader(QObject):
|
||||
self.downloader.callback_requested.emit(callback, None)
|
||||
|
||||
thread = DownloadThread(self, url, local_path, timeout, parallel)
|
||||
thread.moveToThread(self.thread())
|
||||
thread.finished.connect(thread.deleteLater)
|
||||
app = QCoreApplication.instance()
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(thread.wait)
|
||||
|
||||
# Remove from list after completion
|
||||
def cleanup():
|
||||
|
||||
@@ -266,9 +266,11 @@ class EGSAPI:
|
||||
data = self._load_json(self.config_dir / "installed.json", {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def is_game_installed(self, app_id: str) -> bool:
|
||||
def is_game_installed(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> bool:
|
||||
"""Return whether Legendary records a usable installation."""
|
||||
return self.get_launch_target(app_id) is not None
|
||||
return self.get_launch_target(app_id, installed_data) is not None
|
||||
|
||||
@staticmethod
|
||||
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
|
||||
@@ -285,9 +287,13 @@ class EGSAPI:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
def get_launch_target(
|
||||
self, app_id: str, installed_data: dict[str, dict] | None = None
|
||||
) -> str | None:
|
||||
"""Return the installed game's Windows executable."""
|
||||
installed = self.load_installed().get(app_id, {})
|
||||
installed = (
|
||||
installed_data if installed_data is not None else self.load_installed()
|
||||
).get(app_id, {})
|
||||
install_path = Path(str(installed.get("install_path", "")))
|
||||
executable = str(installed.get("executable", "")).replace("\\", "/")
|
||||
target = install_path.joinpath(*executable.split("/"))
|
||||
|
||||
@@ -16,6 +16,7 @@ from PySide6.QtGui import (
|
||||
QEnterEvent,
|
||||
QFocusEvent,
|
||||
QMouseEvent,
|
||||
QKeyEvent,
|
||||
QPaintEvent,
|
||||
)
|
||||
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
@@ -277,7 +278,19 @@ class AnimatedCard(QFrame):
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
super().paintEvent(event)
|
||||
self.animations.paint_border(QPainter(self))
|
||||
painter = QPainter(self)
|
||||
parent = self.parentWidget()
|
||||
layout = parent.layout() if parent is not None else None
|
||||
if getattr(self, "list_layout", False) and isinstance(layout, QVBoxLayout):
|
||||
row_pitch = self.height() + layout.spacing()
|
||||
row = (self.y() - layout.contentsMargins().top()) // max(1, row_pitch)
|
||||
colors = (self.theme.color_bg, self.theme.color_surface_elevated)
|
||||
painter.fillRect(self.rect(), QColor(colors[row % len(colors)]))
|
||||
self.animations.paint_border(painter)
|
||||
if getattr(self, "batch_selected", False):
|
||||
painter.setPen(QPen(QColor(self.theme.color_accent),
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"]))
|
||||
painter.drawRect(self.rect())
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.raise_()
|
||||
@@ -313,7 +326,7 @@ class AnimatedCard(QFrame):
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.pos()):
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.position().toPoint()):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
@@ -322,6 +335,7 @@ class AnimatedCard(QFrame):
|
||||
|
||||
|
||||
class GameCard(AnimatedCard):
|
||||
coverReady = Signal(object)
|
||||
editShortcutRequested = Signal(str, str, str)
|
||||
deleteGameRequested = Signal(str, str)
|
||||
addToMenuRequested = Signal(str, str)
|
||||
@@ -340,6 +354,7 @@ class GameCard(AnimatedCard):
|
||||
card_width=250, parent=None, context_menu_manager=None
|
||||
):
|
||||
super().__init__(parent)
|
||||
self.coverReady.connect(self.on_cover_loaded, Qt.ConnectionType.QueuedConnection)
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.cover_path = cover_path
|
||||
@@ -365,6 +380,7 @@ class GameCard(AnimatedCard):
|
||||
|
||||
self.select_callback = select_callback
|
||||
self.context_menu_manager = context_menu_manager
|
||||
self.batch_selected = False
|
||||
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.customContextMenuRequested.connect(self._show_context_menu)
|
||||
self.theme_manager = ThemeManager()
|
||||
@@ -454,6 +470,7 @@ class GameCard(AnimatedCard):
|
||||
self.favoriteLabel = ClickableLabel(self.coverWidget)
|
||||
self.favoriteLabel.clicked.connect(self.toggle_favorite)
|
||||
self.is_favorite = self.name in set(favorites_config.get_games())
|
||||
self._favorite_icon_state = self.is_favorite
|
||||
self.update_favorite_icon()
|
||||
self.favoriteLabel.raise_()
|
||||
if self.list_layout:
|
||||
@@ -573,7 +590,7 @@ class GameCard(AnimatedCard):
|
||||
strict=True,
|
||||
):
|
||||
label = QLabel(text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setProperty(
|
||||
"theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE"
|
||||
)
|
||||
@@ -727,7 +744,7 @@ class GameCard(AnimatedCard):
|
||||
cover_path,
|
||||
width,
|
||||
height,
|
||||
self.on_cover_loaded,
|
||||
self.coverReady.emit,
|
||||
app_name=str(self.appid or ""),
|
||||
fallback_exe=fallback_exe,
|
||||
fallback_icon_path=fallback_icon_path,
|
||||
@@ -862,10 +879,10 @@ class GameCard(AnimatedCard):
|
||||
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
|
||||
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
|
||||
source_ribbons = [
|
||||
(self.steam_visible and not self.vertical_layout, self.steamLabel),
|
||||
(self.gog_visible and not self.vertical_layout, self.gogLabel),
|
||||
(self.egs_visible and not self.vertical_layout, self.egsLabel),
|
||||
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.egs_visible, self.egsLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
@@ -1006,23 +1023,17 @@ class GameCard(AnimatedCard):
|
||||
# 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):
|
||||
if self.base_card_width == new_width:
|
||||
return
|
||||
self.base_card_width = new_width
|
||||
self._load_cover_image(self.cover_path or "")
|
||||
cover_height = int(new_width * self.card_geometry_cfg.get("cover_aspect_ratio", 1.5))
|
||||
if not self.animated_cover_path and (
|
||||
self.base_pixmap is None or self.base_pixmap.isNull()
|
||||
or self.base_pixmap.width() < new_width
|
||||
or self.base_pixmap.height() < cover_height
|
||||
):
|
||||
self._load_cover_image(self.cover_path or "")
|
||||
self.update_scale()
|
||||
|
||||
def update_badge_visibility(self, display_filter: str):
|
||||
@@ -1041,15 +1052,11 @@ class GameCard(AnimatedCard):
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
show_source_ribbons = self.vertical_layout is not True
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
|
||||
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
|
||||
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and show_source_ribbons
|
||||
)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
|
||||
@@ -1211,6 +1218,9 @@ class GameCard(AnimatedCard):
|
||||
# Handle the case where the Qt object was deleted
|
||||
return
|
||||
|
||||
if self._favorite_icon_state == self.is_favorite:
|
||||
return
|
||||
self._favorite_icon_state = self.is_favorite
|
||||
try:
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
@@ -1248,6 +1258,12 @@ class GameCard(AnimatedCard):
|
||||
super().showEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
if self.context_menu_manager:
|
||||
if self.context_menu_manager.card_selection_mode is True:
|
||||
self.batch_selected = not self.batch_selected
|
||||
self.update()
|
||||
return
|
||||
self.context_menu_manager.clear_card_selection()
|
||||
if self._get_missing_executable_path() and self.context_menu_manager:
|
||||
self.context_menu_manager.handle_missing_executable(self)
|
||||
return
|
||||
@@ -1277,6 +1293,16 @@ class GameCard(AnimatedCard):
|
||||
self.click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
if event.key() == Qt.Key.Key_Space and self.context_menu_manager:
|
||||
if self.context_menu_manager.card_selection_mode:
|
||||
self.context_menu_manager.clear_card_selection()
|
||||
else:
|
||||
self.context_menu_manager.start_card_selection(self)
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up animations to prevent memory leaks when the card is destroyed."""
|
||||
if hasattr(self, 'coverLabel') and self.coverLabel is not None:
|
||||
|
||||
@@ -9,7 +9,7 @@ from portprotonqt.search_utils import (
|
||||
)
|
||||
from PySide6.QtWidgets import QAbstractButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import QRect, QRectF, Qt, QTimer
|
||||
from PySide6.QtCore import QElapsedTimer, QRect, QRectF, Qt, QTimer
|
||||
from PySide6.QtGui import QPaintEvent, QPainter, QPainterPath, QPixmap, QRegion
|
||||
from portprotonqt.custom_widgets import FlowLayout, AutoHideScrollArea
|
||||
from portprotonqt.config import favorites_config, game_config, ui_config
|
||||
@@ -88,11 +88,17 @@ class GameLibraryManager:
|
||||
self.full_library_open = False
|
||||
self.sizeSlider: QSlider | None = None
|
||||
self._update_timer: QTimer | None = None
|
||||
self._card_resize_timer: QTimer | None = None
|
||||
self._card_resize_queue: deque[tuple[str, str]] = deque()
|
||||
self._card_resize_batch_size: int = 8
|
||||
self._incremental_add_timer: QTimer | None = None
|
||||
self._incremental_add_queue: deque[tuple[str, str]] = deque()
|
||||
self._incremental_new_games_map: dict[tuple[str, str], tuple] = {}
|
||||
self._incremental_search_text: str = ""
|
||||
self._incremental_batch_size: int = 16
|
||||
self._incremental_card_positions: dict[tuple[str, str], int] = {}
|
||||
self._incremental_batch_size: int = 8
|
||||
self._incremental_batch_budget_ms: int = 8
|
||||
self._incremental_scroll_delay_ms: int = 250
|
||||
self._focus_first_card_after_update = False
|
||||
self._pending_update = False
|
||||
self.pending_deletions = deque()
|
||||
@@ -126,10 +132,6 @@ class GameLibraryManager:
|
||||
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
|
||||
layout.addWidget(searchWidget)
|
||||
|
||||
if self.layout_mode == "vertical":
|
||||
self.libraryHeaderWidget = self._create_library_header()
|
||||
layout.addWidget(self.libraryHeaderWidget)
|
||||
|
||||
# Scroll area for game grid
|
||||
scrollArea = AutoHideScrollArea(theme=self.theme)
|
||||
scrollArea.setProperty("theme_style_name", "TRANSPARENT_BACKGROUND_STYLE")
|
||||
@@ -150,6 +152,7 @@ class GameLibraryManager:
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||
elif self.layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
|
||||
@@ -163,6 +166,9 @@ class GameLibraryManager:
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
|
||||
scrollArea.setWidget(self.gamesListWidget)
|
||||
if self.layout_mode == "vertical":
|
||||
self.libraryHeaderWidget = self._create_library_header()
|
||||
layout.addWidget(self.libraryHeaderWidget)
|
||||
layout.addWidget(scrollArea)
|
||||
|
||||
# Slider for card size
|
||||
@@ -191,13 +197,17 @@ class GameLibraryManager:
|
||||
self._update_timer.setSingleShot(True)
|
||||
self._update_timer.setInterval(100) # 100ms debounce
|
||||
self._update_timer.timeout.connect(self._perform_update)
|
||||
self._incremental_add_timer = QTimer()
|
||||
self._card_resize_timer = QTimer(self.gamesListWidget)
|
||||
self._card_resize_timer.setSingleShot(True)
|
||||
self._card_resize_timer.timeout.connect(self._resize_next_card_batch)
|
||||
self._incremental_add_timer = QTimer(self.gamesListWidget)
|
||||
self._incremental_add_timer.setSingleShot(True)
|
||||
self._incremental_add_timer.timeout.connect(self._process_incremental_add_batch)
|
||||
|
||||
# Connect scroll event for lazy loading
|
||||
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
scrollArea.horizontalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
scrollArea.viewportResized.connect(self.force_update_cards_library)
|
||||
|
||||
return self.gamesLibraryWidget
|
||||
|
||||
@@ -211,14 +221,19 @@ class GameLibraryManager:
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(*config["header_margins"])
|
||||
header_layout.setSpacing(config["header_spacing"])
|
||||
header_layout.addSpacing(config["header_cover_width"])
|
||||
header_layout.addSpacing(config["header_cover_width"] + config["header_spacing"])
|
||||
labels = (_("Game Title"), _("LAST LAUNCH"), _("TIME SPENT"), _("Library"))
|
||||
for text, stretch in zip(labels, config["column_stretches"], strict=True):
|
||||
label = QLabel(text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
label = QLabel(text.upper())
|
||||
label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
|
||||
if text == labels[0] else Qt.AlignmentFlag.AlignCenter
|
||||
)
|
||||
label.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
|
||||
label.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
|
||||
header_layout.addWidget(label, stretch)
|
||||
if self.gamesScrollArea is not None:
|
||||
header_layout.addSpacing(self.gamesScrollArea.verticalScrollBar().sizeHint().width())
|
||||
return header
|
||||
|
||||
def rebuild_library_layout(self, layout_mode: str) -> None:
|
||||
@@ -253,9 +268,15 @@ class GameLibraryManager:
|
||||
vertical_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if horizontal
|
||||
else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
if layout_mode == "vertical"
|
||||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
|
||||
self.gamesScrollArea.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
theme_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
@@ -308,9 +329,28 @@ class GameLibraryManager:
|
||||
self.main_window._gamepad_tooltip_map[self.sizeSlider] = f"{self.card_width} px"
|
||||
ui_config.set_card_width(self.card_width)
|
||||
self.main_window.card_width = self.card_width
|
||||
for card in self.game_card_cache.values():
|
||||
card.update_card_size(self.card_width)
|
||||
self.update_game_grid()
|
||||
self._card_resize_queue = deque(self.game_card_cache)
|
||||
self._resize_next_card_batch()
|
||||
|
||||
def _resize_next_card_batch(self) -> None:
|
||||
if self.gamesListWidget is None or self.gamesListLayout is None:
|
||||
self._card_resize_queue.clear()
|
||||
return
|
||||
self.gamesListLayout.setEnabled(False)
|
||||
try:
|
||||
for _ in range(min(self._card_resize_batch_size, len(self._card_resize_queue))):
|
||||
card = self.game_card_cache.get(self._card_resize_queue.popleft())
|
||||
if card is not None:
|
||||
card.update_card_size(self.card_width)
|
||||
finally:
|
||||
self.gamesListLayout.invalidate()
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self.gamesListLayout.activate()
|
||||
if self._card_resize_queue and self._card_resize_timer is not None:
|
||||
self._card_resize_timer.start()
|
||||
else:
|
||||
self.force_update_cards_library()
|
||||
self.load_visible_images()
|
||||
|
||||
def _set_card_width_from_slider(self):
|
||||
"""Use max card width for fixed-size layouts."""
|
||||
@@ -328,6 +368,8 @@ class GameLibraryManager:
|
||||
"""Loads images for visible game cards."""
|
||||
if self.gamesListWidget is None:
|
||||
return
|
||||
if self._incremental_add_queue and self._incremental_add_timer is not None:
|
||||
self._incremental_add_timer.start(self._incremental_scroll_delay_ms)
|
||||
visible_region = self.gamesListWidget.visibleRegion()
|
||||
max_concurrent_loads = 5
|
||||
loaded_count = 0
|
||||
@@ -350,6 +392,7 @@ class GameLibraryManager:
|
||||
card.set_animated_cover_paused(True)
|
||||
|
||||
def stop_background_activity(self) -> None:
|
||||
self._cancel_incremental_add()
|
||||
for card in self.game_card_cache.values():
|
||||
card.stop_background_activity()
|
||||
|
||||
@@ -517,18 +560,42 @@ class GameLibraryManager:
|
||||
|
||||
def _perform_force_update(self):
|
||||
"""Perform the actual force update on the layout."""
|
||||
self._fit_horizontal_cards()
|
||||
if self.gamesListLayout:
|
||||
self.gamesListLayout.invalidate()
|
||||
if self.gamesListWidget:
|
||||
self.gamesListWidget.adjustSize()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
def _fit_horizontal_cards(self, card: GameCard | None = None) -> None:
|
||||
if self.layout_mode not in {"horizontal", "horizontal_top"}:
|
||||
return
|
||||
if self.gamesScrollArea is None or self.gamesListLayout is None:
|
||||
return
|
||||
margins = self.gamesListLayout.contentsMargins()
|
||||
height = self.gamesScrollArea.viewport().height() - margins.top() - margins.bottom()
|
||||
if height <= 0:
|
||||
return
|
||||
cards = (card,) if card is not None else self.game_card_cache.values()
|
||||
for card in cards:
|
||||
config = card.card_layout_cfg
|
||||
scale = max(
|
||||
config.get(name, self.theme.GAME_CARD_ANIMATION[name])
|
||||
for name in ("default_scale", "hover_scale", "focus_scale")
|
||||
)
|
||||
available = height / scale - card.base_extra_margin
|
||||
ratio = card.card_geometry_cfg.get("card_height_ratio", 1.8)
|
||||
width = min(self.card_width, max(1, int(available / ratio)))
|
||||
if width < self.theme.COMPACT_CARD["width_threshold"]:
|
||||
width = min(width, max(1, int(available / self.theme.COMPACT_CARD["height_ratio"])))
|
||||
card.update_card_size(width)
|
||||
|
||||
def _cancel_incremental_add(self) -> None:
|
||||
if self._incremental_add_timer is not None and self._incremental_add_timer.isActive():
|
||||
self._incremental_add_timer.stop()
|
||||
self._incremental_add_queue.clear()
|
||||
self._incremental_new_games_map = {}
|
||||
self._incremental_search_text = ""
|
||||
self._incremental_card_positions = {}
|
||||
|
||||
def _start_incremental_add(
|
||||
self,
|
||||
@@ -539,41 +606,43 @@ class GameLibraryManager:
|
||||
if self.gamesListLayout is None:
|
||||
return
|
||||
self._cancel_incremental_add()
|
||||
while self.gamesListLayout.count():
|
||||
self.gamesListLayout.takeAt(0)
|
||||
for card in self.game_card_cache.values():
|
||||
if card.isVisible():
|
||||
card.setVisible(False)
|
||||
self._incremental_add_queue = deque(card_order)
|
||||
self._incremental_card_positions = {key: index for index, key in enumerate(card_order)}
|
||||
desired_keys = set(card_order)
|
||||
self.gamesListLayout.setEnabled(False)
|
||||
try:
|
||||
while self.gamesListLayout.count():
|
||||
self.gamesListLayout.takeAt(0)
|
||||
for key, card in self.game_card_cache.items():
|
||||
visible = key in desired_keys and (not search_text or search_text in key[0].lower())
|
||||
card.setVisible(visible)
|
||||
card.set_animated_cover_paused(not visible)
|
||||
for key in card_order:
|
||||
card = self.game_card_cache.get(key)
|
||||
if card is not None:
|
||||
self.gamesListLayout.addWidget(card)
|
||||
finally:
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self._incremental_add_queue = deque(key for key in card_order if key not in self.game_card_cache)
|
||||
self._incremental_new_games_map = new_games_map
|
||||
self._incremental_search_text = search_text
|
||||
if self._incremental_add_timer is not None:
|
||||
self._incremental_add_timer.start(0)
|
||||
self._process_incremental_add_batch()
|
||||
|
||||
def _process_incremental_add_batch(self) -> None:
|
||||
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||
self._cancel_incremental_add()
|
||||
return
|
||||
|
||||
added_new_card = False
|
||||
processed = 0
|
||||
max_batch = min(self._incremental_batch_size, len(self._incremental_add_queue))
|
||||
self.gamesListWidget.setUpdatesEnabled(False)
|
||||
batch_timer = QElapsedTimer()
|
||||
batch_timer.start()
|
||||
self.gamesListLayout.setEnabled(False)
|
||||
try:
|
||||
while processed < max_batch:
|
||||
for _ in range(min(self._incremental_batch_size, len(self._incremental_add_queue))):
|
||||
game_key = self._incremental_add_queue.popleft()
|
||||
card = self.game_card_cache.get(game_key)
|
||||
if card is None:
|
||||
if self.context_menu_manager is None:
|
||||
processed += 1
|
||||
continue
|
||||
game_data = self._incremental_new_games_map.get(game_key)
|
||||
if game_data is None:
|
||||
processed += 1
|
||||
continue
|
||||
card = self._create_game_card(game_data)
|
||||
self.game_card_cache[game_key] = card
|
||||
added_new_card = True
|
||||
game_data = self._incremental_new_games_map.get(game_key)
|
||||
if game_data is None or self.context_menu_manager is None:
|
||||
continue
|
||||
card = self._create_game_card(game_data)
|
||||
self.game_card_cache[game_key] = card
|
||||
should_be_visible = (
|
||||
not self._incremental_search_text or
|
||||
self._incremental_search_text in str(game_key[0]).lower()
|
||||
@@ -581,24 +650,29 @@ class GameLibraryManager:
|
||||
if card.isVisible() != should_be_visible:
|
||||
card.setVisible(should_be_visible)
|
||||
card.set_animated_cover_paused(not should_be_visible)
|
||||
self.gamesListLayout.addWidget(card)
|
||||
processed += 1
|
||||
self.gamesListLayout.insertWidget(self._incremental_card_positions[game_key], card)
|
||||
if batch_timer.elapsed() >= self._incremental_batch_budget_ms:
|
||||
break
|
||||
finally:
|
||||
self.gamesListWidget.setUpdatesEnabled(True)
|
||||
self.gamesListLayout.update()
|
||||
self.gamesListLayout.invalidate()
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
if self.layout_mode in {"horizontal", "horizontal_top"}:
|
||||
self.gamesListWidget.resize(
|
||||
self.gamesListWidget.size().expandedTo(self.gamesListLayout.minimumSize())
|
||||
)
|
||||
self.gamesListLayout.activate()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
self._focus_first_visible_card()
|
||||
if self._incremental_add_queue:
|
||||
if self._incremental_add_timer is not None:
|
||||
self._incremental_add_timer.start(0)
|
||||
self._incremental_add_timer.start(self._incremental_batch_budget_ms)
|
||||
return
|
||||
|
||||
if added_new_card:
|
||||
self.load_visible_images()
|
||||
self.load_visible_images()
|
||||
self._sync_full_library_tile()
|
||||
self.force_update_cards_library()
|
||||
self._cancel_incremental_add()
|
||||
self._schedule_focus_first_card()
|
||||
|
||||
def _update_game_grid_immediate(self):
|
||||
"""Updates the game grid with the provided or current game list."""
|
||||
@@ -621,7 +695,6 @@ class GameLibraryManager:
|
||||
sort_method = game_config.get_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
|
||||
|
||||
@@ -657,10 +730,19 @@ class GameLibraryManager:
|
||||
current_game_keys = {(game[0], game[5]) for game in sorted_games}
|
||||
|
||||
# Remove cards that no longer exist (batch)
|
||||
retained_keys = {
|
||||
(game[0], game[5])
|
||||
for games in getattr(self.main_window, "_loaded_library_cache", {}).values()
|
||||
for game in games
|
||||
}
|
||||
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)
|
||||
card = self.game_card_cache[card_key]
|
||||
card.setVisible(False)
|
||||
card.set_animated_cover_paused(True)
|
||||
if card_key not in retained_keys:
|
||||
cards_to_remove.append(card_key)
|
||||
|
||||
for card_key in cards_to_remove:
|
||||
card = self.game_card_cache.pop(card_key)
|
||||
@@ -673,15 +755,15 @@ class GameLibraryManager:
|
||||
# Track current layout order (only if dirty/full update needed)
|
||||
if self.dirty and self.gamesListLayout is not None:
|
||||
current_layout_order = []
|
||||
card_keys = {card: key for key, card in self.game_card_cache.items()}
|
||||
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
|
||||
key = card_keys.get(widget)
|
||||
if key is not None:
|
||||
current_layout_order.append(key)
|
||||
else:
|
||||
current_layout_order = None # Skip reorg if not dirty
|
||||
|
||||
@@ -737,7 +819,6 @@ class GameLibraryManager:
|
||||
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()
|
||||
@@ -812,7 +893,7 @@ class GameLibraryManager:
|
||||
QTimer.singleShot(0, self._focus_first_visible_card)
|
||||
|
||||
def _focus_first_visible_card(self) -> None:
|
||||
if self.gamesListWidget is None:
|
||||
if not self._focus_first_card_after_update or self.gamesListWidget is None:
|
||||
return
|
||||
if getattr(self.main_window.stackedWidget, "currentIndex", lambda: -1)() != 0:
|
||||
return
|
||||
@@ -829,74 +910,14 @@ class GameLibraryManager:
|
||||
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[5]) 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)
|
||||
card.set_animated_cover_paused(False)
|
||||
else:
|
||||
# Card should be hidden
|
||||
if card.isVisible():
|
||||
card.setVisible(False)
|
||||
cards_to_hide.append(card_key)
|
||||
card.set_animated_cover_paused(True)
|
||||
|
||||
# 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[5]
|
||||
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
|
||||
card.set_animated_cover_paused(False)
|
||||
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.force_update_cards_library()
|
||||
|
||||
self.gamesListLayout.update()
|
||||
if self.gamesListWidget is not None:
|
||||
self.gamesListWidget.updateGeometry()
|
||||
|
||||
# If search is empty, load images for visible ones
|
||||
if not search_text:
|
||||
self.load_visible_images()
|
||||
else:
|
||||
QTimer.singleShot(0, self.load_visible_images)
|
||||
visible_keys = {(game[0], game[5]) for game in self.filtered_games}
|
||||
card_order = [key for key in self.game_card_cache if key in visible_keys]
|
||||
new_games_map = {
|
||||
(game[0], game[5]): game for game in self.filtered_games
|
||||
if (game[0], game[5]) not in self.game_card_cache
|
||||
}
|
||||
card_order.extend(new_games_map)
|
||||
self._start_incremental_add(card_order, new_games_map, search_text)
|
||||
|
||||
def _create_game_card(self, game_data: tuple) -> GameCard:
|
||||
"""Creates a new game card with all necessary connections."""
|
||||
@@ -912,6 +933,8 @@ class GameLibraryManager:
|
||||
card.hoverChanged.connect(self._on_card_hovered)
|
||||
card.focusChanged.connect(self._on_card_focused)
|
||||
|
||||
self._fit_horizontal_cards(card)
|
||||
|
||||
if self.context_menu_manager:
|
||||
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
|
||||
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
|
||||
@@ -957,6 +980,8 @@ class GameLibraryManager:
|
||||
# Always schedule widget for deletion regardless of cache state
|
||||
if isinstance(widget, GameCard):
|
||||
widget.cleanup()
|
||||
if widget is self.fullLibraryTile:
|
||||
self.fullLibraryTile = None
|
||||
widget.deleteLater()
|
||||
|
||||
# Also clear the cache completely if needed (in case layout wasn't in sync)
|
||||
@@ -1000,7 +1025,8 @@ class GameLibraryManager:
|
||||
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
|
||||
if self.filtered_games is not self.games:
|
||||
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
|
||||
self._build_search_indices(self.games)
|
||||
self.dirty = True
|
||||
self.update_game_grid()
|
||||
|
||||
@@ -8,6 +8,7 @@ import io
|
||||
import pefile
|
||||
from PIL import Image
|
||||
from portprotonqt.config.base import CACHE_DIR
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -49,6 +50,9 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
custom_icon = get_custom_icon_path(exe_path)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
return os.path.join(
|
||||
CACHE_DIR,
|
||||
|
||||
@@ -498,7 +498,7 @@ def load_pixmap_async(
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
if cover and cover.startswith(("https://cdn2.steamgriddb.com/", "https://deckyloader.ru/sgdb-cdn/")):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
|
||||
@@ -86,9 +86,8 @@ class MouseEmulationThread(QThread):
|
||||
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
|
||||
}, name="Virtual DPad Mouse")
|
||||
|
||||
self.finished.emit(True)
|
||||
# Store device in thread result
|
||||
self._device = ui
|
||||
self.finished.emit(True)
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
|
||||
@@ -214,10 +213,6 @@ class InputManager(
|
||||
|
||||
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
|
||||
|
||||
if self.mouse_emulation_enabled:
|
||||
# Initialize mouse emulation asynchronously to avoid blocking startup
|
||||
QTimer.singleShot(0, self._async_enable_mouse_emulation)
|
||||
|
||||
# FileExplorer specific attributes
|
||||
self.file_explorer = None
|
||||
self.nav_timer = QTimer(self)
|
||||
@@ -265,6 +260,8 @@ class InputManager(
|
||||
|
||||
def _on_mouse_emulation_finished(self, success: bool):
|
||||
"""Handle mouse emulation thread completion."""
|
||||
if not self.mouse_emulation_enabled:
|
||||
return
|
||||
if success and self.mouse_emulation_thread:
|
||||
device = self.mouse_emulation_thread.get_device()
|
||||
if device:
|
||||
@@ -281,6 +278,22 @@ class InputManager(
|
||||
self.emulation_active = self.mouse_emulation_enabled
|
||||
if not self.emulation_active:
|
||||
self.emulation_triggered = False
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
return
|
||||
if self.emulation_triggered:
|
||||
if self.ui is None:
|
||||
self._async_enable_mouse_emulation()
|
||||
elif self.ui is not None:
|
||||
try:
|
||||
self.ui.close()
|
||||
except Exception as e:
|
||||
logger.error("EMUL: Error closing virtual mouse: %s", e)
|
||||
self.ui = None
|
||||
self.mouse_emulation_thread = None
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
self.scroll_accumulator = 0.0
|
||||
|
||||
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
|
||||
"""Common navigation logic for game cards in a container."""
|
||||
@@ -414,8 +427,10 @@ class InputManager(
|
||||
|
||||
# Stop the thread if still running
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
if thread:
|
||||
thread.wait()
|
||||
if self.ui is None:
|
||||
self.ui = thread.get_device()
|
||||
self.mouse_emulation_thread = None
|
||||
|
||||
if self.ui:
|
||||
@@ -529,12 +544,18 @@ class InputManager(
|
||||
window = self._parent
|
||||
if not isinstance(window, QWidget):
|
||||
return
|
||||
if enable and not self._is_fullscreen:
|
||||
if enable and not window.isFullScreen():
|
||||
if not window.isFullScreen():
|
||||
window_config.set_geometry(window.width(), window.height())
|
||||
window.showFullScreen()
|
||||
self._is_fullscreen = True
|
||||
elif not enable and self._is_fullscreen:
|
||||
start_animation = getattr(window, "start_boot_animation", None)
|
||||
if callable(start_animation):
|
||||
start_animation()
|
||||
elif not enable and window.isFullScreen():
|
||||
animation = getattr(window, "boot_animation", None)
|
||||
if animation is not None:
|
||||
animation.finish()
|
||||
window.showNormal()
|
||||
width, height = window_config.get_geometry()
|
||||
if width > 0 and height > 0:
|
||||
|
||||
@@ -2,12 +2,14 @@ import time
|
||||
|
||||
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QLineEdit,
|
||||
QListView,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QSlider,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
@@ -34,6 +36,11 @@ logger = get_logger(__name__)
|
||||
class ButtonInputMixin(InputMixin):
|
||||
@Slot(int, int)
|
||||
def handle_button_slot(self, button_code: int, value: int) -> None:
|
||||
animation = getattr(self._parent, "boot_animation", None)
|
||||
if animation is not None:
|
||||
if value:
|
||||
animation.finish()
|
||||
return
|
||||
if self._route_surface_button(button_code, value):
|
||||
return
|
||||
self._handle_default_button(button_code, value)
|
||||
@@ -128,10 +135,15 @@ class ButtonInputMixin(InputMixin):
|
||||
return True
|
||||
|
||||
def _handle_combo_button(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if isinstance(focused, QLineEdit) and focused.isReadOnly():
|
||||
focused = self._find_parent_combo(focused)
|
||||
if not isinstance(focused, QComboBox):
|
||||
return False
|
||||
if button_code in BUTTONS['confirm']:
|
||||
focused.showPopup()
|
||||
if focused.view().isVisible():
|
||||
self._activate_list_view_selection(focused.view())
|
||||
else:
|
||||
focused.showPopup()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and focused.view().isVisible():
|
||||
focused.hidePopup()
|
||||
@@ -155,12 +167,11 @@ class ButtonInputMixin(InputMixin):
|
||||
def _route_button_to_text_input(
|
||||
self, active: QWidget, focused: QWidget | None, button_code: int
|
||||
) -> bool:
|
||||
keyboard = (
|
||||
getattr(active, 'keyboard', None)
|
||||
if isinstance(active, AddGameDialog)
|
||||
else getattr(self._parent, 'keyboard', None)
|
||||
)
|
||||
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
|
||||
keyboard = getattr(active, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
if (
|
||||
button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit)
|
||||
and not focused.isReadOnly()
|
||||
):
|
||||
if keyboard:
|
||||
keyboard.show_for_widget(focused)
|
||||
return True
|
||||
@@ -171,7 +182,7 @@ class ButtonInputMixin(InputMixin):
|
||||
combo = self._find_parent_combo(focused)
|
||||
if combo is not None and combo.isEditable():
|
||||
line_edit = combo.lineEdit()
|
||||
if line_edit is not None and keyboard:
|
||||
if line_edit is not None and not line_edit.isReadOnly() and keyboard:
|
||||
line_edit.setFocus()
|
||||
keyboard.show_for_widget(line_edit)
|
||||
return True
|
||||
@@ -191,7 +202,7 @@ class ButtonInputMixin(InputMixin):
|
||||
parent = parent.parentWidget()
|
||||
return None
|
||||
|
||||
def _activate_list_view_selection(self, view: QListView) -> None:
|
||||
def _activate_list_view_selection(self, view: QAbstractItemView) -> None:
|
||||
index = view.currentIndex()
|
||||
if not index.isValid():
|
||||
return
|
||||
@@ -200,6 +211,7 @@ class ButtonInputMixin(InputMixin):
|
||||
combo.setCurrentIndex(index.row())
|
||||
combo.hidePopup()
|
||||
combo.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
combo.activated.emit(index.row())
|
||||
return
|
||||
view.activated.emit(index)
|
||||
view.clicked.emit(index)
|
||||
@@ -241,6 +253,11 @@ class ButtonInputMixin(InputMixin):
|
||||
return False
|
||||
|
||||
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
|
||||
if button_code in BUTTONS['add_game'] and isinstance(active, QDialog):
|
||||
action = active.findChild(QPushButton, "gamepadAddButton")
|
||||
if action is not None and action.isVisible() and action.isEnabled():
|
||||
action.click()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
|
||||
active.reject()
|
||||
return True
|
||||
|
||||
@@ -11,6 +11,7 @@ from PySide6.QtWidgets import (
|
||||
QListView,
|
||||
QListWidget,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
@@ -35,6 +36,48 @@ class InputSurface:
|
||||
|
||||
|
||||
class DialogInputModesMixin(InputMixin):
|
||||
def _get_dialog_action_buttons(self, dialog: QWidget) -> list[QPushButton]:
|
||||
"""Return available dialog buttons outside tab contents."""
|
||||
buttons = [
|
||||
button for button in dialog.findChildren(
|
||||
QPushButton, options=Qt.FindChildOption.FindDirectChildrenOnly,
|
||||
)
|
||||
if button.isVisible() and button.isEnabled()
|
||||
]
|
||||
return sorted(buttons, key=lambda button: (button.y(), button.x()))
|
||||
|
||||
def _handle_table_action_navigation(self, dialog: QWidget, code: int, value: int) -> bool:
|
||||
"""Move between the current table and dialog action buttons."""
|
||||
direction = self._normalize_dpad_direction(code, value)
|
||||
if direction is None or QApplication.activePopupWidget() is not None:
|
||||
return False
|
||||
code, value = direction
|
||||
buttons = self._get_dialog_action_buttons(dialog)
|
||||
focused = QApplication.focusWidget()
|
||||
table = next((table for table in dialog.findChildren(QTableWidget) if table.isVisible()), None)
|
||||
if isinstance(focused, QPushButton) and focused in buttons:
|
||||
self._reset_surface_navigation()
|
||||
if code == PAD_DPAD_X:
|
||||
index = buttons.index(focused) + value
|
||||
if 0 <= index < len(buttons):
|
||||
buttons[index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
elif value < 0:
|
||||
if table is not None:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
elif dialog is getattr(self, 'settings_dialog', None):
|
||||
self._focus_first_row_in_current_settings_table()
|
||||
return True
|
||||
if table is None or code != PAD_DPAD_Y or value < 0 or not buttons:
|
||||
return False
|
||||
next_row = table.currentRow() + 1
|
||||
while next_row < table.rowCount() and table.isRowHidden(next_row):
|
||||
next_row += 1
|
||||
if next_row < table.rowCount():
|
||||
return False
|
||||
self._reset_surface_navigation()
|
||||
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
# WINETRICKS MODE
|
||||
def enable_winetricks_mode(self, winetricks_dialog):
|
||||
"""Setup gamepad handling for WinetricksDialog"""
|
||||
@@ -70,9 +113,13 @@ class DialogInputModesMixin(InputMixin):
|
||||
# Winetricks-specific button handling
|
||||
focused = QApplication.focusWidget()
|
||||
|
||||
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
|
||||
if isinstance(focused, QTableWidget):
|
||||
if button_code in BUTTONS['confirm']: # A: Confirm
|
||||
if isinstance(focused, QCheckBox) and focused.isEnabled():
|
||||
focused.toggle()
|
||||
elif isinstance(focused, QTableWidget):
|
||||
self.handle_table_confirm(focused)
|
||||
elif isinstance(focused, QPushButton) and focused.isEnabled():
|
||||
focused.click()
|
||||
return
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X: Install
|
||||
@@ -110,6 +157,8 @@ class DialogInputModesMixin(InputMixin):
|
||||
if self.winetricks_dialog is None:
|
||||
return
|
||||
try:
|
||||
if self._handle_table_action_navigation(self.winetricks_dialog, code, value):
|
||||
return
|
||||
if value == 0: # Release
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
@@ -498,7 +547,10 @@ class DialogInputModesMixin(InputMixin):
|
||||
# ProtonManager-specific button handling
|
||||
focused = QApplication.focusWidget()
|
||||
|
||||
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
|
||||
if button_code in BUTTONS['confirm']: # A: Confirm
|
||||
if isinstance(focused, QPushButton) and focused.isEnabled():
|
||||
focused.click()
|
||||
return
|
||||
if isinstance(focused, QTableWidget):
|
||||
current_row = focused.currentRow()
|
||||
if current_row >= 0:
|
||||
@@ -555,6 +607,8 @@ class DialogInputModesMixin(InputMixin):
|
||||
return
|
||||
|
||||
try:
|
||||
if self._handle_table_action_navigation(self.proton_manager_dialog, code, value):
|
||||
return
|
||||
if value == 0: # Release
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
|
||||
@@ -317,10 +317,7 @@ class DpadInputMixin(InputMixin):
|
||||
|
||||
def _handle_default_dpad(self, code: int, value: int, current_time: float) -> None:
|
||||
active_window = QApplication.activeWindow()
|
||||
if isinstance(active_window, AddGameDialog):
|
||||
keyboard = getattr(active_window, 'keyboard', None)
|
||||
else:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
keyboard = getattr(active_window, 'keyboard', None) or getattr(self._parent, 'keyboard', None)
|
||||
|
||||
if value == 0:
|
||||
self._reset_dpad_repeat()
|
||||
|
||||
@@ -138,9 +138,13 @@ class FileExplorerInputMixin(InputMixin):
|
||||
current_item = self.file_explorer.file_list.currentItem()
|
||||
if current_item:
|
||||
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
|
||||
self.file_explorer.show_folder_context_menu(item_rect.center())
|
||||
self.file_explorer.show_context_menu(item_rect.center())
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X button
|
||||
if self._handle_active_window_button(self.file_explorer, button_code):
|
||||
return
|
||||
if not self.file_explorer.directory_only:
|
||||
return
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
return
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import cast
|
||||
|
||||
from shiboken6 import isValid
|
||||
from PySide6.QtCore import QEvent, QObject, QPoint, Qt
|
||||
from PySide6.QtGui import QKeySequence
|
||||
from PySide6.QtGui import QKeyEvent, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QApplication,
|
||||
@@ -93,10 +93,6 @@ class KeyboardInputMixin(InputMixin):
|
||||
keyboard.stop_backspace_repeat()
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
if event.type() == QEvent.Type.Show:
|
||||
if isinstance(obj, QMenu):
|
||||
SoundManager().play("open")
|
||||
@@ -134,7 +130,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if self._redirect_gamecard_input_to_search(event):
|
||||
if isinstance(event, QKeyEvent) and self._redirect_gamecard_input_to_search(event):
|
||||
return True
|
||||
key = self._qt_event_to_input_key(event)
|
||||
if key is not None:
|
||||
@@ -162,7 +158,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
|
||||
return False
|
||||
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
return False
|
||||
|
||||
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
|
||||
native_key = self._native_scan_to_input_key(event)
|
||||
@@ -239,7 +235,9 @@ class KeyboardInputMixin(InputMixin):
|
||||
1: 'autoInstallSearchLineEdit',
|
||||
}
|
||||
|
||||
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
|
||||
def _redirect_gamecard_input_to_search(self, event: QKeyEvent) -> bool:
|
||||
if event.key() == Qt.Key.Key_Space:
|
||||
return False
|
||||
focused = self._focused_widget()
|
||||
if not isinstance(focused, GameCard):
|
||||
return False
|
||||
@@ -448,9 +446,9 @@ class KeyboardInputMixin(InputMixin):
|
||||
for attr_name in (
|
||||
"gamesSortCombo",
|
||||
"gamesDisplayCombo",
|
||||
"onlyInstalledCheckBox",
|
||||
"gamesBadgeViewCombo",
|
||||
"gamesLayoutCombo",
|
||||
"onlyInstalledCheckBox",
|
||||
):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
|
||||
@@ -516,13 +514,14 @@ class KeyboardInputMixin(InputMixin):
|
||||
filter_widgets = self._get_library_filter_widgets() if current_index == 0 else []
|
||||
if focused in filter_widgets:
|
||||
if code == PAD_DPAD_X:
|
||||
widget_index = filter_widgets.index(cast(QWidget, focused))
|
||||
next_index = widget_index + (1 if value > 0 else -1)
|
||||
if 0 <= next_index < len(filter_widgets):
|
||||
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if code != PAD_DPAD_Y:
|
||||
return False
|
||||
widget_index = filter_widgets.index(cast(QWidget, focused))
|
||||
next_index = widget_index + (1 if value > 0 else -1)
|
||||
if 0 <= next_index < len(filter_widgets):
|
||||
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value < 0:
|
||||
controls_button = getattr(self._parent, "libraryControlsButton", None)
|
||||
if isinstance(controls_button, QWidget):
|
||||
|
||||
@@ -100,7 +100,7 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
if not self._initial_gamepad_check:
|
||||
SoundManager().play("gamepad_connect")
|
||||
|
||||
if display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
|
||||
if not self._initial_gamepad_check and display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
|
||||
self.toggle_fullscreen.emit(True)
|
||||
|
||||
elif self.gamepad:
|
||||
@@ -217,7 +217,12 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
|
||||
def _poll_button_events(self, gamepad: SDLGamepad, current_time: float) -> None:
|
||||
"""Emit button changes using SDL's standardized controller mapping."""
|
||||
for button_index, button_code in SDL_CONTROLLER_BUTTON_TO_PAD.items():
|
||||
# Deliver Guide before Select when both change in the same SDL snapshot.
|
||||
buttons = sorted(
|
||||
SDL_CONTROLLER_BUTTON_TO_PAD.items(),
|
||||
key=lambda item: item[1] not in BUTTONS['guide'],
|
||||
)
|
||||
for button_index, button_code in buttons:
|
||||
value = gamepad.get_button(button_index)
|
||||
self._handle_button_value(button_index, button_code, value, current_time)
|
||||
|
||||
@@ -225,8 +230,13 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
if self._button_states.get(button_index) == value:
|
||||
return
|
||||
self._button_states[button_index] = value
|
||||
if getattr(self._parent, "boot_animation", None) is not None:
|
||||
self.button_event.emit(button_code, value)
|
||||
return
|
||||
if button_code in BUTTONS['guide']:
|
||||
self.guide_held = value == 1
|
||||
if self.guide_held:
|
||||
self.pending_menu_fullscreen_time = 0.0
|
||||
if button_code in BUTTONS['menu']:
|
||||
self.select_held = value == 1
|
||||
if button_code == PAD_BUTTON_START:
|
||||
@@ -259,7 +269,8 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
return (
|
||||
value == 1 and button_code in BUTTONS['menu'] and
|
||||
not emulation_combo and
|
||||
not self._is_gamescope_session and not self.in_guide_combination_attempt and
|
||||
not self._is_gamescope_session and not self.guide_held and
|
||||
not self.in_guide_combination_attempt and
|
||||
self._parent.isActiveWindow()
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +128,7 @@ class SettingsInputMixin(InputMixin):
|
||||
open_combo.lineEdit().setFocus()
|
||||
else:
|
||||
open_combo.setFocus()
|
||||
open_combo.activated.emit(open_combo.currentIndex())
|
||||
return
|
||||
|
||||
# Standard interaction
|
||||
@@ -183,10 +184,7 @@ class SettingsInputMixin(InputMixin):
|
||||
return
|
||||
|
||||
# 4. Global Shortcuts
|
||||
if button_code in BUTTONS['add_game']: # X: Apply
|
||||
self.settings_dialog.apply_changes()
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
if button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
focused = QApplication.focusWidget()
|
||||
focused_combo = None
|
||||
if isinstance(focused, QTableWidget) and self.settings_dialog:
|
||||
@@ -269,6 +267,10 @@ class SettingsInputMixin(InputMixin):
|
||||
kb.move_focus_up()
|
||||
return
|
||||
|
||||
if self._handle_table_action_navigation(self.settings_dialog, code, value):
|
||||
return
|
||||
action_buttons = self._get_dialog_action_buttons(self.settings_dialog)
|
||||
|
||||
if self._is_current_settings_tool_tab():
|
||||
focused = QApplication.focusWidget()
|
||||
open_combo = self._get_open_settings_combo()
|
||||
@@ -314,6 +316,8 @@ class SettingsInputMixin(InputMixin):
|
||||
self.current_dpad_value = normalized_value
|
||||
|
||||
sections = self._get_mangohud_nav_sections()
|
||||
if action_buttons:
|
||||
sections.append(action_buttons)
|
||||
if not sections:
|
||||
return
|
||||
|
||||
@@ -335,15 +339,23 @@ class SettingsInputMixin(InputMixin):
|
||||
combo_tables = (
|
||||
self.settings_dialog.advanced_table,
|
||||
self.settings_dialog.settings_table,
|
||||
getattr(self.settings_dialog, "favorites_table", None),
|
||||
)
|
||||
if self.settings_dialog and table in combo_tables and table.currentRow() >= 0:
|
||||
cell_widget = table.cellWidget(table.currentRow(), 1)
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
idx = cell_widget.currentIndex()
|
||||
new_idx = max(0, idx - 1) if value < 0 else min(cell_widget.count() - 1, idx + 1)
|
||||
if new_idx != idx:
|
||||
cell_widget.setCurrentIndex(new_idx)
|
||||
view = cell_widget.view()
|
||||
idx = view.currentIndex().row()
|
||||
step = -1 if value < 0 else 1
|
||||
model = cell_widget.model()
|
||||
end = -1 if step < 0 else cell_widget.count()
|
||||
new_idx = next((
|
||||
row for row in range(idx + step, end, step)
|
||||
if model.flags(model.index(row, 0)) & Qt.ItemFlag.ItemIsEnabled
|
||||
), idx)
|
||||
view.setCurrentIndex(model.index(new_idx, 0))
|
||||
view.scrollTo(model.index(new_idx, 0), QAbstractItemView.ScrollHint.EnsureVisible)
|
||||
return # Consume event
|
||||
|
||||
# 3. Standard Table Navigation
|
||||
@@ -464,7 +476,14 @@ class SettingsInputMixin(InputMixin):
|
||||
if table != self.settings_dialog.advanced_table:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
entry_widget = table.cellWidget(row, 0)
|
||||
if isinstance(entry_widget, QLineEdit):
|
||||
entry_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, QPushButton):
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if isinstance(cell_widget, QComboBox):
|
||||
line_edit = cell_widget.lineEdit()
|
||||
if cell_widget.isEditable() and line_edit is not None:
|
||||
@@ -480,15 +499,33 @@ class SettingsInputMixin(InputMixin):
|
||||
and cell_widget.property("ppqt_run_after_exe_widget")
|
||||
):
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
if line_edit and line_edit.isEnabled():
|
||||
if line_edit and line_edit.isEnabled() and not line_edit.isHidden():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
if button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
|
||||
"""Move focus between inline line edit and button in settings cell."""
|
||||
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
|
||||
return False
|
||||
focused = QApplication.focusWidget()
|
||||
for row in range(table.rowCount()):
|
||||
entry_widget = table.cellWidget(row, 0)
|
||||
delete_button = table.cellWidget(row, 1)
|
||||
if not isinstance(entry_widget, QLineEdit) or not isinstance(delete_button, QPushButton):
|
||||
continue
|
||||
if focused not in (entry_widget, delete_button):
|
||||
continue
|
||||
widget = delete_button if focused == entry_widget else entry_widget
|
||||
if not widget.isEnabled():
|
||||
return False
|
||||
table.setCurrentCell(row, 1)
|
||||
widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if table.currentColumn() != 1:
|
||||
return False
|
||||
|
||||
@@ -496,15 +533,14 @@ class SettingsInputMixin(InputMixin):
|
||||
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
|
||||
return False
|
||||
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
widgets = [cell_widget.findChild(QLineEdit), *cell_widget.findChildren(QPushButton)]
|
||||
widgets = [widget for widget in widgets if widget and widget.isEnabled() and not widget.isHidden()]
|
||||
focused = QApplication.focusWidget()
|
||||
if value > 0 and focused == line_edit and button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if focused in widgets:
|
||||
index = widgets.index(focused) + (1 if value > 0 else -1)
|
||||
if 0 <= index < len(widgets):
|
||||
widgets[index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _focus_first_row_in_current_settings_table(self):
|
||||
|
||||
@@ -22,14 +22,22 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.5\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr ""
|
||||
|
||||
@@ -111,12 +119,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr ""
|
||||
@@ -142,9 +144,15 @@ msgstr ""
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -269,14 +277,6 @@ msgstr ""
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -294,10 +294,6 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -308,10 +304,6 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -471,6 +463,16 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -886,6 +888,22 @@ msgstr ""
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
@@ -901,6 +919,9 @@ msgstr ""
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr ""
|
||||
|
||||
@@ -913,6 +934,30 @@ msgstr ""
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "Select games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr ""
|
||||
|
||||
@@ -928,7 +973,13 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -952,9 +1003,6 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1096,9 +1144,6 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1186,9 +1231,6 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr ""
|
||||
|
||||
@@ -1207,6 +1249,9 @@ msgstr ""
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
@@ -2114,9 +2159,6 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2144,7 +2186,7 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgid "Show only installed games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
@@ -2165,7 +2207,7 @@ msgstr ""
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Table"
|
||||
msgid "Vertical"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
@@ -2174,13 +2216,13 @@ msgstr ""
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid Type:"
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -2189,6 +2231,9 @@ msgid ""
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2219,9 +2264,15 @@ msgstr ""
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
@@ -2246,6 +2297,9 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2255,6 +2309,9 @@ msgstr ""
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr ""
|
||||
|
||||
@@ -2463,16 +2520,10 @@ msgstr ""
|
||||
msgid "Failed to download theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-09-11 12:20+0000\n"
|
||||
"PO-Revision-Date: 2026-10-24 09:27+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -18,17 +18,27 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Spiel beenden"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr "Kein Lese/Schreibzugriff auf den Ordner. Wähle anderen Benutzerordner."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"PortProtonQt läuft bereits. Möchtest du es sowie alle Spiele beenden und "
|
||||
"anschließend PortProtonQt neu starten?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Nein"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Aktualisierung abgeschlossen"
|
||||
|
||||
@@ -114,12 +124,6 @@ msgstr ""
|
||||
"Soll der Ordner '{folder_name}' wirklich gelöscht werden? Dieser Vorgang ist "
|
||||
"unwiderruflich."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Ja"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Nein"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Ordner konnte nicht gelöscht werden: {error}"
|
||||
@@ -147,9 +151,15 @@ msgstr "Die Datei existiert bereits: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Datei konnte nicht umbenannt werden: {error}"
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr "Mehrere auswählen"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Von PortProton löschen"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Spiel beenden"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Spiel starten"
|
||||
|
||||
@@ -279,14 +289,6 @@ msgstr "Fehler beim Generieren der .desktop-Datei"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Die alte .desktop-Datei für '{game_name}' wurde entfernt"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
|
||||
@@ -306,10 +308,6 @@ msgstr ""
|
||||
"'{game_name}' wurde zu Steam hinzugefügt. Bitte starte Steam neu, damit die "
|
||||
"Änderungen wirksam werden."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' wurde zu Steam hinzugefügt."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Fehler beim Hinzufügen von '{game_name}' zu Steam: {error}"
|
||||
@@ -322,10 +320,6 @@ msgstr ""
|
||||
"'{game_name}' wurde von Steam entfernt. Bitte starte Steam neu, damit die "
|
||||
"Änderungen wirksam werden."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' wurde von Steam entfernt."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Das Spiel '{game_name}' konnte nicht von Steam entfernt werden: {error}"
|
||||
@@ -483,6 +477,20 @@ msgstr "Eine weitere Epic-Operation läuft"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Wine-Prefix nicht gefunden"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"In den Pfaden wurden kyrillische Zeichen gefunden. Wine funktioniert unter "
|
||||
"Umständen nicht zuverlässig. Verschiebe das Spiel oder PortProton an einen "
|
||||
"Pfad, der keine kyrillischen Zeichen enthält:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "Nicht erneut erinnern"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
|
||||
@@ -958,6 +966,22 @@ msgstr "AppImage aktualisieren..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Start {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "Scannen: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "Überprüfung vorhandener Verknüpfungen: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "Hinzufügen: {name}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Verknüpfung konnte nicht erstellt werden: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Titel:"
|
||||
|
||||
@@ -973,6 +997,10 @@ msgstr "Eigenes Titelbild:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Lokaler Pfad oder die URL für das Titelbild"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Eigenes Titelbild:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Verknüpfung hinzufügen zu:"
|
||||
|
||||
@@ -985,6 +1013,32 @@ msgstr "Anwenden"
|
||||
msgid "Invalid image"
|
||||
msgstr "Ungültiges Bild"
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr "Ordner auswählen"
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr "Spiele werden verarbeitet…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Verarbeitet {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "Im ausgewählten Ordner wurden keine Spiele gefunden."
|
||||
|
||||
msgid "Select games"
|
||||
msgstr "Spiele auswählen"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Herunterladen des Titelbildes fehlgeschlagen"
|
||||
|
||||
@@ -1002,8 +1056,14 @@ msgstr ""
|
||||
"Das Programm wurde beendet. Kompatibilitätsdetails wurden automatisch "
|
||||
"erfasst."
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Schließen"
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
@@ -1026,9 +1086,6 @@ msgstr "Vorheriger Tab"
|
||||
msgid "Next Tab"
|
||||
msgstr "Nächster Tab"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Speichern"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Suchen"
|
||||
|
||||
@@ -1176,9 +1233,6 @@ msgstr "WINE/Proton konnte nicht entfernt werden in {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "{} WINE/Proton erfolgreich entfernt."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download abgeschlossen"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Alle ausgewählten WINE/Proton-Versionen wurden heruntergeladen!"
|
||||
|
||||
@@ -1218,15 +1272,14 @@ msgstr "Der Download oder das Entpacken wurde abgebrochen."
|
||||
msgid "System WINE"
|
||||
msgstr "System WINE"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Einstellungen speichern"
|
||||
msgstr "Globale Spieleinstellungen"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Einstellungen für die exe-Datei"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
msgstr "user.conf bearbeiten"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Einstellungen suchen…"
|
||||
@@ -1252,9 +1305,8 @@ msgstr "PPDB bearbeiten"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "PPDB zurücksetzen"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Laden der Themes fehlgeschlagen"
|
||||
msgstr "Das Laden der globalen Spieleinstellungen ist fehlgeschlagen."
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "Der Pfad zur ausführbaren Datei ist nicht verfügbar."
|
||||
@@ -1265,12 +1317,8 @@ msgstr "PPDB-Datei ist nicht vorhanden unter: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
msgstr "Datei konnte nicht geöffnet werden:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "PPDB-Zurücksetzung bestätigen"
|
||||
@@ -1292,6 +1340,9 @@ msgstr "Standard"
|
||||
msgid "disabled"
|
||||
msgstr "deaktiviert"
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Lade Wine/Proton herunter..."
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Prefix-Name eingeben"
|
||||
|
||||
@@ -1579,17 +1630,14 @@ msgstr "Hintergrund-Deckkraft"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "Abgerundete Ecken (px)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font scale"
|
||||
msgstr "Schriftarten"
|
||||
msgstr "Schriftgröße"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font size"
|
||||
msgstr "Schriftarten"
|
||||
msgstr "Schriftgröße"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font"
|
||||
msgstr "Schriftarten"
|
||||
msgstr "Schrift"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "Oben links"
|
||||
@@ -2218,9 +2266,6 @@ msgstr "Fehlgeschlagen"
|
||||
msgid "Repaired"
|
||||
msgstr "Repariert"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Download-Vorgang ist fehlgeschlagen"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Schnellstart"
|
||||
|
||||
@@ -2248,8 +2293,8 @@ msgstr "Alle"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Anzeigefilter:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Nur installierte"
|
||||
msgid "Show only installed games"
|
||||
msgstr "Nur installierte Spiele anzeigen"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detailliert"
|
||||
@@ -2260,39 +2305,32 @@ msgstr "Versteckt"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Anzeigetyp von Titelbildern:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Themen"
|
||||
msgstr "Thema"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
msgstr "Gitter"
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Hell"
|
||||
msgstr "Liste"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Stable"
|
||||
msgid "Vertical"
|
||||
msgstr "Vertikal"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Horizontales HUD-Layout"
|
||||
msgstr "Horizontale Blöcke"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Horizontales HUD-Layout"
|
||||
msgstr "Horizontale Karten"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr "Horizontales HUD-Layout"
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "Endlose horizontale Blöcke"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "Endlose horizontale Karten"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "Gamepad-Typ:"
|
||||
msgid "Layout"
|
||||
msgstr "Layout"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
@@ -2302,6 +2340,9 @@ msgstr ""
|
||||
"Sollen '{count}' Titelbidler ohne ausführbaren Dateien wirklich gelöscht "
|
||||
"werden? Dabei werden Benutzerdaten und .desktop-Dateien gelöscht."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Installation des Themes fehlgeschlagen"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton-Einstellungen"
|
||||
|
||||
@@ -2332,9 +2373,15 @@ msgstr "Gamepad-Typ:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Anwendung im Vollbildmodus starten"
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Cursor im Vollbildmodus ausblenden"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Automatischer Vollbildmodus bei angeschlossenem Gamepad"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "Startanimation deaktivieren"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Beim Schließen in die Taskleiste minimieren"
|
||||
|
||||
@@ -2359,8 +2406,11 @@ msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "Warnung bei kyrillischen Pfaden deaktivieren"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
msgstr "ALT Linux i586-Abhängigkeitsprüfung deaktivieren"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Runtime-Download deaktivieren"
|
||||
@@ -2368,6 +2418,9 @@ msgstr "Runtime-Download deaktivieren"
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "Zu den Steam-Kompatibilitätswerkzeugen hinzufügen"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "Scan nach Steam-Proton deaktivieren"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Lade WINE/Proton zu Steam herunter"
|
||||
|
||||
@@ -2580,15 +2633,9 @@ msgstr "Laden der Themes fehlgeschlagen"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Herunterladen des Themes fehlgeschlagen"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Herunterladen…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Herunterladen… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Installation des Themes fehlgeschlagen"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Theme auswählen:"
|
||||
|
||||
|
||||
@@ -21,9 +21,6 @@ msgstr ""
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Detener Juego"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
@@ -31,6 +28,17 @@ msgstr ""
|
||||
"La carpeta de datos de PortProton no tiene permisos de lectura ni escritura. "
|
||||
"Elija otra carpeta para los datos de PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Actualización completada"
|
||||
|
||||
@@ -116,12 +124,6 @@ msgstr ""
|
||||
"¿Estás seguro de que quieres eliminar la carpeta «{folder_name}»? Esta "
|
||||
"acción no se puede deshacer."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sí"
|
||||
|
||||
msgid "No"
|
||||
msgstr "No"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "No se ha podido eliminar la carpeta: {error}"
|
||||
@@ -149,9 +151,16 @@ msgstr "El archivo ya existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "No se pudo cambiar el nombre del archivo: {error}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select multiple"
|
||||
msgstr "Seleccionar todo"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Eliminar de PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Detener Juego"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar juego"
|
||||
|
||||
@@ -281,14 +290,6 @@ msgstr "No se han podido generar los datos del archivo .desktop"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "No se ha podido guardar el archivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Se ha eliminado el antiguo archivo .desktop de «{game_name}»"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "No se ha podido copiar la imagen de portada: {error}"
|
||||
@@ -308,10 +309,6 @@ msgstr ""
|
||||
"Se ha añadido «{game_name}» a Steam. Reinicia Steam para que los cambios "
|
||||
"surtan efecto."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "«{game_name}» se ha añadido a Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "No se ha podido añadir «{game_name}» a Steam: {error}"
|
||||
@@ -324,10 +321,6 @@ msgstr ""
|
||||
"«{game_name}» se ha eliminado de Steam. Por favor, reinicia Steam para que "
|
||||
"los cambios surtan efecto."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "«{game_name}» se ha eliminado de Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "No se ha podido eliminar el juego «{game_name}» de Steam: {error}"
|
||||
@@ -488,6 +481,16 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefijo no encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Juego no encontrado."
|
||||
|
||||
@@ -977,6 +980,22 @@ msgstr "Actualizando AppImage…"
|
||||
msgid "Launching {0}"
|
||||
msgstr "Comenzando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "No se ha podido crear la carpeta: {error}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nombre:"
|
||||
|
||||
@@ -992,6 +1011,10 @@ msgstr "Portada personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Ingrese la ruta local o la URL de la imagen de portada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Portada personalizada:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Agregar acceso directo a:"
|
||||
|
||||
@@ -1004,6 +1027,33 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagen no válida"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "Borrar la carpeta"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Processing games…"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "No se ha podido descargar la portada"
|
||||
|
||||
@@ -1019,7 +1069,14 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -1043,9 +1100,6 @@ msgstr "Pestaña anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Pestaña siguiente"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Guardar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Buscar"
|
||||
|
||||
@@ -1191,9 +1245,6 @@ msgstr "No se pudo eliminar WINE/Proton en {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1284,9 +1335,6 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "No se ha podido abrir la carpeta: {error}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar que PPDB está vacío"
|
||||
|
||||
@@ -1310,6 +1358,10 @@ msgstr ""
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Versión de WINE/Proton"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
@@ -2222,10 +2274,6 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Descargar seleccionados"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2254,8 +2302,8 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Solo instalados"
|
||||
msgid "Show only installed games"
|
||||
msgstr "Mostrar solo los juegos instalados"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
@@ -2277,8 +2325,8 @@ msgid "List"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Estable"
|
||||
msgid "Vertical"
|
||||
msgstr "parcial"
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr ""
|
||||
@@ -2286,13 +2334,13 @@ msgstr ""
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid Type:"
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
@@ -2303,6 +2351,9 @@ msgstr ""
|
||||
"¿Estás seguro de que quieres eliminar «{game_name}»? Esto eliminará el "
|
||||
"archivo .desktop y los datos personalizados."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Error al instalar el tema"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2333,9 +2384,15 @@ msgstr ""
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
@@ -2360,6 +2417,9 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2369,6 +2429,9 @@ msgstr ""
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr ""
|
||||
|
||||
@@ -2579,15 +2642,9 @@ msgstr "Error al cargar los temas"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Error al descargar los temas"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Descargando…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Descargando… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Error al instalar el tema"
|
||||
|
||||
msgid "Select Theme:"
|
||||
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: 2026-09-26 12:00+0500\n"
|
||||
"POT-Creation-Date: 2026-10-24 18:13+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"
|
||||
@@ -18,14 +18,22 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr ""
|
||||
|
||||
@@ -107,12 +115,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
|
||||
msgid "Yes"
|
||||
msgstr ""
|
||||
|
||||
msgid "No"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr ""
|
||||
@@ -138,9 +140,15 @@ msgstr ""
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -265,14 +273,6 @@ msgstr ""
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -290,10 +290,6 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -304,10 +300,6 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr ""
|
||||
@@ -467,6 +459,16 @@ msgstr ""
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -882,6 +884,22 @@ msgstr ""
|
||||
msgid "Launching {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Name:"
|
||||
msgstr ""
|
||||
|
||||
@@ -897,6 +915,9 @@ msgstr ""
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr ""
|
||||
|
||||
@@ -909,6 +930,30 @@ msgstr ""
|
||||
msgid "Invalid image"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr ""
|
||||
|
||||
msgid "Select games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr ""
|
||||
|
||||
@@ -924,7 +969,13 @@ msgstr ""
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
|
||||
msgid "Close"
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open"
|
||||
@@ -948,9 +999,6 @@ msgstr ""
|
||||
msgid "Next Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search"
|
||||
msgstr ""
|
||||
|
||||
@@ -1092,9 +1140,6 @@ msgstr ""
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr ""
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr ""
|
||||
|
||||
@@ -1182,9 +1227,6 @@ msgstr ""
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr ""
|
||||
|
||||
@@ -1203,6 +1245,9 @@ msgstr ""
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
@@ -2110,9 +2155,6 @@ msgstr ""
|
||||
msgid "Repaired"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr ""
|
||||
|
||||
@@ -2140,7 +2182,7 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgid "Show only installed games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
@@ -2161,7 +2203,7 @@ msgstr ""
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Table"
|
||||
msgid "Vertical"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
@@ -2170,13 +2212,13 @@ msgstr ""
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid Type:"
|
||||
msgid "Layout"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -2185,6 +2227,9 @@ msgid ""
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -2215,9 +2260,15 @@ msgstr ""
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr ""
|
||||
|
||||
@@ -2242,6 +2293,9 @@ msgstr ""
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
@@ -2251,6 +2305,9 @@ msgstr ""
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr ""
|
||||
|
||||
@@ -2460,16 +2517,10 @@ msgstr ""
|
||||
msgid "Failed to download theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-09-09 00:26+0000\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
|
||||
"portprotonqt/portprotonqt/pt_BR/>\n"
|
||||
@@ -18,12 +18,9 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n > 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Parar o Jogo"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
@@ -31,6 +28,19 @@ msgstr ""
|
||||
"A pasta de dados do PortProton não pode ser lida nem gravada. Escolha "
|
||||
"outra pasta para os dados do PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"O PortProtonQt já está em execução. Deseja encerrá-lo junto com todos os "
|
||||
"jogos abertos e reiniciá-lo para iniciar uma nova sessão?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Atualização concluída"
|
||||
|
||||
@@ -114,12 +124,6 @@ msgstr ""
|
||||
"Tem certeza de que deseja excluir a pasta '{folder_name}'? Esta ação não "
|
||||
"pode ser desfeita."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Sim"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Não"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Falha ao excluir a pasta: {error}"
|
||||
@@ -147,9 +151,15 @@ msgstr "A pasta já existe: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr "Selecionar vários"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Excluir do PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Parar o Jogo"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
|
||||
@@ -258,17 +268,16 @@ msgstr "Área de Trabalho"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Falha ao salvar os dados personalizados: {error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "Executável não encontrado: {0}"
|
||||
msgstr "Executável não encontrado"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Executável não encontrado: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select another executable"
|
||||
msgstr "Excluir atalhos sem executável"
|
||||
msgstr "Selecione outro executável"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "O nome do jogo e o caminho do executável são obrigatórios"
|
||||
@@ -280,14 +289,6 @@ msgstr "Falha ao gerar os dados do arquivo .desktop"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Falha ao salvar o arquivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Arquivo .desktop antigo de '{game_name}' removido"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Falha ao copiar a imagem de capa: {error}"
|
||||
@@ -307,10 +308,6 @@ msgstr ""
|
||||
"'{game_name}' foi adicionado à Steam. Reinicie a Steam para aplicar as "
|
||||
"alterações."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' foi adicionado à Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Falha ao adicionar '{game_name}' à Steam: {error}"
|
||||
@@ -323,10 +320,6 @@ msgstr ""
|
||||
"'{game_name}' foi removido da Steam. Reinicie à Steam para que as "
|
||||
"alterações tenham efeito."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' foi removido da Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Falha ao remover o jogo '{game_name}' da Steam: {error}"
|
||||
@@ -483,6 +476,20 @@ msgstr "Outra operação da Epic está em andamento"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefixo do Wine não encontrado"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"Foram encontrados caracteres cirílicos nos seguintes caminhos. O Wine pode "
|
||||
"funcionar de forma instável. Mova o jogo ou o PortProton para um caminho sem "
|
||||
"caracteres cirílicos:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "Não lembrar novamente"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
@@ -840,9 +847,8 @@ msgstr "min."
|
||||
msgid "sec."
|
||||
msgstr "s."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Pause Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
msgstr "Pausar jogo"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Sair"
|
||||
@@ -856,13 +862,11 @@ msgstr "Favoritos"
|
||||
msgid "Recent Games"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Resume Game"
|
||||
msgstr "Jogos Recentes"
|
||||
msgstr "Retomar jogo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Falha ao parar o jogo"
|
||||
msgstr "Falha ao pausar o jogo"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Nenhum jogo recente"
|
||||
@@ -963,6 +967,22 @@ msgstr "Atualizando AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Iniciando {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "Verificado: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "Verificando os atalhos existentes: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "Adicionando: {name}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Falha ao criar atalho: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Nome:"
|
||||
|
||||
@@ -978,6 +998,9 @@ msgstr "Capa Personalizada:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Informe o caminho local ou a URL da imagem de capa"
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Ícone personalizado:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Adicionar atalho em:"
|
||||
|
||||
@@ -990,6 +1013,32 @@ msgstr "Aplicar"
|
||||
msgid "Invalid image"
|
||||
msgstr "Imagem inválida"
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr "Selecionar pasta"
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr "Processando jogos…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Processados: {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "Nenhum jogo encontrado na pasta selecionada."
|
||||
|
||||
msgid "Select games"
|
||||
msgstr "Selecionar jogos"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Falha ao baixar a capa"
|
||||
|
||||
@@ -1007,8 +1056,14 @@ msgstr ""
|
||||
"O programa foi encerrado. As informações de compatibilidade foram coletadas "
|
||||
"automaticamente."
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
@@ -1031,9 +1086,6 @@ msgstr "Aba Anterior"
|
||||
msgid "Next Tab"
|
||||
msgstr "Próxima Aba"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Salvar"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Pesquisar"
|
||||
|
||||
@@ -1181,9 +1233,6 @@ msgstr "Falha ao remover WINE/Proton em {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "{} WINE/Proton removido(s) com sucesso."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Download Concluído"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Todos os WINE/Proton selecionados foram baixados!"
|
||||
|
||||
@@ -1222,15 +1271,14 @@ msgstr "O download ou a extração foi cancelado."
|
||||
msgid "System WINE"
|
||||
msgstr "WINE do Sistema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Salvar Configurações"
|
||||
msgstr "Configurações globais dos jogos"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Configurações do Exe"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
msgstr "Editar o arquivo user.conf"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Pesquisar configurações…"
|
||||
@@ -1256,9 +1304,8 @@ msgstr "Editar PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Limpar PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Falha ao carregar temas"
|
||||
msgstr "Falha ao carregar as configurações globais dos jogos."
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "O caminho do executável não está disponível."
|
||||
@@ -1269,12 +1316,8 @@ msgstr "O arquivo PPDB não existe em: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
msgstr "Falha ao abrir o arquivo:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Confirmar Limpeza do PPDB"
|
||||
@@ -1296,6 +1339,9 @@ msgstr "Padrão"
|
||||
msgid "disabled"
|
||||
msgstr "desativado"
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Baixar Wine/Proton..."
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Digite o nome do prefixo"
|
||||
|
||||
@@ -1581,17 +1627,14 @@ msgstr "Opacidade do fundo"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "Cantos arredondados (px)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font scale"
|
||||
msgstr "Fontes"
|
||||
msgstr "Escala da fonte"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font size"
|
||||
msgstr "Fontes"
|
||||
msgstr "Tamanho da fonte"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font"
|
||||
msgstr "Fontes"
|
||||
msgstr "Fonte"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "Superior esquerdo"
|
||||
@@ -2138,7 +2181,7 @@ msgid "GOG account connected"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
msgid "Log out"
|
||||
msgstr "Encerrar sessão"
|
||||
msgstr "Sair da sessão"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Abrir página de login"
|
||||
@@ -2220,9 +2263,6 @@ msgstr "Falhou"
|
||||
msgid "Repaired"
|
||||
msgstr "Reparado"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Início Rápido"
|
||||
|
||||
@@ -2250,8 +2290,8 @@ msgstr "Todos"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Filtro de Exibição:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Apenas instalados"
|
||||
msgid "Show only installed games"
|
||||
msgstr "Mostrar apenas jogos instalados"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detalhado"
|
||||
@@ -2262,39 +2302,32 @@ msgstr "Oculto"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Modo de Exibição do Selo:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Temas"
|
||||
msgstr "Tema"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
msgstr "Grade"
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Claro"
|
||||
msgstr "Lista"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Estável"
|
||||
msgid "Vertical"
|
||||
msgstr "Vertical"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Layout horizontal do HUD"
|
||||
msgstr "Blocos horizontais"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Layout horizontal do HUD"
|
||||
msgstr "Cartões horizontais"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr "Layout horizontal do HUD"
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "Blocos horizontais infinitos"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "Cartões horizontais infinitos"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "Tipo de Controle:"
|
||||
msgid "Layout"
|
||||
msgstr "Layout"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
@@ -2304,6 +2337,9 @@ msgstr ""
|
||||
"Tem certeza de que deseja excluir {count} cartões sem executável? Isso "
|
||||
"removerá os arquivos .desktop e os dados personalizados."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Falha ao instalar o tema"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Configurações do PortProton"
|
||||
|
||||
@@ -2334,9 +2370,15 @@ msgstr "Tipo de Controle:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Iniciar Aplicativo em Tela Cheia"
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Ocultar o cursor em tela cheia"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Tela Cheia Automática ao Conectar o Controle"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "Desativar animação de inicialização"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Minimizar para a bandeja ao fechar"
|
||||
|
||||
@@ -2361,8 +2403,11 @@ msgstr "Ativar sons da interface"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "Desativar aviso de caminho em cirílico"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
msgstr "Desativar verificação de dependências do ALT Linux i586"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
@@ -2370,6 +2415,9 @@ msgstr "Desativar download de runtime"
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "Adicionar às ferramentas de compatibilidade da Steam"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "Desativar a busca por instalações do Steam Proton"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
|
||||
@@ -2580,15 +2628,9 @@ msgstr "Falha ao carregar temas"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Falha ao baixar o tema"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Baixando…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Baixando… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Falha ao instalar o tema"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Selecionar Tema:"
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-09-20 18:28+0000\n"
|
||||
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
|
||||
"PO-Revision-Date: 2026-10-26 12:27+0000\n"
|
||||
"Last-Translator: Andrei Stepanov <adem4ik@gmail.com>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
"Language: ru\n"
|
||||
@@ -19,12 +19,9 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Остановить игру"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
@@ -32,6 +29,19 @@ msgstr ""
|
||||
"Папка с данными PortProton не доступна для чтения и записи. Выберите "
|
||||
"другую папку для данных PortProton."
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr ""
|
||||
"PortProtonQt уже запущен. Остановить его и все игры, а затем перезапустить "
|
||||
"для нового запуска?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Нет"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "Обновление завершено"
|
||||
|
||||
@@ -115,12 +125,6 @@ msgstr ""
|
||||
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
|
||||
msgid "No"
|
||||
msgstr "Нет"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "Не удалось удалить папку: {error}"
|
||||
@@ -147,9 +151,15 @@ msgstr "Файл уже существует: {file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "Не удалось переименовать файл: {error}"
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr "Выбрать несколько"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Удалить из PortProton"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "Остановить игру"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "Запустить игру"
|
||||
|
||||
@@ -277,14 +287,6 @@ msgstr "Не удалось сгенерировать данные файла .
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Не удалось удалить старый файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Удалён старый файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Не удалось скопировать обложку: {error}"
|
||||
@@ -304,10 +306,6 @@ msgstr ""
|
||||
"'{game_name}' добавлен(а) в Steam. Пожалуйста, перезапустите Steam, чтобы"
|
||||
" изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "'{game_name}' добавлен(а) в Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
@@ -320,10 +318,6 @@ msgstr ""
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
|
||||
" чтобы изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "'{game_name}' был(а) удален(а) из Steam."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "Не удалось удалить игру '{game_name}' из Steam: {error}"
|
||||
@@ -480,6 +474,19 @@ msgstr "Другая операция Epic уже выполняется"
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Префикс Wine не найден"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"В следующих путях обнаружены символы кириллицы. Wine может работать "
|
||||
"неправильно. Переместите игру или PortProton в путь без символов кириллицы:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "Больше не напоминать"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
|
||||
@@ -747,14 +754,14 @@ msgstr ""
|
||||
"run --user --scope --slice=app.slice"
|
||||
|
||||
msgid "CPU Cores Limit"
|
||||
msgstr "Ограничение ядер CPU"
|
||||
msgstr "Ограничение ядер ЦП"
|
||||
|
||||
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)"
|
||||
"Ограничение количества ядер процессора полезно для игр на Unity "
|
||||
"(рекомендуется установить значение равным 8)"
|
||||
|
||||
msgid "OpenGL Version"
|
||||
msgstr "Версия OpenGL"
|
||||
@@ -958,6 +965,22 @@ msgstr "Обновление AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "Запуск {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "Сканирование: {folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "Проверка существующих ярлыков: {path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "Добавление: {name}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "Не удалось создать ярлык: {name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "Название:"
|
||||
|
||||
@@ -973,6 +996,9 @@ msgstr "Пользовательская обложка:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "Введите локальный путь или URL обложки"
|
||||
|
||||
msgid "Custom Icon:"
|
||||
msgstr "Пользовательский значок:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "Добавить ярлык:"
|
||||
|
||||
@@ -985,6 +1011,32 @@ msgstr "Применить"
|
||||
msgid "Invalid image"
|
||||
msgstr "Недопустимое изображение"
|
||||
|
||||
msgid "Select folder"
|
||||
msgstr "Выбрать папку"
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr "Обработка игр…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"Обработано {completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "В выбранной папке игры не найдены."
|
||||
|
||||
msgid "Select games"
|
||||
msgstr "Выбрать игры"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "Не удалось скачать обложку"
|
||||
|
||||
@@ -1002,8 +1054,14 @@ msgstr ""
|
||||
"Программа остановилась. Информация о совместимости была собрана "
|
||||
"автоматически."
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
@@ -1026,9 +1084,6 @@ msgstr "Предыдущая вкладка"
|
||||
msgid "Next Tab"
|
||||
msgstr "Следующая вкладка"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "Сохранить"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "Поиск"
|
||||
|
||||
@@ -1176,9 +1231,6 @@ msgstr "Не удалось удалить WINE/Proton в {path}: {error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "Успешно удалено {} WINE/Proton."
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "Скачивание завершено"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "Все выбранные WINE/Proton успешно загружены!"
|
||||
|
||||
@@ -1217,15 +1269,14 @@ msgstr "Скачивание или распаковка отменены."
|
||||
msgid "System WINE"
|
||||
msgstr "Системный WINE"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Сохранить настройки"
|
||||
msgstr "Глобальные настройки игры"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Настройки EXE"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
msgstr "Изменить user.conf"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Поиск настроек…"
|
||||
@@ -1251,9 +1302,8 @@ msgstr "Редактировать PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Очистить PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Не удалось загрузить темы"
|
||||
msgstr "Не удалось загрузить глобальные настройки игры."
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "Путь к исполняемому файлу недоступен."
|
||||
@@ -1264,12 +1314,8 @@ msgstr "Файл PPDB не существует по адресу: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Не удалось открыть файл PPDB:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Не удалось открыть файл PPDB:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
msgstr "Не удалось открыть файл:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "Подтвердите очистку PPDB"
|
||||
@@ -1289,6 +1335,9 @@ msgstr "Стандартный"
|
||||
msgid "disabled"
|
||||
msgstr "отключено"
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Скачать Wine/Proton..."
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Введите имя префикса"
|
||||
|
||||
@@ -1547,7 +1596,7 @@ msgid "Failed to save custom preset."
|
||||
msgstr "Ошибка сохранения пользовательских настроек."
|
||||
|
||||
msgid "Overlay position"
|
||||
msgstr "Позиция MangoHud"
|
||||
msgstr "Положение MangoHud"
|
||||
|
||||
msgid "Device battery"
|
||||
msgstr "Батарея устройства"
|
||||
@@ -1562,7 +1611,7 @@ msgid "FCAT screen edge"
|
||||
msgstr "Край области FCAT"
|
||||
|
||||
msgid "Table columns"
|
||||
msgstr "Кол-во колонок"
|
||||
msgstr "Столбцов таблицы"
|
||||
|
||||
msgid "Network (tx/rx kb/s)"
|
||||
msgstr "Сеть (tx/rx kb/s)"
|
||||
@@ -1931,9 +1980,8 @@ msgid ""
|
||||
"Select one or more FPS presets. The values are saved into FPS_LIMIT and "
|
||||
"MangoHud will also show the active FPS limit."
|
||||
msgstr ""
|
||||
"Выберите один или несколько предустановленных профилей FPS. Значения "
|
||||
"будут сохранены в FPS_LIMIT и MangoHud также отобразит активное "
|
||||
"ограничение FPS."
|
||||
"Выберите один или несколько предустановленных профилей FPS. Значения будут "
|
||||
"сохранены в FPS_LIMIT, а также MangoHud отобразит активное ограничение FPS."
|
||||
|
||||
msgid "Additional comma-separated MangoHud options not covered by the GUI."
|
||||
msgstr "Дополнительные параметры MangoHud (через запятую), отсутствующие в GUI."
|
||||
@@ -2207,9 +2255,6 @@ msgstr "Ошибка"
|
||||
msgid "Repaired"
|
||||
msgstr "Исправлено"
|
||||
|
||||
msgid "Download process failed"
|
||||
msgstr "Ошибка процесса загрузки"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
@@ -2237,8 +2282,8 @@ msgstr "Все"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Фильтр:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Только установленные"
|
||||
msgid "Show only installed games"
|
||||
msgstr "Показывать только установленные игры"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Подробный"
|
||||
@@ -2249,39 +2294,32 @@ msgstr "Скрытый"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Тип отображения бейджей:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Темы"
|
||||
msgstr "Тема"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
msgstr "Сетка"
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Светлая"
|
||||
msgstr "Список"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Стабильный"
|
||||
msgid "Vertical"
|
||||
msgstr "Вертикальная"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Горизонтальное расположение HUD"
|
||||
msgstr "Горизонтальные блоки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Горизонтальное расположение HUD"
|
||||
msgstr "Горизонтальные карточки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr "Горизонтальное расположение HUD"
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "Бесконечные горизонтальные блоки"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "Бесконечные горизонтальные карточки"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "Тип геймпада:"
|
||||
msgid "Layout"
|
||||
msgstr "Режим отображения"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
@@ -2291,6 +2329,9 @@ msgstr ""
|
||||
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
|
||||
"приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProtonQt"
|
||||
|
||||
@@ -2321,9 +2362,15 @@ msgstr "Тип геймпада:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Запускать приложение в полноэкранном режиме"
|
||||
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Скрывать курсор в полноэкранном режиме"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "Отключить анимацию загрузки"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Сворачивать в область уведомления при закрытии"
|
||||
|
||||
@@ -2348,8 +2395,11 @@ msgstr "Включить звуки интерфейса"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Показывать отчёт о совместимости после сбоя игры"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "Отключить предупреждение о кириллице в пути"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
msgstr "Отключить проверку зависимостей ALT Linux i586"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Отключить скачивание контейнера"
|
||||
@@ -2357,6 +2407,9 @@ msgstr "Отключить скачивание контейнера"
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "Добавить в инструменты совместимости Steam"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "Отключить сканирование Steam Proton"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Скачивать Proton в Steam"
|
||||
|
||||
@@ -2565,15 +2618,9 @@ msgstr "Не удалось загрузить темы"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "Не удалось скачать тему"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "Скачивание…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "Скачивание… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "Не удалось установить тему"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "Выбрать тему:"
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"PO-Revision-Date: 2026-11-01 00:08+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Chinese (Simplified Han script) <https://"
|
||||
"translate.codeberg.org/projects/portprotonqt/portprotonqt/zh_Hans/>\n"
|
||||
@@ -18,17 +18,25 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
msgid ""
|
||||
"PortProton data folder is not readable and writable. Choose another "
|
||||
"folder for PortProton data."
|
||||
msgstr "PortProton 数据文件夹不可读写。请为 PortProton 数据选择其他文件夹。"
|
||||
|
||||
msgid ""
|
||||
"PortProtonQt is already running. Stop it and all games, then restart for "
|
||||
"the new launch?"
|
||||
msgstr "PortProtonQt 已 stacked运行。请关闭它及所有游戏,然后重新启动以应用新设置?"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
msgid "Update complete"
|
||||
msgstr "更新完成"
|
||||
|
||||
@@ -110,12 +118,6 @@ msgid ""
|
||||
"cannot be undone."
|
||||
msgstr "确定要删除文件夹\"{folder_name}\"吗?此操作无法撤销。"
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "是"
|
||||
|
||||
msgid "No"
|
||||
msgstr "否"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete folder: {error}"
|
||||
msgstr "删除文件夹失败:{error}"
|
||||
@@ -141,15 +143,20 @@ msgstr "文件已存在:{file_name}"
|
||||
msgid "Failed to rename file: {error}"
|
||||
msgstr "重命名文件失败:{error}"
|
||||
|
||||
msgid "Select multiple"
|
||||
msgstr "多选"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "从 PortProton 中删除"
|
||||
|
||||
msgid "Stop Game"
|
||||
msgstr "停止游戏"
|
||||
|
||||
msgid "Launch Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "已配对"
|
||||
msgstr "修复"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "从桌面移除"
|
||||
@@ -167,7 +174,7 @@ msgid "Delete"
|
||||
msgstr "删除"
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
msgstr "导入"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "编辑快捷方式"
|
||||
@@ -251,9 +258,9 @@ msgstr "桌面"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "保存自定义数据失败:{error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
msgstr "未找到可执行文件"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
@@ -273,14 +280,6 @@ msgstr "生成 .desktop 文件数据失败"
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "保存 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "删除旧的 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "已移除\"{game_name}\"的旧版 .desktop 文件"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "复制封面图像失败:{error}"
|
||||
@@ -298,10 +297,6 @@ msgid ""
|
||||
"take effect."
|
||||
msgstr "\"{game_name}\"已添加到 Steam。请重启 Steam 以使更改生效。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was added to Steam."
|
||||
msgstr "\"{game_name}\"已添加到 Steam。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to Steam: {error}"
|
||||
msgstr "无法将\"{game_name}\"添加到 Steam:{error}"
|
||||
@@ -312,10 +307,6 @@ msgid ""
|
||||
" take effect."
|
||||
msgstr "\"{game_name}\"已从 Steam 中移除。请重启 Steam 以使更改生效。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "'{game_name}' was removed from Steam."
|
||||
msgstr "\"{game_name}\"已从 Steam 中移除。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
msgstr "无法从 Steam 中移除游戏\"{game_name}\":{error}"
|
||||
@@ -463,19 +454,32 @@ msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
msgstr "导入('{0}'及其安装文件夹中的所有文件)?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
msgstr "另一个 Epic 操作正在运行中"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "未找到前缀"
|
||||
|
||||
msgid ""
|
||||
"Cyrillic characters were found in the following paths. Wine may work "
|
||||
"unreliably. Move the game or PortProton to a path without Cyrillic "
|
||||
"characters:\n"
|
||||
"\n"
|
||||
msgstr ""
|
||||
"在以下路径中找到了西里尔字母字符。Wine 可能会运行不稳定。请将游戏或 "
|
||||
"PortProton 移动到不包含西里尔字母字符的路径:\n"
|
||||
"\n"
|
||||
|
||||
msgid "Don't remind again"
|
||||
msgstr "不再提示"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
@@ -925,6 +929,22 @@ msgstr "正在更新 AppImage..."
|
||||
msgid "Launching {0}"
|
||||
msgstr "正在启动 {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Scanning: {folder}"
|
||||
msgstr "正在扫描:{folder}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Checking existing shortcuts: {path}"
|
||||
msgstr "正在检查现有快捷方式:{path}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Adding: {name}"
|
||||
msgstr "正在添加:{name}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to create shortcut: {name}"
|
||||
msgstr "创建快捷方式失败:{name}"
|
||||
|
||||
msgid "Name:"
|
||||
msgstr "名称:"
|
||||
|
||||
@@ -940,6 +960,10 @@ msgstr "自定义封面:"
|
||||
msgid "Enter local path or URL for cover image"
|
||||
msgstr "输入封面图像的本地路径或 URL"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Custom Icon:"
|
||||
msgstr "自定义封面:"
|
||||
|
||||
msgid "Add shortcut to:"
|
||||
msgstr "添加快捷方式到:"
|
||||
|
||||
@@ -952,6 +976,35 @@ msgstr "应用"
|
||||
msgid "Invalid image"
|
||||
msgstr "图像无效"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Processing games…"
|
||||
msgstr "正在处理游戏…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Processed {completed}/{total}\n"
|
||||
"{current}"
|
||||
msgstr ""
|
||||
"已处理:{completed}/{total}\n"
|
||||
"{current}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
msgid "No games found in the selected folder."
|
||||
msgstr "在所选文件夹中未找到任何游戏。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
|
||||
msgid "Failed to download cover"
|
||||
msgstr "下载封面失败"
|
||||
|
||||
@@ -962,14 +1015,20 @@ msgid "No cover selected"
|
||||
msgstr "未选择封面"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
msgstr "兼容性报告"
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
msgstr "程序已停止。已自动收集兼容性详细信息。"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "鼠标"
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "打开"
|
||||
@@ -992,9 +1051,6 @@ msgstr "上一标签页"
|
||||
msgid "Next Tab"
|
||||
msgstr "下一标签页"
|
||||
|
||||
msgid "Save"
|
||||
msgstr "保存"
|
||||
|
||||
msgid "Search"
|
||||
msgstr "搜索"
|
||||
|
||||
@@ -1142,9 +1198,6 @@ msgstr "移除位于 {path} 的 WINE/Proton 失败:{error}"
|
||||
msgid "Successfully removed {} WINE/Proton."
|
||||
msgstr "已成功移除 {} 个 WINE/Proton。"
|
||||
|
||||
msgid "Downloading Complete"
|
||||
msgstr "下载完成"
|
||||
|
||||
msgid "All selected WINE/Proton have been downloaded!"
|
||||
msgstr "所有选定的 WINE/Proton 已下载完成!"
|
||||
|
||||
@@ -1191,7 +1244,7 @@ msgid "Exe Settings"
|
||||
msgstr "可执行文件设置"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
msgstr "编辑 user.conf"
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "搜索设置…"
|
||||
@@ -1217,9 +1270,8 @@ msgstr "编辑 PPDB"
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "加载主题失败"
|
||||
msgstr "加载全局游戏设置失败"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "可执行文件路径不可用。"
|
||||
@@ -1230,12 +1282,8 @@ msgstr "PPDB 文件不存在于: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
msgstr "打开文件失败:\n"
|
||||
|
||||
msgid "Confirm PPDB Clear"
|
||||
msgstr "确认清除 PPDB"
|
||||
@@ -1255,6 +1303,9 @@ msgstr "默认"
|
||||
msgid "disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "下载 Wine/Proton…"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "输入前缀名称"
|
||||
|
||||
@@ -2052,13 +2103,11 @@ msgstr "全部清除"
|
||||
msgid "Now Downloading"
|
||||
msgstr "正在下载"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "警告"
|
||||
msgstr "请稍候…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "链接:"
|
||||
msgstr "磁盘: "
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
@@ -2072,10 +2121,10 @@ msgid "Store"
|
||||
msgstr "存储"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
msgstr "将链接复制到剪贴板"
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
msgstr "退出 GOG 账号失败"
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "正在刷新…"
|
||||
@@ -2097,27 +2146,27 @@ msgid "Log out"
|
||||
msgstr "注销"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr ""
|
||||
msgstr "打开登录页面"
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
msgstr "未连接 GOG 账号"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
msgstr "正在刷新 GOG 藏品库… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
msgstr "退出 Epic 账号失败"
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
msgstr "将授权码复制到剪贴板"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
msgstr "Epic 授权中…"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
@@ -2131,33 +2180,33 @@ msgstr "已连接"
|
||||
msgid "Epic account not connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
msgstr "正在刷新 Epic 游戏库… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
msgstr "选择安装文件夹"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "正在下载"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
msgstr "另一个 GOG 操作正在运行中"
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
msgstr "未找到 GOG 安装"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
msgstr "所选文件夹不是此 GOG 游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
@@ -2169,11 +2218,11 @@ msgstr "正在下载… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
msgstr "磁盘:{0} MiB/s"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr ",预计剩余时间:{}秒"
|
||||
msgstr "ETA: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
@@ -2183,10 +2232,6 @@ msgstr "详细"
|
||||
msgid "Repaired"
|
||||
msgstr "已配对"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "下载进行中"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "快速启动"
|
||||
|
||||
@@ -2214,9 +2259,8 @@ msgstr "全部"
|
||||
msgid "Display Filter:"
|
||||
msgstr "显示筛选:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Only Installed"
|
||||
msgstr "已安装"
|
||||
msgid "Show only installed games"
|
||||
msgstr "仅显示已安装的游戏"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "详细"
|
||||
@@ -2232,34 +2276,31 @@ msgid "Theme"
|
||||
msgstr "主题"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
msgstr "网格"
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "浅色"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "稳定"
|
||||
msgid "Vertical"
|
||||
msgstr "部分"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平块"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
msgstr "水平卡片"
|
||||
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "水平无限块"
|
||||
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "水平无限卡片"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "游戏手柄类型:"
|
||||
msgid "Layout"
|
||||
msgstr "注销"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
@@ -2269,6 +2310,9 @@ msgstr ""
|
||||
"确定要删除 {count} 个没有可执行文件的卡片吗?这将删除 .desktop 文件和自定义数"
|
||||
"据。"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "PortProton 设置"
|
||||
|
||||
@@ -2280,7 +2324,7 @@ msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
msgstr "账号"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "简要"
|
||||
@@ -2300,9 +2344,16 @@ msgstr "游戏手柄类型:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "全屏启动应用程序"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "强制窗口全屏"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "连接手柄时自动全屏"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr "禁用开机动画"
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "关闭时最小化到系统托盘"
|
||||
|
||||
@@ -2316,19 +2367,22 @@ msgid "Hide Auto-Install Tab"
|
||||
msgstr "隐藏自动安装标签页"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
msgstr "隐藏操作提示"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
msgstr "强制使用英语"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
msgstr "游戏崩溃后显示兼容性报告"
|
||||
|
||||
msgid "Disable Cyrillic path warning"
|
||||
msgstr "禁用西里尔字母路径警告"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
msgstr "禁用 ALT Linux i586 依赖项检查"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "禁用运行时下载"
|
||||
@@ -2336,6 +2390,9 @@ msgstr "禁用运行时下载"
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "添加到 Steam 兼容工具"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr "禁用 Steam Proton 扫描"
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
|
||||
@@ -2423,7 +2480,7 @@ msgstr "确定要清除缓存吗?此操作无法撤销。"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr ""
|
||||
msgstr "AppImage 已集成于:{path}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
@@ -2546,15 +2603,9 @@ msgstr "加载主题失败"
|
||||
msgid "Failed to download theme"
|
||||
msgstr "下载主题失败"
|
||||
|
||||
msgid "Downloading…"
|
||||
msgstr "正在下载…"
|
||||
|
||||
msgid "Downloading… {0}%"
|
||||
msgstr "正在下载… {0}%"
|
||||
|
||||
msgid "Failed to install theme"
|
||||
msgstr "安装主题失败"
|
||||
|
||||
msgid "Select Theme:"
|
||||
msgstr "选择主题:"
|
||||
|
||||
@@ -2577,13 +2628,12 @@ msgstr "作者:"
|
||||
msgid "Link:"
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "选择主题:"
|
||||
msgstr "删除主题“{0}”?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "加载主题失败"
|
||||
msgstr "删除主题失败。"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
@@ -3,7 +3,7 @@ import configparser
|
||||
from pathlib import Path
|
||||
import locale
|
||||
import os
|
||||
from babel import Locale
|
||||
from babel import Locale, UnknownLocaleError
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -85,8 +85,18 @@ def get_system_locale():
|
||||
|
||||
if ui_config.get_force_english():
|
||||
return 'en'
|
||||
loc = locale.getdefaultlocale()[0]
|
||||
return loc if loc else 'en'
|
||||
for language in os.getenv("LANGUAGE", "").split(":"):
|
||||
if not language:
|
||||
continue
|
||||
try:
|
||||
return str(Locale.parse(language))
|
||||
except (UnknownLocaleError, ValueError):
|
||||
continue
|
||||
try:
|
||||
loc = locale.getlocale()[0]
|
||||
return str(Locale.parse(loc)) if loc else 'en'
|
||||
except (UnknownLocaleError, ValueError):
|
||||
return 'en'
|
||||
|
||||
def get_steam_language():
|
||||
try:
|
||||
|
||||
@@ -7,6 +7,7 @@ import shlex
|
||||
import subprocess
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
@@ -17,6 +18,7 @@ import psutil
|
||||
import orjson
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog
|
||||
from portprotonqt.dialogs.compatibility_report import CompatibilityReportDialog
|
||||
@@ -93,6 +95,7 @@ STORE_LAUNCH_GRACE_SECONDS = 10
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.appimage_updater import AppImageUpdateWorker
|
||||
from portprotonqt.boot_animation import BootAnimation
|
||||
|
||||
logger = get_logger(__name__)
|
||||
DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
|
||||
@@ -114,7 +117,7 @@ class MainWindow(
|
||||
QMainWindow,
|
||||
):
|
||||
games_loaded = Signal(list)
|
||||
compatibility_report_ready = Signal(str)
|
||||
compatibility_report_ready = Signal(str, str)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
@@ -134,19 +137,49 @@ class MainWindow(
|
||||
def changeEvent(self, event: QEvent) -> None:
|
||||
super().changeEvent(event)
|
||||
if event.type() in (QEvent.Type.ActivationChange, QEvent.Type.WindowStateChange):
|
||||
self._update_cursor_visibility()
|
||||
self._update_animated_covers_activity()
|
||||
|
||||
def hideEvent(self, event: QHideEvent) -> None:
|
||||
self._update_cursor_visibility()
|
||||
self._set_animated_covers_suspended(True)
|
||||
super().hideEvent(event)
|
||||
|
||||
def showEvent(self, event: QShowEvent) -> None:
|
||||
super().showEvent(event)
|
||||
self._update_cursor_visibility()
|
||||
self._update_animated_covers_activity()
|
||||
|
||||
def _on_application_state_changed(self, _state: Qt.ApplicationState) -> None:
|
||||
self._update_cursor_visibility()
|
||||
self._update_animated_covers_activity()
|
||||
|
||||
def _update_cursor_visibility(self) -> None:
|
||||
if not hasattr(self, "_cursor_timer"):
|
||||
return
|
||||
hide = (
|
||||
self.isFullScreen() and self.isVisible() and not self.isMinimized()
|
||||
and display_config.get_hide_cursor()
|
||||
and QApplication.applicationState() == Qt.ApplicationState.ApplicationActive
|
||||
)
|
||||
if hide and not self._cursor_hidden and not self._cursor_timer.isActive():
|
||||
self._cursor_timer.start(self.theme.cursorHideTimeoutMs)
|
||||
elif not hide:
|
||||
self._cursor_timer.stop()
|
||||
if self._cursor_hidden:
|
||||
QApplication.restoreOverrideCursor()
|
||||
self._cursor_hidden = False
|
||||
|
||||
def _hide_cursor(self) -> None:
|
||||
if (
|
||||
self.isFullScreen() and self.isVisible() and not self.isMinimized()
|
||||
and display_config.get_hide_cursor()
|
||||
and QApplication.applicationState() == Qt.ApplicationState.ApplicationActive
|
||||
and not self._cursor_hidden
|
||||
):
|
||||
QApplication.setOverrideCursor(Qt.CursorShape.BlankCursor)
|
||||
self._cursor_hidden = True
|
||||
|
||||
def _update_animated_covers_activity(self) -> None:
|
||||
should_suspend = (
|
||||
not self.isVisible() or
|
||||
@@ -218,6 +251,15 @@ class MainWindow(
|
||||
self.gamepad_tooltip_timer.start(max(2500, min(12000, 1500 + len(text) * 30)))
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
if isinstance(obj, QWidget) and hasattr(self, "_cursor_timer"):
|
||||
if event.type() == QEvent.Type.Polish:
|
||||
obj.setMouseTracking(True)
|
||||
if event.type() in (QEvent.Type.MouseMove, QEvent.Type.MouseButtonPress):
|
||||
if self.isFullScreen() and self.isVisible() and not self.isMinimized() and display_config.get_hide_cursor():
|
||||
if self._cursor_hidden:
|
||||
QApplication.restoreOverrideCursor()
|
||||
self._cursor_hidden = False
|
||||
self._cursor_timer.start(self.theme.cursorHideTimeoutMs)
|
||||
if isinstance(obj, QWidget) and obj in getattr(self, "_gamepad_tooltip_map", {}):
|
||||
if event.type() in (QEvent.Type.Enter, QEvent.Type.FocusIn):
|
||||
self._show_gamepad_tooltip(True, self._gamepad_tooltip_map[obj], obj)
|
||||
@@ -307,6 +349,15 @@ class MainWindow(
|
||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
self.setAcceptDrops(True)
|
||||
self._init_gamepad_tooltip()
|
||||
self._cursor_hidden = False
|
||||
self._cursor_timer = QTimer(self)
|
||||
self._cursor_timer.setSingleShot(True)
|
||||
self._cursor_timer.timeout.connect(self._hide_cursor)
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.installEventFilter(self)
|
||||
for widget in self.findChildren(QWidget):
|
||||
widget.setMouseTracking(True)
|
||||
self.current_exec_line = None
|
||||
self.currentDetailPage = None
|
||||
self.current_play_button = None
|
||||
@@ -367,7 +418,11 @@ class MainWindow(
|
||||
|
||||
# Central widget and main layout
|
||||
centralWidget = QWidget()
|
||||
self.setCentralWidget(centralWidget)
|
||||
self.startup_stack = QStackedWidget(self)
|
||||
self.startup_stack.addWidget(centralWidget)
|
||||
self.setCentralWidget(self.startup_stack)
|
||||
self.boot_animation: BootAnimation | None = None
|
||||
self._boot_pending_load = False
|
||||
mainLayout = QVBoxLayout(centralWidget)
|
||||
mainLayout.setSpacing(0)
|
||||
mainLayout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -504,31 +559,59 @@ class MainWindow(
|
||||
)
|
||||
self.system_theme_watcher.start()
|
||||
|
||||
auto_fullscreen_gamepad = (
|
||||
display_config.get_auto_fullscreen_gamepad()
|
||||
and self.input_manager.gamepad is not None
|
||||
)
|
||||
if display_config.get_fullscreen() or auto_fullscreen_gamepad:
|
||||
self.showFullScreen()
|
||||
elif self._pending_resolution:
|
||||
if self._pending_resolution:
|
||||
# Apply resolution from command line
|
||||
self.resize(self._pending_resolution[0], self._pending_resolution[1])
|
||||
self.showNormal()
|
||||
else:
|
||||
width, height = window_config.get_geometry()
|
||||
if width > 0 and height > 0:
|
||||
self.resize(width, height)
|
||||
else:
|
||||
self.showNormal()
|
||||
|
||||
# Process events to ensure UI is responsive before starting heavy operations
|
||||
QApplication.processEvents()
|
||||
self.updateControlHints("force")
|
||||
|
||||
# Delay game loading until after the UI is fully displayed to prevent blocking
|
||||
# Use a longer delay to ensure window is fully rendered and responsive
|
||||
# Use a custom event processing approach to make sure UI stays responsive
|
||||
QTimer.singleShot(500, self.loadGames) # Reduced delay but ensure UI gets event processing
|
||||
self.startup_load_timer = QTimer(self)
|
||||
self.startup_load_timer.setSingleShot(True)
|
||||
self.startup_load_timer.setInterval(self.theme.startupLibraryLoadDelayMs)
|
||||
self.startup_load_timer.timeout.connect(self.loadGames)
|
||||
self.startup_load_timer.start()
|
||||
|
||||
def start_boot_animation(self) -> None:
|
||||
"""Show the boot page when entering fullscreen."""
|
||||
if not self.isFullScreen() or self.boot_animation is not None:
|
||||
return
|
||||
if not display_config.get_boot_animation_enabled():
|
||||
return
|
||||
from portprotonqt.boot_animation import BootAnimation, find_boot_video
|
||||
|
||||
path = find_boot_video(self.current_theme_name)
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
animation = BootAnimation(self.theme, path, self.startup_stack)
|
||||
except ImportError as error:
|
||||
logger.warning("Qt Multimedia unavailable for boot video: %s", error)
|
||||
return
|
||||
self.boot_animation = animation
|
||||
self._boot_pending_load = self.startup_load_timer.isActive()
|
||||
self.startup_load_timer.stop()
|
||||
self.startup_stack.addWidget(animation)
|
||||
self.startup_stack.setCurrentWidget(animation)
|
||||
animation.finished.connect(self._finish_boot_animation)
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.aboutToQuit.connect(animation.stop)
|
||||
QTimer.singleShot(0, animation.start)
|
||||
|
||||
def _finish_boot_animation(self) -> None:
|
||||
animation = self.boot_animation
|
||||
if animation is None:
|
||||
return
|
||||
self.boot_animation = None
|
||||
self.startup_stack.setCurrentIndex(0)
|
||||
self.startup_stack.removeWidget(animation)
|
||||
animation.deleteLater()
|
||||
if self._boot_pending_load:
|
||||
self._boot_pending_load = False
|
||||
self.startup_load_timer.start()
|
||||
|
||||
def on_slider_released(self) -> None:
|
||||
"""Delegate to game library manager."""
|
||||
@@ -893,17 +976,13 @@ class MainWindow(
|
||||
@Slot(list)
|
||||
def on_games_loaded(self, games: list[tuple]):
|
||||
self._loading_games = False
|
||||
self.games = games
|
||||
if not game_config.get_only_installed():
|
||||
display_filter = game_config.get_display_filter()
|
||||
current_filter = (game_config.get_display_filter(), game_config.get_only_installed())
|
||||
loaded_filter = getattr(self, "_loading_library_filter", current_filter)
|
||||
if not loaded_filter[1]:
|
||||
display_filter = loaded_filter[0]
|
||||
library_cache = getattr(self, "_loaded_library_cache", {})
|
||||
library_cache[display_filter] = games
|
||||
self._loaded_library_cache = library_cache
|
||||
focus_first_card = not getattr(self, "_preserve_library_focus_after_load", False)
|
||||
self._preserve_library_focus_after_load = False
|
||||
self.game_library_manager.set_games(games, focus_first_card=focus_first_card)
|
||||
self._known_portproton_desktops = self._get_portproton_desktop_files()
|
||||
|
||||
# Clear the refresh in progress flag
|
||||
if hasattr(self, '_refresh_in_progress'):
|
||||
self._refresh_in_progress = False
|
||||
@@ -913,6 +992,17 @@ class MainWindow(
|
||||
self.refreshButton.setEnabled(True)
|
||||
self._gamepad_tooltip_map[self.refreshButton] = _("Refresh Grid")
|
||||
|
||||
if loaded_filter != current_filter:
|
||||
self._on_only_installed_changed(current_filter[1])
|
||||
return
|
||||
self.games = games
|
||||
focus_first_card = not self.game_library_manager.games and not getattr(
|
||||
self, "_preserve_library_focus_after_load", False
|
||||
)
|
||||
self._preserve_library_focus_after_load = False
|
||||
self.game_library_manager.set_games(games, focus_first_card=focus_first_card)
|
||||
self._known_portproton_desktops = self._get_portproton_desktop_files()
|
||||
|
||||
def loadGames(self, force_load: bool = False):
|
||||
if self._loading_games:
|
||||
return
|
||||
@@ -926,6 +1016,7 @@ class MainWindow(
|
||||
|
||||
self._loading_games = True
|
||||
display_filter = game_config.get_display_filter()
|
||||
self._loading_library_filter = (display_filter, game_config.get_only_installed())
|
||||
favorites = favorites_config.get_games()
|
||||
self.pending_games = []
|
||||
self.games = []
|
||||
@@ -980,9 +1071,13 @@ class MainWindow(
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
else:
|
||||
# For 'all' filter - load games from different sources in parallel to prevent blocking
|
||||
# Load selected sources in parallel.
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
selected_sources = set(display_filter.split(","))
|
||||
completed = {
|
||||
source: bool(selected_sources & results.keys()) and source not in selected_sources
|
||||
for source in results
|
||||
}
|
||||
|
||||
def on_all_games():
|
||||
seen = set()
|
||||
@@ -1024,11 +1119,13 @@ class MainWindow(
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
# Load all sources in parallel
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
callbacks = {
|
||||
"portproton": portproton_callback, "steam": steam_callback,
|
||||
"gog": gog_callback, "egs": egs_callback,
|
||||
}
|
||||
for source, callback in callbacks.items():
|
||||
if not completed[source]:
|
||||
getattr(self, f"_load_{source}_games_async")(callback)
|
||||
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
@@ -1040,6 +1137,11 @@ class MainWindow(
|
||||
library = [game for game in api.load_library() if game.get("app_id")]
|
||||
if self._upgrade_legacy_gog_library(api, library, callback):
|
||||
return
|
||||
if only_installed:
|
||||
library = [
|
||||
game for game in library
|
||||
if api.is_game_installed(str(game["app_id"]), installed)
|
||||
]
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
@@ -1103,6 +1205,23 @@ class MainWindow(
|
||||
library = [game for game in self.egs_api.load_library() if game.get("app_id")]
|
||||
only_installed = game_config.get_only_installed()
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
installed_data = None
|
||||
installed_by_id = {}
|
||||
if only_installed:
|
||||
installed_data = self.egs_api.load_installed()
|
||||
installed_by_id = {
|
||||
str(game["app_id"]): self.egs_api.is_game_installed(
|
||||
str(game["app_id"]), installed_data
|
||||
)
|
||||
for game in library
|
||||
}
|
||||
library = [
|
||||
game for game in library
|
||||
if installed_by_id[str(game["app_id"])]
|
||||
]
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
@@ -1116,9 +1235,19 @@ class MainWindow(
|
||||
name, custom_cover = MainWindow._get_custom_store_data(
|
||||
"egs", app_id, str(game.get("title", app_id))
|
||||
)
|
||||
installed = self.egs_api.is_game_installed(app_id)
|
||||
installed = (
|
||||
installed_by_id[app_id] if only_installed
|
||||
else self.egs_api.is_game_installed(app_id)
|
||||
)
|
||||
if not only_installed or installed:
|
||||
target = self.egs_api.get_launch_target(app_id) if installed else None
|
||||
target = None
|
||||
if installed:
|
||||
if only_installed:
|
||||
target = self.egs_api.get_launch_target(
|
||||
app_id, installed_data
|
||||
)
|
||||
else:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
description = str(game.get("description", ""))
|
||||
if not description or description == str(game.get("title", "")):
|
||||
description = str(steam_info.get("description", ""))
|
||||
@@ -1293,6 +1422,9 @@ class MainWindow(
|
||||
executor.submit(self._process_desktop_file_async, file_path, on_desktop_processed)
|
||||
|
||||
def _generate_missing_portproton_icon(self, game_exe: str, icon_path: str, desktop_name: str) -> str:
|
||||
custom_icon = get_custom_icon_path(game_exe)
|
||||
if custom_icon:
|
||||
return custom_icon
|
||||
if not self.portproton_location:
|
||||
return ""
|
||||
if not game_exe.lower().endswith(".exe") or not os.path.isfile(game_exe):
|
||||
@@ -2404,11 +2536,12 @@ class MainWindow(
|
||||
except (OSError, ValueError, TypeError) as error:
|
||||
logger.error("Compatibility analysis failed for %s: %s", launch.executable, error)
|
||||
return
|
||||
self.compatibility_report_ready.emit(report)
|
||||
self.compatibility_report_ready.emit(report, launch.executable)
|
||||
|
||||
def _show_compatibility_report(self, report: str) -> None:
|
||||
def _show_compatibility_report(self, report: str, executable: str) -> None:
|
||||
logger.info("Compatibility report dialog opened")
|
||||
dialog = CompatibilityReportDialog(self, self.theme, report)
|
||||
dialog.check_ppdb(executable)
|
||||
dialog.exec()
|
||||
|
||||
def _terminate_game_processes(self) -> None:
|
||||
@@ -2472,8 +2605,8 @@ class MainWindow(
|
||||
msg_box.setText(_("Prefix '{0}' was not found. Install recommended libraries?").format(prefix_name))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.No:
|
||||
env_vars["DISABLE_CP_DEFPFX"] = "1"
|
||||
@@ -2590,8 +2723,8 @@ class MainWindow(
|
||||
QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Ok)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Ok, _("OK"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Cancel, _("Cancel"))
|
||||
msg_box.button(QMessageBox.StandardButton.Ok).setText(_("OK"))
|
||||
msg_box.button(QMessageBox.StandardButton.Cancel).setText(_("Cancel"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Ok:
|
||||
return self._install_alt_i586_dependencies()
|
||||
@@ -2675,22 +2808,23 @@ class MainWindow(
|
||||
|
||||
def _delete_egs_game(self, app_id: str) -> None:
|
||||
game = self.egs_api.load_installed().get(app_id, {})
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game.get("title") or app_id
|
||||
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game.get("title") or app_id
|
||||
)
|
||||
)
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._start_egs_operation(
|
||||
app_id, ["uninstall", app_id, "-y"], _("Delete")
|
||||
)
|
||||
@@ -2781,6 +2915,37 @@ class MainWindow(
|
||||
prefix_path = Path(self.portproton_location) / "data/prefixes" / prefix_name
|
||||
return self.egs_api.is_eos_overlay_enabled(prefix_path)
|
||||
|
||||
def _warn_cyrillic_launch_paths(self, game_path: str) -> bool:
|
||||
if ui_config.get_disable_cyrillic_path_check():
|
||||
return False
|
||||
paths = [game_path, *(self.start_sh or [])[:1]]
|
||||
affected_paths = []
|
||||
for path in paths:
|
||||
for candidate in (os.path.abspath(path), os.path.realpath(path)):
|
||||
if candidate in affected_paths:
|
||||
continue
|
||||
if any("CYRILLIC" in unicodedata.name(char, "") for char in candidate):
|
||||
affected_paths.append(candidate)
|
||||
if not affected_paths:
|
||||
return False
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Warning)
|
||||
message_box.setWindowTitle(_("Warning"))
|
||||
message_box.setText(
|
||||
_("Cyrillic characters were found in the following paths. "
|
||||
"Wine may work unreliably. Move the game or PortProton "
|
||||
"to a path without Cyrillic characters:\n\n")
|
||||
+ "\n".join(affected_paths)
|
||||
)
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Close)
|
||||
disable_button = message_box.addButton(
|
||||
_("Don't remind again"), QMessageBox.ButtonRole.ActionRole
|
||||
)
|
||||
message_box.exec()
|
||||
if message_box.clickedButton() is disable_button:
|
||||
ui_config.set_disable_cyrillic_path_check(True)
|
||||
return True
|
||||
|
||||
def _launch_egs_game(self, app_id: str, button=None) -> None:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
if not target:
|
||||
@@ -2808,6 +2973,7 @@ class MainWindow(
|
||||
"launch", app_id, "--json", "--wrapper", self.start_sh[0],
|
||||
"--no-wine", "--skip-version-check",
|
||||
])
|
||||
self._warn_cyrillic_launch_paths(target)
|
||||
process = subprocess.Popen(
|
||||
command, env=self.egs_api.get_environment(), shell=False,
|
||||
preexec_fn=os.setsid, stdout=subprocess.PIPE,
|
||||
@@ -2869,6 +3035,9 @@ class MainWindow(
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
self._finish_silent_launch()
|
||||
return
|
||||
self._warn_cyrillic_launch_paths(
|
||||
self.gog_api.get_launch_target(app_id) or install_path
|
||||
)
|
||||
needs_setup = getattr(self.gog_api, "needs_support_setup", lambda _app_id: False)
|
||||
if needs_setup(app_id):
|
||||
self._start_gog_support_setup(app_id, button)
|
||||
@@ -2974,6 +3143,8 @@ class MainWindow(
|
||||
else:
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
if file_to_check.lower().endswith(WINDOWS_LAUNCH_EXTENSIONS):
|
||||
self._warn_cyrillic_launch_paths(file_to_check)
|
||||
|
||||
if update_button:
|
||||
try:
|
||||
|
||||
@@ -4,6 +4,7 @@ install_data(
|
||||
'appimage_integration.py',
|
||||
'appimage_updater.py',
|
||||
'cli.py',
|
||||
'boot_animation.py',
|
||||
'context_menu_manager.py',
|
||||
'custom_widgets.py',
|
||||
'dialogs.py',
|
||||
@@ -185,6 +186,7 @@ install_data(
|
||||
['scripts_utils/__init__.py',
|
||||
'scripts_utils/dbus_tools.py',
|
||||
'scripts_utils/easyterm.py',
|
||||
'scripts_utils/exe_metadata.py',
|
||||
'scripts_utils/graphics_detector.py',
|
||||
'scripts_utils/idle_inhibit.py',
|
||||
'scripts_utils/json_tools.py',
|
||||
|
||||
@@ -175,7 +175,7 @@ def _show_portdata_warning(parent: QWidget, theme: Any, text: str, allow_choose:
|
||||
message_box.setText(text)
|
||||
if allow_choose:
|
||||
message_box.setStandardButtons(QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Open)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Open, _("Choose Folder"))
|
||||
message_box.button(QMessageBox.StandardButton.Open).setText(_("Choose Folder"))
|
||||
main_style = getattr(theme, "MAIN_WINDOW_STYLE", "")
|
||||
message_box_style = getattr(theme, "MESSAGE_BOX_STYLE", "")
|
||||
message_box.setStyleSheet(main_style + message_box_style)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import argparse
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from collections.abc import Awaitable
|
||||
@@ -168,17 +169,20 @@ async def request_deepin_wm_switch() -> bool:
|
||||
|
||||
|
||||
async def request_idle_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
|
||||
"""Request idle inhibit through screensaver or portal D-Bus API."""
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
"""Request idle inhibit through screensaver, portal or logind D-Bus API."""
|
||||
bus = None
|
||||
try:
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SESSION).connect())
|
||||
try:
|
||||
kind, payload = await _request_screensaver_inhibit(bus, application, reason)
|
||||
except Exception as error:
|
||||
logger.warning("Screensaver inhibit failed: %s", error)
|
||||
kind, payload = await _request_portal_inhibit(bus, reason)
|
||||
except Exception:
|
||||
bus.disconnect()
|
||||
raise
|
||||
except Exception as error:
|
||||
logger.warning("Session idle inhibit failed: %s", error)
|
||||
if bus is not None:
|
||||
bus.disconnect()
|
||||
return await _request_logind_inhibit(application, reason)
|
||||
|
||||
return bus, kind, payload
|
||||
|
||||
@@ -211,8 +215,33 @@ async def _request_portal_inhibit(bus: MessageBus, reason: str) -> tuple[str, An
|
||||
return "portal", body[0]
|
||||
|
||||
|
||||
async def _request_logind_inhibit(application: str, reason: str) -> tuple[MessageBus, str, Any]:
|
||||
bus = await dbus_call(MessageBus(bus_type=BusType.SYSTEM, negotiate_unix_fd=True).connect())
|
||||
try:
|
||||
reply = await dbus_call(bus.call(Message(
|
||||
destination="org.freedesktop.login1",
|
||||
path="/org/freedesktop/login1",
|
||||
interface="org.freedesktop.login1.Manager",
|
||||
member="Inhibit",
|
||||
signature="ssss",
|
||||
body=["sleep:idle", application, reason, "block"],
|
||||
)))
|
||||
if reply.message_type.name == "ERROR":
|
||||
raise DBusError(reply.error_name, reply.body[0])
|
||||
return bus, "logind", reply.unix_fds[reply.body[0]]
|
||||
except Exception:
|
||||
bus.disconnect()
|
||||
raise
|
||||
|
||||
|
||||
async def release_idle_inhibit(bus: MessageBus, kind: str, payload: Any) -> None:
|
||||
"""Release idle inhibit requested through request_idle_inhibit."""
|
||||
if kind == "logind":
|
||||
try:
|
||||
os.close(payload)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
return
|
||||
with contextlib.suppress(DBusError, asyncio.TimeoutError):
|
||||
if kind == "screensaver":
|
||||
iface, cookie = payload
|
||||
|
||||
@@ -1026,11 +1026,7 @@ class TerminalWidget(QPlainTextEdit):
|
||||
max_fd = os.sysconf("SC_OPEN_MAX")
|
||||
except ValueError:
|
||||
max_fd = 256
|
||||
for fd in range(3, max_fd):
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
os.closerange(3, max_fd)
|
||||
|
||||
if self.cwd:
|
||||
os.chdir(self.cwd)
|
||||
@@ -1873,11 +1869,11 @@ def build_command(args: argparse.Namespace) -> list[str]:
|
||||
"""Build the command list to execute in the PTY."""
|
||||
if args.e:
|
||||
if len(args.e) == 1:
|
||||
return [
|
||||
default_shell(),
|
||||
"-lc",
|
||||
_with_portproton_functions(args.e[0]),
|
||||
]
|
||||
command = args.e[0]
|
||||
# functions_helper is bash-only, user shell may be zsh or fish
|
||||
if _needs_portproton_functions(command):
|
||||
return [CONF_FALLBACK_SHELL, "-lc", _with_portproton_functions(command)]
|
||||
return [default_shell(), "-lc", command]
|
||||
return args.e
|
||||
return [default_shell()]
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Read PE version metadata for PortProton shell scripts."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pefile
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def read_metadata(path: str) -> dict[str, str] | None:
|
||||
"""Read version strings without starting an external metadata tool."""
|
||||
try:
|
||||
with pefile.PE(path, fast_load=True) as pe:
|
||||
pe.parse_data_directories(directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]])
|
||||
return {
|
||||
key.decode("utf-8", errors="replace"): value.decode("utf-8", errors="replace")
|
||||
for block in getattr(pe, "FileInfo", []) for info in block
|
||||
for table in getattr(info, "StringTable", []) for key, value in table.entries.items()
|
||||
}
|
||||
except pefile.PEFormatError as error:
|
||||
logger.debug("Unsupported PE file %s: %s", path, error)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read executable metadata %s: %s", path, error)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) != 2:
|
||||
sys.stderr.write("Usage: python -m portprotonqt.scripts_utils.exe_metadata FILE\n")
|
||||
return 2
|
||||
metadata = read_metadata(sys.argv[1])
|
||||
if metadata is None:
|
||||
return 1
|
||||
sys.stdout.write(f"File Name: {Path(sys.argv[1]).name}\n")
|
||||
for key, value in metadata.items():
|
||||
label = re.sub(r"(?<=[a-z])(?=[A-Z])", " ", key)
|
||||
sys.stdout.write(f"{label}: {' '.join(value.splitlines())}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -5,6 +5,7 @@ import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QCoreApplication, QUrl, qInstallMessageHandler
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -94,13 +95,7 @@ class SoundManager:
|
||||
self._initialized = True
|
||||
self._enabled = ui_config.get_sounds_enabled()
|
||||
self._sounds_dirs: list[str] = []
|
||||
try:
|
||||
if QCoreApplication.instance() is None or not _audio_service_available():
|
||||
raise RuntimeError("audio service is unavailable")
|
||||
self._slots = [_SoundSlot() for _ in range(4)]
|
||||
except Exception as e:
|
||||
logger.warning("UI sounds are unavailable: %s", e)
|
||||
self._slots = []
|
||||
self._slots: list[_SoundSlot] | None = None
|
||||
self._slot_index = 0
|
||||
self._url_cache: dict[str, QUrl] = {}
|
||||
|
||||
@@ -108,7 +103,7 @@ class SoundManager:
|
||||
if dirs != self._sounds_dirs:
|
||||
self._sounds_dirs = dirs
|
||||
self._url_cache.clear()
|
||||
for slot in self._slots:
|
||||
for slot in self._slots or []:
|
||||
slot.reset()
|
||||
|
||||
def reload_config(self) -> None:
|
||||
@@ -136,8 +131,23 @@ class SoundManager:
|
||||
def play(self, event: str) -> None:
|
||||
if not self._enabled:
|
||||
return
|
||||
if any(
|
||||
getattr(window, "silent_launch_mode", False) and not window.isVisible()
|
||||
for window in QApplication.topLevelWidgets()
|
||||
):
|
||||
return
|
||||
url = self._get_url(event)
|
||||
if url is None or not self._slots:
|
||||
if url is None:
|
||||
return
|
||||
if self._slots is None:
|
||||
try:
|
||||
if QCoreApplication.instance() is None or not _audio_service_available():
|
||||
raise RuntimeError("audio service is unavailable")
|
||||
self._slots = [_SoundSlot() for _ in range(4)]
|
||||
except Exception as e:
|
||||
logger.warning("UI sounds are unavailable: %s", e)
|
||||
self._slots = []
|
||||
if not self._slots:
|
||||
return
|
||||
try:
|
||||
for slot in self._slots:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Steam API functions for PortProtonQt."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
@@ -15,6 +16,7 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.localization import get_steam_language
|
||||
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.steam_api.utils import decode_text, get_local_steam_cover
|
||||
from portprotonqt.steam_api.cache import (
|
||||
@@ -46,10 +48,12 @@ SGDB_CACHE_DURATION = 24 * 60 * 60
|
||||
STEAM_CLIENT_ICON_SIZES = (16, 32, 64, 128, 256)
|
||||
|
||||
|
||||
def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> None:
|
||||
def fetch_sgdb_cover_async(
|
||||
game_name: str, callback: Callable[[str], None], image_type: str = "grids"
|
||||
) -> None:
|
||||
"""Asynchronously fetch cover image URL from steamgrid.usebottles.com."""
|
||||
encoded = urllib.parse.quote(game_name)
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
|
||||
encoded = urllib.parse.quote(game_name, safe="")
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}/{image_type}"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -66,6 +70,14 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
text = text[1:-1]
|
||||
if text:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
text = text.replace(
|
||||
"https://cdn2.steamgriddb.com/",
|
||||
"https://deckyloader.ru/sgdb-cdn/",
|
||||
1,
|
||||
)
|
||||
logger.debug("Got SGDB cover URL for %s: %s", game_name, text)
|
||||
callback(text)
|
||||
except Exception as e:
|
||||
@@ -74,7 +86,7 @@ def fetch_sgdb_cover_async(game_name: str, callback: Callable[[str], None]) -> N
|
||||
|
||||
cache_manager = CacheManager()
|
||||
safe_name = "".join(c for c in game_name if c.isalnum() or c in " -_")[:50]
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}.json"
|
||||
cache_file = cache_manager.cache_dir / f"sgdb_{safe_name}_{image_type}.json"
|
||||
if cache_file.exists() and time.time() - cache_file.stat().st_mtime < SGDB_CACHE_DURATION:
|
||||
process_response(str(cache_file))
|
||||
return
|
||||
@@ -182,6 +194,11 @@ def fetch_client_icon_async(appid: int | str, callback: Callable[[str], None]) -
|
||||
callback("")
|
||||
return
|
||||
|
||||
custom_icon = get_custom_icon_path(f"steam://rungameid/{appid_str}")
|
||||
if custom_icon:
|
||||
callback(custom_icon)
|
||||
return
|
||||
|
||||
cache_dir = CacheManager().cache_dir
|
||||
info_path = cache_dir / f"steamcmd_app_{appid_str}_{time.time_ns()}.json"
|
||||
url = f"https://api.steamcmd.net/v1/info/{appid_str}"
|
||||
@@ -224,7 +241,12 @@ def get_protondb_tier_async(appid: int, callback: Callable[[str], None]) -> None
|
||||
callback(cached.get("tier", ""))
|
||||
return
|
||||
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD":
|
||||
url = f"https://deckyloader.ru/protondb/{appid}.json"
|
||||
else:
|
||||
url = f"https://www.protondb.com/api/v1/reports/summaries/{appid}.json"
|
||||
|
||||
def process_response(result: str | None) -> None:
|
||||
if not result or not os.path.exists(result):
|
||||
@@ -623,6 +645,19 @@ def get_steam_game_info_async(
|
||||
from portprotonqt.steam_api.cache import get_exiftool_data
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
|
||||
cache_manager = CacheManager()
|
||||
match_key = "steam_match_" + hashlib.sha256(
|
||||
orjson.dumps([desktop_name, exec_line])
|
||||
).hexdigest()
|
||||
cached_match = (
|
||||
cache_manager.load_json(match_key)
|
||||
if cache_manager.is_fresh(match_key, CACHE_DURATION) else None
|
||||
)
|
||||
if not isinstance(cached_match, dict) or type(cached_match.get("appid")) is not int:
|
||||
cached_match = None
|
||||
elif cached_match["appid"] <= 0:
|
||||
cached_match = None
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_store_uri = exec_line.startswith(("gog://", "egs://"))
|
||||
|
||||
@@ -646,7 +681,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if is_store_uri:
|
||||
if cached_match is not None or is_store_uri:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -685,9 +720,9 @@ def get_steam_game_info_async(
|
||||
data_and_index: tuple[list | None, dict | None]
|
||||
) -> None:
|
||||
steam_apps, steam_apps_index = data_and_index
|
||||
matching_app = None
|
||||
matching_app = cached_match
|
||||
|
||||
if not steam_apps or not steam_apps_index:
|
||||
if matching_app is None and (not steam_apps or not steam_apps_index):
|
||||
game_name = desktop_name or exe_name
|
||||
|
||||
def on_sgdb_cover_failure(cover: str) -> None:
|
||||
@@ -716,9 +751,11 @@ def get_steam_game_info_async(
|
||||
return
|
||||
|
||||
for candidate in candidates_ordered:
|
||||
if matching_app:
|
||||
break
|
||||
if not candidate:
|
||||
continue
|
||||
matching_app = search_app(candidate, steam_apps_index)
|
||||
matching_app = search_app(candidate, steam_apps_index or {})
|
||||
if matching_app:
|
||||
break
|
||||
|
||||
@@ -749,18 +786,23 @@ def get_steam_game_info_async(
|
||||
return
|
||||
|
||||
appid = matching_app["appid"]
|
||||
if cached_match is None:
|
||||
cache_manager.save_json(match_key, matching_app)
|
||||
|
||||
def on_app_info(app_info: dict | None) -> None:
|
||||
if not app_info:
|
||||
cover = get_local_steam_cover(appid, prefer_exact=False)
|
||||
if not cover and not has_custom_data:
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
def on_anticheat_info_no_info(anticheat_info: dict) -> None:
|
||||
result = _build_game_info_result(
|
||||
appid="",
|
||||
appid=appid,
|
||||
name=decode_text(game_name),
|
||||
description="",
|
||||
cover="",
|
||||
cover=cover,
|
||||
controller_support="",
|
||||
protondb_tier="",
|
||||
steam_game="false",
|
||||
steam_game="true",
|
||||
anticheat_status=anticheat_info.get("status", ""),
|
||||
anticheat_slug=anticheat_info.get("slug", ""),
|
||||
)
|
||||
@@ -797,4 +839,7 @@ def get_steam_game_info_async(
|
||||
|
||||
fetch_app_info_async(appid, on_app_info)
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
if cached_match is not None:
|
||||
on_steam_apps_and_index((None, None))
|
||||
else:
|
||||
get_steam_apps_and_index_async(on_steam_apps_and_index)
|
||||
@@ -179,7 +179,7 @@ def build_index(steam_apps: list) -> dict:
|
||||
for app in steam_apps:
|
||||
normalized = app.get("normalized_name", "")
|
||||
if normalized:
|
||||
steam_apps_index[normalized] = app
|
||||
steam_apps_index.setdefault(normalized, app)
|
||||
return steam_apps_index
|
||||
|
||||
|
||||
|
||||
@@ -7,13 +7,16 @@ import re
|
||||
import shlex
|
||||
import shutil
|
||||
import zlib
|
||||
from functools import partial
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
import vdf
|
||||
import websocket
|
||||
|
||||
from portprotonqt.config.portproton import get_custom_icon_path
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -33,6 +36,7 @@ from portprotonqt.steam_api.utils import (
|
||||
)
|
||||
from portprotonqt.steam_api.api import (
|
||||
get_steam_game_info_async,
|
||||
fetch_sgdb_cover_async,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -328,11 +332,12 @@ def _add_shortcut_via_vdf(
|
||||
|
||||
def _download_covers(
|
||||
appid: int,
|
||||
steam_appid: int,
|
||||
steam_appid: tuple[int | None, str],
|
||||
grid_dir: str,
|
||||
was_api_used: bool
|
||||
) -> None:
|
||||
"""Download cover images for the game."""
|
||||
source_appid, game_name = steam_appid
|
||||
cover_types = [
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
("_hero.jpg", "library_hero.jpg"),
|
||||
@@ -349,7 +354,7 @@ def _download_covers(
|
||||
with open(result_path, 'rb') as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
|
||||
ext = Path(steam_name).suffix.lstrip('.')
|
||||
ext = Path(result_path).suffix.lstrip('.')
|
||||
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
|
||||
@@ -358,18 +363,54 @@ def _download_covers(
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
|
||||
|
||||
def download_sgdb(index: int, cover_file: str, steam_name: str) -> None:
|
||||
image_type = ("grids", "heroes", "logos", "hgrids")[index]
|
||||
|
||||
def on_url(url: str) -> None:
|
||||
if not url.startswith((
|
||||
"https://cdn2.steamgriddb.com/",
|
||||
"https://deckyloader.ru/sgdb-cdn/",
|
||||
)):
|
||||
on_cover_download(None, steam_name, index)
|
||||
return
|
||||
extension = Path(urlsplit(url).path).suffix.lower()
|
||||
if extension not in (".png", ".jpg", ".jpeg"):
|
||||
logger.warning("Unsupported SGDB artwork format: %s", extension)
|
||||
on_cover_download(None, steam_name, index)
|
||||
return
|
||||
destination = str(Path(cover_file).with_suffix(extension))
|
||||
downloader.download_async(
|
||||
url, destination, timeout=5,
|
||||
callback=partial(on_cover_download, steam_name=steam_name, index=index),
|
||||
)
|
||||
|
||||
fetch_sgdb_cover_async(game_name, on_url, image_type)
|
||||
|
||||
def on_steam_download(
|
||||
index: int, steam_name: str, cover_file: str, result: str | None
|
||||
) -> None:
|
||||
if result:
|
||||
on_cover_download(result, steam_name, index)
|
||||
else:
|
||||
download_sgdb(index, cover_file, steam_name)
|
||||
|
||||
for i, (suffix, steam_name) in enumerate(cover_types):
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
|
||||
if not source_appid:
|
||||
download_sgdb(i, cover_file, steam_name)
|
||||
continue
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{source_appid}/{steam_name}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
|
||||
callback=partial(on_steam_download, i, steam_name, cover_file)
|
||||
)
|
||||
|
||||
|
||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
|
||||
def add_to_steam(
|
||||
game_name: str, exec_line: str, cover_path: str, icon_source: str | None = None
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
|
||||
and download Steam Grid covers with correct sizes and names.
|
||||
@@ -382,7 +423,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
if not exe_path:
|
||||
return (False, error)
|
||||
|
||||
if not os.path.exists(exe_path):
|
||||
is_store_uri = exe_path.startswith(("gog://launch/", "egs://launch/"))
|
||||
if not is_store_uri and not os.path.exists(exe_path):
|
||||
logger.error(f"Executable not found: {exe_path}")
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
@@ -396,7 +438,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
if error:
|
||||
return (False, error)
|
||||
|
||||
icon_path = _generate_icon(exe_path, portproton_dir)
|
||||
custom_icon = get_custom_icon_path(script_path) or get_custom_icon_path(exe_path)
|
||||
icon_source = custom_icon or icon_source or (None if is_store_uri else exe_path)
|
||||
icon_path = custom_icon or (_generate_icon(icon_source, portproton_dir) if icon_source else "")
|
||||
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
@@ -471,10 +515,10 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
nonlocal steam_appid
|
||||
steam_appid = game_info.get("appid")
|
||||
if not steam_appid or not isinstance(steam_appid, int):
|
||||
logger.info("No valid Steam appid found, skipping cover download")
|
||||
_download_covers(appid, (None, game_name), str(grid_dir), was_api_used)
|
||||
return
|
||||
logger.info(f"Found Steam AppID {steam_appid} for cover download")
|
||||
_download_covers(appid, steam_appid, str(grid_dir), was_api_used)
|
||||
_download_covers(appid, (steam_appid, game_name), str(grid_dir), was_api_used)
|
||||
|
||||
get_steam_game_info_async(game_name, exec_line, on_game_info)
|
||||
|
||||
@@ -487,10 +531,9 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
def _delete_cover_files(grid_dir: str, appid: int) -> None:
|
||||
"""Delete cover files for a game."""
|
||||
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")
|
||||
os.path.join(grid_dir, f"{appid}{suffix}{extension}")
|
||||
for suffix in ("", "p", "_hero", "_logo")
|
||||
for extension in (".jpg", ".png", ".jpeg")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import BinaryIO
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
import vdf
|
||||
|
||||
from portprotonqt.config import game_config
|
||||
from portprotonqt.config import game_config, ui_config
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
|
||||
@@ -349,6 +349,8 @@ def _is_steam_proton_dir(path: Path) -> bool:
|
||||
|
||||
def get_steam_proton_versions() -> list[str]:
|
||||
"""Return Steam Proton install directories usable by PortProton."""
|
||||
if ui_config.get_disable_steam_proton_scanning():
|
||||
return []
|
||||
roots = set()
|
||||
for steam_home in _iter_existing_steam_data_dirs():
|
||||
roots.add(steam_home / "compatibilitytools.d")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from collections.abc import Callable
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtCore import Qt, QTimer, QThread, Signal
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
@@ -20,6 +21,7 @@ from portprotonqt.config import ui_config
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
from portprotonqt.custom_widgets import AutoHideScrollArea, AutoSizeButton, FlowLayout
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.detail_pages.utils import check_autoinstall_installed
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.search_utils import SearchOptimizer, build_search_items, search_index
|
||||
@@ -34,6 +36,34 @@ else:
|
||||
_MainWindowTypingBase = object
|
||||
|
||||
|
||||
class AutoInstallStatusWorker(QThread):
|
||||
status_ready = Signal(str, object)
|
||||
api: Any
|
||||
games: list[tuple]
|
||||
portproton_location: str | None
|
||||
|
||||
def run(self) -> None:
|
||||
for game in self.games:
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
target = game[5][len("autoinstall:"):].strip()
|
||||
try:
|
||||
script = target
|
||||
if target.startswith(("http://", "https://")):
|
||||
script = self.api._get_autoinstall_script_path(target)
|
||||
if not os.path.isfile(script):
|
||||
script = self.api.download_autoinstall_script(target)
|
||||
status = None
|
||||
if script and os.path.isfile(script):
|
||||
status = check_autoinstall_installed(
|
||||
script, game[0], self.portproton_location
|
||||
)
|
||||
self.status_ready.emit(game[13], status)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to check installation of %s: %s", game[0], error)
|
||||
self.status_ready.emit(game[13], None)
|
||||
|
||||
|
||||
class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
if TYPE_CHECKING:
|
||||
autoInstallCustomDataThread: Any
|
||||
@@ -109,6 +139,30 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
self._set_autoinstall_container_layout(auto_layout_mode)
|
||||
self.autoInstallScrollArea.setWidget(self.autoInstallContainer)
|
||||
|
||||
self.autoInstallStatusHeader = QWidget()
|
||||
header_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.autoInstallStatusHeader.setFixedHeight(header_config["header_height"])
|
||||
self.autoInstallStatusHeader.setProperty("theme_style_name", "LIBRARY_HEADER_STYLE")
|
||||
self.autoInstallStatusHeader.setStyleSheet(self.theme.LIBRARY_HEADER_STYLE)
|
||||
header_layout = QHBoxLayout(self.autoInstallStatusHeader)
|
||||
header_layout.setContentsMargins(*header_config["header_margins"])
|
||||
header_layout.setSpacing(header_config["header_spacing"])
|
||||
header_layout.addSpacing(header_config["header_cover_width"] + header_config["header_spacing"])
|
||||
for text, stretch in zip(
|
||||
(_("Game Title"), _("Library"), _("Installed")),
|
||||
self.theme.autoinstallColumnStretches, strict=True,
|
||||
):
|
||||
heading = QLabel(text.upper())
|
||||
heading.setAlignment(
|
||||
Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft
|
||||
if text == _("Game Title") else Qt.AlignmentFlag.AlignCenter
|
||||
)
|
||||
heading.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
|
||||
heading.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
|
||||
header_layout.addWidget(heading, stretch)
|
||||
header_layout.addSpacing(self.autoInstallScrollArea.verticalScrollBar().sizeHint().width())
|
||||
self.autoInstallStatusHeader.setVisible(auto_layout_mode in {"list", "vertical"})
|
||||
autoInstallLayout.addWidget(self.autoInstallStatusHeader)
|
||||
autoInstallLayout.addWidget(self.autoInstallScrollArea)
|
||||
|
||||
self.auto_size_slider = QSlider(Qt.Orientation.Horizontal, autoInstallPage)
|
||||
@@ -137,6 +191,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
|
||||
# Store cards
|
||||
self.autoInstallGameCards = {}
|
||||
self.autoInstallStatusLabels = {}
|
||||
self.allAutoInstallCards = []
|
||||
self.autoInstallSearchOptimizer = SearchOptimizer()
|
||||
self.autoInstallLoaded = False
|
||||
@@ -151,7 +206,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
list_layout = auto_layout_mode in {"list", "vertical"}
|
||||
self.autoInstallContainer.setProperty(
|
||||
"library_layout_mode", auto_layout_mode
|
||||
"library_layout_mode", "vertical" if list_layout else auto_layout_mode
|
||||
)
|
||||
|
||||
# Clear
|
||||
@@ -163,6 +218,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
widget.deleteLater()
|
||||
|
||||
self.autoInstallGameCards.clear()
|
||||
self.autoInstallStatusLabels.clear()
|
||||
self.allAutoInstallCards.clear()
|
||||
|
||||
if not games:
|
||||
@@ -208,6 +264,24 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
parent=self.autoInstallContainer,
|
||||
)
|
||||
card.autoinstall_exe_name = exe_name
|
||||
if list_layout:
|
||||
while card.layout_.count() > 2:
|
||||
item = card.layout_.takeAt(2)
|
||||
widget = item.widget() if item else None
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
stretches = self.theme.autoinstallColumnStretches
|
||||
card.layout_.setStretch(1, stretches[0])
|
||||
source_label = QLabel(str(game_source), card)
|
||||
status_label = QLabel("…", card)
|
||||
for label, stretch in zip(
|
||||
(source_label, status_label), stretches[1:], strict=True,
|
||||
):
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setProperty("theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE")
|
||||
label.setStyleSheet(self.theme.GAME_CARD_COLUMN_LABEL_STYLE)
|
||||
card.layout_.addWidget(label, stretch)
|
||||
self.autoInstallStatusLabels[exe_name] = status_label
|
||||
card.hoverChanged.connect(self._on_autoinstall_card_active)
|
||||
card.focusChanged.connect(self._on_autoinstall_card_active)
|
||||
|
||||
@@ -231,11 +305,39 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
self.autoInstallContainer.updateGeometry()
|
||||
self.autoInstallScrollArea.updateGeometry()
|
||||
self.filterAutoInstallGames()
|
||||
if list_layout:
|
||||
self._load_autoinstall_statuses(games)
|
||||
|
||||
self._on_autoinstall_games_loaded = on_autoinstall_games_loaded
|
||||
|
||||
self.stackedWidget.addWidget(autoInstallPage)
|
||||
|
||||
def _load_autoinstall_statuses(self, games: list[tuple]) -> None:
|
||||
workers = getattr(self, "autoInstallStatusWorkers", [])
|
||||
for previous in workers:
|
||||
previous.requestInterruption()
|
||||
self.autoInstallStatusWorkers = workers
|
||||
cards = self.autoInstallGameCards.copy()
|
||||
labels = self.autoInstallStatusLabels.copy()
|
||||
|
||||
def on_status(exe_name: str, installed: bool | None) -> None:
|
||||
card = cards.get(exe_name)
|
||||
if card is None or self.autoInstallGameCards.get(exe_name) is not card:
|
||||
return
|
||||
labels[exe_name].setText(
|
||||
"?" if installed is None else "✓" if installed else "—"
|
||||
)
|
||||
|
||||
worker = AutoInstallStatusWorker(self)
|
||||
worker.api = self.portproton_api
|
||||
worker.games = games
|
||||
worker.portproton_location = self.portproton_location
|
||||
worker.status_ready.connect(on_status)
|
||||
worker.finished.connect(lambda: workers.remove(worker) if worker in workers else None)
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
workers.append(worker)
|
||||
worker.start()
|
||||
|
||||
def _on_autoinstall_card_active(self, game_name: str, active: bool) -> None:
|
||||
if not active:
|
||||
return
|
||||
@@ -255,8 +357,10 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
if item and item.widget():
|
||||
item.widget().deleteLater()
|
||||
QWidget().setLayout(old_layout)
|
||||
self.autoInstallContainer.setProperty("library_layout_mode", mode)
|
||||
if mode == "vertical":
|
||||
self.autoInstallContainer.setProperty(
|
||||
"library_layout_mode", "vertical" if mode == "list" else mode
|
||||
)
|
||||
if mode in {"list", "vertical"}:
|
||||
config = self.theme.GAME_CARD_VERTICAL
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(*config.get("layout_margins", (0, 0, 0, 0)))
|
||||
@@ -279,7 +383,8 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
horizontal = mode in {"horizontal", "horizontal_top"}
|
||||
vertical_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
if horizontal else Qt.ScrollBarPolicy.ScrollBarAlwaysOn
|
||||
if mode in {"list", "vertical"} else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
horizontal_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
@@ -292,6 +397,11 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
"""Rebuild auto-install cards after a live library layout change."""
|
||||
if not hasattr(self, "autoInstallContainer"):
|
||||
return
|
||||
if self.stackedWidget.currentIndex() != self.auto_install_tab_index:
|
||||
self._autoinstall_layout_pending = True
|
||||
self.autoInstallLoaded = False
|
||||
return
|
||||
self._autoinstall_layout_pending = False
|
||||
mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
@@ -299,6 +409,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
"list", "vertical", "horizontal", "horizontal_top",
|
||||
}
|
||||
self._set_autoinstall_container_layout(mode)
|
||||
self.autoInstallStatusHeader.setVisible(mode in {"list", "vertical"})
|
||||
self.auto_size_slider.setVisible(not fixed_layout)
|
||||
if fixed_layout:
|
||||
self.auto_card_width = self.auto_size_slider.maximum()
|
||||
@@ -381,6 +492,9 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
|
||||
def _start_autoinstall_load(self, force_refresh: bool = False) -> None:
|
||||
if getattr(self, "_autoinstall_layout_pending", False):
|
||||
self.refresh_autoinstall_layout()
|
||||
return
|
||||
if self.autoInstallLoaded or self.autoInstallLoading:
|
||||
return
|
||||
if not hasattr(self, "_on_autoinstall_games_loaded"):
|
||||
|
||||
@@ -10,7 +10,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedWidget, QWidget
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.custom_widgets import FlowLayout
|
||||
from portprotonqt.input_manager import BUTTONS, GamepadType
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.localization import _, retranslate
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.qt_utils import get_device_pixel_ratio
|
||||
|
||||
@@ -226,6 +226,7 @@ class MainWindowControlHintsMixin:
|
||||
|
||||
keyboard_hints = [
|
||||
("key_enter", _("Select"), "confirm"),
|
||||
("key_space", _("Select multiple"), None),
|
||||
("key_backspace", _("Back"), "back"),
|
||||
("key_e", _("Add a shortcut"), "add_game"),
|
||||
("key_context", _("Menu"), "context_menu"),
|
||||
@@ -476,6 +477,7 @@ class MainWindowControlHintsMixin:
|
||||
current_tab_index,
|
||||
system_section_index,
|
||||
getattr(self, "current_theme_name", ""),
|
||||
ui_config.get_force_english(),
|
||||
)
|
||||
if not force_update and hints_state == getattr(self, "_last_control_hints_state", None):
|
||||
return
|
||||
@@ -601,7 +603,9 @@ class MainWindowControlHintsMixin:
|
||||
if not default_texts:
|
||||
return
|
||||
for action, text in default_texts.items():
|
||||
self._setGamepadHintText(action, text)
|
||||
if action in VOLUME_HINT_ACTIONS:
|
||||
text = _("Volume") + text[-2:]
|
||||
self._setGamepadHintText(action, retranslate(text))
|
||||
for action in VOLUME_HINT_ACTIONS:
|
||||
self._setGamepadHintVisible(action, False)
|
||||
|
||||
|
||||
@@ -59,6 +59,7 @@ def _format_download_size(size_mib: float) -> str:
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
|
||||
_MainWindowTypingBase = QMainWindow
|
||||
else:
|
||||
@@ -204,6 +205,35 @@ class GOGRepairWorker(QThread):
|
||||
self.error = str(error)
|
||||
|
||||
|
||||
class GOGDeleteWorker(QThread):
|
||||
"""Delete a GOG installation outside the UI thread."""
|
||||
|
||||
def __init__(self, api: GOGAPI, game: dict, menu_manager: "ContextMenuManager") -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
self.game = game
|
||||
self.menu_manager = menu_manager
|
||||
self.error = ""
|
||||
|
||||
def run(self) -> None:
|
||||
app_id = str(self.game["app_id"])
|
||||
try:
|
||||
install_path = self.api.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
raise FileNotFoundError(f"GOG installation not found: {app_id}")
|
||||
shutil.rmtree(install_path)
|
||||
manifest_path = self.api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
try:
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
|
||||
self.api.remove_installed_game(app_id)
|
||||
self.menu_manager.remove_gog_shortcuts(str(self.game["title"]))
|
||||
except Exception as error:
|
||||
logger.exception("Failed to delete GOG game %s", app_id)
|
||||
self.error = str(error)
|
||||
|
||||
|
||||
class StoreDLCWorker(QThread):
|
||||
"""Read owned, installable store DLC outside the UI thread."""
|
||||
|
||||
@@ -324,6 +354,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
self.gog_process = None
|
||||
self.egs_process = None
|
||||
self.gog_repair_worker = None
|
||||
self.gog_delete_worker = None
|
||||
self.egs_install_importing = False
|
||||
self.store_download_queue = []
|
||||
self.gog_download_output = ""
|
||||
@@ -818,9 +849,17 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
api = self.egs_api if source == "egs" else self.gog_api
|
||||
worker = StoreDLCWorker(api, str(game["app_id"]))
|
||||
worker.loaded.connect(lambda dlcs: self._on_store_dlcs_loaded(source, game, dlcs))
|
||||
worker.failed.connect(lambda error: QMessageBox.warning(self, _("Error"), error))
|
||||
results: list[list[dict]] = []
|
||||
errors: list[str] = []
|
||||
worker.loaded.connect(results.append)
|
||||
worker.failed.connect(errors.append)
|
||||
worker.finished.connect(self._on_store_dlc_worker_finished)
|
||||
worker.finished.connect(
|
||||
lambda: self._on_store_dlcs_loaded(source, game, results[0]) if results else None
|
||||
)
|
||||
worker.finished.connect(
|
||||
lambda: QMessageBox.warning(self, _("Error"), errors[0]) if errors else None
|
||||
)
|
||||
self.store_dlc_worker = worker
|
||||
worker.start()
|
||||
|
||||
@@ -1051,38 +1090,41 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
if install_path is None:
|
||||
self.gogAccountStatus.setText(_("GOG installation not found"))
|
||||
return
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game["title"]
|
||||
if getattr(self, "gog_delete_worker", None) is not None:
|
||||
self.gogAccountStatus.setText(_("Another GOG operation is already running"))
|
||||
return
|
||||
if not getattr(getattr(self, "context_menu_manager", None), "_batch_action", False):
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game["title"]
|
||||
)
|
||||
)
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
worker = GOGDeleteWorker(self.gog_api, game, self.context_menu_manager)
|
||||
worker.finished.connect(self._on_gog_delete_finished)
|
||||
self.gog_delete_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_gog_delete_finished(self) -> None:
|
||||
worker = self.gog_delete_worker
|
||||
if worker is None:
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(install_path)
|
||||
except OSError as error:
|
||||
logger.error("Failed to delete GOG game %s: %s", app_id, error)
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
manifest_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
)
|
||||
try:
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
|
||||
self.gog_api.remove_installed_game(app_id)
|
||||
self.context_menu_manager.remove_gog_shortcuts(str(game["title"]))
|
||||
self.loadGames(force_load=True)
|
||||
self.gog_delete_worker = None
|
||||
if worker.error:
|
||||
self.gogAccountStatus.setText(worker.error)
|
||||
else:
|
||||
self.loadGames(force_load=True)
|
||||
worker.deleteLater()
|
||||
|
||||
def _start_gog_download(
|
||||
self, game: dict, install_path: Path, command: list[str], action: str
|
||||
@@ -1104,6 +1146,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._gog_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_gog_download_output)
|
||||
process.finished.connect(self._on_gog_download_finished)
|
||||
self.gog_process = process
|
||||
@@ -1137,6 +1180,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._egs_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_egs_download_output)
|
||||
process.finished.connect(self._on_egs_download_finished)
|
||||
self.egs_process = process
|
||||
@@ -1170,6 +1214,7 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._egs_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.errorOccurred.connect(lambda error: self._handle_store_process_error(process, error))
|
||||
process.readyReadStandardOutput.connect(self._read_egs_download_output)
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._on_egs_operation_finished(
|
||||
@@ -1185,6 +1230,12 @@ class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
self.switchTab(6)
|
||||
process.start()
|
||||
|
||||
def _handle_store_process_error(self, process: QProcess, error: QProcess.ProcessError) -> None:
|
||||
if error != QProcess.ProcessError.FailedToStart:
|
||||
return
|
||||
logger.error("Failed to start store operation: %s", process.errorString())
|
||||
process.finished.emit(os.EX_UNAVAILABLE, QProcess.ExitStatus.CrashExit)
|
||||
|
||||
def _cancel_gog_download(self) -> None:
|
||||
process = self.gog_process or self.egs_process
|
||||
if process is None:
|
||||
|
||||
@@ -13,6 +13,7 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QProgressDialog,
|
||||
QSizePolicy,
|
||||
QToolButton,
|
||||
QVBoxLayout,
|
||||
@@ -42,6 +43,7 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.scripts_utils.shortcut_tools import find_ext_ppdb
|
||||
from portprotonqt.scripts_utils.prefix_backup import BACKUP_EXTENSION
|
||||
from portprotonqt.steam_api import get_cached_steam_game_info, is_game_in_steam
|
||||
from portprotonqt.tabs.theme_store_workers import THEME_ARCHIVE_EXTENSIONS, ThemeArchiveInstallWorker
|
||||
from portprotonqt.time_utils import format_playtime
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -78,7 +80,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
combo.view().window().setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
combo.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
combo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
combo.addItems(labels)
|
||||
combo.setStyleSheet(self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE)
|
||||
combo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -95,16 +97,30 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _on_library_filter_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.filter_keys):
|
||||
return
|
||||
display_filter = self.filter_keys[index]
|
||||
selected = set(game_config.get_display_filter().split(","))
|
||||
key = self.filter_keys[index]
|
||||
if key in ("all", "favorites"):
|
||||
selected = {key}
|
||||
else:
|
||||
selected -= {"all", "favorites"}
|
||||
selected.symmetric_difference_update({key})
|
||||
display_filter = ",".join(key for key in self.filter_keys if key in selected) or "all"
|
||||
selected = set(display_filter.split(","))
|
||||
combo = self.gamesDisplayCombo
|
||||
combo.setCurrentIndex(self.filter_keys.index(display_filter.split(",")[0]))
|
||||
for row, source in enumerate(self.filter_keys):
|
||||
combo.setItemData(row, Qt.CheckState.Checked if source in selected
|
||||
else Qt.CheckState.Unchecked, Qt.ItemDataRole.CheckStateRole)
|
||||
combo.setCurrentText(" + ".join(
|
||||
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
|
||||
if source in selected
|
||||
))
|
||||
game_config.set_display_filter(display_filter)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
)
|
||||
self._position_library_controls_widget()
|
||||
self.searchEdit.clear()
|
||||
self.games = []
|
||||
self._preserve_library_focus_after_load = True
|
||||
self.loadGames(force_load=True)
|
||||
self._on_only_installed_changed(game_config.get_only_installed())
|
||||
|
||||
def _on_only_installed_changed(self, checked: bool) -> None:
|
||||
game_config.set_only_installed(checked)
|
||||
@@ -153,9 +169,17 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
card_orientation = "vertical"
|
||||
elif layout_mode in {"horizontal", "horizontal_top"}:
|
||||
card_orientation = "horizontal"
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
previous_mode = ui_config.get_library_layout_mode(theme_mode)
|
||||
previous_orientation = ui_config.get_horizontal_card_orientation("theme")
|
||||
ui_config.set_library_layout_mode(layout_mode)
|
||||
ui_config.set_horizontal_card_orientation(card_orientation)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
if (
|
||||
previous_mode == ui_config.get_library_layout_mode(theme_mode)
|
||||
and (previous_mode not in {"horizontal", "horizontal_top"}
|
||||
or previous_orientation == card_orientation)
|
||||
):
|
||||
return
|
||||
self.game_library_manager.rebuild_library_layout(
|
||||
ui_config.get_library_layout_mode(theme_mode)
|
||||
)
|
||||
@@ -349,22 +373,38 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
self._set_combo_current_key(
|
||||
self.gamesDisplayCombo,
|
||||
self.filter_keys,
|
||||
display_filter,
|
||||
self.gamesDisplayCombo, self.filter_keys, display_filter.split(",")[0]
|
||||
)
|
||||
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.setEditable(True)
|
||||
line_edit = self.gamesDisplayCombo.lineEdit()
|
||||
if line_edit is not None:
|
||||
line_edit.setReadOnly(True)
|
||||
line_edit.installEventFilter(self.gamesDisplayCombo)
|
||||
self.gamesDisplayCombo.setStyleSheet(
|
||||
self.gamesDisplayCombo.styleSheet() + self.theme.SOURCE_FILTER_STYLE
|
||||
)
|
||||
selected = set(display_filter.split(","))
|
||||
for row, source in enumerate(self.filter_keys):
|
||||
self.gamesDisplayCombo.setItemData(
|
||||
row, Qt.CheckState.Checked if source in selected else Qt.CheckState.Unchecked,
|
||||
Qt.ItemDataRole.CheckStateRole,
|
||||
)
|
||||
self.gamesDisplayCombo.setCurrentText(" + ".join(
|
||||
label for source, label in zip(self.filter_keys, self.filter_labels, strict=True)
|
||||
if source in selected
|
||||
))
|
||||
self.gamesDisplayCombo.activated.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
|
||||
self.onlyInstalledCheckBox = QCheckBox(_("Only Installed"))
|
||||
self.onlyInstalledCheckBox = QCheckBox(_("Show only installed games"))
|
||||
self.onlyInstalledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.onlyInstalledCheckBox.setChecked(only_installed)
|
||||
self.onlyInstalledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self._register_gamepad_tooltip(self.onlyInstalledCheckBox, _("Only Installed"))
|
||||
self._register_gamepad_tooltip(self.onlyInstalledCheckBox, _("Show only installed games"))
|
||||
self.onlyInstalledCheckBox.toggled.connect(self._on_only_installed_changed)
|
||||
|
||||
controls_layout.addWidget(self.gamesDisplayCombo, 0, 1)
|
||||
controls_layout.addWidget(self.onlyInstalledCheckBox, 1, 1, 1, 2)
|
||||
controls_layout.addWidget(self.gamesDisplayCombo, 1, 0)
|
||||
controls_layout.addWidget(self.onlyInstalledCheckBox, 4, 0)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
)
|
||||
@@ -382,19 +422,19 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.gamesBadgeViewCombo.setEnabled(False)
|
||||
self.gamesBadgeViewCombo.currentIndexChanged.connect(self._on_library_badge_view_changed)
|
||||
self.gamesBadgeViewCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
controls_layout.addWidget(self.gamesBadgeViewCombo, 0, 2)
|
||||
controls_layout.addWidget(self.gamesBadgeViewCombo, 2, 0)
|
||||
|
||||
self.library_layout_keys = [
|
||||
"theme", "grid", "list", "vertical", "horizontal",
|
||||
"horizontal_vertical", "horizontal_top", "horizontal_top_vertical",
|
||||
"theme", "grid", "list", "vertical", "horizontal_top",
|
||||
"horizontal_top_vertical", "horizontal", "horizontal_vertical",
|
||||
]
|
||||
self.library_layout_labels = [
|
||||
_("Theme"), _("Grid"), _("List"), _("Table"),
|
||||
_("Theme"), _("Grid"), _("List"), _("Vertical"),
|
||||
_("Horizontal Blocks"), _("Horizontal Cards"),
|
||||
_("Horizontal Top Blocks"), _("Horizontal Top Cards"),
|
||||
_("Horizontal endless Blocks"), _("Horizontal endless Cards"),
|
||||
]
|
||||
self.gamesLayoutCombo = self._create_library_combo(
|
||||
self.library_layout_labels, _("Grid Type:")
|
||||
self.library_layout_labels, _("Layout")
|
||||
)
|
||||
selected_layout = ui_config.get_library_layout_mode("theme")
|
||||
if (
|
||||
@@ -412,7 +452,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.gamesLayoutCombo.activated.connect(
|
||||
self._delay_library_controls_hover_close
|
||||
)
|
||||
controls_layout.addWidget(self.gamesLayoutCombo, 0, 3)
|
||||
controls_layout.addWidget(self.gamesLayoutCombo, 3, 0)
|
||||
|
||||
def _delay_library_controls_hover_close(self, _index: int = -1) -> None:
|
||||
self._library_controls_hover_close_delayed = True
|
||||
@@ -499,9 +539,13 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
source_refresh()
|
||||
|
||||
if not callable(source_loader):
|
||||
if self.gog_api.auth_path.is_file():
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "gog" in display_filter.split(",")
|
||||
) and self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if self.egs_api.user_path.is_file():
|
||||
if (
|
||||
display_filter in ("all", "favorites") or "egs" in display_filter.split(",")
|
||||
) and self.egs_api.user_path.is_file():
|
||||
self._refresh_egs_library()
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
@@ -539,8 +583,8 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
for game in missing_games:
|
||||
@@ -607,18 +651,21 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile().lower()
|
||||
if path.endswith(
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + WINE_ARCHIVE_EXTENSIONS
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + THEME_ARCHIVE_EXTENSIONS
|
||||
):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event):
|
||||
if any(worker.isRunning() for worker in getattr(self, "_themeArchiveWorkers", [])):
|
||||
event.ignore()
|
||||
return
|
||||
wine_archives = []
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile()
|
||||
path_lower = path.lower()
|
||||
if path_lower.endswith(WINE_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
if path_lower.endswith(THEME_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
wine_archives.append(path)
|
||||
continue
|
||||
if path_lower.endswith(BACKUP_EXTENSION):
|
||||
@@ -634,14 +681,56 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if wine_archives:
|
||||
show_proton_manager(
|
||||
self,
|
||||
self.portproton_location,
|
||||
input_manager=self.input_manager,
|
||||
local_archives=wine_archives,
|
||||
)
|
||||
self._install_dropped_theme_archives(wine_archives)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def _install_dropped_theme_archives(self, paths: list[str]) -> None:
|
||||
progress = QProgressDialog(_("Installing…"), "", 0, 0, self)
|
||||
progress.setWindowTitle(_("Theme"))
|
||||
progress.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
progress.setCancelButton(None)
|
||||
progress.setMinimumDuration(0)
|
||||
progress.setStyleSheet(self.theme.MESSAGE_BOX_STYLE + self.theme.PROGRESS_BAR_STYLE)
|
||||
worker = ThemeArchiveInstallWorker(paths)
|
||||
workers = getattr(self, "_themeArchiveWorkers", [])
|
||||
self._themeArchiveWorkers = workers
|
||||
workers.append(worker)
|
||||
worker.installed.connect(self._on_dropped_theme_installed)
|
||||
worker.failed.connect(progress.close)
|
||||
worker.failed.connect(self._on_dropped_theme_failed)
|
||||
worker.wine_archives.connect(progress.close)
|
||||
worker.wine_archives.connect(self._on_dropped_wine_archives)
|
||||
worker.finished.connect(progress.close)
|
||||
worker.finished.connect(progress.deleteLater)
|
||||
worker.finished.connect(lambda: workers.remove(worker))
|
||||
worker.finished.connect(worker.deleteLater)
|
||||
progress.show()
|
||||
worker.start()
|
||||
|
||||
def _on_dropped_theme_installed(self, theme_names: list[str]) -> None:
|
||||
for theme_name in theme_names:
|
||||
self.theme_manager.invalidate_theme(theme_name)
|
||||
variant = ui_config.get_theme_variant()
|
||||
theme_name = ui_config.resolve_theme(theme_names[0], variant)
|
||||
if theme_name not in theme_names:
|
||||
theme_name = theme_names[0]
|
||||
self._refresh_theme_combo(theme_name)
|
||||
self._apply_theme_and_restart(theme_name, variant)
|
||||
|
||||
def _on_dropped_theme_failed(self, message: str) -> None:
|
||||
logger.warning("Failed to install dropped theme: %s", message)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to install theme"))
|
||||
|
||||
def _on_dropped_wine_archives(self, paths: list[str]) -> None:
|
||||
wine_paths = [path for path in paths if path.lower().endswith(WINE_ARCHIVE_EXTENSIONS)]
|
||||
if len(wine_paths) != len(paths):
|
||||
self._on_dropped_theme_failed("No themes found in archive")
|
||||
if wine_paths:
|
||||
show_proton_manager(
|
||||
self, self.portproton_location,
|
||||
input_manager=self.input_manager, local_archives=wine_paths,
|
||||
)
|
||||
|
||||
def openAddGameDialog(self, exe_path=None):
|
||||
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
|
||||
self.current_add_game_dialog.activateWindow()
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PySide6.QtCore import QObject, Qt, QTimer
|
||||
from PySide6.QtCore import QObject, QRect, QSize, Qt, QTimer
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QBoxLayout,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
@@ -44,7 +45,7 @@ from portprotonqt.config import (
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox, FlowLayout
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.debug_utils import get_selectable_gpu_list
|
||||
from portprotonqt.localization import _, retranslate
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -67,6 +68,42 @@ else:
|
||||
_MainWindowTypingBase = object
|
||||
|
||||
|
||||
class SettingsButtonsLayout(QBoxLayout):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(QBoxLayout.Direction.TopToBottom)
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
def hasHeightForWidth(self) -> bool:
|
||||
return True
|
||||
|
||||
def heightForWidth(self, width: int) -> int:
|
||||
sizes = [item.sizeHint() for index in range(self.count())
|
||||
if (item := self.itemAt(index)) is not None]
|
||||
if sum(size.width() for size in sizes) + self.spacing() <= width:
|
||||
return max((size.height() for size in sizes), default=0)
|
||||
return sum(size.height() for size in sizes) + self.spacing()
|
||||
|
||||
def minimumSize(self) -> QSize:
|
||||
sizes = [item.minimumSize() for index in range(self.count())
|
||||
if (item := self.itemAt(index)) is not None]
|
||||
return QSize(max((size.width() for size in sizes), default=0),
|
||||
sum(size.height() for size in sizes) + self.spacing())
|
||||
|
||||
def setGeometry(self, rect: QRect) -> None:
|
||||
width = sum(item.sizeHint().width() for index in range(self.count())
|
||||
if (item := self.itemAt(index)) is not None)
|
||||
direction = (QBoxLayout.Direction.LeftToRight
|
||||
if width + self.spacing() <= rect.width()
|
||||
else QBoxLayout.Direction.TopToBottom)
|
||||
if self.direction() != direction:
|
||||
self.setDirection(direction)
|
||||
if direction == QBoxLayout.Direction.LeftToRight:
|
||||
row_width = width + self.spacing()
|
||||
rect = QRect(rect.x() + (rect.width() - row_width) // 2,
|
||||
rect.y(), row_width, rect.height())
|
||||
super().setGeometry(rect)
|
||||
|
||||
|
||||
class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
if TYPE_CHECKING:
|
||||
def __getattr__(self, name: str) -> Any: ...
|
||||
@@ -293,6 +330,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
fullscreen_layout.addStretch()
|
||||
uiForm.addRow(fullscreen_layout)
|
||||
|
||||
self.hideCursorCheckBox = QCheckBox()
|
||||
self.hideCursorCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.hideCursorCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.hideCursorTitle = QLabel(_("Hide Cursor in Fullscreen"))
|
||||
self.hideCursorTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.hideCursorTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
|
||||
self.hideCursorTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.hideCursorCheckBox.setChecked(display_config.get_hide_cursor())
|
||||
hide_cursor_layout = QHBoxLayout()
|
||||
hide_cursor_layout.setContentsMargins(0, 0, 0, 0)
|
||||
hide_cursor_layout.addWidget(self.hideCursorCheckBox)
|
||||
hide_cursor_layout.addWidget(self.hideCursorTitle)
|
||||
hide_cursor_layout.addStretch()
|
||||
uiForm.addRow(hide_cursor_layout)
|
||||
|
||||
self.autoFullscreenGamepadCheckBox = QCheckBox()
|
||||
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -309,6 +361,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
auto_fullscreen_layout.addStretch()
|
||||
uiForm.addRow(auto_fullscreen_layout)
|
||||
|
||||
self.disableBootAnimationCheckBox = QCheckBox()
|
||||
self.disableBootAnimationCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.disableBootAnimationCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.disableBootAnimationTitle = QLabel(_("Disable Boot Animation"))
|
||||
self.disableBootAnimationTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.disableBootAnimationTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
|
||||
self.disableBootAnimationTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.disableBootAnimationCheckBox.setChecked(not display_config.get_boot_animation_enabled())
|
||||
boot_animation_layout = QHBoxLayout()
|
||||
boot_animation_layout.setContentsMargins(0, 0, 0, 0)
|
||||
boot_animation_layout.addWidget(self.disableBootAnimationCheckBox)
|
||||
boot_animation_layout.addWidget(self.disableBootAnimationTitle)
|
||||
boot_animation_layout.addStretch()
|
||||
uiForm.addRow(boot_animation_layout)
|
||||
|
||||
self.minimizeToTrayCheckBox = QCheckBox()
|
||||
self.minimizeToTrayCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -441,6 +508,33 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
crash_reports_enabled_layout.addStretch()
|
||||
uiForm.addRow(crash_reports_enabled_layout)
|
||||
|
||||
self.disableCyrillicPathCheckCheckBox = QCheckBox()
|
||||
self.disableCyrillicPathCheckCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.disableCyrillicPathCheckCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
disable_cyrillic_path_check_title = QLabel(
|
||||
_("Disable Cyrillic path warning")
|
||||
)
|
||||
disable_cyrillic_path_check_title.setSizePolicy(
|
||||
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
disable_cyrillic_path_check_title.setStyleSheet(
|
||||
self.theme.SETTINGS_TITLE_CHECKBOX_STYLE
|
||||
)
|
||||
disable_cyrillic_path_check_title.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.disableCyrillicPathCheckCheckBox.setChecked(
|
||||
ui_config.get_disable_cyrillic_path_check()
|
||||
)
|
||||
disable_cyrillic_path_check_layout = QHBoxLayout()
|
||||
disable_cyrillic_path_check_layout.setContentsMargins(0, 0, 0, 0)
|
||||
disable_cyrillic_path_check_layout.addWidget(
|
||||
self.disableCyrillicPathCheckCheckBox
|
||||
)
|
||||
disable_cyrillic_path_check_layout.addWidget(
|
||||
disable_cyrillic_path_check_title
|
||||
)
|
||||
disable_cyrillic_path_check_layout.addStretch()
|
||||
uiForm.addRow(disable_cyrillic_path_check_layout)
|
||||
|
||||
self.disableAltDependencyCheckCheckBox: QCheckBox | None = None
|
||||
if self._is_alt_x86_64():
|
||||
self.disableAltDependencyCheckCheckBox = QCheckBox()
|
||||
@@ -503,6 +597,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
steam_compat_layout.addStretch()
|
||||
uiForm.addRow(steam_compat_layout)
|
||||
|
||||
self.disableSteamProtonScanningCheckBox = QCheckBox()
|
||||
self.disableSteamProtonScanningCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.disableSteamProtonScanningCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.disableSteamProtonScanningTitle = QLabel(_("Disable Steam Proton scanning"))
|
||||
self.disableSteamProtonScanningTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.disableSteamProtonScanningTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
|
||||
self.disableSteamProtonScanningTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.disableSteamProtonScanningCheckBox.setChecked(ui_config.get_disable_steam_proton_scanning())
|
||||
disable_steam_proton_scanning_layout = QHBoxLayout()
|
||||
disable_steam_proton_scanning_layout.setContentsMargins(0, 0, 0, 0)
|
||||
disable_steam_proton_scanning_layout.addWidget(self.disableSteamProtonScanningCheckBox)
|
||||
disable_steam_proton_scanning_layout.addWidget(self.disableSteamProtonScanningTitle)
|
||||
disable_steam_proton_scanning_layout.addStretch()
|
||||
uiForm.addRow(disable_steam_proton_scanning_layout)
|
||||
|
||||
if get_steam_compatibilitytools_dir() is not None:
|
||||
self.downloadWineToSteamCheckBox = QCheckBox()
|
||||
self.downloadWineToSteamCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
@@ -739,9 +848,12 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
layout.addWidget(self.settingsScrollArea)
|
||||
|
||||
# Buttons (outside scroll area, always visible)
|
||||
buttonsLayout = FlowLayout(center_rows=True)
|
||||
actionsLayout = SettingsButtonsLayout()
|
||||
actionsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
|
||||
buttonsLayout = QHBoxLayout()
|
||||
buttonsLayout.setContentsMargins(0, 0, 0, 0)
|
||||
buttonsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
|
||||
buttonsLayout.addStretch()
|
||||
|
||||
self.saveButton = AutoSizeButton(_("Save Settings"), icon=self.theme_manager.get_icon("save", as_path=True))
|
||||
self.saveButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
@@ -765,25 +877,34 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.globalGameSettingsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.globalGameSettingsButton.clicked.connect(self.openGlobalGameSettings)
|
||||
buttonsLayout.addWidget(self.globalGameSettingsButton)
|
||||
buttonsLayout.addStretch()
|
||||
actionsLayout.addLayout(buttonsLayout)
|
||||
|
||||
maintenanceButtonsLayout = QHBoxLayout()
|
||||
maintenanceButtonsLayout.setContentsMargins(0, 0, 0, 0)
|
||||
maintenanceButtonsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
|
||||
maintenanceButtonsLayout.addStretch()
|
||||
|
||||
self.migrateShortcutsButton = AutoSizeButton(_("Migrate legacy shortcuts"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.migrateShortcutsButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.migrateShortcutsButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.migrateShortcutsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.migrateShortcutsButton.clicked.connect(self.migrateLegacyShortcuts)
|
||||
buttonsLayout.addWidget(self.migrateShortcutsButton)
|
||||
maintenanceButtonsLayout.addWidget(self.migrateShortcutsButton)
|
||||
|
||||
if os.getenv("APPIMAGE"):
|
||||
buttonsLayout.addWidget(self.integrateAppImageButton)
|
||||
maintenanceButtonsLayout.addWidget(self.integrateAppImageButton)
|
||||
|
||||
self.clearCacheButton = AutoSizeButton(_("Clear Cache"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.clearCacheButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.clearCacheButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.clearCacheButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.clearCacheButton.clicked.connect(self.clearCache)
|
||||
buttonsLayout.addWidget(self.clearCacheButton)
|
||||
maintenanceButtonsLayout.addWidget(self.clearCacheButton)
|
||||
maintenanceButtonsLayout.addStretch()
|
||||
|
||||
layout.addLayout(buttonsLayout)
|
||||
actionsLayout.addLayout(maintenanceButtonsLayout)
|
||||
layout.addLayout(actionsLayout)
|
||||
self.stackedWidget.addWidget(self.portProtonWidget)
|
||||
|
||||
def openGlobalGameSettings(self) -> None:
|
||||
@@ -799,8 +920,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to reset all settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
if reset_settings():
|
||||
@@ -819,8 +940,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Migrate old PortProton shortcuts to PortProtonQt format?"))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -836,8 +957,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to clear the cache? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
cache_config.clear_cache()
|
||||
@@ -965,9 +1086,12 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
|
||||
fullscreen = self.fullscreenCheckBox.isChecked()
|
||||
display_config.set_fullscreen(fullscreen)
|
||||
display_config.set_hide_cursor(self.hideCursorCheckBox.isChecked())
|
||||
self._update_cursor_visibility()
|
||||
|
||||
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
|
||||
display_config.set_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
|
||||
display_config.set_boot_animation_enabled(not self.disableBootAnimationCheckBox.isChecked())
|
||||
|
||||
hide_control_hints = self.hideControlHintsCheckBox.isChecked()
|
||||
ui_config.set_hide_control_hints(hide_control_hints)
|
||||
@@ -999,6 +1123,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
elif not steam_compat and currently_installed:
|
||||
remove_steam_compat_tool()
|
||||
|
||||
ui_config.set_disable_steam_proton_scanning(self.disableSteamProtonScanningCheckBox.isChecked())
|
||||
|
||||
if hasattr(self, 'downloadWineToSteamCheckBox'):
|
||||
ui_config.set_download_wine_to_steam(self.downloadWineToSteamCheckBox.isChecked())
|
||||
|
||||
@@ -1096,6 +1222,9 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
ui_config.set_crash_reports_enabled(
|
||||
self.crashReportsEnabledCheckBox.isChecked()
|
||||
)
|
||||
ui_config.set_disable_cyrillic_path_check(
|
||||
self.disableCyrillicPathCheckCheckBox.isChecked()
|
||||
)
|
||||
if self.disableAltDependencyCheckCheckBox is not None:
|
||||
ui_config.set_disable_alt_i586_dependency_check(
|
||||
self.disableAltDependencyCheckCheckBox.isChecked()
|
||||
|
||||
@@ -1530,8 +1530,8 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(request.get("message", ""))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.Yes)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
button = msg_box.exec()
|
||||
worker.submit_pairing_response("yes" if button == QMessageBox.StandardButton.Yes else "no")
|
||||
return
|
||||
|
||||
@@ -21,6 +21,7 @@ THEME_STORE_IMAGE_WORKERS = 8
|
||||
THEME_STORE_CARD_BATCH_SIZE = 20
|
||||
THEME_STORE_VOTES_ICON = "★"
|
||||
THEME_STORE_DOWNLOADS_ICON = "⇩"
|
||||
THEME_ARCHIVE_EXTENSIONS = (".zip", ".tar", ".tar.gz", ".tgz", ".tar.xz", ".tar.bz2")
|
||||
|
||||
|
||||
def _theme_store_download_url(theme_id: int) -> str:
|
||||
@@ -126,6 +127,40 @@ def _install_theme_archive(archive_path: str) -> list[str]:
|
||||
return installed_names
|
||||
|
||||
|
||||
class ThemeArchiveInstallWorker(QThread):
|
||||
installed = Signal(list)
|
||||
failed = Signal(str)
|
||||
wine_archives = Signal(list)
|
||||
|
||||
def __init__(self, paths: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.paths = paths
|
||||
|
||||
def run(self) -> None:
|
||||
wine_paths = []
|
||||
for path in self.paths:
|
||||
if self.isInterruptionRequested():
|
||||
return
|
||||
try:
|
||||
if zipfile.is_zipfile(path):
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
names = archive.namelist()
|
||||
else:
|
||||
with tarfile.open(path) as archive:
|
||||
names = archive.getnames()
|
||||
if not any(name.rstrip("/").split("/")[-1] == "styles.py" for name in names):
|
||||
wine_paths.append(path)
|
||||
continue
|
||||
installed = _install_theme_archive(path)
|
||||
if not installed:
|
||||
raise ValueError("No themes found in archive")
|
||||
self.installed.emit(installed)
|
||||
except (OSError, ValueError, tarfile.TarError, zipfile.BadZipFile) as error:
|
||||
self.failed.emit(str(error))
|
||||
if wine_paths and not self.isInterruptionRequested():
|
||||
self.wine_archives.emit(wine_paths)
|
||||
|
||||
|
||||
class ThemeStoreListWorker(QThread):
|
||||
loaded = Signal(list)
|
||||
failed = Signal(str)
|
||||
|
||||
@@ -287,8 +287,8 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
message_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
message_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
removal_results = [
|
||||
|
||||
@@ -336,8 +336,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to clear prefix '{}'?").format(selected_prefix))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
if reply != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
@@ -443,8 +443,8 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
msg_box.setText(_("Are you sure you want to delete prefix '{}'?").format(selected_prefix))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
msg_box.button(QMessageBox.StandardButton.Yes).setText(_("Yes"))
|
||||
msg_box.button(QMessageBox.StandardButton.No).setText(_("No"))
|
||||
reply = msg_box.exec()
|
||||
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
|
||||
@@ -5,6 +5,17 @@ logger = get_logger(__name__)
|
||||
|
||||
class MainWindowWorkersMixin:
|
||||
def _stopBackgroundWorkers(self) -> None:
|
||||
context_menu_manager = getattr(self, "context_menu_manager", None)
|
||||
if context_menu_manager is not None:
|
||||
context_menu_manager.stop_card_actions()
|
||||
self.store_download_queue = []
|
||||
for process_name in ("gog_process", "egs_process"):
|
||||
process = getattr(self, process_name, None)
|
||||
if process is not None:
|
||||
process.terminate()
|
||||
if not process.waitForFinished():
|
||||
process.kill()
|
||||
process.waitForFinished()
|
||||
worker_timeouts = {
|
||||
"networkWorker": 3000,
|
||||
"bluetoothWorker": 12000,
|
||||
@@ -22,6 +33,7 @@ class MainWindowWorkersMixin:
|
||||
"gog_library_worker": None,
|
||||
"gog_metadata_worker": None,
|
||||
"gog_repair_worker": None,
|
||||
"gog_delete_worker": None,
|
||||
"store_dlc_worker": None,
|
||||
"gog_auth_worker": None,
|
||||
"gog_account_worker": None,
|
||||
@@ -33,10 +45,13 @@ class MainWindowWorkersMixin:
|
||||
for worker_name, timeout_ms in worker_timeouts.items():
|
||||
self._stopWorkerThread(worker_name, timeout_ms)
|
||||
for workers_name in (
|
||||
"autoInstallStatusWorkers",
|
||||
"gog_support_workers",
|
||||
"_listWorkerPool",
|
||||
"_imageWorkerPool",
|
||||
"_detailImageWorkerPool",
|
||||
"_themeArchiveWorkers",
|
||||
"_gameFolderWorkers",
|
||||
):
|
||||
self._stopWorkerThreads(workers_name)
|
||||
downloader = getattr(self, "downloader", None)
|
||||
|
||||
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 155 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 25 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 24 KiB |
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6c-2.77 0-5 2.23-5 5v26c0 2.77 2.23 5 5 5h36c2.77 0 5-2.23 5-5v-26c0-2.77-2.23-5-5-5h-36zm4.1816 19.461h3.9473v5.9219h19.742v-5.9219h3.9473v5.9219a3.9483 3.9483 0 0 1-3.9473 3.9492h-19.742a3.9483 3.9483 0 0 1-3.9473-3.9492v-5.9219z" fill="#3f424d"/></svg>
|
||||
|
After Width: | Height: | Size: 386 B |