Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b60a79ae3
|
||
|
|
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 }}
|
||||
@@ -128,8 +128,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 +177,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,12 +22,28 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
|
||||
image: archlinux:base-devel@sha256:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
steps:
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
if 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 system dependencies
|
||||
run: |
|
||||
pacman -Syuu --noconfirm --disable-download-timeout
|
||||
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Syu --noconfirm --needed git nodejs npm mesa libarchive libpulse sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
@@ -80,11 +80,28 @@ 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:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
if 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 +152,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 +207,29 @@ 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:996c3a1d6b0d87b01242f6fcd8cfa3ad3eece1a67ab5c8f108e20af1d7b97bdd
|
||||
|
||||
steps:
|
||||
- name: Rank Arch mirrors
|
||||
run: |
|
||||
if 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
|
||||
@@ -257,8 +296,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
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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,15 +414,20 @@ 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_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
|
||||
@@ -462,6 +469,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 +1006,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
|
||||
@@ -1067,8 +1083,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,26 @@
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
- В меню системного трея при запуске игры с `--silent` добавлены отсутствовавшие действия приостановки и возобновления игры
|
||||
|
||||
### Contributors
|
||||
|
||||
---
|
||||
|
||||
## [1.5.0] - 2026-10-14
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлен поиск настроек игры вводом с клавиатуры без предварительного выбора поля поиска
|
||||
- Добавлена навигация геймпадом к кнопкам под таблицами
|
||||
- Добавлена настройка скрытия курсора в полноэкранном режиме, включённая по умолчанию
|
||||
- Добавлена настройка отключения поиска установленных Proton в Steam для списков выбора версий и окна управления WINE/Proton
|
||||
- Добавлена возможность добавления загрузочной WebM-заставки из тем при переходе в fullscreen, включая автоматический переход при подключении геймпада
|
||||
- Добавлен переход к загрузке Wine/Proton из списка версий в настройках игры
|
||||
- Добавлены выбор и установка DLC при установке игр GOG
|
||||
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
|
||||
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
|
||||
@@ -23,9 +43,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 +77,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 +96,8 @@
|
||||
- Исправлена синтаксическая ошибка в `functions_helper`, препятствовавшая запуску PortProton с Bash 5.1
|
||||
- Исправлено управление интерфейсом при одновременном подключении нескольких геймпадов
|
||||
- Исправлена коллизия пользовательских данных автоустановок с одинаковыми именами исходных исполняемых файлов
|
||||
- Исправлен поиск скриптов и локалей через `XDG_DATA_DIRS` в NixOS
|
||||
- Исправлен запуск PortProtonQt на NixOS через `steam-run`
|
||||
- Исправлена проверка и установка недостающих i586-зависимостей в ALT Linux перед запуском игр
|
||||
- Исправлено игнорирование проверки 32-битных пакетов при запуске игр GOG
|
||||
- Исправлено отсутствие иконки у окна встроенного терминала
|
||||
- Исправлена потеря финального вывода при ошибке встроенного терминала
|
||||
- Исправлено открытие карточек GOG через URI при старте приложения
|
||||
@@ -74,8 +110,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-14.1k-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/Загрузки-14.1k-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
|
||||
@@ -2415,8 +2428,9 @@ pw_specialk_set_ini_value () {
|
||||
[[ -f "$ini_file" ]] && sed -i '/./,$!d' "$ini_file"
|
||||
|
||||
if grep -q "^\[$section\]" "$ini_file" ; then
|
||||
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" | grep -q "^$key=" ; then
|
||||
sed -i "/^\[$section\]/,/^\[/{s/^$key=.*/$key=$value/}" "$ini_file"
|
||||
if sed -n "/^\[$section\]/,/^\[/{p}" "$ini_file" \
|
||||
| grep -q "^[[:space:]]*$key[[:space:]]*=" ; then
|
||||
sed -i "/^\[$section\]/,/^\[/{s/^[[:space:]]*$key[[:space:]]*=.*/$key=$value/}" "$ini_file"
|
||||
else
|
||||
sed -i "/^\[$section\]/a $key=$value" "$ini_file"
|
||||
fi
|
||||
@@ -2509,6 +2523,171 @@ pw_specialk_sync_files () {
|
||||
|
||||
}
|
||||
|
||||
pw_reshade_sync_files () {
|
||||
local root="${PW_PLUGINS_PATH}/reshade_windows" runtime detect arch dll highest ogl vulkan source
|
||||
local manifest_reg package installed installed_arch installed_version extract_dir reshade_exe reshade_font font_win screenshot_path backup
|
||||
local PATH_TO_GAME="${PATH_TO_GAME:-}"
|
||||
runtime="$root/reshade/${PW_RESHADE_VER}_Addon"
|
||||
reshade_exe="${PW_RESHADE_EXE:-$PW_EXE_FILE}"
|
||||
if [[ ! -f "$reshade_exe" ]] ; then
|
||||
print_warning "ReShade executable not found: $reshade_exe. Using $PW_EXE_FILE."
|
||||
reshade_exe="$PW_EXE_FILE"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 ]] ; then
|
||||
detect=$(python_module graphics_detector "$reshade_exe")
|
||||
file "$reshade_exe" | grep -qE 'x86-64|PE32\+' && arch=64 || arch=32
|
||||
highest=$(echo "$detect" | grep '^HIGHEST_DX=' | cut -d= -f2)
|
||||
ogl=$(echo "$detect" | grep '^USES_OGL=' | cut -d= -f2)
|
||||
vulkan=$(echo "$detect" | grep '^USES_VULKAN=' | cut -d= -f2)
|
||||
source=$(echo "$detect" | grep '^SOURCE=' | cut -d= -f2-)
|
||||
[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] && vulkan=false
|
||||
fi
|
||||
if [[ -n "$PW_RESHADE_EXE" ]] ; then
|
||||
PATH_TO_GAME="$(dirname "$(realpath "$reshade_exe")")"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 ]] \
|
||||
&& [[ ! -f "$runtime/ReShade32.dll" || ! -f "$runtime/ReShade32.json" \
|
||||
|| ! -f "$runtime/ReShade64.dll" || ! -f "$runtime/ReShade64.json" ]] ; then
|
||||
try_download "https://reshade.me/downloads/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
|
||||
"${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
|
||||
create_new_dir "$runtime"
|
||||
unpack "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe" \
|
||||
"$runtime" silent || fatal "Failed to extract ReShade."
|
||||
try_remove_file "${PORT_DATA_PATH}/data/tmp/ReShade_Setup_${PW_RESHADE_VER}_Addon.exe"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3dcompiler_47.dll" ]] ; then
|
||||
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/d3dcompiler_47.dll" "$root/d3dcompiler_47.dll"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -f "$root/d3d8.dll" ]] ; then
|
||||
try_download "https://github.com/crosire/d3d8to9/releases/download/v${PW_D3D8TO9_VER}/d3d8.dll" "$root/d3d8.dll"
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && ! -d "$root/shaders/Shaders" ]] ; then
|
||||
try_remove_dir "$root/shaders"
|
||||
create_new_dir "$root/shaders/Shaders"
|
||||
create_new_dir "$root/shaders/Textures"
|
||||
for package in reshade_shaders sweetfx_shaders immerse_shaders astrayfx_shaders prod80_shaders retroarch_shaders ; do
|
||||
extract_dir="${PORT_DATA_PATH}/data/tmp/$package"
|
||||
if [[ "$package" == immerse_shaders ]] ; then
|
||||
try_download "https://github.com/martymcmodding/iMMERSE/archive/refs/heads/main.tar.gz" \
|
||||
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
else
|
||||
try_download "https://github.com/itsOwen/LetMeReShade/releases/download/LetMeReShade_Binaries/${package}.tar.gz" \
|
||||
"${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
fi
|
||||
try_remove_dir "$extract_dir"
|
||||
create_new_dir "$extract_dir"
|
||||
unpack "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz" "$extract_dir" \
|
||||
silent || fatal "Failed to extract $package."
|
||||
find "$extract_dir" -type d -iname Shaders -exec cp -rf '{}/.' "$root/shaders/Shaders/" \;
|
||||
find "$extract_dir" -type d -iname Textures -exec cp -rf '{}/.' "$root/shaders/Textures/" \;
|
||||
try_remove_dir "$extract_dir"
|
||||
try_remove_file "${PORT_DATA_PATH}/data/tmp/${package}.tar.gz"
|
||||
done
|
||||
fi
|
||||
if [[ "$PW_USE_RESHADE" == 1 && "$vulkan" == true ]] ; then
|
||||
dll="Vulkan${arch}"
|
||||
elif [[ "$PW_USE_RESHADE" == 1 ]] ; then
|
||||
case "$highest" in
|
||||
"DirectX 10"|"DirectX 11"|"DirectX 12") dll=dxgi.dll ;;
|
||||
"DirectX 8"|"DirectX 9") dll=d3d9.dll ;;
|
||||
"DirectDraw") return 0 ;;
|
||||
*) [[ "$ogl" == true ]] && dll=opengl32.dll || dll=dxgi.dll ;;
|
||||
esac
|
||||
fi
|
||||
case "$PW_RESHADE_API" in
|
||||
vulkan) vulkan=true; dll="Vulkan${arch}" ;;
|
||||
dxgi.dll|d3d9.dll|opengl32.dll) vulkan=false; dll="$PW_RESHADE_API" ;;
|
||||
esac
|
||||
if [[ -f "$PATH_TO_GAME/reshade_version" ]] \
|
||||
&& { [[ "$PW_USE_RESHADE" != 1 ]] \
|
||||
|| [[ "$(<"$PATH_TO_GAME/reshade_version")" != "${PW_RESHADE_VER}_Addon"$'\n'"$dll" ]]; } ; then
|
||||
installed_version=$(sed -n '1p' "$PATH_TO_GAME/reshade_version")
|
||||
installed=$(sed -n '2p' "$PATH_TO_GAME/reshade_version")
|
||||
if [[ "$installed" == Vulkan32 || "$installed" == Vulkan64 ]] ; then
|
||||
installed_arch=${installed#Vulkan}
|
||||
manifest_reg="Z:${root//\//\\}\\reshade\\${installed_version}\\ReShade${installed_arch}.json"
|
||||
manifest_reg=${manifest_reg//\\/\\\\\\\\}
|
||||
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" user
|
||||
if [[ "$installed_arch" == 32 ]] ; then
|
||||
get_and_set_reg_file --delete 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" system
|
||||
else
|
||||
get_and_set_reg_file --delete 'Software\Khronos\Vulkan\ImplicitLayers' \
|
||||
"$manifest_reg" "" "" system
|
||||
fi
|
||||
fi
|
||||
try_remove_file "$PATH_TO_GAME/$installed"
|
||||
if [[ -L "$PATH_TO_GAME/${installed}.b" \
|
||||
&& $(readlink -f "$PATH_TO_GAME/${installed}.b") == "$root/reshade/"* ]] ; then
|
||||
unlink "$PATH_TO_GAME/${installed}.b"
|
||||
fi
|
||||
[[ -e "$PATH_TO_GAME/${installed}.b" ]] \
|
||||
&& mv -f "$PATH_TO_GAME/${installed}.b" "$PATH_TO_GAME/$installed"
|
||||
[[ -L "$PATH_TO_GAME/d3dcompiler_47.dll" ]] && unlink "$PATH_TO_GAME/d3dcompiler_47.dll"
|
||||
[[ -L "$PATH_TO_GAME/d3d8.dll" ]] && unlink "$PATH_TO_GAME/d3d8.dll"
|
||||
[[ -e "$PATH_TO_GAME/d3d8.dll.b" ]] \
|
||||
&& mv -f "$PATH_TO_GAME/d3d8.dll.b" "$PATH_TO_GAME/d3d8.dll"
|
||||
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
|
||||
try_remove_file "$PATH_TO_GAME/ReShade.ini"
|
||||
try_remove_file "$PATH_TO_GAME/ReShadePreset.ini"
|
||||
try_remove_file "$PATH_TO_GAME/ReShade.log"
|
||||
try_remove_file "$PATH_TO_GAME/reshade_version"
|
||||
fi
|
||||
[[ "$PW_USE_RESHADE" != 1 ]] && return 0
|
||||
[[ -L "$PATH_TO_GAME/ReShade_shaders" ]] && unlink "$PATH_TO_GAME/ReShade_shaders"
|
||||
try_force_link_dir "$root/shaders" "$PATH_TO_GAME/ReShade_shaders"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "EffectSearchPaths" '.\\ReShade_shaders\\Shaders'
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "GENERAL" "TextureSearchPaths" '.\\ReShade_shaders\\Textures'
|
||||
if [[ -n "$PW_RESHADE_FONT" ]] ; then
|
||||
reshade_font=$(fc-match --format='%{file}' "$PW_RESHADE_FONT")
|
||||
else
|
||||
reshade_font=/usr/share/fonts/ttf/dejavu/DejaVuSans.ttf
|
||||
fi
|
||||
if [[ -f "$reshade_font" ]] ; then
|
||||
font_win="Z:${reshade_font//\//\\\\}"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "Font" "$font_win"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "STYLE" "EditorFont" "$font_win"
|
||||
elif [[ -n "$PW_RESHADE_FONT" ]] ; then
|
||||
print_warning "ReShade font not found: $PW_RESHADE_FONT"
|
||||
fi
|
||||
screenshot_path='.\\'
|
||||
if [[ -n "$PW_RESHADE_SCREENSHOT_PATH" ]] ; then
|
||||
create_new_dir "$PW_RESHADE_SCREENSHOT_PATH"
|
||||
screenshot_path="Z:${PW_RESHADE_SCREENSHOT_PATH//\//\\\\}"
|
||||
fi
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyScreenshot" "$PW_RESHADE_SCREENSHOT_KEY"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "INPUT" "KeyOverlay" "$PW_RESHADE_OVERLAY_KEY"
|
||||
pw_specialk_set_ini_value "$PATH_TO_GAME/ReShade.ini" "SCREENSHOT" "SavePath" "$screenshot_path"
|
||||
if [[ "$vulkan" == true ]] ; then
|
||||
manifest_reg="Z:${runtime//\//\\}\\ReShade${arch}.json"
|
||||
manifest_reg=${manifest_reg//\\/\\\\\\\\}
|
||||
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 user
|
||||
if [[ "$arch" == 32 ]] ; then
|
||||
get_and_set_reg_file --add 'Software\Wow6432Node\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
|
||||
else
|
||||
get_and_set_reg_file --add 'Software\Khronos\Vulkan\ImplicitLayers' "$manifest_reg" REG_DWORD 0 system
|
||||
fi
|
||||
var_winedlloverride_update "vulkan-1=n,b"
|
||||
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
|
||||
return 0
|
||||
fi
|
||||
[[ ! -f "$PATH_TO_GAME/reshade_version" \
|
||||
&& -e "$PATH_TO_GAME/$dll" && ! -e "$PATH_TO_GAME/${dll}.b" \
|
||||
&& ( ! -L "$PATH_TO_GAME/$dll" \
|
||||
|| $(readlink -f "$PATH_TO_GAME/$dll") != "$root/reshade/"* ) ]] \
|
||||
&& mv -f "$PATH_TO_GAME/$dll" "$PATH_TO_GAME/${dll}.b"
|
||||
try_force_link_file "$runtime/ReShade${arch}.dll" "$PATH_TO_GAME/$dll"
|
||||
try_force_link_file "$root/d3dcompiler_47.dll" "$PATH_TO_GAME/d3dcompiler_47.dll"
|
||||
var_winedlloverride_update "d3dcompiler_47=n;${dll%.*}=n,b"
|
||||
if [[ "$highest" == "DirectX 8" && "$dll" == d3d9.dll ]] ; then
|
||||
[[ -e "$PATH_TO_GAME/d3d8.dll" && ! -e "$PATH_TO_GAME/d3d8.dll.b" ]] && mv -f "$PATH_TO_GAME/d3d8.dll" "$PATH_TO_GAME/d3d8.dll.b"
|
||||
try_force_link_file "$root/d3d8.dll" "$PATH_TO_GAME/d3d8.dll"
|
||||
var_winedlloverride_update "d3d8=n,b"
|
||||
fi
|
||||
printf '%s\n%s\n' "${PW_RESHADE_VER}_Addon" "$dll" > "$PATH_TO_GAME/reshade_version"
|
||||
}
|
||||
|
||||
pw_prepare_gamescope_launch () {
|
||||
[[ "$PW_USE_NATIVE_WAYLAND" == "1" ]] && export PW_GAMESCOPE="0"
|
||||
|
||||
@@ -2606,21 +2785,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 +2817,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 +3090,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"
|
||||
;;
|
||||
@@ -2943,6 +3123,7 @@ start_portproton () {
|
||||
|
||||
pw_optiscaler_sync_files
|
||||
pw_specialk_sync_files
|
||||
pw_reshade_sync_files
|
||||
|
||||
if [[ "${PW_USE_RAY_TRACING}" == "1" ]] ; then
|
||||
var_vkd3d_config_update dxr
|
||||
@@ -3479,10 +3660,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"
|
||||
@@ -3773,10 +3961,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
|
||||
@@ -4022,7 +4219,6 @@ steamplay_launch () {
|
||||
|
||||
pw_optiscaler_sync_files
|
||||
pw_specialk_sync_files
|
||||
|
||||
PORT_WINE_PREFIX="$STEAM_COMPAT_DATA_PATH/pfx"
|
||||
|
||||
pw_prepare_gamescope_launch
|
||||
@@ -4088,6 +4284,8 @@ steamplay_launch () {
|
||||
fi
|
||||
fi
|
||||
|
||||
pw_reshade_sync_files
|
||||
|
||||
# Winetricks
|
||||
if [[ -n "${PW_DLL_INSTALL:-}" ]] ; then
|
||||
update_winetricks
|
||||
@@ -4161,12 +4359,11 @@ portwine_launch () {
|
||||
if [[ -n "${PW_RUN_AFTER_EXE:-}" ]] \
|
||||
&& [[ -f "${PW_RUN_AFTER_EXE}" ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE,,}" =~ \.exe$ ]] \
|
||||
&& [[ "${PW_RUN_AFTER_EXE}" != "${PW_EXE_FILE}" ]] \
|
||||
&& [[ "${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%%.*}"
|
||||
@@ -4185,16 +4382,27 @@ portwine_launch () {
|
||||
pw_exe_file_win="z:${PW_EXE_FILE//\//\\}"
|
||||
fi
|
||||
pw_exe_file_win="${pw_exe_file_win//\//\\}"
|
||||
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_AFTER_MAIN_PATH="${pw_exe_file_win}" \
|
||||
PW_RUN_AFTER_SECOND_PATH="${run_after_exe_win}" \
|
||||
pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"
|
||||
try_remove_file "${run_after_bat}"
|
||||
else
|
||||
@@ -4291,7 +4499,9 @@ gui_edit_db () {
|
||||
KEY_EDIT_DB_GUI=$RANDOM
|
||||
DISABLE_EDIT_DB_LIST=""
|
||||
PW_EDIT_DB_LIST=(PW_MANGOHUD PW_MANGOHUD_USER_CONF PW_VKBASALT PW_VKBASALT_USER_CONF
|
||||
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK
|
||||
PW_VKBASALT_EFFECTS PW_VKBASALT_FFX_CAS PW_DGVOODOO2 PW_USE_SPECIALK PW_USE_RESHADE
|
||||
PW_RESHADE_EXE PW_RESHADE_API PW_RESHADE_FONT PW_RESHADE_SCREENSHOT_PATH
|
||||
PW_RESHADE_SCREENSHOT_KEY PW_RESHADE_OVERLAY_KEY
|
||||
PW_USE_ESYNC PW_USE_FSYNC PW_USE_NTSYNC PW_USE_RAY_TRACING PW_USE_NVAPI_AND_DLSS PW_USE_OPTISCALER
|
||||
PW_USE_LS_FRAME_GEN PW_WINE_FULLSCREEN_FSR PW_HIDE_NVIDIA_GPU PW_VIRTUAL_DESKTOP PW_USE_TERMINAL
|
||||
PW_USE_GAMEMODE PW_USE_INHIBIT_SLEEP PW_USE_D3D_EXTRAS PW_FIX_VIDEO_IN_GAME PW_REDUCE_PULSE_LATENCY
|
||||
@@ -4325,6 +4535,10 @@ gui_edit_db () {
|
||||
DISABLE_EDIT_DB_LIST+=" PW_VKBASALT PW_VKBASALT_USER_CONF PW_USE_RAY_TRACING PW_USE_OBS_VKCAPTURE PW_USE_GALLIUM_ZINK PW_USE_WINED3D_VULKAN"
|
||||
fi
|
||||
[[ "${PW_USE_SPECIALK}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_DGVOODOO2"
|
||||
[[ "${PW_USE_RESHADE}" == "1" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"
|
||||
if [[ "${PW_USE_OPTISCALER}" == "1" || "${PW_USE_SPECIALK}" == "1" ]] ; then
|
||||
DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"
|
||||
fi
|
||||
|
||||
[[ ! -e "/dev/ntsync" ]] && DISABLE_EDIT_DB_LIST+=" PW_USE_NTSYNC"
|
||||
|
||||
@@ -4391,7 +4605,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 ))"
|
||||
|
||||
@@ -4935,7 +5151,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 $?
|
||||
}
|
||||
@@ -4989,7 +5205,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 () {
|
||||
|
||||
@@ -16,6 +16,8 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
|
||||
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
|
||||
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
|
||||
export PW_SPECIALK_VER="SK_26_5_15"
|
||||
export PW_RESHADE_VER="6.8.0"
|
||||
export PW_D3D8TO9_VER="1.15.1"
|
||||
export PW_GALLIUM_NINE_VER="0.10"
|
||||
export DOTPFX_VER="48v9"
|
||||
export DEFPFX_VER="v1"
|
||||
@@ -33,6 +35,13 @@ check_variables PROTON_USE_XALIA "0"
|
||||
check_variables PW_USE_RUNTIME "1"
|
||||
check_variables PW_USE_D7VK "0"
|
||||
check_variables PW_USE_SPECIALK "0"
|
||||
check_variables PW_USE_RESHADE "0"
|
||||
check_variables PW_RESHADE_API "auto"
|
||||
check_variables PW_RESHADE_EXE ""
|
||||
check_variables PW_RESHADE_FONT ""
|
||||
check_variables PW_RESHADE_SCREENSHOT_PATH ""
|
||||
check_variables PW_RESHADE_SCREENSHOT_KEY "44,0,0,0"
|
||||
check_variables PW_RESHADE_OVERLAY_KEY "36,0,0,0"
|
||||
check_variables PW_USE_ESYNC "0"
|
||||
check_variables PW_USE_FSYNC "0"
|
||||
check_variables PW_USE_NTSYNC "0"
|
||||
@@ -62,7 +71,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###
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -2,12 +2,15 @@ import argparse
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
import signal
|
||||
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"
|
||||
|
||||
@@ -52,7 +55,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"],
|
||||
@@ -102,7 +105,7 @@ def get_portproton_tray_icon() -> QIcon:
|
||||
|
||||
|
||||
def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> None:
|
||||
"""Launch a game with a minimal tray stop action."""
|
||||
"""Launch a game with minimal tray game controls."""
|
||||
logger = get_logger(__name__)
|
||||
app.setQuitOnLastWindowClosed(False)
|
||||
tray_icon = QSystemTrayIcon(get_portproton_tray_icon(), app)
|
||||
@@ -132,6 +135,39 @@ def run_silent_tray(app: QApplication, start_sh: list[str], exe_path: str) -> No
|
||||
if process.poll() is not None:
|
||||
end_silent_run()
|
||||
|
||||
def update_pause_action() -> list[psutil.Process]:
|
||||
try:
|
||||
children = psutil.Process(process.pid).children(recursive=True)
|
||||
paused = bool(children) and all(
|
||||
child.status() == psutil.STATUS_STOPPED for child in children
|
||||
)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
children = []
|
||||
paused = False
|
||||
pause_action.setEnabled(bool(children))
|
||||
pause_action.setText(_("Resume Game") if paused else _("Pause Game"))
|
||||
return children
|
||||
|
||||
def toggle_pause() -> None:
|
||||
children = update_pause_action()
|
||||
if not children:
|
||||
return
|
||||
try:
|
||||
paused = all(child.status() == psutil.STATUS_STOPPED for child in children)
|
||||
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
|
||||
for child in children:
|
||||
try:
|
||||
child.send_signal(pause_signal)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
|
||||
logger.warning("Failed to change pause state for process %s: %s", child.pid, error)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied) as error:
|
||||
logger.warning("Failed to inspect game pause state: %s", error)
|
||||
update_pause_action()
|
||||
|
||||
pause_action = QAction(_("Pause Game"), tray_menu)
|
||||
pause_action.triggered.connect(toggle_pause)
|
||||
tray_menu.addAction(pause_action)
|
||||
tray_menu.aboutToShow.connect(update_pause_action)
|
||||
stop_action = QAction(_("Stop Game"), tray_menu)
|
||||
stop_action.triggered.connect(stop_game)
|
||||
tray_menu.addAction(stop_action)
|
||||
@@ -200,6 +236,7 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
os.environ["PORTPROTONQT_PYTHON"] = sys.executable
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
from portprotonqt.appimage_integration import integrate_appimage
|
||||
@@ -619,12 +656,12 @@ 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())
|
||||
@@ -723,7 +760,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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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
|
||||
@@ -40,6 +41,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 +57,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 +67,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 +91,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 +153,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 +169,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 +185,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 +201,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 +264,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 +316,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 +873,8 @@ class NavLabel(QLabel):
|
||||
super().mousePressEvent(event)
|
||||
|
||||
class AutoHideScrollArea(QScrollArea):
|
||||
viewportResized = Signal()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
theme,
|
||||
@@ -1011,7 +1046,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 +1063,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:
|
||||
|
||||
@@ -1023,6 +1023,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 +1201,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -274,7 +240,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 +256,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)
|
||||
@@ -352,7 +318,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()
|
||||
@@ -425,7 +391,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 +448,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 +638,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):
|
||||
|
||||
@@ -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,
|
||||
@@ -22,8 +33,10 @@ from PySide6.QtWidgets import (
|
||||
QTabWidget,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QPlainTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QTextEdit,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -39,8 +52,10 @@ 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_reshade import RESHADE_ENV_KEYS, ReShadeSettingsMixin
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSettingsMixin
|
||||
from portprotonqt.localization import _, format_setting_name_for_display
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -71,6 +86,8 @@ DEFAULT_WINE_SETTING_KEYS = {
|
||||
'PW_PREFIX_NAME': 'PW_DEFAULT_PREFIX_NAME',
|
||||
'PW_VULKAN_USE': 'PW_DEFAULT_VULKAN_USE',
|
||||
}
|
||||
|
||||
|
||||
def _normalize_prefix_directories(prefixes_dir):
|
||||
if not os.path.isdir(prefixes_dir):
|
||||
return
|
||||
@@ -135,6 +152,7 @@ class ExeSettingsDialog(
|
||||
DraggableDialog,
|
||||
MangoHudSettingsMixin,
|
||||
GamescopeSettingsMixin,
|
||||
ReShadeSettingsMixin,
|
||||
VkBasaltSettingsMixin,
|
||||
):
|
||||
"""Dialog for configuring executable-specific settings."""
|
||||
@@ -193,6 +211,7 @@ class ExeSettingsDialog(
|
||||
self.advanced_settings_by_key = {}
|
||||
self.init_mangohud_state()
|
||||
self.init_gamescope_state()
|
||||
self.init_reshade_state()
|
||||
self.init_vkbasalt_state()
|
||||
self.blocked_keys = set()
|
||||
self.numa_nodes = {}
|
||||
@@ -222,6 +241,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
|
||||
@@ -317,6 +337,8 @@ class ExeSettingsDialog(
|
||||
self.mangohud_tab_layout = QVBoxLayout(self.mangohud_tab)
|
||||
self.vkbasalt_tab = QWidget()
|
||||
self.vkbasalt_tab_layout = QVBoxLayout(self.vkbasalt_tab)
|
||||
self.reshade_tab = QWidget()
|
||||
self.reshade_tab_layout = QVBoxLayout(self.reshade_tab)
|
||||
self.gamescope_tab = QWidget()
|
||||
self.gamescope_tab_layout = QVBoxLayout(self.gamescope_tab)
|
||||
|
||||
@@ -324,9 +346,9 @@ class ExeSettingsDialog(
|
||||
self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
|
||||
self.tab_widget.addTab(self.mangohud_tab, "MangoHud")
|
||||
self.tab_widget.addTab(self.vkbasalt_tab, "vkBasalt")
|
||||
self.tab_widget.addTab(self.reshade_tab, "ReShade")
|
||||
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 +390,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()
|
||||
@@ -421,9 +441,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,12 +466,11 @@ 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()
|
||||
self.setup_vkbasalt_tab()
|
||||
self.setup_reshade_tab()
|
||||
if self.gamescope_available:
|
||||
self.setup_gamescope_tab()
|
||||
|
||||
@@ -543,6 +566,7 @@ class ExeSettingsDialog(
|
||||
or key in MANGOHUD_ENV_KEYS
|
||||
or key in GAMESCOPE_ENV_KEYS
|
||||
or key in VKBASALT_ENV_KEYS
|
||||
or key in RESHADE_ENV_KEYS
|
||||
or key in TOGGLE_BOOL_KEYS
|
||||
):
|
||||
if val.startswith('"') and val.endswith('"') and len(val) >= 2:
|
||||
@@ -577,6 +601,8 @@ class ExeSettingsDialog(
|
||||
self.current_settings[key] = ''
|
||||
for key in VKBASALT_ENV_KEYS:
|
||||
self.current_settings[key] = ''
|
||||
for key in RESHADE_ENV_KEYS:
|
||||
self.current_settings[key] = ''
|
||||
elif not self.user_conf:
|
||||
self.current_settings.setdefault('PW_MANGOHUD', '0')
|
||||
self.current_settings.setdefault('PW_VKBASALT', '0')
|
||||
@@ -605,6 +631,7 @@ class ExeSettingsDialog(
|
||||
self.populate_advanced()
|
||||
self.populate_mangohud()
|
||||
self.populate_vkbasalt()
|
||||
self.populate_reshade()
|
||||
if self.gamescope_available:
|
||||
self.populate_gamescope()
|
||||
self.populate_favorites(select_tab=True)
|
||||
@@ -769,6 +796,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 +810,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,6 +858,14 @@ 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)
|
||||
@@ -958,6 +993,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 +1001,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:
|
||||
@@ -1074,7 +1109,7 @@ class ExeSettingsDialog(
|
||||
|
||||
source_header = source_table.horizontalHeader()
|
||||
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 +1183,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)
|
||||
@@ -1227,6 +1262,10 @@ class ExeSettingsDialog(
|
||||
|
||||
def _on_combo_highlighted(self, row: int, combo: QComboBox) -> None:
|
||||
view = combo.view()
|
||||
view.installEventFilter(self)
|
||||
if not self.isVisible() or not combo.isVisible() or not view.isVisible():
|
||||
self.show_gamepad_tooltip(show=False)
|
||||
return
|
||||
index = view.model().index(row, 0)
|
||||
if not index.isValid():
|
||||
self.gamepad_tooltip.hide()
|
||||
@@ -1236,10 +1275,14 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip.hide()
|
||||
return
|
||||
fm = view.fontMetrics()
|
||||
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
|
||||
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)
|
||||
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)
|
||||
@@ -1258,6 +1301,43 @@ class ExeSettingsDialog(
|
||||
self.gamepad_tooltip.setVisible(True)
|
||||
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.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)
|
||||
|
||||
def _select_checkbox_row(self, widget: QCheckBox) -> bool:
|
||||
for (row, column), checkbox in self.value_widgets.items():
|
||||
if checkbox != widget:
|
||||
@@ -1291,7 +1371,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 +1383,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 +1395,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
|
||||
@@ -1324,6 +1404,7 @@ class ExeSettingsDialog(
|
||||
|
||||
self._filter_mangohud_settings(search_text)
|
||||
self._filter_vkbasalt_settings(search_text)
|
||||
self._filter_reshade_settings(search_text)
|
||||
self._filter_gamescope_settings(search_text)
|
||||
|
||||
def apply_changes(self):
|
||||
@@ -1398,6 +1479,7 @@ class ExeSettingsDialog(
|
||||
if self.gamescope_available:
|
||||
gamescope_changes = self._collect_gamescope_changes()
|
||||
vkbasalt_changes = self._collect_vkbasalt_changes()
|
||||
reshade_changes = self._collect_reshade_changes()
|
||||
if self.user_conf:
|
||||
if self.current_settings.get('PW_MANGOHUD') != '1':
|
||||
mangohud_changes = [
|
||||
@@ -1414,7 +1496,14 @@ class ExeSettingsDialog(
|
||||
change for change in gamescope_changes
|
||||
if change.startswith('PW_GAMESCOPE=')
|
||||
]
|
||||
specialized_changes = mangohud_changes + vkbasalt_changes + gamescope_changes
|
||||
if self.current_settings.get('PW_USE_RESHADE') != '1':
|
||||
reshade_changes = [
|
||||
change for change in reshade_changes
|
||||
if change.startswith('PW_USE_RESHADE=')
|
||||
]
|
||||
specialized_changes = (
|
||||
mangohud_changes + vkbasalt_changes + reshade_changes + gamescope_changes
|
||||
)
|
||||
if self.user_conf:
|
||||
defaults = ('', '0', '0.00', 'Home')
|
||||
specialized_changes = [
|
||||
@@ -1500,6 +1589,9 @@ class ExeSettingsDialog(
|
||||
self.close()
|
||||
|
||||
def closeEvent(self, event):
|
||||
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,6 +1599,108 @@ 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
|
||||
label = cast(QLabel | None, table.cellWidget(row, 0))
|
||||
if label is None:
|
||||
text = item.text()
|
||||
label = QLabel()
|
||||
label.setTextFormat(Qt.TextFormat.RichText)
|
||||
label.setWordWrap(True)
|
||||
label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
item.setData(Qt.ItemDataRole.AccessibleTextRole, text)
|
||||
item.setText("")
|
||||
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)
|
||||
label.setForegroundRole(QPalette.ColorRole.Text)
|
||||
text = item.data(Qt.ItemDataRole.AccessibleTextRole)
|
||||
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))
|
||||
table.setRowHeight(row, label.heightForWidth(width) + table.fontMetrics().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_reshade_key_button_event(obj, event):
|
||||
return True
|
||||
if isinstance(obj, QWidget) and self._handle_vkbasalt_key_button_event(obj, event):
|
||||
return True
|
||||
|
||||
@@ -1615,92 +1809,9 @@ 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, 'keyboard') and self.keyboard.isVisible():
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""ReShade settings support for executable settings dialog."""
|
||||
|
||||
import os
|
||||
from typing import Any, cast
|
||||
|
||||
from PySide6.QtCore import QEvent, Qt
|
||||
from PySide6.QtGui import QKeyEvent, QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
RESHADE_ENV_KEYS = [
|
||||
'PW_RESHADE_EXE',
|
||||
'PW_RESHADE_API',
|
||||
'PW_RESHADE_FONT',
|
||||
'PW_RESHADE_SCREENSHOT_PATH',
|
||||
'PW_RESHADE_SCREENSHOT_KEY',
|
||||
'PW_RESHADE_OVERLAY_KEY',
|
||||
]
|
||||
RESHADE_API_OPTIONS = {
|
||||
"Auto": "auto",
|
||||
"DXGI": "dxgi.dll",
|
||||
"DirectX 8/9": "d3d9.dll",
|
||||
"OpenGL": "opengl32.dll",
|
||||
"Vulkan": "vulkan",
|
||||
}
|
||||
RESHADE_SPECIAL_KEYS = {
|
||||
Qt.Key.Key_Cancel: 3,
|
||||
Qt.Key.Key_Backspace: 8,
|
||||
Qt.Key.Key_Tab: 9,
|
||||
Qt.Key.Key_Clear: 12,
|
||||
Qt.Key.Key_Return: 13,
|
||||
Qt.Key.Key_Enter: 13,
|
||||
Qt.Key.Key_Pause: 19,
|
||||
Qt.Key.Key_CapsLock: 20,
|
||||
Qt.Key.Key_Escape: 27,
|
||||
Qt.Key.Key_Space: 32,
|
||||
Qt.Key.Key_PageUp: 33,
|
||||
Qt.Key.Key_PageDown: 34,
|
||||
Qt.Key.Key_End: 35,
|
||||
Qt.Key.Key_Home: 36,
|
||||
Qt.Key.Key_Left: 37,
|
||||
Qt.Key.Key_Up: 38,
|
||||
Qt.Key.Key_Right: 39,
|
||||
Qt.Key.Key_Down: 40,
|
||||
Qt.Key.Key_Print: 44,
|
||||
Qt.Key.Key_Insert: 45,
|
||||
Qt.Key.Key_Delete: 46,
|
||||
Qt.Key.Key_Help: 47,
|
||||
Qt.Key.Key_Meta: 91,
|
||||
Qt.Key.Key_Menu: 93,
|
||||
Qt.Key.Key_Sleep: 95,
|
||||
Qt.Key.Key_NumLock: 144,
|
||||
Qt.Key.Key_ScrollLock: 145,
|
||||
Qt.Key.Key_Semicolon: 186,
|
||||
Qt.Key.Key_Equal: 187,
|
||||
Qt.Key.Key_Comma: 188,
|
||||
Qt.Key.Key_Minus: 189,
|
||||
Qt.Key.Key_Period: 190,
|
||||
Qt.Key.Key_Slash: 191,
|
||||
Qt.Key.Key_QuoteLeft: 192,
|
||||
Qt.Key.Key_BracketLeft: 219,
|
||||
Qt.Key.Key_Backslash: 220,
|
||||
Qt.Key.Key_BracketRight: 221,
|
||||
Qt.Key.Key_Apostrophe: 222,
|
||||
Qt.Key.Key_Back: 166,
|
||||
Qt.Key.Key_Forward: 167,
|
||||
Qt.Key.Key_Refresh: 168,
|
||||
Qt.Key.Key_Stop: 169,
|
||||
Qt.Key.Key_Search: 170,
|
||||
Qt.Key.Key_Favorites: 171,
|
||||
Qt.Key.Key_HomePage: 172,
|
||||
Qt.Key.Key_VolumeMute: 173,
|
||||
Qt.Key.Key_VolumeDown: 174,
|
||||
Qt.Key.Key_VolumeUp: 175,
|
||||
Qt.Key.Key_MediaNext: 176,
|
||||
Qt.Key.Key_MediaPrevious: 177,
|
||||
Qt.Key.Key_MediaStop: 178,
|
||||
Qt.Key.Key_MediaPlay: 179,
|
||||
Qt.Key.Key_LaunchMail: 180,
|
||||
Qt.Key.Key_LaunchMedia: 181,
|
||||
Qt.Key.Key_Launch0: 182,
|
||||
Qt.Key.Key_Launch1: 183,
|
||||
}
|
||||
RESHADE_KEYPAD_KEYS = {
|
||||
Qt.Key.Key_Asterisk: 106,
|
||||
Qt.Key.Key_Plus: 107,
|
||||
Qt.Key.Key_Comma: 108,
|
||||
Qt.Key.Key_Minus: 109,
|
||||
Qt.Key.Key_Period: 110,
|
||||
Qt.Key.Key_Slash: 111,
|
||||
}
|
||||
RESHADE_FIRST_FUNCTION_KEY = 112
|
||||
RESHADE_LAST_FUNCTION_KEY = 135
|
||||
RESHADE_FIRST_NUMPAD_KEY = 96
|
||||
|
||||
|
||||
class ReShadeSettingsMixin:
|
||||
"""Mixin with ReShade settings UI and serialization logic."""
|
||||
|
||||
theme: Any
|
||||
exe_path: str | None
|
||||
current_settings: dict[str, str]
|
||||
original_values: dict[str, str]
|
||||
reshade_tab: QWidget
|
||||
reshade_tab_layout: QVBoxLayout
|
||||
|
||||
def init_reshade_state(self) -> None:
|
||||
self.reshade_original_values = {}
|
||||
self.reshade_key_target = None
|
||||
|
||||
def setup_reshade_tab(self) -> None:
|
||||
"""Create ReShade tab widgets."""
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
scroll.setStyleSheet(self.theme.SCROLL_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
container = QWidget()
|
||||
layout = QVBoxLayout(container)
|
||||
layout.setSpacing(self.theme.exeSettingsGroupBoxBlockSpacing)
|
||||
|
||||
actions = QGroupBox(_("Actions"))
|
||||
actions.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
|
||||
actions_layout = QGridLayout(actions)
|
||||
self.reshade_enable_button = self._create_reshade_button("")
|
||||
self.reshade_enable_button.clicked.connect(self.toggle_reshade_enable)
|
||||
actions_layout.addWidget(self.reshade_enable_button, 0, 0)
|
||||
layout.addWidget(actions)
|
||||
|
||||
self._add_reshade_settings_group(layout)
|
||||
layout.addStretch()
|
||||
|
||||
scroll.setWidget(container)
|
||||
self.reshade_tab_layout.addWidget(scroll)
|
||||
|
||||
def _add_reshade_settings_group(self, parent_layout: QVBoxLayout) -> None:
|
||||
self.reshade_settings_group = QGroupBox(_("Settings"))
|
||||
self.reshade_settings_group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
|
||||
settings_layout = QGridLayout(self.reshade_settings_group)
|
||||
self.reshade_exe_edit = self._add_reshade_file_row(
|
||||
settings_layout, 0, "EXE", ".exe"
|
||||
)
|
||||
self.reshade_api_combo = CustomComboBox(theme=self.theme)
|
||||
self.reshade_api_combo.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
self.reshade_api_combo.view().window().setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
self.reshade_api_combo.setStyleSheet(
|
||||
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
|
||||
)
|
||||
self.reshade_api_combo.addItems(list(RESHADE_API_OPTIONS))
|
||||
settings_layout.addWidget(QLabel("API"), 1, 0)
|
||||
settings_layout.addWidget(self.reshade_api_combo, 1, 1)
|
||||
self.reshade_font_combo = CustomComboBox(theme=self.theme)
|
||||
self.reshade_font_combo.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
self.reshade_font_combo.view().window().setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
self.reshade_font_combo.setStyleSheet(
|
||||
self.theme.COMBOBOX_STYLE + self.theme.SCROLL_STYLE
|
||||
)
|
||||
self.reshade_font_combo.addItem(_("Default"), "")
|
||||
settings_layout.addWidget(QLabel(_("Font")), 2, 0)
|
||||
settings_layout.addWidget(self.reshade_font_combo, 2, 1)
|
||||
self.reshade_screenshot_edit = self._add_reshade_directory_row(
|
||||
settings_layout, 3, _("Screenshots")
|
||||
)
|
||||
self.reshade_screenshot_key_button = self._create_reshade_key_button(
|
||||
"screenshot"
|
||||
)
|
||||
settings_layout.addWidget(QLabel(_("Screenshot key")), 4, 0)
|
||||
settings_layout.addWidget(self.reshade_screenshot_key_button, 4, 1)
|
||||
self.reshade_overlay_key_button = self._create_reshade_key_button("overlay")
|
||||
settings_layout.addWidget(QLabel(_("Overlay key")), 5, 0)
|
||||
settings_layout.addWidget(self.reshade_overlay_key_button, 5, 1)
|
||||
parent_layout.addWidget(self.reshade_settings_group)
|
||||
self._load_reshade_fonts()
|
||||
|
||||
def _create_reshade_button(self, label: str) -> QPushButton:
|
||||
button = QPushButton(label)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
return button
|
||||
|
||||
def _add_reshade_file_row(
|
||||
self, layout: QGridLayout, row: int, label: str, file_filter: str
|
||||
) -> QLineEdit:
|
||||
line_edit = QLineEdit()
|
||||
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
button = AutoSizeButton(
|
||||
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
|
||||
)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False: self._select_reshade_file(line_edit, file_filter)
|
||||
)
|
||||
field_layout = QHBoxLayout()
|
||||
field_layout.addWidget(line_edit)
|
||||
field_layout.addWidget(button)
|
||||
layout.addWidget(QLabel(label), row, 0)
|
||||
layout.addLayout(field_layout, row, 1)
|
||||
return line_edit
|
||||
|
||||
def _select_reshade_file(self, line_edit: QLineEdit, file_filter: str) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
current_path = line_edit.text().strip()
|
||||
initial_path = os.path.dirname(current_path) if os.path.isfile(current_path) else None
|
||||
explorer = FileExplorer(
|
||||
self, theme=self.theme, file_filter=file_filter, initial_path=initial_path
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(line_edit.setText)
|
||||
explorer.exec()
|
||||
|
||||
def _add_reshade_directory_row(
|
||||
self, layout: QGridLayout, row: int, label: str
|
||||
) -> QLineEdit:
|
||||
line_edit = QLineEdit()
|
||||
line_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
button = AutoSizeButton(
|
||||
_("Browse…"), icon=ThemeManager().get_icon("folder", as_path=True)
|
||||
)
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.clicked.connect(
|
||||
lambda _checked=False: self._select_reshade_directory(line_edit)
|
||||
)
|
||||
field_layout = QHBoxLayout()
|
||||
field_layout.addWidget(line_edit)
|
||||
field_layout.addWidget(button)
|
||||
layout.addWidget(QLabel(label), row, 0)
|
||||
layout.addLayout(field_layout, row, 1)
|
||||
return line_edit
|
||||
|
||||
def _select_reshade_directory(self, line_edit: QLineEdit) -> None:
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
|
||||
initial_path = line_edit.text().strip() or os.path.dirname(self.exe_path or "")
|
||||
explorer = FileExplorer(
|
||||
self, theme=self.theme, initial_path=initial_path, directory_only=True
|
||||
)
|
||||
explorer.file_signal.file_selected.connect(line_edit.setText)
|
||||
explorer.exec()
|
||||
|
||||
def _create_reshade_key_button(self, target: str) -> QPushButton:
|
||||
button = self._create_reshade_button("")
|
||||
button.clicked.connect(lambda _checked=False: self._capture_reshade_key(target))
|
||||
button.installEventFilter(cast(QWidget, self))
|
||||
return button
|
||||
|
||||
def _capture_reshade_key(self, target: str) -> None:
|
||||
self.reshade_key_target = target
|
||||
button = self._get_reshade_key_button(target)
|
||||
button.setText(_("Press a button to choose"))
|
||||
button.grabKeyboard()
|
||||
|
||||
def _get_reshade_key_button(self, target: str) -> QPushButton:
|
||||
if target == "screenshot":
|
||||
return self.reshade_screenshot_key_button
|
||||
return self.reshade_overlay_key_button
|
||||
|
||||
def _handle_reshade_key_button_event(self, obj: QWidget, event: QEvent) -> bool:
|
||||
target = self.reshade_key_target
|
||||
if target is None or obj is not self._get_reshade_key_button(target):
|
||||
return False
|
||||
if event.type() != QEvent.Type.KeyPress:
|
||||
return False
|
||||
key_event = cast(QKeyEvent, event)
|
||||
key_value = self._reshade_virtual_key(
|
||||
key_event.key(), key_event.modifiers()
|
||||
)
|
||||
if key_value is None:
|
||||
return True
|
||||
modifiers = key_event.modifiers()
|
||||
value = ",".join(str(item) for item in (
|
||||
key_value,
|
||||
int(bool(modifiers & Qt.KeyboardModifier.ControlModifier)),
|
||||
int(bool(modifiers & Qt.KeyboardModifier.ShiftModifier)),
|
||||
int(bool(modifiers & Qt.KeyboardModifier.AltModifier)),
|
||||
))
|
||||
self.current_settings[f'PW_RESHADE_{target.upper()}_KEY'] = value
|
||||
button = self._get_reshade_key_button(target)
|
||||
button.setText(QKeySequence(key_event.keyCombination()).toString())
|
||||
button.releaseKeyboard()
|
||||
self.reshade_key_target = None
|
||||
return True
|
||||
|
||||
def _reshade_virtual_key(
|
||||
self, key: int, modifiers: Qt.KeyboardModifier
|
||||
) -> int | None:
|
||||
is_keypad = bool(modifiers & Qt.KeyboardModifier.KeypadModifier)
|
||||
if is_keypad and Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
|
||||
return RESHADE_FIRST_NUMPAD_KEY + key - Qt.Key.Key_0.value
|
||||
if is_keypad:
|
||||
try:
|
||||
keypad_key = RESHADE_KEYPAD_KEYS.get(Qt.Key(key))
|
||||
except ValueError:
|
||||
keypad_key = None
|
||||
if keypad_key is not None:
|
||||
return keypad_key
|
||||
if Qt.Key.Key_0.value <= key <= Qt.Key.Key_9.value:
|
||||
return key
|
||||
if Qt.Key.Key_A.value <= key <= Qt.Key.Key_Z.value:
|
||||
return key
|
||||
if Qt.Key.Key_F1.value <= key <= Qt.Key.Key_F24.value:
|
||||
return RESHADE_FIRST_FUNCTION_KEY + key - Qt.Key.Key_F1.value
|
||||
try:
|
||||
return RESHADE_SPECIAL_KEYS.get(Qt.Key(key))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _format_reshade_key(self, value: str) -> str:
|
||||
try:
|
||||
key, control, shift, alt = (int(item) for item in value.split(","))
|
||||
except ValueError:
|
||||
return value
|
||||
qt_key = next(
|
||||
(item for item, code in RESHADE_SPECIAL_KEYS.items() if code == key), None
|
||||
)
|
||||
if qt_key is not None:
|
||||
name = QKeySequence(qt_key).toString()
|
||||
elif RESHADE_FIRST_FUNCTION_KEY <= key <= RESHADE_LAST_FUNCTION_KEY:
|
||||
name = f"F{key - RESHADE_FIRST_FUNCTION_KEY + 1}"
|
||||
else:
|
||||
name = chr(key)
|
||||
modifiers = [
|
||||
label for active, label in (
|
||||
(control, "Ctrl"), (shift, "Shift"), (alt, "Alt")
|
||||
) if active
|
||||
]
|
||||
return "+".join([*modifiers, name])
|
||||
|
||||
def _load_reshade_fonts(self) -> None:
|
||||
selected_family = self.current_settings.get('PW_RESHADE_FONT', '')
|
||||
font_options = cast(Any, self)._get_mangohud_font_options()
|
||||
families = {name for name, _path in font_options}
|
||||
self.reshade_font_combo.clear()
|
||||
self.reshade_font_combo.addItem(_("Default"), "")
|
||||
for family in sorted(families, key=str.casefold):
|
||||
self.reshade_font_combo.addItem(family, family)
|
||||
self._select_reshade_font(selected_family)
|
||||
|
||||
def _select_reshade_font(self, family: str) -> None:
|
||||
for index in range(self.reshade_font_combo.count()):
|
||||
if self.reshade_font_combo.itemData(index) == family:
|
||||
self.reshade_font_combo.setCurrentIndex(index)
|
||||
return
|
||||
if family:
|
||||
self.reshade_font_combo.addItem(family, family)
|
||||
self.reshade_font_combo.setCurrentIndex(self.reshade_font_combo.count() - 1)
|
||||
|
||||
def populate_reshade(self) -> None:
|
||||
"""Populate ReShade tab from current settings."""
|
||||
reshade_exe = self.current_settings.get('PW_RESHADE_EXE', '') or self.exe_path or ''
|
||||
self.reshade_exe_edit.setText(reshade_exe)
|
||||
api_value = self.current_settings.get('PW_RESHADE_API', 'auto')
|
||||
api_label = next(
|
||||
(label for label, value in RESHADE_API_OPTIONS.items() if value == api_value),
|
||||
"Auto",
|
||||
)
|
||||
self.reshade_api_combo.setCurrentText(api_label)
|
||||
font_family = self.current_settings.get('PW_RESHADE_FONT', '')
|
||||
self._select_reshade_font(font_family)
|
||||
screenshot_path = self.current_settings.get('PW_RESHADE_SCREENSHOT_PATH', '')
|
||||
screenshot_key = self.current_settings.get(
|
||||
'PW_RESHADE_SCREENSHOT_KEY', '44,0,0,0'
|
||||
)
|
||||
overlay_key = self.current_settings.get(
|
||||
'PW_RESHADE_OVERLAY_KEY', '36,0,0,0'
|
||||
)
|
||||
self.reshade_screenshot_edit.setText(screenshot_path)
|
||||
self.reshade_screenshot_key_button.setText(
|
||||
self._format_reshade_key(screenshot_key)
|
||||
)
|
||||
self.reshade_overlay_key_button.setText(self._format_reshade_key(overlay_key))
|
||||
self.current_settings['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
|
||||
self.current_settings['PW_RESHADE_OVERLAY_KEY'] = overlay_key
|
||||
self.reshade_original_values = {
|
||||
key: self.current_settings.get(key, '') for key in RESHADE_ENV_KEYS
|
||||
}
|
||||
self.reshade_original_values['PW_RESHADE_EXE'] = reshade_exe
|
||||
self.reshade_original_values['PW_RESHADE_SCREENSHOT_KEY'] = screenshot_key
|
||||
self.reshade_original_values['PW_RESHADE_OVERLAY_KEY'] = overlay_key
|
||||
self._update_reshade_widgets()
|
||||
|
||||
def toggle_reshade_enable(self) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.current_settings['PW_USE_RESHADE'] = '0' if enabled else '1'
|
||||
self._update_reshade_widgets()
|
||||
|
||||
def _update_reshade_widgets(self) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.reshade_enable_button.setText(
|
||||
_("Disable {0}").format("ReShade")
|
||||
if enabled else _("Enable {0}").format("ReShade")
|
||||
)
|
||||
self.reshade_enable_button.setStyleSheet(
|
||||
self.theme.ACTION_BUTTON_ACTIVE_STYLE if enabled else self.theme.ACTION_BUTTON_STYLE
|
||||
)
|
||||
self.reshade_settings_group.setVisible(enabled)
|
||||
|
||||
def _collect_reshade_changes(self) -> list[str]:
|
||||
changes = []
|
||||
enabled = '1' if self.current_settings.get('PW_USE_RESHADE') == '1' else '0'
|
||||
original_enabled = '1' if self.original_values.get('PW_USE_RESHADE') == '1' else '0'
|
||||
if enabled != original_enabled:
|
||||
changes.append(f"PW_USE_RESHADE={enabled}")
|
||||
values = {
|
||||
'PW_RESHADE_EXE': self.reshade_exe_edit.text().strip(),
|
||||
'PW_RESHADE_API': RESHADE_API_OPTIONS[self.reshade_api_combo.currentText()],
|
||||
'PW_RESHADE_FONT': str(self.reshade_font_combo.currentData() or ''),
|
||||
'PW_RESHADE_SCREENSHOT_PATH': self.reshade_screenshot_edit.text().strip(),
|
||||
'PW_RESHADE_SCREENSHOT_KEY': self.current_settings['PW_RESHADE_SCREENSHOT_KEY'],
|
||||
'PW_RESHADE_OVERLAY_KEY': self.current_settings['PW_RESHADE_OVERLAY_KEY'],
|
||||
}
|
||||
for key, value in values.items():
|
||||
if value != self.reshade_original_values.get(key, ''):
|
||||
changes.append(f"{key}={value}")
|
||||
return changes
|
||||
|
||||
def _filter_reshade_settings(self, search_text: str) -> None:
|
||||
enabled = self.current_settings.get('PW_USE_RESHADE') == '1'
|
||||
self.reshade_settings_group.setVisible(
|
||||
enabled and (
|
||||
not search_text
|
||||
or search_text in "exe api font screenshot overlay"
|
||||
or search_text in _("Settings").lower()
|
||||
)
|
||||
)
|
||||
@@ -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():
|
||||
|
||||
@@ -277,7 +277,15 @@ 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)
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.raise_()
|
||||
@@ -313,7 +321,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 +330,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 +349,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
|
||||
@@ -454,6 +464,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 +584,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 +738,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,
|
||||
@@ -1006,23 +1017,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):
|
||||
@@ -1211,6 +1216,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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -529,12 +529,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:
|
||||
|
||||
@@ -34,6 +34,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -448,9 +448,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 +516,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
|
||||
@@ -269,6 +270,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 +319,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
|
||||
|
||||
@@ -341,9 +348,14 @@ class SettingsInputMixin(InputMixin):
|
||||
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)
|
||||
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)
|
||||
cell_widget.setCurrentIndex(new_idx)
|
||||
return # Consume event
|
||||
|
||||
# 3. Standard Table Navigation
|
||||
|
||||
@@ -1207,6 +1207,9 @@ msgstr ""
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
@@ -2144,7 +2147,7 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgid "Show only installed games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
@@ -2165,7 +2168,7 @@ msgstr ""
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Table"
|
||||
msgid "Vertical"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
@@ -2174,13 +2177,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
|
||||
@@ -2219,9 +2222,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 ""
|
||||
|
||||
@@ -2255,6 +2264,9 @@ msgstr ""
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
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-12 19:09+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.10.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -1218,15 +1218,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 +1251,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,9 +1263,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"
|
||||
msgstr "Datei konnte nicht geöffnet werden:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
@@ -1292,6 +1289,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 +1579,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"
|
||||
@@ -2248,8 +2245,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 +2257,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 ""
|
||||
@@ -2332,9 +2322,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"
|
||||
|
||||
@@ -2360,7 +2356,7 @@ msgid "Show compatibility report after game crash"
|
||||
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
|
||||
|
||||
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 +2364,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"
|
||||
|
||||
|
||||
@@ -1310,6 +1310,10 @@ msgstr ""
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Versión de WINE/Proton"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
@@ -2254,8 +2258,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 +2281,8 @@ msgid "List"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Estable"
|
||||
msgid "Vertical"
|
||||
msgstr "parcial"
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr ""
|
||||
@@ -2286,13 +2290,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
|
||||
@@ -2333,9 +2337,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 ""
|
||||
|
||||
@@ -2369,6 +2379,9 @@ msgstr ""
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
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-11 01:04+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"
|
||||
@@ -1203,6 +1203,9 @@ msgstr ""
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr ""
|
||||
|
||||
@@ -2140,7 +2143,7 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgid "Show only installed games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
@@ -2161,7 +2164,7 @@ msgstr ""
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Table"
|
||||
msgid "Vertical"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
@@ -2170,13 +2173,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
|
||||
@@ -2215,9 +2218,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 ""
|
||||
|
||||
@@ -2251,6 +2260,9 @@ msgstr ""
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
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-09-24 06:40+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,7 +18,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n > 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -258,17 +258,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"
|
||||
@@ -840,9 +839,8 @@ msgstr "min."
|
||||
msgid "sec."
|
||||
msgstr "s."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Pause Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
msgstr "Pausar jogo"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Sair"
|
||||
@@ -856,13 +854,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"
|
||||
@@ -1296,6 +1292,10 @@ msgstr "Padrão"
|
||||
msgid "disabled"
|
||||
msgstr "desativado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Digite o nome do prefixo"
|
||||
|
||||
@@ -1581,17 +1581,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"
|
||||
@@ -2250,8 +2247,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"
|
||||
@@ -2273,28 +2270,23 @@ msgstr ""
|
||||
msgid "List"
|
||||
msgstr "Claro"
|
||||
|
||||
#, 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 ""
|
||||
@@ -2334,9 +2326,16 @@ msgstr "Tipo de Controle:"
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Iniciar Aplicativo em Tela Cheia"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Hide Cursor in Fullscreen"
|
||||
msgstr "Forçar janelas em tela cheia"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Tela Cheia Automática ao Conectar o Controle"
|
||||
|
||||
msgid "Disable Boot Animation"
|
||||
msgstr ""
|
||||
|
||||
msgid "Minimize to tray on close"
|
||||
msgstr "Minimizar para a bandeja ao fechar"
|
||||
|
||||
@@ -2370,6 +2369,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 ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "Baixar WINE/Proton para a Steam"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ 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"
|
||||
"PO-Revision-Date: 2026-10-09 11:06+0000\n"
|
||||
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
@@ -1217,15 +1217,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 +1250,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,9 +1262,8 @@ msgstr "Файл PPDB не существует по адресу: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Не удалось открыть файл PPDB:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Не удалось открыть файл PPDB:\n"
|
||||
msgstr "Не удалось открыть файл:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
@@ -1289,6 +1286,9 @@ msgstr "Стандартный"
|
||||
msgid "disabled"
|
||||
msgstr "отключено"
|
||||
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "Скачать Wine/Proton..."
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "Введите имя префикса"
|
||||
|
||||
@@ -2237,8 +2237,8 @@ msgstr "Все"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Фильтр:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Только установленные"
|
||||
msgid "Show only installed games"
|
||||
msgstr "Показывать только установленные игры"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Подробный"
|
||||
@@ -2249,39 +2249,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 ""
|
||||
@@ -2321,9 +2314,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 "Сворачивать в область уведомления при закрытии"
|
||||
|
||||
@@ -2349,7 +2348,7 @@ msgid "Show compatibility report after game crash"
|
||||
msgstr "Показывать отчёт о совместимости после сбоя игры"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
msgstr "Отключить проверку зависимостей ALT Linux i586"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Отключить скачивание контейнера"
|
||||
@@ -2357,6 +2356,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"
|
||||
|
||||
|
||||
@@ -1255,6 +1255,10 @@ msgstr "默认"
|
||||
msgid "disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download Wine/Proton..."
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
|
||||
msgid "Enter prefix name"
|
||||
msgstr "输入前缀名称"
|
||||
|
||||
@@ -2214,9 +2218,8 @@ msgstr "全部"
|
||||
msgid "Display Filter:"
|
||||
msgstr "显示筛选:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Only Installed"
|
||||
msgstr "已安装"
|
||||
msgid "Show only installed games"
|
||||
msgstr "仅显示已安装的游戏"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "详细"
|
||||
@@ -2239,8 +2242,8 @@ msgid "List"
|
||||
msgstr "浅色"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "稳定"
|
||||
msgid "Vertical"
|
||||
msgstr "部分"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
@@ -2251,15 +2254,16 @@ msgid "Horizontal Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgid "Horizontal endless Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
#, fuzzy
|
||||
msgid "Horizontal endless Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "游戏手柄类型:"
|
||||
msgid "Layout"
|
||||
msgstr "注销"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
@@ -2300,9 +2304,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 "关闭时最小化到系统托盘"
|
||||
|
||||
@@ -2336,6 +2347,9 @@ msgstr "禁用运行时下载"
|
||||
msgid "Add to Steam compatibility tools"
|
||||
msgstr "添加到 Steam 兼容工具"
|
||||
|
||||
msgid "Disable Steam Proton scanning"
|
||||
msgstr ""
|
||||
|
||||
msgid "Download WINE/Proton to Steam"
|
||||
msgstr "将 WINE/Proton 下载到 Steam"
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ def get_system_locale():
|
||||
|
||||
if ui_config.get_force_english():
|
||||
return 'en'
|
||||
loc = locale.getdefaultlocale()[0]
|
||||
loc = locale.getlocale()[0]
|
||||
return loc if loc else 'en'
|
||||
|
||||
def get_steam_language():
|
||||
|
||||
@@ -93,6 +93,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")
|
||||
@@ -134,19 +135,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 +249,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 +347,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 +416,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 +557,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 +974,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 +990,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 +1014,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 = []
|
||||
|
||||
@@ -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',
|
||||
@@ -117,6 +118,7 @@ install_data(
|
||||
'dialogs/settings_dialog.py',
|
||||
'dialogs/settings_mangohud.py',
|
||||
'dialogs/settings_gamescope.py',
|
||||
'dialogs/settings_reshade.py',
|
||||
'dialogs/settings_vkbasalt.py',
|
||||
'dialogs/proton_manager.py',
|
||||
'dialogs/prefix_backup.py',
|
||||
|
||||
@@ -149,6 +149,7 @@ def get_toggle_settings():
|
||||
'PW_USE_SPECIALK': _("Enable SpecialK (injection library for fixing graphics, latency and more)"),
|
||||
# Upscalers and Frame Generation
|
||||
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
|
||||
'PW_USE_RESHADE': _("Enable ReShade post-processing"),
|
||||
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
|
||||
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
|
||||
# Ray Tracing
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -299,6 +404,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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -476,6 +476,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 +602,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)
|
||||
|
||||
|
||||
@@ -78,7 +78,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)
|
||||
@@ -101,10 +101,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
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)
|
||||
@@ -356,15 +353,15 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.gamesDisplayCombo.currentIndexChanged.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 +379,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 +409,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
|
||||
|
||||
@@ -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)
|
||||
@@ -503,6 +570,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 +821,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 +850,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:
|
||||
@@ -965,9 +1059,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 +1096,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())
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ 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",
|
||||
|
||||
|
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 |
|
Before Width: | Height: | Size: 109 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 24 KiB |
@@ -799,6 +799,27 @@ GAME_CARD_NAME_LABEL_STYLE = f"""
|
||||
}}
|
||||
"""
|
||||
|
||||
GAME_CARD_VERTICAL_NAME_STYLE = GAME_CARD_NAME_LABEL_STYLE
|
||||
|
||||
GAME_CARD_COLUMN_LABEL_STYLE = f"""
|
||||
QLabel {{
|
||||
color: {color_text_dark};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_value};
|
||||
background: {color_transparent};
|
||||
}}
|
||||
"""
|
||||
|
||||
LIBRARY_HEADER_LABEL_STYLE = f"""
|
||||
QLabel {{
|
||||
color: {color_text_dark};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_small};
|
||||
font-weight: bold;
|
||||
background: {color_transparent};
|
||||
}}
|
||||
"""
|
||||
|
||||
# PROTONDB BADGE STYLES ON CARD
|
||||
def get_protondb_badge_style(tier):
|
||||
tier = tier.lower()
|
||||
@@ -1188,6 +1209,7 @@ SCROLL_STYLE = f"""
|
||||
background: {color_scrollbar_bg};
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
min-height: {scrollbar_handle_min_height};
|
||||
background: {color_scrollbar_handle};
|
||||
border: {border_none};
|
||||
border-radius: {border_radius_scroll};
|
||||
@@ -1213,6 +1235,7 @@ SCROLL_STYLE = f"""
|
||||
background: {color_scrollbar_bg};
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
min-width: {scrollbar_handle_min_height};
|
||||
background: {color_scrollbar_handle};
|
||||
border: {border_none};
|
||||
border-radius: {border_radius_scroll};
|
||||
|
||||
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -735,6 +735,7 @@ SCROLL_STYLE = f"""
|
||||
background: {color_scrollbar_bg};
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
min-height: {scrollbar_handle_min_height};
|
||||
background: {color_scrollbar_handle};
|
||||
border: {border_none};
|
||||
border-radius: {border_radius_scroll};
|
||||
@@ -760,6 +761,7 @@ SCROLL_STYLE = f"""
|
||||
background: {color_scrollbar_bg};
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
min-width: {scrollbar_handle_min_height};
|
||||
background: {color_scrollbar_handle};
|
||||
border: {border_none};
|
||||
border-radius: {border_radius_scroll};
|
||||
|
||||
@@ -5,6 +5,10 @@ theme_manager = ThemeManager()
|
||||
current_theme_name = ui_config.get_theme()
|
||||
|
||||
# === Layout Constants ===
|
||||
color_boot_animation = "#000000"
|
||||
bootAnimationTimeoutMs = 30000
|
||||
cursorHideTimeoutMs = 5000
|
||||
startupLibraryLoadDelayMs = 500
|
||||
autoSizeButtonPadding = (10, 20)
|
||||
favoriteLabelSize = 48, 48
|
||||
favoriteLabelIconSize = 32
|
||||
@@ -40,6 +44,7 @@ GAME_CARD_LIST = {
|
||||
"cover_radius": 8,
|
||||
"border_radius": 18,
|
||||
}
|
||||
autoinstallColumnStretches = (4, 2, 2)
|
||||
GAME_CARD_GRID = {
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
@@ -61,7 +66,7 @@ GAME_CARD_HORIZONTAL = {
|
||||
GAME_CARD_VERTICAL = {
|
||||
**GAME_CARD_LIST,
|
||||
"layout_margins": (0, 0, 0, 0),
|
||||
"layout_spacing": 0,
|
||||
"layout_spacing": 4,
|
||||
"header_height": 40,
|
||||
"header_margins": (4, 0, 4, 0),
|
||||
"header_spacing": 12,
|
||||
@@ -162,6 +167,7 @@ color_separator = "#7f7f7f"
|
||||
color_scrollbar_bg = "rgba(20, 20, 20, 0.30)"
|
||||
color_scrollbar_handle = "#bebebe"
|
||||
border_radius_scroll = "5px"
|
||||
scrollbar_handle_min_height = "30px"
|
||||
|
||||
# === Slider ===
|
||||
color_slider_handle = "#bebebe"
|
||||
@@ -267,6 +273,7 @@ DETAIL_COMPACT = {
|
||||
"width": 1280,
|
||||
"cover_image_size": 108,
|
||||
"content_spacing": 15,
|
||||
"buttons_bottom_spacing": 20,
|
||||
"header_spacing": 16,
|
||||
"title_margins": (0, 0, 0, 0),
|
||||
"description_margins": (3, 3, 3, 3),
|
||||
@@ -356,3 +363,6 @@ GAME_CARD_ANIMATION = {
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
"detail_page_easing_curve_exit": "InCubic",
|
||||
}
|
||||
|
||||
WINETRICKS_CHECK_COLUMN_WIDTH = 80
|
||||
WINETRICKS_ROW_HEIGHT = 36
|
||||
@@ -1,5 +1,20 @@
|
||||
from .constants import *
|
||||
|
||||
SETTINGS_DESCRIPTION_HTML = (
|
||||
f'<span style="color: {color_disabled_text}; font-size: {font_size_value};">'
|
||||
'{description}</span>'
|
||||
)
|
||||
|
||||
SETTINGS_SELECTED_TEXT_HTML = (
|
||||
f'<span style="color: {color_text_accent_hover};">'
|
||||
'{text}</span>'
|
||||
)
|
||||
|
||||
SETTINGS_SELECTED_DESCRIPTION_HTML = (
|
||||
f'<span style="color: {color_text_accent_hover}; font-size: {font_size_value};">'
|
||||
'{description}</span>'
|
||||
)
|
||||
|
||||
SETTINGS_FRAME_STYLE = f"""
|
||||
QFrame {{
|
||||
background: {color_bg};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[project]
|
||||
name = "portprotonqt"
|
||||
version = "1.4.1"
|
||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||
version = "1.5.0"
|
||||
description = "Game library manager and launcher for Linux"
|
||||
requires-python = ">=3.11"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0" }
|
||||
@@ -12,7 +12,7 @@ authors = [
|
||||
]
|
||||
keywords = ["portproton", "wine", "game", "steam"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Development Status :: 5 - Production",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
@@ -35,7 +35,7 @@ dependencies = [
|
||||
"tqdm>=4.70.1",
|
||||
"vdf>=3.4",
|
||||
"websocket-client>=1.9.2",
|
||||
"dbus-fast>=5.0.22",
|
||||
"dbus-fast>=5.2.0",
|
||||
"pefile>=2024.8.26",
|
||||
]
|
||||
|
||||
@@ -53,7 +53,7 @@ dev = [
|
||||
"pre-commit>=4.6.2",
|
||||
"pyright>=1.1.414",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
"pytest-mock>=3.16.0",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for auto-install installed status matching."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
import portprotonqt.detail_pages as detail_pages
|
||||
from portprotonqt.detail_pages import DetailPageManager
|
||||
@@ -135,3 +135,99 @@ def test_open_installed_autoinstall_card_switches_to_library(monkeypatch: Any) -
|
||||
assert manager.main_window.switched_index == 0
|
||||
assert manager._return_to_tab_index == 0
|
||||
assert opened_data is not None
|
||||
|
||||
|
||||
def test_list_status_worker_uses_cached_script_and_checks_installation(
|
||||
tmp_path: Path, monkeypatch: Any,
|
||||
) -> None:
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from portprotonqt.tabs.autoinstall_tab import AutoInstallStatusWorker
|
||||
|
||||
script = tmp_path / "game.ppai"
|
||||
script.write_text('PW_EXE_FILE="$WINEPREFIX/drive_c/Game/game.exe"\n')
|
||||
location = tmp_path / "portproton"
|
||||
location.mkdir()
|
||||
exe = tmp_path / "prefix" / "drive_c" / "Game" / "game.exe"
|
||||
exe.parent.mkdir(parents=True)
|
||||
exe.touch()
|
||||
desktop = location / "game.desktop"
|
||||
_write_desktop_entry(desktop, "Game", exe)
|
||||
worker = AutoInstallStatusWorker()
|
||||
worker.api = SimpleNamespace(
|
||||
_get_autoinstall_script_path=lambda _: str(script),
|
||||
download_autoinstall_script=MagicMock(),
|
||||
)
|
||||
worker.games = [("Game", "", "", "", "", "autoinstall:https://example/game.ppai",
|
||||
"", "", "", "", 0, 0, "autoinstall", "game_1")]
|
||||
worker.portproton_location = str(location)
|
||||
results: list[tuple[str, bool | None]] = []
|
||||
worker.status_ready.connect(lambda name, status: results.append((name, status)))
|
||||
worker.run()
|
||||
desktop.unlink()
|
||||
worker.run()
|
||||
assert results == [("game_1", True), ("game_1", False)]
|
||||
worker.api.download_autoinstall_script.assert_not_called()
|
||||
monkeypatch.setattr(worker, "isInterruptionRequested", lambda: True)
|
||||
worker.run()
|
||||
assert len(results) == 2
|
||||
|
||||
|
||||
def test_list_status_callback_ignores_replaced_cards(monkeypatch: Any) -> None:
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
import portprotonqt.tabs.autoinstall_tab as tab
|
||||
|
||||
worker = MagicMock()
|
||||
monkeypatch.setattr(tab, "AutoInstallStatusWorker", lambda _: worker)
|
||||
label = MagicMock()
|
||||
card = SimpleNamespace(installStatusLabel=label)
|
||||
window = SimpleNamespace(autoInstallGameCards={"game_1": card},
|
||||
autoInstallStatusLabels={"game_1": label},
|
||||
portproton_api=MagicMock(), portproton_location=None)
|
||||
tab.MainWindowAutoInstallTabMixin._load_autoinstall_statuses(cast(Any, window), [])
|
||||
callback = worker.status_ready.connect.call_args.args[0]
|
||||
callback("game_1", True)
|
||||
assert label.setText.call_args.args == ("✓",)
|
||||
callback("game_1", False)
|
||||
assert label.setText.call_args.args == ("—",)
|
||||
callback("game_1", None)
|
||||
assert label.setText.call_args.args == ("?",)
|
||||
window.autoInstallGameCards.clear()
|
||||
label.reset_mock()
|
||||
callback("game_1", True)
|
||||
label.setText.assert_not_called()
|
||||
assert window.autoInstallStatusWorkers == [worker]
|
||||
|
||||
|
||||
def test_rockstar_status_rejects_unrelated_launcher_and_opens_matching_entry(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
script = tmp_path / "Rockstar.ppai"
|
||||
script.write_text(
|
||||
'PW_AUTOINSTALL_EXE="${PORT_WINE_TMP_PATH}/Rockstar-Games-Launcher.exe"\n'
|
||||
'PW_EXE_FILE="$(find "$WINEPREFIX/drive_c/" -type f -name "Launcher.exe" '
|
||||
'| grep "Rockstar Games/Launcher/Launcher.exe")"\n'
|
||||
'pw_create_unique_exe "rockstar_launcher_pp"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
location = tmp_path / "portproton"
|
||||
location.mkdir()
|
||||
unrelated = tmp_path / "Super Cow" / "game" / "launcher.exe"
|
||||
unrelated.parent.mkdir(parents=True)
|
||||
unrelated.touch()
|
||||
_write_desktop_entry(location / "Cow.desktop", "Cow", unrelated)
|
||||
|
||||
assert not _check_autoinstall_installed_sync(str(script), "Rockstar", str(location))
|
||||
assert find_autoinstall_entry_path(str(script), str(location)) is None
|
||||
|
||||
launcher = tmp_path / "prefix" / "drive_c" / "Program Files" / "Rockstar Games" / "Launcher" / "Launcher.exe"
|
||||
launcher.parent.mkdir(parents=True)
|
||||
launcher.touch()
|
||||
unique_exe = launcher.with_name("rockstar_launcher_pp.exe")
|
||||
unique_exe.symlink_to(launcher.name)
|
||||
desktop = location / "Rockstar.desktop"
|
||||
_write_desktop_entry(desktop, "Rockstar", unique_exe)
|
||||
|
||||
assert _check_autoinstall_installed_sync(str(script), "Rockstar", str(location))
|
||||
assert find_autoinstall_entry_path(str(script), str(location)) == str(desktop)
|
||||
@@ -5,6 +5,7 @@ import runpy
|
||||
import pytest
|
||||
|
||||
from portprotonqt.config.base import BaseConfig
|
||||
from portprotonqt.config.display import DisplayConfig
|
||||
from portprotonqt.config.game import GameConfig
|
||||
from portprotonqt.config.ui import UIConfig
|
||||
import portprotonqt.localization as localization
|
||||
@@ -19,6 +20,26 @@ def isolate_config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
return config_file
|
||||
|
||||
|
||||
def test_hide_cursor_setting_persists(tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "display.conf"
|
||||
config = DisplayConfig(config_file=config_file)
|
||||
assert config.get_hide_cursor() is True
|
||||
config.set_hide_cursor(False)
|
||||
assert DisplayConfig(config_file=config_file).get_hide_cursor() is False
|
||||
config.set_hide_cursor(True)
|
||||
assert DisplayConfig(config_file=config_file).get_hide_cursor() is True
|
||||
|
||||
|
||||
def test_boot_animation_setting_persists(tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "display.conf"
|
||||
config = DisplayConfig(config_file=config_file)
|
||||
assert config.get_boot_animation_enabled() is True
|
||||
config.set_boot_animation_enabled(False)
|
||||
assert DisplayConfig(config_file=config_file).get_boot_animation_enabled() is False
|
||||
config.set_boot_animation_enabled(True)
|
||||
assert DisplayConfig(config_file=config_file).get_boot_animation_enabled() is True
|
||||
|
||||
|
||||
class TestBaseConfigRead:
|
||||
def test_read_creates_file_if_missing(self, tmp_path: Path):
|
||||
config_file = tmp_path / "new.conf"
|
||||
@@ -164,6 +185,21 @@ def test_horizontal_card_orientation_uses_theme_until_overridden(tmp_path: Path)
|
||||
assert config.get_horizontal_card_orientation("vertical") == "horizontal"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("system_locale,force_english,expected", [
|
||||
("ru_RU", False, "ru_RU"),
|
||||
(None, False, "en"),
|
||||
("ru_RU", True, "en"),
|
||||
])
|
||||
def test_system_locale(
|
||||
monkeypatch: pytest.MonkeyPatch, system_locale: str | None,
|
||||
force_english: bool, expected: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(localization.locale, "getlocale", lambda: (system_locale, "UTF-8"))
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_force_english", lambda: force_english)
|
||||
|
||||
assert localization.get_system_locale() == expected
|
||||
|
||||
|
||||
def test_translation_can_switch_to_english_without_restart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
@@ -254,3 +290,13 @@ def test_legacy_cache_merges_into_existing_cache(
|
||||
assert (current_cache / "images" / "cover.jpg").read_text(encoding="utf-8") == "cover"
|
||||
assert (current_cache / "steam_apps.json").read_text(encoding="utf-8") == "apps"
|
||||
assert not legacy_cache.exists()
|
||||
|
||||
|
||||
def test_disable_steam_proton_scanning_persists(tmp_path: Path) -> None:
|
||||
config_file = tmp_path / "ui.conf"
|
||||
config = UIConfig(config_file=config_file)
|
||||
assert config.get_disable_steam_proton_scanning() is False
|
||||
config.set_disable_steam_proton_scanning(True)
|
||||
assert UIConfig(config_file=config_file).get_disable_steam_proton_scanning() is True
|
||||
config.set_disable_steam_proton_scanning(False)
|
||||
assert UIConfig(config_file=config_file).get_disable_steam_proton_scanning() is False
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Boot page rendering, startup transition, and shutdown regressions."""
|
||||
from collections.abc import Generator
|
||||
from pathlib import Path
|
||||
from types import MethodType, SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from pytest import MonkeyPatch, fixture, mark
|
||||
from PySide6.QtCore import QTimer, Qt
|
||||
from PySide6.QtGui import QColor, QImage
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import QApplication, QMainWindow, QStackedWidget, QWidget
|
||||
|
||||
import portprotonqt.boot_animation as boot
|
||||
from portprotonqt.config import display_config
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.themes.standart.styles import constants
|
||||
|
||||
|
||||
def test_theme_video_lookup(tmp_path: Path, monkeypatch: MonkeyPatch) -> None:
|
||||
child = tmp_path / "child"
|
||||
parent = tmp_path / "parent"
|
||||
child.mkdir()
|
||||
parent.mkdir()
|
||||
monkeypatch.setattr(boot, "_find_theme_folder", lambda name: str(tmp_path / name))
|
||||
monkeypatch.setattr(boot, "_read_theme_parent_name", lambda _name: "parent")
|
||||
assert boot.find_boot_video("child") is None
|
||||
parent_video = parent / "bootanimation.webm"
|
||||
parent_video.write_bytes(b"video")
|
||||
assert boot.find_boot_video("child") == str(parent_video)
|
||||
child_video = child / "bootanimation.webm"
|
||||
child_video.write_bytes(b"video")
|
||||
assert boot.find_boot_video("child") == str(child_video)
|
||||
child_video.unlink()
|
||||
child_video.symlink_to(parent_video)
|
||||
assert boot.find_boot_video("child") is None
|
||||
|
||||
|
||||
@fixture
|
||||
def startup(tmp_path: Path, monkeypatch: MonkeyPatch) -> Generator[Any, None, None]:
|
||||
import PySide6.QtMultimedia as media
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
player = MagicMock()
|
||||
factory = MagicMock(return_value=player)
|
||||
factory.MediaStatus = media.QMediaPlayer.MediaStatus
|
||||
monkeypatch.setattr(media, "QMediaPlayer", factory)
|
||||
monkeypatch.setattr(media, "QAudioOutput", MagicMock())
|
||||
monkeypatch.setattr(boot, "find_boot_video", lambda _name: str(tmp_path / "video.webm"))
|
||||
monkeypatch.setattr(display_config, "get_boot_animation_enabled", lambda: True)
|
||||
window = cast(Any, QMainWindow())
|
||||
window.theme = SimpleNamespace(color_boot_animation=constants.color_boot_animation,
|
||||
bootAnimationTimeoutMs=constants.bootAnimationTimeoutMs)
|
||||
window.current_theme_name = "test"
|
||||
window.startup_stack = QStackedWidget(window)
|
||||
library = QWidget()
|
||||
window.startup_stack.addWidget(library)
|
||||
window.setCentralWidget(window.startup_stack)
|
||||
window.boot_animation = None
|
||||
loader = MagicMock()
|
||||
window.startup_load_timer = QTimer(window)
|
||||
window.startup_load_timer.setSingleShot(True)
|
||||
window.startup_load_timer.timeout.connect(loader)
|
||||
window.startup_load_timer.start()
|
||||
window._finish_boot_animation = MethodType(MainWindow._finish_boot_animation, window)
|
||||
window.showFullScreen()
|
||||
MainWindow.start_boot_animation(window)
|
||||
yield SimpleNamespace(app=app, window=window, library=library, player=player, loader=loader)
|
||||
if window.boot_animation is not None:
|
||||
window.boot_animation.stop()
|
||||
window.close()
|
||||
window.deleteLater()
|
||||
|
||||
|
||||
def test_startup_renders_black_and_real_video_frames(startup: Any) -> None:
|
||||
from PySide6.QtMultimedia import QVideoFrame
|
||||
|
||||
window = startup.window
|
||||
animation = window.boot_animation
|
||||
startup.player.play.assert_not_called()
|
||||
window.resize(200, 200)
|
||||
window.show()
|
||||
startup.app.processEvents()
|
||||
startup.player.play.assert_called_once_with()
|
||||
startup.loader.assert_not_called()
|
||||
assert window.startup_stack.currentWidget() is animation
|
||||
assert not animation.isWindow()
|
||||
assert not startup.library.isVisible()
|
||||
black = QColor(constants.color_boot_animation)
|
||||
image = animation.grab().toImage()
|
||||
assert image.pixelColor(0, 0) == black
|
||||
assert image.pixelColor(image.width() // 2, image.height() // 2) == black
|
||||
frame = QImage(32, 16, QImage.Format.Format_RGB32)
|
||||
color = QColor(constants.color_accent)
|
||||
frame.fill(color)
|
||||
animation.sink.setVideoFrame(QVideoFrame(frame))
|
||||
image = animation.grab().toImage()
|
||||
assert image.pixelColor(100, 0) == black
|
||||
assert image.pixelColor(image.width() // 2, image.height() // 2) == color
|
||||
assert image.pixelColor(image.width() // 2, image.height() - 1) == black
|
||||
|
||||
|
||||
@mark.parametrize("reason", ["end", "error", "key", "click", "timeout"])
|
||||
def test_startup_completion_loads_library_once(startup: Any, reason: str) -> None:
|
||||
from PySide6.QtMultimedia import QMediaPlayer
|
||||
|
||||
animation = startup.window.boot_animation
|
||||
startup.window.show()
|
||||
startup.app.processEvents()
|
||||
if reason == "end":
|
||||
animation._media_status_changed(QMediaPlayer.MediaStatus.EndOfMedia)
|
||||
elif reason == "error":
|
||||
animation._playback_error(object(), "invalid WebM")
|
||||
elif reason == "key":
|
||||
QTest.keyClick(animation, Qt.Key.Key_Space)
|
||||
elif reason == "click":
|
||||
QTest.mouseClick(animation, Qt.MouseButton.LeftButton)
|
||||
else:
|
||||
animation.timeout.timeout.emit()
|
||||
animation.finish()
|
||||
startup.app.processEvents()
|
||||
assert startup.window.boot_animation is None
|
||||
assert startup.window.startup_stack.currentWidget() is startup.library
|
||||
assert startup.library.isVisible()
|
||||
startup.player.stop.assert_called_once_with()
|
||||
startup.loader.assert_called_once_with()
|
||||
|
||||
|
||||
def test_shutdown_cancels_pending_playback_without_loading(startup: Any) -> None:
|
||||
animation = startup.window.boot_animation
|
||||
startup.app.aboutToQuit.emit()
|
||||
startup.app.processEvents()
|
||||
startup.player.play.assert_not_called()
|
||||
startup.player.stop.assert_called_once_with()
|
||||
startup.loader.assert_not_called()
|
||||
assert not animation.timeout.isActive()
|
||||
|
||||
|
||||
def test_gamepad_button_skips_boot_before_normal_routing(startup: Any) -> None:
|
||||
from PySide6.QtCore import QObject
|
||||
from portprotonqt.input_manager import InputManager
|
||||
from portprotonqt.input_manager.constants import PAD_BUTTON_SOUTH
|
||||
|
||||
manager: Any = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
manager._parent = startup.window
|
||||
manager._button_states = {}
|
||||
manager.mouse_emulation_enabled = True
|
||||
manager.emulation_active = True
|
||||
manager.emulation_triggered = True
|
||||
manager.button_event.connect(manager.handle_button_slot, Qt.ConnectionType.QueuedConnection)
|
||||
startup.app.processEvents()
|
||||
manager._handle_button_value(0, PAD_BUTTON_SOUTH, 0, 1.0)
|
||||
startup.app.processEvents()
|
||||
assert startup.window.boot_animation is not None
|
||||
manager._handle_button_value(0, PAD_BUTTON_SOUTH, 1, 2.0)
|
||||
assert startup.window.boot_animation is not None
|
||||
startup.app.processEvents()
|
||||
assert startup.window.boot_animation is None
|
||||
startup.app.processEvents()
|
||||
startup.loader.assert_called_once_with()
|
||||
|
||||
|
||||
def test_runtime_boot_does_not_reload_library(startup: Any) -> None:
|
||||
startup.app.processEvents()
|
||||
startup.window.boot_animation.finish()
|
||||
startup.app.processEvents()
|
||||
startup.loader.reset_mock()
|
||||
MainWindow.start_boot_animation(startup.window)
|
||||
animation = startup.window.boot_animation
|
||||
assert animation is not None
|
||||
startup.app.processEvents()
|
||||
animation.finish()
|
||||
startup.app.processEvents()
|
||||
startup.loader.assert_not_called()
|
||||
|
||||
|
||||
def test_windowed_launch_does_not_show_boot(startup: Any) -> None:
|
||||
startup.app.processEvents()
|
||||
startup.window.boot_animation.finish()
|
||||
startup.window.showNormal()
|
||||
MainWindow.start_boot_animation(startup.window)
|
||||
assert startup.window.boot_animation is None
|
||||
|
||||
|
||||
def test_disabled_boot_does_not_start(startup: Any, monkeypatch: MonkeyPatch) -> None:
|
||||
startup.app.processEvents()
|
||||
startup.window.boot_animation.finish()
|
||||
monkeypatch.setattr(display_config, "get_boot_animation_enabled", lambda: False)
|
||||
lookup = MagicMock()
|
||||
monkeypatch.setattr(boot, "find_boot_video", lookup)
|
||||
MainWindow.start_boot_animation(startup.window)
|
||||
assert startup.window.boot_animation is None
|
||||
lookup.assert_not_called()
|
||||
@@ -2,6 +2,10 @@
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from portprotonqt.cli import (
|
||||
normalize_launch_path,
|
||||
@@ -33,6 +37,144 @@ class TestParseArgs:
|
||||
assert args.file_or_url == "file:///tmp/game.exe"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stopped", [False, True])
|
||||
def test_silent_tray_toggles_pause(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stopped: bool) -> None:
|
||||
import signal
|
||||
import psutil
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from portprotonqt import app, time_utils
|
||||
|
||||
qt_app = QApplication.instance() or QApplication([])
|
||||
assert isinstance(qt_app, QApplication)
|
||||
tray = MagicMock()
|
||||
timer = MagicMock()
|
||||
launcher = MagicMock(pid=123)
|
||||
launcher.poll.return_value = None
|
||||
child = MagicMock(pid=124)
|
||||
status = psutil.STATUS_STOPPED if stopped else psutil.STATUS_RUNNING
|
||||
child.status.return_value = status
|
||||
parent = MagicMock()
|
||||
parent.children.return_value = [child]
|
||||
monkeypatch.setattr(app, "QSystemTrayIcon", MagicMock(return_value=tray))
|
||||
monkeypatch.setattr(app, "QTimer", MagicMock(return_value=timer))
|
||||
monkeypatch.setattr(app, "get_portproton_tray_icon", MagicMock())
|
||||
monkeypatch.setattr(app.subprocess, "Popen", MagicMock(return_value=launcher))
|
||||
monkeypatch.setattr(app.psutil, "Process", MagicMock(return_value=parent))
|
||||
monkeypatch.setattr(time_utils, "save_last_launch", MagicMock())
|
||||
monkeypatch.setattr(time_utils, "save_playtime", MagicMock())
|
||||
|
||||
app.run_silent_tray(qt_app, ["start.sh"], str(tmp_path / "game.exe"))
|
||||
menu = tray.setContextMenu.call_args.args[0]
|
||||
menu.aboutToShow.emit()
|
||||
pause, stop = menu.actions()
|
||||
assert pause.isEnabled()
|
||||
assert pause.text() == app._("Resume Game" if stopped else "Pause Game")
|
||||
assert stop.text() == app._("Stop Game")
|
||||
pause.trigger()
|
||||
child.send_signal.assert_called_once_with(signal.SIGCONT if stopped else signal.SIGSTOP)
|
||||
parent.children.assert_called_with(recursive=True)
|
||||
|
||||
parent.children.return_value = []
|
||||
menu.aboutToShow.emit()
|
||||
assert not pause.isEnabled()
|
||||
pause.trigger()
|
||||
assert child.send_signal.call_count == 1
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def silent_startup(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace:
|
||||
from PySide6 import QtNetwork
|
||||
from portprotonqt import app, main_window, port_data_path_selector
|
||||
|
||||
monkeypatch.delenv("STEAM_COMPAT", raising=False)
|
||||
monkeypatch.delenv("PORTPROTONQT_INTEGRATE_APPIMAGE", raising=False)
|
||||
monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config"))
|
||||
monkeypatch.setenv("XDG_DATA_HOME", str(tmp_path / "data"))
|
||||
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "cache"))
|
||||
monkeypatch.setenv("MANGOHUD", "1")
|
||||
monkeypatch.setenv("LD_PRELOAD", "libMangoHud_shim.so")
|
||||
monkeypatch.setattr(app, "get_steam_language", lambda: "english")
|
||||
monkeypatch.setattr(app, "get_portproton_location", lambda: str(tmp_path))
|
||||
monkeypatch.setattr(app, "get_portproton_start_command", lambda: ["start.sh"])
|
||||
monkeypatch.setattr(app, "consume_download_counter_skip", lambda: False)
|
||||
monkeypatch.setattr(app, "update_app_version", lambda _version: False)
|
||||
monkeypatch.setattr(app.ui_config, "get_disable_runtime_download", lambda: False)
|
||||
monkeypatch.setattr(app.display_config, "get_fullscreen", lambda: False)
|
||||
monkeypatch.setattr(port_data_path_selector, "is_portdata_path_read_write", lambda _path: True)
|
||||
qt_app = MagicMock()
|
||||
qt_app.exec.return_value = 0
|
||||
monkeypatch.setattr(app, "QApplication", MagicMock(return_value=qt_app))
|
||||
socket = MagicMock()
|
||||
socket.waitForConnected.return_value = False
|
||||
server = MagicMock()
|
||||
monkeypatch.setattr(QtNetwork, "QLocalSocket", MagicMock(return_value=socket))
|
||||
monkeypatch.setattr(QtNetwork, "QLocalServer", server)
|
||||
tray = MagicMock()
|
||||
window = MagicMock(side_effect=RuntimeError("main window reached"))
|
||||
monkeypatch.setattr(app, "run_silent_tray", tray)
|
||||
monkeypatch.setattr(main_window, "MainWindow", window)
|
||||
exe = tmp_path / "my game.exe"
|
||||
exe.touch()
|
||||
return SimpleNamespace(app=app, qt_app=qt_app, socket=socket, server=server,
|
||||
tray=tray, window=window, exe=exe)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_uri", [False, True])
|
||||
def test_silent_exe_keeps_main_window_for_emulation(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch, file_uri: bool,
|
||||
) -> None:
|
||||
startup = silent_startup
|
||||
target = startup.exe.as_uri() if file_uri else str(startup.exe)
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--silent", target])
|
||||
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
assert startup.window.call_args.kwargs["launch_exe"] == str(startup.exe)
|
||||
assert os.environ["MANGOHUD"] == "1"
|
||||
assert os.environ["LD_PRELOAD"] == "libMangoHud_shim.so"
|
||||
|
||||
|
||||
def test_silent_exe_uses_existing_instance(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
startup = silent_startup
|
||||
startup.socket.waitForConnected.return_value = True
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", "--silent", str(startup.exe)])
|
||||
|
||||
startup.app.main()
|
||||
|
||||
startup.socket.write.assert_called_once_with(f"silent:{startup.exe}".encode())
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_not_called()
|
||||
startup.qt_app.exec.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flags,target", [
|
||||
([], "exe"),
|
||||
(["--silent", "--log"], "exe"),
|
||||
(["--silent"], "gog://launch/123"),
|
||||
(["--silent"], "egs://launch/example"),
|
||||
(["--silent", "--restore-prefix"], "exe"),
|
||||
(["--silent", "--create-backup", "DEFAULT", "/tmp"], "exe"),
|
||||
])
|
||||
def test_other_launch_modes_keep_main_window(
|
||||
silent_startup: SimpleNamespace, monkeypatch: pytest.MonkeyPatch,
|
||||
flags: list[str], target: str,
|
||||
) -> None:
|
||||
startup = silent_startup
|
||||
target = str(startup.exe) if target == "exe" else target
|
||||
monkeypatch.setattr(sys, "argv", ["portprotonqt", *flags, target])
|
||||
|
||||
with pytest.raises(RuntimeError, match="main window reached"):
|
||||
startup.app.main()
|
||||
|
||||
startup.tray.assert_not_called()
|
||||
startup.window.assert_called_once()
|
||||
|
||||
|
||||
class TestNormalizeLaunchPath:
|
||||
def test_plain_path(self):
|
||||
assert normalize_launch_path("/tmp/game.exe") == "/tmp/game.exe"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Download cache completion and worker lifecycle regressions."""
|
||||
|
||||
from pathlib import Path
|
||||
import threading
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QCoreApplication, QThread
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def application() -> QCoreApplication:
|
||||
return QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parallel", [False, True])
|
||||
@pytest.mark.parametrize("failed", [False, True])
|
||||
def test_resolved_download_completes_without_a_worker(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, parallel: bool, failed: bool,
|
||||
) -> None:
|
||||
downloader = Downloader()
|
||||
url = "https://example.com/data.json"
|
||||
path = str(tmp_path / "data.json")
|
||||
if failed:
|
||||
downloader._last_error[url] = True
|
||||
else:
|
||||
Path(path).write_text("{}")
|
||||
start = MagicMock()
|
||||
monkeypatch.setattr(QThread, "start", start)
|
||||
results: list[tuple[str | None, QThread]] = []
|
||||
completed = MagicMock()
|
||||
downloader.download_completed.connect(completed)
|
||||
|
||||
worker = downloader.download_async(
|
||||
url, path, parallel=parallel,
|
||||
callback=lambda result: results.append((result, QThread.currentThread())),
|
||||
)
|
||||
|
||||
assert worker is None
|
||||
start.assert_not_called()
|
||||
assert results == []
|
||||
assert downloader._active_threads == []
|
||||
QApplication.processEvents()
|
||||
expected = None if failed else path
|
||||
assert results == [(expected, QThread.currentThread())]
|
||||
completed.assert_called_once_with(url, expected or "", not failed)
|
||||
if not failed:
|
||||
assert downloader._cache[url] == path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("parallel", [False, True])
|
||||
@pytest.mark.parametrize("cached_file_exists", [False, True])
|
||||
def test_missing_destination_still_downloads_with_a_worker(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
||||
parallel: bool, cached_file_exists: bool,
|
||||
) -> None:
|
||||
downloader = Downloader()
|
||||
url = "https://example.com/data.json"
|
||||
cached_path = tmp_path / "old.json"
|
||||
target = tmp_path / "new.json"
|
||||
if cached_file_exists:
|
||||
cached_path.write_text("{}")
|
||||
downloader._cache[url] = str(cached_path)
|
||||
download = MagicMock(return_value=str(target))
|
||||
parallel_download = MagicMock(return_value={url: str(target)})
|
||||
monkeypatch.setattr("portprotonqt.downloader.download_with_cache", download)
|
||||
monkeypatch.setattr(downloader, "_download_with_parallel", parallel_download)
|
||||
results: list[str | None] = []
|
||||
|
||||
worker = downloader.download_async(url, str(target), parallel=parallel, callback=results.append)
|
||||
|
||||
assert worker is not None
|
||||
assert worker in downloader._active_threads
|
||||
assert worker.wait(5000)
|
||||
assert results == []
|
||||
QApplication.processEvents()
|
||||
assert results == [str(target)]
|
||||
assert downloader._active_threads == []
|
||||
if parallel:
|
||||
assert parallel_download.call_args.args[:2] == ([url], [str(target)])
|
||||
else:
|
||||
download.assert_called_once_with(url, str(target), 5, downloader)
|
||||
|
||||
|
||||
def test_parallel_known_failure_does_not_create_an_executor(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
||||
) -> None:
|
||||
downloader = Downloader()
|
||||
url = "https://example.com/data.json"
|
||||
path = str(tmp_path / "data.json")
|
||||
downloader._last_error[url] = True
|
||||
downloader._cache[url] = path
|
||||
download = MagicMock()
|
||||
monkeypatch.setattr(downloader, "_download_with_parallel", download)
|
||||
completed = MagicMock()
|
||||
|
||||
result = downloader.download_parallel([url], [path], on_result=completed)
|
||||
|
||||
download.assert_not_called()
|
||||
assert result == {url: None}
|
||||
completed.assert_called_once_with(url, None)
|
||||
|
||||
|
||||
def test_download_started_from_a_worker_cleans_up_on_the_owner_thread(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
||||
) -> None:
|
||||
downloader = Downloader()
|
||||
path = str(tmp_path / "data.json")
|
||||
monkeypatch.setattr("portprotonqt.downloader.download_with_cache",
|
||||
lambda *_args: path)
|
||||
workers: list[QThread | None] = []
|
||||
results: list[tuple[str | None, QThread]] = []
|
||||
|
||||
def start_download() -> None:
|
||||
workers.append(downloader.download_async(
|
||||
"https://example.com/data.json", path,
|
||||
callback=lambda result: results.append((result, QThread.currentThread())),
|
||||
))
|
||||
|
||||
caller = threading.Thread(target=start_download)
|
||||
caller.start()
|
||||
caller.join(timeout=5)
|
||||
assert not caller.is_alive()
|
||||
worker = workers[0]
|
||||
assert worker is not None
|
||||
assert worker.wait(5000)
|
||||
QApplication.processEvents()
|
||||
|
||||
assert results == [(path, QThread.currentThread())]
|
||||
assert downloader._active_threads == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("shutdown", ["owner", "application"])
|
||||
def test_shutdown_waits_for_an_active_download(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, shutdown: str,
|
||||
) -> None:
|
||||
downloader = Downloader()
|
||||
entered = threading.Event()
|
||||
release = threading.Event()
|
||||
path = str(tmp_path / "data.json")
|
||||
|
||||
def download(*_args: Any) -> str:
|
||||
entered.set()
|
||||
assert release.wait(5)
|
||||
return path
|
||||
|
||||
monkeypatch.setattr("portprotonqt.downloader.download_with_cache", download)
|
||||
worker = downloader.download_async("https://example.com/data.json", path)
|
||||
assert worker is not None
|
||||
assert entered.wait(5)
|
||||
owner = cast(Any, MainWindowWorkersMixin())
|
||||
owner.downloader = downloader
|
||||
owner.compatibility_workers = []
|
||||
release_timer = threading.Timer(0.05, release.set)
|
||||
release_timer.start()
|
||||
try:
|
||||
if shutdown == "owner":
|
||||
owner._stopBackgroundWorkers()
|
||||
else:
|
||||
app = QApplication.instance()
|
||||
assert app is not None
|
||||
app.aboutToQuit.emit()
|
||||
assert not worker.isRunning()
|
||||
QApplication.processEvents()
|
||||
assert downloader._active_threads == []
|
||||
finally:
|
||||
release.set()
|
||||
release_timer.join()
|
||||
worker.wait(5000)
|
||||
@@ -582,7 +582,7 @@ def test_game_card_uses_exe_fallback_without_valid_cover(
|
||||
appid="game",
|
||||
list_layout=False,
|
||||
base_card_width=100,
|
||||
on_cover_loaded=lambda _pixmap: None,
|
||||
coverReady=SimpleNamespace(emit=lambda _pixmap: None),
|
||||
_set_animated_cover=lambda *_args: False,
|
||||
_get_exe_icon_fallback=lambda: ("/games/game.exe", "/cache/images/Game.png"),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QStringListModel, Qt
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QWidget
|
||||
from pytest import MonkeyPatch, raises
|
||||
from pytest import MonkeyPatch, raises, mark
|
||||
|
||||
import portprotonqt.input_manager as input_manager
|
||||
import portprotonqt.input_manager.buttons as input_buttons
|
||||
@@ -19,6 +19,8 @@ from portprotonqt.input_manager.constants import (
|
||||
KEY_LEFT,
|
||||
PAD_BUTTON_SOUTH,
|
||||
PAD_BUTTON_SELECT,
|
||||
PAD_BUTTON_GUIDE,
|
||||
PAD_BUTTON_START,
|
||||
SDL_GAMEPAD_TYPE_PS5,
|
||||
SDL_GAMEPAD_BUTTON_DPAD_DOWN,
|
||||
SDL_GAMEPAD_BUTTON_DPAD_UP,
|
||||
@@ -33,6 +35,42 @@ HIDDEN_CARD_X = 20
|
||||
NEXT_CARD_X = 40
|
||||
|
||||
|
||||
@mark.parametrize("initial", [True, False])
|
||||
def test_gamepad_auto_fullscreen_shows_boot_on_hotplug(monkeypatch: MonkeyPatch, initial: bool) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
window = cast(Any, QWidget())
|
||||
window.start_boot_animation = MagicMock()
|
||||
manager: Any = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
manager._parent = window
|
||||
manager._is_fullscreen = False
|
||||
manager._initial_gamepad_check = initial
|
||||
manager._gamepad_polling_suspended = False
|
||||
manager.gamepad = None
|
||||
manager.find_gamepad = lambda: SimpleNamespace(path="test", name="test")
|
||||
manager.detect_gamepad_axes = MagicMock()
|
||||
manager._reset_input_state = MagicMock()
|
||||
manager._refresh_gamepad_ui = MagicMock()
|
||||
manager._get_effective_gamepad_type = MagicMock()
|
||||
manager.toggle_fullscreen.connect(manager.handle_fullscreen_slot)
|
||||
monkeypatch.setattr(input_runtime.display_config, "get_auto_fullscreen_gamepad", lambda: True)
|
||||
monkeypatch.setattr(input_runtime.display_config, "get_fullscreen", lambda: False)
|
||||
monkeypatch.setattr(input_manager.window_config, "set_geometry", MagicMock())
|
||||
monkeypatch.setattr(input_manager.window_config, "get_geometry", lambda: (640, 480))
|
||||
manager.check_gamepad()
|
||||
assert window.isFullScreen() is (not initial)
|
||||
assert window.start_boot_animation.call_count == (0 if initial else 1)
|
||||
if not initial:
|
||||
manager.handle_fullscreen_slot(True)
|
||||
assert window.start_boot_animation.call_count == 1
|
||||
window.boot_animation = MagicMock()
|
||||
manager.handle_fullscreen_slot(False)
|
||||
window.boot_animation.finish.assert_called_once_with()
|
||||
assert not window.isFullScreen()
|
||||
window.close()
|
||||
assert app is not None
|
||||
|
||||
|
||||
def _tile_theme() -> Any:
|
||||
return SimpleNamespace(
|
||||
fullLibraryTileSize=(180, 180),
|
||||
@@ -201,6 +239,7 @@ def test_disabling_mouse_emulation_keeps_gamepad_events_working() -> None:
|
||||
emitted: list[tuple[int, int]] = []
|
||||
manager = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
cast(Any, manager)._parent = SimpleNamespace(boot_animation=None)
|
||||
manager._button_states = {}
|
||||
manager.mouse_emulation_enabled = True
|
||||
manager.emulation_active = True
|
||||
@@ -217,6 +256,48 @@ def test_disabling_mouse_emulation_keeps_gamepad_events_working() -> None:
|
||||
assert emitted == [(PAD_BUTTON_SOUTH, 1)]
|
||||
|
||||
|
||||
@mark.parametrize("partner", [PAD_BUTTON_GUIDE, PAD_BUTTON_START])
|
||||
def test_sdl_simultaneous_button_combinations(partner: int) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
manager: Any = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
manager._parent = SimpleNamespace(refreshGames=MagicMock(), isActiveWindow=lambda: True)
|
||||
manager._button_states = {}
|
||||
manager.guide_held = manager.select_held = manager.start_held = False
|
||||
manager.guide_pressed_time = manager.select_pressed_time = 0
|
||||
manager.guide_combination_timeout = 0.3
|
||||
manager.in_guide_combination_attempt = False
|
||||
manager.guide_timer = SimpleNamespace(start=MagicMock(), stop=MagicMock())
|
||||
manager.pending_menu_fullscreen_time = 0.0
|
||||
manager._is_gamescope_session = False
|
||||
manager.mouse_emulation_enabled = False
|
||||
manager.emulation_triggered = False
|
||||
manager.button_event.connect(
|
||||
lambda code, value: manager._handle_guide_combination(code, 1.0) if value else None,
|
||||
Qt.ConnectionType.QueuedConnection,
|
||||
)
|
||||
pressed: set[int] = set()
|
||||
gamepad = cast(SDLGamepad, SimpleNamespace(
|
||||
get_button=lambda index: int(input_runtime.SDL_CONTROLLER_BUTTON_TO_PAD[index] in pressed),
|
||||
))
|
||||
manager._poll_button_events(gamepad, 0.0)
|
||||
app.processEvents()
|
||||
for expected_emulation in (True, False):
|
||||
pressed.update((PAD_BUTTON_SELECT, partner))
|
||||
manager._poll_button_events(gamepad, 1.0)
|
||||
app.processEvents()
|
||||
assert manager.pending_menu_fullscreen_time == 0.0
|
||||
if partner == PAD_BUTTON_START:
|
||||
assert manager.emulation_triggered is expected_emulation
|
||||
manager._parent.refreshGames.assert_not_called()
|
||||
else:
|
||||
manager._parent.refreshGames.assert_called_once()
|
||||
manager._parent.refreshGames.reset_mock()
|
||||
pressed.clear()
|
||||
manager._poll_button_events(gamepad, 1.1)
|
||||
app.processEvents()
|
||||
|
||||
|
||||
class DummyCard(QFrame):
|
||||
def __init__(self, parent: QWidget, x_pos: int) -> None:
|
||||
super().__init__(parent)
|
||||
@@ -420,12 +501,7 @@ def test_library_filter_navigation_includes_only_installed_checkbox() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
controls = QWidget()
|
||||
controls.show()
|
||||
widgets = [
|
||||
QComboBox(controls),
|
||||
QComboBox(controls),
|
||||
QCheckBox(controls),
|
||||
QComboBox(controls),
|
||||
]
|
||||
widgets = [QComboBox(controls) for _ in range(4)] + [QCheckBox(controls)]
|
||||
for widget in widgets:
|
||||
widget.show()
|
||||
app.processEvents()
|
||||
@@ -434,13 +510,34 @@ def test_library_filter_navigation_includes_only_installed_checkbox() -> None:
|
||||
libraryControlsWidget=controls,
|
||||
gamesSortCombo=widgets[0],
|
||||
gamesDisplayCombo=widgets[1],
|
||||
onlyInstalledCheckBox=widgets[2],
|
||||
gamesBadgeViewCombo=widgets[3],
|
||||
gamesBadgeViewCombo=widgets[2],
|
||||
gamesLayoutCombo=widgets[3],
|
||||
onlyInstalledCheckBox=widgets[4],
|
||||
stackedWidget=SimpleNamespace(currentIndex=lambda: 0),
|
||||
libraryControlsButton=QWidget(controls),
|
||||
)
|
||||
parent.libraryControlsButton.show()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, parent)
|
||||
|
||||
assert manager._get_library_filter_widgets() == widgets
|
||||
manager._get_library_toolbar_widgets = lambda: []
|
||||
manager._focused_widget = QApplication.focusWidget
|
||||
manager._focus_first_library_card = MagicMock(return_value=True)
|
||||
widgets[0].setFocus()
|
||||
for widget in widgets[1:]:
|
||||
assert manager._handle_toolbar_navigation(PAD_DPAD_Y, 1)
|
||||
assert QApplication.focusWidget() is widget
|
||||
assert manager._handle_toolbar_navigation(PAD_DPAD_Y, 1)
|
||||
manager._focus_first_library_card.assert_called_once()
|
||||
for widget in reversed(widgets[:-1]):
|
||||
assert manager._handle_toolbar_navigation(PAD_DPAD_Y, -1)
|
||||
assert QApplication.focusWidget() is widget
|
||||
assert manager._handle_toolbar_navigation(PAD_DPAD_Y, -1)
|
||||
assert QApplication.focusWidget() is parent.libraryControlsButton
|
||||
widgets[-1].hide()
|
||||
assert manager._get_library_filter_widgets() == widgets[:-1]
|
||||
controls.close()
|
||||
|
||||
|
||||
def test_library_size_adjustment_uses_original_step() -> None:
|
||||
@@ -549,6 +646,94 @@ def test_confirm_button_opens_combo_popup() -> None:
|
||||
assert app is not None
|
||||
|
||||
|
||||
def test_settings_gamepad_skips_separator_and_activates_download(monkeypatch: MonkeyPatch) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.input_manager.constants import BUTTONS
|
||||
from portprotonqt.input_manager.settings import SettingsInputMixin
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = QDialog()
|
||||
table = QTableWidget(1, 2, dialog)
|
||||
combo = QComboBox()
|
||||
combo.addItem("Proton")
|
||||
combo.insertSeparator(1)
|
||||
combo.addItem("Download Wine/Proton...", "download_wine")
|
||||
table.setCellWidget(0, 1, combo)
|
||||
table.setCurrentCell(0, 1)
|
||||
highlighted = []
|
||||
combo.highlighted.connect(highlighted.append)
|
||||
settings = SimpleNamespace(
|
||||
original_display_values={"PW_WINE_USE": "Proton"},
|
||||
portproton_path=None, input_manager=None,
|
||||
)
|
||||
opened = MagicMock()
|
||||
monkeypatch.setattr("portprotonqt.dialogs.settings_dialog.show_proton_manager", opened)
|
||||
combo.activated.connect(lambda index: ExeSettingsDialog._open_wine_manager_from_combo(
|
||||
cast(ExeSettingsDialog, settings), index, combo, "PW_WINE_USE",
|
||||
))
|
||||
manager = SimpleNamespace(
|
||||
settings_dialog=SimpleNamespace(advanced_table=table, settings_table=None, favorites_table=None),
|
||||
_is_current_settings_tool_tab=lambda: False,
|
||||
_handle_table_action_navigation=lambda *_args: False,
|
||||
_get_dialog_action_buttons=lambda _dialog: [],
|
||||
_get_current_settings_table=lambda: table,
|
||||
_get_open_settings_combo=lambda: combo,
|
||||
_handle_common_ui_elements=lambda _code: False,
|
||||
)
|
||||
dialog.show()
|
||||
combo.showPopup()
|
||||
app.processEvents()
|
||||
SettingsInputMixin.handle_settings_dpad(cast(SettingsInputMixin, manager), PAD_DPAD_Y, 1, 0)
|
||||
assert combo.view().currentIndex().row() == 2
|
||||
assert highlighted[-1] == 2
|
||||
assert combo.currentData() == "download_wine"
|
||||
SettingsInputMixin.handle_settings_button(
|
||||
cast(SettingsInputMixin, manager), next(iter(BUTTONS["confirm"])), 1,
|
||||
)
|
||||
opened.assert_called_once()
|
||||
assert combo.currentText() == "Proton"
|
||||
assert not combo.view().isVisible()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_settings_combo_navigation_with_keyboard_events_intercepted() -> None:
|
||||
from portprotonqt.input_manager.settings import SettingsInputMixin
|
||||
|
||||
class KeyFilter(QObject):
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
return event.type() == QEvent.Type.KeyPress
|
||||
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = QDialog()
|
||||
table = QTableWidget(1, 2, dialog)
|
||||
combo = QComboBox()
|
||||
combo.addItems(["First", "Second", "Third"])
|
||||
table.setCellWidget(0, 1, combo)
|
||||
table.setCurrentCell(0, 1)
|
||||
manager = SimpleNamespace(
|
||||
settings_dialog=SimpleNamespace(advanced_table=table, settings_table=None),
|
||||
_is_current_settings_tool_tab=lambda: False,
|
||||
_handle_table_action_navigation=lambda *_args: False,
|
||||
_get_dialog_action_buttons=lambda _dialog: [],
|
||||
_get_current_settings_table=lambda: table,
|
||||
)
|
||||
key_filter = KeyFilter()
|
||||
app.installEventFilter(key_filter)
|
||||
try:
|
||||
dialog.show()
|
||||
combo.showPopup()
|
||||
app.processEvents()
|
||||
for direction, expected in ((1, 1), (1, 2), (1, 2), (-1, 1), (-1, 0), (-1, 0)):
|
||||
SettingsInputMixin.handle_settings_dpad(cast(SettingsInputMixin, manager), PAD_DPAD_Y, direction, 0)
|
||||
assert combo.currentIndex() == expected
|
||||
assert combo.view().currentIndex().row() == expected
|
||||
assert combo.view().isVisible()
|
||||
finally:
|
||||
app.removeEventFilter(key_filter)
|
||||
combo.hidePopup()
|
||||
dialog.close()
|
||||
|
||||
|
||||
def test_user_conf_table_combo_gets_focus_and_opens() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
table = QTableWidget(1, 2)
|
||||
@@ -779,6 +964,7 @@ def test_dialog_surface_receives_connected_input_signals() -> None:
|
||||
default_events: list[tuple[int, int]] = []
|
||||
manager: Any = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
manager._parent = SimpleNamespace(boot_animation=None)
|
||||
manager._input_surfaces = []
|
||||
manager._input_surface_base_state = None
|
||||
manager._gamepad_handling_enabled = False
|
||||
@@ -812,6 +998,7 @@ def test_gamepad_ui_events_are_queued_after_polling() -> None:
|
||||
events: list[tuple[int, int]] = []
|
||||
manager: Any = InputManager.__new__(InputManager)
|
||||
QObject.__init__(manager)
|
||||
manager._parent = SimpleNamespace(boot_animation=None)
|
||||
manager._input_surfaces = []
|
||||
manager._handle_default_button = lambda code, value: events.append((code, value))
|
||||
manager.button_event.connect(
|
||||
@@ -1117,3 +1304,126 @@ def test_settings_grid_vertical_navigation_does_not_wrap_columns() -> None:
|
||||
assert manager._find_mangohud_vertical_grid_target(
|
||||
checkboxes[1], -1, checkboxes
|
||||
) is None
|
||||
|
||||
|
||||
@mark.parametrize("tool_tab", [False, True])
|
||||
def test_settings_gamepad_reaches_clear_ppdb(monkeypatch: MonkeyPatch, tool_tab: bool) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = QDialog()
|
||||
layout = QHBoxLayout(dialog)
|
||||
table = QTableWidget(1, 2)
|
||||
table.setCurrentCell(0, 1)
|
||||
layout.addWidget(table)
|
||||
buttons = [QPushButton(name) for name in ("Apply", "Cancel", "Edit", "Clear")]
|
||||
for button in buttons:
|
||||
layout.addWidget(button)
|
||||
manager = InputManager.__new__(InputManager)
|
||||
settings = SimpleNamespace(
|
||||
keyboard=None, advanced_table=table, settings_table=table,
|
||||
mangohud_fps_widgets={}, mangohud_toggle_widgets={},
|
||||
tab_widget=SimpleNamespace(currentWidget=lambda: table),
|
||||
apply_button=buttons[0], cancel_button=buttons[1],
|
||||
open_ppdb_button=buttons[2], clear_ppdb_button=buttons[3],
|
||||
)
|
||||
for name, value in vars(settings).items():
|
||||
setattr(dialog, name, value)
|
||||
manager.settings_dialog = cast(Any, dialog)
|
||||
manager.dpad_timer = MagicMock()
|
||||
manager.current_dpad_code = None
|
||||
manager.current_dpad_value = 0
|
||||
monkeypatch.setattr(manager, "_get_current_settings_table", lambda: table)
|
||||
monkeypatch.setattr(manager, "_is_current_settings_tool_tab", lambda: tool_tab)
|
||||
monkeypatch.setattr(manager, "_get_open_settings_combo", lambda: None)
|
||||
monkeypatch.setattr(manager, "_get_mangohud_nav_sections", lambda: [[table]])
|
||||
monkeypatch.setattr(manager, "_handle_common_ui_elements", lambda _code: False)
|
||||
clicked = MagicMock()
|
||||
buttons[3].clicked.connect(clicked)
|
||||
dialog.show()
|
||||
table.setFocus()
|
||||
app.processEvents()
|
||||
manager.handle_settings_dpad(PAD_DPAD_Y, 1, 0)
|
||||
assert QApplication.focusWidget() is buttons[0]
|
||||
for button in buttons[1:]:
|
||||
manager.handle_settings_dpad(PAD_DPAD_X, 1, 0)
|
||||
assert QApplication.focusWidget() is button
|
||||
manager.handle_settings_button(next(iter(input_manager.BUTTONS["confirm"])), 1)
|
||||
clicked.assert_called_once()
|
||||
manager.handle_settings_dpad(PAD_DPAD_Y, -1, 0)
|
||||
assert QApplication.focusWidget() is table
|
||||
dialog.close()
|
||||
|
||||
|
||||
@mark.parametrize("mode", ["winetricks", "proton_manager"])
|
||||
@mark.parametrize("empty", [False, True])
|
||||
def test_manager_gamepad_table_actions(mode: str, empty: bool, monkeypatch: MonkeyPatch) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = QDialog()
|
||||
layout = QHBoxLayout(dialog)
|
||||
table = QTableWidget(0 if empty else 2, 1)
|
||||
layout.addWidget(table)
|
||||
if not empty:
|
||||
table.setCurrentCell(0, 0)
|
||||
table.setRowHidden(1, True)
|
||||
buttons = [QPushButton(name) for name in ("First", "Disabled", "Last")]
|
||||
for button in buttons:
|
||||
layout.addWidget(button)
|
||||
buttons[1].setEnabled(False)
|
||||
manager = InputManager.__new__(InputManager)
|
||||
setattr(manager, mode + "_dialog", dialog)
|
||||
manager.dpad_timer = MagicMock()
|
||||
manager.current_dpad_code = None
|
||||
manager.current_dpad_value = 0
|
||||
monkeypatch.setattr(manager, "_handle_common_ui_elements", lambda _code: False)
|
||||
clicked = MagicMock()
|
||||
buttons[-1].clicked.connect(clicked)
|
||||
navigate = getattr(manager, "handle_" + mode + "_dpad")
|
||||
confirm = getattr(manager, "handle_" + mode + "_button")
|
||||
dialog.show()
|
||||
table.setFocus()
|
||||
app.processEvents()
|
||||
navigate(PAD_DPAD_Y, 1, 0)
|
||||
assert QApplication.focusWidget() is buttons[0]
|
||||
navigate(PAD_DPAD_X, 1, 0)
|
||||
assert QApplication.focusWidget() is buttons[-1]
|
||||
confirm(next(iter(input_manager.BUTTONS["confirm"])), 1)
|
||||
clicked.assert_called_once()
|
||||
navigate(PAD_DPAD_X, -1, 0)
|
||||
assert QApplication.focusWidget() is buttons[0]
|
||||
navigate(PAD_DPAD_Y, -1, 0)
|
||||
assert QApplication.focusWidget() is table
|
||||
dialog.close()
|
||||
|
||||
|
||||
@mark.parametrize("tool", ["mangohud", "vkbasalt", "gamescope"])
|
||||
def test_settings_tool_gamepad_reaches_action_buttons(tool: str, monkeypatch: MonkeyPatch) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
dialog = cast(Any, QDialog())
|
||||
layout = QHBoxLayout(dialog)
|
||||
tool_tab = QWidget()
|
||||
tool_layout = QHBoxLayout(tool_tab)
|
||||
control = QLineEdit()
|
||||
tool_layout.addWidget(control)
|
||||
layout.addWidget(tool_tab)
|
||||
button = QPushButton("Clear")
|
||||
layout.addWidget(button)
|
||||
dialog.keyboard = None
|
||||
dialog.tab_widget = SimpleNamespace(currentWidget=lambda: tool_tab)
|
||||
setattr(dialog, tool + "_tab", tool_tab)
|
||||
dialog.mangohud_widgets = {}
|
||||
dialog.mangohud_fps_widgets = {}
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager.settings_dialog = dialog
|
||||
manager.dpad_timer = MagicMock()
|
||||
manager.current_dpad_code = None
|
||||
manager.current_dpad_value = 0
|
||||
monkeypatch.setattr(manager, "_get_current_settings_table", lambda: None)
|
||||
monkeypatch.setattr(manager, "_get_open_settings_combo", lambda: None)
|
||||
monkeypatch.setattr(manager, "_get_mangohud_nav_sections", lambda: [[control]])
|
||||
dialog.show()
|
||||
control.setFocus()
|
||||
app.processEvents()
|
||||
manager.handle_settings_dpad(PAD_DPAD_Y, 1, 0)
|
||||
assert QApplication.focusWidget() is button
|
||||
manager.handle_settings_dpad(PAD_DPAD_Y, -1, 0)
|
||||
assert QApplication.focusWidget() is control
|
||||
dialog.close()
|
||||
@@ -0,0 +1,547 @@
|
||||
"""Progressive library loading, drawing and input regressions."""
|
||||
from collections.abc import Callable, Generator
|
||||
from pathlib import Path
|
||||
from time import perf_counter
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QCoreApplication, QEvent, QEventLoop, QThread, Qt, QTimer
|
||||
from PySide6.QtGui import QMouseEvent, QPixmap
|
||||
from PySide6.QtWidgets import QApplication, QLineEdit, QWidget
|
||||
|
||||
from portprotonqt import image_utils
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.theme_manager import load_theme
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def library_scene(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_config_dir: Path,
|
||||
) -> Generator[tuple[GameLibraryManager, list[Callable[[QPixmap], None]]], None, None]:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
callbacks: list[Callable[[QPixmap], None]] = []
|
||||
monkeypatch.setattr("portprotonqt.game_card.load_pixmap_async",
|
||||
lambda *args, **_kwargs: callbacks.append(args[3]))
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_library_layout_mode", lambda _: "grid")
|
||||
main = cast(Any, SimpleNamespace(
|
||||
searchEdit=QLineEdit(), card_width=180, on_slider_released=lambda: None,
|
||||
openGameDetailPage=MagicMock(), current_hovered_card=None, current_focused_card=None,
|
||||
))
|
||||
main.createSearchWidget = lambda: (QWidget(), main.searchEdit)
|
||||
manager = GameLibraryManager(main, load_theme("standart"), cast(Any, MagicMock()))
|
||||
manager.create_games_library_widget()
|
||||
cast(Any, manager.gamesLibraryWidget).game_library_manager = manager
|
||||
manager.gamesLibraryWidget.resize(1000, 700)
|
||||
manager.gamesLibraryWidget.show()
|
||||
app.processEvents()
|
||||
try:
|
||||
yield manager, callbacks
|
||||
finally:
|
||||
assert manager._incremental_add_timer is not None
|
||||
manager._incremental_add_timer.stop()
|
||||
cover = QPixmap(300, 450)
|
||||
cover.fill(Qt.GlobalColor.darkBlue)
|
||||
for callback in callbacks:
|
||||
callback(cover)
|
||||
manager.stop_background_activity()
|
||||
app.processEvents()
|
||||
manager.gamesLibraryWidget.close()
|
||||
manager.gamesLibraryWidget.deleteLater()
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
|
||||
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
|
||||
def test_new_horizontal_cards_fit_before_layout_insertion(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, layout_mode: str, orientation: str,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_horizontal_card_orientation",
|
||||
lambda _: orientation)
|
||||
manager.rebuild_library_layout(layout_mode)
|
||||
manager.gamesLibraryWidget.resize(800, 400)
|
||||
QApplication.processEvents()
|
||||
assert manager.gamesListLayout is not None and manager.gamesScrollArea is not None
|
||||
for name in ("First", "Second"):
|
||||
card = manager._create_game_card((name, "", "", "", "", name, "", "", "", "", 0, 0, "steam"))
|
||||
margins = manager.gamesListLayout.contentsMargins()
|
||||
scale = max(card.card_layout_cfg.get(key, manager.theme.GAME_CARD_ANIMATION[key])
|
||||
for key in ("default_scale", "hover_scale", "focus_scale"))
|
||||
ratio = card.card_geometry_cfg["card_height_ratio"]
|
||||
assert (card.base_card_width * ratio + card.base_extra_margin) * scale <= (
|
||||
manager.gamesScrollArea.viewport().height() - margins.top() - margins.bottom()
|
||||
)
|
||||
initial_size = card.size()
|
||||
manager.game_card_cache[(name, name)] = card
|
||||
manager.gamesListLayout.addWidget(card)
|
||||
manager._fit_horizontal_cards()
|
||||
assert card.size() == initial_size
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
|
||||
def test_library_clear_releases_full_library_tile(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
layout_mode: str,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
manager.layout_mode = "horizontal_top"
|
||||
manager.filtered_games = [()] * (manager.theme.horizontalTopGameLimit + 1)
|
||||
manager._sync_full_library_tile()
|
||||
tile = manager.fullLibraryTile
|
||||
assert tile is not None
|
||||
|
||||
manager.clear_layout(manager.gamesListLayout)
|
||||
QCoreApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
manager.layout_mode = layout_mode
|
||||
manager._sync_full_library_tile()
|
||||
manager._set_full_library_tile_cover(tile, 0, QPixmap())
|
||||
|
||||
if layout_mode == "horizontal":
|
||||
assert manager.fullLibraryTile is None
|
||||
else:
|
||||
assert manager.fullLibraryTile is not None
|
||||
assert manager.fullLibraryTile is not tile
|
||||
|
||||
|
||||
def test_cached_library_stays_visible_when_order_changes(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
assert manager.gamesListLayout is not None
|
||||
for name in ("First", "Second"):
|
||||
card = manager._create_game_card((name, "", "", "", "", name, "", "", "", "", 0, 0, "steam"))
|
||||
manager.game_card_cache[(name, name)] = card
|
||||
manager.gamesListLayout.addWidget(card)
|
||||
card.show()
|
||||
QApplication.processEvents()
|
||||
order = [("Second", "Second"), ("First", "First")]
|
||||
|
||||
manager._start_incremental_add(order, {}, "")
|
||||
|
||||
assert all(card.isVisible() for card in manager.game_card_cache.values())
|
||||
assert manager.gamesListLayout.count() == 2
|
||||
assert [cast(Any, manager.gamesListLayout.itemAt(index)).widget().name for index in range(2)] == ["Second", "First"]
|
||||
assert not manager._incremental_add_queue
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
|
||||
def test_horizontal_loading_keeps_card_geometry_stable(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, layout_mode: str,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_horizontal_card_orientation",
|
||||
lambda _: "vertical")
|
||||
manager.rebuild_library_layout(layout_mode)
|
||||
manager.gamesLibraryWidget.resize(800, 400)
|
||||
games = [(f"Game {i}", "", "", "", "", str(i), "", "", "", "", 0, 0, "steam")
|
||||
for i in range(4)]
|
||||
manager.set_games(games, focus_first_card=False)
|
||||
assert manager._incremental_add_timer is not None
|
||||
for _ in range(20):
|
||||
QApplication.processEvents()
|
||||
if manager._incremental_add_queue:
|
||||
manager._process_incremental_add_batch()
|
||||
manager._incremental_add_timer.stop()
|
||||
assert not manager._incremental_add_queue
|
||||
card = next(iter(manager.game_card_cache.values()))
|
||||
geometry = card.geometry()
|
||||
for _ in range(20):
|
||||
QApplication.processEvents()
|
||||
assert card.geometry() == geometry
|
||||
assert manager.gamesScrollArea is not None
|
||||
assert card.height() <= manager.gamesScrollArea.viewport().height()
|
||||
manager.rebuild_library_layout("grid")
|
||||
assert manager.gamesScrollArea.horizontalScrollBarPolicy() == Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["horizontal", "horizontal_top"])
|
||||
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
|
||||
def test_horizontal_batches_keep_existing_cards_in_place(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, layout_mode: str, orientation: str,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
monkeypatch.setattr("portprotonqt.config.ui_config.get_horizontal_card_orientation",
|
||||
lambda _: orientation)
|
||||
manager.rebuild_library_layout(layout_mode)
|
||||
manager.gamesLibraryWidget.resize(1280, 800)
|
||||
QApplication.processEvents()
|
||||
manager._incremental_batch_size = 1
|
||||
games = [(f"Game {i}", "", "", "", "", str(i), "", "", "", "", 0, 0, "steam")
|
||||
for i in range(8)]
|
||||
keys = [(game[0], game[5]) for game in games]
|
||||
manager._start_incremental_add(keys, dict(zip(keys, games, strict=True)), "")
|
||||
assert manager._incremental_add_timer is not None
|
||||
manager._incremental_add_timer.stop()
|
||||
QApplication.processEvents()
|
||||
manager._incremental_add_timer.stop()
|
||||
while manager._incremental_add_queue:
|
||||
geometries = {card: card.geometry() for card in manager.game_card_cache.values()}
|
||||
manager._process_incremental_add_batch()
|
||||
manager._incremental_add_timer.stop()
|
||||
assert all(card.geometry() == previous for card, previous in geometries.items())
|
||||
QApplication.processEvents()
|
||||
manager._incremental_add_timer.stop()
|
||||
assert all(card.geometry() == previous for card, previous in geometries.items())
|
||||
|
||||
|
||||
def test_batches_keep_drawing_enabled_and_accept_clicks_before_completion(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
assert manager.gamesListWidget is not None and manager.gamesListLayout is not None
|
||||
update_toggle = MagicMock(wraps=manager.gamesListWidget.setUpdatesEnabled)
|
||||
monkeypatch.setattr(manager.gamesListWidget, "setUpdatesEnabled", update_toggle)
|
||||
games = [(f"Game {index}", "", "", "", "", str(index), "", "", "", "", 0, 0, "steam") for index in range(40)]
|
||||
keys = [(game[0], game[5]) for game in games]
|
||||
manager._start_incremental_add(keys, dict(zip(keys, games, strict=True)), "")
|
||||
assert manager._incremental_add_timer is not None
|
||||
manager._incremental_add_timer.stop()
|
||||
if not manager.game_card_cache:
|
||||
manager._process_incremental_add_batch()
|
||||
manager._incremental_add_timer.stop()
|
||||
card = next(iter(manager.game_card_cache.values()))
|
||||
position = card.rect().center().toPointF()
|
||||
for event_type, buttons in ((QEvent.Type.MouseButtonPress, Qt.MouseButton.LeftButton),
|
||||
(QEvent.Type.MouseButtonRelease, Qt.MouseButton.NoButton)):
|
||||
QCoreApplication.postEvent(card, QMouseEvent(event_type, position, position,
|
||||
Qt.MouseButton.LeftButton, buttons, Qt.KeyboardModifier.NoModifier))
|
||||
QApplication.processEvents()
|
||||
manager._incremental_add_timer.stop()
|
||||
|
||||
cast(Any, manager.main_window).openGameDetailPage.assert_called_once()
|
||||
assert manager._incremental_add_queue
|
||||
while manager._incremental_add_queue:
|
||||
count_before = manager.gamesListLayout.count()
|
||||
manager._process_incremental_add_batch()
|
||||
manager._incremental_add_timer.stop()
|
||||
QApplication.processEvents()
|
||||
assert manager.gamesListLayout.count() > count_before
|
||||
assert manager.gamesListWidget.updatesEnabled()
|
||||
assert all(card.isVisible() for card in manager.game_card_cache.values())
|
||||
update_toggle.assert_not_called()
|
||||
|
||||
|
||||
def test_switch_during_loading_cancels_old_cards_and_reuses_cached_cards(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
games = [(f"Game {i}", "", "", "", "", str(i), "", "", "", "", 0, 0, "steam") for i in range(40)]
|
||||
keys = [(game[0], game[5]) for game in games]
|
||||
manager._start_incremental_add(keys, dict(zip(keys, games, strict=True)), "")
|
||||
cached = manager.game_card_cache[keys[0]]
|
||||
manager._start_incremental_add([keys[0]], {}, "")
|
||||
QApplication.processEvents()
|
||||
assert manager.game_card_cache[keys[0]] is cached
|
||||
assert cached.isVisible()
|
||||
assert not manager._incremental_add_queue
|
||||
assert len(manager.game_card_cache) < len(games)
|
||||
assert manager.gamesListLayout is not None
|
||||
assert manager.gamesListLayout.count() == 1
|
||||
|
||||
|
||||
def test_previous_source_completion_is_cached_without_replacing_current_source(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
|
||||
monkeypatch.setattr("portprotonqt.main_window.game_config.get_display_filter", lambda: "egs")
|
||||
monkeypatch.setattr("portprotonqt.main_window.game_config.get_only_installed", lambda: False)
|
||||
manager = SimpleNamespace(set_games=MagicMock())
|
||||
window = cast(Any, SimpleNamespace(
|
||||
_loading_library_filter=("steam", False), _loaded_library_cache={},
|
||||
game_library_manager=manager, _on_only_installed_changed=MagicMock(),
|
||||
))
|
||||
games = [("Steam game", "", "", "", "", "steam://1")]
|
||||
MainWindow.on_games_loaded(window, games)
|
||||
assert window._loaded_library_cache == {"steam": games}
|
||||
window._on_only_installed_changed.assert_called_once_with(False)
|
||||
manager.set_games.assert_not_called()
|
||||
assert not window._loading_games
|
||||
|
||||
|
||||
def test_source_switch_keeps_cached_widgets_and_shutdown_cancels_loading(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
games = [(name, "", "", "", "", name, "", "", "", "", 0, 0, "steam") for name in ("First", "Second")]
|
||||
main = cast(Any, manager.main_window)
|
||||
main._loaded_library_cache = {"steam": games, "egs": games[:1]}
|
||||
manager.games = manager.filtered_games = games
|
||||
manager.dirty = True
|
||||
manager._update_game_grid_immediate()
|
||||
assert manager._incremental_add_timer is not None
|
||||
while manager._incremental_add_queue:
|
||||
manager._process_incremental_add_batch()
|
||||
manager._incremental_add_timer.stop()
|
||||
second = manager.game_card_cache[("Second", "Second")]
|
||||
manager.games = manager.filtered_games = games[:1]
|
||||
manager.dirty = True
|
||||
manager._update_game_grid_immediate()
|
||||
assert not second.isVisible()
|
||||
manager.games = manager.filtered_games = games
|
||||
manager.dirty = True
|
||||
manager._update_game_grid_immediate()
|
||||
assert manager.game_card_cache[("Second", "Second")] is second
|
||||
assert second.isVisible()
|
||||
assert manager._incremental_add_timer is not None
|
||||
manager._incremental_add_timer.start(0)
|
||||
manager.stop_background_activity()
|
||||
assert not manager._incremental_add_timer.isActive()
|
||||
assert not manager._incremental_add_queue
|
||||
|
||||
|
||||
def test_first_batch_receives_focus_without_waiting_for_remaining_cards(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
main = cast(Any, manager.main_window)
|
||||
main.stackedWidget = SimpleNamespace(currentIndex=lambda: 0)
|
||||
manager.gamesLibraryWidget.activateWindow()
|
||||
main.searchEdit.setFocus()
|
||||
QApplication.processEvents()
|
||||
manager._focus_first_card_after_update = True
|
||||
games = [(f"Game {i}", "", "", "", "", str(i), "", "", "", "", 0, 0, "steam") for i in range(80)]
|
||||
keys = [(game[0], game[5]) for game in games]
|
||||
manager._start_incremental_add(keys, dict(zip(keys, games, strict=True)), "")
|
||||
assert manager._incremental_add_timer is not None
|
||||
manager._incremental_add_timer.stop()
|
||||
QApplication.processEvents()
|
||||
|
||||
first = manager.game_card_cache[keys[0]]
|
||||
assert manager._incremental_add_queue
|
||||
assert first.hasFocus()
|
||||
assert main.current_focused_card is first
|
||||
while keys[1] not in manager.game_card_cache:
|
||||
manager._process_incremental_add_batch()
|
||||
manager._incremental_add_timer.stop()
|
||||
QApplication.processEvents()
|
||||
second = manager.game_card_cache[keys[1]]
|
||||
second.setFocus()
|
||||
while manager._incremental_add_queue:
|
||||
manager._process_incremental_add_batch()
|
||||
manager._incremental_add_timer.stop()
|
||||
QApplication.processEvents()
|
||||
assert second.hasFocus()
|
||||
assert main.current_focused_card is second
|
||||
|
||||
|
||||
@pytest.mark.parametrize("layout_mode", ["grid", "horizontal"])
|
||||
def test_scrolling_defers_card_creation_until_scroll_stops(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
layout_mode: str,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
manager.rebuild_library_layout(layout_mode)
|
||||
QApplication.processEvents()
|
||||
games = [(f"Game {i}", "", "", "", "", str(i), "", "", "", "", 0, 0, "steam") for i in range(80)]
|
||||
keys = [(game[0], game[5]) for game in games]
|
||||
manager._start_incremental_add(keys, dict(zip(keys, games, strict=True)), "")
|
||||
assert manager._incremental_add_timer is not None
|
||||
assert manager.gamesScrollArea is not None
|
||||
scrollbar = (manager.gamesScrollArea.horizontalScrollBar() if layout_mode == "horizontal"
|
||||
else manager.gamesScrollArea.verticalScrollBar())
|
||||
while scrollbar.maximum() < 100:
|
||||
manager._incremental_add_timer.stop()
|
||||
manager._process_incremental_add_batch()
|
||||
QApplication.processEvents()
|
||||
manager._incremental_add_timer.stop()
|
||||
count_before = len(manager.game_card_cache)
|
||||
|
||||
def scroll() -> None:
|
||||
scrollbar.setValue(0 if scrollbar.value() else 50)
|
||||
|
||||
loop = QEventLoop()
|
||||
scroll_timer = QTimer()
|
||||
scroll_timer.timeout.connect(scroll)
|
||||
scroll_timer.start(20)
|
||||
manager._incremental_add_timer.start(manager._incremental_batch_budget_ms)
|
||||
scroll()
|
||||
QTimer.singleShot(manager._incremental_scroll_delay_ms * 2, loop.quit)
|
||||
loop.exec()
|
||||
scroll_timer.stop()
|
||||
assert len(manager.game_card_cache) == count_before
|
||||
assert manager._incremental_add_queue
|
||||
assert manager.gamesListWidget is not None and manager.gamesListWidget.updatesEnabled()
|
||||
|
||||
manager._incremental_add_timer.timeout.connect(loop.quit)
|
||||
QTimer.singleShot(manager._incremental_scroll_delay_ms * 2, loop.quit)
|
||||
loop.exec()
|
||||
manager._incremental_add_timer.timeout.disconnect(loop.quit)
|
||||
manager._incremental_add_timer.stop()
|
||||
assert len(manager.game_card_cache) > count_before
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def real_cover_scene(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
||||
) -> tuple[GameLibraryManager, Path]:
|
||||
manager, _ = library_scene
|
||||
main = cast(Any, manager.main_window)
|
||||
main.stackedWidget = SimpleNamespace(currentIndex=lambda: 0)
|
||||
main.getColorPalette_from_pixmap = MagicMock()
|
||||
manager.gamesLibraryWidget.activateWindow()
|
||||
QApplication.processEvents()
|
||||
cover = QPixmap(600, 900)
|
||||
cover.fill(Qt.GlobalColor.darkBlue)
|
||||
cover_path = tmp_path / "cover.jpg"
|
||||
assert cover.save(str(cover_path))
|
||||
monkeypatch.setattr("portprotonqt.game_card.load_pixmap_async", image_utils.load_pixmap_async)
|
||||
return manager, cover_path
|
||||
|
||||
|
||||
@pytest.mark.parametrize("animation_type", ["gradient", "glow"])
|
||||
def test_real_cover_loading_and_first_paint_focus_latency(
|
||||
real_cover_scene: tuple[GameLibraryManager, Path],
|
||||
monkeypatch: pytest.MonkeyPatch, animation_type: str,
|
||||
) -> None:
|
||||
manager, cover_path = real_cover_scene
|
||||
monkeypatch.setattr(manager.theme, "GAME_CARD_ANIMATION",
|
||||
{**manager.theme.GAME_CARD_ANIMATION, "card_animation_type": animation_type})
|
||||
threads: list[bool] = []
|
||||
paints: list[tuple[float, bool]] = []
|
||||
ticks: list[float] = []
|
||||
original_paint = GameCard.paintEvent
|
||||
original_cover = GameCard.on_cover_loaded
|
||||
|
||||
def record_paint(card: GameCard, event: Any) -> None:
|
||||
if card.name == "Game 0":
|
||||
paints.append((perf_counter(), card._focused or card._hovered))
|
||||
original_paint(card, event)
|
||||
|
||||
def record_cover(card: GameCard, pixmap: QPixmap) -> None:
|
||||
threads.append(QThread.currentThread() == card.thread())
|
||||
original_cover(card, pixmap)
|
||||
|
||||
def sample() -> None:
|
||||
ticks.append(perf_counter())
|
||||
if len(threads) == len(games) and not manager._incremental_add_queue:
|
||||
loop.quit()
|
||||
|
||||
monkeypatch.setattr(GameCard, "paintEvent", record_paint)
|
||||
monkeypatch.setattr(GameCard, "on_cover_loaded", record_cover)
|
||||
games = [(f"Game {i}", "", str(cover_path), "", "", str(i), "", "", "", "", 0, 0, "steam") for i in range(16)]
|
||||
loop = QEventLoop()
|
||||
timer = QTimer()
|
||||
timer.setInterval(5)
|
||||
timer.timeout.connect(sample)
|
||||
timer.start()
|
||||
QTimer.singleShot(2000, loop.quit)
|
||||
manager.set_games(games)
|
||||
loop.exec()
|
||||
timer.stop()
|
||||
assert len(threads) == len(games) and all(threads), threads
|
||||
highlighted = next((paint for paint in paints if paint[1]), None)
|
||||
assert highlighted is not None
|
||||
focus_delay = highlighted[0] - paints[0][0]
|
||||
max_gap = max(after - before for before, after in zip(ticks, ticks[1:], strict=False))
|
||||
logging.getLogger(__name__).info("%s: highlight %.1f ms, UI pause %.1f ms", animation_type, focus_delay * 1000, max_gap * 1000)
|
||||
assert focus_delay < 0.05, paints
|
||||
assert max_gap < 0.1, ticks
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scenario", [("grid", 0), ("grid", 1), ("vertical", 0), ("vertical", 1)])
|
||||
def test_failed_protondb_completion_preserves_library_scroll(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, scenario: tuple[str, int],
|
||||
) -> None:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.steam_api import api
|
||||
|
||||
manager, _ = library_scene
|
||||
manager.rebuild_library_layout(scenario[0])
|
||||
main = cast(Any, manager.main_window)
|
||||
main.stackedWidget = SimpleNamespace(currentIndex=lambda: 0)
|
||||
main.getColorPalette_from_pixmap = MagicMock()
|
||||
main.game_library_manager = manager
|
||||
main._get_portproton_desktop_files = lambda: set()
|
||||
monkeypatch.setattr(api, "load_protondb_status", lambda _appid: None)
|
||||
monkeypatch.setattr(api, "CacheManager", lambda: SimpleNamespace(cache_dir=tmp_path))
|
||||
responses: list[Callable[[str | None], None]] = []
|
||||
monkeypatch.setattr(api.downloader, "download_async",
|
||||
lambda *_args, **kwargs: responses.append(kwargs["callback"]))
|
||||
games = [(f"Game {i}", "", "", "", "", str(i), "", "", "", "", 0, 0, "steam") for i in range(40)]
|
||||
manager.set_games(games)
|
||||
QApplication.processEvents()
|
||||
assert manager._incremental_add_timer is not None
|
||||
while manager._incremental_add_queue:
|
||||
manager._process_incremental_add_batch()
|
||||
manager._incremental_add_timer.stop()
|
||||
QApplication.processEvents()
|
||||
api.get_protondb_tier_async(1, lambda _tier: MainWindow.on_games_loaded(main, games))
|
||||
assert manager.gamesScrollArea is not None
|
||||
scrollbar = manager.gamesScrollArea.verticalScrollBar()
|
||||
scrollbar.setValue(scrollbar.maximum() // 2)
|
||||
previous_value = scrollbar.value()
|
||||
assert previous_value > 0
|
||||
main.stackedWidget.currentIndex = lambda: scenario[1]
|
||||
|
||||
responses[0](None)
|
||||
QApplication.processEvents()
|
||||
QApplication.processEvents()
|
||||
|
||||
assert scrollbar.value() == previous_value
|
||||
assert not manager._focus_first_card_after_update
|
||||
|
||||
|
||||
def test_initial_favorite_icon_does_not_restart_library_loading(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
cast(Any, manager.gamesLibraryWidget).game_library_manager = manager
|
||||
update_grid = MagicMock()
|
||||
monkeypatch.setattr(manager, "update_game_grid", update_grid)
|
||||
monkeypatch.setattr("portprotonqt.game_card.favorites_config.get_games", lambda: [])
|
||||
monkeypatch.setattr("portprotonqt.game_card.favorites_config.set_games", lambda _games: None)
|
||||
game = ("Game", "", "", "", "", "steam://1", "", "", "", "", 0, 0, "steam")
|
||||
card = manager._create_game_card(game)
|
||||
manager.game_card_cache[(game[0], game[5])] = card
|
||||
QApplication.processEvents()
|
||||
update_grid.assert_not_called()
|
||||
card.update_favorite_icon()
|
||||
QApplication.processEvents()
|
||||
update_grid.assert_not_called()
|
||||
card.toggle_favorite()
|
||||
QApplication.processEvents()
|
||||
update_grid.assert_called_once()
|
||||
|
||||
|
||||
def test_vertical_library_headers_align_with_values(
|
||||
library_scene: tuple[GameLibraryManager, list[Callable[[QPixmap], None]]],
|
||||
) -> None:
|
||||
manager, _ = library_scene
|
||||
manager.rebuild_library_layout("vertical")
|
||||
assert manager._update_timer is not None
|
||||
manager._update_timer.stop()
|
||||
assert manager.gamesListLayout is not None
|
||||
assert manager.libraryHeaderWidget is not None
|
||||
card = manager._create_game_card(
|
||||
("Game", "", "", "", "", "game", "", "", "", "", 0, 0, "steam")
|
||||
)
|
||||
manager.game_card_cache[("Game", "game")] = card
|
||||
manager.gamesListLayout.addWidget(card)
|
||||
card.show()
|
||||
header = cast(Any, manager.libraryHeaderWidget.layout())
|
||||
for width in (900, 1300):
|
||||
manager.gamesLibraryWidget.resize(width, 500)
|
||||
QApplication.processEvents()
|
||||
QApplication.processEvents()
|
||||
for index in range(1, 5):
|
||||
heading = header.itemAt(index).widget()
|
||||
value = cast(Any, card.layout_.itemAt(index)).widget()
|
||||
assert heading.text() == heading.text().upper()
|
||||
if index == 1:
|
||||
assert heading.mapToGlobal(heading.rect().topLeft()).x() == value.mapToGlobal(value.rect().topLeft()).x()
|
||||
else:
|
||||
assert heading.alignment() == value.alignment() == Qt.AlignmentFlag.AlignCenter
|
||||
assert abs(heading.mapToGlobal(heading.rect().center()).x()
|
||||
- value.mapToGlobal(value.rect().center()).x()) <= 1
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Regression checks for library resize layout and batching."""
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QRect, QSize, Qt
|
||||
from PySide6.QtGui import QColor, QPixmap
|
||||
from PySide6.QtWidgets import QApplication, QHBoxLayout, QScrollArea, QVBoxLayout, QWidget
|
||||
|
||||
from portprotonqt.custom_widgets import FlowLayout, compute_layout
|
||||
from portprotonqt.game_card import AnimatedCard, GameCard
|
||||
from portprotonqt.game_library_manager import GameLibraryManager
|
||||
from portprotonqt.theme_manager import load_theme
|
||||
from portprotonqt.config import ui_config
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme_name", ["standart", "classic-light"])
|
||||
def test_list_rows_alternate_after_filtering(theme_name: str) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
parent = QWidget()
|
||||
layout = QVBoxLayout(parent)
|
||||
layout.setContentsMargins(0, 7, 0, 0)
|
||||
layout.setSpacing(4)
|
||||
theme = load_theme(theme_name)
|
||||
colors = (theme.color_bg, theme.color_surface_elevated)
|
||||
cards = []
|
||||
for _ in range(3):
|
||||
card = cast(Any, AnimatedCard(parent))
|
||||
card.list_layout = True
|
||||
card.theme = theme
|
||||
card.animations = SimpleNamespace(paint_border=lambda painter: None)
|
||||
card.setFixedSize(100, 80)
|
||||
layout.addWidget(card)
|
||||
cards.append(card)
|
||||
layout.activate()
|
||||
for index, card in enumerate(cards):
|
||||
assert card.grab().toImage().pixelColor(50, 40) == QColor(
|
||||
colors[index % 2]
|
||||
)
|
||||
cards[0].hide()
|
||||
layout.invalidate()
|
||||
layout.activate()
|
||||
assert cards[1].grab().toImage().pixelColor(50, 40) == QColor(colors[0])
|
||||
parent.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_flow_layout_preserves_rows_and_centering() -> None:
|
||||
sizes = [(100, 120), (140, 80), (80, 150), (180, 90)]
|
||||
positions, height = compute_layout(sizes, 320, 20, 1.0)
|
||||
assert positions == [[30, 0, 100, 120], [150, 0, 140, 80],
|
||||
[20, 140, 80, 150], [120, 140, 180, 90]]
|
||||
assert height == 290
|
||||
assert compute_layout([], 320, 20, 1.0) == ([], 0)
|
||||
assert compute_layout([(100, 120)], 40, 20, 1.0) == ([[20, 0, 100, 120]], 120)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("centered", [False, True])
|
||||
def test_flow_layout_wrapped_row_alignment(centered: bool) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
parent = QWidget()
|
||||
layout = FlowLayout(parent)
|
||||
if centered:
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignHCenter)
|
||||
widgets = []
|
||||
for width in (100, 100, 100):
|
||||
widget = QWidget(parent)
|
||||
widget.setFixedSize(width, 30)
|
||||
layout.addWidget(widget)
|
||||
widget.show()
|
||||
widgets.append(widget)
|
||||
layout.setGeometry(QRect(0, 0, 300, 100))
|
||||
assert widgets[0].x() == 40
|
||||
assert widgets[1].x() == 160
|
||||
assert widgets[2].x() == (100 if centered else 40)
|
||||
parent.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_flow_minimum_size_tracks_fixed_hidden_and_removed_widgets() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
parent = QWidget()
|
||||
layout = FlowLayout(parent)
|
||||
card = QWidget(parent)
|
||||
card.setFixedSize(100, 150)
|
||||
layout.addWidget(card)
|
||||
card.show()
|
||||
assert layout.minimumSize() == QSize(140, 190)
|
||||
assert layout._get_visible_data()[2] == [(100, 150)]
|
||||
card.setFixedSize(200, 300)
|
||||
assert layout.minimumSize() == QSize(240, 340)
|
||||
assert layout._get_visible_data()[2] == [(200, 300)]
|
||||
card.hide()
|
||||
assert layout.minimumSize() == QSize(40, 40)
|
||||
assert layout._get_visible_data()[2] == []
|
||||
layout.takeAt(0)
|
||||
assert layout.minimumSize() == QSize(39, 39)
|
||||
parent.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_card_resize_does_not_activate_whole_grid(
|
||||
tmp_config_dir: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
parent = QWidget()
|
||||
layout = FlowLayout(parent)
|
||||
card = GameCard("Test", "", "", "", "", "", "", "", "", "", 0, 0,
|
||||
"steam", select_callback=lambda _: None,
|
||||
theme=load_theme("standart"), parent=parent)
|
||||
layout.addWidget(card)
|
||||
activate = MagicMock()
|
||||
monkeypatch.setattr(layout, "activate", activate)
|
||||
loader = MagicMock()
|
||||
monkeypatch.setattr(card, "_load_cover_image", loader)
|
||||
card.base_pixmap = None
|
||||
card.animated_cover_path = ""
|
||||
card.update_card_size(100)
|
||||
assert card.base_card_width == 100
|
||||
activate.assert_not_called()
|
||||
loader.assert_called_once()
|
||||
card.update_card_size(100)
|
||||
loader.assert_called_once()
|
||||
card.stop_background_activity()
|
||||
parent.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_card_resize_batches_keep_latest_width_and_skip_removed_cards() -> None:
|
||||
cards = {(str(index), ""): MagicMock() for index in range(40)}
|
||||
manager = cast(Any, SimpleNamespace(
|
||||
gamesListWidget=MagicMock(), gamesListLayout=MagicMock(),
|
||||
game_card_cache=cards, _card_resize_queue=deque(cards),
|
||||
_card_resize_batch_size=8, _card_resize_timer=MagicMock(), card_width=100,
|
||||
force_update_cards_library=MagicMock(), load_visible_images=MagicMock(),
|
||||
))
|
||||
GameLibraryManager._resize_next_card_batch(manager)
|
||||
assert sum(card.update_card_size.call_count for card in cards.values()) == 8
|
||||
assert len(manager._card_resize_queue) == 32
|
||||
assert manager.gamesListLayout.setEnabled.call_args.args == (True,)
|
||||
manager.gamesListLayout.activate.assert_called_once()
|
||||
manager.load_visible_images.assert_not_called()
|
||||
manager.card_width = 220
|
||||
removed = cards.pop(("20", ""))
|
||||
while manager._card_resize_queue:
|
||||
GameLibraryManager._resize_next_card_batch(manager)
|
||||
removed.update_card_size.assert_not_called()
|
||||
cards[("39", "")].update_card_size.assert_called_once_with(220)
|
||||
manager.force_update_cards_library.assert_called_once()
|
||||
manager.load_visible_images.assert_called_once()
|
||||
assert manager.gamesListLayout.setEnabled.call_args.args == (True,)
|
||||
manager.gamesListWidget.setUpdatesEnabled.assert_not_called()
|
||||
|
||||
|
||||
def test_card_resize_reuses_loaded_cover_and_animation() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
card = cast(Any, SimpleNamespace(
|
||||
base_card_width=180, base_pixmap=QPixmap(250, 375),
|
||||
animated_cover_path="", card_geometry_cfg={"cover_aspect_ratio": 1.5},
|
||||
cover_path="cover.png", _load_cover_image=MagicMock(), update_scale=MagicMock(),
|
||||
))
|
||||
GameCard.update_card_size(card, 100)
|
||||
GameCard.update_card_size(card, 250)
|
||||
card._load_cover_image.assert_not_called()
|
||||
GameCard.update_card_size(card, 300)
|
||||
card._load_cover_image.assert_called_once_with("cover.png")
|
||||
card.animated_cover_path = "animated.gif"
|
||||
GameCard.update_card_size(card, 400)
|
||||
card._load_cover_image.assert_called_once()
|
||||
assert card.update_scale.call_count == 4
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("orientation", ["horizontal", "vertical"])
|
||||
def test_horizontal_cards_fit_viewport_height(
|
||||
tmp_config_dir: Path, monkeypatch: pytest.MonkeyPatch, orientation: str,
|
||||
) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
monkeypatch.setattr(ui_config, "get_horizontal_card_orientation", lambda _: orientation)
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
parent = QWidget()
|
||||
parent.setProperty("library_layout_mode", "horizontal")
|
||||
layout = QHBoxLayout(parent)
|
||||
theme = load_theme("standart")
|
||||
card = GameCard("Test", "", "", "", "", "", "", "", "", "", 0, 0,
|
||||
"steam", select_callback=lambda _: None, theme=theme, parent=parent)
|
||||
layout.addWidget(card)
|
||||
scroll.setWidget(parent)
|
||||
manager = cast(Any, SimpleNamespace(
|
||||
layout_mode="horizontal", gamesScrollArea=scroll, gamesListLayout=layout,
|
||||
game_card_cache={("Test", ""): card}, card_width=250, theme=theme,
|
||||
))
|
||||
scroll.show()
|
||||
for height in (600, 300, 200, 600):
|
||||
scroll.resize(800, height)
|
||||
app.processEvents()
|
||||
GameLibraryManager._fit_horizontal_cards(manager)
|
||||
layout.activate()
|
||||
app.processEvents()
|
||||
margins = layout.contentsMargins()
|
||||
assert card.height() + margins.top() + margins.bottom() <= scroll.viewport().height()
|
||||
assert card.nameLabel.geometry().bottom() < card.height()
|
||||
assert card.nameLabel.height() >= card.nameLabel.sizeHint().height()
|
||||
assert card.base_card_width <= manager.card_width
|
||||
assert card.base_card_width == manager.card_width
|
||||
card.stop_background_activity()
|
||||
scroll.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("initial_width,target_width", [(100, 250), (250, 100)])
|
||||
def test_large_resize_keeps_cards_separate_between_batches(
|
||||
initial_width: int, target_width: int,
|
||||
) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
content = QWidget()
|
||||
layout = FlowLayout(content)
|
||||
cards = {}
|
||||
for index in range(40):
|
||||
card = cast(Any, QWidget(content))
|
||||
card.setFixedSize(initial_width, initial_width * 2)
|
||||
card.update_card_size = MagicMock(
|
||||
side_effect=lambda width, card=card: card.setFixedSize(width, width * 2)
|
||||
)
|
||||
layout.addWidget(card)
|
||||
cards[(str(index), "")] = card
|
||||
scroll.setWidget(content)
|
||||
scroll.resize(800, 700)
|
||||
scroll.show()
|
||||
app.processEvents()
|
||||
manager = cast(Any, SimpleNamespace(
|
||||
gamesListWidget=content, gamesListLayout=layout, game_card_cache=cards,
|
||||
_card_resize_queue=deque(cards), _card_resize_batch_size=8,
|
||||
_card_resize_timer=MagicMock(), card_width=target_width,
|
||||
force_update_cards_library=MagicMock(), load_visible_images=MagicMock(),
|
||||
))
|
||||
try:
|
||||
while manager._card_resize_queue:
|
||||
GameLibraryManager._resize_next_card_batch(manager)
|
||||
app.processEvents()
|
||||
geometries = [card.geometry() for card in cards.values()]
|
||||
for index, geometry in enumerate(geometries):
|
||||
assert all(not geometry.intersects(other) for other in geometries[index + 1:])
|
||||
finally:
|
||||
scroll.close()
|
||||
@@ -12,8 +12,8 @@ from unittest.mock import MagicMock
|
||||
|
||||
import psutil
|
||||
from pytest import MonkeyPatch, fixture, mark
|
||||
from PySide6.QtCore import QEventLoop, QObject, QPoint, QPointF, Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QPixmap, QWheelEvent
|
||||
from PySide6.QtCore import QEvent, QEventLoop, QObject, QPoint, QPointF, Qt, QTimer
|
||||
from PySide6.QtGui import QAction, QMouseEvent, QPixmap, QWheelEvent
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
@@ -21,6 +21,7 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMenu,
|
||||
QMainWindow,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
@@ -64,6 +65,107 @@ from portprotonqt.tabs.theme_tab import (
|
||||
)
|
||||
from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
from portprotonqt.tabs.wine_tab import MainWindowWineTabMixin as WineMixin
|
||||
from portprotonqt.tabs.control_hints import MainWindowControlHintsMixin
|
||||
import portprotonqt.localization as localization
|
||||
|
||||
|
||||
@mark.parametrize("fullscreen, enabled", [(False, True), (True, True), (True, False)])
|
||||
def test_fullscreen_cursor_hides_after_inactivity(monkeypatch: MonkeyPatch, fullscreen: bool, enabled: bool) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
window = MainWindow.__new__(MainWindow)
|
||||
QMainWindow.__init__(window)
|
||||
monkeypatch.setattr(window, "_update_animated_covers_activity", lambda: None)
|
||||
monkeypatch.setattr(window, "_set_animated_covers_suspended", lambda value: None)
|
||||
monkeypatch.setattr(QApplication, "applicationState", lambda: Qt.ApplicationState.ApplicationActive)
|
||||
monkeypatch.setattr(main_window_module.display_config, "get_hide_cursor", lambda: enabled)
|
||||
should_hide = fullscreen and enabled
|
||||
window.theme = SimpleNamespace(cursorHideTimeoutMs=5000)
|
||||
window._cursor_hidden = False
|
||||
window._cursor_timer = QTimer(window)
|
||||
window._cursor_timer.setSingleShot(True)
|
||||
window._cursor_timer.timeout.connect(window._hide_cursor)
|
||||
app.installEventFilter(window)
|
||||
child = QWidget(window)
|
||||
child.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
try:
|
||||
window.showFullScreen() if fullscreen else window.show()
|
||||
assert QApplication.overrideCursor() is None
|
||||
assert window._cursor_timer.isActive() == should_hide
|
||||
window._cursor_timer.stop()
|
||||
window._cursor_timer.timeout.emit()
|
||||
assert window._cursor_hidden == should_hide
|
||||
event = QMouseEvent(
|
||||
QEvent.Type.MouseMove, QPointF(1, 1), QPointF(1, 1),
|
||||
Qt.MouseButton.NoButton, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier,
|
||||
)
|
||||
app.sendEvent(child, event)
|
||||
assert QApplication.overrideCursor() is None
|
||||
assert child.cursor().shape() == Qt.CursorShape.PointingHandCursor
|
||||
assert window._cursor_timer.isActive() == should_hide
|
||||
if should_hide:
|
||||
assert window._cursor_timer.interval() == 5000
|
||||
window._cursor_timer.stop()
|
||||
window._cursor_timer.timeout.emit()
|
||||
assert window._cursor_hidden == should_hide
|
||||
monkeypatch.setattr(QApplication, "applicationState", lambda: Qt.ApplicationState.ApplicationInactive)
|
||||
window._on_application_state_changed(Qt.ApplicationState.ApplicationInactive)
|
||||
assert QApplication.overrideCursor() is None
|
||||
monkeypatch.setattr(QApplication, "applicationState", lambda: Qt.ApplicationState.ApplicationActive)
|
||||
window._on_application_state_changed(Qt.ApplicationState.ApplicationActive)
|
||||
assert window._cursor_timer.isActive() == should_hide
|
||||
window.hide()
|
||||
assert QApplication.overrideCursor() is None
|
||||
assert not window._cursor_timer.isActive()
|
||||
finally:
|
||||
window.hide()
|
||||
window._update_cursor_visibility()
|
||||
app.removeEventFilter(window)
|
||||
|
||||
|
||||
def test_disabling_cursor_hiding_restores_cursor(monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(main_window_module.display_config, "get_hide_cursor", lambda: False)
|
||||
restore = MagicMock()
|
||||
monkeypatch.setattr(QApplication, "restoreOverrideCursor", restore)
|
||||
window = SimpleNamespace(
|
||||
_cursor_hidden=True, _cursor_timer=MagicMock(),
|
||||
isFullScreen=lambda: True, isVisible=lambda: True, isMinimized=lambda: False,
|
||||
)
|
||||
MainWindow._update_cursor_visibility(cast(Any, window))
|
||||
restore.assert_called_once_with()
|
||||
window._cursor_timer.stop.assert_called_once_with()
|
||||
assert not window._cursor_hidden
|
||||
|
||||
|
||||
@mark.parametrize("initial_english", [True, False])
|
||||
def test_gamepad_hint_defaults_follow_language_change(
|
||||
monkeypatch: MonkeyPatch, initial_english: bool
|
||||
) -> None:
|
||||
force_english = initial_english
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.config.ui_config.get_force_english", lambda: force_english
|
||||
)
|
||||
translations = {"Select": "Выбрать", "Volume": "Громкость"}
|
||||
monkeypatch.setattr(localization.translate, "gettext", lambda text: translations.get(text, text))
|
||||
monkeypatch.setattr(localization, "_translation_sources", {})
|
||||
window = SimpleNamespace(
|
||||
gamepadHintDefaultTexts={
|
||||
"confirm": localization._("Select"),
|
||||
"decrease_size": localization._("Volume") + " -",
|
||||
"increase_size": localization._("Volume") + " +",
|
||||
},
|
||||
_setGamepadHintText=MagicMock(),
|
||||
_setGamepadHintVisible=MagicMock(),
|
||||
stackedWidget=SimpleNamespace(currentIndex=lambda: 0),
|
||||
system_tab_index=1,
|
||||
)
|
||||
|
||||
for english_enabled in (not initial_english, initial_english):
|
||||
force_english = english_enabled
|
||||
MainWindowControlHintsMixin._updateSystemGamepadHintTexts(cast(Any, window))
|
||||
window._setGamepadHintText.assert_any_call("confirm", localization._("Select"))
|
||||
window._setGamepadHintText.assert_any_call("decrease_size", localization._("Volume") + " -")
|
||||
window._setGamepadHintText.assert_any_call("increase_size", localization._("Volume") + " +")
|
||||
window._setGamepadHintText.reset_mock()
|
||||
|
||||
|
||||
@fixture(autouse=True)
|
||||
@@ -559,6 +661,7 @@ def test_vertical_theme_rebuilds_autoinstall_layout() -> None:
|
||||
window = cast(Any, AutoInstallMixin())
|
||||
window.theme = SimpleNamespace(LIBRARY_LAYOUT_MODE="vertical")
|
||||
window.autoInstallContainer = MagicMock()
|
||||
window.autoInstallStatusHeader = MagicMock()
|
||||
window._set_autoinstall_container_layout = MagicMock()
|
||||
window.auto_size_slider = MagicMock()
|
||||
window.auto_size_slider.maximum.return_value = 250
|
||||
@@ -568,6 +671,7 @@ def test_vertical_theme_rebuilds_autoinstall_layout() -> None:
|
||||
window.refresh_autoinstall_layout()
|
||||
|
||||
window._set_autoinstall_container_layout.assert_called_once_with("vertical")
|
||||
window.autoInstallStatusHeader.setVisible.assert_called_once_with(True)
|
||||
window.auto_size_slider.setVisible.assert_called_once_with(False)
|
||||
assert window.auto_card_width == 250
|
||||
assert window.autoInstallLoaded is False
|
||||
@@ -694,17 +798,24 @@ def test_auto_hide_scroll_area_tracks_horizontal_overflow() -> None:
|
||||
assert scroll_area._h_hide_timer.isActive()
|
||||
|
||||
|
||||
def test_auto_hide_scroll_area_uses_wheel_for_horizontal_overflow() -> None:
|
||||
@mark.parametrize("layout_mode", [None, "horizontal", "horizontal_top", "grid"])
|
||||
@mark.parametrize("content_height", [50, 300])
|
||||
def test_auto_hide_scroll_area_uses_wheel_for_horizontal_overflow(
|
||||
layout_mode: str | None, content_height: int,
|
||||
) -> None:
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
theme = SimpleNamespace(TRANSPARENT_BACKGROUND_STYLE="", SCROLL_STYLE="")
|
||||
scroll_area = AutoHideScrollArea(theme=theme)
|
||||
scroll_area.resize(100, 100)
|
||||
content = QWidget()
|
||||
content.setMinimumSize(300, 50)
|
||||
content.setMinimumSize(300, content_height)
|
||||
content.setProperty("library_layout_mode", layout_mode)
|
||||
scroll_area.setWidget(content)
|
||||
scroll_area.show()
|
||||
QApplication.processEvents()
|
||||
scroll_area.horizontalScrollBar().setValue(100)
|
||||
scroll_area.verticalScrollBar().setValue(100)
|
||||
vertical_value = scroll_area.verticalScrollBar().value()
|
||||
event = QWheelEvent(
|
||||
QPointF(), QPointF(), QPoint(), QPoint(0, 120),
|
||||
Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier,
|
||||
@@ -713,7 +824,13 @@ def test_auto_hide_scroll_area_uses_wheel_for_horizontal_overflow() -> None:
|
||||
|
||||
scroll_area.wheelEvent(event)
|
||||
|
||||
assert scroll_area.horizontalScrollBar().value() < 100
|
||||
if content_height == 50 or layout_mode in {"horizontal", "horizontal_top"}:
|
||||
assert scroll_area.horizontalScrollBar().value() < 100
|
||||
assert scroll_area.verticalScrollBar().value() == vertical_value
|
||||
else:
|
||||
assert scroll_area.horizontalScrollBar().value() == 100
|
||||
assert scroll_area.verticalScrollBar().value() < vertical_value
|
||||
scroll_area.close()
|
||||
|
||||
|
||||
@mark.parametrize("layout_mode", ["vertical", "horizontal", "horizontal_top"])
|
||||
@@ -1194,9 +1311,17 @@ def test_library_source_filter_stays_top_aligned_without_checkbox(
|
||||
|
||||
LibraryMixin._add_library_filter_controls(window, controls_layout)
|
||||
|
||||
display_filter_item = controls_layout.itemAtPosition(0, 1)
|
||||
display_filter_item = controls_layout.itemAtPosition(1, 0)
|
||||
assert display_filter_item is not None
|
||||
assert display_filter_item.widget() is test_window.gamesDisplayCombo
|
||||
assert controls_layout.columnCount() == 1
|
||||
for row, widget in enumerate((
|
||||
test_window.gamesSortCombo, test_window.gamesDisplayCombo,
|
||||
test_window.gamesBadgeViewCombo, test_window.gamesLayoutCombo,
|
||||
test_window.onlyInstalledCheckBox,
|
||||
)):
|
||||
assert controls_layout.indexOf(widget) >= 0
|
||||
assert controls_layout.getItemPosition(controls_layout.indexOf(widget)) == (row, 0, 1, 1)
|
||||
controls_widget.show()
|
||||
_application.processEvents()
|
||||
expanded_height = controls_widget.sizeHint().height()
|
||||
@@ -2340,6 +2465,36 @@ def test_installed_filter_reuses_loaded_store_games(monkeypatch: MonkeyPatch) ->
|
||||
window.loadGames.assert_not_called()
|
||||
|
||||
|
||||
@mark.parametrize("index, mode", [
|
||||
(4, "horizontal_top"), (5, "horizontal_top_vertical"),
|
||||
(6, "horizontal"), (7, "horizontal_vertical"),
|
||||
])
|
||||
def test_horizontal_layout_menu_maps_endless_modes(
|
||||
index: int, mode: str, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
application = QApplication.instance() or QApplication([])
|
||||
assert application is not None
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_economy_mode", lambda: False)
|
||||
monkeypatch.setattr(library_tab_module.ui_config, "get_library_layout_mode", lambda _: "theme")
|
||||
window = cast(Any, SimpleNamespace(
|
||||
theme=SimpleNamespace(CHECKBOX_STYLE=""),
|
||||
_create_library_combo=lambda labels, tooltip: QComboBox(),
|
||||
_set_combo_current_key=MagicMock(),
|
||||
_register_gamepad_tooltip=MagicMock(),
|
||||
_on_library_sort_changed=MagicMock(),
|
||||
_on_library_filter_changed=MagicMock(),
|
||||
_on_only_installed_changed=MagicMock(),
|
||||
_on_library_badge_view_changed=MagicMock(),
|
||||
_on_library_layout_changed=MagicMock(),
|
||||
_delay_library_controls_hover_close=MagicMock(),
|
||||
))
|
||||
controls = QWidget()
|
||||
|
||||
LibraryMixin._add_library_filter_controls(window, QGridLayout(controls))
|
||||
|
||||
assert window.library_layout_keys[index] == mode
|
||||
|
||||
|
||||
def test_library_layout_control_rebuilds_both_libraries(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
@@ -2408,17 +2563,20 @@ def test_vertical_horizontal_layout_saves_card_orientation(
|
||||
manager.rebuild_library_layout.assert_called_once_with(layout_mode)
|
||||
|
||||
|
||||
def test_incremental_game_add_updates_search_index() -> None:
|
||||
@mark.parametrize("shared_lists", [False, True])
|
||||
def test_incremental_game_add_updates_search_index(shared_lists: bool) -> None:
|
||||
game = ("New Game", "", "", "", "", "/games/new.exe")
|
||||
manager = GameLibraryManager.__new__(GameLibraryManager)
|
||||
manager.games = []
|
||||
manager.filtered_games = []
|
||||
manager.filtered_games = manager.games if shared_lists else []
|
||||
manager.search_optimizer = SearchOptimizer()
|
||||
manager.update_game_grid = MagicMock()
|
||||
manager._update_missing_exe_button = MagicMock()
|
||||
|
||||
manager.add_game_incremental(game)
|
||||
|
||||
assert manager.games == [game]
|
||||
assert manager.filtered_games == [game]
|
||||
assert search_index(manager.search_optimizer, "new") == [game]
|
||||
|
||||
|
||||
@@ -2764,3 +2922,68 @@ def test_replace_game_refreshes_matching_detail_page(
|
||||
}
|
||||
else:
|
||||
assert detail_data == {"name": current_name, "exec_line": "old.exe", "appid": "42"}
|
||||
|
||||
|
||||
def test_shutdown_waits_for_autoinstall_status_workers() -> None:
|
||||
worker = MagicMock()
|
||||
worker.isRunning.side_effect = [True, False]
|
||||
window = SimpleNamespace(autoInstallStatusWorkers=[worker])
|
||||
window._stopWorkerThread = MethodType(MainWindowWorkersMixin._stopWorkerThread, window)
|
||||
window._stopWorkerThreads = MethodType(MainWindowWorkersMixin._stopWorkerThreads, window)
|
||||
window._stopWorker = MethodType(MainWindowWorkersMixin._stopWorker, window)
|
||||
|
||||
MainWindowWorkersMixin._stopBackgroundWorkers(cast(Any, window))
|
||||
|
||||
worker.requestInterruption.assert_called_once_with()
|
||||
worker.wait.assert_called_once_with()
|
||||
assert window.autoInstallStatusWorkers == []
|
||||
|
||||
|
||||
@mark.parametrize("mode", ("list", "vertical"))
|
||||
def test_autoinstall_table_columns_match_header(
|
||||
monkeypatch: MonkeyPatch, tmp_config_dir: Path, mode: str,
|
||||
) -> None:
|
||||
from PySide6.QtWidgets import QMainWindow, QStackedWidget
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
window_type = type("AutoInstallWindow", (QMainWindow, cast(Any, AutoInstallMixin)), {})
|
||||
window = cast(Any, window_type())
|
||||
window.theme = load_theme("standart")
|
||||
window.theme_manager = ThemeManager()
|
||||
window.auto_card_width = 250
|
||||
window.stackedWidget = QStackedWidget()
|
||||
window.setCentralWidget(window.stackedWidget)
|
||||
window._gamepad_tooltip_map = {}
|
||||
for method in ("_register_gamepad_tooltip", "_setup_autoinstall_search_animation",
|
||||
"_build_autoinstall_search_indices", "filterAutoInstallGames",
|
||||
"_load_autoinstall_statuses"):
|
||||
setattr(window, method, MagicMock())
|
||||
monkeypatch.setattr(autoinstall_tab_module.ui_config, "get_library_layout_mode", lambda _: mode)
|
||||
monkeypatch.setattr("portprotonqt.game_card.load_pixmap_async", lambda *args, **kwargs: None)
|
||||
window.createAutoInstallTab()
|
||||
window._on_autoinstall_games_loaded([
|
||||
("Game", "", "", "", "", "autoinstall:/tmp/game.ppai", "Never", "0h 0m",
|
||||
"", "", 0, 0, "autoinstall", "game_1"),
|
||||
])
|
||||
card = window.autoInstallGameCards["game_1"]
|
||||
header = window.autoInstallStatusHeader.layout()
|
||||
assert header.count() == 5
|
||||
assert card.layout_.count() == 4
|
||||
assert card.layout_.itemAt(1).widget() is card.nameLabel
|
||||
assert card.layout_.itemAt(2).widget().text() == "autoinstall"
|
||||
assert card.layout_.itemAt(3).widget() is window.autoInstallStatusLabels["game_1"]
|
||||
window.resize(1200, 500)
|
||||
window.show()
|
||||
_application.processEvents()
|
||||
for index, stretch in enumerate(window.theme.autoinstallColumnStretches, start=1):
|
||||
assert header.stretch(index) == card.layout_.stretch(index) == stretch
|
||||
heading = header.itemAt(index).widget()
|
||||
assert heading.text() == heading.text().upper()
|
||||
if index > 1:
|
||||
value = card.layout_.itemAt(index).widget()
|
||||
assert heading.alignment() == value.alignment() == Qt.AlignmentFlag.AlignCenter
|
||||
assert abs(heading.mapTo(window, heading.rect().center()).x()
|
||||
- value.mapTo(window, value.rect().center()).x()) <= 1
|
||||
window.stackedWidget.deleteLater()
|
||||
window.deleteLater()
|
||||
@@ -29,6 +29,11 @@ def test_scripts_path_uses_xdg_data_dirs(
|
||||
monkeypatch.setenv("XDG_DATA_DIRS", str(data_dir))
|
||||
monkeypatch.delenv("APPDIR", raising=False)
|
||||
monkeypatch.delenv("SHARUN_DIR", raising=False)
|
||||
monkeypatch.delenv("APPIMAGE", raising=False)
|
||||
is_file = Path.is_file
|
||||
monkeypatch.setattr(
|
||||
Path, "is_file", lambda path: path.is_relative_to(tmp_path) and is_file(path)
|
||||
)
|
||||
|
||||
assert get_portproton_scripts_path() == str(scripts_dir)
|
||||
|
||||
@@ -272,3 +277,96 @@ def test_vk_gpu_info_uses_build_aux_binary() -> None:
|
||||
|
||||
assert '/../../../bin/vk_gpu_info"' in helper
|
||||
assert "dev-scripts/vk_gpu_info" not in helper
|
||||
|
||||
|
||||
def test_reshade_prefers_directx_for_unity() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert "source=$(echo \"$detect\" | grep '^SOURCE=' | cut -d= -f2-)" in helper
|
||||
assert (
|
||||
'[[ "${source##*/}" == UnityPlayer.dll && "$highest" != None ]] '
|
||||
"&& vulkan=false"
|
||||
) in helper
|
||||
|
||||
|
||||
def test_reshade_and_optiscaler_are_mutually_exclusive() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_OPTISCALER PW_USE_SPECIALK"' in helper
|
||||
assert 'DISABLE_EDIT_DB_LIST+=" PW_USE_RESHADE"' in helper
|
||||
|
||||
|
||||
def test_reshade_repeated_link_does_not_create_nested_shaders(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
link_function = helper.split("try_force_link_dir () {", 1)[1].split("\n}", 1)[0]
|
||||
shaders = tmp_path / "shaders"
|
||||
shaders.mkdir()
|
||||
game = tmp_path / "game"
|
||||
game.mkdir()
|
||||
script = f'try_force_link_dir () {{{link_function}\n}}\n'
|
||||
link_call = helper.split(' [[ "$PW_USE_RESHADE" != 1 ]] && return 0', 1)[1]
|
||||
link_call = link_call.split(' pw_specialk_set_ini_value', 1)[0]
|
||||
script += f'root="$1"\nPATH_TO_GAME="$2"\n{link_call * 2}'
|
||||
|
||||
subprocess.run(
|
||||
["bash", "-c", script, "bash", str(tmp_path), str(game)],
|
||||
check=True,
|
||||
)
|
||||
|
||||
assert (game / "ReShade_shaders").resolve() == shaders
|
||||
assert not (shaders / "shaders").exists()
|
||||
|
||||
|
||||
def test_reshade_disable_cleans_unreal_shipping_directory(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
sync_function = helper.split("pw_reshade_sync_files () {", 1)[1].split("\n}", 1)[0]
|
||||
game_root = tmp_path / "game"
|
||||
game_root.mkdir()
|
||||
game_exe = game_root / "Praest.exe"
|
||||
game_exe.touch()
|
||||
game = game_root / "Praest" / "Binaries" / "Win64"
|
||||
game.mkdir(parents=True)
|
||||
runtime = tmp_path / "plugins" / "reshade_windows" / "reshade" / "6.8.0_Addon"
|
||||
runtime.mkdir(parents=True)
|
||||
reshade_dll = runtime / "ReShade32.dll"
|
||||
reshade_dll.touch()
|
||||
(game / "opengl32.dll").symlink_to(reshade_dll)
|
||||
(game / "opengl32.dll.b").symlink_to(reshade_dll)
|
||||
(game / "ReShadePreset.ini").write_text("[GENERAL]\n")
|
||||
(game / "reshade_version").write_text("6.8.0_Addon\nopengl32.dll\n")
|
||||
script = f'pw_reshade_sync_files () {{{sync_function}\n}}\n'
|
||||
script += 'try_remove_file () { rm -f "$1"; }\npw_reshade_sync_files\n'
|
||||
|
||||
subprocess.run(
|
||||
["bash", "-c", script],
|
||||
check=True,
|
||||
env={
|
||||
**os.environ,
|
||||
"PW_EXE_FILE": str(game_exe),
|
||||
"PW_PLUGINS_PATH": str(tmp_path / "plugins"),
|
||||
"PW_USE_RESHADE": "0",
|
||||
"PW_RESHADE_API": "",
|
||||
"PW_RESHADE_EXE": "",
|
||||
"PATH_TO_GAME": str(game),
|
||||
},
|
||||
)
|
||||
|
||||
assert not (game / "opengl32.dll").exists()
|
||||
assert not (game / "opengl32.dll.b").exists()
|
||||
assert not (game / "ReShadePreset.ini").exists()
|
||||
|
||||
|
||||
def test_ini_writer_matches_spaces_around_equals() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert '^[[:space:]]*$key[[:space:]]*=' in helper
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Tests for the bundled PortProton shell scripts."""
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from pytest import mark
|
||||
|
||||
|
||||
@mark.parametrize("case", [
|
||||
("0", "0", "", ""),
|
||||
("1", "1", "", ""),
|
||||
("1", "0", "", 'export MANGOHUD_CONFIG="cpu_stats,font_size=24"\n'),
|
||||
("1", "0", "gpu_stats", ""),
|
||||
])
|
||||
def test_gui_edit_db_only_creates_enabled_mangohud_config(
|
||||
tmp_path: Path, case: tuple[str, str, str, str],
|
||||
) -> None:
|
||||
enabled, user_conf, existing, expected = case
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
gui_body = helper.split("gui_edit_db () {", 1)[1].split(
|
||||
"# Gamescope configuration and processing", 1,
|
||||
)[0]
|
||||
block = gui_body.split(' edit_db_from_gui "${PW_EDIT_DB_FINAL_LIST[@]}"', 1)[1]
|
||||
ppdb = tmp_path / "game.exe.ppdb"
|
||||
ppdb.touch()
|
||||
script = '''
|
||||
edit_db_from_gui () {
|
||||
printf 'export MANGOHUD_CONFIG="%s"\\n' "$MANGOHUD_CONFIG" >> "$PORTWINE_DB_FILE"
|
||||
}
|
||||
gui_config () {
|
||||
''' + block + '\ngui_config\n'
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True, check=False,
|
||||
env={"PATH": os.defpath, "PORTWINE_DB_FILE": str(ppdb), "MANGOHUD": "1",
|
||||
"PW_MANGOHUD": enabled, "PW_MANGOHUD_USER_CONF": user_conf,
|
||||
"MANGOHUD_CONFIG": existing, "DEFAULT_MANGOHUD_CONFIG": "cpu_stats",
|
||||
"PW_SCREEN_RESOLUTION": "1920x1080"},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert ppdb.read_text() == expected
|
||||
|
||||
|
||||
@mark.parametrize("second_name", ["Game.exe", "Trainer.exe"])
|
||||
def test_run_after_accepts_same_exe(tmp_path: Path, second_name: str) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
condition = helper.split(' if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]', 1)[1].split(
|
||||
" then", 1,
|
||||
)[0]
|
||||
main_exe = tmp_path / "Game.exe"
|
||||
second_exe = tmp_path / second_name
|
||||
main_exe.touch()
|
||||
second_exe.touch()
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'if [[ -n "${PW_RUN_AFTER_EXE:-}" ]]' + condition
|
||||
+ "then exit 0; else exit 1; fi"],
|
||||
env={**os.environ, "PW_EXE_FILE": str(main_exe),
|
||||
"PW_RUN_AFTER_EXE": str(second_exe)},
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_run_after_batch_is_created_next_to_exe() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert 'run_after_dir="$(dirname "${PW_EXE_FILE}")"' in helper
|
||||
assert 'pw_exe_file_win="$("${WINELOADER}" winepath -w "${PW_EXE_FILE}"' in helper
|
||||
assert "chcp 65001 >nul" in helper
|
||||
assert 'start "" "%PW_RUN_AFTER_MAIN_PATH%" ${LAUNCH_PARAMETERS}' in helper
|
||||
assert 'start "" "%PW_RUN_AFTER_SECOND_PATH%"' in helper
|
||||
assert 'LAUNCH_PARAMETERS="" proxy_launch_parameters="" \\' in helper
|
||||
assert 'pw_run "${PW_VD_TMP[@]}" "${run_after_bat}"' in helper
|
||||
|
||||
|
||||
@mark.parametrize("mapped_path", [
|
||||
"D:\\Загрузки\\Trainer.exe",
|
||||
"E:\\Тестовые файлы\\Утилиты\\Папка 123 @тест\\Test Trainer.exe",
|
||||
"",
|
||||
])
|
||||
def test_run_after_exe_windows_path(tmp_path: Path, mapped_path: str) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
conversion = helper.split(' run_after_exe_win="$(', 1)[1].split(
|
||||
'\n\n cat >', 1,
|
||||
)[0]
|
||||
exe_path = (
|
||||
tmp_path / "mnt" / "test-drive" / "Тестовые файлы" / "Утилиты"
|
||||
/ "Папка 123 @тест" / "Test Trainer.exe"
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'loader() { printf "%s\\r\\n" "$MAPPED_PATH"; }\n'
|
||||
'WINELOADER=loader\nwine_drive_c="$WINEPREFIX/drive_c"\n'
|
||||
'run_after_exe_win="$(' + conversion + '\nprintf "%s" "$run_after_exe_win"'],
|
||||
env={**os.environ, "PW_RUN_AFTER_EXE": str(exe_path),
|
||||
"WINEPREFIX": str(tmp_path / "prefix"), "MAPPED_PATH": mapped_path},
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert result.stdout == (mapped_path or "z:" + str(exe_path).replace("/", "\\"))
|
||||
|
||||
|
||||
@mark.parametrize("trainer_path", [
|
||||
"H:\\Загрузки\\Trainer.exe",
|
||||
"E:\\Тестовые файлы\\Утилиты\\Папка 123 @тест\\Test Trainer.exe",
|
||||
])
|
||||
def test_run_after_paths_are_passed_in_environment(tmp_path: Path, trainer_path: str) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
block = helper.split(' cat > "${run_after_bat}" <<EOF\n', 1)[1].split(
|
||||
' try_remove_file "${run_after_bat}"', 1,
|
||||
)[0]
|
||||
main_path = "D:\\Games\\Test Game\\Test Game.exe"
|
||||
result = subprocess.run(
|
||||
["bash", "-c", 'pw_run() { env; }\ncat > "$run_after_bat" <<EOF\n' + block],
|
||||
env={**os.environ, "run_after_bat": str(tmp_path / "launch.bat"),
|
||||
"pw_exe_file_win": main_path, "run_after_exe_win": trainer_path,
|
||||
"wait_delay_int": "3", "LAUNCH_PARAMETERS": ""},
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
assert f"PW_RUN_AFTER_MAIN_PATH={main_path}" in result.stdout
|
||||
assert f"PW_RUN_AFTER_SECOND_PATH={trainer_path}" in result.stdout
|
||||
batch = (tmp_path / "launch.bat").read_text(encoding="ascii")
|
||||
assert 'start "" "%PW_RUN_AFTER_SECOND_PATH%"' in batch
|
||||
|
||||
|
||||
def test_game_launch_marker_is_emitted_for_wine_and_proton() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
marker = "printf '%s\\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'"
|
||||
assert helper.count(marker) == 2
|
||||
|
||||
|
||||
def test_user_conf_get_without_name_lists_active_values(tmp_path: Path) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
definition = helper.split("manage_user_conf_value () {", 1)[1].split(
|
||||
"use_exiftool () {", 1
|
||||
)[0]
|
||||
user_conf = tmp_path / "user.conf"
|
||||
user_conf.write_text(
|
||||
'# comment\nexport PW_USE_ESYNC="1"\n# export PW_USE_FSYNC="1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
script = "manage_user_conf_value () {" + definition + '\nmanage_user_conf_value get\n'
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script], capture_output=True, text=True,
|
||||
env={"PATH": os.defpath, "USER_CONF": str(user_conf)}, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout == 'PW_USE_ESYNC="1"\n'
|
||||
|
||||
|
||||
@mark.parametrize("runtime,logging", [("0", "0"), ("0", "1"), ("1", "0"), ("1", "1")])
|
||||
def test_wine_exit_code_survives_log_output_and_wineserver_wait(
|
||||
tmp_path: Path, runtime: str, logging: str,
|
||||
) -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text()
|
||||
definition = helper.split("pw_run () {", 1)[1].split("export -f pw_run", 1)[0]
|
||||
script = "pw_run () {" + definition + '''
|
||||
check_variables () { :; }
|
||||
pw_launch_wrapper () { return 37; }
|
||||
wait_wineserver () { return 0; }
|
||||
print_info () { :; }
|
||||
PATH_TO_GAME="$1"
|
||||
PW_TMPFS_PATH="$1"
|
||||
PW_LOG_FILE="$1/wine.log"
|
||||
PW_EXE_FILE="$1/game.exe"
|
||||
PW_USE_RUNTIME="$2"
|
||||
PW_LOG="$3"
|
||||
pw_run "$PW_EXE_FILE"
|
||||
exit $?
|
||||
'''
|
||||
(tmp_path / "game.exe").touch()
|
||||
|
||||
result = subprocess.run(
|
||||
["bash", "-c", script, "bash", str(tmp_path), runtime, logging],
|
||||
capture_output=True, text=True, env={"PATH": os.defpath}, check=False,
|
||||
)
|
||||
|
||||
assert result.returncode == 37, result.stderr
|
||||
assert result.stdout.count("PORTPROTONQT_GAME_EXIT_CODE=37") == 1
|
||||
|
||||
|
||||
def test_vk_gpu_info_uses_build_aux_binary() -> None:
|
||||
helper = Path("build-aux/share/portproton/scripts/functions_helper").read_text(
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
assert '/../../../bin/vk_gpu_info"' in helper
|
||||
assert "dev-scripts/vk_gpu_info" not in helper
|
||||
@@ -2,34 +2,35 @@
|
||||
|
||||
import io
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QMimeData, QUrl
|
||||
|
||||
from portprotonqt.dialogs.proton_manager import ProtonManager, sort_wine_entries
|
||||
from portprotonqt.dialogs.proton_manager import ProtonManager
|
||||
from portprotonqt.dialogs.wine_extractor import ExtractionThread
|
||||
from portprotonqt.tabs import library_tab
|
||||
|
||||
|
||||
def test_cachyos_releases_alternate_with_wineland() -> None:
|
||||
names = [
|
||||
"proton-cachyos-wineland-11.0-20260713.4-slr-x86_64",
|
||||
"proton-cachyos-wineland-11.0-20260713.4-slr-x86_64_v3",
|
||||
"proton-cachyos-wineland-11.0-20260713.3-slr-x86_64",
|
||||
"proton-cachyos-11.0-20260703-slr-x86_64",
|
||||
"proton-cachyos-11.0-20260703-slr-x86_64_v3",
|
||||
"proton-cachyos-11.0-20260702-slr-x86_64",
|
||||
def test_cachyos_v3_builds_require_supported_cpu_level(monkeypatch) -> None:
|
||||
manager = ProtonManager.__new__(ProtonManager)
|
||||
manager.cpu_level = 2
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.dialogs.proton_manager.platform.machine", lambda: "x86_64"
|
||||
)
|
||||
entries = [
|
||||
{
|
||||
"name": "proton-wineland-11.0-20260930-x86_64",
|
||||
"url": "https://example.com/proton-wineland-11.0-20260930-x86_64.tar.xz",
|
||||
},
|
||||
{
|
||||
"name": "proton-wineland-11.0-20260930-x86_64_v3",
|
||||
"url": "https://example.com/proton-wineland-11.0-20260930-x86_64_v3.tar.xz",
|
||||
},
|
||||
]
|
||||
entries = [{"name": name} for name in names]
|
||||
|
||||
assert [entry["name"] for entry in sort_wine_entries(entries, "proton_cachyos")] == [
|
||||
"proton-cachyos-11.0-20260703-slr-x86_64",
|
||||
"proton-cachyos-11.0-20260703-slr-x86_64_v3",
|
||||
"proton-cachyos-wineland-11.0-20260713.4-slr-x86_64",
|
||||
"proton-cachyos-wineland-11.0-20260713.4-slr-x86_64_v3",
|
||||
"proton-cachyos-11.0-20260702-slr-x86_64",
|
||||
"proton-cachyos-wineland-11.0-20260713.3-slr-x86_64",
|
||||
]
|
||||
assert manager.filter_entries_by_cpu_level(entries, "proton_wineland") == entries[:1]
|
||||
|
||||
|
||||
def test_wine_entries_match_host_architecture(monkeypatch) -> None:
|
||||
@@ -154,3 +155,22 @@ def test_open_wine_folder_creates_and_opens_dist(tmp_path, monkeypatch) -> None:
|
||||
wine_folder = tmp_path / "data" / "dist"
|
||||
assert wine_folder.is_dir()
|
||||
assert opened_urls[0].toLocalFile() == str(wine_folder)
|
||||
|
||||
|
||||
def test_disabled_steam_proton_scanning_keeps_local_versions(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
manager = ProtonManager.__new__(ProtonManager)
|
||||
manager.portproton_location = str(tmp_path)
|
||||
local_wine = tmp_path / "data" / "dist" / "LocalWine"
|
||||
wine_binary = local_wine / "bin" / "wine"
|
||||
wine_binary.parent.mkdir(parents=True)
|
||||
wine_binary.touch()
|
||||
monkeypatch.setattr("portprotonqt.dialogs.proton_manager.ui_config.get_disable_steam_proton_scanning", lambda: True)
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("portprotonqt.dialogs.proton_manager.get_steam_compatibilitytools_dir") as compat:
|
||||
with patch("portprotonqt.dialogs.proton_manager.get_steam_home") as home:
|
||||
assert manager.get_installed_versions() == [("LocalWine", str(local_wine))]
|
||||
compat.assert_not_called()
|
||||
home.assert_not_called()
|
||||
@@ -1,21 +1,32 @@
|
||||
"""Tests for searching MangoHud, vkBasalt, and Gamescope settings."""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from PySide6.QtCore import QEvent
|
||||
from PySide6.QtCore import QEvent, QRect, Qt, QTimer
|
||||
from PySide6.QtTest import QTest
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QBoxLayout,
|
||||
QCheckBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QDialog,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QStackedWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import CustomComboBox
|
||||
from portprotonqt.tabs.settings_tab import SettingsButtonsLayout
|
||||
|
||||
from portprotonqt.dialogs.settings_gamescope import (
|
||||
GAMESCOPE_TOGGLE_CATEGORIES,
|
||||
GAMESCOPE_TOGGLE_DESCRIPTIONS,
|
||||
@@ -26,12 +37,99 @@ from portprotonqt.dialogs.settings_mangohud import (
|
||||
MANGOHUD_TOGGLE_DESCRIPTIONS,
|
||||
MangoHudSettingsMixin,
|
||||
)
|
||||
from portprotonqt.dialogs.settings_reshade import ReShadeSettingsMixin
|
||||
from portprotonqt.dialogs.settings_vkbasalt import VkBasaltSettingsMixin
|
||||
|
||||
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_settings_dialog_search_accepts_typing_without_search_focus() -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.search_edit = QLineEdit(dialog)
|
||||
dialog.vkbasalt_toggle_key_button = None
|
||||
dialog._gamepad_tooltip_map = {}
|
||||
dialog.input_manager = None
|
||||
dialog.gamepad_tooltip = QLabel(dialog)
|
||||
dialog.gamepad_tooltip_timer = QTimer(dialog)
|
||||
button = QPushButton(dialog)
|
||||
other_input = QLineEdit(dialog)
|
||||
dialog.search_edit.show()
|
||||
button.show()
|
||||
other_input.show()
|
||||
dialog.setModal(True)
|
||||
dialog.show()
|
||||
QApplication.processEvents()
|
||||
_application.installEventFilter(dialog)
|
||||
|
||||
try:
|
||||
button.setFocus()
|
||||
QTest.keyClicks(button, "fps")
|
||||
assert dialog.search_edit.text() == "fps"
|
||||
assert QApplication.focusWidget() is button
|
||||
|
||||
QTest.keyClick(button, Qt.Key.Key_Backspace)
|
||||
assert dialog.search_edit.text() == "fp"
|
||||
QTest.keyClick(button, Qt.Key.Key_Backspace)
|
||||
QTest.keyClick(button, Qt.Key.Key_Backspace)
|
||||
assert dialog.search_edit.text() == ""
|
||||
QTest.keyClick(button, Qt.Key.Key_Backspace)
|
||||
assert dialog.search_edit.text() == ""
|
||||
assert dialog.isVisible()
|
||||
|
||||
other_input.setFocus()
|
||||
QTest.keyClicks(other_input, "text")
|
||||
assert other_input.text() == "text"
|
||||
assert dialog.search_edit.text() == ""
|
||||
finally:
|
||||
_application.removeEventFilter(dialog)
|
||||
dialog.close()
|
||||
dialog.deleteLater()
|
||||
QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
|
||||
|
||||
def test_reshade_collects_enable_exe_api_and_font() -> None:
|
||||
settings = ReShadeSettingsMixin()
|
||||
settings.current_settings = {
|
||||
"PW_USE_RESHADE": "1",
|
||||
"PW_RESHADE_SCREENSHOT_KEY": "44,0,0,0",
|
||||
"PW_RESHADE_OVERLAY_KEY": "36,0,0,0",
|
||||
}
|
||||
settings.original_values = {"PW_USE_RESHADE": "0"}
|
||||
settings.reshade_original_values = {
|
||||
"PW_RESHADE_EXE": "",
|
||||
"PW_RESHADE_API": "auto",
|
||||
"PW_RESHADE_FONT": "",
|
||||
"PW_RESHADE_SCREENSHOT_PATH": "",
|
||||
"PW_RESHADE_SCREENSHOT_KEY": "44,0,0,0",
|
||||
"PW_RESHADE_OVERLAY_KEY": "36,0,0,0",
|
||||
}
|
||||
settings.reshade_exe_edit = QLineEdit("/games/game.exe")
|
||||
settings.reshade_api_combo = CustomComboBox()
|
||||
settings.reshade_api_combo.addItems(["Auto", "Vulkan"])
|
||||
settings.reshade_api_combo.setCurrentText("Vulkan")
|
||||
settings.reshade_font_combo = CustomComboBox()
|
||||
settings.reshade_font_combo.addItem("DejaVu Sans", "DejaVu Sans")
|
||||
settings.reshade_screenshot_edit = QLineEdit("/screenshots")
|
||||
|
||||
assert settings._collect_reshade_changes() == [
|
||||
"PW_USE_RESHADE=1",
|
||||
"PW_RESHADE_EXE=/games/game.exe",
|
||||
"PW_RESHADE_API=vulkan",
|
||||
"PW_RESHADE_FONT=DejaVu Sans",
|
||||
"PW_RESHADE_SCREENSHOT_PATH=/screenshots",
|
||||
]
|
||||
assert settings._reshade_virtual_key(
|
||||
Qt.Key.Key_Semicolon.value, Qt.KeyboardModifier.NoModifier
|
||||
) == 186
|
||||
assert settings._reshade_virtual_key(
|
||||
Qt.Key.Key_1.value, Qt.KeyboardModifier.KeypadModifier
|
||||
) == 97
|
||||
|
||||
|
||||
def test_mangohud_search_ignores_unavailable_categories() -> None:
|
||||
settings = MangoHudSettingsMixin()
|
||||
settings.mangohud_category_groups = {}
|
||||
@@ -52,6 +150,42 @@ def test_mangohud_parser_recognizes_font_settings() -> None:
|
||||
assert raw_tokens == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("original_enabled", ["0", "1"])
|
||||
@pytest.mark.parametrize("original_config", ["", "cpu_stats,font_size=28"])
|
||||
def test_disabled_mangohud_preserves_config(
|
||||
monkeypatch: pytest.MonkeyPatch, original_enabled: str, original_config: str,
|
||||
) -> None:
|
||||
monkeypatch.setenv("MANGOHUD", "1")
|
||||
settings = MangoHudSettingsMixin()
|
||||
settings.current_settings = {"PW_MANGOHUD": "0", "PW_MANGOHUD_USER_CONF": "1"}
|
||||
monkeypatch.setattr(settings, "original_values", {
|
||||
"PW_MANGOHUD": original_enabled, "PW_MANGOHUD_USER_CONF": "0",
|
||||
}, raising=False)
|
||||
settings.mangohud_original_values = {"MANGOHUD_CONFIG": original_config, "FPS_LIMIT": "60"}
|
||||
|
||||
changes = settings._collect_mangohud_changes()
|
||||
|
||||
expected = ["PW_MANGOHUD_USER_CONF=1"]
|
||||
if original_enabled == "1":
|
||||
expected.insert(0, "PW_MANGOHUD=0")
|
||||
assert changes == expected
|
||||
|
||||
|
||||
def test_enabled_mangohud_collects_config_and_fps(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
settings = MangoHudSettingsMixin()
|
||||
settings.current_settings = {"PW_MANGOHUD": "1"}
|
||||
monkeypatch.setattr(settings, "original_values", {"PW_MANGOHUD": "0"}, raising=False)
|
||||
settings.mangohud_original_values = {"MANGOHUD_CONFIG": "", "FPS_LIMIT": ""}
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setChecked(True)
|
||||
settings.mangohud_fps_widgets = {"60": checkbox}
|
||||
monkeypatch.setattr(settings, "_build_mangohud_config", lambda: "fps_limit=60")
|
||||
|
||||
assert settings._collect_mangohud_changes() == [
|
||||
"PW_MANGOHUD=1", "MANGOHUD_CONFIG=fps_limit=60", "FPS_LIMIT=60",
|
||||
]
|
||||
|
||||
|
||||
def test_mangohud_font_options_use_fc_list(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
output = (
|
||||
"Noto Sans Regular\t/usr/share/fonts/NotoSans.ttf\n"
|
||||
@@ -293,3 +427,247 @@ def test_gamescope_search_uses_description_and_selects_category(
|
||||
settings._filter_gamescope_settings("full")
|
||||
QApplication.sendPostedEvents(None, QEvent.Type.DeferredDelete)
|
||||
assert fullscreen.text() == "Fullscreen window"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic-light"])
|
||||
def test_settings_table_descriptions_and_centered_controls(theme_name: str) -> None:
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
theme = ThemeWrapper(load_theme(theme_name))
|
||||
table = QTableWidget()
|
||||
table.setStyleSheet(theme.WINETRICKS_TABBLE_STYLE)
|
||||
table.viewport().setProperty("ppqt_settings_values", True)
|
||||
table.resize(1000, 600)
|
||||
table.setColumnCount(3)
|
||||
table.setColumnHidden(2, True)
|
||||
table.setRowCount(2)
|
||||
table.setColumnWidth(0, 700)
|
||||
dialog = SimpleNamespace(favorites_table=table, theme=theme)
|
||||
for row, description in enumerate(("Short <description> & text", "Long description " * 30)):
|
||||
ExeSettingsDialog._set_favorite_text_cells(
|
||||
cast(ExeSettingsDialog, dialog), row, "key", "Setting", description,
|
||||
)
|
||||
combo = CustomComboBox(theme=theme)
|
||||
combo.addItem("Value")
|
||||
table.setCellWidget(row, 1, combo)
|
||||
table.resizeRowsToContents()
|
||||
table.show()
|
||||
QApplication.processEvents()
|
||||
ExeSettingsDialog.eventFilter(
|
||||
cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint),
|
||||
)
|
||||
assert table.rowHeight(1) > table.rowHeight(0)
|
||||
for row in range(2):
|
||||
combo = table.cellWidget(row, 1)
|
||||
assert isinstance(combo, CustomComboBox)
|
||||
rect = table.visualRect(table.model().index(row, 1))
|
||||
assert combo.height() == combo.sizeHint().height()
|
||||
assert abs(combo.geometry().center().y() - rect.center().y()) <= 1
|
||||
name_item = table.item(row, 0)
|
||||
description_item = table.item(row, 2)
|
||||
assert name_item is not None and description_item is not None
|
||||
label = table.cellWidget(row, 0)
|
||||
assert isinstance(label, QLabel)
|
||||
from html import escape
|
||||
|
||||
assert label.textFormat() == Qt.TextFormat.RichText
|
||||
description_style = (
|
||||
theme.SETTINGS_SELECTED_DESCRIPTION_HTML if row == table.currentRow()
|
||||
else theme.SETTINGS_DESCRIPTION_HTML
|
||||
)
|
||||
expected_text = "Setting<br>" + description_style.format(
|
||||
description=escape(description_item.text()),
|
||||
)
|
||||
if row == table.currentRow():
|
||||
expected_text = theme.SETTINGS_SELECTED_TEXT_HTML.format(text=expected_text)
|
||||
assert label.text() == expected_text
|
||||
assert name_item.data(Qt.ItemDataRole.AccessibleTextRole) == f"Setting\n{description_item.text()}"
|
||||
expected_color = (
|
||||
QColor(theme.color_text_accent_hover) if row == table.currentRow()
|
||||
else table.palette().color(label.foregroundRole())
|
||||
)
|
||||
assert label.palette().color(label.foregroundRole()) == expected_color
|
||||
assert name_item.text() == ""
|
||||
if theme_name.endswith("-light"):
|
||||
from PySide6.QtGui import QPalette
|
||||
|
||||
assert table.palette().color(QPalette.ColorRole.Text) == QColor(theme.color_text_dark)
|
||||
table.selectRow(1)
|
||||
ExeSettingsDialog.eventFilter(cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint))
|
||||
assert isinstance(table.cellWidget(1, 0), QLabel)
|
||||
table.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("close_dialog", [False, True])
|
||||
@pytest.mark.parametrize("themed", [False, True])
|
||||
def test_combo_tooltip_only_for_clipped_visible_popup(close_dialog: bool, themed: bool) -> None:
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import QComboBox, QDialog
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
dialog = ExeSettingsDialog.__new__(ExeSettingsDialog)
|
||||
QDialog.__init__(dialog)
|
||||
dialog.vkbasalt_toggle_key_button = None
|
||||
dialog.input_manager = None
|
||||
dialog.gamepad_tooltip = QLabel(dialog, Qt.WindowType.ToolTip)
|
||||
dialog.gamepad_tooltip_timer = QTimer(dialog)
|
||||
dialog.gamepad_tooltip_timer.setSingleShot(True)
|
||||
combo = QComboBox(dialog)
|
||||
if themed:
|
||||
from portprotonqt.theme_manager import ThemeWrapper
|
||||
from portprotonqt.themes.standart import styles
|
||||
|
||||
combo.setStyleSheet(ThemeWrapper(styles).COMBOBOX_STYLE)
|
||||
combo.addItems(["Proton", "WINE-11.18-AMD64-WOW64" if themed else "Proton " * 40])
|
||||
combo.view().setFixedWidth(180)
|
||||
combo.highlighted.connect(lambda row: dialog._on_combo_highlighted(row, combo))
|
||||
combo.setCurrentIndex(1)
|
||||
dialog.show()
|
||||
combo.showPopup()
|
||||
QApplication.processEvents()
|
||||
|
||||
assert dialog.gamepad_tooltip.isVisible()
|
||||
assert dialog.gamepad_tooltip.text() == combo.itemText(1)
|
||||
dialog._on_combo_highlighted(0, combo)
|
||||
assert not dialog.gamepad_tooltip.isVisible()
|
||||
assert not dialog.gamepad_tooltip_timer.isActive()
|
||||
dialog._on_combo_highlighted(1, combo)
|
||||
assert dialog.gamepad_tooltip.isVisible()
|
||||
|
||||
if close_dialog:
|
||||
dialog.hide()
|
||||
else:
|
||||
combo.hidePopup()
|
||||
assert not dialog.gamepad_tooltip.isVisible()
|
||||
assert not dialog.gamepad_tooltip_timer.isActive()
|
||||
dialog._on_combo_highlighted(1, combo)
|
||||
assert not dialog.gamepad_tooltip.isVisible()
|
||||
combo.hidePopup()
|
||||
dialog.close()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic", "classic-light"])
|
||||
def test_settings_selected_row_keeps_text_readable(theme_name: str) -> None:
|
||||
from PySide6.QtGui import QColor, QPalette
|
||||
from PySide6.QtWidgets import QTableWidgetItem
|
||||
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
theme = ThemeWrapper(load_theme(theme_name))
|
||||
table = QTableWidget(2, 3)
|
||||
table.setStyleSheet(theme.WINETRICKS_TABBLE_STYLE)
|
||||
table.viewport().setProperty("ppqt_settings_values", True)
|
||||
for row in range(2):
|
||||
table.setItem(row, 0, QTableWidgetItem("Setting\nDescription"))
|
||||
dialog = SimpleNamespace(theme=theme)
|
||||
for selected_row in range(2):
|
||||
table.setCurrentCell(selected_row, 0)
|
||||
table.selectRow(selected_row)
|
||||
ExeSettingsDialog.eventFilter(
|
||||
cast(ExeSettingsDialog, dialog), table.viewport(), QEvent(QEvent.Type.Paint),
|
||||
)
|
||||
for row in range(2):
|
||||
label = table.cellWidget(row, 0)
|
||||
assert isinstance(label, QLabel)
|
||||
selected = row == selected_row
|
||||
assert label.autoFillBackground() == selected
|
||||
if selected:
|
||||
assert label.palette().color(QPalette.ColorRole.Window) == QColor(theme.color_accent)
|
||||
assert label.palette().color(QPalette.ColorRole.Text) == QColor(theme.color_text_accent_hover)
|
||||
expected_text = "Setting<br>" + theme.SETTINGS_SELECTED_DESCRIPTION_HTML.format(
|
||||
description="Description",
|
||||
)
|
||||
assert label.text() == theme.SETTINGS_SELECTED_TEXT_HTML.format(text=expected_text)
|
||||
else:
|
||||
assert label.text() == "Setting<br>" + theme.SETTINGS_DESCRIPTION_HTML.format(
|
||||
description="Description",
|
||||
)
|
||||
table.close()
|
||||
|
||||
|
||||
def test_settings_buttons_switch_between_one_and_two_rows() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
parent = QWidget()
|
||||
layout = SettingsButtonsLayout()
|
||||
layout.setSpacing(10)
|
||||
parent.setLayout(layout)
|
||||
buttons = []
|
||||
for _ in range(2):
|
||||
row = QHBoxLayout()
|
||||
row.setContentsMargins(0, 0, 0, 0)
|
||||
row.addStretch()
|
||||
button = QWidget(parent)
|
||||
button.setFixedSize(100, 30)
|
||||
row.addWidget(button)
|
||||
row.addStretch()
|
||||
layout.addLayout(row)
|
||||
buttons.append(button)
|
||||
for width, height in ((210, 30), (200, 70), (300, 30)):
|
||||
layout.setGeometry(QRect(0, 0, width, height))
|
||||
assert layout.heightForWidth(width) == height
|
||||
if height == 30:
|
||||
assert layout.direction() == QBoxLayout.Direction.LeftToRight
|
||||
assert buttons[0].y() == buttons[1].y()
|
||||
assert buttons[1].x() - buttons[0].x() == 110
|
||||
assert buttons[0].x() == (width - 210) // 2
|
||||
else:
|
||||
assert layout.direction() == QBoxLayout.Direction.TopToBottom
|
||||
assert buttons[0].x() == buttons[1].x() == 50
|
||||
assert buttons[1].y() > buttons[0].y()
|
||||
parent.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("theme_name", ["standart", "standart-light", "classic", "classic-light"])
|
||||
def test_winetricks_centered_checkboxes_and_install_selection(
|
||||
tmp_path: Path, theme_name: str,
|
||||
) -> None:
|
||||
from unittest.mock import Mock
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
from portprotonqt.dialogs.winetricks_dialog import WinetricksDialog
|
||||
from portprotonqt.input_manager.dialog_modes import DialogInputModesMixin
|
||||
from portprotonqt.input_manager.constants import BUTTONS
|
||||
from portprotonqt.theme_manager import ThemeWrapper, load_theme
|
||||
|
||||
theme = ThemeWrapper(load_theme(theme_name))
|
||||
dialog = WinetricksDialog.__new__(WinetricksDialog)
|
||||
DraggableDialog.__init__(dialog)
|
||||
dialog.theme = theme
|
||||
dialog.setup_ui()
|
||||
dialog.log_path = str(tmp_path / "winetricks.log")
|
||||
(tmp_path / "winetricks.log").write_text("amstream\n")
|
||||
dialog._start_install_process = Mock()
|
||||
dialog.show()
|
||||
for index, table in enumerate((dialog.dll_table, dialog.fonts_table, dialog.settings_table)):
|
||||
dialog.populate_table(table, "amstream MS amstream.dll\nart2kmin Access", r"^$", dialog.log_path)
|
||||
assert table.columnWidth(0) == theme.WINETRICKS_CHECK_COLUMN_WIDTH
|
||||
dialog.tab_widget.setCurrentIndex(index)
|
||||
cast(QStackedWidget, dialog.tab_widget.currentWidget()).setCurrentWidget(table)
|
||||
QApplication.processEvents()
|
||||
cell = table.cellWidget(0, 0)
|
||||
assert cell is not None
|
||||
assert cell.geometry() == table.visualRect(table.model().index(0, 0))
|
||||
checkbox = cell.findChild(QCheckBox)
|
||||
assert checkbox is not None and checkbox.isChecked()
|
||||
assert table.item(0, 0) is None
|
||||
assert abs(checkbox.geometry().center().x() - cell.rect().center().x()) <= 1
|
||||
assert abs(checkbox.geometry().center().y() - cell.rect().center().y()) <= 1
|
||||
table.setCurrentCell(1, 0)
|
||||
table.setFocus()
|
||||
assert table.focusProxy() is table.cellWidget(1, 0).findChild(QCheckBox)
|
||||
assert QApplication.focusWidget() is table.focusProxy()
|
||||
manager = cast(DialogInputModesMixin, SimpleNamespace(
|
||||
winetricks_dialog=dialog, _handle_common_ui_elements=lambda _code: False,
|
||||
))
|
||||
DialogInputModesMixin.handle_winetricks_button(manager, next(iter(BUTTONS["confirm"])), 1)
|
||||
cell = table.cellWidget(1, 0)
|
||||
assert cell is not None
|
||||
checkbox = cell.findChild(QCheckBox)
|
||||
assert checkbox is not None and checkbox.isChecked()
|
||||
dialog.install_selected(force=True)
|
||||
dialog._start_install_process.assert_called_once_with(["art2kmin"], True)
|
||||
dialog.deleteLater()
|
||||
@@ -51,6 +51,9 @@ def test_sound_manager_survives_multimedia_initialization_failure(
|
||||
monkeypatch.setattr(sound_manager, "_SoundSlot", Mock(side_effect=RuntimeError("failed")))
|
||||
|
||||
manager.__init__()
|
||||
manager._enabled = True
|
||||
manager._get_url = lambda _event: Mock()
|
||||
manager.play("navigate")
|
||||
|
||||
assert manager._slots == []
|
||||
|
||||
@@ -70,11 +73,49 @@ def test_sound_manager_skips_unavailable_audio_service(
|
||||
monkeypatch.setattr(sound_manager, "_SoundSlot", sound_slot)
|
||||
|
||||
manager.__init__()
|
||||
manager._enabled = True
|
||||
manager._get_url = lambda _event: Mock()
|
||||
manager.play("navigate")
|
||||
|
||||
assert manager._slots == []
|
||||
sound_slot.assert_not_called()
|
||||
|
||||
|
||||
def test_sound_manager_defers_backend_until_visible_playback(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
window: Any = QWidget()
|
||||
window.silent_launch_mode = True
|
||||
manager: Any = object.__new__(SoundManager)
|
||||
manager._initialized = False
|
||||
slot = Mock(_loaded_event=None)
|
||||
sound_slot = Mock(return_value=slot)
|
||||
monkeypatch.setattr(sound_manager, "_SoundSlot", sound_slot)
|
||||
monkeypatch.setattr(sound_manager, "_audio_service_available", lambda: True)
|
||||
monkeypatch.setattr(sound_manager.ui_config, "get_sounds_enabled", lambda: True)
|
||||
monkeypatch.setattr(QApplication, "topLevelWidgets", lambda: [window])
|
||||
|
||||
manager.__init__()
|
||||
manager.set_sounds_dirs([str(tmp_path)])
|
||||
manager._get_url = Mock(return_value=Mock())
|
||||
assert manager._slots is None
|
||||
sound_slot.assert_not_called()
|
||||
|
||||
manager.play("game_launch")
|
||||
manager.play("gamepad_connect")
|
||||
manager._get_url.assert_not_called()
|
||||
sound_slot.assert_not_called()
|
||||
|
||||
window.show()
|
||||
manager.play("navigate")
|
||||
manager.play("navigate")
|
||||
assert sound_slot.call_count == 4
|
||||
assert slot.play.call_count == 2
|
||||
window.close()
|
||||
app.processEvents()
|
||||
|
||||
|
||||
def test_sound_manager_survives_playback_failure() -> None:
|
||||
slot = Mock(_loaded_event="navigate")
|
||||
slot.play.side_effect = RuntimeError("failed")
|
||||
|
||||
@@ -758,3 +758,12 @@ class TestIsPortraitImage:
|
||||
bad_file = tmp_path / "bad.png"
|
||||
bad_file.write_bytes(b"not an image")
|
||||
assert _is_portrait_image(bad_file) is False
|
||||
|
||||
|
||||
def test_disabled_steam_proton_scanning_skips_discovery(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from portprotonqt.steam_api.utils import get_steam_proton_versions
|
||||
|
||||
monkeypatch.setattr("portprotonqt.steam_api.utils.ui_config.get_disable_steam_proton_scanning", lambda: True)
|
||||
with patch("portprotonqt.steam_api.utils._iter_existing_steam_data_dirs") as discover:
|
||||
assert get_steam_proton_versions() == []
|
||||
discover.assert_not_called()
|
||||
@@ -129,46 +129,59 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dbus-fast"
|
||||
version = "5.0.22"
|
||||
version = "5.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/ce64b8788c10a8bd313c8638b28be5dccdd5c2daf14839f23aff37e0b39d/dbus_fast-5.2.0.tar.gz", hash = "sha256:a4a5dddc04b1ade5eb7650d791e2f6fb7c1334595593473914e78a2526ecddda", size = 86442, upload-time = "2026-10-02T13:18:54.585Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/1d/f6020228675338b7184ce5491e4535fb64c7badc0197151b9776f345a1a1/dbus_fast-5.0.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0926e4cf49989b4ec8233e8fd462eb35a640fcfe81bb75d91675dd47489022b", size = 693158, upload-time = "2026-06-05T18:55:49.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/84/dfb014de75a3a854dccaae1cce8f840e4312e3efc781768eedd60d25d9ef/dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040", size = 838836, upload-time = "2026-06-05T18:55:51.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c2/be41bcc678e97092d44ba22d09ce687f76c955b3367a7e6863377b1cfea5/dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4", size = 883163, upload-time = "2026-06-05T18:55:53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/cf/336c08f88fdd813a39fc1603a10a15aec67115e08a895e7c9840df54d4d7/dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355", size = 886852, upload-time = "2026-06-05T18:55:54.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f1/7c1aa53f25252a2317f21ad6e8eaa125246d2585ea4611f3f10a6feaeeb1/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618", size = 846497, upload-time = "2026-06-05T18:55:56.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/32/a981ef2305f1bf41e538e02fa0cd69614f9043fd00ca965bf3044416c79b/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1", size = 882275, upload-time = "2026-06-05T18:55:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/a0/78800a172f4ca32e19a70a36e175f54831f33a497c9644f3a3fa4dce01ea/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84", size = 890566, upload-time = "2026-06-05T18:55:59.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/81/ffc155f700c45191673e7f7620a28cbbbf5f116ff74a99f765895baa6f9c/dbus_fast-5.0.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f72b77be63f7bb24cf42936ad10994d40f43fed691f857f7854b5882d6a5227c", size = 690171, upload-time = "2026-06-05T18:56:01.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ca/964f0d39a3be03b12a98f39519d34ad95b74360c7e3adf4cd4907dc25fd6/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717", size = 806891, upload-time = "2026-06-05T18:56:07.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/25/57fe6ab509ad9da2e190498fa9c37f868e38ac521d940a9edb9ba6b6c657/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7", size = 830867, upload-time = "2026-06-05T18:56:09.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/1d/ebd02ae707328286c24582ac9189e4ed9c344399bc3b6a4b74e9687088eb/dbus_fast-5.0.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26e26b409e1e6edf5e2a4df8d192625fb38876b074fb5c7d0a5b15c5792e549d", size = 685921, upload-time = "2026-06-05T18:56:12.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b6/171f8e92775254e2f42afe9e7501a57ace47f32de2969cb694a375dc9dce/dbus_fast-5.0.22-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:511655d915692f55b8c5f5a535acf80cca9c6d1a35384db7fcfdabaae05dd837", size = 692986, upload-time = "2026-06-05T18:56:24.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/dd/61086156a1c2d8ffd04d61a232debbaff8ca9fc1cf598476999e1f06164d/dbus_fast-5.0.22-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69a077c296eaab8c30160e861b5514c33d99d67d41d17dbf02e89aae44543b11", size = 1353993, upload-time = "2026-06-05T18:56:36.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/68/89b9d66202884e7a3008af170834e52c452d4d2c49749f633f61afa3f9ad/dbus_fast-5.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:78fe0dd5dbdaaf281d32d4e609f99cdc7eac3872f9ca805d27d30b3f53d893b3", size = 736964, upload-time = "2026-10-02T13:40:08.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/b5/c781f46c21fb123e41987d99859bd38351fa1fd0629a670a009b490d17d8/dbus_fast-5.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddf103405d0fe9b0c764d3ce8d59e557bed1d68aff7d3b16f6eab94bf6f85d91", size = 884515, upload-time = "2026-10-02T13:40:11.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/8c/3c5c5eb0a09d765d016122a0af1bff21b882ab2b870bca03ddcd3d351a9f/dbus_fast-5.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4944cb6cf92af103b4127e3935f138a8852b8268444ed8bf7975c507cad45c8a", size = 929821, upload-time = "2026-10-02T13:40:12.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/fd/d5b3f4cdf2792d21c3db416817e6e67d57e048103db27aebf4d93e1e191d/dbus_fast-5.2.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0858d3f0a9b9fe506e6847c8200e85fda4b62319aa5712e61dfa696f19e0d1f3", size = 926958, upload-time = "2026-10-02T13:40:14.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/76/778e70c856fe599900fd60ecff1365c65c44cc14bcf7e8e28afaefc4bed8/dbus_fast-5.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5c8b4b70b8811e3fe431370f7e9699c01eb097561ddfa367081e5abdcf34a9a8", size = 892146, upload-time = "2026-10-02T13:40:16.106Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/57/447943bdda0982389c5481198fbc42421e11a033ecf6d0c826f6ea7d1c39/dbus_fast-5.2.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e5b07944dce15b734f6d0e80a54f66909de6fca0671fdf12ed512758721ba4bb", size = 924596, upload-time = "2026-10-02T13:40:17.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/c996514180fa9053e202d5945c99fbd70d492f4b13520957bd4c68e9e467/dbus_fast-5.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ba81e8455b9b893dc6ce31535049b938835beea8c20e85e181fc23e46f0bed12", size = 937884, upload-time = "2026-10-02T13:40:19.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/20/e2fc0dd4f19ec90411df9135cad15fb85f598a985cc2f783626623146ebd/dbus_fast-5.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:383d6d43dcc6b2ca6c079b5a16cb94ea2c86dd8af9a8d22f3f29d960a9fe1533", size = 740307, upload-time = "2026-10-02T13:40:20.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/81/3c3ab1c728e2493e6c01c2e652ed42a60821cc190c4a596d2405d3374812/dbus_fast-5.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e5a0742c9c2bdae7599d1fff06a609cc7cf19d4a84bf7e6e2cea1d086d1ecc0b", size = 847456, upload-time = "2026-10-02T13:40:22.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/d8/528ce993791bc06fb5bc8b8abef87f49240945302e858a1d6894073e7c54/dbus_fast-5.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:236327794a5957238e83c363809903b6a47d1f4fecd64831a29fd09458d90f0e", size = 902179, upload-time = "2026-10-02T13:40:24.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/74/5b05962c37965d790433b29e1aa4b0c1e6e21eea546cda8ee9bc4410ea5a/dbus_fast-5.2.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bde3e9bae0af4bdc27afa9b4550e15ed55c32907b45620d35b36bd0f1ba28b4", size = 880104, upload-time = "2026-10-02T13:40:26.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9d/c1f15f5d59ac55bb39a01e41e429f2436f2f005aac414b17ef2183b56e6a/dbus_fast-5.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ec7e32787f43ddd637da4b46576e288deb0148c19253ac5a3cf5a69e8dacbe08", size = 856934, upload-time = "2026-10-02T13:40:27.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/83/3dfc69e0b35b8ea0db5834a4d3b57222a1986dc9d710485483d6de8c8e6a/dbus_fast-5.2.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:255b79663e44708479a9b15f4a05be633186e6169e9ee4037001b036f2fda989", size = 879743, upload-time = "2026-10-02T13:40:29.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/fc/80e83874d305e6afc48fd6908022d34ff35d9e5eed6b7a3e93a50855d79c/dbus_fast-5.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9945f15b3cff4857a82498ee933f5c56aa949afe3bcbdf62e4c7c670e7ac7ec3", size = 909808, upload-time = "2026-10-02T13:40:30.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/2d/40a4a4597bdfd2f839a5c248f41f030f259eb0a5414592537b422280d2b0/dbus_fast-5.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:93615c23d5766c796ce1835bf76d5c20b3908e087e7ffb1da3aa7ac99f2446f8", size = 735418, upload-time = "2026-10-02T13:40:32.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f6/5af4fe51007d99801affbac6e9a9231c5a75ba4d410e569ec5fa3987cf24/dbus_fast-5.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0c3d3f153fbcdaae27409afe7ac42654ed768c8de2da35aa929ba4143935455", size = 844140, upload-time = "2026-10-02T13:40:34.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/8d/8faf59c288feabba6545998de9c7748c8f995ec953a7b6a07e2c7f84cba4/dbus_fast-5.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da7835ccc6e8cb2b54516558097156da6dbf6636c27033b427135bad693317fb", size = 896495, upload-time = "2026-10-02T13:40:35.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/87/3723caedeab96ffb963c84485108c5764a583e8d7abc379bdd9230b7f3fe/dbus_fast-5.2.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c0d6ff2dffa3115fb5c670a0d17474827428ba87991f5e7b4d3791f0abcb07f", size = 872192, upload-time = "2026-10-02T13:40:37.361Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/62/fb216d28c404182c353df3523de5de8f20b4a95dc1227685bc255cc72c9c/dbus_fast-5.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f6cfee9c3de4b8a3dd406abca9aabe2f28ccefc7b68f9b26c4f92ccc9b2fe4e", size = 854004, upload-time = "2026-10-02T13:40:38.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f3/35ff56204e5843224037a5226837e1af25f7908e198df58dbb2e895c72e9/dbus_fast-5.2.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:0e061cf9b31c540af7641739fef11654392c283f3c611f5009b6019b0d7c6ddd", size = 874144, upload-time = "2026-10-02T13:40:40.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1c/9010c0937a1f4de1d1fdc1cb0c00e2140d1ef606f5191063ade56347dbaf/dbus_fast-5.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a17cd5e062ebfa48f996b4aa5db7202eb8e2df9ad5be36bf39198422e6457b8", size = 905251, upload-time = "2026-10-02T13:40:42.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/09/13254d809e03db83138809a3df358307e694dd7ded3f56361596280a82ae/dbus_fast-5.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ff55fddbc7567cb39f10b5d7e9bed1f2b19c88fc1d18c86fa67ca06becfe8fe7", size = 744968, upload-time = "2026-10-02T13:40:43.751Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/4c/cdb494b0aadaf99c970f6baca4a3156506b6ffe9a6061ea2c725b214fea5/dbus_fast-5.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a772708d25c11e980642781f603882e3dc51b5767be19075ffc5a484c4d3411", size = 857487, upload-time = "2026-10-02T13:40:45.254Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/a7/ec412544064624f12681113debf1a991293e9632bd0125a03a8e652d00e8/dbus_fast-5.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c66b094e96c221b877ccd6627bc3b9d808ac8317a8f6adc1cb2a0223e7d64e2", size = 900696, upload-time = "2026-10-02T13:40:47.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/8e/d2e7791016d88ce8b28afdd5a6d0381937c376e8eed3b761c585cc1ef117/dbus_fast-5.2.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:79b842eb42f439fabd47db9deb7846d849933eb864fc53373d355c63f850eaa6", size = 877727, upload-time = "2026-10-02T13:40:48.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/3f/edc14f91f77030bffc891319a2b7939b737972e1b7a17490dc5df3cc7a78/dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:788861134ac1794d44a03970fc817896b4bb35247353eeb13c363e238f7d4474", size = 867199, upload-time = "2026-10-02T13:40:50.478Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/96/cfc6f0c7a6e3634239bc98de1f5e701ed7330c5c2f9f1f8115a637efe1a9/dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:352e4cc8dbc608e297a73784857a8f9841d3a221b10e4b0f6a1b4b5168456e51", size = 880795, upload-time = "2026-10-02T13:40:52.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/5b/07ec1855d708d396c8847414508f126d792b69ae0767e6c6305fd07d92a2/dbus_fast-5.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc04ca465f9d9847aa4273efe85da8fed82988004f1002b833df788f48fc0ecd", size = 910365, upload-time = "2026-10-02T13:40:53.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/72/f72e0f33f15c2538d210427a654427cc0d82b836e7363ad65f5c142a0c1e/dbus_fast-5.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bcc1514888cbb82533777f3855e06135e5e8526ca6d7f75687b8b1fcf140ce33", size = 1451833, upload-time = "2026-10-02T13:40:55.444Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/09/6c97339dcdce2c1aed42eaeaf4bff309c097ae92ee2395b1d3e6844171b2/dbus_fast-5.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa260884e2df72d584ffec2d5d2f90ea0d624db8326ff0bea33b59f8998a09f2", size = 1625010, upload-time = "2026-10-02T13:40:57.105Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/27/ee9b144dd0960960c39300aee480df9da7597fd9158e10992c6f8198c67a/dbus_fast-5.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc5845602cd734e01bcee84fc2ff08642987d95d42edb434d048103905d3173f", size = 1706829, upload-time = "2026-10-02T13:40:58.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/cf/46b9fb29b1cc51bbca6ba6da078739fde78c6f2b80da1e903a5ab7adf4e8/dbus_fast-5.2.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:288111b8d920b5ab445c2d9e4f13cd8521fe5233efe191c4749dd8fd07c5beb9", size = 868530, upload-time = "2026-10-02T13:41:00.639Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/3d/fd53daea0cfa5d7d1e2abfb02253003d4c82cb566575047c69b295d26508/dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d828828f879c0536981c1eaf2d4c6fa65fd30354cecb1e16a158a9cda36a827c", size = 1646233, upload-time = "2026-10-02T13:41:02.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/d4/f245a10be37bd2b3ca285a4ba43796421d018e52c9b59f8e92f92d2ca733/dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:0c4e7f48961e7c85540086458be0c5ca6ae6272e327c15907bd7d1e777ab2ace", size = 872057, upload-time = "2026-10-02T13:41:04.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/6e/08d7cce0bdb8b930e19aa7fa1e6cd89b9984ce2039c23f29b2b85e6df171/dbus_fast-5.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a0d506adfcbd5451e23ec2b645437ccf419e9ed7ad1f6f82b622d2a292fd23e5", size = 1727355, upload-time = "2026-10-02T13:41:05.805Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/50/6c1cd4761d50e9a1a1dcad4eae2ed0d87cd9adccabaebeb699b1dd8ca2b1/dbus_fast-5.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:90da44436de6f5773637216159b7f0c5b53aa357c54c38593b12ff3ed3a4e649", size = 741212, upload-time = "2026-10-02T13:41:07.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/8e/f6e5ac0f44785e7913824d4c6bebcd27d60e536d0b28309ec9e7b8350f4a/dbus_fast-5.2.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1aad5984b9724f438a2ccd5e3df15723aece0d248b309576744345a04eee948a", size = 857866, upload-time = "2026-10-02T13:41:09.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/f3/a8fbdc8b5fa801b4f08b73abfdd62372a37badbc63e36380578c4882a82e/dbus_fast-5.2.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbe4982d86e93fe285c695c0808601e7187f01501df77c2342d4132c11bcac17", size = 905536, upload-time = "2026-10-02T13:41:11.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/6b/8cfbdd0fc286ceef1280c877897e21a4d689068afe04d48f517a26342300/dbus_fast-5.2.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d01ae4246b3b503b529be3f4ad3660d92687b5d0f085683d2ef48ec3247d5133", size = 898586, upload-time = "2026-10-02T13:41:13.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/77/2447fc6a66cf02ead0ad4077cead0fae5745838a915794a6c79ebbf26216/dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:f4a47be94f369cca2308645345df8a0949e9139f9b0f6e64fbd11945924b13b7", size = 867311, upload-time = "2026-10-02T13:41:15.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/c1/5067a3bc84e29e6fe1450a2391a4c04b6cc8623a8c9ca6bca685a867ff23/dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:594f755fe172c76dd1a7f6558504244a0713da4ce07d9cf9abb5db80372a5d4f", size = 901258, upload-time = "2026-10-02T13:41:17.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/58/0af518b24f40d240b969c9840bd3b8c8d8adb4c12c245e8a86b58c4133ee/dbus_fast-5.2.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:c0ca312d8643f1f358f9fd96d2ccaf8dccd01d0c14c08c20e3f1686aa198231d", size = 913535, upload-time = "2026-10-02T13:41:18.823Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/24/e3664e646d6cce365afbd7048230046416d85a0ded88ac3ab3e2e8289ff6/dbus_fast-5.2.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:679f2daef2b88d6129845013403b32d184ecf90805fce247340469bd5f495943", size = 1443660, upload-time = "2026-10-02T13:41:20.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/e9/409f538dfb3a8f85543decb70100f20b46fcb0a7c1d6ae46c2a93cf74dd9/dbus_fast-5.2.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b8a6c22657740523f8d16e4d373925a408c7dc30dfd4935ea21939c042510c", size = 1623008, upload-time = "2026-10-02T13:41:22.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/42/05c3bd682615dd6407edcca284604e83999f9967540a1376f7c51a40ef19/dbus_fast-5.2.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22ac2df864dd0532f3d797f21118341e7520d6b36ac68a327eac6291624fb2b", size = 1710212, upload-time = "2026-10-02T13:41:24.231Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/c5/f063efc49884d6eeaf97a6c499847326e8fa3d163f5b3817cd2e8dd12aba/dbus_fast-5.2.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d91ce3cd74b3b8a1518afca3ceb90ab7b280a53e9c50a257453b83e48c4b19c", size = 886477, upload-time = "2026-10-02T13:41:26.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/8c/32e83f3635ae43a1863ef55b1be42ce58cee85fd13409bb8b197bca600b1/dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:cc171f8b0728626eba19ac5893cbf1a5a813120e6fd0440168ba013f941abeb8", size = 1644612, upload-time = "2026-10-02T13:41:27.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/f4/13461600a4f019ff3b6eb285a6f992efcbe188b203defe7d0977634e231a/dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:5e8d93ca1b3d344c7ff5c4959e6d8ac2c6a0e794aef9d1606177647d537b7e99", size = 888703, upload-time = "2026-10-02T13:41:29.948Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/86/df2000ce91efb75104189fe41ffae517c6c8c1ba97f4160fa8322390f704/dbus_fast-5.2.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:e6f32672a446284b0d381349c91f6602356a4c017c1604fccbcc02347496be92", size = 1727655, upload-time = "2026-10-02T13:41:32.145Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -426,7 +439,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "portprotonqt"
|
||||
version = "1.4.1"
|
||||
version = "1.5.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "babel" },
|
||||
@@ -457,7 +470,7 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "babel", specifier = ">=2.18.0" },
|
||||
{ name = "dbus-fast", specifier = ">=5.0.22" },
|
||||
{ name = "dbus-fast", specifier = ">=5.2.0" },
|
||||
{ name = "evdev", specifier = ">=2.0.0" },
|
||||
{ name = "libarchive-c", specifier = ">=5.3" },
|
||||
{ name = "orjson", specifier = ">=3.12.0" },
|
||||
@@ -478,7 +491,7 @@ dev = [
|
||||
{ name = "pre-commit", specifier = ">=4.6.2" },
|
||||
{ name = "pyright", specifier = ">=1.1.414" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
{ name = "pytest-mock", specifier = ">=3.16.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -613,14 +626,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pytest-mock"
|
||||
version = "3.15.1"
|
||||
version = "3.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/14/eb014d26be205d38ad5ad20d9a80f7d201472e08167f0bb4361e251084a9/pytest_mock-3.15.1.tar.gz", hash = "sha256:1849a238f6f396da19762269de72cb1814ab44416fa73a8686deac10b0d87a0f", size = 34036, upload-time = "2025-09-16T16:37:27.081Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/7f/6ed29931d5c8cd396e7c0a55412e6cc88020373365c8685985dea53d26d7/pytest_mock-3.16.0.tar.gz", hash = "sha256:5a8395528b8f498205f3718f575228d0edaed7425fff638f87d1a6c3e0383636", size = 35362, upload-time = "2026-09-27T14:57:55.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/cc/06253936f4a7fa2e0f48dfe6d851d9c56df896a9ab09ac019d70b760619c/pytest_mock-3.15.1-py3-none-any.whl", hash = "sha256:0a25e2eb88fe5168d535041d09a4529a188176ae608a6d249ee65abc0949630d", size = 10095, upload-time = "2025-09-16T16:37:25.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/5b/b83a9bf1a3b4ec222f9fa083147ff6816245223da0ab92370e7e056f113f/pytest_mock-3.16.0-py3-none-any.whl", hash = "sha256:007cfeb257801d88d9c0b2a7b5a15a15e73b71968dfd72e7bf8c4a2f8393aec8", size = 10016, upload-time = "2026-09-27T14:57:54.283Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||