Compare commits
143
Commits
v1.4.1
...
nvapi-reflex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa9519efc
|
||
|
|
13e89d8b2b
|
||
|
|
30c3bfb3b1
|
||
|
|
8e12ec713f
|
||
|
|
e809f031ce
|
||
|
|
1b42c672bb
|
||
|
|
07b53f3160
|
||
|
|
40d80f0ce8
|
||
|
|
0b8536b163
|
||
|
|
31f6d84537
|
||
|
|
5c86b3d943
|
||
|
|
ec2ffee64d
|
||
|
|
60e9a2866a
|
||
|
|
94269455ec
|
||
|
|
f5c07cf3ea
|
||
|
|
509d4272ab
|
||
|
|
ff560fb43a
|
||
|
|
ed78af49d2
|
||
|
|
5c5a154a0d
|
||
|
|
ae011adfdf
|
||
|
|
b0d97e0e0b
|
||
|
|
8a303bf9ed
|
||
|
|
a14563dd78
|
||
|
|
abb511c173 | ||
|
|
ef0626a069
|
||
|
|
40f06caa16
|
||
|
|
bfd4bf4065
|
||
|
|
92ecc008f3
|
||
|
|
0c78e9c689
|
||
|
|
4840d512b5 | ||
|
|
c22992b803
|
||
|
|
57f955d827 | ||
|
|
f9694e576b | ||
|
|
a74c5631ec | ||
|
|
55361797c3
|
||
|
|
f3999e9bf1
|
||
|
|
35366bbf12
|
||
|
|
bab6d6b84e
|
||
|
|
061ee7fedf
|
||
|
|
8707c93835
|
||
|
|
53a44c5708
|
||
|
|
154cf43bf8
|
||
|
|
a70bf97774
|
||
|
|
a06f76a2e9
|
||
|
|
649aec7229
|
||
|
|
0da0d55537
|
||
|
|
c2f611c73e | ||
|
|
c4f9f05363 | ||
|
|
56c8a9781b
|
||
|
|
9d72466402
|
||
|
|
4df207fa3b
|
||
|
|
bd6dc490b1 | ||
|
|
383d17f523 | ||
|
|
9b889a8d2e | ||
|
|
64c9f4d196 | ||
|
|
0de9e46485
|
||
|
|
bdc14d1d86
|
||
|
|
1fc8d62a85
|
||
|
|
0e3a0be146 | ||
|
|
6e2f7452bd | ||
|
|
37168f8892 | ||
|
|
141f98d81f | ||
|
|
7031051dc4
|
||
|
|
769ad421a2
|
||
|
|
2316efe3d6
|
||
|
|
09b790fc26
|
||
|
|
a48b9050d2
|
||
|
|
b602880907
|
||
|
|
05a5b136e9
|
||
|
|
bde4ba9602
|
||
|
|
2634370ee3 | ||
|
|
9cea141f9c | ||
|
|
3d417c1a83
|
||
|
|
27003d4967
|
||
|
|
c75755bb64
|
||
|
|
67652ea258
|
||
|
|
3735c7514c
|
||
|
|
803c98b6ff
|
||
|
|
9214018c03
|
||
|
|
d2597fa8d6
|
||
|
|
25e20c3ea1
|
||
|
|
f1a5db4f34
|
||
|
|
096d86cb7b
|
||
|
|
7b7553a1fe
|
||
|
|
7c30418d61
|
||
|
|
fa30835e97
|
||
|
|
0bdab8fd40 | ||
|
|
43f4d0a665 | ||
|
|
779a5eca65 | ||
|
|
1a30a105d0
|
||
|
|
45fe72d393
|
||
|
|
3b9e531eda
|
||
|
|
822b454bf4
|
||
|
|
77a65d9e81 | ||
|
|
eee8fb5f0e | ||
|
|
4d84d74d70 | ||
|
|
c9fea6e713
|
||
|
|
b4f39b003b
|
||
|
|
f7e44d72c8
|
||
|
|
747b85ce2f
|
||
|
|
b907b5400c
|
||
|
|
043031baec
|
||
|
|
17e7fa2690
|
||
|
|
be1e517c68
|
||
|
|
bcd089439e
|
||
|
|
cb1b984f04
|
||
|
|
22bf9b2451
|
||
|
|
c8ff25dd69
|
||
|
|
927531620a
|
||
|
|
bb14e1a346
|
||
|
|
2a5a45fabd
|
||
|
|
5e0e5b785e
|
||
|
|
90c4901e8e | ||
|
|
c1e76b8e8c
|
||
|
|
782c9cae6a | ||
|
|
725c8d371e
|
||
|
|
53d79c50d3
|
||
|
|
af8679aa08
|
||
|
|
96a2bd92e8
|
||
|
|
810d09b0ad | ||
|
|
359ac7d72e | ||
|
|
6135f640fd
|
||
|
|
58d85dcf9f
|
||
|
|
6b614ce0c5
|
||
|
|
7b563dff32
|
||
|
|
d04e637030
|
||
|
|
00a222073f
|
||
|
|
e09c87491f | ||
|
|
4f5fa36a0d | ||
|
|
6e70c92afc
|
||
|
|
ff568fad1c
|
||
|
|
254f56fe17 | ||
|
|
b0e8345517 | ||
|
|
897daf983b | ||
|
|
2effcf5505 | ||
|
|
5aaa7cbc7e
|
||
|
|
6a502711ad | ||
|
|
4889ad1f48
|
||
|
|
2d5336bc51
|
||
|
|
a7fe71558e
|
||
|
|
b9de7e40fe
|
||
|
|
9ad69007ec
|
||
|
|
6af8b31a90
|
No files matched your search
@@ -32,10 +32,10 @@ jobs:
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman-key --init
|
||||
pacman -Sy --noconfirm archlinux-keyring
|
||||
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7278cbb7c3692fce7241d427614ef7984192f138 # v3
|
||||
|
||||
- name: Install CI dependencies
|
||||
run: pacman -S --noconfirm --needed nodejs npm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@v4
|
||||
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [43, 44, rawhide]
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
stats:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -11,10 +11,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
debian: ${{ steps.check.outputs.debian }}
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -80,19 +80,19 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman-key --init
|
||||
pacman -Sy --noconfirm archlinux-keyring
|
||||
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
|
||||
uses: https://git.linux-gaming.ru/actions/anylinux-setup-action@7ff2bdc2fe97f9a4c6a4007f5f42fb4941f49fee # v4
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- name: Install CI dependencies
|
||||
run: pacman -S --noconfirm --needed nodejs npm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [43, 44, rawhide]
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -149,11 +149,11 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:4894f5a268c696fad671966f383175a13faf433c9d9c88cdd4e32eaa2d18838b
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
@@ -213,11 +213,11 @@ jobs:
|
||||
chown user -R ..
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20260623@sha256:d07d1b51c39f51188e60be9b64e6bf769fa94e187f092bc32b91305cfa34ba5a
|
||||
image: debian:trixie-20260918@sha256:9cc080028c43b27d2074d63a5f9caf7166d731494965616c1a6d2827a004585c
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
@@ -263,11 +263,11 @@ jobs:
|
||||
pkgconf python3
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
|
||||
@@ -10,10 +10,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.10
|
||||
3.11
|
||||
@@ -1,7 +1,7 @@
|
||||
# PortProtonQt — AI Agent Guidelines
|
||||
|
||||
**Project:** PortProtonQt — GUI for PortProton, Steam, Epic Games Store
|
||||
**Language:** Python 3.10+
|
||||
**Project:** PortProtonQt — Game library manager and launcher for Linux
|
||||
**Language:** Python 3.11+
|
||||
**Platform:** Linux (POSIX)
|
||||
**License:** GPL-3.0
|
||||
**Build:** Meson + uv
|
||||
@@ -78,6 +78,31 @@ Example:
|
||||
|
||||
---
|
||||
|
||||
## Commit Classification
|
||||
|
||||
Use Conventional Commit prefixes according to the primary purpose of the change:
|
||||
|
||||
| Prefix | Use for |
|
||||
|--------|---------|
|
||||
| `feat` | A new user-facing capability or supported workflow |
|
||||
| `fix` | A correction for a confirmed defect or regression in existing behavior |
|
||||
| `chore` | Maintenance, integration adjustments, filtering, metadata, or routine upkeep that is neither a feature nor a confirmed bug fix |
|
||||
| `refactor` | Internal restructuring with no intended behavior change |
|
||||
| `perf` | A measurable performance improvement |
|
||||
| `test` | Test-only changes |
|
||||
| `docs` | Documentation-only changes |
|
||||
| `build` | Build system or dependency changes |
|
||||
| `ci` | CI or automation configuration changes |
|
||||
| `style` | Formatting-only changes with no behavior change |
|
||||
| `revert` | Reverting an earlier commit |
|
||||
|
||||
- Classify by intent, not merely by whether code behavior changed.
|
||||
- Do not default to `fix` for every behavioral correction or edge-case filter.
|
||||
- Use `fix` only when the task identifies a bug/regression or the defect is clearly demonstrated.
|
||||
- If `feat`, `fix`, and `chore` are ambiguous, prefer `chore` unless the task clearly establishes a new capability or confirmed defect.
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
```python
|
||||
@@ -277,6 +302,9 @@ Do not delete or disable caching or downloaders to satisfy this rule. Caching is
|
||||
- No heavy operations in paintEvent
|
||||
- Use methods instead of lambda for complex logic
|
||||
- Use QThread for long tasks
|
||||
- Retain every background worker that can outlive its caller in an owner field or collection until it finishes
|
||||
- Register window-owned workers and retained worker collections in the application shutdown path; cancel when supported and wait before interpreter exit
|
||||
- Add or update shutdown regression tests when adding or changing a background worker, including executor-backed workers
|
||||
|
||||
---
|
||||
|
||||
@@ -304,7 +332,7 @@ Do not assume conventions. Never invent behavior.
|
||||
|
||||
```bash
|
||||
# Setup
|
||||
uv python install 3.10
|
||||
uv python install 3.11
|
||||
uv sync
|
||||
source .venv/bin/activate
|
||||
pre-commit install --install-hooks
|
||||
@@ -387,9 +415,11 @@ tests/
|
||||
├── 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_main_window.py # Main window data processing and callback regressions
|
||||
├── 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_api.py # PPDB API helpers, autoinstall localization fallback
|
||||
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
|
||||
@@ -467,6 +497,7 @@ pre-commit run pytest
|
||||
- [ ] Prefer dedicated functions for subprocess calls when practical within task scope
|
||||
- [ ] No shared mutable global state (except logger and explicit cache/session infrastructure)
|
||||
- [ ] No blocking calls introduced in the UI thread
|
||||
- [ ] Background workers have explicit ownership and shutdown cleanup with regression coverage
|
||||
- [ ] **No hardcoded styles or constants (use theme constants)**
|
||||
- [ ] **New constants added to theme files only**
|
||||
|
||||
|
||||
@@ -7,11 +7,79 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлены выбор и установка DLC при установке игр GOG
|
||||
- При установке GOG в папку с уже имеющейся игрой добавлены автоматический импорт, проверка файлов и загрузка недостающих или повреждённых файлов
|
||||
- Добавлена возможность приостанавливать и возобновлять запущенную игру через системный трей
|
||||
- Добавлена настройка отключения проверки i586-зависимостей в ALT Linux
|
||||
- Добавлена настройка принудительного применения английского языка
|
||||
- Добавлено редактирование названия и обложки игр GOG по ID
|
||||
- Добавлена интеграция Epic Games Store с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
|
||||
- Добавлена поддержка сборок Proton CachyOS WineLand
|
||||
- Добавлена кнопка возврата на рабочий стол в системной вкладке при указании команды в `PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT`
|
||||
- Добавлены режимы библиотеки `horizontal` и `horizontal_top` с циклической навигацией и плиткой открытия полной сетки в топ-режиме
|
||||
- В панель библиотеки добавлен выбор типа сетки
|
||||
- В виджет с исчезающей полосой прокрутки добавлена поддержка горизонтальной ориентации
|
||||
- Добавлен режим библиотеки `vertical` с табличным отображением названия, последнего запуска, проведённого времени и источника игры
|
||||
- При открытии карточки с отсутствующим исполняемым файлом добавлен выбор: удалить карточку или указать другой файл
|
||||
- Добавлен отдельный раздел настройки шрифтов MangoHud с масштабом, размером и выбором шрифта
|
||||
- Добавлен графический редактор глобальных настроек игр из `user.conf`
|
||||
|
||||
### Changed
|
||||
|
||||
- После завершения загрузки показывается системное уведомление, а строка результата открывает карточку игры мышью или кнопкой A геймпада
|
||||
- Улучшено определение вылетов игр: учитывается код завершения команды Wine/Proton и время работы игровых процессов, включая дочерний движок, без учёта времени завершения скрипта запуска
|
||||
- Каталоги пользовательских данных игр переведены на единый формат `источник-ID`
|
||||
- Данные приложения организованы в `$XDG_DATA_HOME/PortProtonQt` (по умолчанию `~/.local/share/PortProtonQt`): кеш перенесён из `.cache` в `cache`, данные лаунчеров — в `launcher`, загружаемые бинарники — в `bin`, чтобы временный кеш AnyLinux не вызывал повторную загрузку при каждом запуске AppImage
|
||||
- Минимальная поддерживаемая версия Python повышена с 3.10 до 3.11
|
||||
- Поиск в библиотеке теперь находит совпадения в любой части названия и показывает одноимённые игры из разных источников
|
||||
- Тема теперь применяется без перезапуска приложения
|
||||
- Добавлена сборка Fedora 45
|
||||
- Автоматическая загрузка PPDB с linux-gaming.ru теперь отключена по умолчанию
|
||||
- Кнопка «Открыть PPDB» переименована в «Редактировать PPDB», чтобы не путать открытие файла с его запуском
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- Обновлён перевод на Испанский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/es/`
|
||||
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
|
||||
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
|
||||
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
|
||||
- Открытие карточек и тихий запуск унифицированы
|
||||
- `minazuki` добавлен в список конфликтующих с GameMode процессов
|
||||
- Встроенный терминал после ошибки остаётся открытым и закрывается клавишей Enter или кнопкой A геймпада
|
||||
- В список советов при вылете добавлено отключение Optiscaler
|
||||
|
||||
### Fixed
|
||||
|
||||
- Исправлены создание и восстановление `.desktop`-файлов с переносами строк в названии игры
|
||||
- Исправлено появление дубля карточки при добавлении игры
|
||||
- Исправлено отсутствие новых `.exe` в поиске библиотеки до обновления сетки
|
||||
- Исправлено обновление названия на детальной странице после редактирования ярлыка без повторного открытия страницы
|
||||
- Исправлено отображение дополнений и наборов GOG как отдельных игр в библиотеке
|
||||
- Исправлено отсутствие установленных демоверсий, модов, приложений и бета-версий Steam в библиотеке
|
||||
- Исправлена синтаксическая ошибка в `functions_helper`, препятствовавшая запуску PortProton с Bash 5.1
|
||||
- Исправлено управление интерфейсом при одновременном подключении нескольких геймпадов
|
||||
- Исправлена коллизия пользовательских данных автоустановок с одинаковыми именами исходных исполняемых файлов
|
||||
- Исправлен поиск скриптов и локалей через `XDG_DATA_DIRS` в NixOS
|
||||
- Исправлена проверка и установка недостающих i586-зависимостей в ALT Linux перед запуском игр
|
||||
- Исправлено игнорирование проверки 32-битных пакетов при запуске игр GOG
|
||||
- Исправлено отсутствие иконки у окна встроенного терминала
|
||||
- Исправлена потеря финального вывода при ошибке встроенного терминала
|
||||
- Исправлено открытие карточек GOG через URI при старте приложения
|
||||
- Исправлен сброс состояния кнопки записи лога при переключении детальной страницы в компактный режим
|
||||
- Исправлено отображение значка PortProtonQt в системном трее при медленной инициализации приложения
|
||||
- Исправлено отсутствие вкладок Wi-Fi и Bluetooth при запуске до инициализации системных адаптеров
|
||||
- Исправлена ошибка, из-за которой не работал поиск по настройкам при отсутствии доступных параметров MangoHud или Gamescope
|
||||
- Фильтр установленных игр теперь переключается без повторной загрузки библиотеки
|
||||
- Некорректные изображения в кэше Steam теперь пропускаются при поиске обложек
|
||||
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
|
||||
- Исправлено создание резервной копии префикса с `libarchive-c 5.1`
|
||||
- Исправлено завершение приложения при активных фоновых операциях
|
||||
|
||||
### Contributors
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @adem4ik
|
||||
- @gallegonovato
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -15,439 +15,3 @@ GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
We make use of various projects, some of which require their license to be preserved. Here's their respective licenses:
|
||||
-----------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
===============
|
||||
= icoextract: =
|
||||
===============
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2015-2016 Fadhil Mandaga
|
||||
Copyright (c) 2019-2025 James Lu <james@overdrivenetworks.com>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
|
||||
===============
|
||||
= portproton: =
|
||||
===============
|
||||
MIT License
|
||||
|
||||
|
||||
Copyright (c) 2025 Mikhail Tergoev
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
===============================
|
||||
= HowLongToBeat-Python-API : =
|
||||
===============================
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 JaeguKim
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
===============================
|
||||
= mdf2iso : =
|
||||
===============================
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.
|
||||
59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Library General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program; if not, write to the Free Software
|
||||
Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
|
||||
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Ty Coon>, 1 April 1989
|
||||
Ty Coon, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Library General
|
||||
Public License instead of this License.
|
||||
|
||||
=====================================================================
|
||||
|
||||
===========================
|
||||
= Kenney Interface Sounds =
|
||||
===========================
|
||||
|
||||
Interface Sounds (1.0)
|
||||
|
||||
Created/distributed by Kenney (www.kenney.nl)
|
||||
|
||||
License: Creative Commons Zero, CC0
|
||||
http://creativecommons.org/publicdomain/zero/1.0/
|
||||
|
||||
This content is free to use in personal, educational and commercial projects.
|
||||
|
||||
Support us by crediting Kenney or www.kenney.nl (this is not mandatory)
|
||||
@@ -5,14 +5,14 @@
|
||||
<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-6382-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
### Installation (devel)
|
||||
|
||||
```sh
|
||||
uv python install 3.10
|
||||
uv python install 3.11
|
||||
uv sync
|
||||
source .venv/bin/activate # For bash/zsh
|
||||
# or
|
||||
@@ -29,7 +29,7 @@ Launch using the `portprotonqt` command or via the shortcut in your application
|
||||
|
||||
### Development
|
||||
|
||||
To automatically prepare the environment (install Python 3.10, dependencies, pre-commit hooks, and generate translations), run the following script:
|
||||
To automatically prepare the environment (install Python 3.11, dependencies, pre-commit hooks, and generate translations), run the following script:
|
||||
|
||||
```sh
|
||||
./dev-scripts/prepare_env.sh
|
||||
@@ -65,13 +65,16 @@ PortProtonQt uses code and dependencies from the following projects:
|
||||
|
||||
- [Icoextract](https://github.com/jlu5/icoextract) — icon extraction library, [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE) license.
|
||||
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — library for interacting with HowLongToBeat, [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md) license.
|
||||
- [iat](https://sourceforge.net/projects/iat.berlios) — library for converting mdf and nrg to iso, GPLv3 license.
|
||||
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — library for converting mdf and nrg to iso, GPLv2 license.
|
||||
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — GOG downloading module, [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE) license.
|
||||
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — Epic Games launcher alternative, [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE) license.
|
||||
- [pyte](https://github.com/selectel/pyte) — ANSI escape code parser, [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file) license.
|
||||
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — base for virtual keyboard layout data, GPLv3 license.
|
||||
- [omikuji](https://github.com/omikuji-launcher/omikuji) — inspiration and source material for the procedural detail page backgrounds, [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE) license.
|
||||
- [Bottles](https://github.com/bottlesdevs/Bottles) — source of the compatibility analysis rules, GPL-3.0-only license.
|
||||
|
||||
See the [LICENSE](LICENSE) file for the full text of the licenses.
|
||||
PortProtonQt is licensed under the terms in [LICENSE](LICENSE). See
|
||||
[THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES) for third-party licenses.
|
||||
|
||||
> [!WARNING]
|
||||
> **Be careful!** If you are using a theme not from the official repository or a trusted source, make sure its `styles.py` file does not contain malicious or unwanted code. Since `styles.py` is a regular Python file, it can contain any instructions. Always check the contents of third-party themes before use.
|
||||
+9
-6
@@ -5,14 +5,14 @@
|
||||
<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-6382-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
### Установка (devel)
|
||||
|
||||
```sh
|
||||
uv python install 3.10
|
||||
uv python install 3.11
|
||||
uv sync
|
||||
source .venv/bin/activate # For bash/zsh
|
||||
# or
|
||||
@@ -29,7 +29,7 @@ source .venv/bin/activate.fish # For fish
|
||||
|
||||
### Разработка
|
||||
|
||||
Для автоматической подготовки окружения (установка Python 3.10, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
|
||||
Для автоматической подготовки окружения (установка Python 3.11, зависимостей, pre-commit хуков и генерация переводов) выполните скрипт:
|
||||
|
||||
```sh
|
||||
./dev-scripts/prepare_env.sh
|
||||
@@ -65,13 +65,16 @@ PortProtonQt использует код и зависимости от след
|
||||
|
||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
|
||||
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
|
||||
- [iat](https://sourceforge.net/projects/iat.berlios) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv3
|
||||
- [Iso9660 Analyzer Tool (IAT)](https://sourceforge.net/projects/iat.berlios/) — библиотека для конвертации mdf и nrg в iso, лицензия GPLv2
|
||||
- [heroic-gogdl](https://github.com/Heroic-Games-Launcher/heroic-gogdl) — модуль загрузки игр GOG, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/heroic-gogdl/blob/main/LICENSE).
|
||||
- [Legendary](https://github.com/Heroic-Games-Launcher/legendary) — альтернатива Epic Games Launcher, лицензия [GPLv3](https://github.com/Heroic-Games-Launcher/legendary/blob/master/LICENSE).
|
||||
- [pyte](https://github.com/selectel/pyte) — разбор ANSI escape-кодов, лицензия [LGPLv3](https://github.com/selectel/pyte?tab=LGPL-3.0-1-ov-file)
|
||||
- [gjs-osk](https://github.com/Vishram1123/gjs-osk) — основа данных раскладок виртуальной клавиатуры, лицензия GPLv3.
|
||||
- [omikuji](https://github.com/omikuji-launcher/omikuji) — источник вдохновения и материалов для процедурных фонов детальной страницы, лицензия [GPLv3](https://github.com/omikuji-launcher/omikuji/blob/master/LICENSE).
|
||||
- [Bottles](https://github.com/bottlesdevs/Bottles) — источник правил анализа совместимости, лицензия GPL-3.0-only.
|
||||
|
||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
|
||||
Условия лицензии PortProtonQt приведены в файле [LICENSE](LICENSE). Лицензии
|
||||
сторонних проектов приведены в файле [THIRD_PARTY_NOTICES](THIRD_PARTY_NOTICES).
|
||||
|
||||
> [!WARNING]
|
||||
> **Будьте осторожны!** Если вы берёте тему не из официального репозитория или надёжного источника, убедитесь, что в её файле `styles.py` нет вредоносного или нежелательного кода. Поскольку `styles.py` — это обычный Python-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.
|
||||
+1278
File diff suppressed because it is too large.
Load diff
@@ -52,7 +52,6 @@ while [ $# -gt 0 ]; do
|
||||
done
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
EXTRA_PACKAGES="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/get-debloated-pkgs.sh"
|
||||
|
||||
if [ "$LOCAL_MODE" = true ]; then
|
||||
echo "Using local PKGBUILD-git from repository..."
|
||||
@@ -104,9 +103,7 @@ makepkg -si --noconfirm
|
||||
|
||||
echo "Installing debloated packages..."
|
||||
echo "---------------------------------------------------------------"
|
||||
wget --retry-connrefused --tries=30 "$EXTRA_PACKAGES" -O ./get-debloated-pkgs.sh
|
||||
chmod +x ./get-debloated-pkgs.sh
|
||||
./get-debloated-pkgs.sh --add-common --prefer-nano
|
||||
get-debloated-pkgs --add-common --prefer-nano
|
||||
|
||||
if [ "$LOCAL_MODE" = true ]; then
|
||||
# For git version, we use portprotonqt-git
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
set -eu
|
||||
|
||||
SHARUN="https://raw.githubusercontent.com/pkgforge-dev/Anylinux-AppImages/refs/heads/main/useful-tools/quick-sharun.sh"
|
||||
ARCH="$(uname -m)"
|
||||
VERSION="$(cat ~/version)"
|
||||
export ARCH VERSION
|
||||
@@ -20,16 +19,12 @@ export OPTIMIZE_LAUNCH=1
|
||||
# Adjust comp settings to bypass oom-killer
|
||||
export DWARFS_COMP="zstd:level=15 -S22 -B5"
|
||||
|
||||
# DEPLOY ALL LIBS
|
||||
wget --retry-connrefused --tries=30 "$SHARUN" -O ./quick-sharun
|
||||
chmod +x ./quick-sharun
|
||||
|
||||
# Add udev rules
|
||||
mkdir -p ./AppDir/etc/udev/rules.d
|
||||
cp /usr/lib/udev/rules.d/60-portprotonqt.rules ./AppDir/etc/udev/rules.d
|
||||
|
||||
# Add PortProton scripts
|
||||
# Copy manual because im to lazzy for wait ./quick-sharun strace 208 .ppdb
|
||||
# Copy manually to avoid tracing 208 .ppdb files with quick-sharun
|
||||
mkdir -p ./AppDir/share
|
||||
cp -r /usr/share/portproton ./AppDir/share
|
||||
|
||||
@@ -43,9 +38,11 @@ GAMEPAD_LIBRARY=$(find /usr/lib -type f \
|
||||
-path '*/site-packages/portprotonqt/libportprotonqt_gamepad.so' \
|
||||
-print -quit)
|
||||
test -n "$GAMEPAD_LIBRARY"
|
||||
./quick-sharun \
|
||||
quick-sharun \
|
||||
/usr/bin/portprotonqt* \
|
||||
/usr/bin/bash \
|
||||
/usr/bin/update-desktop-database \
|
||||
/usr/bin/update-mime-database \
|
||||
/usr/bin/vk_gpu_info \
|
||||
"$GAMEPAD_LIBRARY" \
|
||||
/usr/lib/libQt6Core.so* \
|
||||
@@ -55,6 +52,11 @@ test -n "$GAMEPAD_LIBRARY"
|
||||
/usr/lib/qt6/plugins/imageformats/libqwebp.so \
|
||||
/usr/lib/7zip/7z*
|
||||
|
||||
# quick-sharun removes MIME package XML files during dependency deployment
|
||||
mkdir -p ./AppDir/share/mime/packages
|
||||
cp /usr/share/mime/packages/ru.linux_gaming.PortProtonQt.xml \
|
||||
./AppDir/share/mime/packages
|
||||
|
||||
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
|
||||
# build/development content and PySide bindings not imported by PortProtonQt.
|
||||
PYTHON_DIR=$(find ./AppDir/lib -maxdepth 1 -type d -name 'python3.*' -print -quit)
|
||||
@@ -96,4 +98,4 @@ done
|
||||
rm -rf "$PYSIDE_DIR"/QtAsyncio
|
||||
|
||||
# Turn AppDir into AppImage
|
||||
./quick-sharun --make-appimage
|
||||
quick-sharun --make-appimage
|
||||
Binary file not shown.
@@ -11,4 +11,4 @@ Terminal=false
|
||||
Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;Utility;
|
||||
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-executable;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds-disk;application/x-nrg;
|
||||
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds;application/x-nrg;
|
||||
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;
|
||||
NoDisplay=true
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;
|
||||
NoDisplay=true
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -10,4 +10,16 @@
|
||||
<comment xml:lang="ru">Скрипт автоустановки PortProton</comment>
|
||||
<glob pattern="*.ppai"/>
|
||||
</mime-type>
|
||||
<mime-type type="application/x-portproton-ppdb">
|
||||
<comment>PortProton configuration</comment>
|
||||
<comment xml:lang="ru">Конфигурация PortProton</comment>
|
||||
<sub-class-of type="text/plain"/>
|
||||
<glob pattern="*.ppdb" weight="100"/>
|
||||
</mime-type>
|
||||
<mime-type type="application/x-portproton-user-conf">
|
||||
<comment>PortProton user configuration</comment>
|
||||
<comment xml:lang="ru">Пользовательская конфигурация PortProton</comment>
|
||||
<sub-class-of type="text/plain"/>
|
||||
<glob pattern="user.conf" weight="100"/>
|
||||
</mime-type>
|
||||
</mime-info>
|
||||
@@ -23,6 +23,29 @@ python_module () {
|
||||
python3 -m portprotonqt.scripts_utils.$python3_module "$@"
|
||||
}
|
||||
|
||||
pw_alt_i586_dependencies () {
|
||||
local action="${1:-check}" installed list package
|
||||
installed="$(rpm -qa --qf '%{NAME}\n')" || return 2
|
||||
list=""
|
||||
|
||||
for package in glibc-nss glibc-gconv-modules libnm libvulkan1 libfreetype \
|
||||
$(grep '^libnss-' <<< "$installed" | grep -v '^libnss-fallback$') \
|
||||
$(grep '^xorg-dri-' <<< "$installed"); do
|
||||
if grep -Fqx "$package" <<< "$installed" \
|
||||
&& ! grep -Fqx "i586-$package" <<< "$installed" ; then
|
||||
list+=" i586-$package"
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ "$action" == "check" ]] ; then
|
||||
[[ -z "$list" ]]
|
||||
return $?
|
||||
fi
|
||||
|
||||
[[ -z "$list" ]] && return 0
|
||||
apt-get update && apt-get install -y $list
|
||||
}
|
||||
|
||||
python_term () {
|
||||
print_info "Running: easyterm $@"
|
||||
python_module easyterm -e "$@"
|
||||
@@ -1064,6 +1087,7 @@ EOF
|
||||
-e '/export PW_SOUND_DRIVER_USE=/d' \
|
||||
"$USER_CONF"
|
||||
fi
|
||||
chmod 644 "${USER_CONF}"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "$USER_CONF"
|
||||
@@ -2174,8 +2198,8 @@ manage_user_conf_value () {
|
||||
local var_name="$2"
|
||||
local var_value="$3"
|
||||
|
||||
if [[ -z "${action}" ]] || [[ -z "${var_name}" ]]; then
|
||||
print_error "Action and variable name must be provided"
|
||||
if [[ -z "${action}" ]]; then
|
||||
print_error "Action must be provided"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -2186,6 +2210,11 @@ manage_user_conf_value () {
|
||||
|
||||
case "$action" in
|
||||
get)
|
||||
if [[ -z "${var_name}" ]]; then
|
||||
grep -E '^export [A-Za-z_][A-Za-z0-9_]*=' "${USER_CONF}" 2>/dev/null \
|
||||
| sed -E 's/^export //' || true
|
||||
return 0
|
||||
fi
|
||||
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
|
||||
# Extract the value, handling both quoted and unquoted values
|
||||
local value=$(grep "^export ${var_name}=" "${USER_CONF}" | sed -E 's/^export [^=]+=("?)(.*)\1$/\2/')
|
||||
@@ -2197,6 +2226,10 @@ manage_user_conf_value () {
|
||||
fi
|
||||
;;
|
||||
set)
|
||||
if [[ -z "${var_name}" ]]; then
|
||||
print_error "Variable name must be provided"
|
||||
return 1
|
||||
fi
|
||||
if [[ -z "${var_value}" ]]; then
|
||||
print_error "Variable value must be provided for set action"
|
||||
return 1
|
||||
@@ -2207,6 +2240,10 @@ manage_user_conf_value () {
|
||||
return 0
|
||||
;;
|
||||
delete)
|
||||
if [[ -z "${var_name}" ]]; then
|
||||
print_error "Variable name must be provided"
|
||||
return 1
|
||||
fi
|
||||
if grep -q "^export ${var_name}=" "${USER_CONF}" ; then
|
||||
sed -i "/^export ${var_name}=/d" "${USER_CONF}"
|
||||
print_info "Removed ${var_name} from USER.conf"
|
||||
@@ -2918,6 +2955,9 @@ start_portproton () {
|
||||
|
||||
if [[ "${PW_USE_NVAPI_AND_DLSS}" == "1" ]] ; then
|
||||
export DXVK_ENABLE_NVAPI="1"
|
||||
if [[ -f "${WINEDIR}/share/dxvk-nvapi-vkreflex-layer/implicit_layer.d/VkLayer_DXVK_NVAPI_reflex.json" ]] ; then
|
||||
PW_VKREFLEX=1
|
||||
fi
|
||||
if echo "$LSPCI_VGA" | grep -i -q 'nvidia' ; then
|
||||
FIND_NVNGX="$(dirname $(find /usr/* -type f -name "nvngx.dll" 2>/dev/null | head -n 1 | awk '{print $1}'))"
|
||||
if [[ -n "$FIND_NVNGX" ]] ; then
|
||||
@@ -2929,6 +2969,7 @@ start_portproton () {
|
||||
fi
|
||||
else
|
||||
export DXVK_ENABLE_NVAPI="0"
|
||||
unset PW_VKREFLEX DXVK_NVAPI_VKREFLEX
|
||||
fi
|
||||
|
||||
if [[ "${PW_HEAP_DELAY_FREE}" == 1 ]]
|
||||
@@ -2994,7 +3035,7 @@ start_portproton () {
|
||||
elif [[ "$PW_USE_GAMEMODE" = "1" ]] \
|
||||
&& [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]
|
||||
then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn ; then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn minazuki ; then
|
||||
export GAMEMODERUN=0
|
||||
export PW_GAMEMODERUN_SLR=""
|
||||
local current_power_profile
|
||||
@@ -3206,7 +3247,7 @@ start_portproton () {
|
||||
# DGVOODOO2 enable:
|
||||
if [[ "${PW_DGVOODOO2}" == "1" ]] \
|
||||
&& [[ "${PW_USE_SPECIALK}" != "1" ]] \
|
||||
&& [[ "${PW_USE_D7VK}" != "1" ]] \
|
||||
&& [[ "${PW_USE_D7VK}" != "1" ]]
|
||||
then
|
||||
if [[ ! -f "${PW_VULKAN_DIR}/dgvoodoo2-${DGV2_VER}/dgVoodoo.conf" ]] ; then
|
||||
print_info "Download and install DGVOODOO2 v.${DGV2_VER}"
|
||||
@@ -3776,6 +3817,7 @@ start_portproton () {
|
||||
}
|
||||
|
||||
pw_run () {
|
||||
local pw_game_exit_code
|
||||
if [[ "$PW_USE_TERMINAL" == 1 ]] \
|
||||
&& [[ "${PRESSURE_VESSEL_TERMINAL:-}" != "tty" ]]
|
||||
then
|
||||
@@ -3842,6 +3884,7 @@ pw_run () {
|
||||
${PW_GAMEMODERUN_SLR} \
|
||||
${PW_ADD_VAR_SLR} \
|
||||
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
|
||||
pw_game_exit_code=$?
|
||||
else
|
||||
if [[ "$PW_USE_TERMINAL" == 1 ]] ; then
|
||||
pw_init_runtime
|
||||
@@ -3862,6 +3905,7 @@ pw_run () {
|
||||
${PW_GAMEMODERUN_SLR} \
|
||||
${PW_ADD_VAR_SLR} \
|
||||
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
|
||||
pw_game_exit_code=$?
|
||||
print_info "Update prefix log:"
|
||||
[[ -f "${PW_TMPFS_PATH}/update_pfx_log" ]] && cat "${PW_TMPFS_PATH}/update_pfx_log"
|
||||
echo
|
||||
@@ -3891,6 +3935,7 @@ pw_run () {
|
||||
${PW_INHIBIT_SLR} \
|
||||
${PW_TASKSET_SLR} \
|
||||
"${WINELOADER}" "$@" ${proxy_launch_parameters} &>>"${PW_LOG_FILE}"
|
||||
pw_game_exit_code=$?
|
||||
wait_wineserver
|
||||
else
|
||||
print_info "Update prefix log:"
|
||||
@@ -3911,9 +3956,12 @@ pw_run () {
|
||||
${PW_INHIBIT_SLR} \
|
||||
${PW_TASKSET_SLR} \
|
||||
"${WINELOADER}" "$@" ${proxy_launch_parameters}
|
||||
pw_game_exit_code=$?
|
||||
wait_wineserver
|
||||
fi
|
||||
fi
|
||||
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$pw_game_exit_code"
|
||||
return "$pw_game_exit_code"
|
||||
}
|
||||
export -f pw_run
|
||||
|
||||
@@ -4001,6 +4049,10 @@ steamplay_launch () {
|
||||
else
|
||||
export PW_VK_LAYER_PATH="${PW_PLUGINS_PATH}/portable/share/vulkan/implicit_layer.d"
|
||||
fi
|
||||
if [[ "${PW_VKREFLEX}" == "1" ]] ; then
|
||||
export DXVK_NVAPI_VKREFLEX="1"
|
||||
export PW_VK_LAYER_PATH="${PW_VK_LAYER_PATH:+${PW_VK_LAYER_PATH}:}${WINEDIR}/share/dxvk-nvapi-vkreflex-layer/implicit_layer.d"
|
||||
fi
|
||||
|
||||
pw_mangohud_check
|
||||
pw_vkbasalt_check
|
||||
@@ -4079,6 +4131,7 @@ steamplay_launch () {
|
||||
${PW_GAMEMODERUN_SLR} \
|
||||
${PW_TASKSET_SLR} \
|
||||
"${proton_runner}" waitforexitandrun "${PW_EXE_FILE}" "$@"
|
||||
printf 'PORTPROTONQT_GAME_EXIT_CODE=%s\n' "$?"
|
||||
if [[ $PW_LOG != 1 ]] && [[ -n $START_PW_TIME_IN_GAME ]] ; then
|
||||
debug_timer --end -s "PW_TIME_IN_GAME"
|
||||
PW_TIME_IN_GAME=$(( PW_TIME_IN_GAME / 1000 ))
|
||||
|
||||
@@ -6,6 +6,19 @@ export url_git="https://git.linux-gaming.ru/CastroFidel/PortWINE"
|
||||
########################################################################
|
||||
$PW_DEBUG
|
||||
|
||||
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
|
||||
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
|
||||
else
|
||||
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
|
||||
fi
|
||||
|
||||
if [[ "${1:-}" == "cli" ]] \
|
||||
&& [[ "${2:-}" == "--alt-i586-dependencies" ]] ; then
|
||||
source "$PORT_SCRIPTS_PATH/functions_helper"
|
||||
pw_alt_i586_dependencies "${3:-}"
|
||||
exit $?
|
||||
fi
|
||||
|
||||
if [[ $(id -u) = 0 ]] \
|
||||
&& [[ ! -e "/userdata/system/batocera.conf" ]]
|
||||
then
|
||||
@@ -13,12 +26,6 @@ then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$(realpath "$0")" == "/usr/share/portproton/scripts/start.sh" ]] ; then
|
||||
PORT_SCRIPTS_PATH="/usr/share/portproton/scripts/"
|
||||
else
|
||||
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
|
||||
fi
|
||||
|
||||
PORT_CONF_PATH="$(dirname "$PORT_SCRIPTS_PATH")/conf"
|
||||
|
||||
if [[ -z "$PORT_DATA_PATH" ]] ; then
|
||||
@@ -266,7 +273,7 @@ case "$1" in
|
||||
--launch Launches the application immediately, requires the path to the .exe file
|
||||
--edit-db After the variable, the path to the .exe file is required and then the variables.
|
||||
(List their variables and values for example PW_MANGOHUD=1 PW_VKBASALT=0, etc.)
|
||||
--get-user-conf Get a value from user.conf file, requires variable name
|
||||
--get-user-conf Get one or all values from user.conf file
|
||||
--set-user-conf Set a value in user.conf file, requires variable name and value
|
||||
--del-user-conf Delete a value from user.conf file, requires variable name
|
||||
--list-db List all available database variables
|
||||
|
||||
+141
-35
@@ -3,28 +3,119 @@
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
#define AXIS_ACTIVATION_THRESHOLD 16000
|
||||
|
||||
typedef struct {
|
||||
SDL_Gamepad *gamepad;
|
||||
SDL_JoystickID instance_id;
|
||||
int sdl_type;
|
||||
bool buttons[SDL_GAMEPAD_BUTTON_COUNT];
|
||||
bool axes_active[SDL_GAMEPAD_AXIS_COUNT];
|
||||
} GamepadEntry;
|
||||
|
||||
typedef struct {
|
||||
GamepadEntry *entries;
|
||||
int count;
|
||||
SDL_JoystickID active_instance_id;
|
||||
} PortProtonGamepad;
|
||||
|
||||
static PortProtonGamepad *open_gamepad(SDL_JoystickID instance_id)
|
||||
static bool has_gamepad(const PortProtonGamepad *gamepads,
|
||||
SDL_JoystickID instance_id)
|
||||
{
|
||||
for (int index = 0; index < gamepads->count; index++) {
|
||||
if (gamepads->entries[index].instance_id == instance_id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool add_gamepad(PortProtonGamepad *gamepads,
|
||||
SDL_JoystickID instance_id)
|
||||
{
|
||||
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
|
||||
if (gamepad == NULL) {
|
||||
return NULL;
|
||||
return false;
|
||||
}
|
||||
PortProtonGamepad *result = malloc(sizeof(*result));
|
||||
if (result == NULL) {
|
||||
GamepadEntry *entries = realloc(
|
||||
gamepads->entries, sizeof(*entries) * (gamepads->count + 1));
|
||||
if (entries == NULL) {
|
||||
SDL_CloseGamepad(gamepad);
|
||||
SDL_SetError("Failed to allocate gamepad handle");
|
||||
return SDL_SetError("Failed to allocate gamepad handle");
|
||||
}
|
||||
gamepads->entries = entries;
|
||||
GamepadEntry entry = {0};
|
||||
entry.gamepad = gamepad;
|
||||
entry.instance_id = instance_id;
|
||||
entry.sdl_type = SDL_GetGamepadTypeForID(instance_id);
|
||||
entries[gamepads->count] = entry;
|
||||
gamepads->count++;
|
||||
return true;
|
||||
}
|
||||
|
||||
static void remove_disconnected(PortProtonGamepad *gamepads)
|
||||
{
|
||||
int active_count = 0;
|
||||
for (int index = 0; index < gamepads->count; index++) {
|
||||
GamepadEntry entry = gamepads->entries[index];
|
||||
if (SDL_GamepadConnected(entry.gamepad)) {
|
||||
gamepads->entries[active_count++] = entry;
|
||||
} else {
|
||||
if (gamepads->active_instance_id == entry.instance_id) {
|
||||
gamepads->active_instance_id = 0;
|
||||
}
|
||||
SDL_CloseGamepad(entry.gamepad);
|
||||
}
|
||||
}
|
||||
gamepads->count = active_count;
|
||||
}
|
||||
|
||||
static void update_gamepads(PortProtonGamepad *gamepads)
|
||||
{
|
||||
SDL_PumpEvents();
|
||||
SDL_UpdateGamepads();
|
||||
remove_disconnected(gamepads);
|
||||
int count = 0;
|
||||
SDL_JoystickID *instance_ids = SDL_GetGamepads(&count);
|
||||
if (instance_ids == NULL) {
|
||||
return;
|
||||
}
|
||||
for (int index = 0; index < count; index++) {
|
||||
if (!has_gamepad(gamepads, instance_ids[index])) {
|
||||
add_gamepad(gamepads, instance_ids[index]);
|
||||
}
|
||||
}
|
||||
SDL_free(instance_ids);
|
||||
}
|
||||
|
||||
static bool update_entry_activity(GamepadEntry *entry)
|
||||
{
|
||||
bool activated = false;
|
||||
for (int button = 0; button < SDL_GAMEPAD_BUTTON_COUNT; button++) {
|
||||
bool pressed = SDL_GetGamepadButton(entry->gamepad, button);
|
||||
activated |= pressed && !entry->buttons[button];
|
||||
entry->buttons[button] = pressed;
|
||||
}
|
||||
for (int axis = 0; axis < SDL_GAMEPAD_AXIS_COUNT; axis++) {
|
||||
int value = SDL_GetGamepadAxis(entry->gamepad, axis);
|
||||
bool active = abs(value) >= AXIS_ACTIVATION_THRESHOLD;
|
||||
activated |= active && !entry->axes_active[axis];
|
||||
entry->axes_active[axis] = active;
|
||||
}
|
||||
return activated;
|
||||
}
|
||||
|
||||
static const GamepadEntry *get_active_gamepad(const PortProtonGamepad *gamepads)
|
||||
{
|
||||
if (gamepads == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
result->gamepad = gamepad;
|
||||
result->instance_id = instance_id;
|
||||
result->sdl_type = SDL_GetGamepadTypeForID(instance_id);
|
||||
return result;
|
||||
for (int index = 0; index < gamepads->count; index++) {
|
||||
if (gamepads->entries[index].instance_id == gamepads->active_instance_id) {
|
||||
return &gamepads->entries[index];
|
||||
}
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
PortProtonGamepad *portproton_gamepad_find(void)
|
||||
@@ -33,23 +124,17 @@ PortProtonGamepad *portproton_gamepad_find(void)
|
||||
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
|
||||
return NULL;
|
||||
}
|
||||
SDL_PumpEvents();
|
||||
SDL_UpdateGamepads();
|
||||
int count = 0;
|
||||
SDL_JoystickID *gamepads = SDL_GetGamepads(&count);
|
||||
if (gamepads == NULL) {
|
||||
PortProtonGamepad *result = calloc(1, sizeof(*result));
|
||||
if (result == NULL) {
|
||||
SDL_SetError("Failed to allocate gamepad collection");
|
||||
return NULL;
|
||||
}
|
||||
if (count == 0) {
|
||||
SDL_free(gamepads);
|
||||
update_gamepads(result);
|
||||
if (result->count == 0) {
|
||||
free(result);
|
||||
SDL_ClearError();
|
||||
return NULL;
|
||||
}
|
||||
PortProtonGamepad *result = NULL;
|
||||
for (int index = 0; index < count && result == NULL; index++) {
|
||||
result = open_gamepad(gamepads[index]);
|
||||
}
|
||||
SDL_free(gamepads);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -64,56 +149,77 @@ void portproton_gamepad_close(PortProtonGamepad *gamepad)
|
||||
if (gamepad == NULL) {
|
||||
return;
|
||||
}
|
||||
if (gamepad->gamepad != NULL) {
|
||||
SDL_CloseGamepad(gamepad->gamepad);
|
||||
for (int index = 0; index < gamepad->count; index++) {
|
||||
SDL_CloseGamepad(gamepad->entries[index].gamepad);
|
||||
}
|
||||
free(gamepad->entries);
|
||||
free(gamepad);
|
||||
}
|
||||
|
||||
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL && gamepad->gamepad != NULL &&
|
||||
SDL_GamepadConnected(gamepad->gamepad);
|
||||
return gamepad != NULL && gamepad->count > 0;
|
||||
}
|
||||
|
||||
void portproton_gamepad_update(void)
|
||||
void portproton_gamepad_update(PortProtonGamepad *gamepad)
|
||||
{
|
||||
SDL_UpdateGamepads();
|
||||
if (gamepad != NULL) {
|
||||
update_gamepads(gamepad);
|
||||
for (int index = 0; index < gamepad->count; index++) {
|
||||
if (update_entry_activity(&gamepad->entries[index])) {
|
||||
gamepad->active_instance_id = gamepad->entries[index].instance_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
|
||||
{
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
const GamepadEntry *active = get_active_gamepad(gamepad);
|
||||
if (active == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return SDL_GetGamepadButton(gamepad->gamepad, button);
|
||||
return SDL_GetGamepadButton(active->gamepad, button);
|
||||
}
|
||||
|
||||
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
|
||||
{
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
const GamepadEntry *active = get_active_gamepad(gamepad);
|
||||
if (active == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return SDL_GetGamepadAxis(gamepad->gamepad, axis);
|
||||
return SDL_GetGamepadAxis(active->gamepad, axis);
|
||||
}
|
||||
|
||||
uint32_t portproton_gamepad_get_active_instance_id(
|
||||
const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL ? gamepad->active_instance_id : 0;
|
||||
}
|
||||
|
||||
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
if (gamepad == NULL || gamepad->count == 0) {
|
||||
return "";
|
||||
}
|
||||
const char *name = SDL_GetGamepadName(gamepad->gamepad);
|
||||
const char *name = SDL_GetGamepadName(gamepad->entries[0].gamepad);
|
||||
return name != NULL ? name : "";
|
||||
}
|
||||
|
||||
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL ? gamepad->instance_id : 0;
|
||||
return gamepad != NULL && gamepad->count > 0
|
||||
? gamepad->entries[0].instance_id : 0;
|
||||
}
|
||||
|
||||
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL ? gamepad->sdl_type : 0;
|
||||
const GamepadEntry *active = get_active_gamepad(gamepad);
|
||||
if (active != NULL) {
|
||||
return active->sdl_type;
|
||||
}
|
||||
return gamepad != NULL && gamepad->count > 0
|
||||
? gamepad->entries[0].sdl_type : 0;
|
||||
}
|
||||
|
||||
void portproton_gamepad_shutdown(void)
|
||||
|
||||
@@ -19,6 +19,18 @@ for file in "$@"; do
|
||||
echo "$file: bash -n syntax check failed"
|
||||
status=1
|
||||
fi
|
||||
|
||||
# Bash 5.1 requires a separator before then after a conditional command.
|
||||
if ! awk '
|
||||
/\]\][ \t]+then([ \t;]|$)/ || (continued && /^[ \t]*then([ \t;]|$)/) {
|
||||
printf "%s:%d: Bash 5.1 requires a newline or semicolon before then\n", FILENAME, NR
|
||||
failed = 1
|
||||
}
|
||||
{ continued = /\]\][ \t]*\\$/ }
|
||||
END { exit failed }
|
||||
' "$file"; then
|
||||
status=1
|
||||
fi
|
||||
done
|
||||
|
||||
exit "$status"
|
||||
@@ -1,10 +1,12 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
GITHUB_REPOS=("Boria138/PortProtonQt" "linux-gaming-ru/PortProtonQt")
|
||||
GITHUB_REPOS=("linux-gaming-ru/PortProtonQt")
|
||||
GITEA_REPO="Linux-Gaming/PortProtonQt"
|
||||
GITEA_URL="https://git.linux-gaming.ru"
|
||||
|
||||
jq_format='def format_downloads: if . < 1000 then tostring else ((. / 100 | round) / 10 | tostring) + "k" end;'
|
||||
|
||||
jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | .download_count] | add // 0'
|
||||
|
||||
echo "=== PortProtonQt Download Stats ==="
|
||||
@@ -12,49 +14,52 @@ echo
|
||||
|
||||
echo "--- GitHub (${GITHUB_REPOS[*]}) ---"
|
||||
GH_RAW=$(for repo in "${GITHUB_REPOS[@]}"; do
|
||||
curl -sf "https://api.github.com/repos/${repo}/releases?per_page=100"
|
||||
curl -sfL "https://api.github.com/repos/${repo}/releases?per_page=100"
|
||||
done | jq -s 'add')
|
||||
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
|
||||
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
|
||||
echo "Releases: $GH_RELEASES"
|
||||
echo "Total downloads: $GH_COUNT"
|
||||
GH_DISPLAY=$(jq -nr --argjson count "$GH_COUNT" "$jq_format"'$count | format_downloads')
|
||||
echo "Total downloads: $GH_DISPLAY"
|
||||
echo
|
||||
echo "Per release:"
|
||||
echo "$GH_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
|
||||
echo "$GH_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.published_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
|
||||
echo
|
||||
|
||||
echo "--- Gitea (${GITEA_REPO}) ---"
|
||||
GIT_RAW=$(curl -sf "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
|
||||
GIT_RAW=$(curl -sfL "${GITEA_URL}/api/v1/repos/${GITEA_REPO}/releases?limit=50")
|
||||
GIT_COUNT=$(echo "$GIT_RAW" | jq "$jq_total")
|
||||
GIT_RELEASES=$(echo "$GIT_RAW" | jq 'length')
|
||||
echo "Releases: $GIT_RELEASES"
|
||||
echo "Total downloads: $GIT_COUNT"
|
||||
GIT_DISPLAY=$(jq -nr --argjson count "$GIT_COUNT" "$jq_format"'$count | format_downloads')
|
||||
echo "Total downloads: $GIT_DISPLAY"
|
||||
echo
|
||||
echo "Per release:"
|
||||
echo "$GIT_RAW" | jq -r '.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count)")'
|
||||
echo "$GIT_RAW" | jq -r "$jq_format"'.[] | "\n \(.tag_name) (\(.created_at)):" as $header | $header, (.assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not) | " \(.name): \(.download_count | format_downloads)")'
|
||||
echo
|
||||
|
||||
TOTAL=$((GH_COUNT + GIT_COUNT))
|
||||
BADGE_TOTAL=$(jq -nr --argjson total "$TOTAL" "$jq_format"'$total | format_downloads')
|
||||
echo "========================"
|
||||
echo "GitHub total: $GH_COUNT"
|
||||
echo "Gitea total: $GIT_COUNT"
|
||||
echo "Grand total: $TOTAL"
|
||||
echo "GitHub total: $GH_DISPLAY"
|
||||
echo "Gitea total: $GIT_DISPLAY"
|
||||
echo "Grand total: $BADGE_TOTAL"
|
||||
echo
|
||||
echo "--- Top downloads (by file) ---"
|
||||
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total)\t\(.name)"'
|
||||
jq_filter_top='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | group_by(.name) | map({name: .[0].name, total: (map(.download_count) | add)}) | sort_by(-.total) | .[:15] | .[] | " \(.total | format_downloads)\t\(.name)"'
|
||||
echo "GitHub:"
|
||||
echo "$GH_RAW" | jq -r "$jq_filter_top"
|
||||
echo "$GH_RAW" | jq -r "$jq_format $jq_filter_top"
|
||||
echo
|
||||
echo "Gitea:"
|
||||
echo "$GIT_RAW" | jq -r "$jq_filter_top"
|
||||
echo "$GIT_RAW" | jq -r "$jq_format $jq_filter_top"
|
||||
echo
|
||||
echo "--- Top downloads (by type) ---"
|
||||
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total)"'
|
||||
jq_type='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat") | not)] | map({type: (.name | split(".") | last | if test("^AppImage$") then "AppImage" elif test("^deb$") then "deb" elif test("^rpm$") then "rpm" elif test("^zst$") then "pkg.tar.zst" else "other" end), downloads: .download_count}) | group_by(.type) | map({type: .[0].type, total: (map(.downloads) | add)}) | sort_by(-.total) | .[] | " \(.type): \(.total | format_downloads)"'
|
||||
echo "GitHub:"
|
||||
echo "$GH_RAW" | jq -r "$jq_type"
|
||||
echo "$GH_RAW" | jq -r "$jq_format $jq_type"
|
||||
echo
|
||||
echo "Gitea:"
|
||||
echo "$GIT_RAW" | jq -r "$jq_type"
|
||||
echo "$GIT_RAW" | jq -r "$jq_format $jq_type"
|
||||
|
||||
if [[ "${CI:-}" != "true" ]]; then
|
||||
echo
|
||||
@@ -62,9 +67,9 @@ if [[ "${CI:-}" != "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
sed -i "s|-[0-9]*-green?style=flat-square|-$TOTAL-green?style=flat-square|g" README.md README.ru.md
|
||||
sed -i -E "s|-[0-9]+(\.[0-9]+)?k?-green\?style=flat-square|-$BADGE_TOTAL-green?style=flat-square|g" README.md README.ru.md
|
||||
echo
|
||||
echo "Badge updated: $TOTAL"
|
||||
echo "Badge updated: $BADGE_TOTAL"
|
||||
|
||||
git diff --quiet README.md README.ru.md && { echo "No changes to commit"; exit 0; }
|
||||
|
||||
|
||||
@@ -17,13 +17,13 @@ if ! command -v uv &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. Check if Python 3.10 is already installed via uv
|
||||
# We look for cpython-3.10 and ensure it doesn't say "<download available>"
|
||||
if uv python list | grep "cpython-3.10" | grep -qv "<download available>"; then
|
||||
echo "✅ Python 3.10 is already available."
|
||||
# 2. Check if Python 3.11 is already installed via uv
|
||||
# We look for cpython-3.11 and ensure it doesn't say "<download available>"
|
||||
if uv python list | grep "cpython-3.11" | grep -qv "<download available>"; then
|
||||
echo "✅ Python 3.11 is already available."
|
||||
else
|
||||
echo "🐍 Installing Python 3.10..."
|
||||
uv python install 3.10
|
||||
echo "🐍 Installing Python 3.11..."
|
||||
uv python install 3.11
|
||||
fi
|
||||
|
||||
# 3. Sync dependencies
|
||||
|
||||
@@ -44,7 +44,7 @@ Translations submitted online are periodically reviewed and merged into the main
|
||||
1. Run:
|
||||
|
||||
```bash
|
||||
uv python install 3.10
|
||||
uv python install 3.11
|
||||
uv sync --all-extras --dev
|
||||
source .venv/bin/activate
|
||||
python dev-scripts/l10n.py --create-new <locale_code>
|
||||
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
|
||||
If you’ve added new strings to the code:
|
||||
|
||||
```bash
|
||||
uv python install 3.10
|
||||
uv python install 3.11
|
||||
uv sync --all-extras --dev
|
||||
source .venv/bin/activate
|
||||
python dev-scripts/l10n.py --update-all
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
1. Выполните:
|
||||
|
||||
```bash
|
||||
uv python install 3.10
|
||||
uv python install 3.11
|
||||
uv sync --all-extras --dev
|
||||
source .venv/bin/activate
|
||||
python dev-scripts/l10n.py --create-new <код_локали>
|
||||
@@ -64,7 +64,7 @@ python dev-scripts/l10n.py
|
||||
Если вы добавили новые строки в код:
|
||||
|
||||
```bash
|
||||
uv python install 3.10
|
||||
uv python install 3.11
|
||||
uv sync --all-extras --dev
|
||||
source .venv/bin/activate
|
||||
python dev-scripts/l10n.py --update-all
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [Detail Page Layout Mode](#detail-page-layout-mode)
|
||||
- [Detail Page Background Mode](#detail-page-background-mode)
|
||||
- [Background Configuration](#background-configuration)
|
||||
- [Library Background](#library-background)
|
||||
- [Preloader](#preloader)
|
||||
- [Source Corner (Ribbon)](#source-corner-ribbon)
|
||||
- [Terminal Color Schemes](#terminal-color-schemes)
|
||||
@@ -234,14 +235,36 @@ NAV_BUTTON_STYLE = f"""
|
||||
You can control the game library card layout directly from the theme via `styles.py`:
|
||||
|
||||
```python
|
||||
# "grid" (default) or "list"
|
||||
# "grid", "list", "vertical", "horizontal", or "horizontal_top"
|
||||
LIBRARY_LAYOUT_MODE = "grid"
|
||||
```
|
||||
|
||||
- `grid`: multi-column card grid (classic behavior).
|
||||
- `list`: horizontal row-style cards (launcher-style list).
|
||||
- `vertical`: a full-width table with game title, last launch, time spent, and
|
||||
library columns.
|
||||
- `horizontal`: one horizontally scrolling row containing every game.
|
||||
- `horizontal_top`: the first `horizontalTopGameLimit` games from the current
|
||||
filter and sort order, followed by a tile composed from the next games that
|
||||
opens the complete grid.
|
||||
|
||||
Card geometry is controlled by `GAME_CARD_LIST` and `GAME_CARD_GRID`:
|
||||
The `horizontal_top` preview tile is configured with theme-level constants:
|
||||
|
||||
```python
|
||||
horizontalTopGameLimit = 10 # Games shown before the tile.
|
||||
fullLibraryTileGameCount = 4 # Cover images shown in the tile.
|
||||
fullLibraryTileSize = (180, 180) # Tile width and height.
|
||||
fullLibraryTileColumns = 2 # Cover columns inside the tile.
|
||||
fullLibraryTileRows = 2 # Cover rows inside the tile.
|
||||
fullLibraryTileRadius = 10 # Tile corner radius in pixels.
|
||||
```
|
||||
|
||||
`fullLibraryTileColumns * fullLibraryTileRows` should be at least
|
||||
`fullLibraryTileGameCount`. The tile opens the complete library grid when
|
||||
activated.
|
||||
|
||||
Card geometry is controlled by `GAME_CARD_LIST`, `GAME_CARD_GRID`, and
|
||||
`GAME_CARD_HORIZONTAL`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
@@ -258,6 +281,7 @@ GAME_CARD_LIST = {
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
@@ -265,12 +289,48 @@ GAME_CARD_GRID = {
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_orientation": "vertical",
|
||||
"card_animation_type": "scale",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 0.56,
|
||||
"card_height_ratio": 0.78,
|
||||
"cover_radius": 15,
|
||||
"layout_margins": (20, 20, 20, 20),
|
||||
"layout_spacing": 20,
|
||||
}
|
||||
```
|
||||
|
||||
Set `card_orientation` to `"vertical"` to reuse the grid card proportions in
|
||||
the horizontal strip. Omit it to keep the horizontal block proportions.
|
||||
|
||||
Values are logical pixels. `cover_radius` applies to static and animated covers;
|
||||
`border_radius` applies to the painted focus and hover border. Grid cover width
|
||||
continues to follow the library card-size setting.
|
||||
|
||||
Card animation keys can be overridden independently in `GAME_CARD_LIST`,
|
||||
`GAME_CARD_GRID`, or `GAME_CARD_HORIZONTAL`: `card_animation_type`, border
|
||||
widths, pulse settings, gradient angles and colors, fill/stripe/glow colors and
|
||||
opacity, scale values, animation durations, and easing curves. For example,
|
||||
`horizontal_top` can define its own `scale` type, scale values, duration, and
|
||||
easing while the complete grid uses separately configured `gradient` values.
|
||||
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback;
|
||||
`card_animation_type` defaults to `gradient`.
|
||||
|
||||
```python
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_animation_type": "scale",
|
||||
"default_scale": 1.0,
|
||||
"hover_scale": 1.055,
|
||||
"focus_scale": 1.075,
|
||||
"scale_anim_duration": 135,
|
||||
"scale_easing_curve": "OutCubic",
|
||||
"scale_easing_curve_out": "InOutCubic",
|
||||
}
|
||||
```
|
||||
|
||||
Card shadows use the shared `shadow_blur_radius` and `shadow_offset` theme
|
||||
constants in both grid and list layouts.
|
||||
|
||||
@@ -452,6 +512,53 @@ Existing themes may continue using `DETAIL_PAGE_GRADIENT`,
|
||||
`DETAIL_PAGE_GRADIENT_FY`, and `DETAIL_PAGE_WAVES`. Legacy values override the
|
||||
corresponding nested settings.
|
||||
|
||||
## Library Background
|
||||
|
||||
The library can use the cover of the currently hovered or focused card as its
|
||||
background. This feature is opt-in: if `LIBRARY_BACKGROUND` is not defined, the
|
||||
library keeps the theme's regular background unchanged.
|
||||
|
||||
`LIBRARY_BACKGROUND` selects the effect and can override detail-page
|
||||
background settings locally. Its `backgrounds` dictionary is merged with
|
||||
`DETAIL_PAGE_BACKGROUNDS`, so only the values that should differ need to be
|
||||
specified:
|
||||
|
||||
```python
|
||||
LIBRARY_BACKGROUND = {
|
||||
"margins": (0, 0, 0, 0),
|
||||
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
|
||||
"backgrounds": {
|
||||
"animation_interval_ms": 30,
|
||||
"animation_speed": 0.03,
|
||||
"blur": {
|
||||
"render_resolution": (1280, 720),
|
||||
"radius": 64,
|
||||
},
|
||||
"gradient": {
|
||||
"stops": None,
|
||||
"type": "linear",
|
||||
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4,
|
||||
"wave_amplitude_ratio": 0.06,
|
||||
"wave_frequency": 2.0,
|
||||
"layer_spacing_ratio": 0.04,
|
||||
"base_opacity": 0.45,
|
||||
"opacity_decay": 0.85,
|
||||
"animation_speed": 0.03,
|
||||
"animation_interval_ms": 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `blur` settings control the intermediate image size and blur radius.
|
||||
`margins` are applied to the background layer layout. Animated modes use the
|
||||
same effect-specific dictionaries documented in `DETAIL_PAGE_BACKGROUNDS`;
|
||||
copy the corresponding dictionary there when overriding `aurora`, `leaf`,
|
||||
`metaballs`, `veins`, or `diagnostics`.
|
||||
|
||||
---
|
||||
|
||||
The `PRELOADER` dictionary controls the loading indicator style and animation. If not defined, the default spinner is used.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
|
||||
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
|
||||
- [Настройка фона](#настройка-фона)
|
||||
- [Фон библиотеки](#фон-библиотеки)
|
||||
- [Прелоадер](#прелоадер)
|
||||
- [Уголок источника (Лента)](#уголок-источника-лента)
|
||||
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
|
||||
@@ -234,14 +235,36 @@ NAV_BUTTON_STYLE = f"""
|
||||
Вы можете управлять типом отображения карточек библиотеки прямо из темы через `styles.py`:
|
||||
|
||||
```python
|
||||
# "grid" (по умолчанию) или "list"
|
||||
# "grid", "list", "vertical", "horizontal" или "horizontal_top"
|
||||
LIBRARY_LAYOUT_MODE = "grid"
|
||||
```
|
||||
|
||||
- `grid`: многоколоночная сетка карточек (классическое поведение).
|
||||
- `list`: горизонтальные карточки-строки (формат лаунчера).
|
||||
- `vertical`: полноширинная таблица с названием игры, последним запуском,
|
||||
временем в игре и библиотекой.
|
||||
- `horizontal`: одна горизонтально прокручиваемая лента со всеми играми,
|
||||
- `horizontal_top`: первые `horizontalTopGameLimit` игр из текущих результатов
|
||||
фильтрации и сортировки, затем плитка из следующих игр для открытия полной
|
||||
сетки.
|
||||
|
||||
Геометрия карточек управляется через `GAME_CARD_LIST` и `GAME_CARD_GRID`:
|
||||
Плитка перехода в режиме `horizontal_top` настраивается константами темы:
|
||||
|
||||
```python
|
||||
horizontalTopGameLimit = 10 # Игр показывается до плитки.
|
||||
fullLibraryTileGameCount = 4 # Обложек показывается в плитке.
|
||||
fullLibraryTileSize = (180, 180) # Ширина и высота плитки.
|
||||
fullLibraryTileColumns = 2 # Столбцов обложек внутри плитки.
|
||||
fullLibraryTileRows = 2 # Строк обложек внутри плитки.
|
||||
fullLibraryTileRadius = 10 # Радиус скругления плитки в пикселях.
|
||||
```
|
||||
|
||||
Произведение `fullLibraryTileColumns * fullLibraryTileRows` должно быть не
|
||||
меньше `fullLibraryTileGameCount`. При активации плитка открывает полную сетку
|
||||
библиотеки.
|
||||
|
||||
Геометрия карточек управляется через `GAME_CARD_LIST`, `GAME_CARD_GRID` и
|
||||
`GAME_CARD_HORIZONTAL`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
@@ -258,6 +281,7 @@ GAME_CARD_LIST = {
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
@@ -265,13 +289,50 @@ GAME_CARD_GRID = {
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_orientation": "vertical",
|
||||
"card_animation_type": "scale",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 0.56,
|
||||
"card_height_ratio": 0.78,
|
||||
"cover_radius": 15,
|
||||
"layout_margins": (20, 20, 20, 20),
|
||||
"layout_spacing": 20,
|
||||
}
|
||||
```
|
||||
|
||||
Укажите `card_orientation = "vertical"`, чтобы использовать в горизонтальной
|
||||
ленте вертикальные пропорции карточек из сетки. Без параметра сохраняются
|
||||
пропорции горизонтальных блоков.
|
||||
|
||||
Значения задаются в логических пикселях. `cover_radius` применяется к
|
||||
статическим и анимированным обложкам, а `border_radius` — к отрисовываемой
|
||||
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
|
||||
определяется настройкой размера карточек библиотеки.
|
||||
|
||||
Параметры анимации карточек можно отдельно переопределить в
|
||||
`GAME_CARD_LIST`, `GAME_CARD_GRID` или `GAME_CARD_HORIZONTAL`: тип анимации,
|
||||
ширину обводки, pulse, углы и цвета градиента, цвета и прозрачность
|
||||
fill/stripe/glow, масштаб, длительности и easing-кривые. Например,
|
||||
`horizontal_top` может задавать собственные тип `scale`, значения масштаба,
|
||||
длительность и easing, а полная сетка — отдельные параметры `gradient`.
|
||||
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`, а
|
||||
`card_animation_type` по умолчанию использует `gradient`.
|
||||
|
||||
```python
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_animation_type": "scale",
|
||||
"default_scale": 1.0,
|
||||
"hover_scale": 1.055,
|
||||
"focus_scale": 1.075,
|
||||
"scale_anim_duration": 135,
|
||||
"scale_easing_curve": "OutCubic",
|
||||
"scale_easing_curve_out": "InOutCubic",
|
||||
}
|
||||
```
|
||||
|
||||
Тень карточек использует общие константы темы `shadow_blur_radius` и
|
||||
`shadow_offset` как в сеточном, так и в списочном режиме.
|
||||
|
||||
@@ -452,6 +513,54 @@ DETAIL_PAGE_BACKGROUNDS = {
|
||||
`DETAIL_PAGE_GRADIENT_FY` и `DETAIL_PAGE_WAVES`. Старые значения
|
||||
переопределяют соответствующие вложенные настройки.
|
||||
|
||||
## Фон библиотеки
|
||||
|
||||
Библиотека может использовать обложку карточки, на которой находится курсор
|
||||
или фокус, как фон. Функция включается только при наличии
|
||||
`LIBRARY_BACKGROUND`; если словарь не определён, обычный фон темы библиотеки
|
||||
остаётся без изменений.
|
||||
|
||||
`LIBRARY_BACKGROUND` выбирает эффект и может локально переопределять настройки
|
||||
фона детальной страницы. Словарь `backgrounds` объединяется с
|
||||
`DETAIL_PAGE_BACKGROUNDS`, поэтому достаточно указать только отличающиеся
|
||||
значения:
|
||||
|
||||
```python
|
||||
LIBRARY_BACKGROUND = {
|
||||
"margins": (0, 0, 0, 0),
|
||||
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
|
||||
"backgrounds": {
|
||||
"animation_interval_ms": 30,
|
||||
"animation_speed": 0.03,
|
||||
"blur": {
|
||||
"render_resolution": (1280, 720),
|
||||
"radius": 64,
|
||||
},
|
||||
"gradient": {
|
||||
"stops": None,
|
||||
"type": "linear",
|
||||
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4,
|
||||
"wave_amplitude_ratio": 0.06,
|
||||
"wave_frequency": 2.0,
|
||||
"layer_spacing_ratio": 0.04,
|
||||
"base_opacity": 0.45,
|
||||
"opacity_decay": 0.85,
|
||||
"animation_speed": 0.03,
|
||||
"animation_interval_ms": 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Настройки `blur` задают размер промежуточного изображения и радиус размытия.
|
||||
`margins` применяются к слою фона. Анимированные режимы используют те же
|
||||
словаря эффектов, что и `DETAIL_PAGE_BACKGROUNDS`; для переопределения
|
||||
`aurora`, `leaf`, `metaballs`, `veins` или `diagnostics` скопируйте нужный
|
||||
словарь из описания выше.
|
||||
|
||||
---
|
||||
|
||||
Словарь `PRELOADER` управляет стилем и анимацией индикатора загрузки. Если не определён, используется стандартный спиннер.
|
||||
|
||||
@@ -30,7 +30,16 @@ class DetailBackgroundAnimations:
|
||||
if effect not in self.effects:
|
||||
self.remove(page)
|
||||
return False
|
||||
config = theme.DETAIL_PAGE_BACKGROUNDS
|
||||
config = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
|
||||
animated = not mode.startswith("static_")
|
||||
if not isinstance(config, dict) or not isinstance(config.get(effect), dict):
|
||||
self.remove(page)
|
||||
return False
|
||||
if animated and not all(
|
||||
key in config for key in ("animation_interval_ms", "animation_speed")
|
||||
):
|
||||
self.remove(page)
|
||||
return False
|
||||
self.remove(page)
|
||||
state = {
|
||||
"effect": effect,
|
||||
@@ -47,7 +56,7 @@ class DetailBackgroundAnimations:
|
||||
self._paint(page, state)
|
||||
|
||||
page.paintEvent = paint_event
|
||||
if not mode.startswith("static_"):
|
||||
if animated:
|
||||
self._start_timer(page, state)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from math import radians, sin
|
||||
from typing import Any
|
||||
import warnings
|
||||
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
|
||||
@@ -22,6 +23,22 @@ class GameCardAnimations:
|
||||
self.pulse_anim: QPropertyAnimation | None = None
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
def _animation_type(self) -> str:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
return layout_config.get("card_animation_type", "gradient")
|
||||
|
||||
def _config_value(self, key: str) -> Any:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
if key in layout_config:
|
||||
return layout_config[key]
|
||||
return self.theme.GAME_CARD_ANIMATION[key]
|
||||
|
||||
def _optional_config_value(self, key: str, default: Any) -> Any:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
if key in layout_config:
|
||||
return layout_config[key]
|
||||
return self.theme.GAME_CARD_ANIMATION.get(key, default)
|
||||
|
||||
def _disconnect_pulse_animation(self) -> None:
|
||||
if not self._isPulseAnimationConnected or self.thickness_anim is None:
|
||||
return
|
||||
@@ -49,20 +66,20 @@ class GameCardAnimations:
|
||||
def _restart_gradient_animation(self) -> None:
|
||||
self._stop_animation("gradient_anim")
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
|
||||
self.gradient_anim.setStartValue(self._config_value("gradient_start_angle"))
|
||||
self.gradient_anim.setEndValue(self._config_value("gradient_end_angle"))
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
def _easing_curve(self, easing_key: str) -> QEasingCurve:
|
||||
easing_type = QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION[easing_key]]
|
||||
easing_type = QEasingCurve.Type[self._config_value(easing_key)]
|
||||
return QEasingCurve(easing_type)
|
||||
|
||||
def _restart_scale_animation(self, end_value: float, easing_key: str) -> None:
|
||||
self._stop_animation("scale_anim")
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
|
||||
self.scale_anim.setEasingCurve(self._easing_curve(easing_key))
|
||||
self.scale_anim.setStartValue(self.game_card._scale)
|
||||
self.scale_anim.setEndValue(end_value)
|
||||
@@ -89,18 +106,47 @@ class GameCardAnimations:
|
||||
self._stop_animation("pulse_anim")
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
def refresh_theme(self, theme: Any) -> None:
|
||||
"""Recreate animation state for a live theme change."""
|
||||
self.cleanup()
|
||||
self.theme = theme
|
||||
animation_type = self._animation_type()
|
||||
self.game_card._gradientAngle = self._config_value("gradient_start_angle")
|
||||
if self.game_card._hovered:
|
||||
self.game_card._borderWidth = self._config_value("hover_border_width")
|
||||
active_scale = self._config_value("hover_scale")
|
||||
elif self.game_card._focused:
|
||||
self.game_card._borderWidth = self._config_value("focus_border_width")
|
||||
active_scale = self._config_value("focus_scale")
|
||||
else:
|
||||
self.game_card._borderWidth = self._config_value("default_border_width")
|
||||
active_scale = self._config_value("default_scale")
|
||||
self.game_card._scale = (
|
||||
active_scale
|
||||
if animation_type in {"scale", "scale_fill"}
|
||||
else self._config_value("default_scale")
|
||||
)
|
||||
self.setup_animations()
|
||||
if (self.game_card._hovered or self.game_card._focused) and animation_type in {
|
||||
"gradient", "glow",
|
||||
}:
|
||||
self._restart_gradient_animation()
|
||||
if self.game_card._hovered or self.game_card._focused:
|
||||
self.start_pulse_animation()
|
||||
self.game_card.update()
|
||||
|
||||
def setup_animations(self):
|
||||
"""Initialize animation properties based on theme."""
|
||||
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
self.thickness_anim.setDuration(self._config_value("thickness_anim_duration"))
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
|
||||
|
||||
def start_pulse_animation(self):
|
||||
"""Start pulse animation for border width when hovered or focused."""
|
||||
@@ -110,11 +156,11 @@ class GameCardAnimations:
|
||||
self._stop_animation("pulse_anim")
|
||||
|
||||
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||
self.pulse_anim.setDuration(self._config_value("pulse_anim_duration"))
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0, self._config_value("pulse_min_border_width"))
|
||||
self.pulse_anim.setKeyValueAt(0.5, self._config_value("pulse_max_border_width"))
|
||||
self.pulse_anim.setKeyValueAt(1, self._config_value("pulse_min_border_width"))
|
||||
self.pulse_anim.start()
|
||||
|
||||
def handle_enter_event(self):
|
||||
@@ -126,10 +172,10 @@ class GameCardAnimations:
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["hover_border_width"],
|
||||
self._config_value("hover_border_width"),
|
||||
"thickness_easing_curve",
|
||||
True,
|
||||
)
|
||||
@@ -137,21 +183,21 @@ class GameCardAnimations:
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._restart_gradient_animation()
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["hover_scale"], "scale_easing_curve")
|
||||
self._restart_scale_animation(self._config_value("hover_scale"), "scale_easing_curve")
|
||||
|
||||
def handle_leave_event(self):
|
||||
"""Handle mouse leave event animations."""
|
||||
self.game_card._hovered = False
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._focused:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._stop_animation("gradient_anim")
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
|
||||
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
|
||||
self._stop_animation("pulse_anim")
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["default_border_width"],
|
||||
self._config_value("default_border_width"),
|
||||
"thickness_easing_curve_out",
|
||||
False,
|
||||
)
|
||||
@@ -165,10 +211,10 @@ class GameCardAnimations:
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"],
|
||||
self._config_value("focus_border_width"),
|
||||
"thickness_easing_curve",
|
||||
True,
|
||||
)
|
||||
@@ -176,21 +222,21 @@ class GameCardAnimations:
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._restart_gradient_animation()
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["focus_scale"], "scale_easing_curve")
|
||||
self._restart_scale_animation(self._config_value("focus_scale"), "scale_easing_curve")
|
||||
|
||||
def handle_focus_out_event(self):
|
||||
"""Handle focus out event animations."""
|
||||
self.game_card._focused = False
|
||||
self.game_card.focusChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._hovered:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._stop_animation("gradient_anim")
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
|
||||
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
|
||||
self._stop_animation("pulse_anim")
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["default_border_width"],
|
||||
self._config_value("default_border_width"),
|
||||
"thickness_easing_curve_out",
|
||||
False,
|
||||
)
|
||||
@@ -203,39 +249,39 @@ class GameCardAnimations:
|
||||
pen = QPen()
|
||||
pen.setWidth(self.game_card._borderWidth)
|
||||
fill_brush = QBrush(Qt.BrushStyle.NoBrush)
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
|
||||
center = self.game_card.rect().center()
|
||||
gradient = QConicalGradient(center, self.game_card._gradientAngle)
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
for stop in self._config_value("gradient_colors"):
|
||||
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type in {"fill", "scale_fill"}:
|
||||
fill_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
fill_color_value = self._optional_config_value(
|
||||
"fill_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
fill_alpha = int(self.theme.GAME_CARD_ANIMATION.get("fill_alpha", 90))
|
||||
fill_alpha = int(self._optional_config_value("fill_alpha", 90))
|
||||
fill_color = QColor(fill_color_value)
|
||||
fill_color.setAlpha(max(0, min(255, fill_alpha)))
|
||||
fill_brush = QBrush(fill_color)
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "stripe":
|
||||
stripe_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
stripe_color_value = self._optional_config_value(
|
||||
"stripe_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
stripe_alpha = int(self.theme.GAME_CARD_ANIMATION.get("stripe_alpha", 255))
|
||||
stripe_alpha = int(self._optional_config_value("stripe_alpha", 255))
|
||||
stripe_color = QColor(stripe_color_value)
|
||||
stripe_color.setAlpha(max(0, min(255, stripe_alpha)))
|
||||
pen.setColor(stripe_color)
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "glow":
|
||||
glow_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
glow_color_value = self._optional_config_value(
|
||||
"stripe_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
glow_base_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_base_alpha", 120))
|
||||
glow_pulse_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_pulse_alpha", 80))
|
||||
glow_base_alpha = int(self._optional_config_value("glow_base_alpha", 120))
|
||||
glow_pulse_alpha = int(self._optional_config_value("glow_pulse_alpha", 80))
|
||||
glow_wave = 0.5 + 0.5 * sin(radians(self.game_card._gradientAngle))
|
||||
glow_alpha = max(0, min(255, glow_base_alpha + int(glow_pulse_alpha * glow_wave)))
|
||||
glow_color = QColor(glow_color_value)
|
||||
|
||||
+115
-31
@@ -312,6 +312,7 @@ def main():
|
||||
autoinstall_path = None
|
||||
theme_store_id = None
|
||||
gog_launch_uri = None
|
||||
egs_launch_uri = None
|
||||
resolution_from_args = None
|
||||
if args.resolution:
|
||||
resolution_from_args = parse_resolution(args.resolution)
|
||||
@@ -325,14 +326,11 @@ def main():
|
||||
prefix_name, backup_dir = args.create_backup
|
||||
backup_request = (prefix_name, os.path.abspath(os.path.expanduser(backup_dir)))
|
||||
ipc_message = "backup:{}:{}".format(quote(prefix_name, safe=""), quote(backup_request[1], safe=""))
|
||||
elif is_silent_launch and not args.log and args.file_or_url and is_launch_file(args.file_or_url):
|
||||
run_silent_tray(app, start_sh, normalize_launch_path(args.file_or_url))
|
||||
sys.exit(app.exec())
|
||||
elif is_restore_prefix_request(args):
|
||||
restore_prefix_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
|
||||
elif args.file_or_url and is_launch_file(args.file_or_url):
|
||||
launch_action = "log" if args.log else "open"
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
elif args.file_or_url and is_autoinstall_file(args.file_or_url):
|
||||
autoinstall_path = normalize_launch_path(args.file_or_url)
|
||||
@@ -341,7 +339,18 @@ def main():
|
||||
app_id = args.file_or_url.removeprefix("gog://launch/")
|
||||
if app_id.isdigit():
|
||||
gog_launch_uri = args.file_or_url
|
||||
ipc_message = f"gog:{app_id}"
|
||||
if args.log:
|
||||
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
|
||||
else:
|
||||
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"gog:{app_id}"
|
||||
elif args.file_or_url and args.file_or_url.startswith("egs://launch/"):
|
||||
app_id = args.file_or_url.removeprefix("egs://launch/")
|
||||
if app_id and "/" not in app_id:
|
||||
egs_launch_uri = args.file_or_url
|
||||
if args.log:
|
||||
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
|
||||
else:
|
||||
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"egs:{app_id}"
|
||||
elif args.file_or_url:
|
||||
theme_store_id = parse_portprotonqt_theme_url(args.file_or_url)
|
||||
if theme_store_id is not None:
|
||||
@@ -410,7 +419,7 @@ def main():
|
||||
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
|
||||
|
||||
# Check if we have a portproton:// URL or launch file to handle
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
|
||||
if is_portproton_url(args.file_or_url):
|
||||
# Parse the portproton:// URL to get the full download URL
|
||||
download_url = parse_portproton_url(args.file_or_url)
|
||||
@@ -494,13 +503,61 @@ def main():
|
||||
logger.warning(f"Invalid resolution format: {args.resolution}, expected WIDTHxHEIGHT (e.g. 1920x1080)")
|
||||
|
||||
launch_path = exe_path or autoinstall_path
|
||||
silent_game_request = is_silent_launch and not args.log and bool(
|
||||
exe_path or gog_launch_uri or egs_launch_uri
|
||||
)
|
||||
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
|
||||
window.silent_launch_mode = silent_game_request
|
||||
if silent_game_request:
|
||||
window._initialize_tray(__app_name__)
|
||||
assert window.tray_manager is not None
|
||||
if exe_path:
|
||||
window.tray_manager.tray_icon.hide()
|
||||
else:
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = f"{source}://launch/{app_id}"
|
||||
if log_mode:
|
||||
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
|
||||
def open_card(games: list[tuple]) -> None:
|
||||
game = next((item for item in games if item[5] == exec_line), None)
|
||||
if game is None:
|
||||
return
|
||||
window.openGameDetailPage({
|
||||
"name": game[0], "description": game[1], "cover_path": game[2],
|
||||
"appid": game[3], "controller_support": game[4],
|
||||
"exec_line": game[5], "last_launch": game[6],
|
||||
"formatted_playtime": game[7], "protondb_tier": game[8],
|
||||
"anticheat_status": game[9], "playtime_seconds": game[11],
|
||||
"game_source": game[12], "anticheat_slug": game[13],
|
||||
"ppdb_id": game[14], "ppdb_rating": game[15],
|
||||
"protondb_appid": game[16],
|
||||
})
|
||||
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
loader(open_card)
|
||||
|
||||
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
|
||||
if is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
run_silent_tray(app, start_sh, path)
|
||||
return
|
||||
window.handle_launch_exe(path, log_mode=log_mode)
|
||||
return
|
||||
source, app_id = target.split("://launch/", 1)
|
||||
if silent:
|
||||
window.toggleGame(target)
|
||||
return
|
||||
open_store_game_card(source, app_id, log_mode)
|
||||
|
||||
# Handle launch file if provided
|
||||
if exe_path:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe():
|
||||
window.handle_launch_exe(exe_path, log_mode=args.log)
|
||||
def handle_launch_exe() -> None:
|
||||
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
|
||||
QTimer.singleShot(0, handle_launch_exe)
|
||||
elif autoinstall_path:
|
||||
def handle_autoinstall():
|
||||
@@ -515,9 +572,13 @@ def main():
|
||||
window._perform_restore(restore_prefix_path)
|
||||
QTimer.singleShot(0, handle_restore_prefix)
|
||||
elif gog_launch_uri:
|
||||
def handle_gog_launch():
|
||||
window.toggleGame(gog_launch_uri)
|
||||
def handle_gog_launch() -> None:
|
||||
handle_game_request(gog_launch_uri, is_silent_launch, args.log)
|
||||
QTimer.singleShot(0, handle_gog_launch)
|
||||
elif egs_launch_uri:
|
||||
def handle_egs_launch() -> None:
|
||||
handle_game_request(egs_launch_uri, is_silent_launch, args.log)
|
||||
QTimer.singleShot(0, handle_egs_launch)
|
||||
|
||||
# --- Handle incoming connections ---
|
||||
def handle_new_connection():
|
||||
@@ -536,20 +597,24 @@ def main():
|
||||
msg.startswith("show")
|
||||
or msg.startswith("open:")
|
||||
or msg.startswith("log:")
|
||||
or msg.startswith("silent:")
|
||||
or msg.startswith("restore:")
|
||||
or msg.startswith("backup:")
|
||||
or msg.startswith("theme:")
|
||||
or msg.startswith("autoinstall:")
|
||||
or msg.startswith("gog:")
|
||||
or msg.startswith("egs:")
|
||||
):
|
||||
# Ensure the window is visible and not minimized
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
if not msg.startswith("silent:"):
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
|
||||
# Ensure window is in active state for systems with strict focus policies
|
||||
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
|
||||
# Ensure active state for strict focus policies
|
||||
window.setWindowState(
|
||||
window.windowState() | Qt.WindowState.WindowActive
|
||||
)
|
||||
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
@@ -561,12 +626,17 @@ def main():
|
||||
display_config.set_fullscreen(False)
|
||||
window.showNormal()
|
||||
|
||||
if msg.startswith("open:") or msg.startswith("log:"):
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = msg.split(":", 1)[1].strip()
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
window.handle_launch_exe(launch_path, log_mode=log_mode)
|
||||
handle_game_request(launch_path, False, log_mode)
|
||||
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
|
||||
handle_game_request(unquote(launch_path), False, True)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -579,9 +649,15 @@ def main():
|
||||
elif msg.startswith("gog:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id.isdigit():
|
||||
window.toggleGame(f"gog://launch/{app_id}")
|
||||
handle_game_request(f"gog://launch/{app_id}", False)
|
||||
else:
|
||||
logger.warning("Invalid GOG app id via IPC: %s", app_id)
|
||||
elif msg.startswith("egs:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id and "/" not in app_id:
|
||||
handle_game_request(f"egs://launch/{app_id}", False)
|
||||
else:
|
||||
logger.warning("Invalid Epic app id via IPC: %s", app_id)
|
||||
elif msg.startswith("backup:"):
|
||||
parts = msg.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
@@ -624,16 +700,20 @@ def main():
|
||||
and getattr(window.input_manager, "gamepad", None) is not None
|
||||
)
|
||||
launch_minimized = (
|
||||
display_config.get_start_minimized()
|
||||
and not args.fullscreen
|
||||
and not launch_auto_fullscreen
|
||||
and window_resolution is None
|
||||
and exe_path is None
|
||||
and backup_request is None
|
||||
and restore_prefix_path is None
|
||||
and autoinstall_path is None
|
||||
and theme_store_id is None
|
||||
and gog_launch_uri is None
|
||||
silent_game_request
|
||||
or (
|
||||
display_config.get_start_minimized()
|
||||
and not args.fullscreen
|
||||
and not launch_auto_fullscreen
|
||||
and window_resolution is None
|
||||
and exe_path is None
|
||||
and backup_request is None
|
||||
and restore_prefix_path is None
|
||||
and autoinstall_path is None
|
||||
and theme_store_id is None
|
||||
and gog_launch_uri is None
|
||||
and egs_launch_uri is None
|
||||
)
|
||||
)
|
||||
if launch_minimized:
|
||||
logger.info("Launching in tray")
|
||||
@@ -785,6 +865,10 @@ def main():
|
||||
|
||||
if start_sh:
|
||||
worker = InitialCommandWorker(start_sh)
|
||||
window.initialCommandWorker = worker
|
||||
worker.finished.connect(
|
||||
lambda: setattr(window, "initialCommandWorker", None)
|
||||
)
|
||||
worker.start()
|
||||
else:
|
||||
logger.warning("PortProton start command not available, skipping initial command")
|
||||
|
||||
@@ -15,11 +15,12 @@ from portprotonqt.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
APP_ID = "ru.linux_gaming.PortProtonQt"
|
||||
MIME_PACKAGE_NAME = f"{APP_ID}.xml"
|
||||
DESKTOP_DATABASE_TIMEOUT = 10
|
||||
EXECUTABLE_MODE = 0o755
|
||||
WINDOWS_MIME_TYPES = (
|
||||
"application/x-ms-dos-executable;application/x-msdos-program;"
|
||||
"application/x-ms-dos-exec;application/x-executable;"
|
||||
"application/x-ms-dos-exec;"
|
||||
"application/x-dosexec;application/vnd.microsoft.portable-executable;"
|
||||
)
|
||||
INTEGRATION_MODES = ("log", "silent")
|
||||
@@ -141,6 +142,27 @@ def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
|
||||
return desktop, icon
|
||||
|
||||
|
||||
def _install_mime_package(appdir: Path, data_home: Path) -> None:
|
||||
source = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
f"share/mime/packages/{MIME_PACKAGE_NAME}",
|
||||
f"usr/share/mime/packages/{MIME_PACKAGE_NAME}",
|
||||
),
|
||||
)
|
||||
if not source:
|
||||
raise FileNotFoundError("AppImage MIME metadata is incomplete")
|
||||
mime_dir = data_home / "mime"
|
||||
packages_dir = mime_dir / "packages"
|
||||
packages_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, packages_dir / MIME_PACKAGE_NAME)
|
||||
subprocess.run(
|
||||
["update-mime-database", str(mime_dir)],
|
||||
check=True,
|
||||
timeout=DESKTOP_DATABASE_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def integrate_appimage() -> Path:
|
||||
"""Install the running AppImage and its desktop handlers for this user."""
|
||||
source = Path(os.environ["APPIMAGE"]).expanduser()
|
||||
@@ -161,6 +183,7 @@ def integrate_appimage() -> Path:
|
||||
resolved_icon = icon_source.resolve()
|
||||
icon = icon_dir / f"{APP_ID}.svg"
|
||||
_copy_appimage(source, destination)
|
||||
# Remove after the legacy AppImage migration period.
|
||||
legacy_appimage = Path.home() / "Applications/PortProtonQt.AppImage"
|
||||
if legacy_appimage.resolve() != destination.resolve():
|
||||
portproton_location = get_portproton_location()
|
||||
@@ -171,6 +194,7 @@ def integrate_appimage() -> Path:
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
shutil.copy2(resolved_icon, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination)
|
||||
_install_mime_package(appdir, data_home)
|
||||
_set_default_mime_handlers()
|
||||
try:
|
||||
subprocess.run(
|
||||
|
||||
@@ -14,7 +14,8 @@ import requests
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from portprotonqt.downloader import get_requests_session
|
||||
from portprotonqt.config import CACHE_DIR, ui_config
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.config.base import XDG_DATA_HOME
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -59,7 +60,7 @@ def _get_appimage_arch() -> str:
|
||||
|
||||
|
||||
def _cached_tool_path() -> Path:
|
||||
return CACHE_DIR / "bin" / "appimageupdatetool"
|
||||
return XDG_DATA_HOME / "PortProtonQt" / "bin" / "appimageupdatetool"
|
||||
|
||||
|
||||
def _get_appimageupdatetool_url() -> str:
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any
|
||||
|
||||
import orjson
|
||||
import pefile
|
||||
import psutil
|
||||
|
||||
from portprotonqt.debug_utils import (
|
||||
get_portproton_env,
|
||||
@@ -28,7 +29,7 @@ from portprotonqt.scripts_utils.graphics_detector import analyze_executable
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CRASH_THRESHOLD_SECONDS = 5
|
||||
CRASH_THRESHOLD_SECONDS = 7
|
||||
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
|
||||
PE_MACHINE_AMD64 = 0x8664
|
||||
COMPATIBILITY_SCAN_BYTES = 4_194_304
|
||||
@@ -98,16 +99,46 @@ class CompatibilityLaunch:
|
||||
duration: float
|
||||
|
||||
|
||||
def is_suspected_crash(duration: float, stopped_by_user: bool, executable: str) -> bool:
|
||||
"""Return whether an executable closed too soon after launch."""
|
||||
def is_suspected_crash(
|
||||
duration: float, stopped_by_user: bool, executable: str, exit_code: int | None = None
|
||||
) -> bool:
|
||||
"""Prefer Wine's command status; use short runtime only when it is unknown."""
|
||||
always_report = os.getenv(COMPATIBILITY_ALWAYS_REPORT_ENV) == "1"
|
||||
return (
|
||||
(always_report or (not stopped_by_user and duration < CRASH_THRESHOLD_SECONDS))
|
||||
(always_report or (not stopped_by_user and (
|
||||
exit_code != 0 if exit_code is not None else duration < CRASH_THRESHOLD_SECONDS
|
||||
)))
|
||||
and executable.lower().endswith((".exe", ".msi"))
|
||||
and os.path.isfile(executable)
|
||||
)
|
||||
|
||||
|
||||
def get_running_game_processes(executable: str, started: float) -> dict[tuple[int, float], str]:
|
||||
"""Find new Windows processes in the game's directory, including engine children."""
|
||||
directory = os.path.dirname(os.path.abspath(executable)).lower() + "/"
|
||||
running = {}
|
||||
for process in psutil.process_iter(attrs=["pid", "name", "cmdline", "cwd", "create_time", "status"]):
|
||||
info = process.info
|
||||
created = info.get("create_time")
|
||||
if created is None or created < started or info.get("status") == psutil.STATUS_ZOMBIE:
|
||||
continue
|
||||
arguments = info.get("cmdline") or []
|
||||
command = (arguments[0] if arguments else info.get("name") or "").replace("\\", "/")
|
||||
if not command.lower().endswith((".exe", ".msi")):
|
||||
continue
|
||||
if command.lower().startswith("z:/"):
|
||||
command = command[2:]
|
||||
if not command.startswith("/"):
|
||||
cwd = info.get("cwd")
|
||||
if not cwd:
|
||||
continue
|
||||
command = os.path.join(cwd, os.path.basename(command) if ":" in command else command)
|
||||
command = os.path.normpath(command)
|
||||
if command.lower().startswith(directory):
|
||||
running[(info["pid"], created)] = command
|
||||
return running
|
||||
|
||||
|
||||
def _read_imports(pe: pefile.PE) -> set[str]:
|
||||
imports = set()
|
||||
for directory_name in ("DIRECTORY_ENTRY_IMPORT", "DIRECTORY_ENTRY_DELAY_IMPORT"):
|
||||
@@ -288,9 +319,12 @@ def _analyze_pe(executable: str) -> tuple[str, dict[str, list[str]]]:
|
||||
try:
|
||||
optional_header = getattr(pe, "OPTIONAL_HEADER", None)
|
||||
data_directories = getattr(optional_header, "DATA_DIRECTORY", [])
|
||||
clr = data_directories[
|
||||
pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"]
|
||||
descriptor_index = pefile.DIRECTORY_ENTRY[
|
||||
"IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR"
|
||||
]
|
||||
if not isinstance(descriptor_index, int):
|
||||
raise IndexError
|
||||
clr = data_directories[descriptor_index]
|
||||
if clr.VirtualAddress and ".NET Framework" not in findings.setdefault("Runtimes", []):
|
||||
findings["Runtimes"].append(".NET Framework")
|
||||
except (AttributeError, IndexError):
|
||||
@@ -420,6 +454,8 @@ def _compatibility_suggestions(
|
||||
)
|
||||
if uses_directx_8_11 and environment.get("PW_VULKAN_USE") == "0":
|
||||
suggestions.append("Switch from WineD3D to DXVK for DirectX 8-11.")
|
||||
if environment.get("PW_USE_OPTISCALER") == "1":
|
||||
suggestions.append("Temporarily disable OptiScaler in the game settings and retry to check for a conflict.")
|
||||
if _uses_integrated_gpu(environment):
|
||||
suggestions.append("Use the discrete GPU.")
|
||||
runtimes = findings.get("Runtimes", [])
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Base configuration class for PortProtonQt."""
|
||||
import os
|
||||
import configparser
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config.validators import validate_string, validate_int, validate_bool, ValidationError
|
||||
@@ -24,7 +25,27 @@ THEMES_DIRS = [
|
||||
]
|
||||
|
||||
# Cache paths
|
||||
CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
|
||||
CACHE_DIR = XDG_DATA_HOME / "PortProtonQt" / "cache"
|
||||
LEGACY_CACHE_DIR = Path(os.getenv("XDG_CACHE_HOME", Path.home() / ".cache")) / "PortProtonQt"
|
||||
|
||||
|
||||
# Remove after the legacy cache migration period.
|
||||
def _migrate_legacy_cache() -> None:
|
||||
"""Move the legacy XDG cache into persistent application data."""
|
||||
if not LEGACY_CACHE_DIR.is_dir():
|
||||
return
|
||||
try:
|
||||
if not CACHE_DIR.exists():
|
||||
CACHE_DIR.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(LEGACY_CACHE_DIR), str(CACHE_DIR))
|
||||
return
|
||||
shutil.copytree(LEGACY_CACHE_DIR, CACHE_DIR, dirs_exist_ok=True)
|
||||
shutil.rmtree(LEGACY_CACHE_DIR)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to migrate legacy cache directory: %s", error)
|
||||
|
||||
|
||||
_migrate_legacy_cache()
|
||||
|
||||
# Module-level cache storage
|
||||
_config_cache: dict[str, configparser.ConfigParser] = {}
|
||||
|
||||
@@ -164,13 +164,19 @@ def get_portproton_scripts_path() -> str | None:
|
||||
if appimage_path:
|
||||
appimage_root = Path(sys.executable).resolve().parent.parent
|
||||
prefixes.append(("AppImage executable", appimage_root))
|
||||
if os.getenv("FLATPAK_ID"):
|
||||
prefixes.append(("Flatpak package", Path("/app")))
|
||||
prefixes.append(("system package", Path("/usr")))
|
||||
xdg_scripts_dirs = [
|
||||
("XDG data", Path(data_dir) / "portproton" / "scripts")
|
||||
for data_dir in os.getenv(
|
||||
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
|
||||
).split(os.pathsep)
|
||||
if data_dir
|
||||
]
|
||||
|
||||
scripts_dirs = (
|
||||
("repository", Path.cwd() / "build-aux" / "share" / "portproton" / "scripts"),
|
||||
*[(source, prefix / "share" / "portproton" / "scripts") for source, prefix in prefixes],
|
||||
*xdg_scripts_dirs,
|
||||
)
|
||||
for source, scripts_dir in scripts_dirs:
|
||||
if (scripts_dir / "start.sh").is_file():
|
||||
@@ -283,6 +289,7 @@ def _get_desktop_paths(desktop_dir: str | None) -> tuple[str, ...]:
|
||||
return (os.path.join(os.path.expanduser("~"), "Desktop"),)
|
||||
|
||||
|
||||
# Remove after the legacy shortcut migration period.
|
||||
def migrate_legacy_shortcut(
|
||||
portproton_path: str,
|
||||
desktop_dir: str | None = None,
|
||||
@@ -448,7 +455,42 @@ def migrate_legacy_shortcut(
|
||||
def parse_desktop_entry(file_path: str) -> configparser.SectionProxy | None:
|
||||
"""Read and parse a .desktop file using configparser."""
|
||||
cp = configparser.ConfigParser(interpolation=None)
|
||||
cp.read(file_path, encoding="utf-8")
|
||||
content = ""
|
||||
try:
|
||||
content = Path(file_path).read_text(encoding="utf-8")
|
||||
cp.read_string(content, source=file_path)
|
||||
except configparser.ParsingError:
|
||||
lines = []
|
||||
for line in content.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped and "=" not in line and not stripped.startswith(("[", "#", ";")):
|
||||
if not lines or "=" not in lines[-1]:
|
||||
return None
|
||||
lines[-1] = f"{lines[-1]} {stripped}"
|
||||
continue
|
||||
lines.append(line)
|
||||
repaired_content = "\n".join(lines) + ("\n" if content.endswith("\n") else "")
|
||||
try:
|
||||
cp.read_string(repaired_content, source=file_path)
|
||||
except configparser.Error as error:
|
||||
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
|
||||
return None
|
||||
desktop_path = Path(file_path)
|
||||
temp_path = desktop_path.with_name(f".{desktop_path.name}.tmp")
|
||||
try:
|
||||
temp_path.write_text(repaired_content, encoding="utf-8")
|
||||
os.chmod(temp_path, desktop_path.stat().st_mode)
|
||||
os.replace(temp_path, desktop_path)
|
||||
logger.warning("Repaired malformed desktop file %s", file_path)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to save repaired desktop file %s: %s", file_path, error)
|
||||
try:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
except OSError as cleanup_error:
|
||||
logger.debug("Failed to remove temporary file %s: %s", temp_path, cleanup_error)
|
||||
except (configparser.Error, OSError, UnicodeError) as error:
|
||||
logger.warning("Failed to parse desktop file %s: %s", file_path, error)
|
||||
return None
|
||||
if "Desktop Entry" not in cp:
|
||||
return None
|
||||
return cp["Desktop Entry"]
|
||||
@@ -546,8 +588,8 @@ def create_desktop_file(
|
||||
) -> tuple[str, str, str] | None:
|
||||
"""Create desktop entry content, destination path and icon path for a game."""
|
||||
portproton_path = get_portproton_location()
|
||||
is_gog_uri = re.fullmatch(r"gog://launch/\d+", exe_path) is not None
|
||||
if not is_gog_uri and not os.path.isfile(exe_path):
|
||||
is_store_uri = re.fullmatch(r"(?:gog|egs)://launch/[^/]+", exe_path) is not None
|
||||
if not is_store_uri and not os.path.isfile(exe_path):
|
||||
logger.error("Executable not found: %s", exe_path)
|
||||
return None
|
||||
if not portproton_path:
|
||||
@@ -556,11 +598,12 @@ def create_desktop_file(
|
||||
|
||||
if not game_name:
|
||||
game_name = os.path.splitext(os.path.basename(exe_path))[0]
|
||||
game_name = " ".join(game_name.splitlines()).strip()
|
||||
base_path = os.path.join(portproton_path, "data")
|
||||
icon_name = _sanitize_icon_name(game_name)
|
||||
has_icon_source = bool(icon_source and os.path.isfile(icon_source))
|
||||
icon_path = (
|
||||
"applications-games" if is_gog_uri and not has_icon_source
|
||||
"applications-games" if is_store_uri and not has_icon_source
|
||||
else os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
)
|
||||
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
|
||||
|
||||
@@ -357,6 +357,22 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(hide, "hide_control_hints")
|
||||
self._save_value("hide_control_hints", hide, "bool")
|
||||
|
||||
def get_force_english(self) -> bool:
|
||||
"""Get forced English language setting."""
|
||||
cp = self._read_config()
|
||||
if cp is None:
|
||||
return False
|
||||
try:
|
||||
return cp.getboolean(self._section, "force_english", fallback=False)
|
||||
except (configparser.Error, ValueError) as e:
|
||||
logger.warning("Error reading force_english: %s", e)
|
||||
return False
|
||||
|
||||
def set_force_english(self, enabled: bool) -> None:
|
||||
"""Set forced English language setting."""
|
||||
validate_bool(enabled, "force_english")
|
||||
self._save_value("force_english", enabled, "bool")
|
||||
|
||||
def get_badge_view_mode(self) -> str:
|
||||
"""Get badge view mode ('detailed' or 'compact')."""
|
||||
mode = self._get_str("badge_view_mode", "detailed")
|
||||
@@ -367,6 +383,27 @@ class UIConfig(BaseConfig):
|
||||
validate_string(mode, "badge_view_mode", min_len=1, max_len=20)
|
||||
self._save_value("badge_view_mode", mode, "str")
|
||||
|
||||
def get_library_layout_mode(self, theme_mode: str = "grid") -> str:
|
||||
"""Get the selected library layout or the theme default."""
|
||||
modes = {"grid", "list", "vertical", "horizontal", "horizontal_top"}
|
||||
mode = self._get_str("library_layout_mode", "theme")
|
||||
return mode if mode in modes else theme_mode.lower()
|
||||
|
||||
def set_library_layout_mode(self, mode: str) -> None:
|
||||
"""Set the library layout mode."""
|
||||
validate_string(mode, "library_layout_mode", min_len=1, max_len=20)
|
||||
self._save_value("library_layout_mode", mode, "str")
|
||||
|
||||
def get_horizontal_card_orientation(self, theme_mode: str) -> str:
|
||||
"""Get the horizontal card orientation or the theme default."""
|
||||
mode = self._get_str("horizontal_card_orientation", "theme")
|
||||
return mode if mode in {"horizontal", "vertical"} else theme_mode
|
||||
|
||||
def set_horizontal_card_orientation(self, mode: str) -> None:
|
||||
"""Set the horizontal card orientation."""
|
||||
validate_string(mode, "horizontal_card_orientation", min_len=1, max_len=20)
|
||||
self._save_value("horizontal_card_orientation", mode, "str")
|
||||
|
||||
def get_economy_mode(self) -> bool:
|
||||
"""Get economy mode setting."""
|
||||
return self._get_download_bool("economy_mode", False)
|
||||
@@ -430,7 +467,7 @@ class UIConfig(BaseConfig):
|
||||
|
||||
def get_auto_download_ppdb(self) -> bool:
|
||||
"""Get PPDB auto-download setting."""
|
||||
return self._get_download_bool("auto_download_ppdb", True)
|
||||
return self._get_download_bool("auto_download_ppdb", False)
|
||||
|
||||
def set_auto_download_ppdb(self, enabled: bool) -> None:
|
||||
"""Set PPDB auto-download setting."""
|
||||
@@ -473,6 +510,15 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(enabled, "crash_reports_enabled")
|
||||
self._save_value("crash_reports_enabled", enabled, "bool")
|
||||
|
||||
def get_disable_alt_i586_dependency_check(self) -> bool:
|
||||
"""Get ALT i586 dependency check setting."""
|
||||
return self._get_bool("disable_alt_i586_dependency_check", False)
|
||||
|
||||
def set_disable_alt_i586_dependency_check(self, enabled: bool) -> None:
|
||||
"""Set ALT i586 dependency check setting."""
|
||||
validate_bool(enabled, "disable_alt_i586_dependency_check")
|
||||
self._save_value("disable_alt_i586_dependency_check", enabled, "bool")
|
||||
|
||||
|
||||
def load_theme_metainfo(theme_name: str) -> dict:
|
||||
"""Load theme metadata from metainfo.ini."""
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import glob
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import TYPE_CHECKING
|
||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplication, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QFrame
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt, QStandardPaths, QTimer, QSize, QProcess
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
@@ -34,6 +35,9 @@ from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.game_card import GameCard
|
||||
|
||||
class ContextMenuSignals(QObject):
|
||||
"""Signals for thread-safe UI updates from worker threads."""
|
||||
show_warning_dialog = Signal(str, str)
|
||||
@@ -471,12 +475,12 @@ class ContextMenuManager:
|
||||
return
|
||||
|
||||
# Normal menu for games with valid exe or from Steam
|
||||
is_uninstalled_gog = (
|
||||
game_card.game_source == "gog"
|
||||
and game_card.exec_line.startswith("gog://install/")
|
||||
is_uninstalled_store_game = (
|
||||
game_card.game_source in ("gog", "egs")
|
||||
and game_card.exec_line.startswith(("gog://install/", "egs://install/"))
|
||||
)
|
||||
is_running = self._is_game_running(game_card)
|
||||
if not is_uninstalled_gog:
|
||||
if not is_uninstalled_store_game:
|
||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||
action_icon = "stop" if is_running else "play"
|
||||
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
|
||||
@@ -544,6 +548,83 @@ class ContextMenuManager:
|
||||
import_action.triggered.connect(
|
||||
lambda: self.parent._import_gog_game(game)
|
||||
)
|
||||
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action.triggered.connect(
|
||||
lambda: self._edit_id_shortcut(
|
||||
game_card.name,
|
||||
game_card.cover_path,
|
||||
("gog", str(game_card.appid)),
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "egs":
|
||||
app_id = str(game_card.appid)
|
||||
if game_card.exec_line.startswith("egs://launch/"):
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
)
|
||||
desktop_path = self._get_shortcut_path(game_card.name, desktop_dir)
|
||||
desktop_exists = os.path.exists(desktop_path)
|
||||
desktop_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if desktop_exists else "desktop"),
|
||||
_("Remove from Desktop") if desktop_exists else _("Add to Desktop"),
|
||||
)
|
||||
desktop_action.triggered.connect(
|
||||
lambda: self.remove_from_desktop(game_card.name)
|
||||
if desktop_exists
|
||||
else self.add_to_desktop(game_card.name, game_card.exec_line)
|
||||
)
|
||||
menu_path = self._get_menu_shortcut_path(game_card.name)
|
||||
menu_exists = os.path.exists(menu_path)
|
||||
menu_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if menu_exists else "menu"),
|
||||
_("Remove from Menu") if menu_exists else _("Add to Menu"),
|
||||
)
|
||||
menu_action.triggered.connect(
|
||||
lambda: self.remove_from_menu(game_card.name)
|
||||
if menu_exists
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
update_action = menu.addAction(
|
||||
self._get_safe_icon("update"), _("Update")
|
||||
)
|
||||
update_action.triggered.connect(
|
||||
lambda: self.parent._update_egs_game(app_id)
|
||||
)
|
||||
repair_action = menu.addAction(
|
||||
self._get_safe_icon("update"), _("Repair")
|
||||
)
|
||||
repair_action.triggered.connect(
|
||||
lambda: self.parent._repair_egs_game(app_id)
|
||||
)
|
||||
eos_enabled = self.parent._is_egs_overlay_enabled(app_id)
|
||||
eos_action = menu.addAction(
|
||||
self._get_safe_icon("update"),
|
||||
_("Disable EOS Overlay")
|
||||
if eos_enabled else _("Enable EOS Overlay"),
|
||||
)
|
||||
eos_action.triggered.connect(
|
||||
lambda: self.parent._enable_egs_overlay(app_id)
|
||||
)
|
||||
delete_action = menu.addAction(
|
||||
self._get_safe_icon("delete"), _("Delete")
|
||||
)
|
||||
delete_action.triggered.connect(
|
||||
lambda: self.parent._delete_egs_game(app_id)
|
||||
)
|
||||
else:
|
||||
import_action = menu.addAction(
|
||||
self._get_safe_icon("folder"), _("Import")
|
||||
)
|
||||
import_action.triggered.connect(
|
||||
lambda: self.parent._import_egs_game(app_id)
|
||||
)
|
||||
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action.triggered.connect(
|
||||
lambda: self._edit_id_shortcut(
|
||||
game_card.name, game_card.cover_path, ("egs", app_id)
|
||||
)
|
||||
)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
@@ -944,7 +1025,7 @@ class ContextMenuManager:
|
||||
if not resolved_exec_line:
|
||||
return
|
||||
shortcut_paths = self._get_installed_shortcut_paths(game_name)
|
||||
exe_path = self._parse_exe_path(resolved_exec_line, game_name)
|
||||
exe_path = extract_exec_target_path(resolved_exec_line)
|
||||
if not self._remove_file(
|
||||
desktop_path,
|
||||
_("Failed to delete .desktop file: {error}"),
|
||||
@@ -997,15 +1078,15 @@ class ContextMenuManager:
|
||||
"""Add the game desktop entry to the XDG applications directory."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_gog_uri and not os.path.exists(desktop_path):
|
||||
if not is_store_uri and not os.path.exists(desktop_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
if not is_gog_uri:
|
||||
if not is_store_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@@ -1013,9 +1094,9 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
if is_store_uri:
|
||||
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
@@ -1068,16 +1149,16 @@ class ContextMenuManager:
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_gog_uri and not os.path.exists(desktop_path):
|
||||
if not is_store_uri and not os.path.exists(desktop_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
# Ensure icon exists
|
||||
if not is_gog_uri:
|
||||
if not is_store_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@@ -1085,9 +1166,9 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
if is_store_uri:
|
||||
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
@@ -1221,29 +1302,34 @@ class ContextMenuManager:
|
||||
):
|
||||
self._update_desktop_database()
|
||||
|
||||
def _edit_steam_shortcut(self, game_name: str, appid: int | str, cover_path: str) -> None:
|
||||
appid_str = str(appid).strip()
|
||||
if not appid_str.isdigit():
|
||||
logger.warning("Invalid Steam appid for shortcut editing: %s", appid)
|
||||
def _edit_id_shortcut(self, game_name: str, cover_path: str,
|
||||
store: tuple[str, str]) -> None:
|
||||
source, app_id = store
|
||||
app_id = str(app_id).strip()
|
||||
invalid_path = app_id in {".", ".."} or os.path.basename(app_id) != app_id
|
||||
if not app_id or invalid_path or (source == "steam" and not app_id.isdigit()):
|
||||
logger.warning("Invalid %s game ID for shortcut editing: %s", source, app_id)
|
||||
return
|
||||
dialog = AddGameDialog(parent=self.parent, theme=self.theme, edit_mode=True,
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=appid_str)
|
||||
game_name=game_name, cover_path=cover_path, steam_appid=app_id)
|
||||
if dialog.exec() != QDialog.DialogCode.Accepted:
|
||||
return
|
||||
new_name = dialog.nameEdit.text().strip()
|
||||
if not new_name:
|
||||
return
|
||||
selected_cover = dialog.coverEdit.text().strip()
|
||||
if not os.path.isfile(selected_cover) and dialog.last_cover_path and os.path.isfile(dialog.last_cover_path):
|
||||
if (not os.path.isfile(selected_cover) and dialog.last_cover_path
|
||||
and os.path.isfile(dialog.last_cover_path)):
|
||||
selected_cover = dialog.last_cover_path
|
||||
xdg_data_home = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
)
|
||||
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", appid_str)
|
||||
default_data_home = os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME", default_data_home)
|
||||
game_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data",
|
||||
f"{source}-{app_id}")
|
||||
try:
|
||||
os.makedirs(game_dir, exist_ok=True)
|
||||
clean_name = new_name.replace("\r", " ").replace("\n", " ").strip()
|
||||
with open(os.path.join(game_dir, "metadata.txt"), "w", encoding="utf-8") as metadata_file:
|
||||
metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||
with open(metadata_path, "w", encoding="utf-8") as metadata_file:
|
||||
metadata_file.write(f"name={clean_name}\n")
|
||||
if selected_cover and os.path.isfile(selected_cover):
|
||||
extension = os.path.splitext(selected_cover)[1].lower()
|
||||
@@ -1255,22 +1341,90 @@ class ContextMenuManager:
|
||||
shutil.copyfile(selected_cover, target_cover)
|
||||
cover_path = target_cover
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"), _("Failed to save custom data: {error}").format(error=str(e))
|
||||
)
|
||||
message = _("Failed to save custom data: {error}").format(error=str(e))
|
||||
self.signals.show_warning_dialog.emit(_("Error"), message)
|
||||
return
|
||||
for game in self.game_library_manager.games:
|
||||
if str(game[3]) != appid_str or game[12] != "steam":
|
||||
if str(game[3]) != app_id or game[12] != source:
|
||||
continue
|
||||
updated_game = list(game)
|
||||
updated_game[0] = new_name
|
||||
updated_game[2] = cover_path
|
||||
self.game_library_manager.replace_game_incremental(
|
||||
game[0], game[5], tuple(updated_game)
|
||||
)
|
||||
self.game_library_manager.replace_game_incremental(game[0], game[5], tuple(updated_game))
|
||||
QTimer.singleShot(0, self.game_library_manager.load_visible_images)
|
||||
return
|
||||
|
||||
def handle_missing_executable(self, game_card: "GameCard") -> None:
|
||||
"""Offer removal or shortcut repair when the launch file is missing."""
|
||||
msg_box = QMessageBox(self.parent)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
msg_box.setWindowTitle(_("Executable not found"))
|
||||
msg_box.setText(_("Executable not found: {0}").format(game_card.missing_executable_path))
|
||||
delete_button = msg_box.addButton(_("Delete from PortProton"), QMessageBox.ButtonRole.DestructiveRole)
|
||||
edit_button = msg_box.addButton(_("Select another executable"), QMessageBox.ButtonRole.ActionRole)
|
||||
msg_box.addButton(QMessageBox.StandardButton.Cancel)
|
||||
for button in msg_box.buttons():
|
||||
button.ensurePolished()
|
||||
button.setMinimumWidth(button.sizeHint().width())
|
||||
msg_box.exec()
|
||||
if msg_box.clickedButton() == delete_button:
|
||||
self.delete_game(game_card.name, game_card.exec_line)
|
||||
elif msg_box.clickedButton() == edit_button:
|
||||
self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
|
||||
def _relocate_shortcut_data(self, old_exe: str, new_exe: str) -> list[tuple[str, str]]:
|
||||
"""Move path-specific data, refusing to overwrite another game's data."""
|
||||
if os.path.abspath(old_exe) == os.path.abspath(new_exe):
|
||||
return []
|
||||
data_home = os.getenv("XDG_DATA_HOME", os.path.expanduser("~/.local/share"))
|
||||
custom_root = os.path.join(data_home, "PortProtonQt", "custom_data")
|
||||
old_folder = resolve_custom_data_dir(custom_root, old_exe)
|
||||
new_folder = os.path.join(custom_root, get_custom_data_dir_name(new_exe))
|
||||
paths = [(old_folder, new_folder), (old_exe + ".ppdb", new_exe + ".ppdb")]
|
||||
moves = [(source, target) for source, target in paths if os.path.exists(source)]
|
||||
for _source, target in moves:
|
||||
if os.path.lexists(target):
|
||||
raise FileExistsError(_("File already exists: {file_name}").format(file_name=target))
|
||||
completed = []
|
||||
try:
|
||||
for source, target in moves:
|
||||
shutil.move(source, target)
|
||||
completed.append((source, target))
|
||||
except OSError:
|
||||
for source, target in reversed(completed):
|
||||
shutil.move(target, source)
|
||||
raise
|
||||
return completed
|
||||
|
||||
def _relocate_shortcut_statistics(self, old_exe: str, new_exe: str) -> None:
|
||||
"""Keep playtime and last launch associated with a repaired shortcut."""
|
||||
from portprotonqt.time_utils import get_last_launch_path, get_statistics_path
|
||||
|
||||
old_name = os.path.splitext(os.path.basename(old_exe))[0]
|
||||
new_name = os.path.splitext(os.path.basename(new_exe))[0]
|
||||
last_launch_path = get_last_launch_path()
|
||||
records = (
|
||||
(get_statistics_path(), os.path.normpath(old_exe).replace(" ", "#@_@#"), new_exe.replace(" ", "#@_@#")),
|
||||
(last_launch_path, old_name, new_name),
|
||||
)
|
||||
for path, old_key, new_key in records:
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
try:
|
||||
with open(path, encoding="utf-8") as stream:
|
||||
lines = stream.readlines()
|
||||
updated = []
|
||||
for line in lines:
|
||||
parts = line.rsplit(maxsplit=1) if path == last_launch_path else line.split(maxsplit=1)
|
||||
if len(parts) == 2 and parts[0] == old_key:
|
||||
line = f"{new_key} {parts[1].rstrip()}\n"
|
||||
updated.append(line)
|
||||
if updated != lines:
|
||||
with open(path, "w", encoding="utf-8") as stream:
|
||||
stream.writelines(updated)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to relocate statistics for %s: %s", old_exe, error)
|
||||
|
||||
def edit_game_shortcut(self, game_name, exec_line, cover_path, appid=None):
|
||||
"""
|
||||
Opens a dialog allowing the user to edit a game shortcut in edit mode to modify an existing .desktop file.
|
||||
@@ -1281,7 +1435,7 @@ class ContextMenuManager:
|
||||
cover_path: The path to the game's cover image.
|
||||
"""
|
||||
if appid is not None:
|
||||
self._edit_steam_shortcut(game_name, appid, cover_path)
|
||||
self._edit_id_shortcut(game_name, cover_path, ("steam", str(appid)))
|
||||
return
|
||||
if not self._check_portproton():
|
||||
return
|
||||
@@ -1289,7 +1443,7 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
old_exec_line = exec_line
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
return
|
||||
dialog = AddGameDialog(
|
||||
@@ -1320,6 +1474,27 @@ class ContextMenuManager:
|
||||
return
|
||||
|
||||
old_path = self._get_desktop_path(game_name)
|
||||
moved_paths = []
|
||||
try:
|
||||
moved_paths = self._relocate_shortcut_data(exe_path, new_exe_path)
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
except OSError as e:
|
||||
for source, target in reversed(moved_paths):
|
||||
shutil.move(target, source)
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to save .desktop file: {error}").format(error=str(e))
|
||||
)
|
||||
return
|
||||
for source, target in moved_paths:
|
||||
if new_cover_path.startswith(source + os.sep):
|
||||
new_cover_path = target + new_cover_path[len(source):]
|
||||
if dialog.last_cover_path and dialog.last_cover_path.startswith(source + os.sep):
|
||||
dialog.last_cover_path = target + dialog.last_cover_path[len(source):]
|
||||
if exe_path != new_exe_path:
|
||||
self._relocate_shortcut_statistics(exe_path, new_exe_path)
|
||||
if game_name != new_name and os.path.exists(old_path):
|
||||
self._remove_file(
|
||||
old_path,
|
||||
@@ -1327,17 +1502,6 @@ class ContextMenuManager:
|
||||
_("Removed old .desktop file for '{game_name}'"),
|
||||
game_name
|
||||
)
|
||||
|
||||
try:
|
||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||
f.write(desktop_entry)
|
||||
os.chmod(new_desktop_path, 0o755)
|
||||
except OSError as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to save .desktop file: {error}").format(error=str(e))
|
||||
)
|
||||
return
|
||||
saved_entry = parse_desktop_entry(new_desktop_path)
|
||||
updated_exec_line = new_exe_path
|
||||
if saved_entry:
|
||||
@@ -1398,8 +1562,8 @@ class ContextMenuManager:
|
||||
old_menu_path = self._get_menu_shortcut_path(game_name)
|
||||
new_menu_path = self._get_menu_shortcut_path(new_name)
|
||||
desktop_dir = QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
old_desktop_path = os.path.join(desktop_dir, f"{game_name}.desktop")
|
||||
new_desktop_path_target = os.path.join(desktop_dir, f"{new_name}.desktop")
|
||||
old_desktop_path = self._get_shortcut_path(game_name, desktop_dir)
|
||||
new_desktop_path_target = self._get_shortcut_path(new_name, desktop_dir)
|
||||
|
||||
if game_name != new_name:
|
||||
if os.path.exists(old_menu_path):
|
||||
@@ -1410,19 +1574,22 @@ class ContextMenuManager:
|
||||
self.remove_from_steam(game_name, new_exe_path, "portproton")
|
||||
|
||||
if dialog.add_to_menu_checkbox.isChecked():
|
||||
if not os.path.exists(new_menu_path):
|
||||
if exe_path != new_exe_path or not os.path.exists(new_menu_path):
|
||||
self.add_to_menu(new_name, updated_exec_line)
|
||||
elif os.path.exists(new_menu_path):
|
||||
self.remove_from_menu(new_name)
|
||||
|
||||
if dialog.add_to_desktop_checkbox.isChecked():
|
||||
if not os.path.exists(new_desktop_path_target):
|
||||
if exe_path != new_exe_path or not os.path.exists(new_desktop_path_target):
|
||||
self.add_to_desktop(new_name, updated_exec_line)
|
||||
elif os.path.exists(new_desktop_path_target):
|
||||
self.remove_from_desktop(new_name)
|
||||
|
||||
is_in_steam = is_game_in_steam(new_name)
|
||||
if dialog.add_to_steam_checkbox.isChecked():
|
||||
if is_in_steam and exe_path != new_exe_path:
|
||||
self.remove_from_steam(new_name, old_exec_line, "portproton")
|
||||
is_in_steam = False
|
||||
if not is_in_steam:
|
||||
self.add_to_steam(new_name, updated_exec_line, new_cover_path)
|
||||
elif is_in_steam:
|
||||
@@ -1506,7 +1673,7 @@ class ContextMenuManager:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
exe_path = extract_exec_target_path(exec_line)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import os
|
||||
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
|
||||
from PySide6.QtGui import QFont, QFontMetrics, QIcon, QPainter, QPalette, QColor, QWheelEvent
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config import ui_config
|
||||
@@ -547,7 +547,8 @@ class AutoSizeButton(QPushButton):
|
||||
self.current_theme_name = selected_theme
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
|
||||
if padding is None:
|
||||
self._uses_theme_padding = padding is None
|
||||
if self._uses_theme_padding:
|
||||
padding = getattr(self.theme, 'autoSizeButtonPadding', 20)
|
||||
|
||||
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = self._normalize_padding(padding)
|
||||
@@ -574,6 +575,18 @@ class AutoSizeButton(QPushButton):
|
||||
|
||||
self.adjustFontSize()
|
||||
|
||||
def refresh_theme(self, theme: object) -> None:
|
||||
"""Refresh sizing values supplied by the active theme."""
|
||||
self.theme = theme
|
||||
if not self._uses_theme_padding:
|
||||
return
|
||||
padding = getattr(theme, "autoSizeButtonPadding", 20)
|
||||
self._pad_top, self._pad_bottom, self._pad_left, self._pad_right = (
|
||||
self._normalize_padding(padding)
|
||||
)
|
||||
self.adjustFontSize()
|
||||
self.updateGeometry()
|
||||
|
||||
def _extract_icon_name(self, icon_path):
|
||||
if isinstance(icon_path, str):
|
||||
base = os.path.basename(icon_path)
|
||||
@@ -845,18 +858,30 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
|
||||
self._v_scrollbar = self.verticalScrollBar()
|
||||
self._h_scrollbar = self.horizontalScrollBar()
|
||||
self._v_scrollbar.installEventFilter(self)
|
||||
self._h_scrollbar.installEventFilter(self)
|
||||
|
||||
self._opacity_effect = QGraphicsOpacityEffect(self._v_scrollbar)
|
||||
self._opacity_effect.setOpacity(0.0)
|
||||
self._v_scrollbar.setGraphicsEffect(self._opacity_effect)
|
||||
|
||||
self._h_opacity_effect = QGraphicsOpacityEffect(self._h_scrollbar)
|
||||
self._h_opacity_effect.setOpacity(0.0)
|
||||
self._h_scrollbar.setGraphicsEffect(self._h_opacity_effect)
|
||||
|
||||
self._fade_animation = QPropertyAnimation(self._opacity_effect, b"opacity")
|
||||
self._fade_animation.setDuration(self.fade_duration_ms)
|
||||
self._fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||||
|
||||
self._h_fade_animation = QPropertyAnimation(self._h_opacity_effect, b"opacity")
|
||||
self._h_fade_animation.setDuration(self.fade_duration_ms)
|
||||
self._h_fade_animation.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||||
|
||||
self._is_visible = False
|
||||
self._scroll_needed = False
|
||||
self._h_is_visible = False
|
||||
self._h_scroll_needed = False
|
||||
|
||||
self._apply_visible_style()
|
||||
|
||||
@@ -864,7 +889,12 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self._hide_timer.setSingleShot(True)
|
||||
self._hide_timer.timeout.connect(self._start_fade_out)
|
||||
|
||||
self._h_hide_timer = QTimer(self)
|
||||
self._h_hide_timer.setSingleShot(True)
|
||||
self._h_hide_timer.timeout.connect(self._start_horizontal_fade_out)
|
||||
|
||||
self._v_scrollbar.valueChanged.connect(self._on_scroll)
|
||||
self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
|
||||
|
||||
self.viewport().installEventFilter(self)
|
||||
initial_widget = self.widget()
|
||||
@@ -875,6 +905,25 @@ class AutoHideScrollArea(QScrollArea):
|
||||
|
||||
def _apply_visible_style(self) -> None:
|
||||
self._v_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
|
||||
self._h_scrollbar.setStyleSheet(self.theme.SCROLL_STYLE)
|
||||
|
||||
def _start_horizontal_fade_in(self) -> None:
|
||||
if not self._h_scroll_needed:
|
||||
return
|
||||
self._h_fade_animation.stop()
|
||||
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
|
||||
self._h_fade_animation.setEndValue(1.0)
|
||||
self._h_fade_animation.start()
|
||||
self._h_is_visible = True
|
||||
|
||||
def _start_horizontal_fade_out(self) -> None:
|
||||
if not self._h_scroll_needed:
|
||||
return
|
||||
self._h_fade_animation.stop()
|
||||
self._h_fade_animation.setStartValue(self._h_opacity_effect.opacity())
|
||||
self._h_fade_animation.setEndValue(0.0)
|
||||
self._h_fade_animation.start()
|
||||
self._h_is_visible = False
|
||||
|
||||
def _start_fade_in(self) -> None:
|
||||
if not self._scroll_needed:
|
||||
@@ -899,17 +948,28 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self._opacity_effect.setOpacity(opacity)
|
||||
self._is_visible = opacity == 1.0
|
||||
|
||||
def _set_horizontal_opacity_immediately(self, opacity: float) -> None:
|
||||
self._h_fade_animation.stop()
|
||||
self._h_opacity_effect.setOpacity(opacity)
|
||||
self._h_is_visible = opacity == 1.0
|
||||
|
||||
def _update_scroll_needed(self) -> None:
|
||||
widget = self.widget()
|
||||
if widget is None:
|
||||
self._scroll_needed = False
|
||||
self._h_scroll_needed = False
|
||||
self._set_opacity_immediately(0.0)
|
||||
self._set_horizontal_opacity_immediately(0.0)
|
||||
self._hide_timer.stop()
|
||||
self._h_hide_timer.stop()
|
||||
return
|
||||
|
||||
content_height = widget.sizeHint().height()
|
||||
content_width = widget.sizeHint().width()
|
||||
viewport_height = self.viewport().height()
|
||||
viewport_width = self.viewport().width()
|
||||
self._scroll_needed = content_height > viewport_height
|
||||
self._h_scroll_needed = content_width > viewport_width
|
||||
|
||||
if not self._scroll_needed:
|
||||
self._set_opacity_immediately(0.0)
|
||||
@@ -918,23 +978,46 @@ class AutoHideScrollArea(QScrollArea):
|
||||
if not self._is_visible:
|
||||
self._set_opacity_immediately(0.0)
|
||||
|
||||
if not self._h_scroll_needed:
|
||||
self._set_horizontal_opacity_immediately(0.0)
|
||||
self._h_hide_timer.stop()
|
||||
elif not self._h_is_visible:
|
||||
self._set_horizontal_opacity_immediately(0.0)
|
||||
|
||||
def _on_scroll(self, value: int) -> None:
|
||||
if not self._scroll_needed:
|
||||
return
|
||||
self._start_fade_in()
|
||||
self._hide_timer.start(self.hide_delay_ms)
|
||||
|
||||
def _on_horizontal_scroll(self, value: int) -> None:
|
||||
if not self._h_scroll_needed:
|
||||
return
|
||||
self._start_horizontal_fade_in()
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
|
||||
def enterEvent(self, event):
|
||||
if self._scroll_needed:
|
||||
self._start_fade_in()
|
||||
self._hide_timer.start(self.hide_delay_ms)
|
||||
if self._h_scroll_needed:
|
||||
self._start_horizontal_fade_in()
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
if self._scroll_needed and self._is_visible:
|
||||
self._hide_timer.start(self.hide_delay_ms)
|
||||
if self._h_scroll_needed and self._h_is_visible:
|
||||
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:
|
||||
self._h_scrollbar.event(event)
|
||||
return
|
||||
super().wheelEvent(event)
|
||||
|
||||
def eventFilter(self, obj, event):
|
||||
if not hasattr(self, "_v_scrollbar"):
|
||||
if not hasattr(self, "_v_scrollbar") or not hasattr(self, "_h_scrollbar"):
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
if event.type() == QEvent.Type.Resize:
|
||||
@@ -947,6 +1030,14 @@ class AutoHideScrollArea(QScrollArea):
|
||||
elif event.type() == QEvent.Type.Leave:
|
||||
if self._scroll_needed and self._is_visible:
|
||||
self._hide_timer.start(self.hide_delay_ms)
|
||||
elif obj == self._h_scrollbar:
|
||||
if event.type() == QEvent.Type.Enter:
|
||||
if self._h_scroll_needed:
|
||||
self._start_horizontal_fade_in()
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
elif event.type() == QEvent.Type.Leave:
|
||||
if self._h_scroll_needed and self._h_is_visible:
|
||||
self._h_hide_timer.start(self.hide_delay_ms)
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def setWidget(self, widget: QWidget | None) -> None:
|
||||
|
||||
@@ -86,6 +86,9 @@ class DetailPageManager:
|
||||
self._gog_size_requests: dict[
|
||||
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
|
||||
] = {}
|
||||
self._egs_size_requests: dict[
|
||||
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
|
||||
] = {}
|
||||
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open detailed game information page."""
|
||||
@@ -310,6 +313,7 @@ class DetailPageManager:
|
||||
game_info_layout.addLayout(first_row)
|
||||
|
||||
self._setup_gog_size_data(game_data, game_info_layout)
|
||||
self._setup_egs_size_data(game_data, game_info_layout)
|
||||
|
||||
hltb_layout = QHBoxLayout()
|
||||
hltb_layout.setSpacing(10)
|
||||
@@ -365,6 +369,75 @@ class DetailPageManager:
|
||||
)
|
||||
process.start()
|
||||
|
||||
def _setup_egs_size_data(
|
||||
self, game_data: dict, game_info_layout: QVBoxLayout
|
||||
) -> None:
|
||||
exec_line = str(game_data.get("exec_line", ""))
|
||||
if str(game_data.get("game_source", "")).lower() != "egs":
|
||||
return
|
||||
if not exec_line.startswith("egs://install/"):
|
||||
return
|
||||
row = QHBoxLayout()
|
||||
values = []
|
||||
for title_text in (_("Download"), _("Size")):
|
||||
title = QLabel(title_text.upper())
|
||||
title.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||
value = QLabel("—")
|
||||
value.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||
row.addWidget(title)
|
||||
row.addWidget(value)
|
||||
row.addSpacing(30)
|
||||
values.append(weakref.ref(value))
|
||||
game_info_layout.addLayout(row)
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
api = self.main_window.egs_api
|
||||
cached = api.get_cached_download_sizes(app_id)
|
||||
if cached is not None:
|
||||
self._set_gog_size_values(values, cached)
|
||||
return
|
||||
request = self._egs_size_requests.get(app_id)
|
||||
if request is not None:
|
||||
request[1].append(values)
|
||||
return
|
||||
try:
|
||||
command = api.build_command(["info", app_id, "--json", "--platform", "Windows"])
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.warning("Failed to get EGS download size: %s", error)
|
||||
return
|
||||
process = QProcess(self.main_window)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
environment.insert("LEGENDARY_CONFIG_PATH", str(api.config_dir))
|
||||
process.setProcessEnvironment(environment)
|
||||
self._egs_size_requests[app_id] = (process, [values])
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._finish_egs_size_request(app_id, code)
|
||||
)
|
||||
process.start()
|
||||
|
||||
def _finish_egs_size_request(self, app_id: str, code: int) -> None:
|
||||
request = self._egs_size_requests.pop(app_id, None)
|
||||
if request is None:
|
||||
return
|
||||
process, value_groups = request
|
||||
if code != 0:
|
||||
logger.warning("legendary info exited with code %s", code)
|
||||
process.deleteLater()
|
||||
return
|
||||
try:
|
||||
sizes = self.main_window.egs_api.parse_download_sizes(
|
||||
bytes(process.readAllStandardOutput().data())
|
||||
)
|
||||
except (ValueError, TypeError) as error:
|
||||
logger.warning("Failed to parse EGS download size: %s", error)
|
||||
process.deleteLater()
|
||||
return
|
||||
self.main_window.egs_api.save_download_sizes(app_id, sizes)
|
||||
for value_refs in value_groups:
|
||||
self._set_gog_size_values(value_refs, sizes)
|
||||
process.deleteLater()
|
||||
|
||||
def _finish_gog_size_request(self, app_id: str, code: int) -> None:
|
||||
request = self._gog_size_requests.pop(app_id, None)
|
||||
if request is None:
|
||||
@@ -531,23 +604,32 @@ class DetailPageManager:
|
||||
|
||||
current_exe = self._get_current_exe(exec_line)
|
||||
play_button = self._create_play_button(exec_line, current_exe)
|
||||
if str(game_source).lower() == "gog" and exec_line.startswith("gog://install/"):
|
||||
source = str(game_source).lower()
|
||||
install_uri = exec_line.startswith(f"{source}://install/")
|
||||
if source in {"gog", "egs"} and install_uri:
|
||||
play_button.setText(_("Install"))
|
||||
play_button.setIcon(
|
||||
self.main_window.theme_manager.get_icon("update", as_path=True)
|
||||
)
|
||||
buttons_layout.addWidget(play_button)
|
||||
|
||||
if str(game_source).lower() == "steam" and appid:
|
||||
if source in {"steam", "gog", "egs"} and appid:
|
||||
edit_button = self._make_action_button(
|
||||
_("Edit Shortcut"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
)
|
||||
edit_button.clicked.connect(
|
||||
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
|
||||
game_name, exec_line, cover_path, appid
|
||||
if source == "steam":
|
||||
edit_button.clicked.connect(
|
||||
lambda: self.main_window.context_menu_manager.edit_game_shortcut(
|
||||
game_name, exec_line, cover_path, appid
|
||||
)
|
||||
)
|
||||
else:
|
||||
edit_button.clicked.connect(
|
||||
lambda: self.main_window.context_menu_manager._edit_id_shortcut(
|
||||
game_name, cover_path, (source, str(appid))
|
||||
)
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(edit_button)
|
||||
elif self._has_game_shortcut(game_name):
|
||||
edit_button = self._make_action_button(
|
||||
@@ -570,7 +652,7 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() != "gog"
|
||||
str(game_source).lower() not in {"gog", "egs"}
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
@@ -588,21 +670,26 @@ class DetailPageManager:
|
||||
if str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
str(game_source).lower() == "gog"
|
||||
source in {"gog", "egs"}
|
||||
and appid
|
||||
and exec_line.startswith("gog://launch/")
|
||||
and exec_line.startswith(f"{source}://launch/")
|
||||
):
|
||||
gog_exe = self.main_window.gog_api.get_launch_target(str(appid))
|
||||
if not gog_exe:
|
||||
store_api = (
|
||||
self.main_window.gog_api if source == "gog"
|
||||
else self.main_window.egs_api
|
||||
)
|
||||
store_exe = store_api.get_launch_target(str(appid))
|
||||
if not store_exe:
|
||||
return buttons_layout
|
||||
self.main_window.gog_api.ensure_launch_parameters(str(appid))
|
||||
if source == "gog":
|
||||
store_api.ensure_launch_parameters(str(appid))
|
||||
settings_button = self._make_action_button(
|
||||
_("Settings"),
|
||||
self.main_window.theme_manager.get_icon("settings", as_path=True),
|
||||
)
|
||||
settings_button.clicked.connect(
|
||||
lambda: self.main_window.open_exe_settings(
|
||||
gog_exe, game_source="gog",
|
||||
store_exe, game_source=source,
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(settings_button)
|
||||
@@ -610,7 +697,9 @@ class DetailPageManager:
|
||||
_("Create Log"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
)
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(gog_exe, log_button))
|
||||
log_button.clicked.connect(
|
||||
lambda: self.toggleDebugLog(store_exe, log_button)
|
||||
)
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
folder_button = self._make_action_button(
|
||||
@@ -618,7 +707,7 @@ class DetailPageManager:
|
||||
self.main_window.theme_manager.get_icon("search", as_path=True),
|
||||
)
|
||||
folder_button.clicked.connect(
|
||||
lambda: self._open_executable_folder(gog_exe)
|
||||
lambda: self._open_executable_folder(store_exe)
|
||||
)
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "steam" and appid:
|
||||
@@ -833,17 +922,32 @@ class DetailPageManager:
|
||||
return_tab_index = getattr(self, "_return_to_tab_index", 0)
|
||||
current_page = self._current_detail_page
|
||||
running_button = self.main_window.current_running_button
|
||||
debug_log_button = self._debug_log_button
|
||||
replace_running_button = (
|
||||
current_page is not None
|
||||
and running_button is not None
|
||||
and current_page.isAncestorOf(running_button)
|
||||
)
|
||||
replace_debug_log_button = (
|
||||
current_page is not None
|
||||
and debug_log_button is not None
|
||||
and current_page.isAncestorOf(debug_log_button)
|
||||
)
|
||||
self._remove_current_detail_page()
|
||||
if source_kind == "autoinstall":
|
||||
self.openAutoInstallDetailPage(source_data)
|
||||
else:
|
||||
self.openGameDetailPage(source_data)
|
||||
self._return_to_tab_index = return_tab_index
|
||||
if (
|
||||
replace_debug_log_button
|
||||
and self.debug_log_manager.is_running
|
||||
and self._debug_log_button is not None
|
||||
):
|
||||
self._debug_log_button.setText(_("Stop Log"))
|
||||
icon = self.main_window.theme_manager.get_icon("stop", as_path=True)
|
||||
if icon:
|
||||
self._debug_log_button.setIcon(icon)
|
||||
if replace_running_button and self._current_detail_page is not None:
|
||||
play_button = self._find_play_button(self._current_detail_page)
|
||||
if play_button is not None:
|
||||
@@ -1344,6 +1448,8 @@ class DetailPageManager:
|
||||
resolved_exe_path = self.main_window.resolve_launch_file_path(exe_path)
|
||||
if resolved_exe_path is None:
|
||||
return
|
||||
if not self.main_window._check_alt_i586_dependencies_before_launch():
|
||||
return
|
||||
|
||||
start_command = get_portproton_start_command()
|
||||
if not start_command:
|
||||
|
||||
@@ -4,9 +4,9 @@ import os
|
||||
import re
|
||||
from weakref import WeakKeyDictionary
|
||||
from collections.abc import Callable
|
||||
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsOpacityEffect
|
||||
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsBlurEffect, QGraphicsOpacityEffect, QGraphicsPixmapItem, QGraphicsScene
|
||||
from PySide6.QtCore import QTimer, Qt, QObject, Signal, QPropertyAnimation, QByteArray
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtGui import QBrush, QImage, QPainter, QPalette, QPixmap
|
||||
|
||||
from portprotonqt.image_utils import get_animated_cover_pixmap
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners, set_animated_cover
|
||||
@@ -85,7 +85,7 @@ def _set_animated_cover(
|
||||
detail_page, image_label = cover_widgets
|
||||
if not set_animated_cover(image_label, cover_path, cover_size[0], cover_size[1], 10):
|
||||
return False
|
||||
_setup_palette_stylesheet(detail_page, get_animated_cover_pixmap(image_label), main_window)
|
||||
setup_cover_background(detail_page, get_animated_cover_pixmap(image_label), main_window)
|
||||
_reveal_cover(image_label, main_window.theme)
|
||||
return True
|
||||
|
||||
@@ -99,7 +99,7 @@ def _on_pixmap_ready(pixmap: QPixmap, detail_page: QWidget, image_label: QLabel,
|
||||
image_label.setPixmap(rounded)
|
||||
_reveal_cover(image_label, main_window.theme)
|
||||
logger.debug("Pixmap set for imageLabel")
|
||||
_setup_palette_stylesheet(detail_page, pixmap, main_window)
|
||||
setup_cover_background(detail_page, pixmap, main_window)
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping pixmap update")
|
||||
|
||||
@@ -138,25 +138,75 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
|
||||
animation.start()
|
||||
|
||||
|
||||
def _setup_palette_stylesheet(detail_page: QWidget, pixmap: QPixmap | None, main_window) -> None:
|
||||
def setup_cover_background(
|
||||
detail_page: QWidget, pixmap: QPixmap | None, main_window, theme=None
|
||||
) -> None:
|
||||
"""Setup palette-based stylesheet after image load."""
|
||||
if pixmap is None:
|
||||
return
|
||||
active_theme = theme or main_window.theme
|
||||
if getattr(active_theme, "DETAIL_PAGE_BG_MODE", "gradient") == "blur":
|
||||
_apply_blurred_cover(detail_page, pixmap, active_theme)
|
||||
return
|
||||
|
||||
def on_palette_ready(palette: list) -> None:
|
||||
_on_palette_ready(palette, detail_page, main_window)
|
||||
_on_palette_ready(palette, detail_page, main_window, active_theme)
|
||||
|
||||
num_colors = getattr(main_window.theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
|
||||
num_colors = getattr(active_theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
|
||||
if num_colors < 2:
|
||||
num_colors = 2
|
||||
main_window.getColorPalette_from_pixmap(pixmap, num_colors=num_colors, callback=on_palette_ready)
|
||||
|
||||
|
||||
def _on_palette_ready(palette: list, detail_page: QWidget, main_window) -> None:
|
||||
def remove_cover_background(widget: QWidget) -> None:
|
||||
"""Remove a shared cover-driven background and stop its animation."""
|
||||
_remove_wave_background(widget)
|
||||
_detail_backgrounds.remove(widget)
|
||||
widget.setStyleSheet("")
|
||||
widget.setAutoFillBackground(False)
|
||||
if isinstance(widget, QLabel):
|
||||
widget.clear()
|
||||
|
||||
|
||||
def _on_palette_ready(
|
||||
palette: list, detail_page: QWidget, main_window, theme
|
||||
) -> None:
|
||||
"""Handle palette ready callback."""
|
||||
if not _is_detail_page_valid(detail_page):
|
||||
return
|
||||
_apply_palette_stylesheet(detail_page, palette, main_window)
|
||||
_apply_palette_stylesheet(detail_page, palette, main_window, theme)
|
||||
|
||||
|
||||
def _apply_blurred_cover(widget: QWidget, pixmap: QPixmap, theme) -> None:
|
||||
config = theme.DETAIL_PAGE_BACKGROUNDS.get("blur", {})
|
||||
width, height = config.get("render_resolution", (1280, 720))
|
||||
scaled = pixmap.scaled(
|
||||
width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
source = scaled.copy(
|
||||
(scaled.width() - width) // 2, (scaled.height() - height) // 2,
|
||||
width, height,
|
||||
)
|
||||
item = QGraphicsPixmapItem(source)
|
||||
effect = QGraphicsBlurEffect()
|
||||
effect.setBlurRadius(config.get("radius", 64))
|
||||
item.setGraphicsEffect(effect)
|
||||
scene = QGraphicsScene()
|
||||
scene.addItem(item)
|
||||
image = QImage(source.size(), QImage.Format.Format_ARGB32_Premultiplied)
|
||||
image.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(image)
|
||||
scene.render(painter)
|
||||
painter.end()
|
||||
background = QPixmap.fromImage(image).scaled(widget.size())
|
||||
if isinstance(widget, QLabel):
|
||||
widget.setPixmap(background)
|
||||
return
|
||||
palette = widget.palette()
|
||||
palette.setBrush(QPalette.ColorRole.Window, QBrush(background))
|
||||
widget.setPalette(palette)
|
||||
widget.setAutoFillBackground(True)
|
||||
|
||||
|
||||
def _build_palette_stops(dark_palette: list) -> str:
|
||||
@@ -228,14 +278,16 @@ def _resolve_gradient_config(theme: object) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window) -> None:
|
||||
def _apply_palette_stylesheet(
|
||||
detail_page: QWidget, palette: list, main_window, theme
|
||||
) -> None:
|
||||
"""Apply palette-based stylesheet to detail page."""
|
||||
try:
|
||||
dark_palette = [
|
||||
main_window.darkenColor(color, factor=200) for color in palette
|
||||
]
|
||||
gradient_config = _resolve_gradient_config(main_window.theme)
|
||||
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
|
||||
gradient_config = _resolve_gradient_config(theme)
|
||||
stops = _resolve_gradient_stops(theme, dark_palette)
|
||||
gradient_type = gradient_config.get("type", "linear")
|
||||
|
||||
if gradient_type == 'radial':
|
||||
@@ -268,7 +320,7 @@ def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window)
|
||||
"""
|
||||
|
||||
detail_page.setStyleSheet(stylesheet)
|
||||
_setup_wave_background(detail_page, dark_palette, main_window.theme)
|
||||
_setup_wave_background(detail_page, dark_palette, theme)
|
||||
detail_page.update()
|
||||
logger.debug("Stylesheet updated with palette")
|
||||
except RuntimeError:
|
||||
@@ -287,7 +339,7 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
|
||||
return
|
||||
_detail_backgrounds.remove(detail_page)
|
||||
|
||||
if bg_mode == "gradient":
|
||||
if bg_mode not in {"waves", "static_waves"}:
|
||||
_remove_wave_background(detail_page)
|
||||
return
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ def create_content_frame(
|
||||
) -> tuple[QFrame, QBoxLayout]:
|
||||
"""Create content frame."""
|
||||
content_frame = QFrame()
|
||||
content_frame.setProperty("theme_style_name", "DETAIL_CONTENT_FRAME_STYLE")
|
||||
content_frame.setStyleSheet(theme.DETAIL_CONTENT_FRAME_STYLE)
|
||||
content_frame_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, content_frame)
|
||||
content_frame_layout.setContentsMargins(20, 20, 20, 20)
|
||||
@@ -139,7 +140,7 @@ def _add_source_corner(
|
||||
) -> None:
|
||||
"""Add source corner badge to cover frame."""
|
||||
source = game_source.lower()
|
||||
if source not in ("steam", "gog", "portproton") or theme_manager is None:
|
||||
if source not in ("steam", "gog", "egs", "portproton") or theme_manager is None:
|
||||
return
|
||||
|
||||
icon_name = f"badge_{source}"
|
||||
@@ -182,6 +183,7 @@ def create_compact_detail_header(
|
||||
header_widget.setFixedHeight(cover_frame.height())
|
||||
|
||||
title_frame = QFrame(header_widget)
|
||||
title_frame.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
|
||||
title_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
title_frame.setMaximumHeight(cover_frame.height())
|
||||
title_frame.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
|
||||
@@ -192,6 +194,7 @@ def create_compact_detail_header(
|
||||
title_layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
||||
title_label = QLabel(title)
|
||||
title_label.setWordWrap(True)
|
||||
title_label.setProperty("theme_style_name", "COMPACT_DETAIL_PAGE_TITLE_STYLE")
|
||||
title_label.setStyleSheet(theme.COMPACT_DETAIL_PAGE_TITLE_STYLE)
|
||||
title_layout.addWidget(title_label, alignment=Qt.AlignmentFlag.AlignVCenter)
|
||||
header_layout.addWidget(title_frame, stretch=1)
|
||||
@@ -201,6 +204,7 @@ def create_compact_detail_header(
|
||||
def create_compact_layout_panel(parent: QWidget, theme, content_layout: QLayout) -> QWidget:
|
||||
"""Create compact panel for an existing layout."""
|
||||
panel = QWidget(parent)
|
||||
panel.setProperty("theme_style_name", "COMPACT_PLAYTIME_WIDGET_STYLE")
|
||||
panel.setStyleSheet(theme.COMPACT_PLAYTIME_WIDGET_STYLE)
|
||||
panel.setLayout(content_layout)
|
||||
content_layout.setContentsMargins(
|
||||
@@ -218,6 +222,7 @@ def create_compact_description_panel(
|
||||
) -> QWidget:
|
||||
"""Create compact description block."""
|
||||
desc_widget = QWidget(parent)
|
||||
desc_widget.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
|
||||
desc_widget.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
|
||||
desc_layout = QVBoxLayout(desc_widget)
|
||||
desc_layout.setContentsMargins(
|
||||
@@ -397,6 +402,7 @@ def create_details_widget(
|
||||
) -> QWidget:
|
||||
"""Create details widget with title, description, and optional content."""
|
||||
details_widget = QWidget()
|
||||
details_widget.setProperty("theme_style_name", "DETAILS_WIDGET_STYLE")
|
||||
details_widget.setStyleSheet(main_window.theme.DETAILS_WIDGET_STYLE)
|
||||
|
||||
details_layout = QVBoxLayout(details_widget)
|
||||
@@ -439,6 +445,7 @@ def create_details_widget(
|
||||
def _add_details_header(layout: QVBoxLayout, theme, title: str) -> None:
|
||||
"""Add title and divider line to details layout."""
|
||||
title_label = QLabel(title)
|
||||
title_label.setProperty("theme_style_name", "DETAIL_PAGE_TITLE_STYLE")
|
||||
title_label.setStyleSheet(theme.DETAIL_PAGE_TITLE_STYLE)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
@@ -36,6 +37,40 @@ 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."""
|
||||
|
||||
@@ -317,7 +352,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, sorted(filtered_entries, key=version_sort_key)))
|
||||
tabs.append((source_name, sort_wine_entries(filtered_entries, source_name)))
|
||||
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
|
||||
self.selected_assets.clear()
|
||||
self.tab_widget.clear()
|
||||
@@ -383,14 +418,13 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
def filter_entries_by_cpu_level(self, entries, source_name):
|
||||
is_arm = platform.machine().lower().startswith(('arm', 'aarch64'))
|
||||
if not is_arm:
|
||||
entries = [
|
||||
entry for entry in entries
|
||||
if 'aarch64' not in entry.get('name', '').lower()
|
||||
and '-arm' not in entry.get('name', '').lower()
|
||||
and 'aarch64' not in entry.get('url', '').lower()
|
||||
and '-arm' not in entry.get('url', '').lower()
|
||||
]
|
||||
filtered_entries = []
|
||||
for entry in entries:
|
||||
entry_info = f"{entry.get('name', '')} {entry.get('url', '')}".lower()
|
||||
entry_is_arm = 'aarch64' in entry_info or '-arm' in entry_info
|
||||
if entry_is_arm == is_arm:
|
||||
filtered_entries.append(entry)
|
||||
entries = filtered_entries
|
||||
if source_name.lower() != 'proton_cachyos':
|
||||
return entries
|
||||
if self.cpu_level >= 4:
|
||||
@@ -448,7 +482,7 @@ class ProtonManager(DraggableDialog):
|
||||
if url_filename:
|
||||
entry['filename'] = url_filename
|
||||
all_entries.append(entry)
|
||||
all_entries.sort(key=version_sort_key)
|
||||
all_entries = sort_wine_entries(all_entries, source_name)
|
||||
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)
|
||||
|
||||
@@ -45,6 +45,7 @@ from portprotonqt.dialogs.settings_vkbasalt import VKBASALT_ENV_KEYS, VkBasaltSe
|
||||
from portprotonqt.localization import _, format_setting_name_for_display
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.preloader import Preloader
|
||||
from portprotonqt.qt_utils import get_screen_info, get_system_dpi_for_wine
|
||||
from portprotonqt.settings_manager import (
|
||||
ADVANCED_SETTING_KEYS,
|
||||
get_available_prefix_options,
|
||||
@@ -65,8 +66,11 @@ TOGGLE_BOOL_KEYS = {
|
||||
'PW_VKBASALT',
|
||||
'PW_VKBASALT_USER_CONF',
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_WINE_SETTING_KEYS = {
|
||||
'PW_WINE_USE': 'PW_DEFAULT_WINE_USE',
|
||||
'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,13 +139,15 @@ class ExeSettingsDialog(
|
||||
):
|
||||
"""Dialog for configuring executable-specific settings."""
|
||||
|
||||
def __init__(self, parent=None, theme=None, exe_path=None, appid=None, game_source=None):
|
||||
def __init__(self, parent=None, theme=None, exe_path=None, appid=None, game_source=None,
|
||||
user_conf=False):
|
||||
super().__init__(parent)
|
||||
self.theme = theme if theme else theme_manager.apply_theme(ui_config.get_theme())
|
||||
self.exe_path = exe_path
|
||||
self.appid = appid
|
||||
self.game_source = str(game_source).lower() if game_source else ""
|
||||
if not self.exe_path and not self.appid:
|
||||
self.user_conf = user_conf
|
||||
if not self.user_conf and not self.exe_path and not self.appid:
|
||||
return
|
||||
self.portproton_path = get_portproton_location()
|
||||
if self.portproton_path is None:
|
||||
@@ -154,12 +160,21 @@ class ExeSettingsDialog(
|
||||
|
||||
self.dist_options = []
|
||||
self.lg_dist_aliases = {}
|
||||
self.plugins_ver = ""
|
||||
self.prefix_options = []
|
||||
if self.portproton_path:
|
||||
scripts_path = get_portproton_scripts_path()
|
||||
if scripts_path:
|
||||
var_path = os.path.join(scripts_path, "var")
|
||||
self.lg_dist_aliases = read_lg_dist_versions_from_var(var_path)
|
||||
try:
|
||||
with open(var_path, encoding="utf-8") as var_file:
|
||||
for line in var_file:
|
||||
if line.startswith('export PW_PLUGINS_VER='):
|
||||
self.plugins_ver = line.split('=', 1)[1].strip().strip('"\'')
|
||||
break
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to read PW_PLUGINS_VER: %s", exc)
|
||||
system_wine_label = "" if self.game_source == "steam" else _('System WINE')
|
||||
self.dist_options = get_available_wine_options(
|
||||
self.portproton_path, system_wine_label, self.game_source == "steam"
|
||||
@@ -185,7 +200,7 @@ class ExeSettingsDialog(
|
||||
self.logical_core_options = []
|
||||
self._gamepad_tooltip_map = {}
|
||||
|
||||
self.setWindowTitle(_("Exe Settings"))
|
||||
self.setWindowTitle(_("Global Game Settings") if self.user_conf else _("Exe Settings"))
|
||||
self.setModal(True)
|
||||
self.resize(1100, 720)
|
||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||
@@ -194,6 +209,16 @@ class ExeSettingsDialog(
|
||||
self.toggle_settings = get_toggle_settings()
|
||||
|
||||
self.setup_ui()
|
||||
if self.user_conf:
|
||||
self.open_ppdb_button.clicked.disconnect()
|
||||
self.open_ppdb_button.setText(_("Edit user.conf"))
|
||||
self.open_ppdb_button.clicked.connect(self.open_user_conf)
|
||||
self.clear_ppdb_button.clicked.disconnect()
|
||||
self.clear_ppdb_button.setText(_("Clear All"))
|
||||
self.clear_ppdb_button.clicked.connect(self.clear_user_conf)
|
||||
self.settings_table.horizontalHeader().setSectionResizeMode(
|
||||
1, QHeaderView.ResizeMode.ResizeToContents
|
||||
)
|
||||
app = QApplication.instance()
|
||||
if isinstance(app, QApplication):
|
||||
app.focusChanged.connect(self._on_focus_changed)
|
||||
@@ -443,7 +468,7 @@ class ExeSettingsDialog(
|
||||
button_layout = QHBoxLayout()
|
||||
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply", as_path=True))
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel", as_path=True))
|
||||
self.open_ppdb_button = AutoSizeButton(_("Open PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
self.open_ppdb_button = AutoSizeButton(_("Edit PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
self.clear_ppdb_button = AutoSizeButton(_("Clear PPDB"), icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
@@ -472,7 +497,10 @@ class ExeSettingsDialog(
|
||||
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_show_ppdb_finished)
|
||||
args = self._get_process_args(["cli", "--show-ppdb", f"{self.exe_path}"])
|
||||
command = ["cli", "--get-user-conf"] if self.user_conf else [
|
||||
"cli", "--show-ppdb", f"{self.exe_path}"
|
||||
]
|
||||
args = self._get_process_args(command)
|
||||
process.start(args[0], args[1:])
|
||||
|
||||
def on_show_ppdb_finished(self, exit_code, exit_status):
|
||||
@@ -484,6 +512,13 @@ class ExeSettingsDialog(
|
||||
self.logical_core_options = []
|
||||
self.locale_options = []
|
||||
|
||||
if self.user_conf and (
|
||||
exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit
|
||||
):
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to load global game settings."))
|
||||
self.reject()
|
||||
return
|
||||
|
||||
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
|
||||
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
|
||||
for line in output.splitlines():
|
||||
@@ -504,6 +539,7 @@ class ExeSettingsDialog(
|
||||
if (
|
||||
key in self.toggle_settings
|
||||
or key in ADVANCED_SETTING_KEYS
|
||||
or key in DEFAULT_WINE_SETTING_KEYS.values()
|
||||
or key in MANGOHUD_ENV_KEYS
|
||||
or key in GAMESCOPE_ENV_KEYS
|
||||
or key in VKBASALT_ENV_KEYS
|
||||
@@ -515,6 +551,8 @@ class ExeSettingsDialog(
|
||||
self.current_settings[key] = val
|
||||
except ValueError:
|
||||
continue
|
||||
if self.user_conf and self.plugins_ver:
|
||||
self.current_settings['PW_PLUGINS_VER'] = self.plugins_ver
|
||||
|
||||
if self.game_source == "steam":
|
||||
self.blocked_keys.update({
|
||||
@@ -539,17 +577,18 @@ class ExeSettingsDialog(
|
||||
self.current_settings[key] = ''
|
||||
for key in VKBASALT_ENV_KEYS:
|
||||
self.current_settings[key] = ''
|
||||
else:
|
||||
elif not self.user_conf:
|
||||
self.current_settings.setdefault('PW_MANGOHUD', '0')
|
||||
self.current_settings.setdefault('PW_VKBASALT', '0')
|
||||
|
||||
for key in self.blocked_keys:
|
||||
self.current_settings[key] = '0'
|
||||
|
||||
current_wine_version = self.current_settings.get('PW_WINE_USE')
|
||||
wine_key = 'PW_DEFAULT_WINE_USE' if self.user_conf else 'PW_WINE_USE'
|
||||
current_wine_version = self.current_settings.get(wine_key)
|
||||
if current_wine_version in self.lg_dist_aliases:
|
||||
self.current_settings['PW_WINE_USE'] = self.lg_dist_aliases[current_wine_version]
|
||||
current_wine_version = self.current_settings['PW_WINE_USE']
|
||||
self.current_settings[wine_key] = self.lg_dist_aliases[current_wine_version]
|
||||
current_wine_version = self.current_settings[wine_key]
|
||||
if (
|
||||
current_wine_version
|
||||
and current_wine_version not in self.dist_options
|
||||
@@ -560,7 +599,7 @@ class ExeSettingsDialog(
|
||||
|
||||
self.original_values = self.current_settings.copy()
|
||||
for key in set(self.toggle_settings.keys()):
|
||||
self.original_values.setdefault(key, '0')
|
||||
self.original_values.setdefault(key, '' if self.user_conf else '0')
|
||||
|
||||
self.populate_table()
|
||||
self.populate_advanced()
|
||||
@@ -588,6 +627,14 @@ class ExeSettingsDialog(
|
||||
if not QDesktopServices.openUrl(QUrl.fromLocalFile(db_path)):
|
||||
QMessageBox.critical(self, _("Error"), _("Failed to open PPDB file:\n") + db_path)
|
||||
|
||||
def open_user_conf(self) -> None:
|
||||
user_conf = os.path.join(cast(str, self.portproton_path), "data", "user.conf")
|
||||
if not os.path.exists(user_conf):
|
||||
QMessageBox.critical(self, _("Error"), _("Failed to open file:\n") + user_conf)
|
||||
return
|
||||
if not QDesktopServices.openUrl(QUrl.fromLocalFile(user_conf)):
|
||||
QMessageBox.critical(self, _("Error"), _("Failed to open file:\n") + user_conf)
|
||||
|
||||
def clear_ppdb_file(self):
|
||||
"""Remove the PPDB file and reload settings."""
|
||||
if not self.exe_path:
|
||||
@@ -620,6 +667,40 @@ class ExeSettingsDialog(
|
||||
|
||||
self.load_current_settings()
|
||||
|
||||
def clear_user_conf(self) -> None:
|
||||
msg_box = QMessageBox(self)
|
||||
msg_box.setIcon(QMessageBox.Icon.Question)
|
||||
msg_box.setWindowTitle(_("Confirm Clear"))
|
||||
msg_box.setText(_("Are you sure you want to clear settings? This action cannot be undone."))
|
||||
msg_box.setStandardButtons(QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No)
|
||||
msg_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
msg_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if msg_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
|
||||
user_conf = os.path.join(cast(str, self.portproton_path), "data", "user.conf")
|
||||
try:
|
||||
os.remove(user_conf)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to remove user.conf: %s", exc)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
return
|
||||
screen_resolution, screen_primary = get_screen_info()
|
||||
self.user_conf_changes = [
|
||||
screen_resolution,
|
||||
screen_primary,
|
||||
f"PW_WINE_DPI_VALUE={get_system_dpi_for_wine()}",
|
||||
]
|
||||
self.user_conf_changes = [
|
||||
change for change in self.user_conf_changes if change.split('=', 1)[1]
|
||||
]
|
||||
self.close_after_user_conf_changes = False
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
|
||||
def populate_table(self):
|
||||
"""Populate the table with settings."""
|
||||
self.settings_table.setRowCount(0)
|
||||
@@ -640,27 +721,46 @@ class ExeSettingsDialog(
|
||||
name_item.setData(Qt.ItemDataRole.UserRole, toggle)
|
||||
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
|
||||
current_val = self.current_settings.get(toggle, '0')
|
||||
current_val = self.current_settings.get(toggle, '') if self.user_conf else (
|
||||
self.current_settings.get(toggle, '0')
|
||||
)
|
||||
is_blocked = toggle in self.blocked_keys
|
||||
checkbox_widget = QCheckBox()
|
||||
checkbox_widget.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
checkbox_widget.setChecked(current_val == '1' and not is_blocked)
|
||||
checkbox_widget.setEnabled(not is_blocked)
|
||||
checkbox_widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
checkbox_widget.installEventFilter(self)
|
||||
checkbox_container = QWidget()
|
||||
checkbox_container.setStyleSheet(self.theme.CHECKBOX_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
checkbox_layout = QHBoxLayout(checkbox_container)
|
||||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
checkbox_layout.addWidget(checkbox_widget)
|
||||
checkbox_container.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
checkbox_item = QTableWidgetItem()
|
||||
checkbox_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
if self.user_conf:
|
||||
value_widget = CustomComboBox(theme=self.theme)
|
||||
value_widget.setObjectName("settingsTableCombo")
|
||||
value_widget.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
)
|
||||
value_widget.view().window().setAttribute(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
value_widget.addItem(_("Default"), "")
|
||||
value_widget.addItem(_("Yes"), "1")
|
||||
value_widget.addItem(_("No"), "0")
|
||||
value_widget.setCurrentIndex(max(0, value_widget.findData(current_val)))
|
||||
else:
|
||||
value_widget = QCheckBox()
|
||||
value_widget.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
value_widget.setChecked(current_val == '1' and not is_blocked)
|
||||
value_widget.setEnabled(not is_blocked)
|
||||
value_widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
value_widget.installEventFilter(self)
|
||||
if is_blocked:
|
||||
name_item.setForeground(QColor(self.theme.color_disabled_text))
|
||||
self.settings_table.setItem(row, 1, checkbox_item)
|
||||
self.settings_table.setCellWidget(row, 1, checkbox_container)
|
||||
if self.user_conf:
|
||||
self.settings_table.setCellWidget(row, 1, value_widget)
|
||||
else:
|
||||
checkbox_container = QWidget()
|
||||
checkbox_container.setStyleSheet(self.theme.CHECKBOX_STYLE + self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
checkbox_layout = QHBoxLayout(checkbox_container)
|
||||
checkbox_layout.setContentsMargins(0, 0, 0, 0)
|
||||
checkbox_layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
checkbox_layout.addWidget(value_widget)
|
||||
checkbox_container.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
checkbox_item = QTableWidgetItem()
|
||||
checkbox_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
self.settings_table.setItem(row, 1, checkbox_item)
|
||||
self.settings_table.setCellWidget(row, 1, checkbox_container)
|
||||
|
||||
desc_item = QTableWidgetItem(description)
|
||||
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
|
||||
@@ -670,7 +770,7 @@ class ExeSettingsDialog(
|
||||
self.settings_table.setItem(row, 2, desc_item)
|
||||
|
||||
self.settings_table.setItem(row, 0, name_item)
|
||||
self.value_widgets[(row, 1)] = checkbox_widget
|
||||
self.value_widgets[(row, 1)] = value_widget
|
||||
|
||||
self.settings_table.resizeRowsToContents()
|
||||
if self.settings_table.rowCount() > 0:
|
||||
@@ -702,6 +802,11 @@ class ExeSettingsDialog(
|
||||
dist_options=self.dist_options,
|
||||
prefix_options=self.prefix_options
|
||||
)
|
||||
if self.user_conf:
|
||||
for setting in advanced_settings:
|
||||
setting['key'] = DEFAULT_WINE_SETTING_KEYS.get(setting['key'], setting['key'])
|
||||
if setting['type'] == 'combo':
|
||||
setting['options'] = [_('Default')] + setting['options']
|
||||
self.advanced_settings_by_key = {
|
||||
setting['key']: setting for setting in advanced_settings
|
||||
}
|
||||
@@ -726,7 +831,7 @@ class ExeSettingsDialog(
|
||||
Qt.WidgetAttribute.WA_TranslucentBackground
|
||||
)
|
||||
combo.addItems(setting['options'])
|
||||
if setting['key'] == 'PW_PREFIX_NAME':
|
||||
if setting['key'] in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
|
||||
combo.setEditable(True)
|
||||
combo.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||||
prefix_line_edit = combo.lineEdit()
|
||||
@@ -735,17 +840,21 @@ class ExeSettingsDialog(
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
)
|
||||
elif setting['key'] == 'PW_WINE_USE':
|
||||
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
|
||||
combo.highlighted.connect(
|
||||
lambda row, c=combo: self._on_combo_highlighted(row, c),
|
||||
)
|
||||
|
||||
current_raw = current.get(setting['key'], setting['default'])
|
||||
if setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
|
||||
current_raw = current.get(setting['key'], '') if self.user_conf else (
|
||||
current.get(setting['key'], setting['default'])
|
||||
)
|
||||
if self.user_conf and not current_raw:
|
||||
current_val = _('Default')
|
||||
elif setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
|
||||
current_val = disabled_text if current_raw == 'disabled' else (
|
||||
current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw
|
||||
)
|
||||
elif setting['key'] == 'PW_WINE_USE':
|
||||
elif setting['key'] in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE'):
|
||||
current_val = _('System WINE') if current_raw == 'USE_SYSTEM_WINE' else current_raw
|
||||
else:
|
||||
current_val = disabled_text if current_raw == 'disabled' else current_raw
|
||||
@@ -781,6 +890,9 @@ class ExeSettingsDialog(
|
||||
else:
|
||||
self.original_display_values[setting['key']] = current_val_text
|
||||
|
||||
if self.user_conf and not current_raw:
|
||||
self.original_display_values[setting['key']] = ''
|
||||
|
||||
if '_value_map' in setting:
|
||||
reverse_map = {v: k for k, v in setting['_value_map'].items()}
|
||||
self.value_mapping[setting['key']] = {
|
||||
@@ -790,7 +902,9 @@ class ExeSettingsDialog(
|
||||
|
||||
elif setting['type'] == 'text':
|
||||
line_edit = QLineEdit()
|
||||
current_val = current.get(setting['key'], setting['default'])
|
||||
current_val = current.get(setting['key'], '') if self.user_conf else (
|
||||
current.get(setting['key'], setting['default'])
|
||||
)
|
||||
line_edit.setText(current_val)
|
||||
if not current_val and not setting['default']:
|
||||
line_edit.setPlaceholderText(_("Default value"))
|
||||
@@ -996,6 +1110,10 @@ class ExeSettingsDialog(
|
||||
source_widget = self._find_toggle_widget(key)
|
||||
checkbox = QCheckBox()
|
||||
checkbox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
if isinstance(source_widget, QComboBox):
|
||||
self.favorites_table.removeCellWidget(row, 1)
|
||||
self._add_favorite_combo(row, source_widget)
|
||||
return
|
||||
checkbox.setChecked(source_widget.isChecked() if source_widget else False)
|
||||
checkbox.setEnabled(source_widget.isEnabled() if source_widget else False)
|
||||
checkbox.stateChanged.connect(
|
||||
@@ -1039,7 +1157,7 @@ class ExeSettingsDialog(
|
||||
self.favorites_table.setItem(row, 0, name_item)
|
||||
self.favorites_table.setItem(row, 2, desc_item)
|
||||
|
||||
def _find_toggle_widget(self, key: str) -> QCheckBox | None:
|
||||
def _find_toggle_widget(self, key: str) -> QCheckBox | QComboBox | None:
|
||||
for (row, _column), widget in self.value_widgets.items():
|
||||
item = self.settings_table.item(row, 0)
|
||||
if item and item.data(Qt.ItemDataRole.UserRole) == key:
|
||||
@@ -1047,8 +1165,8 @@ class ExeSettingsDialog(
|
||||
return None
|
||||
|
||||
def _sync_checkbox(self, source_widget: QCheckBox | None, checkbox: QCheckBox) -> None:
|
||||
if source_widget is not None and source_widget.isChecked() != checkbox.isChecked():
|
||||
source_widget.setChecked(checkbox.isChecked())
|
||||
if source_widget is not None and source_widget.checkState() != checkbox.checkState():
|
||||
source_widget.setCheckState(checkbox.checkState())
|
||||
|
||||
def _add_favorite_combo(self, row: int, source_widget: QComboBox) -> None:
|
||||
combo = CustomComboBox(theme=self.theme)
|
||||
@@ -1226,10 +1344,12 @@ class ExeSettingsDialog(
|
||||
continue
|
||||
|
||||
widget = self.value_widgets.get((row, 1))
|
||||
if not isinstance(widget, QCheckBox):
|
||||
if isinstance(widget, QComboBox) and self.user_conf:
|
||||
new_val = str(widget.currentData() or '')
|
||||
elif isinstance(widget, QCheckBox):
|
||||
new_val = '1' if widget.isChecked() else '0'
|
||||
else:
|
||||
continue
|
||||
|
||||
new_val = '1' if widget.isChecked() else '0'
|
||||
if new_val != orig_val:
|
||||
changes.append(f"{key}={new_val}")
|
||||
# Track if PW_MANGOHUD is being enabled
|
||||
@@ -1240,9 +1360,11 @@ class ExeSettingsDialog(
|
||||
orig_val = self.original_display_values.get(key, '')
|
||||
if isinstance(widget, QComboBox):
|
||||
new_val = widget.currentText()
|
||||
if self.user_conf and new_val == _('Default'):
|
||||
new_val = ''
|
||||
if key in ('PW_PREFIX_NAME', 'PW_VULKAN_USE') and self.game_source == "steam":
|
||||
continue
|
||||
if key == 'PW_PREFIX_NAME':
|
||||
if key in ('PW_PREFIX_NAME', 'PW_DEFAULT_PREFIX_NAME'):
|
||||
new_val = re.sub(r"[ \t]", "_", new_val.strip()).upper()
|
||||
|
||||
if key in self.value_mapping and 'forward' in self.value_mapping[key]:
|
||||
@@ -1253,7 +1375,7 @@ class ExeSettingsDialog(
|
||||
else:
|
||||
has_changed = (new_val != orig_val)
|
||||
|
||||
if key == 'PW_WINE_USE' and new_val == _('System WINE'):
|
||||
if key in ('PW_WINE_USE', 'PW_DEFAULT_WINE_USE') and new_val == _('System WINE'):
|
||||
new_val = 'USE_SYSTEM_WINE'
|
||||
|
||||
if new_val.lower() == _('disabled').lower():
|
||||
@@ -1275,9 +1397,32 @@ class ExeSettingsDialog(
|
||||
gamescope_changes = []
|
||||
if self.gamescope_available:
|
||||
gamescope_changes = self._collect_gamescope_changes()
|
||||
changes.extend(mangohud_changes)
|
||||
changes.extend(self._collect_vkbasalt_changes())
|
||||
changes.extend(gamescope_changes)
|
||||
vkbasalt_changes = self._collect_vkbasalt_changes()
|
||||
if self.user_conf:
|
||||
if self.current_settings.get('PW_MANGOHUD') != '1':
|
||||
mangohud_changes = [
|
||||
change for change in mangohud_changes
|
||||
if change.startswith(('PW_MANGOHUD=', 'PW_MANGOHUD_USER_CONF='))
|
||||
]
|
||||
if self.current_settings.get('PW_VKBASALT') != '1':
|
||||
vkbasalt_changes = [
|
||||
change for change in vkbasalt_changes
|
||||
if change.startswith(('PW_VKBASALT=', 'PW_VKBASALT_USER_CONF='))
|
||||
]
|
||||
if self.current_settings.get('PW_GAMESCOPE') != '1':
|
||||
gamescope_changes = [
|
||||
change for change in gamescope_changes
|
||||
if change.startswith('PW_GAMESCOPE=')
|
||||
]
|
||||
specialized_changes = mangohud_changes + vkbasalt_changes + gamescope_changes
|
||||
if self.user_conf:
|
||||
defaults = ('', '0', '0.00', 'Home')
|
||||
specialized_changes = [
|
||||
change for change in specialized_changes
|
||||
if change.split('=', 1)[0] in self.original_values
|
||||
or change.split('=', 1)[1] not in defaults
|
||||
]
|
||||
changes.extend(specialized_changes)
|
||||
|
||||
# Check if PW_GAMESCOPE toggle changes are already in the list
|
||||
has_gamescope_toggle = any(change.startswith("PW_GAMESCOPE=") for change in gamescope_changes)
|
||||
@@ -1298,6 +1443,13 @@ class ExeSettingsDialog(
|
||||
if not changes:
|
||||
return
|
||||
|
||||
if self.user_conf:
|
||||
self.user_conf_changes = changes
|
||||
self.close_after_user_conf_changes = True
|
||||
self.apply_button.setEnabled(False)
|
||||
self._apply_next_user_conf_change()
|
||||
return
|
||||
|
||||
process = QProcess(self)
|
||||
process.finished.connect(self.on_edit_db_finished)
|
||||
process_args = ["cli", "--edit-db", self.exe_path] + changes
|
||||
@@ -1305,6 +1457,36 @@ class ExeSettingsDialog(
|
||||
process.start(args[0], args[1:])
|
||||
self.apply_button.setEnabled(False)
|
||||
|
||||
def _apply_next_user_conf_change(self) -> None:
|
||||
if not self.user_conf_changes:
|
||||
self.apply_button.setEnabled(True)
|
||||
if self.close_after_user_conf_changes:
|
||||
self.close()
|
||||
else:
|
||||
self.load_current_settings()
|
||||
return
|
||||
|
||||
key, value = self.user_conf_changes.pop(0).split('=', 1)
|
||||
action = "--set-user-conf" if value else "--delete-user-conf"
|
||||
command = ["cli", action, key]
|
||||
if value:
|
||||
command.append(value)
|
||||
self.user_conf_process = QProcess(self)
|
||||
self.user_conf_process.finished.connect(self._on_user_conf_change_finished)
|
||||
args = self._get_process_args(command)
|
||||
self.user_conf_process.start(args[0], args[1:])
|
||||
|
||||
def _on_user_conf_change_finished(self, exit_code, exit_status) -> None:
|
||||
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
|
||||
error_output = bytes(self.user_conf_process.readAllStandardError().data()).decode(
|
||||
'utf-8', 'ignore'
|
||||
)
|
||||
self.apply_button.setEnabled(True)
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
|
||||
logger.error("Failed to update user.conf: %s", error_output)
|
||||
return
|
||||
self._apply_next_user_conf_change()
|
||||
|
||||
def on_edit_db_finished(self, exit_code, exit_status):
|
||||
"""Handle --edit-db output."""
|
||||
process = cast(QProcess, self.sender())
|
||||
|
||||
@@ -1027,6 +1027,8 @@ class GamescopeSettingsMixin:
|
||||
|
||||
def _filter_gamescope_settings(self, search_text):
|
||||
"""Filter Gamescope groups based on search text."""
|
||||
if not self.gamescope_category_groups:
|
||||
return
|
||||
gamescope_enabled = self.current_settings.get('PW_GAMESCOPE') == '1'
|
||||
for checkbox in self.gamescope_toggle_widgets.values():
|
||||
layout = checkbox.parentWidget().layout()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import configparser
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any, cast
|
||||
|
||||
@@ -151,8 +152,14 @@ MANGOHUD_VALUE_SPECS = [
|
||||
'options': [f'{i / 10:.1f}' for i in range(11)]},
|
||||
{'key': 'round_corners', 'label': _("Round corners (px)"), 'type': 'combo',
|
||||
'options': [str(i) for i in range(16)]},
|
||||
{'key': 'font_scale', 'label': _("Font scale"), 'type': 'combo',
|
||||
'options': ['', '0.5', '0.75', '1.0', '1.25', '1.5', '2.0']},
|
||||
{'key': 'font_size', 'label': _("Font size"), 'type': 'line'},
|
||||
{'key': 'font_file', 'label': _("Font"), 'type': 'combo', 'options': []},
|
||||
]
|
||||
|
||||
MANGOHUD_FONT_KEYS = {'font_scale', 'font_size', 'font_file'}
|
||||
|
||||
MANGOHUD_VALUE_OPTION_TRANSLATIONS = {
|
||||
'position': {
|
||||
'top-left': _("Top-left"),
|
||||
@@ -391,6 +398,7 @@ class MangoHudSettingsMixin:
|
||||
self.mangohud_presets_group = None
|
||||
self.mangohud_toggle_group = None
|
||||
self.mangohud_values_group = None
|
||||
self.mangohud_fonts_group = None
|
||||
self.mangohud_fps_group = None
|
||||
self.mangohud_extra_group = None
|
||||
self.mangohud_action_buttons = []
|
||||
@@ -412,6 +420,7 @@ class MangoHudSettingsMixin:
|
||||
self._add_mangohud_presets_group(layout)
|
||||
self._add_mangohud_toggle_group(layout)
|
||||
self._add_mangohud_values_group(layout)
|
||||
self._add_mangohud_fonts_group(layout)
|
||||
self._add_mangohud_fps_group(layout)
|
||||
self._add_mangohud_extra_group(layout)
|
||||
layout.addStretch()
|
||||
@@ -433,6 +442,8 @@ class MangoHudSettingsMixin:
|
||||
gpu_count = sum(1 for _text, value in self.mangohud_gpu_options if value.isdigit())
|
||||
|
||||
for spec in MANGOHUD_VALUE_SPECS:
|
||||
if spec['key'] in MANGOHUD_FONT_KEYS:
|
||||
continue
|
||||
if spec['key'] == 'fps_limit_method':
|
||||
continue
|
||||
if spec['key'] == 'gpu_list' and gpu_count < 2:
|
||||
@@ -441,8 +452,32 @@ class MangoHudSettingsMixin:
|
||||
|
||||
parent_layout.addWidget(group)
|
||||
|
||||
def _add_mangohud_fonts_group(self, parent_layout):
|
||||
"""Add MangoHud font controls."""
|
||||
group = QGroupBox(_("Fonts"))
|
||||
group.setStyleSheet(self.theme.QGROUP_BOX_STYLE)
|
||||
form = QFormLayout(group)
|
||||
form.setVerticalSpacing(self.theme.exeSettingsGroupBoxElementVerticalSpacing)
|
||||
form.setHorizontalSpacing(self.theme.exeSettingsGroupBoxElementHorizontalSpacing)
|
||||
form.setFieldGrowthPolicy(QFormLayout.FieldGrowthPolicy.ExpandingFieldsGrow)
|
||||
self.mangohud_fonts_group = group
|
||||
|
||||
for spec in MANGOHUD_VALUE_SPECS:
|
||||
if spec['key'] in MANGOHUD_FONT_KEYS:
|
||||
form.addRow(spec['label'], self._create_mangohud_value_widget(spec))
|
||||
|
||||
parent_layout.addWidget(group)
|
||||
|
||||
def _create_mangohud_value_widget(self, spec):
|
||||
"""Create a MangoHud value widget."""
|
||||
if spec['type'] == 'line':
|
||||
widget = QLineEdit()
|
||||
widget.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
widget.installEventFilter(cast(QWidget, self))
|
||||
widget.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||
self.mangohud_widgets[spec['key']] = widget
|
||||
return widget
|
||||
|
||||
widget = CustomComboBox(theme=self.theme)
|
||||
widget.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
@@ -458,6 +493,10 @@ class MangoHudSettingsMixin:
|
||||
gpu_options = self.mangohud_gpu_options or self._get_mangohud_gpu_options()
|
||||
for text, value in gpu_options:
|
||||
widget.addItem(text, value)
|
||||
elif spec['key'] == 'font_file':
|
||||
widget.addItem(placeholder_text, '')
|
||||
for text, value in self._get_mangohud_font_options():
|
||||
widget.addItem(text, value)
|
||||
else:
|
||||
value_translations = MANGOHUD_VALUE_OPTION_TRANSLATIONS.get(spec['key'], {})
|
||||
for option in options:
|
||||
@@ -475,6 +514,30 @@ class MangoHudSettingsMixin:
|
||||
self.mangohud_widgets[spec['key']] = widget
|
||||
return widget
|
||||
|
||||
def _get_mangohud_font_options(self) -> list[tuple[str, str]]:
|
||||
"""Get installed font names and files from fontconfig."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['fc-list', '--format=%{family[0]} %{style[0]}\t%{file}\n'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
timeout=3,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("Failed to list system fonts: %s", exc)
|
||||
return []
|
||||
if result.returncode != 0:
|
||||
logger.warning("fc-list failed with exit code %s", result.returncode)
|
||||
return []
|
||||
|
||||
fonts = set()
|
||||
for line in result.stdout.splitlines():
|
||||
name, separator, path = line.partition('\t')
|
||||
if separator and path.lower().endswith(('.ttf', '.otf')):
|
||||
fonts.add((name.strip(), path.strip()))
|
||||
return sorted(fonts, key=lambda font: font[0].lower())
|
||||
|
||||
def _add_mangohud_presets_group(self, parent_layout):
|
||||
"""Add preset buttons for common MangoHud layouts."""
|
||||
presets_group = QGroupBox(_("Presets"))
|
||||
@@ -866,6 +929,8 @@ class MangoHudSettingsMixin:
|
||||
self.mangohud_presets_group.setVisible(config_visible)
|
||||
if self.mangohud_values_group is not None:
|
||||
self.mangohud_values_group.setVisible(config_visible)
|
||||
if self.mangohud_fonts_group is not None:
|
||||
self.mangohud_fonts_group.setVisible(config_visible)
|
||||
if self.mangohud_fps_group is not None:
|
||||
self.mangohud_fps_group.setVisible(config_visible)
|
||||
if self.mangohud_extra_group is not None:
|
||||
@@ -1065,6 +1130,9 @@ class MangoHudSettingsMixin:
|
||||
if widget is None:
|
||||
return
|
||||
text = value if isinstance(value, str) else ''
|
||||
if isinstance(widget, QLineEdit):
|
||||
widget.setText(text)
|
||||
return
|
||||
index = widget.findData(text)
|
||||
if text and index < 0:
|
||||
widget.addItem(text, text)
|
||||
@@ -1179,10 +1247,13 @@ class MangoHudSettingsMixin:
|
||||
if widget is None:
|
||||
return ''
|
||||
|
||||
current_data = widget.currentData()
|
||||
value = '' if current_data is None else str(current_data).strip()
|
||||
if not value and current_data is None:
|
||||
value = widget.currentText().strip()
|
||||
if isinstance(widget, QLineEdit):
|
||||
value = widget.text().strip()
|
||||
else:
|
||||
current_data = widget.currentData()
|
||||
value = '' if current_data is None else str(current_data).strip()
|
||||
if not value and current_data is None:
|
||||
value = widget.currentText().strip()
|
||||
if not value:
|
||||
return ''
|
||||
parsed_original, _raw_tokens = self._parse_mangohud_config(
|
||||
@@ -1232,6 +1303,8 @@ class MangoHudSettingsMixin:
|
||||
|
||||
def _filter_mangohud_settings(self, search_text):
|
||||
"""Filter MangoHud groups based on search text."""
|
||||
if not self.mangohud_category_groups:
|
||||
return
|
||||
config_visible = self.current_settings.get('PW_MANGOHUD') == '1' \
|
||||
and self.current_settings.get('PW_MANGOHUD_USER_CONF') != '1'
|
||||
for checkbox in self.mangohud_toggle_widgets.values():
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
"""Epic Games Store integration through Legendary."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
|
||||
from portprotonqt.localization import get_store_content_languages
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
EGS_LOGIN_URL = "https://legendary.gl/epiclogin"
|
||||
LEGENDARY_RELEASE_URL = (
|
||||
"https://api.github.com/repos/Heroic-Games-Launcher/legendary/releases/latest"
|
||||
)
|
||||
LEGENDARY_TIMEOUT = 120
|
||||
EGS_METADATA_WORKERS = 4
|
||||
EGS_METADATA_TIMEOUT = 15
|
||||
EGS_DESCRIPTION_CACHE_VERSION = 2
|
||||
LEGENDARY_UPDATE_INTERVAL = 30 * 24 * 60 * 60
|
||||
EGS_API_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 EpicGamesLauncher"
|
||||
)
|
||||
|
||||
|
||||
class EGSAPI:
|
||||
"""Provide EGS authentication, library and installation helpers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
self.data_dir = data_home / "PortProtonQt" / "launcher" / "egs"
|
||||
self.bin_dir = data_home / "PortProtonQt" / "bin"
|
||||
# Remove after the legacy launcher storage migration period.
|
||||
legacy_dir = data_home / "PortProtonQt" / "egs"
|
||||
if legacy_dir.exists() and not self.data_dir.exists():
|
||||
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy_dir.rename(self.data_dir)
|
||||
legacy_bin = self.data_dir / "bin"
|
||||
if legacy_bin.is_dir():
|
||||
self.bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
for legacy_binary in legacy_bin.iterdir():
|
||||
target = self.bin_dir / legacy_binary.name
|
||||
if not target.exists():
|
||||
legacy_binary.rename(target)
|
||||
if not any(legacy_bin.iterdir()):
|
||||
legacy_bin.rmdir()
|
||||
self.config_dir = self.data_dir / "legendary"
|
||||
self.user_path = self.config_dir / "user.json"
|
||||
self.library_path = self.data_dir / "library.json"
|
||||
self.sizes_path = self.data_dir / "sizes.json"
|
||||
self.version_path = self.bin_dir / "legendary.version"
|
||||
self.games_dir = Path.home() / "Games"
|
||||
|
||||
def get_legendary_path(self) -> str | None:
|
||||
"""Return an available Legendary executable."""
|
||||
bundled = self.bin_dir / "legendary"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
return str(bundled)
|
||||
return shutil.which("legendary")
|
||||
|
||||
def ensure_legendary(self) -> str:
|
||||
"""Download the current official Heroic Legendary binary if needed."""
|
||||
existing = self.get_legendary_path()
|
||||
if existing:
|
||||
return existing
|
||||
asset, release_tag = self._get_latest_legendary_release()
|
||||
return self._download_legendary(asset, release_tag)
|
||||
|
||||
def update_legendary(self) -> str:
|
||||
"""Update the bundled Legendary binary when a newer release exists."""
|
||||
bundled = self.bin_dir / "legendary"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
try:
|
||||
last_check = self.version_path.stat().st_mtime
|
||||
except OSError:
|
||||
last_check = 0
|
||||
if time.time() - last_check < LEGENDARY_UPDATE_INTERVAL:
|
||||
return str(bundled)
|
||||
asset, release_tag = self._get_latest_legendary_release()
|
||||
if (
|
||||
bundled.is_file()
|
||||
and self._get_legendary_release_tag() == release_tag
|
||||
):
|
||||
self.version_path.touch()
|
||||
return str(bundled)
|
||||
return self._download_legendary(asset, release_tag)
|
||||
|
||||
def _get_latest_legendary_release(self) -> tuple[dict, str]:
|
||||
"""Return the matching Legendary asset and release tag."""
|
||||
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(
|
||||
platform.machine()
|
||||
)
|
||||
if not architecture:
|
||||
raise OSError(f"Unsupported Legendary architecture: {platform.machine()}")
|
||||
response = requests.get(LEGENDARY_RELEASE_URL, timeout=15)
|
||||
response.raise_for_status()
|
||||
release = response.json()
|
||||
asset_name = f"legendary_linux_{architecture}"
|
||||
asset = next(
|
||||
(item for item in release.get("assets", []) if item.get("name") == asset_name),
|
||||
None,
|
||||
)
|
||||
if not asset:
|
||||
raise OSError(f"Legendary release asset not found: {asset_name}")
|
||||
release_tag = str(release.get("tag_name", ""))
|
||||
if not release_tag:
|
||||
raise OSError("Legendary release tag not found")
|
||||
return asset, release_tag
|
||||
|
||||
def _get_legendary_release_tag(self) -> str:
|
||||
try:
|
||||
return self.version_path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def _download_legendary(self, asset: dict, release_tag: str) -> str:
|
||||
target = self.bin_dir / "legendary"
|
||||
temporary = target.with_suffix(".part")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with requests.get(asset["browser_download_url"], stream=True, timeout=30) as response:
|
||||
response.raise_for_status()
|
||||
with open(temporary, "wb") as output:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
expected = str(asset.get("digest", "")).removeprefix("sha256:")
|
||||
if not expected or digest.hexdigest() != expected:
|
||||
raise OSError("Legendary checksum verification failed")
|
||||
temporary.chmod(0o755)
|
||||
temporary.replace(target)
|
||||
self.version_path.write_text(release_tag, encoding="utf-8")
|
||||
return str(target)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def build_command(self, arguments: list[str]) -> list[str]:
|
||||
"""Build a Legendary command using private configuration paths."""
|
||||
executable = self.get_legendary_path()
|
||||
if not executable:
|
||||
raise FileNotFoundError("Legendary executable not found")
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.config_dir.chmod(0o700)
|
||||
command_arguments = list(arguments)
|
||||
if "-y" in command_arguments:
|
||||
command_arguments.remove("-y")
|
||||
command_arguments.insert(0, "-y")
|
||||
return [executable, *command_arguments]
|
||||
|
||||
def get_environment(self) -> dict[str, str]:
|
||||
"""Return the environment required by Legendary."""
|
||||
environment = os.environ.copy()
|
||||
environment["LEGENDARY_CONFIG_PATH"] = str(self.config_dir)
|
||||
return environment
|
||||
|
||||
def authenticate(self, code: str) -> tuple[bool, str]:
|
||||
"""Authenticate an Epic account with an authorization code."""
|
||||
self.ensure_legendary()
|
||||
result = subprocess.run(
|
||||
self.build_command(["auth", "--code", code]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
if result.returncode == 0 and self.user_path.is_file():
|
||||
self.user_path.chmod(0o600)
|
||||
return True, ""
|
||||
error = result.stderr.decode(errors="replace").strip()
|
||||
logger.error("Legendary authentication failed: %s", error)
|
||||
return False, error or f"Legendary exited with code {result.returncode}"
|
||||
|
||||
@staticmethod
|
||||
def extract_auth_code(text: str) -> str:
|
||||
"""Extract an Epic authorization code from copied text or JSON."""
|
||||
value = text.strip()
|
||||
try:
|
||||
data = orjson.loads(value)
|
||||
except orjson.JSONDecodeError:
|
||||
return value if "://" not in value else ""
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
return str(data.get("authorizationCode", "")).strip()
|
||||
|
||||
def logout(self) -> bool:
|
||||
"""Remove the Epic login and cached library."""
|
||||
try:
|
||||
if self.get_legendary_path() and self.user_path.is_file():
|
||||
subprocess.run(
|
||||
self.build_command(["auth", "--delete"]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
self.user_path.unlink(missing_ok=True)
|
||||
self.library_path.unlink(missing_ok=True)
|
||||
return True
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
logger.error("Failed to disconnect Epic account: %s", error)
|
||||
return False
|
||||
|
||||
def get_account_name(self) -> str:
|
||||
"""Return the Epic display name from Legendary state."""
|
||||
user = self._load_json(self.user_path, {})
|
||||
return str(user.get("displayName", "")) if isinstance(user, dict) else ""
|
||||
|
||||
def refresh_library(
|
||||
self, progress_callback: Callable[[int, int], None] | None = None
|
||||
) -> list[dict]:
|
||||
"""Refresh and cache the Epic library through Legendary."""
|
||||
self.update_legendary()
|
||||
result = subprocess.run(
|
||||
self.build_command(["list", "--json", "--third-party"]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.decode(errors="replace").strip()
|
||||
raise OSError(error or "Failed to refresh Epic library")
|
||||
raw_games = orjson.loads(result.stdout)
|
||||
if not isinstance(raw_games, list):
|
||||
raise OSError("Legendary returned an invalid Epic library")
|
||||
games = []
|
||||
for game in raw_games:
|
||||
metadata = game.get("metadata", {})
|
||||
if metadata.get("mainGameItem"):
|
||||
continue
|
||||
release_info = metadata.get("releaseInfo", [])
|
||||
mobile_only = bool(release_info) and all(
|
||||
info.get("platform")
|
||||
and all(platform in {"Android", "iOS"}
|
||||
for platform in info["platform"])
|
||||
for info in release_info
|
||||
)
|
||||
if not mobile_only:
|
||||
games.append(self._normalize_game(game))
|
||||
games = [game for game in games if game.get("app_id")]
|
||||
self._enrich_descriptions(games, progress_callback)
|
||||
self._save_json(self.library_path, games)
|
||||
return games
|
||||
|
||||
def load_library(self) -> list[dict]:
|
||||
"""Load the cached Epic library."""
|
||||
data = self._load_json(self.library_path, [])
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def clear_library_cache(self) -> None:
|
||||
"""Remove cached Epic library metadata."""
|
||||
try:
|
||||
self.library_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to clear Epic library cache: %s", error)
|
||||
|
||||
def load_installed(self) -> dict[str, dict]:
|
||||
"""Load Legendary installed game records."""
|
||||
data = self._load_json(self.config_dir / "installed.json", {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def is_game_installed(self, app_id: str) -> bool:
|
||||
"""Return whether Legendary records a usable installation."""
|
||||
return self.get_launch_target(app_id) is not None
|
||||
|
||||
@staticmethod
|
||||
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
|
||||
"""Return whether the EOS overlay is enabled in a Wine prefix."""
|
||||
try:
|
||||
registry = (prefix_path / "user.reg").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
for section in registry.casefold().split("\n["):
|
||||
if section.startswith(r"software\\epic games\\eos]"):
|
||||
if '"overlaypath"=' in section:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
"""Return the installed game's Windows executable."""
|
||||
installed = self.load_installed().get(app_id, {})
|
||||
install_path = Path(str(installed.get("install_path", "")))
|
||||
executable = str(installed.get("executable", "")).replace("\\", "/")
|
||||
target = install_path.joinpath(*executable.split("/"))
|
||||
return str(target) if executable and target.is_file() else None
|
||||
|
||||
def get_cached_download_sizes(self, app_id: str) -> tuple[int, int] | None:
|
||||
"""Return cached EGS download and disk sizes."""
|
||||
cache = self._load_json(self.sizes_path, {})
|
||||
value = cache.get(app_id) if isinstance(cache, dict) else None
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return None
|
||||
sizes = int(value[0]), int(value[1])
|
||||
return None if sizes[0] > 0 and sizes[1] <= 0 else sizes
|
||||
|
||||
def save_download_sizes(self, app_id: str, sizes: tuple[int, int]) -> None:
|
||||
"""Cache EGS download and disk sizes."""
|
||||
cache = self._load_json(self.sizes_path, {})
|
||||
if not isinstance(cache, dict):
|
||||
cache = {}
|
||||
cache[app_id] = list(sizes)
|
||||
self._save_json(self.sizes_path, cache)
|
||||
|
||||
@staticmethod
|
||||
def parse_download_sizes(output: bytes) -> tuple[int, int]:
|
||||
"""Extract download and install sizes from Legendary JSON."""
|
||||
data = orjson.loads(output)
|
||||
values: dict[str, int] = {}
|
||||
|
||||
def visit(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if key in {"download_size", "install_size", "disk_size"}:
|
||||
try:
|
||||
values[key] = max(values.get(key, 0), int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
visit(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
visit(item)
|
||||
|
||||
visit(data)
|
||||
install_size = values.get("install_size") or values.get("disk_size", 0)
|
||||
return values.get("download_size", 0), install_size
|
||||
|
||||
def _enrich_descriptions(
|
||||
self, games: list[dict],
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
) -> None:
|
||||
cached = {str(game.get("app_id")): game for game in self.load_library()}
|
||||
pending = []
|
||||
completed = 0
|
||||
total = len(games)
|
||||
preferred_locale = get_store_content_languages()[0]
|
||||
for game in games:
|
||||
old_game = cached.get(game["app_id"], {})
|
||||
old_description = str(old_game.get("description", ""))
|
||||
if (
|
||||
old_description
|
||||
and old_description != game["title"]
|
||||
and old_game.get("description_preference") == preferred_locale
|
||||
and old_game.get("description_cache_version")
|
||||
== EGS_DESCRIPTION_CACHE_VERSION
|
||||
):
|
||||
game["description"] = old_description
|
||||
game["description_locale"] = str(old_game.get("description_locale", ""))
|
||||
game["description_preference"] = preferred_locale
|
||||
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
|
||||
completed += 1
|
||||
if progress_callback:
|
||||
progress_callback(completed, total)
|
||||
else:
|
||||
pending.append(game)
|
||||
if not pending:
|
||||
return
|
||||
worker_count = min(EGS_METADATA_WORKERS, os.cpu_count() or 1)
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||
descriptions = executor.map(self._get_store_description, pending)
|
||||
for game, result in zip(pending, descriptions, strict=True):
|
||||
description, description_locale = result
|
||||
if description:
|
||||
game["description"] = description
|
||||
game["description_locale"] = description_locale
|
||||
game["description_preference"] = preferred_locale
|
||||
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
|
||||
completed += 1
|
||||
if progress_callback:
|
||||
progress_callback(completed, total)
|
||||
|
||||
def _get_store_description(self, game: dict) -> tuple[str, str]:
|
||||
slug = self._get_product_slug(str(game.get("namespace", "")), game["title"])
|
||||
for language in get_store_content_languages():
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://store-content.ak.epicgames.com/api/"
|
||||
f"{language}/content/products/{slug}",
|
||||
headers={"User-Agent": EGS_API_USER_AGENT},
|
||||
timeout=EGS_METADATA_TIMEOUT,
|
||||
)
|
||||
if response.status_code == requests.codes.not_found:
|
||||
continue
|
||||
response.raise_for_status()
|
||||
description = self._parse_store_description(response.json())
|
||||
if description:
|
||||
return description, language
|
||||
except requests.RequestException as error:
|
||||
logger.debug(
|
||||
"Epic description unavailable for %s: %s", game["app_id"], error
|
||||
)
|
||||
return "", ""
|
||||
return "", ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_store_description(data: Any) -> str:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("pages"), list):
|
||||
return ""
|
||||
page = next(
|
||||
(item for item in data["pages"]
|
||||
if isinstance(item, dict) and item.get("type") == "productHome"),
|
||||
{},
|
||||
)
|
||||
about = page.get("data", {}).get("about", {})
|
||||
short_description = str(about.get("shortDescription") or "").strip()
|
||||
if short_description:
|
||||
return short_description
|
||||
description = str(about.get("description") or "").strip()
|
||||
return description.split("\n\n", 1)[0].strip()
|
||||
|
||||
def _get_product_slug(self, namespace: str, title: str) -> str:
|
||||
fallback = "-".join("".join(
|
||||
character if character.isascii() and character.isalnum() else " "
|
||||
for character in title.lower()
|
||||
).split())
|
||||
if not namespace:
|
||||
return fallback
|
||||
query = {
|
||||
"query": "query ProductSlug($namespace: String!) { Catalog { "
|
||||
"catalogNs(namespace: $namespace) { mappings(pageType: "
|
||||
"\"productHome\") { pageSlug pageType } } } }",
|
||||
"variables": {"namespace": namespace},
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://launcher.store.epicgames.com/graphql",
|
||||
json=query,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": EGS_API_USER_AGENT,
|
||||
},
|
||||
timeout=EGS_METADATA_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
mappings = response.json()["data"]["Catalog"]["catalogNs"]["mappings"]
|
||||
return str(mappings[0].get("pageSlug") or fallback) if mappings else fallback
|
||||
except (KeyError, IndexError, TypeError, requests.RequestException) as error:
|
||||
logger.debug("Epic product slug unavailable for %s: %s", namespace, error)
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _normalize_game(game: dict) -> dict:
|
||||
metadata = game.get("metadata", {})
|
||||
images = metadata.get("keyImages", []) if isinstance(metadata, dict) else []
|
||||
cover = next(
|
||||
(str(image.get("url", "")) for image in images
|
||||
if image.get("type") in ("DieselGameBoxTall", "OfferImageTall")),
|
||||
"",
|
||||
)
|
||||
return {
|
||||
"app_id": str(game.get("app_name") or game.get("appName") or ""),
|
||||
"title": str(game.get("app_title") or metadata.get("title") or ""),
|
||||
"description": str(metadata.get("description", "")),
|
||||
"cover": cover,
|
||||
"namespace": str(metadata.get("namespace", "")),
|
||||
"folder_name": str(metadata.get("customAttributes", {}).get(
|
||||
"FolderName", {}
|
||||
).get("value") or game.get("app_name") or game.get("appName") or ""),
|
||||
"description_locale": "",
|
||||
"description_preference": "",
|
||||
"description_cache_version": 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _load_json(path: Path, default: Any) -> Any:
|
||||
try:
|
||||
return orjson.loads(path.read_bytes())
|
||||
except (OSError, orjson.JSONDecodeError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _save_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(orjson.dumps(data, option=orjson.OPT_INDENT_2))
|
||||
+358
-130
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtGui import (
|
||||
QPainter,
|
||||
@@ -12,8 +13,12 @@ from PySide6.QtGui import (
|
||||
QIcon,
|
||||
QPen,
|
||||
QLinearGradient,
|
||||
QEnterEvent,
|
||||
QFocusEvent,
|
||||
QMouseEvent,
|
||||
QPaintEvent,
|
||||
)
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -24,6 +29,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QStackedLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
)
|
||||
from portprotonqt.image_utils import (
|
||||
cleanup_animated_cover,
|
||||
@@ -199,11 +205,123 @@ class SourceCorner(QWidget):
|
||||
self._visible = visible
|
||||
super().setVisible(visible)
|
||||
|
||||
def refresh_source_theme(self, config: dict, icon: str | QIcon | None) -> None:
|
||||
"""Refresh source-corner visuals for a live theme change."""
|
||||
self._cfg = config
|
||||
self._icon = icon
|
||||
self._color = QColor(config.get("ribbon_color", "#3f424d"))
|
||||
self._fold_color = QColor(config.get("ribbon_fold_color", "#00000096"))
|
||||
self.update()
|
||||
|
||||
class GameCard(QFrame):
|
||||
|
||||
class AnimatedCard(QFrame):
|
||||
animation_base_size: tuple[int, int]
|
||||
borderWidthChanged = Signal()
|
||||
gradientAngleChanged = Signal()
|
||||
scaleChanged = Signal()
|
||||
hoverChanged = Signal(str, bool)
|
||||
focusChanged = Signal(str, bool)
|
||||
clicked = Signal()
|
||||
|
||||
def setup_card_animations(self, theme: Any, layout_config: dict) -> None:
|
||||
self.theme = theme
|
||||
self.card_layout_cfg = layout_config
|
||||
self._borderWidth = layout_config.get(
|
||||
"default_border_width", theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||
)
|
||||
self._gradientAngle = layout_config.get(
|
||||
"gradient_start_angle", theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||
)
|
||||
self._scale = layout_config.get(
|
||||
"default_scale", theme.GAME_CARD_ANIMATION["default_scale"]
|
||||
)
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
self._hover_sound_pending = False
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setMouseTracking(True)
|
||||
self.animations = GameCardAnimations(self, theme)
|
||||
self.animations.setup_animations()
|
||||
|
||||
def update_scale(self) -> None:
|
||||
width, height = self.animation_base_size
|
||||
self.setFixedSize(int(width * self._scale), int(height * self._scale))
|
||||
|
||||
def getBorderWidth(self) -> int:
|
||||
return self._borderWidth
|
||||
|
||||
def setBorderWidth(self, value: int) -> None:
|
||||
self._borderWidth = value
|
||||
self.borderWidthChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getGradientAngle(self) -> float:
|
||||
return self._gradientAngle
|
||||
|
||||
def setGradientAngle(self, value: float) -> None:
|
||||
self._gradientAngle = value
|
||||
self.gradientAngleChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getScale(self) -> float:
|
||||
return self._scale
|
||||
|
||||
def setScale(self, value: float) -> None:
|
||||
self._scale = value
|
||||
self.update_scale()
|
||||
self.scaleChanged.emit()
|
||||
|
||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, notify=borderWidthChanged)
|
||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, notify=gradientAngleChanged)
|
||||
scale = Property(float, getScale, setScale, notify=scaleChanged)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
super().paintEvent(event)
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.raise_()
|
||||
self._hover_sound_pending = True
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
self._hover_sound_pending = False
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
||||
if self._hover_sound_pending:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self._hover_sound_pending = False
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def focusInEvent(self, event: QFocusEvent) -> None:
|
||||
self.raise_()
|
||||
if QApplication.activeWindow() is not None and event.reason() not in (
|
||||
Qt.FocusReason.ActiveWindowFocusReason,
|
||||
Qt.FocusReason.MouseFocusReason,
|
||||
):
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event: QFocusEvent) -> None:
|
||||
self.animations.handle_focus_out_event()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
self.clicked.emit()
|
||||
|
||||
|
||||
class GameCard(AnimatedCard):
|
||||
editShortcutRequested = Signal(str, str, str)
|
||||
deleteGameRequested = Signal(str, str)
|
||||
addToMenuRequested = Signal(str, str)
|
||||
@@ -213,8 +331,6 @@ class GameCard(QFrame):
|
||||
addToSteamRequested = Signal(str, str, str)
|
||||
removeFromSteamRequested = Signal(str, str)
|
||||
openGameFolderRequested = Signal(str, str)
|
||||
hoverChanged = Signal(str, bool)
|
||||
focusChanged = Signal(str, bool)
|
||||
|
||||
def __init__(
|
||||
self, name, description, cover_path, appid, controller_support, exec_line,
|
||||
@@ -257,33 +373,47 @@ class GameCard(QFrame):
|
||||
self.display_filter = game_config.get_display_filter()
|
||||
self.badge_view_mode = ui_config.get_badge_view_mode()
|
||||
self.current_theme_name = ui_config.get_theme()
|
||||
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.list_layout = self.layout_mode == "list"
|
||||
parent_mode = parent.property("library_layout_mode") if parent is not None else None
|
||||
self.layout_mode = str(
|
||||
parent_mode or getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
).lower()
|
||||
self.list_layout = self.layout_mode in {"list", "vertical"}
|
||||
self.vertical_layout = self.layout_mode == "vertical"
|
||||
self.horizontal_layout = self.layout_mode in {"horizontal", "horizontal_top"}
|
||||
self.economy_mode = ui_config.get_economy_mode()
|
||||
self.missing_executable_path = self._get_missing_executable_path()
|
||||
|
||||
self.steam_visible = str(game_source).lower() == "steam"
|
||||
self.gog_visible = str(game_source).lower() == "gog"
|
||||
self.egs_visible = str(game_source).lower() == "egs"
|
||||
self.portproton_visible = str(game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
config_name = "GAME_CARD_LIST" if self.list_layout else "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = getattr(self.theme, config_name, {})
|
||||
if self.vertical_layout:
|
||||
config_name = "GAME_CARD_VERTICAL"
|
||||
elif self.list_layout:
|
||||
config_name = "GAME_CARD_LIST"
|
||||
elif self.horizontal_layout:
|
||||
config_name = "GAME_CARD_HORIZONTAL"
|
||||
else:
|
||||
config_name = "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = self._get_layout_config(self.theme, config_name)
|
||||
self.card_geometry_cfg = self._get_card_geometry_config(self.theme)
|
||||
default_margin = 8 if self.list_layout else 20
|
||||
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
||||
|
||||
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
self._hover_sound_pending = False
|
||||
self.setMouseTracking(True)
|
||||
|
||||
self.animations = GameCardAnimations(self, self.theme)
|
||||
self.animations.setup_animations()
|
||||
card_style_name = (
|
||||
"GAME_CARD_VERTICAL_STYLE"
|
||||
if self.vertical_layout
|
||||
else "GAME_CARD_WINDOW_STYLE"
|
||||
)
|
||||
self.setProperty("theme_style_name", card_style_name)
|
||||
card_style = (
|
||||
self.theme.GAME_CARD_VERTICAL_STYLE
|
||||
if self.vertical_layout
|
||||
else self.theme.GAME_CARD_WINDOW_STYLE
|
||||
)
|
||||
self.setStyleSheet(card_style)
|
||||
self.setup_card_animations(self.theme, self.card_layout_cfg)
|
||||
|
||||
self.shadow = QGraphicsDropShadowEffect(self)
|
||||
self.shadow.setBlurRadius(self.theme.shadow_blur_radius)
|
||||
@@ -301,6 +431,7 @@ class GameCard(QFrame):
|
||||
|
||||
self.coverWidget = QWidget()
|
||||
if self.list_layout:
|
||||
self.coverWidget.setProperty("theme_style_name", "COVER_WIDGET_STYLE")
|
||||
self.coverWidget.setStyleSheet(self.theme.COVER_WIDGET_STYLE)
|
||||
coverLayout = QStackedLayout(self.coverWidget)
|
||||
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -310,8 +441,8 @@ class GameCard(QFrame):
|
||||
self.coverLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
|
||||
if self.missing_executable_path or (
|
||||
str(self.game_source).lower() == "gog"
|
||||
and self.exec_line.startswith("gog://install/")
|
||||
str(self.game_source).lower() in ("gog", "egs")
|
||||
and self.exec_line.startswith(("gog://install/", "egs://install/"))
|
||||
):
|
||||
self.coverOpacity = QGraphicsOpacityEffect(self.coverLabel)
|
||||
self.coverOpacity.setOpacity(self.theme.missing_exe_cover_opacity)
|
||||
@@ -349,7 +480,7 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.steamLabel.setVisible(self.steam_visible and not self.vertical_layout)
|
||||
|
||||
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
|
||||
self.gogLabel = SourceCorner(
|
||||
@@ -357,7 +488,15 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible and not self.vertical_layout)
|
||||
|
||||
egs_icon = self.theme_manager.get_icon("badge_egs", as_path=True)
|
||||
self.egsLabel = SourceCorner(
|
||||
icon=egs_icon,
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
@@ -365,7 +504,9 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and not self.vertical_layout
|
||||
)
|
||||
|
||||
if self.ppdb_visible:
|
||||
self.ppdbLabel = ClickableLabel(
|
||||
@@ -411,9 +552,36 @@ class GameCard(QFrame):
|
||||
self.nameLabel.setContentsMargins(0, 0, 10, 0)
|
||||
else:
|
||||
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||
self.layout_.addWidget(self.nameLabel)
|
||||
if self.list_layout:
|
||||
if self.horizontal_layout:
|
||||
self.nameLabel.setWordWrap(True)
|
||||
name_style = (
|
||||
self.theme.GAME_CARD_VERTICAL_NAME_STYLE
|
||||
if self.vertical_layout
|
||||
else self.theme.GAME_CARD_NAME_LABEL_STYLE
|
||||
)
|
||||
self.nameLabel.setStyleSheet(name_style)
|
||||
if self.vertical_layout:
|
||||
self.nameLabel.setProperty(
|
||||
"theme_style_name", "GAME_CARD_VERTICAL_NAME_STYLE"
|
||||
)
|
||||
if self.vertical_layout:
|
||||
stretches = self.theme.GAME_CARD_VERTICAL["column_stretches"]
|
||||
self.layout_.addWidget(self.nameLabel, stretches[0])
|
||||
for text, stretch in zip(
|
||||
(self.last_launch, self.formatted_playtime, str(self.game_source)),
|
||||
stretches[1:],
|
||||
strict=True,
|
||||
):
|
||||
label = QLabel(text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
label.setProperty(
|
||||
"theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE"
|
||||
)
|
||||
label.setStyleSheet(self.theme.GAME_CARD_COLUMN_LABEL_STYLE)
|
||||
self.layout_.addWidget(label, stretch)
|
||||
else:
|
||||
self.layout_.addWidget(self.nameLabel)
|
||||
if self.list_layout and not self.vertical_layout:
|
||||
self.layout_.addStretch()
|
||||
|
||||
font_size = self.nameLabel.font().pointSizeF()
|
||||
@@ -430,6 +598,114 @@ class GameCard(QFrame):
|
||||
layout.invalidate()
|
||||
parent.updateGeometry()
|
||||
|
||||
def refresh_theme(self, theme: Any) -> None:
|
||||
"""Refresh cached card visuals after a live theme change."""
|
||||
old_theme = self.theme
|
||||
old_layout_cfg = self.card_layout_cfg
|
||||
self.theme = theme
|
||||
if self.vertical_layout:
|
||||
config_name = "GAME_CARD_VERTICAL"
|
||||
elif self.list_layout:
|
||||
config_name = "GAME_CARD_LIST"
|
||||
elif self.horizontal_layout:
|
||||
config_name = "GAME_CARD_HORIZONTAL"
|
||||
else:
|
||||
config_name = "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = self._get_layout_config(theme, config_name)
|
||||
self.card_geometry_cfg = self._get_card_geometry_config(theme)
|
||||
default_margin = 8 if self.list_layout else 20
|
||||
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
|
||||
spacing = self.card_layout_cfg.get("spacing", 12 if self.list_layout else 5)
|
||||
self.layout_.setSpacing(spacing)
|
||||
margin = self.base_extra_margin // 2
|
||||
self.layout_.setContentsMargins(margin, margin, margin, margin)
|
||||
self.shadow.setBlurRadius(theme.shadow_blur_radius)
|
||||
self.shadow.setColor(QColor(theme.color_shadow_card))
|
||||
self.shadow.setOffset(*theme.shadow_offset)
|
||||
if hasattr(self, "coverOpacity"):
|
||||
self.coverOpacity.setOpacity(theme.missing_exe_cover_opacity)
|
||||
if is_valid_protondb_tier(self.protondb_tier):
|
||||
self.protondbLabel.setStyleSheet(
|
||||
theme.get_protondb_badge_style(self.protondb_tier)
|
||||
)
|
||||
if self.ppdb_id:
|
||||
ppdb_style = (
|
||||
theme.get_ppdb_badge_style(self.ppdb_rating)
|
||||
if self.ppdb_rating
|
||||
else theme.STEAM_BADGE_STYLE
|
||||
)
|
||||
self.ppdbLabel.setStyleSheet(ppdb_style)
|
||||
if self.getAntiCheatText(self.anticheat_status):
|
||||
self.anticheatLabel.setStyleSheet(
|
||||
theme.get_anticheat_badge_style(self.anticheat_status)
|
||||
)
|
||||
corner_config = theme.get_source_corner_config()
|
||||
for label, icon_name in (
|
||||
(self.steamLabel, "badge_steam"),
|
||||
(self.gogLabel, "badge_gog"),
|
||||
(self.egsLabel, "badge_egs"),
|
||||
(self.portprotonLabel, "badge_portproton"),
|
||||
):
|
||||
icon = self.theme_manager.get_icon(
|
||||
icon_name, self.current_theme_name, as_path=True
|
||||
)
|
||||
label.refresh_source_theme(corner_config, icon)
|
||||
if (
|
||||
old_theme.GAME_CARD_ANIMATION != theme.GAME_CARD_ANIMATION
|
||||
or old_layout_cfg != self.card_layout_cfg
|
||||
):
|
||||
self.animations.refresh_theme(theme)
|
||||
else:
|
||||
self.animations.theme = theme
|
||||
self.update_favorite_icon()
|
||||
layout_changed = (
|
||||
old_layout_cfg != self.card_layout_cfg
|
||||
or old_theme.COMPACT_CARD != theme.COMPACT_CARD
|
||||
or old_theme.favoriteLabelSize != theme.favoriteLabelSize
|
||||
or old_theme.favoriteLabelIconSize != theme.favoriteLabelIconSize
|
||||
)
|
||||
if layout_changed:
|
||||
self.update_scale()
|
||||
else:
|
||||
self.update()
|
||||
|
||||
def _get_card_geometry_config(self, theme: Any) -> dict:
|
||||
theme_orientation = self.card_layout_cfg.get(
|
||||
"card_orientation", "horizontal"
|
||||
)
|
||||
if (
|
||||
self.horizontal_layout
|
||||
and ui_config.get_horizontal_card_orientation(theme_orientation)
|
||||
== "vertical"
|
||||
):
|
||||
return getattr(theme, "GAME_CARD_GRID", {})
|
||||
return self.card_layout_cfg
|
||||
|
||||
@staticmethod
|
||||
def _get_layout_config(theme: Any, config_name: str) -> dict:
|
||||
config = getattr(theme, config_name, {})
|
||||
get_override = getattr(theme, "get_theme_override", None)
|
||||
if not callable(get_override) or get_override(config_name) is not None:
|
||||
return config
|
||||
config = dict(config)
|
||||
for name in (
|
||||
"GAME_CARD_GRID", "GAME_CARD_HORIZONTAL",
|
||||
"GAME_CARD_LIST", "GAME_CARD_VERTICAL",
|
||||
):
|
||||
override = get_override(name)
|
||||
if isinstance(override, dict) and "card_animation_type" in override:
|
||||
config["card_animation_type"] = override["card_animation_type"]
|
||||
break
|
||||
else:
|
||||
animation = getattr(theme, "GAME_CARD_ANIMATION", {})
|
||||
config["card_animation_type"] = animation.get(
|
||||
"card_animation_type", "gradient"
|
||||
)
|
||||
radius = str(getattr(theme, "border_radius_card", "")).removesuffix("px")
|
||||
if radius.isdigit():
|
||||
config.update(cover_radius=int(radius), border_radius=int(radius))
|
||||
return config
|
||||
|
||||
def on_cover_loaded(self, pixmap):
|
||||
self.animated_cover_path = ""
|
||||
self.base_pixmap = pixmap
|
||||
@@ -442,7 +718,8 @@ class GameCard(QFrame):
|
||||
height = cover_size
|
||||
else:
|
||||
width = self.base_card_width
|
||||
height = int(self.base_card_width * 1.5)
|
||||
layout_config = getattr(self, "card_geometry_cfg", {})
|
||||
height = int(self.base_card_width * layout_config.get("cover_aspect_ratio", 1.5))
|
||||
if self._set_animated_cover(cover_path, width, height):
|
||||
return
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback()
|
||||
@@ -585,9 +862,10 @@ class GameCard(QFrame):
|
||||
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
|
||||
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
|
||||
source_ribbons = [
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
(self.steam_visible and not self.vertical_layout, self.steamLabel),
|
||||
(self.gog_visible and not self.vertical_layout, self.gogLabel),
|
||||
(self.egs_visible and not self.vertical_layout, self.egsLabel),
|
||||
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
@@ -609,6 +887,7 @@ class GameCard(QFrame):
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -631,7 +910,15 @@ class GameCard(QFrame):
|
||||
int(self.card_layout_cfg.get("cover_size", 56) * self._scale),
|
||||
)
|
||||
margin_left = self.card_layout_cfg.get("cover_left_margin", 10)
|
||||
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
|
||||
if self.vertical_layout:
|
||||
self.setMinimumWidth(0)
|
||||
self.setFixedHeight(row_height + scaled_extra)
|
||||
self.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding,
|
||||
QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
else:
|
||||
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
|
||||
self.coverWidget.setFixedSize(icon_size + margin_left, icon_size)
|
||||
self.coverWidget.setContentsMargins(margin_left, 0, 0, 0)
|
||||
self.coverLabel.setFixedSize(icon_size, icon_size)
|
||||
@@ -639,11 +926,11 @@ class GameCard(QFrame):
|
||||
small_card_mode = self.base_card_width < self.theme.COMPACT_CARD["width_threshold"]
|
||||
height_ratio = (
|
||||
self.theme.COMPACT_CARD["height_ratio"]
|
||||
if small_card_mode else self.card_layout_cfg.get("card_height_ratio", 1.8)
|
||||
if small_card_mode else self.card_geometry_cfg.get("card_height_ratio", 1.8)
|
||||
)
|
||||
scaled_height = int(self.base_card_width * height_ratio * self._scale)
|
||||
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
|
||||
cover_ratio = self.card_layout_cfg.get("cover_aspect_ratio", 1.5)
|
||||
cover_ratio = self.card_geometry_cfg.get("cover_aspect_ratio", 1.5)
|
||||
cover_height = int(scaled_width * cover_ratio)
|
||||
self.coverWidget.setFixedSize(scaled_width, cover_height)
|
||||
self.coverLabel.setFixedSize(scaled_width, cover_height)
|
||||
@@ -747,17 +1034,22 @@ class GameCard(QFrame):
|
||||
self.economy_mode = ui_config.get_economy_mode()
|
||||
self.steam_visible = str(self.game_source).lower() == "steam"
|
||||
self.gog_visible = str(self.game_source).lower() == "gog"
|
||||
self.egs_visible = str(self.game_source).lower() == "egs"
|
||||
self.portproton_visible = str(self.game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
show_source_ribbons = self.vertical_layout is not True
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
|
||||
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
|
||||
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and show_source_ribbons
|
||||
)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
|
||||
@@ -947,76 +1239,6 @@ class GameCard(QFrame):
|
||||
favorites_config.set_games(favorites)
|
||||
self.update_favorite_icon()
|
||||
|
||||
def getBorderWidth(self) -> int:
|
||||
return self._borderWidth
|
||||
|
||||
def setBorderWidth(self, value: int):
|
||||
if self._borderWidth != value:
|
||||
self._borderWidth = value
|
||||
self.borderWidthChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getGradientAngle(self) -> float:
|
||||
return self._gradientAngle
|
||||
|
||||
def setGradientAngle(self, value: float):
|
||||
if self._gradientAngle != value:
|
||||
self._gradientAngle = value
|
||||
self.gradientAngleChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getScale(self) -> float:
|
||||
return self._scale
|
||||
|
||||
def setScale(self, value: float):
|
||||
if self._scale != value:
|
||||
self._scale = value
|
||||
self.update_scale()
|
||||
self.scaleChanged.emit()
|
||||
|
||||
borderWidth = Property(int, fget=getBorderWidth, fset=setBorderWidth, notify=borderWidthChanged)
|
||||
gradientAngle = Property(float, fget=getGradientAngle, fset=setGradientAngle, notify=gradientAngleChanged)
|
||||
scale = Property(float, fget=getScale, fset=setScale, notify=scaleChanged)
|
||||
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hover_sound_pending = True
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hover_sound_pending = False
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._hover_sound_pending:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self._hover_sound_pending = False
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
if (
|
||||
QApplication.activeWindow() is not None
|
||||
and event.reason() not in (
|
||||
Qt.FocusReason.ActiveWindowFocusReason,
|
||||
Qt.FocusReason.MouseFocusReason,
|
||||
)
|
||||
):
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event):
|
||||
self.animations.handle_focus_out_event()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def hideEvent(self, event: QHideEvent) -> None:
|
||||
self.set_animated_cover_paused(True)
|
||||
super().hideEvent(event)
|
||||
@@ -1025,28 +1247,34 @@ class GameCard(QFrame):
|
||||
self.set_animated_cover_paused(False)
|
||||
super().showEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
def click(self) -> None:
|
||||
if self._get_missing_executable_path() and self.context_menu_manager:
|
||||
self.context_menu_manager.handle_missing_executable(self)
|
||||
return
|
||||
game_data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"cover_path": self.cover_path,
|
||||
"appid": self.appid,
|
||||
"controller_support": self.controller_support,
|
||||
"exec_line": self.exec_line,
|
||||
"last_launch": self.last_launch,
|
||||
"formatted_playtime": self.formatted_playtime,
|
||||
"playtime_seconds": self.playtime_seconds,
|
||||
"protondb_tier": self.protondb_tier,
|
||||
"game_source": self.game_source,
|
||||
"anticheat_status": self.anticheat_status,
|
||||
"anticheat_slug": self.anticheat_slug,
|
||||
"ppdb_id": self.ppdb_id,
|
||||
"ppdb_rating": self.ppdb_rating,
|
||||
"protondb_appid": self.protondb_appid,
|
||||
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
|
||||
}
|
||||
self.select_callback(game_data)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
game_data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"cover_path": self.cover_path,
|
||||
"appid": self.appid,
|
||||
"controller_support": self.controller_support,
|
||||
"exec_line": self.exec_line,
|
||||
"last_launch": self.last_launch,
|
||||
"formatted_playtime": self.formatted_playtime,
|
||||
"playtime_seconds": self.playtime_seconds,
|
||||
"protondb_tier": self.protondb_tier,
|
||||
"game_source": self.game_source,
|
||||
"anticheat_status": self.anticheat_status,
|
||||
"anticheat_slug": self.anticheat_slug,
|
||||
"ppdb_id": self.ppdb_id,
|
||||
"ppdb_rating": self.ppdb_rating,
|
||||
"protondb_appid": self.protondb_appid,
|
||||
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
|
||||
}
|
||||
self.select_callback(game_data)
|
||||
self.click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Protocol
|
||||
from portprotonqt.game_card import GameCard
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Protocol
|
||||
from portprotonqt.game_card import AnimatedCard, GameCard
|
||||
from portprotonqt.search_utils import (
|
||||
SearchOptimizer,
|
||||
ThreadedSearch,
|
||||
@@ -7,15 +8,42 @@ from portprotonqt.search_utils import (
|
||||
search_index,
|
||||
)
|
||||
from PySide6.QtWidgets import QAbstractButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QRegion
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import 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
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.detail_pages.utils import remove_cover_background, setup_cover_background
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
|
||||
from collections import deque
|
||||
|
||||
|
||||
class FullLibraryTile(AnimatedCard):
|
||||
def __init__(self, theme: Any):
|
||||
super().__init__()
|
||||
self.name = ""
|
||||
self.animation_base_size = theme.fullLibraryTileSize
|
||||
self.tile_pixmap = QPixmap()
|
||||
self.setup_card_animations(
|
||||
theme, GameCard._get_layout_config(theme, "GAME_CARD_HORIZONTAL")
|
||||
)
|
||||
self.update_scale()
|
||||
|
||||
def set_tile_pixmap(self, pixmap: QPixmap) -> None:
|
||||
self.tile_pixmap = pixmap
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
QFrame.paintEvent(self, event)
|
||||
painter = QPainter(self)
|
||||
painter.drawPixmap(self.rect(), self.tile_pixmap)
|
||||
painter.end()
|
||||
border_painter = QPainter(self)
|
||||
self.animations.paint_border(border_painter)
|
||||
border_painter.end()
|
||||
|
||||
class MainWindowProtocol(Protocol):
|
||||
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
|
||||
|
||||
@@ -46,10 +74,18 @@ class GameLibraryManager:
|
||||
self.game_card_cache = {}
|
||||
self.pending_images = {}
|
||||
self.card_width = ui_config.get_card_width()
|
||||
self.layout_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
theme_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
self.layout_mode = ui_config.get_library_layout_mode(theme_mode)
|
||||
self.gamesListWidget: QWidget | None = None
|
||||
self.gamesListLayout: FlowLayout | None = None
|
||||
self.gamesListLayout: FlowLayout | QHBoxLayout | QVBoxLayout | None = None
|
||||
self.gamesScrollArea: QScrollArea | None = None
|
||||
self.libraryHeaderWidget: QWidget | None = None
|
||||
self.libraryContentLayout: QVBoxLayout | None = None
|
||||
self.libraryBackgroundLabel: QLabel | None = None
|
||||
self.fullLibraryTile: FullLibraryTile | None = None
|
||||
self._full_library_tile_covers: list[str] = []
|
||||
self._full_library_tile_pixmaps: dict[int, QPixmap] = {}
|
||||
self.full_library_open = False
|
||||
self.sizeSlider: QSlider | None = None
|
||||
self._update_timer: QTimer | None = None
|
||||
self._incremental_add_timer: QTimer | None = None
|
||||
@@ -69,23 +105,61 @@ class GameLibraryManager:
|
||||
def create_games_library_widget(self):
|
||||
"""Creates the games library widget with search, grid, and slider."""
|
||||
self.gamesLibraryWidget = QWidget()
|
||||
self.gamesLibraryWidget.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
|
||||
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(self.gamesLibraryWidget)
|
||||
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
|
||||
stack_layout = QGridLayout(self.gamesLibraryWidget)
|
||||
if isinstance(library_background, dict):
|
||||
stack_layout.setContentsMargins(*library_background["margins"])
|
||||
else:
|
||||
stack_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.libraryBackgroundLabel = QLabel()
|
||||
stack_layout.addWidget(self.libraryBackgroundLabel, 0, 0)
|
||||
content_widget = QWidget()
|
||||
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
layout = QVBoxLayout(content_widget)
|
||||
stack_layout.addWidget(content_widget, 0, 0)
|
||||
self.libraryContentLayout = layout
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Search widget
|
||||
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")
|
||||
self.gamesScrollArea = scrollArea
|
||||
scrollArea.setWidgetResizable(True)
|
||||
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
|
||||
|
||||
self.gamesListWidget = QWidget()
|
||||
self.gamesListWidget.setProperty("library_layout_mode", self.layout_mode)
|
||||
self.gamesListWidget.setProperty("theme_style_name", "LIST_WIDGET_STYLE")
|
||||
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
|
||||
self.gamesListLayout = FlowLayout(self.gamesListWidget)
|
||||
if self.layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
|
||||
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
|
||||
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
elif self.layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
|
||||
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
else:
|
||||
self.gamesListLayout = FlowLayout(self.gamesListWidget)
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
|
||||
scrollArea.setWidget(self.gamesListWidget)
|
||||
@@ -106,7 +180,7 @@ class GameLibraryManager:
|
||||
self.main_window._register_gamepad_tooltip(self.sizeSlider, f"{self.card_width} px")
|
||||
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
|
||||
sliderLayout.addWidget(self.sizeSlider)
|
||||
if self.layout_mode == "list":
|
||||
if self.layout_mode in {"list", "vertical", "horizontal", "horizontal_top"}:
|
||||
self.sizeSlider.setVisible(False)
|
||||
self._set_card_width_from_slider()
|
||||
|
||||
@@ -123,30 +197,128 @@ class GameLibraryManager:
|
||||
|
||||
# Connect scroll event for lazy loading
|
||||
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
scrollArea.horizontalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
|
||||
return self.gamesLibraryWidget
|
||||
|
||||
def _create_library_header(self) -> QWidget:
|
||||
"""Create the shared header for vertical library columns."""
|
||||
config = self.theme.GAME_CARD_VERTICAL
|
||||
header = QWidget()
|
||||
header.setFixedHeight(config["header_height"])
|
||||
header.setProperty("theme_style_name", "LIBRARY_HEADER_STYLE")
|
||||
header.setStyleSheet(self.theme.LIBRARY_HEADER_STYLE)
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(*config["header_margins"])
|
||||
header_layout.setSpacing(config["header_spacing"])
|
||||
header_layout.addSpacing(config["header_cover_width"])
|
||||
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.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
|
||||
label.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
|
||||
header_layout.addWidget(label, stretch)
|
||||
return header
|
||||
|
||||
def rebuild_library_layout(self, layout_mode: str) -> None:
|
||||
"""Replace the card layout when a theme changes its library mode."""
|
||||
if self.gamesListWidget is None or self.gamesListLayout is None:
|
||||
return
|
||||
self.clear_layout(self.gamesListLayout)
|
||||
self.fullLibraryTile = None
|
||||
old_layout = self.gamesListLayout
|
||||
if layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
|
||||
self.gamesListLayout = QHBoxLayout()
|
||||
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
elif layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout()
|
||||
self.gamesListLayout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
else:
|
||||
self.gamesListLayout = FlowLayout()
|
||||
QWidget().setLayout(old_layout)
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
if self.gamesScrollArea is not None:
|
||||
horizontal = layout_mode in {"horizontal", "horizontal_top"}
|
||||
vertical_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if horizontal
|
||||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
|
||||
theme_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
self.full_library_open = theme_mode == "horizontal_top" and layout_mode == "grid"
|
||||
if self.full_library_open and self.libraryBackgroundLabel is not None:
|
||||
remove_cover_background(self.libraryBackgroundLabel)
|
||||
self.layout_mode = layout_mode
|
||||
if (
|
||||
layout_mode == "vertical"
|
||||
and self.libraryHeaderWidget is None
|
||||
and self.libraryContentLayout is not None
|
||||
):
|
||||
self.libraryHeaderWidget = self._create_library_header()
|
||||
self.libraryContentLayout.insertWidget(1, self.libraryHeaderWidget)
|
||||
if self.libraryHeaderWidget is not None:
|
||||
self.libraryHeaderWidget.setVisible(layout_mode == "vertical")
|
||||
self.gamesListWidget.setProperty("library_layout_mode", layout_mode)
|
||||
if self.sizeSlider is not None:
|
||||
self.sizeSlider.setVisible(
|
||||
not self.full_library_open
|
||||
and layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
|
||||
)
|
||||
self._set_card_width_from_slider()
|
||||
self.main_window.card_width = self.card_width
|
||||
self.set_games(self.games, focus_first_card=False)
|
||||
|
||||
def open_full_library(self) -> None:
|
||||
"""Open the complete grid from the horizontal top mode."""
|
||||
if self.layout_mode == "horizontal_top":
|
||||
self.rebuild_library_layout("grid")
|
||||
|
||||
def close_full_library(self) -> bool:
|
||||
"""Return from the complete grid to the horizontal top mode."""
|
||||
if not self.full_library_open:
|
||||
return False
|
||||
self.full_library_open = False
|
||||
self.rebuild_library_layout("horizontal_top")
|
||||
return True
|
||||
|
||||
def on_slider_released(self):
|
||||
"""Handles slider release to update card size."""
|
||||
if self.layout_mode == "list":
|
||||
if self.full_library_open or self.layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
return
|
||||
if self.sizeSlider is None:
|
||||
return
|
||||
self._set_card_width_from_slider()
|
||||
if hasattr(self.main_window, "_gamepad_tooltip_map"):
|
||||
self.main_window._gamepad_tooltip_map[self.sizeSlider] = f"{self.card_width} px"
|
||||
if self.layout_mode != "list":
|
||||
ui_config.set_card_width(self.card_width)
|
||||
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()
|
||||
|
||||
def _set_card_width_from_slider(self):
|
||||
"""Use max card width for list layout."""
|
||||
"""Use max card width for fixed-size layouts."""
|
||||
if self.sizeSlider is None:
|
||||
return
|
||||
if self.layout_mode == "list":
|
||||
if self.full_library_open or self.layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
self.card_width = self.sizeSlider.maximum()
|
||||
else:
|
||||
self.card_width = self.sizeSlider.value()
|
||||
@@ -199,7 +371,7 @@ class GameLibraryManager:
|
||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||
try:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
self.main_window.current_hovered_card.animations.handle_leave_event()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = None
|
||||
@@ -210,10 +382,36 @@ class GameLibraryManager:
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_focused_card = card
|
||||
self._update_library_background(card)
|
||||
else:
|
||||
if self.main_window.current_focused_card == card:
|
||||
self.main_window.current_focused_card = None
|
||||
|
||||
def _update_library_background(self, card: GameCard) -> None:
|
||||
"""Render the configured library background from the active cover."""
|
||||
if self.full_library_open or self.libraryBackgroundLabel is None:
|
||||
return
|
||||
self.render_library_background(self.libraryBackgroundLabel, card)
|
||||
|
||||
def render_library_background(self, label: QLabel, card: GameCard) -> None:
|
||||
"""Render the configured library background on a target label."""
|
||||
pixmap = card.coverLabel.pixmap()
|
||||
if pixmap is None or pixmap.isNull():
|
||||
return
|
||||
library_config = getattr(self.theme, "LIBRARY_BACKGROUND", None)
|
||||
if not isinstance(library_config, dict):
|
||||
remove_cover_background(label)
|
||||
return
|
||||
backgrounds = dict(getattr(self.theme, "DETAIL_PAGE_BACKGROUNDS", {}))
|
||||
backgrounds.update(library_config.get("backgrounds", {}))
|
||||
background_theme = SimpleNamespace(
|
||||
DETAIL_PAGE_BG_MODE=library_config.get("mode", "gradient"),
|
||||
DETAIL_PAGE_BACKGROUNDS=backgrounds,
|
||||
)
|
||||
setup_cover_background(
|
||||
label, pixmap, self.main_window, background_theme
|
||||
)
|
||||
|
||||
def _collapse_library_filter_controls(self) -> None:
|
||||
if getattr(self.main_window, "_library_controls_hover_close_delayed", False):
|
||||
return
|
||||
@@ -251,10 +449,11 @@ class GameLibraryManager:
|
||||
try:
|
||||
if self.main_window.current_hovered_card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
self.main_window.current_hovered_card.animations.handle_leave_event()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = card
|
||||
self._update_library_background(card)
|
||||
else:
|
||||
if self.main_window.current_hovered_card == card:
|
||||
self.main_window.current_hovered_card = None
|
||||
@@ -275,9 +474,16 @@ class GameLibraryManager:
|
||||
"""Schedules a game grid update with debouncing."""
|
||||
if focus_first_card is not None:
|
||||
self._focus_first_card_after_update = focus_first_card
|
||||
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
theme_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
if not self.full_library_open:
|
||||
self.layout_mode = theme_mode
|
||||
if self.sizeSlider is not None:
|
||||
self.sizeSlider.setVisible(self.layout_mode != "list")
|
||||
self.sizeSlider.setVisible(
|
||||
not self.full_library_open
|
||||
and self.layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
|
||||
)
|
||||
old_card_width = self.card_width
|
||||
self._set_card_width_from_slider()
|
||||
self.main_window.card_width = self.card_width
|
||||
@@ -292,6 +498,9 @@ class GameLibraryManager:
|
||||
# When filtering, we want to update with the current filtered_games
|
||||
# which has already been set by _perform_search
|
||||
pass
|
||||
if is_filter and self.layout_mode == "horizontal_top":
|
||||
self.dirty = True
|
||||
is_filter = False
|
||||
self.is_filtering = is_filter
|
||||
self._pending_update = True
|
||||
|
||||
@@ -386,6 +595,7 @@ class GameLibraryManager:
|
||||
|
||||
if added_new_card:
|
||||
self.load_visible_images()
|
||||
self._sync_full_library_tile()
|
||||
self.force_update_cards_library()
|
||||
self._cancel_incremental_add()
|
||||
self._schedule_focus_first_card()
|
||||
@@ -404,7 +614,9 @@ class GameLibraryManager:
|
||||
self._update_search_results(search_text)
|
||||
else:
|
||||
# Full update: sorting, removal/addition, reorganization
|
||||
games_list = self.filtered_games if self.filtered_games else self.games
|
||||
games_list = self.filtered_games
|
||||
if self.layout_mode != "horizontal_top" and not games_list:
|
||||
games_list = self.games
|
||||
favorites = favorites_config.get_games()
|
||||
sort_method = game_config.get_sort_method()
|
||||
|
||||
@@ -433,6 +645,13 @@ class GameLibraryManager:
|
||||
sorted_fav = sorted(fav_games, key=partition_sort_key)
|
||||
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
|
||||
sorted_games = sorted_fav + sorted_non_fav
|
||||
if self.layout_mode == "horizontal_top":
|
||||
limit = self.theme.horizontalTopGameLimit
|
||||
tile_games = sorted_games[limit:limit + self.theme.fullLibraryTileGameCount]
|
||||
self._full_library_tile_covers = [str(game[2] or "") for game in tile_games]
|
||||
sorted_games = sorted_games[:limit]
|
||||
else:
|
||||
self._full_library_tile_covers = []
|
||||
|
||||
# Build set of current game keys for faster lookup
|
||||
current_game_keys = {(game[0], game[5]) for game in sorted_games}
|
||||
@@ -525,8 +744,68 @@ class GameLibraryManager:
|
||||
self.force_update_cards_library()
|
||||
|
||||
self.is_filtering = False # Reset flag in any case
|
||||
if not self._incremental_add_queue:
|
||||
self._sync_full_library_tile()
|
||||
self._schedule_focus_first_card()
|
||||
|
||||
def _sync_full_library_tile(self) -> None:
|
||||
if self.fullLibraryTile is not None:
|
||||
self.fullLibraryTile.setParent(None)
|
||||
self.fullLibraryTile.deleteLater()
|
||||
self.fullLibraryTile = None
|
||||
if self.layout_mode != "horizontal_top" or self.gamesListLayout is None:
|
||||
return
|
||||
if len(self.filtered_games) <= self.theme.horizontalTopGameLimit:
|
||||
return
|
||||
tile = FullLibraryTile(self.theme)
|
||||
tile.setProperty("full_library_tile", True)
|
||||
tile.setStyleSheet(self.theme.FULL_LIBRARY_TILE_STYLE)
|
||||
tile.clicked.connect(self.open_full_library)
|
||||
self.gamesListLayout.addWidget(tile)
|
||||
self.fullLibraryTile = tile
|
||||
self._full_library_tile_pixmaps = {}
|
||||
cover_width, cover_height = self.theme.fullLibraryTileSize
|
||||
for index, cover_path in enumerate(self._full_library_tile_covers):
|
||||
load_pixmap_async(
|
||||
cover_path,
|
||||
cover_width,
|
||||
cover_height,
|
||||
lambda pixmap, item=index: self._set_full_library_tile_cover(
|
||||
tile, item, pixmap
|
||||
),
|
||||
)
|
||||
|
||||
def _set_full_library_tile_cover(
|
||||
self, tile: FullLibraryTile, index: int, pixmap: QPixmap
|
||||
) -> None:
|
||||
if tile is not self.fullLibraryTile:
|
||||
return
|
||||
self._full_library_tile_pixmaps[index] = pixmap
|
||||
width, height = self.theme.fullLibraryTileSize
|
||||
cell_width = width // self.theme.fullLibraryTileColumns
|
||||
cell_height = height // self.theme.fullLibraryTileRows
|
||||
canvas = QPixmap(width, height)
|
||||
canvas.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(canvas)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
clip = QPainterPath()
|
||||
clip.addRoundedRect(
|
||||
QRectF(canvas.rect()),
|
||||
self.theme.fullLibraryTileRadius,
|
||||
self.theme.fullLibraryTileRadius,
|
||||
)
|
||||
painter.setClipPath(clip)
|
||||
for item, cover in self._full_library_tile_pixmaps.items():
|
||||
cell = QRect(
|
||||
item % self.theme.fullLibraryTileColumns * cell_width,
|
||||
item // self.theme.fullLibraryTileColumns * cell_height,
|
||||
cell_width,
|
||||
cell_height,
|
||||
)
|
||||
painter.drawPixmap(cell, cover)
|
||||
painter.end()
|
||||
tile.set_tile_pixmap(canvas)
|
||||
|
||||
def _schedule_focus_first_card(self) -> None:
|
||||
if not self._focus_first_card_after_update:
|
||||
return
|
||||
@@ -722,6 +1001,7 @@ class GameLibraryManager:
|
||||
"""Add a single game without full reload."""
|
||||
self.games.append(game_data)
|
||||
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
|
||||
self._build_search_indices(self.games)
|
||||
self.dirty = True
|
||||
self.update_game_grid()
|
||||
self._update_missing_exe_button()
|
||||
@@ -748,6 +1028,17 @@ class GameLibraryManager:
|
||||
self.update_game_grid()
|
||||
self._update_missing_exe_button()
|
||||
|
||||
detail_manager = getattr(self.main_window, "detail_page_manager", None)
|
||||
source = getattr(detail_manager, "_current_detail_source", None)
|
||||
if detail_manager and source and source[0] == "game" and detail_manager._detail_page_active:
|
||||
detail_data = source[1]
|
||||
if (detail_data.get("name"), detail_data.get("exec_line")) == old_key:
|
||||
detail_data.update(
|
||||
name=game_data[0], description=game_data[1],
|
||||
cover_path=game_data[2], exec_line=game_data[5],
|
||||
)
|
||||
detail_manager._reopen_current_detail_page()
|
||||
|
||||
def remove_game_incremental(self, game_name: str, exec_line: str):
|
||||
"""Remove a single game without full reload."""
|
||||
key = (game_name, exec_line)
|
||||
|
||||
+72
-6
@@ -7,6 +7,7 @@ import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
import zlib
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
@@ -31,6 +32,9 @@ GOG_USER_TIMEOUT = 15
|
||||
GOG_METADATA_WORKERS = 4
|
||||
GOG_SETUP_MARKER_VERSION = 1
|
||||
GOG_SETUP_TIMEOUT = 600
|
||||
GOG_REPAIR_METADATA_TIMEOUT = 30
|
||||
GOG_COMPRESSED_MANIFEST_VERSION = 2
|
||||
GOG_BUILDS_API_VERSION = 2
|
||||
GOG_PRODUCT_LOCALES = {
|
||||
"bg": "bg-BG", "cs": "cs-CZ", "da": "da-DK", "de": "de-DE",
|
||||
"el": "el-GR", "en": "en-US", "es": "es-ES", "fi": "fi-FI",
|
||||
@@ -47,11 +51,26 @@ class GOGAPI:
|
||||
|
||||
def __init__(self) -> None:
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
self.data_dir = data_home / "PortProtonQt" / "gog"
|
||||
self.data_dir = data_home / "PortProtonQt" / "launcher" / "gog"
|
||||
self.bin_dir = data_home / "PortProtonQt" / "bin"
|
||||
# Remove after the legacy launcher storage migration period.
|
||||
legacy_dir = data_home / "PortProtonQt" / "gog"
|
||||
if legacy_dir.exists() and not self.data_dir.exists():
|
||||
self.data_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy_dir.rename(self.data_dir)
|
||||
legacy_bin = self.data_dir / "bin"
|
||||
if legacy_bin.is_dir():
|
||||
self.bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
for legacy_binary in legacy_bin.iterdir():
|
||||
target = self.bin_dir / legacy_binary.name
|
||||
if not target.exists():
|
||||
legacy_binary.rename(target)
|
||||
if not any(legacy_bin.iterdir()):
|
||||
legacy_bin.rmdir()
|
||||
self.auth_path = self.data_dir / "auth.json"
|
||||
self.account_path = self.data_dir / "account.json"
|
||||
self.config_dir = self.data_dir / "gogdl"
|
||||
self.gogdl_version_path = self.data_dir / "bin/gogdl.version"
|
||||
self.gogdl_version_path = self.bin_dir / "gogdl.version"
|
||||
self.library_path = self.data_dir / "library.json"
|
||||
self.installed_path = self.data_dir / "installed.json"
|
||||
self.sizes_path = self.data_dir / "sizes.json"
|
||||
@@ -59,7 +78,7 @@ class GOGAPI:
|
||||
|
||||
def get_gogdl_path(self) -> str | None:
|
||||
"""Return an available gogdl executable."""
|
||||
bundled = self.data_dir / "bin/gogdl"
|
||||
bundled = self.bin_dir / "gogdl"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
return str(bundled)
|
||||
return shutil.which("gogdl")
|
||||
@@ -84,7 +103,7 @@ class GOGAPI:
|
||||
|
||||
def update_gogdl(self) -> str:
|
||||
"""Update the bundled gogdl binary to the latest official release."""
|
||||
bundled = self.data_dir / "bin/gogdl"
|
||||
bundled = self.bin_dir / "gogdl"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
try:
|
||||
last_check = self.gogdl_version_path.stat().st_mtime
|
||||
@@ -134,7 +153,7 @@ class GOGAPI:
|
||||
return path
|
||||
|
||||
def _download_gogdl_asset(self, asset: dict) -> str:
|
||||
target = self.data_dir / "bin/gogdl"
|
||||
target = self.bin_dir / "gogdl"
|
||||
temporary = target.with_suffix(".part")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
@@ -375,6 +394,53 @@ class GOGAPI:
|
||||
logger.warning("Failed to inspect GOG installation path %s: %s", parent, error)
|
||||
return None
|
||||
|
||||
def prepare_repair_manifest(self, app_id: str, install_path: Path) -> None:
|
||||
"""Restore gogdl metadata before verifying an imported Windows game."""
|
||||
manifest_path = self.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
if manifest_path.is_file():
|
||||
return
|
||||
info = self._load_json(install_path / f"goggame-{app_id}.info", {})
|
||||
build_info = self._load_json(install_path / f"goggame-{app_id}.id", {})
|
||||
build_id = build_info.get("buildId") or info.get("buildId")
|
||||
credentials = self.get_credentials()
|
||||
response = requests.get(
|
||||
f"https://content-system.gog.com/products/{app_id}/os/windows/builds",
|
||||
params={"generation": GOG_COMPRESSED_MANIFEST_VERSION, "_version": GOG_BUILDS_API_VERSION},
|
||||
headers={"Authorization": f"Bearer {credentials.get('access_token', '')}"},
|
||||
timeout=GOG_REPAIR_METADATA_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
builds = response.json().get("items", [])
|
||||
build = next((item for item in builds if (
|
||||
str(item.get("build_id")) == str(build_id) if build_id
|
||||
else not item.get("branch")
|
||||
)), None)
|
||||
if build is None and build_id:
|
||||
build = next((item for item in builds if not item.get("branch") and item.get("urls")), None)
|
||||
if build:
|
||||
logger.warning(
|
||||
"GOG build %s for %s is unavailable; verifying against build %s",
|
||||
build_id, app_id, build.get("build_id"),
|
||||
)
|
||||
if not build or not build.get("urls"):
|
||||
raise OSError(f"GOG build not found for installed game {app_id}")
|
||||
response = requests.get(build["urls"][0]["url"], timeout=GOG_REPAIR_METADATA_TIMEOUT)
|
||||
response.raise_for_status()
|
||||
data = response.content
|
||||
if build.get("generation") == GOG_COMPRESSED_MANIFEST_VERSION:
|
||||
data = zlib.decompress(data)
|
||||
manifest = orjson.loads(data)
|
||||
manifest["HGLPlatform"] = "windows"
|
||||
manifest["HGLInstallLanguage"] = (
|
||||
info.get("language") or next(iter(info.get("languages", [])), "en-US")
|
||||
)
|
||||
manifest["HGLdlcs"] = [
|
||||
{"id": path.stem.removeprefix("goggame-")}
|
||||
for path in install_path.glob("goggame-*.info")
|
||||
if path.stem != f"goggame-{app_id}"
|
||||
]
|
||||
self._save_json(manifest_path, manifest)
|
||||
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
"""Return the primary executable from gogdl installation metadata."""
|
||||
primary, install_path = self._get_primary_task(app_id)
|
||||
@@ -615,7 +681,7 @@ class GOGAPI:
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
game = data.get("game", {})
|
||||
if not game.get("visible_in_library", True):
|
||||
if data.get("type") not in {"game", "mod"} or not game.get("visible_in_library", True):
|
||||
return {}
|
||||
steam_release = next(
|
||||
(
|
||||
|
||||
@@ -7,6 +7,7 @@ import struct
|
||||
import io
|
||||
import pefile
|
||||
from PIL import Image
|
||||
from portprotonqt.config.base import CACHE_DIR
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -48,14 +49,9 @@ def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
cache_home = os.getenv(
|
||||
"XDG_CACHE_HOME",
|
||||
os.path.join(os.path.expanduser("~"), ".cache"),
|
||||
)
|
||||
icon_name = re.sub(r"[^A-Za-z0-9._-]", "_", os.path.basename(exe_path))
|
||||
return os.path.join(
|
||||
cache_home,
|
||||
"PortProtonQt",
|
||||
CACHE_DIR,
|
||||
"images",
|
||||
"exe_icons",
|
||||
f"{icon_name}.png",
|
||||
|
||||
@@ -8,7 +8,7 @@ from PySide6.QtCore import (
|
||||
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
|
||||
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
|
||||
from PIL import Image, ImageQt, ImageSequence
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.config import CACHE_DIR, ui_config
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
@@ -23,6 +23,7 @@ downloader = Downloader()
|
||||
logger = get_logger(__name__)
|
||||
COVER_IMAGE_EXTENSIONS = (".png", ".apng", ".jpg", ".jpeg", ".gif", ".webp", ".jxl", ".svg")
|
||||
DEFAULT_ANIMATION_DELAY_MS = 100
|
||||
REMOTE_COVER_DOWNLOAD_TIMEOUT = 15
|
||||
|
||||
# Global queue and thread pool for image loading
|
||||
image_load_queue = Queue()
|
||||
@@ -366,8 +367,7 @@ def _get_ppdb_autoinstall_image_name(cover: str) -> str:
|
||||
|
||||
|
||||
def clear_ppdb_autoinstall_image_cache(cover_urls: list[str]) -> None:
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
|
||||
image_folder = str(CACHE_DIR / "images")
|
||||
for cover in cover_urls:
|
||||
filename = _get_ppdb_autoinstall_image_name(cover)
|
||||
if not filename:
|
||||
@@ -456,8 +456,7 @@ def load_pixmap_async(
|
||||
pixmap.load(icon_path)
|
||||
return pixmap
|
||||
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
|
||||
image_folder = str(CACHE_DIR / "images")
|
||||
os.makedirs(image_folder, exist_ok=True)
|
||||
|
||||
if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):
|
||||
@@ -489,7 +488,11 @@ def load_pixmap_async(
|
||||
finish_with(pixmap)
|
||||
|
||||
finish_with(load_fallback_pixmap())
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
downloader.download_async(
|
||||
cover, local_path,
|
||||
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
@@ -524,7 +527,11 @@ def load_pixmap_async(
|
||||
|
||||
logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
|
||||
finish_with(load_fallback_pixmap())
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
downloader.download_async(
|
||||
cover, local_path,
|
||||
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
@@ -555,7 +562,11 @@ def load_pixmap_async(
|
||||
finish_with(pixmap)
|
||||
|
||||
finish_with(load_fallback_pixmap())
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
downloader.download_async(
|
||||
cover, local_path,
|
||||
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Error processing remote image URL %s: %s", cover, str(e))
|
||||
@@ -606,6 +617,7 @@ def round_corners(pixmap, radius):
|
||||
painter.end()
|
||||
return rounded
|
||||
|
||||
|
||||
class FullscreenDialog(QDialog):
|
||||
"""
|
||||
Dialog for viewing images without standard controls.
|
||||
|
||||
@@ -7,7 +7,7 @@ from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea
|
||||
from PySide6.QtCore import Qt, QObject, Signal, Slot, QTimer, QThread
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_card import AnimatedCard
|
||||
from portprotonqt.config import display_config, window_config
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
@@ -60,6 +60,7 @@ class MainWindowProtocol(Protocol):
|
||||
current_exec_line: str | None
|
||||
current_add_game_dialog: AddGameDialog | None
|
||||
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
|
||||
theme: Any
|
||||
auto_size_slider: QSlider | None
|
||||
|
||||
class MouseEmulationThread(QThread):
|
||||
@@ -286,9 +287,11 @@ class InputManager(
|
||||
if container is None:
|
||||
return
|
||||
game_cards = [
|
||||
card for card in container.findChildren(GameCard)
|
||||
card for card in container.findChildren(AnimatedCard)
|
||||
if card.isVisible() and card.isEnabled()
|
||||
]
|
||||
if self._navigate_horizontal_library(game_cards, tab_index, code, value):
|
||||
return
|
||||
focused = QApplication.focusWidget()
|
||||
if game_cards and focused not in game_cards:
|
||||
self._focus_grid_card(game_cards[0])
|
||||
@@ -299,6 +302,40 @@ class InputManager(
|
||||
if focused in first_row:
|
||||
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _navigate_horizontal_library(
|
||||
self, cards: list[QWidget], tab_index: int, code: int, value: int
|
||||
) -> bool:
|
||||
if tab_index == 1:
|
||||
mode = str(
|
||||
getattr(self._parent.theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
).lower()
|
||||
layout = getattr(self._parent, "autoInstallContainerLayout", None)
|
||||
else:
|
||||
manager = getattr(self._parent, "game_library_manager", None)
|
||||
mode = getattr(manager, "layout_mode", "grid")
|
||||
layout = getattr(manager, "gamesListLayout", None)
|
||||
if mode not in {
|
||||
"horizontal", "horizontal_top"
|
||||
}:
|
||||
return False
|
||||
if code != PAD_DPAD_X or value == 0 or layout is None:
|
||||
return False
|
||||
ordered_cards = []
|
||||
for index in range(layout.count()):
|
||||
item = layout.itemAt(index)
|
||||
widget = item.widget() if item is not None else None
|
||||
if widget in cards:
|
||||
ordered_cards.append(widget)
|
||||
focused = QApplication.focusWidget()
|
||||
if not ordered_cards:
|
||||
return True
|
||||
if focused not in ordered_cards:
|
||||
self._focus_grid_card(ordered_cards[0])
|
||||
return True
|
||||
target_index = (ordered_cards.index(focused) + value) % len(ordered_cards)
|
||||
self._focus_grid_card(ordered_cards[target_index])
|
||||
return True
|
||||
|
||||
def _get_card_grid_rows(self, cards: list[QWidget]) -> list[list[QWidget]]:
|
||||
"""Group cards into rows ordered by their visual position."""
|
||||
rows = {}
|
||||
|
||||
@@ -82,6 +82,9 @@ class ButtonInputMixin(InputMixin):
|
||||
if self._handle_system_table_button(focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_table_button(focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_system_quick_button(button_code):
|
||||
return
|
||||
|
||||
@@ -89,6 +92,14 @@ class ButtonInputMixin(InputMixin):
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
||||
|
||||
def _handle_table_button(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if not isinstance(focused, QTableWidget) or button_code not in BUTTONS['confirm']:
|
||||
return False
|
||||
if not self.handle_table_confirm(focused):
|
||||
return False
|
||||
SoundManager().play("click")
|
||||
return True
|
||||
|
||||
def _open_focused_context_menu(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if button_code not in BUTTONS['context_menu'] or focused is None:
|
||||
return False
|
||||
@@ -276,6 +287,10 @@ class ButtonInputMixin(InputMixin):
|
||||
|
||||
def _handle_standard_back(self) -> None:
|
||||
SoundManager().play("back")
|
||||
manager = getattr(self._parent, "game_library_manager", None)
|
||||
close_full_library = getattr(manager, "close_full_library", None)
|
||||
if callable(close_full_library) and close_full_library():
|
||||
return
|
||||
if self._is_theme_store_visible():
|
||||
store_stack = getattr(self._parent, "themeStoreStack", None)
|
||||
detail_page = getattr(self._parent, "themeStoreDetailPage", None)
|
||||
|
||||
@@ -257,6 +257,10 @@ class DialogInputModesMixin(InputMixin):
|
||||
if isinstance(cell_widget, QCheckBox) and cell_widget.isEnabled():
|
||||
cell_widget.setChecked(not cell_widget.isChecked())
|
||||
return True
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.isEnabled():
|
||||
cell_widget.showPopup()
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if cell_widget is not None:
|
||||
checkbox = cell_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
@@ -265,7 +269,8 @@ class DialogInputModesMixin(InputMixin):
|
||||
|
||||
# Check if the cell contains a checkbox
|
||||
item = table.item(current_row, current_col)
|
||||
if item and (item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
|
||||
if (item and item.data(Qt.ItemDataRole.CheckStateRole) is not None
|
||||
and item.flags() & Qt.ItemFlag.ItemIsUserCheckable):
|
||||
# Toggle the checkbox state
|
||||
new_state = Qt.CheckState.Checked if item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
|
||||
item.setCheckState(new_state)
|
||||
|
||||
@@ -15,7 +15,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, NavLabel
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_card import AnimatedCard
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
PAD_AXIS_LEFT_X,
|
||||
@@ -267,7 +267,7 @@ class DpadInputMixin(InputMixin):
|
||||
current_index = self._parent.stackedWidget.currentIndex()
|
||||
if code != PAD_DPAD_Y or value >= 0 or current_index not in (0, 1):
|
||||
return False
|
||||
if not isinstance(focused, GameCard):
|
||||
if not isinstance(focused, AnimatedCard):
|
||||
return False
|
||||
if current_index == 0:
|
||||
container = self._parent.gamesListWidget
|
||||
@@ -279,7 +279,7 @@ class DpadInputMixin(InputMixin):
|
||||
if container is None or focus_target is None:
|
||||
return False
|
||||
cards: list[QWidget] = [
|
||||
card for card in container.findChildren(GameCard)
|
||||
card for card in container.findChildren(AnimatedCard)
|
||||
if card.isVisible() and card.isEnabled()
|
||||
]
|
||||
if not cards or focused not in self._get_card_grid_rows(cards)[0]:
|
||||
@@ -354,7 +354,7 @@ class DpadInputMixin(InputMixin):
|
||||
isinstance(focused, QListView)
|
||||
and self._find_parent_combo(focused) is not None
|
||||
)
|
||||
if is_initial_press and not isinstance(focused, GameCard) and not combo_popup:
|
||||
if is_initial_press and not isinstance(focused, AnimatedCard) and not combo_popup:
|
||||
SoundManager().play("navigate")
|
||||
if self._handle_dialog_dpad(active, code, value):
|
||||
return
|
||||
|
||||
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_card import AnimatedCard, GameCard
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
BUTTONS,
|
||||
@@ -406,7 +406,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
active = QApplication.activeWindow()
|
||||
if self.file_explorer or isinstance(active, QMessageBox | QDialog):
|
||||
return False
|
||||
if isinstance(focused, GameCard | QLineEdit | QTableWidget | AutoSizeButton | QCheckBox):
|
||||
if isinstance(focused, AnimatedCard | QLineEdit | QTableWidget | AutoSizeButton | QCheckBox):
|
||||
return False
|
||||
return self._switch_visible_tab(-1 if key == KEY_LEFT else 1)
|
||||
|
||||
@@ -450,6 +450,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
"gamesDisplayCombo",
|
||||
"onlyInstalledCheckBox",
|
||||
"gamesBadgeViewCombo",
|
||||
"gamesLayoutCombo",
|
||||
):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
|
||||
@@ -474,7 +475,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
def _focus_first_game_card(self, container: QWidget | None) -> bool:
|
||||
if container is None:
|
||||
return False
|
||||
for card in container.findChildren(GameCard):
|
||||
for card in container.findChildren(AnimatedCard):
|
||||
if card.isVisible() and card.isEnabled():
|
||||
card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
scroll_area = container.parentWidget()
|
||||
@@ -598,7 +599,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
def _handle_input_back_key(self, focused: QWidget | None) -> bool:
|
||||
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
|
||||
return False
|
||||
self._parent.goBackDetailPage(self._parent.currentDetailPage)
|
||||
self._handle_standard_back()
|
||||
return True
|
||||
|
||||
def _handle_input_key_release(self, key: int) -> bool:
|
||||
|
||||
@@ -175,7 +175,7 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
keyboard.update_keyboard()
|
||||
|
||||
def find_gamepad(self) -> SDLGamepad | None:
|
||||
"""Find the first SDL3 gamepad with a standardized mapping."""
|
||||
"""Open the SDL3 handle that combines all connected gamepads."""
|
||||
try:
|
||||
gamepad = find_gamepad()
|
||||
self._last_gamepad_error = None
|
||||
@@ -353,10 +353,18 @@ class GamepadRuntimeMixin(InputMixin):
|
||||
self._last_gamepad_check_time = current_time
|
||||
return
|
||||
try:
|
||||
active_gamepad.update()
|
||||
active_changed = active_gamepad.update()
|
||||
if not active_gamepad.connected():
|
||||
self.gamepad_hotplug.emit('remove')
|
||||
return
|
||||
if active_changed:
|
||||
self._reset_input_state()
|
||||
self.gamepad_type = self._get_effective_gamepad_type(active_gamepad)
|
||||
self._refresh_gamepad_ui()
|
||||
logger.info(
|
||||
"Active gamepad changed to SDL instance %s",
|
||||
active_gamepad.active_instance_id,
|
||||
)
|
||||
self._poll_button_events(active_gamepad, current_time)
|
||||
self._handle_pending_menu_fullscreen(current_time)
|
||||
self._poll_hat_events(active_gamepad, current_time)
|
||||
|
||||
@@ -327,12 +327,16 @@ class SettingsInputMixin(InputMixin):
|
||||
self._move_mangohud_vertical(focused, normalized_value, sections)
|
||||
return
|
||||
|
||||
# 2. Combo Box Navigation (within Advanced Table)
|
||||
# 2. Combo Box Navigation (within Advanced and Main Tables)
|
||||
table = self._get_current_settings_table()
|
||||
if not table or table.rowCount() == 0:
|
||||
return
|
||||
|
||||
if self.settings_dialog and table == self.settings_dialog.advanced_table and table.currentRow() >= 0:
|
||||
combo_tables = (
|
||||
self.settings_dialog.advanced_table,
|
||||
self.settings_dialog.settings_table,
|
||||
)
|
||||
if self.settings_dialog and table in combo_tables and table.currentRow() >= 0:
|
||||
cell_widget = table.cellWidget(table.currentRow(), 1)
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
@@ -433,6 +437,9 @@ class SettingsInputMixin(InputMixin):
|
||||
|
||||
if table == self.settings_dialog.settings_table:
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.isEnabled():
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if cell_widget is not None:
|
||||
checkbox = cell_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
|
||||
@@ -169,10 +169,19 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Steam"
|
||||
@@ -240,12 +249,26 @@ msgstr ""
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Executable not found"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
@@ -254,10 +277,6 @@ msgstr ""
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -334,15 +353,24 @@ msgstr ""
|
||||
msgid "Denied"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
@@ -406,10 +434,6 @@ msgstr ""
|
||||
msgid "Unknown Application"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
@@ -434,6 +458,19 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -729,6 +766,9 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
@@ -741,6 +781,12 @@ msgstr ""
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr ""
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr ""
|
||||
|
||||
@@ -763,12 +809,6 @@ msgstr ""
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr ""
|
||||
|
||||
@@ -793,15 +833,15 @@ msgstr ""
|
||||
msgid "Create Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr ""
|
||||
|
||||
@@ -833,9 +873,6 @@ msgstr ""
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Later"
|
||||
msgstr ""
|
||||
|
||||
@@ -1101,9 +1138,15 @@ msgstr ""
|
||||
msgid "System WINE"
|
||||
msgstr ""
|
||||
|
||||
msgid "Global Game Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr ""
|
||||
|
||||
@@ -1122,12 +1165,15 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr ""
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr ""
|
||||
|
||||
@@ -1137,6 +1183,9 @@ msgstr ""
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
@@ -1149,6 +1198,12 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
@@ -1158,9 +1213,6 @@ msgstr ""
|
||||
msgid "Default value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr ""
|
||||
|
||||
@@ -1354,9 +1406,6 @@ msgstr ""
|
||||
msgid "Enable {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr ""
|
||||
|
||||
@@ -1433,6 +1482,15 @@ msgstr ""
|
||||
msgid "Round corners (px)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font scale"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font size"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font"
|
||||
msgstr ""
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr ""
|
||||
|
||||
@@ -1742,6 +1800,9 @@ msgstr ""
|
||||
msgid "Layout and limiter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Presets"
|
||||
msgstr ""
|
||||
|
||||
@@ -1827,9 +1888,6 @@ msgstr ""
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -1945,9 +2003,6 @@ msgstr ""
|
||||
msgid "Disk: "
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "Started at"
|
||||
msgstr ""
|
||||
|
||||
@@ -1993,9 +2048,39 @@ msgstr ""
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
@@ -2008,10 +2093,6 @@ msgstr ""
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting"
|
||||
msgstr ""
|
||||
|
||||
@@ -2075,6 +2156,33 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Table"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid Type:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2129,12 +2237,18 @@ msgstr ""
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
@@ -2294,6 +2408,9 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
@@ -2387,9 +2504,6 @@ msgstr ""
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-08-16 16:24+0000\n"
|
||||
"PO-Revision-Date: 2026-09-11 12:20+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -174,12 +174,21 @@ msgstr "Löschen"
|
||||
msgid "Import"
|
||||
msgstr "Import"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Spielordner öffnen"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Verknüpfung bearbeiten"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Aktualisierung"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "EOS-Overlay deaktivieren"
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "EOS-Overlay aktivieren"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Spielordner öffnen"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Von Steam entfernen"
|
||||
|
||||
@@ -249,12 +258,27 @@ msgstr "Desktop"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Fehler beim Speichern der Benutzerdaten: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "Ausführbare Datei nicht gefunden"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Ausführbare Datei nicht gefunden: {0}"
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr "Wähle eine andere ausführbare Datei aus"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "Spielname und Ausführungspfad werden benötigt"
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "Fehler beim Generieren der .desktop-Datei"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
|
||||
@@ -263,10 +287,6 @@ msgstr "Die alte .desktop-Datei konnte nicht gelöscht werden: {error}"
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Die alte .desktop-Datei für '{game_name}' wurde entfernt"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Speichern der .desktop-Datei fehlgeschlagen: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Fehler beim Kopieren des Titelbildes: {error}"
|
||||
@@ -347,15 +367,24 @@ msgstr "Kaputt"
|
||||
msgid "Denied"
|
||||
msgstr "Abgelehnt"
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Spieltitel"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "LETZTER START"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "SPIELZEIT"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Sammlung"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Unbekanntes Spiel"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "Wine wird heruntergeladen…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Sammlung"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Automatische Installation"
|
||||
|
||||
@@ -416,10 +445,6 @@ msgstr "Niemals"
|
||||
msgid "Unknown Application"
|
||||
msgstr "Unbekannte Anwendung"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Ausführbare Datei nicht gefunden: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Datei nicht gefunden: {0}"
|
||||
@@ -445,6 +470,19 @@ msgstr "Abhängigkeiten installieren"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Notwendige 32-Bit-Software fehlen. Jetzt installieren?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Epic-Spieleordner auswählen"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "Eine weitere Epic-Operation läuft"
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Wine-Prefix nicht gefunden"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
|
||||
@@ -799,6 +837,9 @@ msgstr "Min."
|
||||
msgid "sec."
|
||||
msgstr "Sek."
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Spiel anhalten"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Exit"
|
||||
|
||||
@@ -811,6 +852,12 @@ msgstr "Favoriten"
|
||||
msgid "Recent Games"
|
||||
msgstr "Aktuelle Spiele"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Spiel fortsetzen"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Spiel konnte nicht pausiert werden"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Keine aktuellen Spiele"
|
||||
|
||||
@@ -834,12 +881,6 @@ msgstr "Herunterladen"
|
||||
msgid "Size"
|
||||
msgstr "Größe"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "LETZTER START"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "SPIELZEIT"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "HAUPTSPIEL"
|
||||
|
||||
@@ -864,15 +905,15 @@ msgstr "Einstellungen"
|
||||
msgid "Create Log"
|
||||
msgstr "Protokoll erstellen"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Protokollierung beenden"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Ansicht öffnen"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Neuinstallation"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Protokollierung beenden"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "Protokollierung konnte nicht gestartet werden"
|
||||
|
||||
@@ -904,9 +945,6 @@ msgstr "Update verfügbar"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Ein neues AppImage-Update ist verfügbar."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Aktualisierung"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Später"
|
||||
|
||||
@@ -1180,9 +1218,16 @@ msgstr "Der Download oder das Entpacken wurde abgebrochen."
|
||||
msgid "System WINE"
|
||||
msgstr "System WINE"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Einstellungen speichern"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Einstellungen für die exe-Datei"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Einstellungen suchen…"
|
||||
|
||||
@@ -1201,12 +1246,16 @@ msgstr "Wert"
|
||||
msgid "Description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgstr "PPDB öffnen"
|
||||
msgid "Edit PPDB"
|
||||
msgstr "PPDB bearbeiten"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "PPDB zurücksetzen"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Laden der Themes fehlgeschlagen"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "Der Pfad zur ausführbaren Datei ist nicht verfügbar."
|
||||
|
||||
@@ -1216,6 +1265,10 @@ msgstr "PPDB-Datei ist nicht vorhanden unter: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Fehler beim Öffnen der PPDB-Datei:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Information"
|
||||
|
||||
@@ -1230,6 +1283,12 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "Fehler beim Entfernen der PPDB-Datei:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Anwenden der Änderungen fehlgeschlagen. Überprüfe die Protokolle."
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Standard"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "deaktiviert"
|
||||
|
||||
@@ -1239,9 +1298,6 @@ msgstr "Prefix-Name eingeben"
|
||||
msgid "Default value"
|
||||
msgstr "Standardwert"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Anwenden der Änderungen fehlgeschlagen. Überprüfe die Protokolle."
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr "Rahmenloses Fenster"
|
||||
|
||||
@@ -1445,9 +1501,6 @@ msgstr "Voreingestellte Einstellungen"
|
||||
msgid "Enable {0}"
|
||||
msgstr "Einschalten {0}"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Standard"
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr "FSR-Upscaling"
|
||||
|
||||
@@ -1526,6 +1579,18 @@ msgstr "Hintergrund-Deckkraft"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "Abgerundete Ecken (px)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font scale"
|
||||
msgstr "Schriftarten"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font size"
|
||||
msgstr "Schriftarten"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font"
|
||||
msgstr "Schriftarten"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "Oben links"
|
||||
|
||||
@@ -1835,6 +1900,9 @@ msgstr "VRAM-Nutzung"
|
||||
msgid "Layout and limiter"
|
||||
msgstr "Layout und Limiter"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Schriftarten"
|
||||
|
||||
msgid "Presets"
|
||||
msgstr "Voreinstellungen"
|
||||
|
||||
@@ -1924,9 +1992,6 @@ msgstr "Auswahl"
|
||||
msgid "Libraries"
|
||||
msgstr "Bibliotheken"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Schriftarten"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "Winetricks wurde nicht gefunden. Bitte versuche es erneut."
|
||||
|
||||
@@ -2042,9 +2107,6 @@ msgstr "Warten…"
|
||||
msgid "Disk: "
|
||||
msgstr "Datenträger: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Spieltitel"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Begonnen"
|
||||
|
||||
@@ -2090,9 +2152,39 @@ msgstr "GOG-Bibliothek wird aktualisiert… {0}/{1}"
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Aktualisierung der GOG-Bibliothek fehlgeschlagen: {0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "Abmeldung vom Epic-Konto fehlgeschlagen"
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "Kopiere den Autorisierungscode in die Zwischenablage"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr "Anmeldung bei Epic-Konto…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Epic-Anmeldung fehlgeschlagen: {0}"
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr "Epic-Konto verknüpft"
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr "Epic-Konto nicht verbunden"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Epic-Bibliothek wird aktualisiert… {0}/{1}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Aktualisierung der Epic-Bibliothek fehlgeschlagen: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Installationsordner auswählen"
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr "Ein weiterer Download läuft"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Eine andere GOG-Aktion läuft bereits"
|
||||
|
||||
@@ -2105,10 +2197,6 @@ msgstr "GOG-Spielordner auswählen"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "Der ausgewählte Ordner ist nicht dieses GOG-Spiel"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "'{0}' und alle Dateien im Installationsordner löschen?"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "Starten"
|
||||
|
||||
@@ -2172,6 +2260,40 @@ msgstr "Versteckt"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Anzeigetyp von Titelbildern:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Themen"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Hell"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Stable"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Horizontales HUD-Layout"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Horizontales HUD-Layout"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr "Horizontales HUD-Layout"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "Gamepad-Typ:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2226,7 +2348,10 @@ msgid "Hide Auto-Install Tab"
|
||||
msgstr "Tab „Automatische Installation“ ausblenden"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
msgstr "Steuerungshinweise ausblenden"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "Englische Sprache erzwingen"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
@@ -2234,6 +2359,9 @@ msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Kompatibilitätsbericht nach Spielabsturz anzeigen"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Runtime-Download deaktivieren"
|
||||
|
||||
@@ -2397,6 +2525,9 @@ msgstr "Wiedergabegeräte"
|
||||
msgid "Output"
|
||||
msgstr "Ausgang"
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr "Zum Desktop zurückkehren"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Abmelden"
|
||||
|
||||
@@ -2464,9 +2595,8 @@ msgstr "Theme auswählen:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Theme anwenden"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Theme auswählen:"
|
||||
msgstr "Theme löschen"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Kein Link"
|
||||
@@ -2480,20 +2610,16 @@ msgstr "Autor:"
|
||||
msgid "Link:"
|
||||
msgstr "Link:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Theme auswählen:"
|
||||
msgstr "Theme '{0}' löschen?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Laden der Themes fehlgeschlagen"
|
||||
msgstr "Das Theme konnte nicht gelöscht werden."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefix:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "Standardmäßig verwenden"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Wine-Konfiguration"
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-08-11 19:26+0000\n"
|
||||
"PO-Revision-Date: 2026-09-10 15:44+0000\n"
|
||||
"Last-Translator: gallegonovato <gallegonovato@noreply.codeberg.org>\n"
|
||||
"Language-Team: Spanish <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/es/>\n"
|
||||
@@ -176,12 +176,21 @@ msgstr "Borrar"
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir la carpeta del juego"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Editar acceso directo"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Actualizar"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir la carpeta del juego"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Eliminar de Steam"
|
||||
|
||||
@@ -249,6 +258,18 @@ msgstr "Escritorio"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "No se han podido guardar los datos personalizados: {error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "No se ha encontrado el archivo ejecutable: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "No se ha encontrado el archivo ejecutable: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select another executable"
|
||||
msgstr "Ruta al ejecutable:"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
"Es necesario indicar el nombre del juego y la ruta del archivo ejecutable"
|
||||
@@ -256,6 +277,10 @@ msgstr ""
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "No se han podido generar los datos del archivo .desktop"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "No se ha podido guardar el archivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
|
||||
@@ -264,10 +289,6 @@ msgstr "No se ha podido eliminar el archivo .desktop antiguo: {error}"
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Se ha eliminado el antiguo archivo .desktop de «{game_name}»"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "No se ha podido guardar el archivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "No se ha podido copiar la imagen de portada: {error}"
|
||||
@@ -348,15 +369,24 @@ msgstr "Falló"
|
||||
msgid "Denied"
|
||||
msgstr "Denegado"
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título del juego"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANZAMIENTO"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "TIEMPO EMPLEADO"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Juego desconocido"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "Descargando Wine…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Instalación automática"
|
||||
|
||||
@@ -417,10 +447,6 @@ msgstr "Nunca"
|
||||
msgid "Unknown Application"
|
||||
msgstr "Aplicación desconocida"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "No se ha encontrado el archivo ejecutable: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Archivo no encontrado: {0}"
|
||||
@@ -447,6 +473,21 @@ msgstr "Instalar las dependencias"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Faltan algunas dependencias de 32 bits. ¿Las instalas ahora?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Borrar la carpeta"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefijo no encontrado"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Juego no encontrado."
|
||||
|
||||
@@ -816,6 +857,9 @@ msgstr "min"
|
||||
msgid "sec."
|
||||
msgstr "s"
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Pausar el juego"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Salir"
|
||||
|
||||
@@ -828,6 +872,12 @@ msgstr "Favoritos"
|
||||
msgid "Recent Games"
|
||||
msgstr "Juegos recientes"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Reanudar el juego"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Error al pausar el juego"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Sin juegos recientes"
|
||||
|
||||
@@ -850,12 +900,6 @@ msgstr "Descargar"
|
||||
msgid "Size"
|
||||
msgstr "Tamaño"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANZAMIENTO"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "TIEMPO EMPLEADO"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "HISTORIA PRINCIPAL"
|
||||
|
||||
@@ -880,15 +924,15 @@ msgstr "Ajustes"
|
||||
msgid "Create Log"
|
||||
msgstr "Crear registro"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Detener el registro"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Carta abierta"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Reinstalar"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Detener el registro"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "No se pudo iniciar la sesión de depuración"
|
||||
|
||||
@@ -920,9 +964,6 @@ msgstr "Actualización disponible"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Hay una nueva actualización de AppImage disponible."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Actualizar"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Más tarde"
|
||||
|
||||
@@ -1192,9 +1233,16 @@ msgstr ""
|
||||
msgid "System WINE"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Ajustes de Wine"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Buscar ajustes…"
|
||||
|
||||
@@ -1213,12 +1261,16 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Error al cargar los temas"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr ""
|
||||
|
||||
@@ -1228,6 +1280,10 @@ msgstr ""
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "No se ha podido abrir la carpeta: {error}"
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
@@ -1245,6 +1301,12 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "No se ha podido eliminar el archivo PPDB:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
@@ -1254,9 +1316,6 @@ msgstr ""
|
||||
msgid "Default value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr ""
|
||||
|
||||
@@ -1449,9 +1508,6 @@ msgstr ""
|
||||
msgid "Enable {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr ""
|
||||
|
||||
@@ -1528,6 +1584,17 @@ msgstr ""
|
||||
msgid "Round corners (px)"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font scale"
|
||||
msgstr "Local"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font size"
|
||||
msgstr "Tamaño"
|
||||
|
||||
msgid "Font"
|
||||
msgstr ""
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr ""
|
||||
|
||||
@@ -1837,6 +1904,9 @@ msgstr ""
|
||||
msgid "Layout and limiter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Presets"
|
||||
msgstr ""
|
||||
|
||||
@@ -1922,9 +1992,6 @@ msgstr ""
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -2040,9 +2107,6 @@ msgstr "Esperando…"
|
||||
msgid "Disk: "
|
||||
msgstr "Disco: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título del juego"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Comenzó en"
|
||||
|
||||
@@ -2089,9 +2153,40 @@ msgstr ""
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Error al iniciar el juego: {0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Archivo: {0}"
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Error al iniciar el juego: {0}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Error al iniciar el juego: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "Descargando"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
@@ -2105,10 +2200,6 @@ msgstr "Borrar la carpeta"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "Iniciar"
|
||||
@@ -2175,6 +2266,35 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Temas"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Estable"
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid Type:"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2231,12 +2351,18 @@ msgstr ""
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
@@ -2397,6 +2523,10 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "Añadir al escritorio"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
@@ -2491,9 +2621,6 @@ msgstr "Error al cargar los temas"
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefijo:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "Usar por defecto"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Configuración de Wine"
|
||||
|
||||
|
||||
@@ -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-08-21 21:05+0500\n"
|
||||
"POT-Creation-Date: 2026-09-26 12:00+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"
|
||||
@@ -165,10 +165,19 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgid "Edit Shortcut"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Steam"
|
||||
@@ -236,12 +245,26 @@ msgstr ""
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Executable not found"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr ""
|
||||
@@ -250,10 +273,6 @@ msgstr ""
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr ""
|
||||
@@ -330,15 +349,24 @@ msgstr ""
|
||||
msgid "Denied"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr ""
|
||||
|
||||
msgid "Library"
|
||||
msgstr ""
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
@@ -402,10 +430,6 @@ msgstr ""
|
||||
msgid "Unknown Application"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr ""
|
||||
@@ -430,6 +454,19 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -725,6 +762,9 @@ msgstr ""
|
||||
msgid "sec."
|
||||
msgstr ""
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exit"
|
||||
msgstr ""
|
||||
|
||||
@@ -737,6 +777,12 @@ msgstr ""
|
||||
msgid "Recent Games"
|
||||
msgstr ""
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr ""
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr ""
|
||||
|
||||
@@ -759,12 +805,6 @@ msgstr ""
|
||||
msgid "Size"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr ""
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr ""
|
||||
|
||||
@@ -789,15 +829,15 @@ msgstr ""
|
||||
msgid "Create Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr ""
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr ""
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr ""
|
||||
|
||||
@@ -829,9 +869,6 @@ msgstr ""
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Later"
|
||||
msgstr ""
|
||||
|
||||
@@ -1097,9 +1134,15 @@ msgstr ""
|
||||
msgid "System WINE"
|
||||
msgstr ""
|
||||
|
||||
msgid "Global Game Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr ""
|
||||
|
||||
@@ -1118,12 +1161,15 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr ""
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr ""
|
||||
|
||||
@@ -1133,6 +1179,9 @@ msgstr ""
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
@@ -1145,6 +1194,12 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "disabled"
|
||||
msgstr ""
|
||||
|
||||
@@ -1154,9 +1209,6 @@ msgstr ""
|
||||
msgid "Default value"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr ""
|
||||
|
||||
@@ -1350,9 +1402,6 @@ msgstr ""
|
||||
msgid "Enable {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Default"
|
||||
msgstr ""
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr ""
|
||||
|
||||
@@ -1429,6 +1478,15 @@ msgstr ""
|
||||
msgid "Round corners (px)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font scale"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font size"
|
||||
msgstr ""
|
||||
|
||||
msgid "Font"
|
||||
msgstr ""
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr ""
|
||||
|
||||
@@ -1738,6 +1796,9 @@ msgstr ""
|
||||
msgid "Layout and limiter"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Presets"
|
||||
msgstr ""
|
||||
|
||||
@@ -1823,9 +1884,6 @@ msgstr ""
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
@@ -1941,9 +1999,6 @@ msgstr ""
|
||||
msgid "Disk: "
|
||||
msgstr ""
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr ""
|
||||
|
||||
msgid "Started at"
|
||||
msgstr ""
|
||||
|
||||
@@ -1989,9 +2044,39 @@ msgstr ""
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
@@ -2004,10 +2089,6 @@ msgstr ""
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Starting"
|
||||
msgstr ""
|
||||
|
||||
@@ -2071,6 +2152,33 @@ msgstr ""
|
||||
msgid "Badge View Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
msgid "List"
|
||||
msgstr ""
|
||||
|
||||
msgid "Table"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr ""
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
|
||||
msgid "Grid Type:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2125,12 +2233,18 @@ msgstr ""
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr ""
|
||||
|
||||
@@ -2291,6 +2405,9 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
@@ -2384,9 +2501,6 @@ msgstr ""
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Configuration"
|
||||
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-08-20 21:25+0000\n"
|
||||
"PO-Revision-Date: 2026-09-09 00:26+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"
|
||||
@@ -174,12 +174,21 @@ msgstr "Excluir"
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir Pasta do Jogo"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Editar Atalho"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Atualizar"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "Desativar sobreposição do EOS"
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "Ativar sobreposição do EOS"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir Pasta do Jogo"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Remover da Steam"
|
||||
|
||||
@@ -249,12 +258,28 @@ msgstr "Área de Trabalho"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Falha ao salvar os dados personalizados: {error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "Executável não encontrado: {0}"
|
||||
|
||||
#, 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"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "O nome do jogo e o caminho do executável são obrigatórios"
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "Falha ao gerar os dados do arquivo .desktop"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Falha ao salvar o arquivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
|
||||
@@ -263,10 +288,6 @@ msgstr "Falha ao excluir o arquivo .desktop antigo: {error}"
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Arquivo .desktop antigo de '{game_name}' removido"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Falha ao salvar o arquivo .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "Falha ao copiar a imagem de capa: {error}"
|
||||
@@ -347,15 +368,24 @@ msgstr "Quebrado"
|
||||
msgid "Denied"
|
||||
msgstr "Negado"
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título do jogo"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANÇAMENTO"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "TEMPO JOGADO"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Jogo Desconhecido"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "Baixando Wine…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Biblioteca"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Instalação Automática"
|
||||
|
||||
@@ -416,10 +446,6 @@ msgstr "Nunca"
|
||||
msgid "Unknown Application"
|
||||
msgstr "Aplicativo Desconhecido"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Executável não encontrado: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Arquivo não encontrado: {0}"
|
||||
@@ -444,6 +470,19 @@ msgstr "Instalar dependências"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "As dependências de 32 bits estão faltando. Instalá-las agora?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Excluir '{0}' e todos os arquivos da sua pasta de instalação?"
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "elecionar pasta do jogo da Epic"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "Outra operação da Epic está em andamento"
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefixo do Wine não encontrado"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
@@ -801,6 +840,10 @@ msgstr "min."
|
||||
msgid "sec."
|
||||
msgstr "s."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Pause Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Sair"
|
||||
|
||||
@@ -813,6 +856,14 @@ msgstr "Favoritos"
|
||||
msgid "Recent Games"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Resume Game"
|
||||
msgstr "Jogos Recentes"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Falha ao parar o jogo"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Nenhum jogo recente"
|
||||
|
||||
@@ -835,12 +886,6 @@ msgstr "Baixar"
|
||||
msgid "Size"
|
||||
msgstr "Tamanho"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ÚLTIMO LANÇAMENTO"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "TEMPO JOGADO"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "HISTÓRIA PRINCIPAL"
|
||||
|
||||
@@ -865,15 +910,15 @@ msgstr "Configurações"
|
||||
msgid "Create Log"
|
||||
msgstr "Criar log"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Encerrar log"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Abrir cartão"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Reinstale"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Encerrar log"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "Falha ao iniciar a sessão de depuração"
|
||||
|
||||
@@ -905,9 +950,6 @@ msgstr "Atualização disponível"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Uma nova atualização do AppImage está disponível."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Atualizar"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Mais tarde"
|
||||
|
||||
@@ -1180,9 +1222,16 @@ msgstr "O download ou a extração foi cancelado."
|
||||
msgid "System WINE"
|
||||
msgstr "WINE do Sistema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Salvar Configurações"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Configurações do Exe"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Pesquisar configurações…"
|
||||
|
||||
@@ -1201,12 +1250,16 @@ msgstr "Valor"
|
||||
msgid "Description"
|
||||
msgstr "Descrição"
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgstr "Abrir PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr "Editar PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Limpar PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Falha ao carregar temas"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "O caminho do executável não está disponível."
|
||||
|
||||
@@ -1216,6 +1269,10 @@ msgstr "O arquivo PPDB não existe em: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Falha ao abrir o arquivo PPDB:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Informação"
|
||||
|
||||
@@ -1230,6 +1287,12 @@ msgstr ""
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "Falha ao remover o arquivo PPDB:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Falha ao aplicar as alterações. Verifique os logs."
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Padrão"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "desativado"
|
||||
|
||||
@@ -1239,9 +1302,6 @@ msgstr "Digite o nome do prefixo"
|
||||
msgid "Default value"
|
||||
msgstr "Valor padrão"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Falha ao aplicar as alterações. Verifique os logs."
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr "Janela sem bordas"
|
||||
|
||||
@@ -1445,9 +1505,6 @@ msgstr "Predefinições rápidas"
|
||||
msgid "Enable {0}"
|
||||
msgstr "Ativar {0}"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Padrão"
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr "FSR upscaling"
|
||||
|
||||
@@ -1524,6 +1581,18 @@ msgstr "Opacidade do fundo"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "Cantos arredondados (px)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font scale"
|
||||
msgstr "Fontes"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font size"
|
||||
msgstr "Fontes"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font"
|
||||
msgstr "Fontes"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "Superior esquerdo"
|
||||
|
||||
@@ -1833,6 +1902,9 @@ msgstr "Uso de VRAM"
|
||||
msgid "Layout and limiter"
|
||||
msgstr "Layout e limitador"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Fontes"
|
||||
|
||||
msgid "Presets"
|
||||
msgstr "Predefinições"
|
||||
|
||||
@@ -1922,9 +1994,6 @@ msgstr "Definir"
|
||||
msgid "Libraries"
|
||||
msgstr "Bibliotecas"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Fontes"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "Winetricks não encontrado. Tente novamente."
|
||||
|
||||
@@ -2040,9 +2109,6 @@ msgstr "Aguardando…"
|
||||
msgid "Disk: "
|
||||
msgstr "Disco: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Título do jogo"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Iniciado em"
|
||||
|
||||
@@ -2088,9 +2154,39 @@ msgstr "Atualizando biblioteca da GOG… {0}/{1}"
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "Falha ao sair da conta da Epic"
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "Copiar o código de autorização para a área de transferência"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr "Autorização da Epic…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Falha no login da Epic: {0}"
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr "Conta da Epic conectada"
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr "Conta Epic não conectada"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Atualizando biblioteca da Epic… {0}/{1}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da Epic: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Selecionar pasta de instalação"
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr "Outro download está em andamento"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Outra operação da GOG já está em execução"
|
||||
|
||||
@@ -2103,10 +2199,6 @@ msgstr "Selecionar pasta do jogo da GOG"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "A pasta selecionada não corresponde a este jogo da GOG"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Excluir '{0}' e todos os arquivos da sua pasta de instalação?"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "Iniciando"
|
||||
|
||||
@@ -2170,6 +2262,40 @@ msgstr "Oculto"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Modo de Exibição do Selo:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Temas"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Claro"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Estável"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Layout horizontal do HUD"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Layout horizontal do HUD"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr "Layout horizontal do HUD"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "Tipo de Controle:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2226,12 +2352,18 @@ msgstr "Ocultar Aba de Instalação Automática"
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Ocultar dicas de controle"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "Forçar idioma inglês"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Ativar sons da interface"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
|
||||
@@ -2393,8 +2525,11 @@ msgstr "Dispositivos de reprodução"
|
||||
msgid "Output"
|
||||
msgstr "Saída"
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr "Voltar para a área de trabalho"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Sair"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
msgid "Reboot"
|
||||
msgstr "Reiniciar"
|
||||
@@ -2460,9 +2595,8 @@ msgstr "Selecionar Tema:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Aplicar Tema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Selecionar Tema:"
|
||||
msgstr "Excluir tema"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Sem link"
|
||||
@@ -2476,20 +2610,16 @@ msgstr "Autor:"
|
||||
msgid "Link:"
|
||||
msgstr "Link:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Selecionar Tema:"
|
||||
msgstr "Excluir o tema '{0}'?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Falha ao carregar temas"
|
||||
msgstr "Não foi possível excluir o tema."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefixo:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "Usar por padrão"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Configuração do Wine"
|
||||
|
||||
|
||||
@@ -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-08-24 10:25+0000\n"
|
||||
"PO-Revision-Date: 2026-09-20 18:28+0000\n"
|
||||
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
@@ -19,7 +19,7 @@ msgstr ""
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -112,8 +112,8 @@ msgid ""
|
||||
"Are you sure you want to delete folder '{folder_name}'? This action "
|
||||
"cannot be undone."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите удалить папку '{folder_name}'? Это действие нельзя"
|
||||
" отменить."
|
||||
"Уверены, что хотите удалить папку «{folder_name}»? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
msgid "Yes"
|
||||
msgstr "Да"
|
||||
@@ -130,8 +130,7 @@ msgid ""
|
||||
"Are you sure you want to delete file '{file_name}'? This action cannot be"
|
||||
" undone."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите удалить файл '{file_name}'? Это действие нельзя "
|
||||
"отменить."
|
||||
"Уверены, что хотите удалить файл «{file_name}»? Это действие нельзя отменить."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete file: {error}"
|
||||
@@ -175,12 +174,21 @@ msgstr "Удалить"
|
||||
msgid "Import"
|
||||
msgstr "Импорт"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Открыть папку с игрой"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "Редактировать ярлык"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "Отключить наложение EOS"
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "Включить наложение EOS"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Открыть папку с игрой"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "Удалить из Steam"
|
||||
|
||||
@@ -223,8 +231,8 @@ msgid ""
|
||||
"Are you sure you want to delete '{game_name}'? This will remove the "
|
||||
".desktop file and custom data."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению "
|
||||
"файла .desktop и пользовательских данных."
|
||||
"Уверены, что хотите удалить «{game_name}»? Это приведёт к удалению файла "
|
||||
".desktop и пользовательских данных."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to add '{game_name}' to {location}: {error}"
|
||||
@@ -248,23 +256,34 @@ msgstr "Рабочий стол"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "Не удалось сохранить пользовательские данные: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "Исполняемый файл не найден"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Исполняемый файл не найден: {0}"
|
||||
|
||||
msgid "Select another executable"
|
||||
msgstr "Выбрать другой исполняемый файл"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "Требуются название игры и путь к исполняемому файлу"
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "Не удалось сгенерировать данные файла .desktop"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "Не удалось удалить старый файл .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Удален старый файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "Не удалось сохранить файл .desktop: {error}"
|
||||
msgstr "Удалён старый файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
@@ -346,15 +365,24 @@ msgstr "Сломано"
|
||||
msgid "Denied"
|
||||
msgstr "Отказано"
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Название игры"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ПОСЛЕДНИЙ ЗАПУСК"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "ПРОВЕДЁННОЕ ВРЕМЯ"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Библиотека"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "Неизвестная игра"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "Скачивание Wine…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "Библиотека"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "Автоустановка"
|
||||
|
||||
@@ -415,10 +443,6 @@ msgstr "Никогда"
|
||||
msgid "Unknown Application"
|
||||
msgstr "Неизвестное приложение"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "Исполняемый файл не найден: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "Файл не найден: {0}"
|
||||
@@ -443,6 +467,19 @@ msgstr "Установить зависимости"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Отсутствуют 32-битные зависимости. Установить их сейчас?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Удалить «{0}» и все файлы в папке установки?"
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Выберите папку игры Epic"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "Другая операция Epic уже выполняется"
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Префикс Wine не найден"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
|
||||
@@ -558,7 +595,7 @@ msgid "Force DirectInput protocol instead of XInput"
|
||||
msgstr "Принудительно использовать протокол DirectInput вместо XInput"
|
||||
|
||||
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||
msgstr "Использовать GStreamer для внутриигровых клипов (поддержка WMF)"
|
||||
msgstr "Использовать GStreamer для внутриигровых роликов (поддержка WMF)"
|
||||
|
||||
msgid "Use WINE shader caching"
|
||||
msgstr "Использовать кэширование шейдеров WINE"
|
||||
@@ -594,8 +631,8 @@ msgid ""
|
||||
"Prevent the system from going to sleep and disable the screensaver while "
|
||||
"the game is running"
|
||||
msgstr ""
|
||||
"Предотвратите переход системы в спящий режим и отключите заставку во "
|
||||
"время игры"
|
||||
"Предотвращать переход системы в спящий режим и отключать заставку во время "
|
||||
"игры"
|
||||
|
||||
msgid "Use container launch mode (recommended default)"
|
||||
msgstr "Использовать режим запуска в контейнере (рекомендуемый по умолчанию)"
|
||||
@@ -619,7 +656,7 @@ msgid "Stable"
|
||||
msgstr "Стабильный"
|
||||
|
||||
msgid "Vulkan Backend"
|
||||
msgstr "Vulkan рендеринг"
|
||||
msgstr "Бэкенд Vulkan"
|
||||
|
||||
msgid ""
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
@@ -773,9 +810,9 @@ msgid ""
|
||||
" single node reduces memory-access latency and limits costly core-to-core"
|
||||
" switches."
|
||||
msgstr ""
|
||||
"Узел NUMA для аффинности CPU. В многоядерных системах CPU разделены на "
|
||||
"узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка игры к "
|
||||
"одному узлу уменьшает задержку доступа к памяти и ограничивает "
|
||||
"Узел NUMA для привязки процессора. В многоядерных системах процессоры "
|
||||
"разделены на узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка "
|
||||
"игры к одному узлу уменьшает задержку доступа к памяти и ограничивает "
|
||||
"дорогостоящие переключения между ядрами."
|
||||
|
||||
msgid "Sound Driver"
|
||||
@@ -801,6 +838,9 @@ msgstr "мин."
|
||||
msgid "sec."
|
||||
msgstr "сек."
|
||||
|
||||
msgid "Pause Game"
|
||||
msgstr "Приостановить игру"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "Выход"
|
||||
|
||||
@@ -813,6 +853,12 @@ msgstr "Избранное"
|
||||
msgid "Recent Games"
|
||||
msgstr "Недавние"
|
||||
|
||||
msgid "Resume Game"
|
||||
msgstr "Возобновить игру"
|
||||
|
||||
msgid "Failed to pause game"
|
||||
msgstr "Не удалось приостановить игру"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Нет недавних игр"
|
||||
|
||||
@@ -821,7 +867,7 @@ msgstr "Нет избранных"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Launch \"{name}\" with PortProton"
|
||||
msgstr "Запустить \"{name}\" с помощью PortProton"
|
||||
msgstr "Запустить «{name}» с помощью PortProton"
|
||||
|
||||
msgid "Debug log mode was launched"
|
||||
msgstr "Запущен режим логирования"
|
||||
@@ -835,12 +881,6 @@ msgstr "Скачать"
|
||||
msgid "Size"
|
||||
msgstr "Размер"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "ПОСЛЕДНИЙ ЗАПУСК"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "ПРОВЕДЁННОЕ ВРЕМЯ"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "СЮЖЕТ"
|
||||
|
||||
@@ -865,15 +905,15 @@ msgstr "Настройки"
|
||||
msgid "Create Log"
|
||||
msgstr "Создать лог"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Остановить лог"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "Открыть карточку"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "Переустановить"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "Остановить лог"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "Не удалось запустить процесс отладки"
|
||||
|
||||
@@ -905,9 +945,6 @@ msgstr "Доступно обновление"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Доступно новое обновление AppImage."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Позже"
|
||||
|
||||
@@ -958,7 +995,7 @@ msgid "No cover selected"
|
||||
msgstr "Обложка не выбрана"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr "Отчет о совместимости"
|
||||
msgstr "Отчёт о совместимости"
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
@@ -1128,7 +1165,7 @@ msgid ""
|
||||
"\n"
|
||||
"This action cannot be undone."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите удалить {} выбранные WINE/Proton?\n"
|
||||
"Уверены, что хотите удалить {} выбранных WINE/Proton?\n"
|
||||
"\n"
|
||||
"Это действие нельзя отменить."
|
||||
|
||||
@@ -1151,7 +1188,7 @@ msgstr "Загрузка: {0} ({1}%)"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Extracting: {0}"
|
||||
msgstr "Распаковка {0}"
|
||||
msgstr "Распаковка: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ", ETA: {}s"
|
||||
@@ -1180,9 +1217,16 @@ msgstr "Скачивание или распаковка отменены."
|
||||
msgid "System WINE"
|
||||
msgstr "Системный WINE"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "Сохранить настройки"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "Настройки EXE"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "Поиск настроек…"
|
||||
|
||||
@@ -1201,12 +1245,16 @@ msgstr "Значение"
|
||||
msgid "Description"
|
||||
msgstr "Описание"
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgstr "Открыть PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr "Редактировать PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Очистить PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "Не удалось загрузить темы"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "Путь к исполняемому файлу недоступен."
|
||||
|
||||
@@ -1216,6 +1264,10 @@ msgstr "Файл PPDB не существует по адресу: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "Не удалось открыть файл PPDB:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "Не удалось открыть файл PPDB:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Информация"
|
||||
|
||||
@@ -1223,11 +1275,17 @@ msgid "Confirm PPDB Clear"
|
||||
msgstr "Подтвердите очистку PPDB"
|
||||
|
||||
msgid "Are you sure you want to clear settings? This action cannot be undone."
|
||||
msgstr "Вы уверены, что хотите сбросить настройки? Это действие нельзя отменить."
|
||||
msgstr "Уверены, что хотите сбросить настройки? Это действие нельзя отменить."
|
||||
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "Не удалось удалить файл PPDB:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Не удалось применить изменения. Проверьте логи."
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Стандартный"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "отключено"
|
||||
|
||||
@@ -1237,9 +1295,6 @@ msgstr "Введите имя префикса"
|
||||
msgid "Default value"
|
||||
msgstr "По умолчанию"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "Не удалось применить изменения. Проверьте логи."
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr "Без рамок"
|
||||
|
||||
@@ -1265,7 +1320,7 @@ msgid "Enable HDR output"
|
||||
msgstr "Включить вывод HDR"
|
||||
|
||||
msgid "Enable SDR→HDR inverse tone mapping"
|
||||
msgstr "Включить SDR→HDR инвертированное тональное отображение"
|
||||
msgstr "Включить обратное тональное отображение SDR→HDR"
|
||||
|
||||
msgid "Force HDR support"
|
||||
msgstr "Принудительная поддержка HDR"
|
||||
@@ -1283,10 +1338,10 @@ msgid "Enable adaptive sync"
|
||||
msgstr "Включить адаптивную синхронизацию"
|
||||
|
||||
msgid "Output width"
|
||||
msgstr "Выходная ширина"
|
||||
msgstr "Ширина вывода"
|
||||
|
||||
msgid "Output height"
|
||||
msgstr "Выходная высота"
|
||||
msgstr "Высота вывода"
|
||||
|
||||
msgid "Game width"
|
||||
msgstr "Ширина игры"
|
||||
@@ -1307,7 +1362,7 @@ msgid "Upscaler type"
|
||||
msgstr "Режим масштабирования"
|
||||
|
||||
msgid "Upscaler filter"
|
||||
msgstr "Режим интерполяции"
|
||||
msgstr "Фильтр масштабирования"
|
||||
|
||||
msgid "Upscaler sharpness (0-20)"
|
||||
msgstr "Улучшение резкости (0-20)"
|
||||
@@ -1428,7 +1483,7 @@ msgid "Display heatmap-style debug view of HDR luminance in nits"
|
||||
msgstr "Отображение отладочной тепловой карты вида яркости HDR в нитах"
|
||||
|
||||
msgid "Launch with the mangoapp (mangohud) performance overlay enabled"
|
||||
msgstr "Запуск с включенным mangoapp (mangohud)"
|
||||
msgstr "Запуск с оверлеем производительности mangoapp (mangohud)"
|
||||
|
||||
msgid "Enable adaptive sync if available (variable rate refresh)"
|
||||
msgstr ""
|
||||
@@ -1445,9 +1500,6 @@ msgstr "Быстрые профили"
|
||||
msgid "Enable {0}"
|
||||
msgstr "Включить {0}"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "Стандартный"
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr "FSR масштабирование"
|
||||
|
||||
@@ -1524,6 +1576,15 @@ msgstr "Прозрачность фона"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "Скругление углов (пикс)"
|
||||
|
||||
msgid "Font scale"
|
||||
msgstr "Масштаб шрифта"
|
||||
|
||||
msgid "Font size"
|
||||
msgstr "Размер шрифта"
|
||||
|
||||
msgid "Font"
|
||||
msgstr "Шрифт"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "Слева сверху"
|
||||
|
||||
@@ -1804,7 +1865,7 @@ msgid "Local time"
|
||||
msgstr "Местное время"
|
||||
|
||||
msgid "Time without label"
|
||||
msgstr "Скрыть слово \"Time\" перед временем"
|
||||
msgstr "Скрывать слово «Time» перед временем"
|
||||
|
||||
msgid "Force trilinear filtering"
|
||||
msgstr "Включить трилинейную фильтрацию"
|
||||
@@ -1833,6 +1894,9 @@ msgstr "Использование видеопамяти"
|
||||
msgid "Layout and limiter"
|
||||
msgstr "Компоновка и ограничения"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Шрифты"
|
||||
|
||||
msgid "Presets"
|
||||
msgstr "Профили"
|
||||
|
||||
@@ -1887,7 +1951,7 @@ msgid "Failed to read default MangoHud config."
|
||||
msgstr "Ошибка при чтении стандартной конфигурации MangoHud."
|
||||
|
||||
msgid "System MangoHud config has been written."
|
||||
msgstr "Конфигурация записана в системный конфиг."
|
||||
msgstr "Конфигурация MangoHud записана в системный конфиг."
|
||||
|
||||
msgid "Failed to write system MangoHud config."
|
||||
msgstr "Ошибка при записи конфигурации MangoHud."
|
||||
@@ -1921,9 +1985,6 @@ msgstr "Выбор"
|
||||
msgid "Libraries"
|
||||
msgstr "Библиотеки"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Шрифты"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "Winetricks не найден. Повторите попытку."
|
||||
|
||||
@@ -2039,9 +2100,6 @@ msgstr "Ожидание…"
|
||||
msgid "Disk: "
|
||||
msgstr "Диск: "
|
||||
|
||||
msgid "Game Title"
|
||||
msgstr "Название игры"
|
||||
|
||||
msgid "Started at"
|
||||
msgstr "Начато"
|
||||
|
||||
@@ -2085,9 +2143,39 @@ msgstr "Обновление библиотеки GOG… {0}/{1}"
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Не удалось обновить библиотеку GOG: {0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "Не удалось выйти из учётной записи Epic"
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "Скопировать код авторизации в буфер обмена"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr "Авторизация Epic…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Ошибка входа в Epic: {0}"
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr "Учётная запись Epic подключена"
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr "Учётная запись Epic не подключена"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Обновление библиотеки Epic… {0}/{1}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Не удалось обновить библиотеку Epic: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Выберите папку установки"
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr "Другая загрузка уже выполняется"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Другая операция GOG уже выполняется"
|
||||
|
||||
@@ -2100,10 +2188,6 @@ msgstr "Выберите папку игры GOG"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "Выбранная папка не является этой игрой GOG"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr "Удалить «{0}» и все файлы в папке установки?"
|
||||
|
||||
msgid "Starting"
|
||||
msgstr "Запуск"
|
||||
|
||||
@@ -2130,7 +2214,7 @@ msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
msgid "Delete cards without executable"
|
||||
msgstr "Удалить карточки без исполняемого файла"
|
||||
msgstr "Удалить карточки без исполняемых файлов"
|
||||
|
||||
msgid "Library Settings"
|
||||
msgstr "Настройки Библиотеки"
|
||||
@@ -2154,10 +2238,10 @@ msgid "Display Filter:"
|
||||
msgstr "Фильтр:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Только Установленные"
|
||||
msgstr "Только установленные"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Детальный"
|
||||
msgstr "Подробный"
|
||||
|
||||
msgid "Hidden"
|
||||
msgstr "Скрытый"
|
||||
@@ -2165,13 +2249,47 @@ msgstr "Скрытый"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "Тип отображения бейджей:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "Темы"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "Светлая"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "Стабильный"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "Горизонтальное расположение HUD"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "Горизонтальное расположение HUD"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr "Горизонтальное расположение HUD"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "Тип геймпада:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
"will remove the .desktop files and custom data."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите удалить {count} карточек без исполняемого файла? "
|
||||
"Это приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
"Уверены, что хотите удалить {count} карточек без исполняемых файлов? Это "
|
||||
"приведёт к удалению файлов .desktop и пользовательских данных."
|
||||
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Настройки PortProtonQt"
|
||||
@@ -2201,7 +2319,7 @@ msgid "Gamepad Type:"
|
||||
msgstr "Тип геймпада:"
|
||||
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Запуск приложения в полноэкранном режиме"
|
||||
msgstr "Запускать приложение в полноэкранном режиме"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
|
||||
@@ -2210,22 +2328,28 @@ msgid "Minimize to tray on close"
|
||||
msgstr "Сворачивать в область уведомления при закрытии"
|
||||
|
||||
msgid "Run at system startup"
|
||||
msgstr "Запускать при запуске системы"
|
||||
msgstr "Запускать при старте системы"
|
||||
|
||||
msgid "Start in tray"
|
||||
msgstr "Запускать свёрнутым"
|
||||
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Скрыть вкладку \"Автоустановка\""
|
||||
msgstr "Скрывать вкладку «Автоустановка»"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Скрыть подсказки управления"
|
||||
msgstr "Скрывать подсказки управления"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "Принудительно использовать английский язык"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Включить звуки интерфейса"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Показать отчет о совместимости после сбоя игры"
|
||||
msgstr "Показывать отчёт о совместимости после сбоя игры"
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Отключить скачивание контейнера"
|
||||
@@ -2307,8 +2431,7 @@ msgstr "Подтверждение сброса"
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
|
||||
"отменить."
|
||||
"Уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
|
||||
|
||||
msgid "Migrate old PortProton shortcuts to PortProtonQt format?"
|
||||
msgstr "Обновить старые ярлыки PortProton до формата PortProtonQt?"
|
||||
@@ -2317,7 +2440,7 @@ msgid "Confirm Clear Cache"
|
||||
msgstr "Подтверждение очистки кэша"
|
||||
|
||||
msgid "Are you sure you want to clear the cache? This action cannot be undone."
|
||||
msgstr "Вы уверены, что хотите очистить кэш? Это действие нельзя отменить."
|
||||
msgstr "Уверены, что хотите очистить кэш? Это действие нельзя отменить."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
@@ -2387,6 +2510,9 @@ msgstr "Устройства воспроизведения"
|
||||
msgid "Output"
|
||||
msgstr "Вывод"
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr "Вернуться на рабочий стол"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Выйти"
|
||||
|
||||
@@ -2474,14 +2600,11 @@ msgid "Delete theme '{0}'?"
|
||||
msgstr "Удалить тему '{0}'?"
|
||||
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Не удалось удалить тему"
|
||||
msgstr "Не удалось удалить тему."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Префикс:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "Использовать по умолчанию"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Конфигурация Wine"
|
||||
|
||||
@@ -2511,7 +2634,7 @@ msgstr "Не удалось запустить процесс."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to clear prefix '{}'?"
|
||||
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
|
||||
msgstr "Уверены, что хотите очистить префикс «{}»?"
|
||||
|
||||
msgid "Failed to start prefix clear process."
|
||||
msgstr "Не удалось запустить процесс очистки префикса."
|
||||
@@ -2544,7 +2667,7 @@ msgstr "Восстановление префикса не удалось."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete prefix '{}'?"
|
||||
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
|
||||
msgstr "Уверены, что хотите удалить префикс «{}»?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix '{}' deleted."
|
||||
|
||||
@@ -169,12 +169,23 @@ msgstr "删除"
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "打开游戏文件夹"
|
||||
|
||||
msgid "Edit Shortcut"
|
||||
msgstr "编辑快捷方式"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "更新"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "启用 mangoapp 叠加层"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "启用 mangoapp 叠加层"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "打开游戏文件夹"
|
||||
|
||||
msgid "Remove from Steam"
|
||||
msgstr "从 Steam 移除"
|
||||
|
||||
@@ -240,12 +251,28 @@ msgstr "桌面"
|
||||
msgid "Failed to save custom data: {error}"
|
||||
msgstr "保存自定义数据失败:{error}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Executable not found"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select another executable"
|
||||
msgstr "删除没有可执行文件的卡片"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "游戏名称和可执行文件路径为必填项"
|
||||
|
||||
msgid "Failed to generate .desktop file data"
|
||||
msgstr "生成 .desktop 文件数据失败"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "保存 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete old .desktop file: {error}"
|
||||
msgstr "删除旧的 .desktop 文件失败:{error}"
|
||||
@@ -254,10 +281,6 @@ msgstr "删除旧的 .desktop 文件失败:{error}"
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "已移除\"{game_name}\"的旧版 .desktop 文件"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
msgstr "保存 .desktop 文件失败:{error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to copy cover image: {error}"
|
||||
msgstr "复制封面图像失败:{error}"
|
||||
@@ -334,15 +357,25 @@ msgstr "损坏"
|
||||
msgid "Denied"
|
||||
msgstr "已拒绝"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Game Title"
|
||||
msgstr "游戏宽度"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "上次启动"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "游戏时长"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "库"
|
||||
|
||||
msgid "Unknown Game"
|
||||
msgstr "未知游戏"
|
||||
|
||||
msgid "Downloading Wine…"
|
||||
msgstr "正在下载 Wine…"
|
||||
|
||||
msgid "Library"
|
||||
msgstr "库"
|
||||
|
||||
msgid "Auto Install"
|
||||
msgstr "自动安装"
|
||||
|
||||
@@ -404,10 +437,6 @@ msgstr "从不"
|
||||
msgid "Unknown Application"
|
||||
msgstr "未知应用程序"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found: {0}"
|
||||
msgstr "找不到可执行文件:{0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "File not found: {0}"
|
||||
msgstr "找不到文件:{0}"
|
||||
@@ -432,6 +461,21 @@ msgstr "安装依赖"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "未找到前缀"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
@@ -758,6 +802,10 @@ msgstr "分钟。"
|
||||
msgid "sec."
|
||||
msgstr "秒。"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Pause Game"
|
||||
msgstr "启动游戏"
|
||||
|
||||
msgid "Exit"
|
||||
msgstr "退出"
|
||||
|
||||
@@ -770,6 +818,14 @@ msgstr "收藏夹"
|
||||
msgid "Recent Games"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Resume Game"
|
||||
msgstr "最近游戏"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to pause game"
|
||||
msgstr "停止游戏失败"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "无最近游戏"
|
||||
|
||||
@@ -792,12 +848,6 @@ msgstr "下载"
|
||||
msgid "Size"
|
||||
msgstr "大小"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "上次启动"
|
||||
|
||||
msgid "TIME SPENT"
|
||||
msgstr "游戏时长"
|
||||
|
||||
msgid "MAIN STORY"
|
||||
msgstr "主线剧情"
|
||||
|
||||
@@ -822,15 +872,15 @@ msgstr "设置"
|
||||
msgid "Create Log"
|
||||
msgstr "创建日志"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "停止日志"
|
||||
|
||||
msgid "Open Card"
|
||||
msgstr "打开卡片"
|
||||
|
||||
msgid "Reinstall"
|
||||
msgstr "重新安装"
|
||||
|
||||
msgid "Stop Log"
|
||||
msgstr "停止日志"
|
||||
|
||||
msgid "Failed to start debug session"
|
||||
msgstr "启动调试会话失败"
|
||||
|
||||
@@ -862,9 +912,6 @@ msgstr "有可用更新"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "有新的 AppImage 更新可用。"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "更新"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "稍后"
|
||||
|
||||
@@ -1136,9 +1183,16 @@ msgstr "下载或提取已被取消。"
|
||||
msgid "System WINE"
|
||||
msgstr "系统 WINE"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Global Game Settings"
|
||||
msgstr "保存设置"
|
||||
|
||||
msgid "Exe Settings"
|
||||
msgstr "可执行文件设置"
|
||||
|
||||
msgid "Edit user.conf"
|
||||
msgstr ""
|
||||
|
||||
msgid "Search settings…"
|
||||
msgstr "搜索设置…"
|
||||
|
||||
@@ -1157,12 +1211,16 @@ msgstr "值"
|
||||
msgid "Description"
|
||||
msgstr "描述"
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgstr "打开 PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr "编辑 PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to load global game settings."
|
||||
msgstr "加载主题失败"
|
||||
|
||||
msgid "Executable path is not available."
|
||||
msgstr "可执行文件路径不可用。"
|
||||
|
||||
@@ -1172,6 +1230,10 @@ msgstr "PPDB 文件不存在于: "
|
||||
msgid "Failed to open PPDB file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to open file:\n"
|
||||
msgstr "打开 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "信息"
|
||||
|
||||
@@ -1184,6 +1246,12 @@ msgstr "确定要清除设置吗?此操作无法撤销。"
|
||||
msgid "Failed to remove PPDB file:\n"
|
||||
msgstr "移除 PPDB 文件失败:\n"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "应用更改失败。请检查日志。"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "默认"
|
||||
|
||||
msgid "disabled"
|
||||
msgstr "已禁用"
|
||||
|
||||
@@ -1193,9 +1261,6 @@ msgstr "输入前缀名称"
|
||||
msgid "Default value"
|
||||
msgstr "默认值"
|
||||
|
||||
msgid "Failed to apply changes. Check logs."
|
||||
msgstr "应用更改失败。请检查日志。"
|
||||
|
||||
msgid "Borderless window"
|
||||
msgstr "无边框窗口"
|
||||
|
||||
@@ -1389,9 +1454,6 @@ msgstr "快速预设"
|
||||
msgid "Enable {0}"
|
||||
msgstr "启用 {0}"
|
||||
|
||||
msgid "Default"
|
||||
msgstr "默认"
|
||||
|
||||
msgid "FSR upscaling"
|
||||
msgstr "FSR 升频"
|
||||
|
||||
@@ -1468,6 +1530,18 @@ msgstr "背景不透明度"
|
||||
msgid "Round corners (px)"
|
||||
msgstr "圆角(像素)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font scale"
|
||||
msgstr "字体"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font size"
|
||||
msgstr "字体"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Font"
|
||||
msgstr "字体"
|
||||
|
||||
msgid "Top-left"
|
||||
msgstr "左上角"
|
||||
|
||||
@@ -1777,6 +1851,9 @@ msgstr "VRAM 使用量"
|
||||
msgid "Layout and limiter"
|
||||
msgstr "布局和限制器"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "字体"
|
||||
|
||||
msgid "Presets"
|
||||
msgstr "预设"
|
||||
|
||||
@@ -1862,9 +1939,6 @@ msgstr "设置"
|
||||
msgid "Libraries"
|
||||
msgstr "库"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "字体"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "未找到 Winetricks。请重试。"
|
||||
|
||||
@@ -1986,10 +2060,6 @@ msgstr "警告"
|
||||
msgid "Disk: "
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Game Title"
|
||||
msgstr "游戏宽度"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
msgstr "状态"
|
||||
@@ -2040,9 +2110,42 @@ msgstr ""
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "文件:{0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account not connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "正在下载"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
@@ -2056,10 +2159,6 @@ msgstr "删除文件夹"
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "设置项"
|
||||
@@ -2128,6 +2227,40 @@ msgstr "隐藏"
|
||||
msgid "Badge View Type:"
|
||||
msgstr "徽章视图类型:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Theme"
|
||||
msgstr "主题"
|
||||
|
||||
msgid "Grid"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "List"
|
||||
msgstr "浅色"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Table"
|
||||
msgstr "稳定"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Cards"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Horizontal Top Blocks"
|
||||
msgstr "水平 HUD 布局"
|
||||
|
||||
msgid "Horizontal Top Cards"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Grid Type:"
|
||||
msgstr "游戏手柄类型:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete {count} cards without executable? This "
|
||||
@@ -2185,12 +2318,18 @@ msgstr "隐藏自动安装标签页"
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable ALT Linux i586 dependency check"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "禁用运行时下载"
|
||||
|
||||
@@ -2351,6 +2490,10 @@ msgstr "播放设备"
|
||||
msgid "Output"
|
||||
msgstr "输出"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "添加到桌面"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "注销"
|
||||
|
||||
@@ -2445,9 +2588,6 @@ msgstr "加载主题失败"
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
msgid "Use by default"
|
||||
msgstr "默认使用"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Wine 配置"
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_translation_sources: dict[str, str] = {}
|
||||
|
||||
LOCALE_MAP = {
|
||||
'ru': 'russian',
|
||||
'en': 'english',
|
||||
@@ -36,26 +38,53 @@ LOCALE_MAP = {
|
||||
'el': 'greek',
|
||||
}
|
||||
|
||||
STORE_CONTENT_LOCALES = {'es': 'es-ES', 'pt': 'pt-BR', 'zh': 'zh-CN'}
|
||||
|
||||
# Try local locale directory first, fallback to system for development
|
||||
_local_localedir = Path(__file__).parent / "locales"
|
||||
_system_prefix = "/app" if os.getenv("FLATPAK_ID") else os.getenv("SHARUN_DIR", "/usr")
|
||||
_system_localedir = Path(_system_prefix) / "share" / "locale"
|
||||
_locale_dirs = [_local_localedir]
|
||||
if os.getenv("SHARUN_DIR"):
|
||||
_locale_dirs.append(Path(os.environ["SHARUN_DIR"]) / "share" / "locale")
|
||||
_locale_dirs.extend(
|
||||
Path(data_dir) / "locale"
|
||||
for data_dir in os.getenv(
|
||||
"XDG_DATA_DIRS", "/usr/local/share:/usr/share"
|
||||
).split(os.pathsep)
|
||||
if data_dir
|
||||
)
|
||||
|
||||
translate = gettext.NullTranslations()
|
||||
for _locale_dir in _locale_dirs:
|
||||
try:
|
||||
translate = gettext.translation("portprotonqt", localedir=_locale_dir)
|
||||
break
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
|
||||
|
||||
def _(message: str) -> str:
|
||||
from portprotonqt.config import ui_config
|
||||
|
||||
translated = translate.gettext(message)
|
||||
_translation_sources[message] = message
|
||||
_translation_sources[translated] = message
|
||||
return message if ui_config.get_force_english() else translated
|
||||
|
||||
|
||||
def retranslate(message: str) -> str:
|
||||
"""Translate text previously passed through gettext."""
|
||||
source = _translation_sources.get(message)
|
||||
if source is None:
|
||||
return message
|
||||
return _(source)
|
||||
|
||||
try:
|
||||
translate = gettext.translation(
|
||||
domain="portprotonqt",
|
||||
localedir=_local_localedir,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
translate = gettext.translation(
|
||||
domain="portprotonqt",
|
||||
localedir=_system_localedir,
|
||||
fallback=True,
|
||||
)
|
||||
_ = translate.gettext
|
||||
|
||||
def get_system_locale():
|
||||
"""Return system locale, e.g., 'ru_RU'. Returns 'en' if detection fails."""
|
||||
from portprotonqt.config import ui_config
|
||||
|
||||
if ui_config.get_force_english():
|
||||
return 'en'
|
||||
loc = locale.getdefaultlocale()[0]
|
||||
return loc if loc else 'en'
|
||||
|
||||
@@ -87,6 +116,23 @@ def get_metadata_language() -> str:
|
||||
|
||||
return 'en'
|
||||
|
||||
def get_store_content_languages() -> tuple[str, ...]:
|
||||
"""Return preferred store content locales with English fallback."""
|
||||
language = get_metadata_language().lower().split('_', 1)[0] or 'en'
|
||||
system_locale = get_system_locale().lower().replace('-', '_')
|
||||
if language == 'zh' and any(
|
||||
variant in system_locale for variant in ('hant', '_tw', '_hk', '_mo')
|
||||
):
|
||||
preferred = 'zh-TW'
|
||||
else:
|
||||
preferred = STORE_CONTENT_LOCALES.get(language, language)
|
||||
languages = [preferred]
|
||||
if language not in languages:
|
||||
languages.append(language)
|
||||
if 'en-US' not in languages:
|
||||
languages.append('en-US')
|
||||
return tuple(languages)
|
||||
|
||||
def read_metadata_translations(metadata_file, language_code):
|
||||
"""
|
||||
Read translations from metadata.txt for the specified language.
|
||||
|
||||
+592
-170
File diff suppressed because it is too large.
Load diff
@@ -13,6 +13,7 @@ install_data(
|
||||
'game_card.py',
|
||||
'game_library_manager.py',
|
||||
'gog_api.py',
|
||||
'egs_api.py',
|
||||
'howlongtobeat_api.py',
|
||||
'image_utils.py',
|
||||
'icon_extractor.py',
|
||||
@@ -152,7 +153,7 @@ install_data(
|
||||
['tabs/__init__.py',
|
||||
'tabs/autoinstall_tab.py',
|
||||
'tabs/control_hints.py',
|
||||
'tabs/gog_tab.py',
|
||||
'tabs/download_tab.py',
|
||||
'tabs/library_tab.py',
|
||||
'tabs/settings_tab.py',
|
||||
'tabs/system_tab.py',
|
||||
|
||||
@@ -28,12 +28,14 @@ _library.portproton_gamepad_close.argtypes = [_handle]
|
||||
_library.portproton_gamepad_close.restype = None
|
||||
_library.portproton_gamepad_connected.argtypes = [_handle]
|
||||
_library.portproton_gamepad_connected.restype = ctypes.c_bool
|
||||
_library.portproton_gamepad_update.argtypes = []
|
||||
_library.portproton_gamepad_update.argtypes = [_handle]
|
||||
_library.portproton_gamepad_update.restype = None
|
||||
_library.portproton_gamepad_get_button.argtypes = [_handle, ctypes.c_int]
|
||||
_library.portproton_gamepad_get_button.restype = ctypes.c_int
|
||||
_library.portproton_gamepad_get_axis.argtypes = [_handle, ctypes.c_int]
|
||||
_library.portproton_gamepad_get_axis.restype = ctypes.c_int16
|
||||
_library.portproton_gamepad_get_active_instance_id.argtypes = [_handle]
|
||||
_library.portproton_gamepad_get_active_instance_id.restype = ctypes.c_uint32
|
||||
_library.portproton_gamepad_get_name.argtypes = [_handle]
|
||||
_library.portproton_gamepad_get_name.restype = ctypes.c_char_p
|
||||
_library.portproton_gamepad_get_instance_id.argtypes = [_handle]
|
||||
@@ -45,7 +47,7 @@ _library.portproton_gamepad_shutdown.restype = None
|
||||
|
||||
|
||||
class GamepadBackendError(RuntimeError):
|
||||
"""SDL failed while discovering the active gamepad."""
|
||||
"""SDL failed while discovering connected gamepads."""
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -55,6 +57,7 @@ class SDLGamepad:
|
||||
path: str
|
||||
instance_id: int
|
||||
sdl_type: int
|
||||
active_instance_id: int = 0
|
||||
|
||||
def close(self) -> None:
|
||||
if self.controller:
|
||||
@@ -67,8 +70,16 @@ class SDLGamepad:
|
||||
def connected(self) -> bool:
|
||||
return bool(_library.portproton_gamepad_connected(self.controller))
|
||||
|
||||
def update(self) -> None:
|
||||
_library.portproton_gamepad_update()
|
||||
def update(self) -> bool:
|
||||
_library.portproton_gamepad_update(self.controller)
|
||||
self.sdl_type = int(_library.portproton_gamepad_get_type(self.controller))
|
||||
active_instance_id = int(
|
||||
_library.portproton_gamepad_get_active_instance_id(self.controller)
|
||||
)
|
||||
if active_instance_id == self.active_instance_id:
|
||||
return False
|
||||
self.active_instance_id = active_instance_id
|
||||
return True
|
||||
|
||||
def get_button(self, button: int) -> int:
|
||||
return int(_library.portproton_gamepad_get_button(self.controller, button))
|
||||
|
||||
@@ -340,17 +340,31 @@ class PortProtonAPI:
|
||||
|
||||
def _get_autoinstall_exe_name(self, script_path: str) -> str:
|
||||
install_exe = ""
|
||||
shortcut_name = ""
|
||||
try:
|
||||
with open(script_path, encoding="utf-8") as script_file:
|
||||
for line in script_file:
|
||||
if line.lstrip().startswith("#"):
|
||||
continue
|
||||
if "PORTWINE_CREATE_SHORTCUT_NAME=" in line:
|
||||
match = re.search(
|
||||
r'PORTWINE_CREATE_SHORTCUT_NAME=["\']([^"\']+)', line
|
||||
)
|
||||
if match:
|
||||
shortcut_name = match.group(1)
|
||||
if "PW_EXE_FILE" in line:
|
||||
exe_name = self._extract_exe_name_from_script_line(line)
|
||||
if exe_name:
|
||||
return exe_name
|
||||
install_exe = exe_name
|
||||
if line.strip().startswith("pw_create_unique_exe"):
|
||||
match = re.search(r'pw_create_unique_exe\s+["\']?([^"\' ]+)', line)
|
||||
unique_name = match.group(1) if match else shortcut_name
|
||||
if unique_name:
|
||||
return f"{os.path.splitext(unique_name)[0]}.exe"
|
||||
if "PW_AUTOINSTALL_EXE" not in line:
|
||||
continue
|
||||
exe_name = self._extract_exe_name_from_script_line(line)
|
||||
if exe_name:
|
||||
if exe_name and not install_exe:
|
||||
install_exe = exe_name
|
||||
except OSError as e:
|
||||
logger.warning("Failed to read autoinstall script %s: %s", script_path, e)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import codecs
|
||||
import errno
|
||||
import fcntl
|
||||
import os
|
||||
import pty
|
||||
@@ -14,6 +15,7 @@ from typing import Any
|
||||
|
||||
from PySide6.QtCore import QMimeData, QSocketNotifier, Qt, QTimer
|
||||
from PySide6.QtGui import (
|
||||
QCloseEvent,
|
||||
QColor,
|
||||
QFont,
|
||||
QFontDatabase,
|
||||
@@ -28,10 +30,13 @@ from PySide6.QtGui import (
|
||||
)
|
||||
from PySide6.QtWidgets import QApplication, QMainWindow, QMenu, QPlainTextEdit
|
||||
|
||||
from portprotonqt.input_manager.constants import SDL_GAMEPAD_BUTTON_SOUTH
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_gamepad import GamepadBackendError, SDLGamepad, find_gamepad
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
APPLICATION_ID = "ru.linux_gaming.PortProtonQt"
|
||||
|
||||
CONF_NAME = "PortProtonQt Terminal"
|
||||
CONF_FALLBACK_SHELL = "/bin/bash"
|
||||
@@ -39,6 +44,7 @@ CONF_DEF_SIZE = (800, 260)
|
||||
CONF_FALLBACK_FONT_EXTRA = 6
|
||||
CONF_SCROLLBACK_LINES = 2000
|
||||
CONF_RENDER_INTERVAL_MS = 16
|
||||
CONF_GAMEPAD_POLL_INTERVAL_MS = 50
|
||||
|
||||
XDG_DATA_HOME = os.getenv(
|
||||
"XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")
|
||||
@@ -878,6 +884,9 @@ class TerminalWidget(QPlainTextEdit):
|
||||
self.app_theme = app_theme or load_current_theme()
|
||||
self.cmd_mode = cmd_mode
|
||||
self.child_pid: int | None = None
|
||||
self.exit_code: int | None = None
|
||||
self.gamepad: SDLGamepad | None = None
|
||||
self.gamepad_a_pressed = False
|
||||
self.pty_fd: int | None = None
|
||||
self.notifier: QSocketNotifier | None = None
|
||||
self.ansi_colors = _theme_ansi_colors(theme)
|
||||
@@ -906,6 +915,8 @@ class TerminalWidget(QPlainTextEdit):
|
||||
self.theme_manager = ThemeManager()
|
||||
self.child_timer = QTimer(self)
|
||||
self.child_timer.timeout.connect(self._check_child)
|
||||
self.gamepad_timer = QTimer(self)
|
||||
self.gamepad_timer.timeout.connect(self._poll_close_gamepad)
|
||||
self.render_timer = QTimer(self)
|
||||
self.render_timer.setSingleShot(True)
|
||||
self.render_timer.timeout.connect(self._render_screen)
|
||||
@@ -1043,11 +1054,15 @@ class TerminalWidget(QPlainTextEdit):
|
||||
except BlockingIOError:
|
||||
return
|
||||
except OSError as e:
|
||||
if e.errno == errno.EIO:
|
||||
if self.notifier is not None:
|
||||
self.notifier.setEnabled(False)
|
||||
return
|
||||
logger.warning("Terminal read failed: %s", e)
|
||||
QApplication.quit()
|
||||
return
|
||||
if not data:
|
||||
QApplication.quit()
|
||||
if self.notifier is not None:
|
||||
self.notifier.setEnabled(False)
|
||||
return
|
||||
text = self.output_decoder.decode(data)
|
||||
if self.cmd_mode:
|
||||
@@ -1061,15 +1076,65 @@ class TerminalWidget(QPlainTextEdit):
|
||||
if self.child_pid is None:
|
||||
return
|
||||
try:
|
||||
pid, _status = os.waitpid(self.child_pid, os.WNOHANG)
|
||||
pid, status = os.waitpid(self.child_pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
pid = self.child_pid
|
||||
status = 0
|
||||
if pid == 0:
|
||||
return
|
||||
self.child_pid = None
|
||||
self.child_timer.stop()
|
||||
self.viewport().setCursor(Qt.CursorShape.ArrowCursor)
|
||||
QApplication.quit()
|
||||
self._read_remaining_output()
|
||||
self.exit_code = os.waitstatus_to_exitcode(status)
|
||||
if self.exit_code == 0:
|
||||
QApplication.exit(0)
|
||||
return
|
||||
self._start_close_gamepad()
|
||||
|
||||
def _start_close_gamepad(self) -> None:
|
||||
try:
|
||||
self.gamepad = find_gamepad()
|
||||
except GamepadBackendError as e:
|
||||
logger.debug("Failed to find terminal gamepad: %s", e)
|
||||
return
|
||||
if self.gamepad is None:
|
||||
return
|
||||
self.gamepad.update()
|
||||
self.gamepad_a_pressed = bool(
|
||||
self.gamepad.get_button(SDL_GAMEPAD_BUTTON_SOUTH)
|
||||
)
|
||||
self.gamepad_timer.start(CONF_GAMEPAD_POLL_INTERVAL_MS)
|
||||
|
||||
def _poll_close_gamepad(self) -> None:
|
||||
if self.gamepad is None or not self.gamepad.connected():
|
||||
self.gamepad_timer.stop()
|
||||
return
|
||||
self.gamepad.update()
|
||||
pressed = bool(self.gamepad.get_button(SDL_GAMEPAD_BUTTON_SOUTH))
|
||||
if pressed and not self.gamepad_a_pressed:
|
||||
self.window().close()
|
||||
self.gamepad_a_pressed = pressed
|
||||
|
||||
def _close_gamepad(self) -> None:
|
||||
self.gamepad_timer.stop()
|
||||
if self.gamepad is not None:
|
||||
self.gamepad.close()
|
||||
self.gamepad = None
|
||||
|
||||
def _read_remaining_output(self) -> None:
|
||||
if self.pty_fd is None:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
data = os.read(self.pty_fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not data:
|
||||
break
|
||||
self._append_output(self.output_decoder.decode(data))
|
||||
if self.terminal_screen is not None:
|
||||
self._render_screen()
|
||||
|
||||
def _append_output(self, text: str) -> None:
|
||||
if self.terminal_screen is not None:
|
||||
@@ -1196,6 +1261,12 @@ class TerminalWidget(QPlainTextEdit):
|
||||
self._write_pty(source.text())
|
||||
|
||||
def keyPressEvent(self, event: QKeyEvent) -> None:
|
||||
if self.child_pid is None and event.key() in (
|
||||
Qt.Key.Key_Return,
|
||||
Qt.Key.Key_Enter,
|
||||
):
|
||||
self.window().close()
|
||||
return
|
||||
if self.cmd_mode and self._handle_cmd_key(event):
|
||||
return
|
||||
if self._handle_shortcut(event):
|
||||
@@ -1783,6 +1854,12 @@ class MainWindow(QMainWindow):
|
||||
if fullscreen:
|
||||
self.showFullScreen()
|
||||
|
||||
def closeEvent(self, event: QCloseEvent) -> None:
|
||||
exit_code = self.terminal.exit_code or 0
|
||||
self.terminal._close_gamepad()
|
||||
event.accept()
|
||||
QApplication.exit(exit_code)
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
"""Parse xterm-compatible arguments used by PortProton scripts."""
|
||||
@@ -1862,6 +1939,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
command = build_command(args)
|
||||
cmd_mode = _is_cmd_mode(args)
|
||||
app = QApplication(sys.argv[:1])
|
||||
app.setWindowIcon(QIcon.fromTheme(APPLICATION_ID))
|
||||
app.setDesktopFileName(APPLICATION_ID)
|
||||
app_theme = load_current_theme()
|
||||
theme = load_current_terminal_scheme() or load_terminal_scheme("default")
|
||||
window = MainWindow(
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
UNREAL_BOOTSTRAPPER_MAX_SIZE = 1_048_576
|
||||
LAUNCHER_NAME_MARKERS = ("launcher", "setup", "updater", "unins")
|
||||
|
||||
DIRECTX_IMPORTS = {
|
||||
"ddraw.dll": "DirectDraw",
|
||||
@@ -49,6 +52,8 @@ OPENGL_SIGNATURES = {
|
||||
"OpenGL Utility Library": (b"glu32.dll", b"gluPerspective", b"gluLookAt"),
|
||||
}
|
||||
|
||||
VULKAN_SIGNATURES = (b"vulkan-1.dll", b"vkCreateInstance", b"vkGetInstanceProcAddr")
|
||||
|
||||
|
||||
def analyze_executable(file_path: str) -> dict[str, Any]:
|
||||
"""Detect graphics API usage in a Windows executable."""
|
||||
@@ -59,23 +64,58 @@ def analyze_executable(file_path: str) -> dict[str, Any]:
|
||||
|
||||
directx = set()
|
||||
opengl = set()
|
||||
vulkan = set()
|
||||
|
||||
if analysis_path.suffix.lower() in {".exe", ".dll"}:
|
||||
_add_pe_imports(analysis_path, directx, opengl)
|
||||
_add_file_signatures(analysis_path, directx, opengl)
|
||||
_add_file_signatures(analysis_path, directx, opengl, vulkan)
|
||||
engine_family = _read_engine_render_family(analysis_path)
|
||||
if engine_family == "vulkan":
|
||||
vulkan.add("Vulkan")
|
||||
elif engine_family == "opengl":
|
||||
opengl.add("OpenGL")
|
||||
|
||||
return {
|
||||
"highest_directx": _highest_directx(list(directx)),
|
||||
"uses_opengl": bool(opengl),
|
||||
"uses_vulkan": bool(vulkan),
|
||||
"source": str(analysis_path),
|
||||
}
|
||||
|
||||
|
||||
def _read_engine_render_family(executable: Path) -> str | None:
|
||||
"""Read a renderer choice from the active Wine prefix engine config."""
|
||||
wineprefix = os.getenv("WINEPREFIX")
|
||||
if not wineprefix:
|
||||
return None
|
||||
config_root = Path(wineprefix) / "drive_c" / "users"
|
||||
try:
|
||||
config_paths = config_root.glob("*/Saved Games/**/kexengine.cfg")
|
||||
for config_path in config_paths:
|
||||
game_name = re.sub(r"[^a-z0-9]", "", config_path.parent.name.lower())
|
||||
executable_name = re.sub(
|
||||
r"[^a-z0-9]", "", executable.parent.name.lower()
|
||||
)
|
||||
executable_stem = re.sub(r"[^a-z0-9]", "", executable.stem.lower())
|
||||
if game_name not in executable_name and game_name not in executable_stem:
|
||||
continue
|
||||
for line in config_path.read_text(errors="ignore").splitlines():
|
||||
match = re.match(r'\s*seta\s+r_rhirenderfamily\s+"?([^"\s]+)', line)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
except OSError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def resolve_graphics_executable(file_path: str) -> Path:
|
||||
"""Resolve a launcher to the binary that implements its graphics API."""
|
||||
path = Path(file_path).expanduser().resolve()
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
unity_player = path.parent / "UnityPlayer.dll"
|
||||
if unity_player.is_file():
|
||||
return unity_player
|
||||
if path.stat().st_size < UNREAL_BOOTSTRAPPER_MAX_SIZE:
|
||||
patterns = (
|
||||
"*/Binaries/Win64/*-Win64-Shipping.exe",
|
||||
@@ -89,9 +129,28 @@ def resolve_graphics_executable(file_path: str) -> Path:
|
||||
renpy_libraries = sorted(path.parent.glob("lib/*/librenpython.dll"))
|
||||
if renpy_libraries:
|
||||
return renpy_libraries[0]
|
||||
is_launcher = any(marker in path.stem.lower() for marker in LAUNCHER_NAME_MARKERS)
|
||||
if is_launcher or not _is_pe_file(path):
|
||||
pe_candidates = [
|
||||
candidate
|
||||
for candidate in path.parent.glob("*.exe")
|
||||
if candidate != path
|
||||
and _is_pe_file(candidate)
|
||||
and not any(marker in candidate.stem.lower() for marker in LAUNCHER_NAME_MARKERS)
|
||||
]
|
||||
if pe_candidates:
|
||||
return max(pe_candidates, key=lambda candidate: candidate.stat().st_size)
|
||||
return path
|
||||
|
||||
|
||||
def _is_pe_file(path: Path) -> bool:
|
||||
try:
|
||||
with path.open("rb") as executable:
|
||||
return executable.read(2) == b"MZ"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _add_pe_imports(path: Path, directx: set[str], opengl: set[str]) -> None:
|
||||
try:
|
||||
import pefile
|
||||
@@ -117,7 +176,9 @@ def _add_pe_imports(path: Path, directx: set[str], opengl: set[str]) -> None:
|
||||
return
|
||||
|
||||
|
||||
def _add_file_signatures(path: Path, directx: set[str], opengl: set[str]) -> None:
|
||||
def _add_file_signatures(
|
||||
path: Path, directx: set[str], opengl: set[str], vulkan: set[str]
|
||||
) -> None:
|
||||
try:
|
||||
with path.open("rb") as f:
|
||||
data = f.read(67108864).lower()
|
||||
@@ -131,6 +192,8 @@ def _add_file_signatures(path: Path, directx: set[str], opengl: set[str]) -> Non
|
||||
for name, patterns in OPENGL_SIGNATURES.items():
|
||||
if any(p.lower() in data for p in patterns):
|
||||
opengl.add(name)
|
||||
if any(signature.lower() in data for signature in VULKAN_SIGNATURES):
|
||||
vulkan.add("Vulkan")
|
||||
|
||||
|
||||
def _highest_directx(directx: list[str]) -> str:
|
||||
@@ -148,6 +211,8 @@ def _main(argv: list[str]) -> int:
|
||||
result = analyze_executable(argv[1])
|
||||
print(f"HIGHEST_DX={result['highest_directx']}")
|
||||
print(f"USES_OGL={'true' if result['uses_opengl'] else 'false'}")
|
||||
print(f"USES_VULKAN={'true' if result['uses_vulkan'] else 'false'}")
|
||||
print(f"SOURCE={result['source']}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
|
||||
@@ -121,7 +121,6 @@ def _archive_entry(archive, source_path: str, archive_path: str) -> bool:
|
||||
flags=READDISK_NO_XATTR,
|
||||
pathname=archive_path,
|
||||
recursive=False,
|
||||
symlink_mode="physical",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ def search_index(
|
||||
prefix_results = optimizer.prefix_search(search_text)
|
||||
return _unique_search_results([payload for _match_text, payload in prefix_results])
|
||||
|
||||
substring_results = optimizer.substring_search(search_text)
|
||||
if substring_results:
|
||||
return _unique_search_results([payload for _match_text, payload in substring_results])
|
||||
|
||||
exact_result = optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
return [exact_result]
|
||||
@@ -179,6 +183,13 @@ class SearchOptimizer:
|
||||
with self._lock:
|
||||
return self.trie_index.search_prefix(prefix)
|
||||
|
||||
def substring_search(self, query: str) -> list[tuple[str, Any]]:
|
||||
"""Find all indexed names containing the query."""
|
||||
with self._lock:
|
||||
query = query.lower()
|
||||
items = self.fuzzy_index.items if self.fuzzy_index else []
|
||||
return [(key, value) for key, value in items if query in key]
|
||||
|
||||
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
|
||||
"""Perform fuzzy search."""
|
||||
if self.fuzzy_index:
|
||||
|
||||
@@ -14,7 +14,7 @@ from PIL import Image, UnidentifiedImageError
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.localization import get_steam_language
|
||||
from portprotonqt.config import THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
|
||||
from portprotonqt.config import CACHE_DIR, THEMED_LAUNCH_ICON_NAMES, extract_exec_target_path, ui_config
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.steam_api.utils import decode_text, get_local_steam_cover
|
||||
from portprotonqt.steam_api.cache import (
|
||||
@@ -501,8 +501,7 @@ def _get_cached_data_and_index(
|
||||
|
||||
|
||||
def _get_cached_steam_cover_path(appid: int) -> str:
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
|
||||
image_folder = str(CACHE_DIR / "images")
|
||||
for ext in COVER_IMAGE_EXTENSIONS:
|
||||
cover_path = os.path.join(image_folder, f"{appid}{ext}")
|
||||
if os.path.exists(cover_path):
|
||||
@@ -625,7 +624,7 @@ def get_steam_game_info_async(
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_gog_uri = exec_line.startswith("gog://")
|
||||
is_store_uri = exec_line.startswith(("gog://", "egs://"))
|
||||
|
||||
if game_exe.lower().endswith(('.bat', '.cmd')):
|
||||
if os.path.exists(game_exe):
|
||||
@@ -647,7 +646,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if is_gog_uri:
|
||||
if is_store_uri:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -671,15 +670,15 @@ def get_steam_game_info_async(
|
||||
candidates.append(file_description)
|
||||
if desktop_name:
|
||||
candidates.append(desktop_name)
|
||||
if exe_name and not is_gog_uri:
|
||||
if exe_name and not is_store_uri:
|
||||
candidates.append(exe_name)
|
||||
if folder_name and not is_gog_uri:
|
||||
if folder_name and not is_store_uri:
|
||||
candidates.append(folder_name)
|
||||
candidates = filter_candidates(candidates)
|
||||
candidates = remove_duplicates(candidates)
|
||||
candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True)
|
||||
has_custom_data = (
|
||||
is_autoinstall or is_gog_uri or _has_custom_data_for_exe(exe_name)
|
||||
is_autoinstall or is_store_uri or _has_custom_data_for_exe(exe_name)
|
||||
)
|
||||
|
||||
def on_steam_apps_and_index(
|
||||
|
||||
@@ -143,6 +143,9 @@ def _is_portrait_image(path: Path) -> bool:
|
||||
except (OSError, UnidentifiedImageError) as e:
|
||||
logger.debug("Failed to read Steam cover dimensions %s: %s", path, e)
|
||||
return False
|
||||
except ValueError as e:
|
||||
logger.debug("Rejected unsafe Steam cover metadata %s: %s", path, e)
|
||||
return False
|
||||
return height > width
|
||||
|
||||
|
||||
@@ -498,9 +501,9 @@ def _load_steam_app_metadata(
|
||||
|
||||
|
||||
def _is_steam_game(common: dict) -> bool:
|
||||
"""Return whether Steam metadata describes a game."""
|
||||
"""Return whether Steam metadata describes a game or related application."""
|
||||
app_type = str(common.get("type", "")).lower()
|
||||
return app_type == "game"
|
||||
return app_type in {"game", "demo", "mod", "application", "beta"}
|
||||
|
||||
|
||||
def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
|
||||
|
||||
@@ -4,7 +4,9 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QScroller,
|
||||
QSlider,
|
||||
@@ -40,8 +42,20 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def createAutoInstallTab(self):
|
||||
autoInstallPage = QWidget()
|
||||
autoInstallPage.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
|
||||
autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
|
||||
autoInstallLayout = QVBoxLayout(autoInstallPage)
|
||||
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
|
||||
stack_layout = QGridLayout(autoInstallPage)
|
||||
if isinstance(library_background, dict):
|
||||
stack_layout.setContentsMargins(*library_background["margins"])
|
||||
else:
|
||||
stack_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.autoInstallBackgroundLabel = QLabel()
|
||||
stack_layout.addWidget(self.autoInstallBackgroundLabel, 0, 0)
|
||||
content_widget = QWidget()
|
||||
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
autoInstallLayout = QVBoxLayout(content_widget)
|
||||
stack_layout.addWidget(content_widget, 0, 0)
|
||||
autoInstallLayout.setSpacing(15)
|
||||
|
||||
searchWidget = QWidget()
|
||||
@@ -89,8 +103,10 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
|
||||
self.autoInstallContainer = QWidget()
|
||||
self.autoInstallContainer.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
|
||||
self.autoInstallContainerLayout = FlowLayout(self.autoInstallContainer)
|
||||
self.autoInstallContainer.setLayout(self.autoInstallContainerLayout)
|
||||
auto_layout_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
self._set_autoinstall_container_layout(auto_layout_mode)
|
||||
self.autoInstallScrollArea.setWidget(self.autoInstallContainer)
|
||||
|
||||
autoInstallLayout.addWidget(self.autoInstallScrollArea)
|
||||
@@ -110,9 +126,11 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
sliderLayout.addWidget(self.auto_size_slider)
|
||||
autoInstallLayout.addLayout(sliderLayout)
|
||||
|
||||
auto_layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.auto_size_slider.setVisible(auto_layout_mode != "list")
|
||||
if auto_layout_mode == "list":
|
||||
fixed_layout = auto_layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top",
|
||||
}
|
||||
self.auto_size_slider.setVisible(not fixed_layout)
|
||||
if fixed_layout:
|
||||
self.auto_card_width = self.auto_size_slider.maximum()
|
||||
self.auto_size_slider.setValue(self.auto_card_width)
|
||||
self._gamepad_tooltip_map[self.auto_size_slider] = f"{self.auto_card_width} px"
|
||||
@@ -128,6 +146,13 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
def on_autoinstall_games_loaded(games: list[tuple]):
|
||||
self.autoInstallLoaded = True
|
||||
self.autoInstallLoading = False
|
||||
auto_layout_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
list_layout = auto_layout_mode in {"list", "vertical"}
|
||||
self.autoInstallContainer.setProperty(
|
||||
"library_layout_mode", auto_layout_mode
|
||||
)
|
||||
|
||||
# Clear
|
||||
while self.autoInstallContainerLayout.count():
|
||||
@@ -168,7 +193,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
exe_name = game_tuple[13]
|
||||
compact_cover = game_tuple[14] if len(game_tuple) > 14 else ""
|
||||
full_cover = game_tuple[15] if len(game_tuple) > 15 else cover_path
|
||||
if auto_layout_mode == "list":
|
||||
if list_layout:
|
||||
cover_path = compact_cover or full_cover
|
||||
else:
|
||||
cover_path = full_cover or compact_cover
|
||||
@@ -183,6 +208,8 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
parent=self.autoInstallContainer,
|
||||
)
|
||||
card.autoinstall_exe_name = exe_name
|
||||
card.hoverChanged.connect(self._on_autoinstall_card_active)
|
||||
card.focusChanged.connect(self._on_autoinstall_card_active)
|
||||
|
||||
# Hide badges and favorite button
|
||||
if hasattr(card, 'steamLabel'):
|
||||
@@ -209,6 +236,78 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
|
||||
self.stackedWidget.addWidget(autoInstallPage)
|
||||
|
||||
def _on_autoinstall_card_active(self, game_name: str, active: bool) -> None:
|
||||
if not active:
|
||||
return
|
||||
for card in self.allAutoInstallCards:
|
||||
if card.name != game_name:
|
||||
continue
|
||||
self.game_library_manager.render_library_background(
|
||||
self.autoInstallBackgroundLabel, card
|
||||
)
|
||||
return
|
||||
|
||||
def _set_autoinstall_container_layout(self, mode: str) -> None:
|
||||
old_layout = getattr(self, "autoInstallContainerLayout", None)
|
||||
if old_layout is not None:
|
||||
while old_layout.count():
|
||||
item = old_layout.takeAt(0)
|
||||
if item and item.widget():
|
||||
item.widget().deleteLater()
|
||||
QWidget().setLayout(old_layout)
|
||||
self.autoInstallContainer.setProperty("library_layout_mode", mode)
|
||||
if mode == "vertical":
|
||||
config = self.theme.GAME_CARD_VERTICAL
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(*config.get("layout_margins", (0, 0, 0, 0)))
|
||||
layout.setSpacing(config.get("layout_spacing", 0))
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
elif mode in {"horizontal", "horizontal_top"}:
|
||||
config = getattr(self.theme, "GAME_CARD_HORIZONTAL", {})
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(
|
||||
*config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
layout.setSpacing(config.get("layout_spacing", 0))
|
||||
layout.setAlignment(
|
||||
Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
else:
|
||||
layout = FlowLayout()
|
||||
self.autoInstallContainerLayout = layout
|
||||
self.autoInstallContainer.setLayout(layout)
|
||||
horizontal = mode in {"horizontal", "horizontal_top"}
|
||||
vertical_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if horizontal else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
horizontal_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
if horizontal else Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
self.autoInstallScrollArea.setVerticalScrollBarPolicy(vertical_policy)
|
||||
self.autoInstallScrollArea.setHorizontalScrollBarPolicy(horizontal_policy)
|
||||
|
||||
def refresh_autoinstall_layout(self) -> None:
|
||||
"""Rebuild auto-install cards after a live library layout change."""
|
||||
if not hasattr(self, "autoInstallContainer"):
|
||||
return
|
||||
mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
fixed_layout = mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top",
|
||||
}
|
||||
self._set_autoinstall_container_layout(mode)
|
||||
self.auto_size_slider.setVisible(not fixed_layout)
|
||||
if fixed_layout:
|
||||
self.auto_card_width = self.auto_size_slider.maximum()
|
||||
self.auto_size_slider.setValue(self.auto_card_width)
|
||||
if self.autoInstallLoading:
|
||||
return
|
||||
self.autoInstallLoaded = False
|
||||
self._start_autoinstall_load()
|
||||
|
||||
def _open_autoinstall_card_after_script_download(
|
||||
self,
|
||||
game_data: dict,
|
||||
@@ -313,12 +412,17 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def on_auto_slider_released(self):
|
||||
"""Handles auto-install slider release to update card size."""
|
||||
auto_layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
auto_layout_mode = ui_config.get_library_layout_mode(
|
||||
str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
)
|
||||
fixed_layout = auto_layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top",
|
||||
}
|
||||
if hasattr(self, 'auto_size_slider') and self.auto_size_slider:
|
||||
if auto_layout_mode != "list":
|
||||
if not fixed_layout:
|
||||
self.auto_card_width = self.auto_size_slider.value()
|
||||
self._gamepad_tooltip_map[self.auto_size_slider] = f"{self.auto_card_width} px"
|
||||
if auto_layout_mode != "list":
|
||||
if not fixed_layout:
|
||||
ui_config.set_auto_card_width(self.auto_card_width)
|
||||
if not hasattr(self, 'allAutoInstallCards'):
|
||||
return
|
||||
|
||||
@@ -234,6 +234,7 @@ class MainWindowControlHintsMixin:
|
||||
]
|
||||
|
||||
self.hintsLabels = []
|
||||
self.hintTextLabels = []
|
||||
self.gamepadHintTextLabels = {}
|
||||
self.gamepadHintDefaultTexts = {}
|
||||
self.gamepadHintContainers = {}
|
||||
@@ -266,7 +267,9 @@ class MainWindowControlHintsMixin:
|
||||
layout.addWidget(icon_label)
|
||||
|
||||
text_label = QLabel(action_text)
|
||||
text_label.setProperty("theme_style_name", "HINTS_LABEL_STYLE")
|
||||
text_label.setStyleSheet(self.theme.HINTS_LABEL_STYLE)
|
||||
self.hintTextLabels.append(text_label)
|
||||
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
layout.addWidget(text_label)
|
||||
|
||||
@@ -358,7 +361,9 @@ class MainWindowControlHintsMixin:
|
||||
icon.clicked.connect(lambda hint_action=action: self._triggerControlHintAction(hint_action))
|
||||
|
||||
text_label = QLabel(action_text)
|
||||
text_label.setProperty("theme_style_name", "HINTS_LABEL_STYLE")
|
||||
text_label.setStyleSheet(self.theme.HINTS_LABEL_STYLE)
|
||||
self.hintTextLabels.append(text_label)
|
||||
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
layout.addWidget(text_label)
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,803 +0,0 @@
|
||||
"""Shared download manager with GOG download support."""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PySide6.QtCore import QProcess, QProcessEnvironment, QThread, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHeaderView,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QProgressBar,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
from portprotonqt.gog_api import GOGAPI, GOG_LOGIN_URL
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
GOG_CANCEL_KILL_TIMEOUT_MS = 3000
|
||||
GOG_LOGIN_TIMEOUT_MS = 300000
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
|
||||
_MainWindowTypingBase = QMainWindow
|
||||
else:
|
||||
_MainWindowTypingBase = object
|
||||
|
||||
|
||||
class GOGLibraryWorker(QThread):
|
||||
"""Refresh the GOG library outside the UI thread."""
|
||||
|
||||
loaded = Signal(list)
|
||||
failed = Signal(str)
|
||||
progress = Signal(int, int)
|
||||
|
||||
def __init__(self, api: GOGAPI) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.loaded.emit(self.api.refresh_library(self.progress.emit))
|
||||
except Exception as error:
|
||||
logger.exception("Failed to refresh GOG library")
|
||||
self.failed.emit(str(error))
|
||||
|
||||
|
||||
class GOGAuthWorker(QThread):
|
||||
"""Exchange the GOG OAuth code outside the UI thread."""
|
||||
|
||||
authenticated = Signal(bool, str)
|
||||
|
||||
def __init__(self, api: GOGAPI, code: str) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
self.code = code
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
authenticated, error = self.api.authenticate(self.code)
|
||||
self.authenticated.emit(authenticated, error)
|
||||
except Exception as error:
|
||||
logger.exception("Failed to authenticate with GOG: %s", error)
|
||||
self.authenticated.emit(False, str(error))
|
||||
|
||||
|
||||
class GOGAccountWorker(QThread):
|
||||
"""Load the GOG account name outside the UI thread."""
|
||||
|
||||
loaded = Signal(str)
|
||||
|
||||
def __init__(self, api: GOGAPI) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.loaded.emit(self.api.refresh_account_name())
|
||||
except Exception:
|
||||
logger.exception("Failed to refresh GOG account name")
|
||||
self.loaded.emit("")
|
||||
|
||||
|
||||
class GOGSupportWorker(QThread):
|
||||
"""Install GOG support instructions outside the UI thread."""
|
||||
|
||||
failed = Signal(str)
|
||||
launched = Signal(object)
|
||||
succeeded = Signal()
|
||||
|
||||
def __init__(self, api: GOGAPI, app_id: str, start_command: list[str]) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
self.app_id = app_id
|
||||
self.start_command = start_command
|
||||
self.error = ""
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.api.install_support(
|
||||
self.app_id, self.start_command, self.launched.emit
|
||||
)
|
||||
self.succeeded.emit()
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
logger.exception("Failed to install GOG support for %s", self.app_id)
|
||||
self.error = str(error)
|
||||
self.failed.emit(self.error)
|
||||
|
||||
|
||||
class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
"""Add account actions and the shared downloads page."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
def __getattr__(self, name: str) -> Any: ...
|
||||
|
||||
def createGOGDownloadsTab(self) -> None:
|
||||
self.gog_process = None
|
||||
self.gog_download_queue = []
|
||||
self.gog_download_output = ""
|
||||
self.gog_support_workers = []
|
||||
self.downloadTableHeadings = {}
|
||||
page = QWidget()
|
||||
page.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setSpacing(self.theme.downloadsSectionSpacing)
|
||||
title = QLabel(_("Downloads"))
|
||||
title.setStyleSheet(self.theme.TAB_TITLE_STYLE)
|
||||
layout.addWidget(title)
|
||||
self._create_active_download_card(layout)
|
||||
self.downloadQueuedTable = self._create_download_table(_("Queued"), layout)
|
||||
completed_header = QHBoxLayout()
|
||||
completed_header.addWidget(QLabel(_("Completed")))
|
||||
clear_button = QPushButton(_("Clear List"))
|
||||
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
clear_button.clicked.connect(self._clear_completed_downloads)
|
||||
completed_header.addWidget(clear_button)
|
||||
completed_header.addStretch()
|
||||
layout.addLayout(completed_header)
|
||||
self.downloadCompletedTable = self._create_download_table("", layout)
|
||||
layout.addStretch()
|
||||
self.stackedWidget.addWidget(page)
|
||||
self._update_downloads_tab_visibility()
|
||||
|
||||
def _update_downloads_tab_visibility(self) -> None:
|
||||
has_downloads = (
|
||||
not self.downloadActiveCard.isHidden()
|
||||
or self.downloadQueuedTable.rowCount() > 0
|
||||
or self.downloadCompletedTable.rowCount() > 0
|
||||
)
|
||||
self.tabButtons[6].setVisible(has_downloads)
|
||||
if not has_downloads and self.stackedWidget.currentIndex() == 6:
|
||||
self.switchTab(0)
|
||||
|
||||
def _create_active_download_card(self, layout: QVBoxLayout) -> None:
|
||||
self.downloadActiveHeading = QLabel(_("Now Downloading"))
|
||||
self.downloadActiveHeading.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
layout.addWidget(self.downloadActiveHeading)
|
||||
self.downloadActiveCard = QFrame()
|
||||
self.downloadActiveCard.setObjectName("downloadsActiveCard")
|
||||
self.downloadActiveCard.setStyleSheet(self.theme.DOWNLOADS_ACTIVE_STYLE)
|
||||
self.downloadActiveCard.setFixedHeight(self.theme.downloadsActiveCardHeight)
|
||||
card_layout = QVBoxLayout(self.downloadActiveCard)
|
||||
card_layout.setContentsMargins(*self.theme.downloadsActiveCardMargins)
|
||||
card_layout.setSpacing(self.theme.downloadsActiveCardSpacing)
|
||||
content = QHBoxLayout()
|
||||
content.setSpacing(self.theme.downloadsActiveCardSpacing)
|
||||
self.downloadActiveCover = QLabel()
|
||||
self.downloadActiveCover.setFixedSize(*self.theme.downloadsActiveCoverSize)
|
||||
content.addWidget(self.downloadActiveCover)
|
||||
info = QVBoxLayout()
|
||||
header = QHBoxLayout()
|
||||
self.downloadActiveTitle = QLabel()
|
||||
self.downloadActiveTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
header.addWidget(self.downloadActiveTitle)
|
||||
header.addStretch()
|
||||
self.downloadCancelButton = QPushButton(_("Cancel"))
|
||||
self.downloadCancelButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.downloadCancelButton.clicked.connect(self._cancel_gog_download)
|
||||
header.addWidget(self.downloadCancelButton)
|
||||
info.addLayout(header)
|
||||
self.downloadActiveDetails = QLabel(_("Waiting…"))
|
||||
self.downloadActiveDetails.setStyleSheet(self.theme.CONTENT_STYLE)
|
||||
info.addWidget(self.downloadActiveDetails)
|
||||
metrics = QHBoxLayout()
|
||||
self.downloadSpeedLabel = QLabel(_("Downloading: ") + "\u2014")
|
||||
self.diskSpeedLabel = QLabel(_("Disk: ") + "\u2014")
|
||||
metrics.addWidget(self.downloadSpeedLabel)
|
||||
metrics.addWidget(self.diskSpeedLabel)
|
||||
metrics.addStretch()
|
||||
info.addLayout(metrics)
|
||||
content.addLayout(info, stretch=1)
|
||||
card_layout.addLayout(content)
|
||||
self.downloadOverallProgress = QProgressBar()
|
||||
self.downloadOverallProgress.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
|
||||
card_layout.addWidget(self.downloadOverallProgress)
|
||||
layout.addWidget(self.downloadActiveCard)
|
||||
self.downloadActiveHeading.setVisible(False)
|
||||
self.downloadActiveCard.setVisible(False)
|
||||
|
||||
def _create_download_table(self, heading: str, layout: QVBoxLayout) -> QTableWidget:
|
||||
label = None
|
||||
if heading:
|
||||
label = QLabel(heading)
|
||||
label.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
layout.addWidget(label)
|
||||
columns = (_("Game Title"), _("Started at"), _("Type"), _("Store"))
|
||||
table = QTableWidget(0, len(columns))
|
||||
table.setHorizontalHeaderLabels(columns)
|
||||
table.setStyleSheet(self.theme.DOWNLOADS_TABLE_STYLE)
|
||||
table.setAlternatingRowColors(True)
|
||||
table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
||||
table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
|
||||
table.setShowGrid(False)
|
||||
header = table.horizontalHeader()
|
||||
header.setFixedHeight(self.theme.downloadsTableHeaderHeight)
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeMode.Stretch)
|
||||
for column in range(1, len(columns)):
|
||||
header.setSectionResizeMode(column, QHeaderView.ResizeMode.ResizeToContents)
|
||||
table.verticalHeader().setVisible(False)
|
||||
self._update_download_table_height(table)
|
||||
layout.addWidget(table)
|
||||
if label is not None:
|
||||
self.downloadTableHeadings[table] = label
|
||||
label.setVisible(False)
|
||||
table.setVisible(False)
|
||||
return table
|
||||
|
||||
def _update_download_table_height(self, table: QTableWidget) -> None:
|
||||
rows = max(1, table.rowCount())
|
||||
height = self.theme.downloadsTableHeaderHeight + rows * self.theme.downloadsTableRowHeight
|
||||
table.setFixedHeight(height)
|
||||
has_rows = table.rowCount() > 0
|
||||
table.setVisible(has_rows)
|
||||
heading = self.downloadTableHeadings.get(table)
|
||||
if heading is not None:
|
||||
heading.setVisible(has_rows)
|
||||
|
||||
def _clear_completed_downloads(self) -> None:
|
||||
self.downloadCompletedTable.setRowCount(0)
|
||||
self._update_download_table_height(self.downloadCompletedTable)
|
||||
self._update_downloads_tab_visibility()
|
||||
|
||||
def _start_gog_login(self) -> None:
|
||||
if getattr(self, "gog_auth_worker", None) is not None:
|
||||
return
|
||||
clipboard = QApplication.clipboard()
|
||||
if not getattr(self, "gog_auth_clipboard_connected", False):
|
||||
clipboard.dataChanged.connect(self._check_gog_login_clipboard)
|
||||
self.gog_auth_clipboard_connected = True
|
||||
self.gog_auth_timer = QTimer(self)
|
||||
self.gog_auth_timer.setSingleShot(True)
|
||||
self.gog_auth_timer.timeout.connect(self._cancel_gog_login)
|
||||
self.gog_auth_timer.start(GOG_LOGIN_TIMEOUT_MS)
|
||||
self.gogLoginButton.setEnabled(False)
|
||||
self.gogAccountStatus.setText(_("Copy the link to clipboard"))
|
||||
if not QDesktopServices.openUrl(QUrl(GOG_LOGIN_URL)):
|
||||
self._stop_gog_login_clipboard()
|
||||
self.gogLoginButton.setEnabled(True)
|
||||
self._update_gog_account_state()
|
||||
|
||||
def _handle_gog_account_action(self) -> None:
|
||||
if not self.gog_api.auth_path.is_file():
|
||||
self._start_gog_login()
|
||||
return
|
||||
if not self.gog_api.logout():
|
||||
self.gogAccountStatus.setText(_("Failed to log out of GOG account"))
|
||||
return
|
||||
self._update_gog_account_state()
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _check_gog_login_clipboard(self) -> None:
|
||||
code = self.gog_api.extract_auth_code(QApplication.clipboard().text().strip())
|
||||
if not code:
|
||||
return
|
||||
self._stop_gog_login_clipboard()
|
||||
self.gogAccountStatus.setText(_("Refreshing…"))
|
||||
worker = GOGAuthWorker(self.gog_api, code)
|
||||
worker.authenticated.connect(self._on_gog_authenticated)
|
||||
worker.finished.connect(self._on_gog_auth_worker_finished)
|
||||
self.gog_auth_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _stop_gog_login_clipboard(self) -> None:
|
||||
timer = getattr(self, "gog_auth_timer", None)
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
self.gog_auth_timer = None
|
||||
if not getattr(self, "gog_auth_clipboard_connected", False):
|
||||
return
|
||||
QApplication.clipboard().dataChanged.disconnect(
|
||||
self._check_gog_login_clipboard
|
||||
)
|
||||
self.gog_auth_clipboard_connected = False
|
||||
|
||||
def _cancel_gog_login(self) -> None:
|
||||
self._stop_gog_login_clipboard()
|
||||
self.gogLoginButton.setEnabled(True)
|
||||
self._update_gog_account_state()
|
||||
|
||||
def _on_gog_authenticated(self, authenticated: bool, error: str) -> None:
|
||||
if not authenticated:
|
||||
message = error or _("Unknown error")
|
||||
self.gogAccountStatus.setText(_("GOG login failed: {0}").format(message))
|
||||
return
|
||||
account_name = self.gog_api.get_account_name()
|
||||
self.gogAccountStatus.setText(
|
||||
account_name or _("GOG account connected")
|
||||
)
|
||||
self.loadGames(force_load=True)
|
||||
self._refresh_gog_library()
|
||||
|
||||
def _on_gog_auth_worker_finished(self) -> None:
|
||||
self.gogLoginButton.setEnabled(True)
|
||||
button_text = (
|
||||
_("Log out")
|
||||
if self.gog_api.auth_path.is_file() else _("Open login page")
|
||||
)
|
||||
self.gogLoginButton.setText(button_text)
|
||||
self.gog_auth_worker = None
|
||||
|
||||
def _update_gog_account_state(self) -> None:
|
||||
connected = self.gog_api.auth_path.is_file()
|
||||
account_name = self.gog_api.get_account_name() if connected else ""
|
||||
status = (
|
||||
account_name or _("GOG account connected")
|
||||
if connected else _("GOG account not connected")
|
||||
)
|
||||
self.gogAccountStatus.setText(status)
|
||||
self.gogLoginButton.setEnabled(True)
|
||||
self.gogLoginButton.setText(
|
||||
_("Log out") if connected else _("Open login page")
|
||||
)
|
||||
if connected and not account_name:
|
||||
self._refresh_gog_account_name()
|
||||
|
||||
def _refresh_gog_account_name(self) -> None:
|
||||
if getattr(self, "gog_account_worker", None) is not None:
|
||||
return
|
||||
worker = GOGAccountWorker(self.gog_api)
|
||||
worker.loaded.connect(self._on_gog_account_name_loaded)
|
||||
worker.finished.connect(self._on_gog_account_worker_finished)
|
||||
self.gog_account_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_gog_account_name_loaded(self, account_name: str) -> None:
|
||||
if account_name:
|
||||
self.gogAccountStatus.setText(account_name)
|
||||
|
||||
def _on_gog_account_worker_finished(self) -> None:
|
||||
self.gog_account_worker = None
|
||||
|
||||
def _refresh_gog_library(self) -> None:
|
||||
if not self.gog_api.auth_path.is_file():
|
||||
return
|
||||
if getattr(self, "gog_library_worker", None) is not None:
|
||||
return
|
||||
self.gogAccountStatus.setText(_("Refreshing…"))
|
||||
worker = GOGLibraryWorker(self.gog_api)
|
||||
worker.loaded.connect(self._on_gog_library_loaded)
|
||||
worker.failed.connect(self._on_gog_library_failed)
|
||||
worker.progress.connect(self._on_gog_library_progress)
|
||||
worker.finished.connect(self._on_gog_library_worker_finished)
|
||||
self.gog_library_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_gog_library_progress(self, completed: int, total: int) -> None:
|
||||
self.gogAccountStatus.setText(
|
||||
_("Refreshing GOG library… {0}/{1}").format(completed, total)
|
||||
)
|
||||
|
||||
def _on_gog_library_loaded(self, _games: list) -> None:
|
||||
self._update_gog_account_state()
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _on_gog_library_failed(self, message: str) -> None:
|
||||
self.gogAccountStatus.setText(_("Failed to refresh GOG library: {0}").format(message))
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _on_gog_library_worker_finished(self) -> None:
|
||||
self.gog_library_worker = None
|
||||
|
||||
def _install_gog_game(self, game: dict) -> None:
|
||||
if self.gog_process is not None:
|
||||
self.gog_download_queue.append(game)
|
||||
self._append_download_row(self.downloadQueuedTable, game, _("Install"))
|
||||
self.switchTab(6)
|
||||
return
|
||||
app_id = str(game["app_id"])
|
||||
install_path = self.gog_api.get_install_path(app_id, str(game["title"]))
|
||||
install_path.mkdir(parents=True, exist_ok=True)
|
||||
selected_paths: list[str] = []
|
||||
file_explorer = FileExplorer(
|
||||
self,
|
||||
theme=self.theme,
|
||||
initial_path=str(install_path),
|
||||
directory_only=True,
|
||||
)
|
||||
file_explorer.setWindowTitle(_("Select installation folder"))
|
||||
file_explorer.file_signal.file_selected.connect(selected_paths.append)
|
||||
file_explorer.exec()
|
||||
if not selected_paths:
|
||||
return
|
||||
install_path = Path(selected_paths[0])
|
||||
manifest_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
)
|
||||
if manifest_path.is_file() and not self.gog_api.is_game_installed(app_id):
|
||||
try:
|
||||
manifest_path.unlink()
|
||||
except OSError as error:
|
||||
logger.error("Failed to reset incomplete GOG download: %s", error)
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
support_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "gog-support" / app_id
|
||||
)
|
||||
try:
|
||||
command = self.gog_api.build_command(
|
||||
[
|
||||
"download", app_id, "--path", str(install_path),
|
||||
"--support", str(support_path), "--platform", "windows",
|
||||
]
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
self._start_gog_download(game, install_path, command, _("Install"))
|
||||
|
||||
def _repair_gog_game(self, game: dict) -> None:
|
||||
if self.gog_process is not None:
|
||||
self.gogAccountStatus.setText(_("Another GOG operation is already running"))
|
||||
return
|
||||
app_id = str(game["app_id"])
|
||||
install_path = self.gog_api.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
self.gogAccountStatus.setText(_("GOG installation not found"))
|
||||
return
|
||||
support_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "gog-support" / app_id
|
||||
)
|
||||
try:
|
||||
command = self.gog_api.build_command(
|
||||
[
|
||||
"repair", app_id, "--path", str(install_path),
|
||||
"--support", str(support_path), "--platform", "windows",
|
||||
]
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
self._start_gog_download(game, install_path, command, _("Repair"))
|
||||
|
||||
def _import_gog_game(self, game: dict) -> None:
|
||||
selected_paths: list[str] = []
|
||||
file_explorer = FileExplorer(
|
||||
self, theme=self.theme, initial_path=str(Path.home() / "Games"),
|
||||
directory_only=True,
|
||||
)
|
||||
file_explorer.setWindowTitle(_("Select GOG game folder"))
|
||||
file_explorer.file_signal.file_selected.connect(selected_paths.append)
|
||||
file_explorer.exec()
|
||||
if not selected_paths:
|
||||
return
|
||||
app_id = str(game["app_id"])
|
||||
game_path = self.gog_api.find_install_path(app_id, Path(selected_paths[0]))
|
||||
if game_path is None:
|
||||
self.gogAccountStatus.setText(_("Selected folder is not this GOG game"))
|
||||
return
|
||||
self.gog_api.save_installed_game(
|
||||
app_id, {"install_path": str(game_path), "title": str(game["title"])}
|
||||
)
|
||||
self.gog_api.ensure_launch_parameters(app_id)
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _delete_gog_game(self, game: dict) -> None:
|
||||
app_id = str(game["app_id"])
|
||||
install_path = self.gog_api.get_installed_path(app_id)
|
||||
if install_path is None:
|
||||
self.gogAccountStatus.setText(_("GOG installation not found"))
|
||||
return
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(
|
||||
_("Delete '{0}' and all files in its installation folder?").format(
|
||||
game["title"]
|
||||
)
|
||||
)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
try:
|
||||
shutil.rmtree(install_path)
|
||||
except OSError as error:
|
||||
logger.error("Failed to delete GOG game %s: %s", app_id, error)
|
||||
self.gogAccountStatus.setText(str(error))
|
||||
return
|
||||
manifest_path = (
|
||||
self.gog_api.config_dir / "heroic_gogdl" / "manifests" / app_id
|
||||
)
|
||||
try:
|
||||
manifest_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to delete GOG manifest %s: %s", app_id, error)
|
||||
self.gog_api.remove_installed_game(app_id)
|
||||
self.context_menu_manager.remove_gog_shortcuts(str(game["title"]))
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _start_gog_download(
|
||||
self, game: dict, install_path: Path, command: list[str], action: str
|
||||
) -> None:
|
||||
app_id = str(game["app_id"])
|
||||
self.downloadActiveTitle.setText(str(game["title"]))
|
||||
self.downloadActiveDetails.setText(_("Starting"))
|
||||
cover_width, cover_height = self.theme.downloadsActiveCoverSize
|
||||
load_pixmap_async(
|
||||
str(game.get("cover", "")), cover_width, cover_height,
|
||||
self.downloadActiveCover.setPixmap,
|
||||
app_name=f"download-active-{app_id}",
|
||||
)
|
||||
self.downloadActiveHeading.setVisible(True)
|
||||
self.downloadActiveCard.setVisible(True)
|
||||
self._update_downloads_tab_visibility()
|
||||
process = QProcess(self)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._gog_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.readyReadStandardOutput.connect(self._read_gog_download_output)
|
||||
process.finished.connect(self._on_gog_download_finished)
|
||||
self.gog_process = process
|
||||
self.gog_active_app_id = app_id
|
||||
self.gog_active_game = game
|
||||
self.gog_active_install_path = install_path
|
||||
self.gog_active_action = action
|
||||
self.gog_download_output = ""
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
self.downloadCancelButton.setEnabled(True)
|
||||
self.switchTab(6)
|
||||
process.start()
|
||||
|
||||
def _cancel_gog_download(self) -> None:
|
||||
if self.gog_process is None:
|
||||
return
|
||||
process = self.gog_process
|
||||
self.downloadCancelButton.setEnabled(False)
|
||||
self.downloadActiveDetails.setText(_("Cancel"))
|
||||
process.terminate()
|
||||
QTimer.singleShot(
|
||||
GOG_CANCEL_KILL_TIMEOUT_MS,
|
||||
lambda: self._kill_gog_process(process),
|
||||
)
|
||||
|
||||
def _kill_gog_process(self, process: QProcess) -> None:
|
||||
if process.state() != QProcess.ProcessState.NotRunning:
|
||||
logger.warning("gogdl did not terminate after cancellation; killing it")
|
||||
process.kill()
|
||||
|
||||
def _append_download_row(
|
||||
self, table: QTableWidget, game: dict, action: str
|
||||
) -> tuple[int, QLabel]:
|
||||
row = table.rowCount()
|
||||
table.insertRow(row)
|
||||
game_cell, details_label = self._create_download_game_cell(game)
|
||||
table.setCellWidget(row, 0, game_cell)
|
||||
values = (datetime.now().strftime("%H:%M:%S"), action, "GOG")
|
||||
for column, value in enumerate(values, 1):
|
||||
table.setItem(row, column, QTableWidgetItem(value))
|
||||
table.setRowHeight(row, self.theme.downloadsTableRowHeight)
|
||||
if table is self.downloadQueuedTable:
|
||||
details_label.setText(_("Queued"))
|
||||
else:
|
||||
details_label.setText(action)
|
||||
self._update_download_table_height(table)
|
||||
self._update_downloads_tab_visibility()
|
||||
return row, details_label
|
||||
|
||||
def _create_download_game_cell(self, game: dict) -> tuple[QWidget, QLabel]:
|
||||
widget = QWidget()
|
||||
layout = QHBoxLayout(widget)
|
||||
layout.setContentsMargins(*self.theme.downloadsCellMargins)
|
||||
layout.setSpacing(self.theme.downloadsCellSpacing)
|
||||
cover = QLabel()
|
||||
cover_width, cover_height = self.theme.downloadsCoverSize
|
||||
cover.setFixedSize(cover_width, cover_height)
|
||||
layout.addWidget(cover)
|
||||
text_layout = QVBoxLayout()
|
||||
title = QLabel(str(game["title"]))
|
||||
details = QLabel(_("Waiting…"))
|
||||
details.setStyleSheet(self.theme.CONTENT_STYLE)
|
||||
text_layout.addWidget(title)
|
||||
text_layout.addWidget(details)
|
||||
layout.addLayout(text_layout)
|
||||
load_pixmap_async(
|
||||
str(game.get("cover", "")), cover_width, cover_height, cover.setPixmap,
|
||||
app_name=f"download-{game['app_id']}",
|
||||
)
|
||||
return widget, details
|
||||
|
||||
def _gog_process_environment(self) -> QProcessEnvironment:
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
environment.insert("GOGDL_CONFIG_PATH", str(self.gog_api.config_dir))
|
||||
return environment
|
||||
|
||||
def _read_gog_download_output(self) -> None:
|
||||
if self.gog_process is None:
|
||||
return
|
||||
output = bytes(
|
||||
self.gog_process.readAllStandardOutput().data()
|
||||
).decode(errors="replace")
|
||||
self.gog_download_output = (self.gog_download_output + output)[-4096:]
|
||||
percentages = re.findall(
|
||||
r"Progress:\s+(\d{1,3}(?:\.\d+)?)", self.gog_download_output
|
||||
)
|
||||
if percentages:
|
||||
self.downloadOverallProgress.setValue(int(float(percentages[-1])))
|
||||
download_speeds = re.findall(
|
||||
r"Download\t-\s+(\S+)\s+MiB", self.gog_download_output
|
||||
)
|
||||
disk_speeds = re.findall(
|
||||
r"Disk\t-\s+(\S+)\s+MiB", self.gog_download_output
|
||||
)
|
||||
downloaded = re.findall(
|
||||
r"Downloaded:\s+([\d.]+\s+MiB)", self.gog_download_output
|
||||
)
|
||||
eta = re.findall(r"ETA:\s+(\d{2}:\d{2}:\d{2})", self.gog_download_output)
|
||||
if download_speeds:
|
||||
self.downloadSpeedLabel.setText(
|
||||
_("Download: {0} MiB/s").format(download_speeds[-1])
|
||||
)
|
||||
if disk_speeds:
|
||||
self.diskSpeedLabel.setText(
|
||||
_("Disk: {0} MiB/s").format(disk_speeds[-1])
|
||||
)
|
||||
details = []
|
||||
if downloaded:
|
||||
details.append(downloaded[-1])
|
||||
if download_speeds:
|
||||
details.append(f"{download_speeds[-1]} MiB/s")
|
||||
if eta:
|
||||
details.append(_("ETA: {0}").format(eta[-1]))
|
||||
if details:
|
||||
self._update_active_download_details(details)
|
||||
|
||||
def _update_active_download_details(self, details: list[str]) -> None:
|
||||
self.downloadActiveDetails.setText(" · ".join(details))
|
||||
|
||||
def _on_gog_download_finished(
|
||||
self, code: int, _status: QProcess.ExitStatus
|
||||
) -> None:
|
||||
game = self.gog_active_game
|
||||
install_path = self.gog_active_install_path
|
||||
app_id = str(game["app_id"])
|
||||
self.downloadActiveHeading.setVisible(False)
|
||||
self.downloadActiveCard.setVisible(False)
|
||||
game_path = self.gog_api.find_install_path(app_id, install_path)
|
||||
if code == 0 and game_path is None:
|
||||
code = 1
|
||||
completed_action = _("Failed")
|
||||
if code == 0:
|
||||
completed_action = (
|
||||
_("Repaired") if self.gog_active_action == _("Repair")
|
||||
else _("Installed")
|
||||
)
|
||||
_completed_row, completed_details = self._append_download_row(
|
||||
self.downloadCompletedTable, game, completed_action,
|
||||
)
|
||||
if code == 0:
|
||||
self.gog_api.save_installed_game(
|
||||
app_id, {"install_path": str(game_path), "title": game["title"]}
|
||||
)
|
||||
self._update_installed_gog_detail(app_id)
|
||||
self.loadGames(force_load=True)
|
||||
else:
|
||||
error = self._get_gog_download_error()
|
||||
completed_details.setText(error)
|
||||
logger.error("GOG download failed for %s: %s", app_id, error)
|
||||
self.gog_process = None
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
self.downloadSpeedLabel.setText(_("Downloading: ") + "\u2014")
|
||||
self.diskSpeedLabel.setText(_("Disk: ") + "\u2014")
|
||||
if self.gog_download_queue:
|
||||
next_game = self.gog_download_queue.pop(0)
|
||||
self.downloadQueuedTable.removeRow(0)
|
||||
self._update_download_table_height(self.downloadQueuedTable)
|
||||
self._install_gog_game(next_game)
|
||||
|
||||
def _start_gog_support_setup(self, app_id: str, button=None) -> None:
|
||||
if getattr(self, "gog_support_workers", []):
|
||||
return
|
||||
start_command = getattr(self, "start_sh", None)
|
||||
if not start_command:
|
||||
logger.warning("PortProton is unavailable for GOG support setup")
|
||||
return
|
||||
self.downloadActiveTitle.setText("GOG Redist")
|
||||
self.downloadActiveDetails.setText(_("Starting"))
|
||||
self.downloadActiveCover.clear()
|
||||
self.downloadActiveHeading.setVisible(True)
|
||||
self.downloadActiveCard.setVisible(True)
|
||||
self._update_downloads_tab_visibility()
|
||||
self.downloadCancelButton.setEnabled(False)
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
worker = GOGSupportWorker(self.gog_api, app_id, start_command)
|
||||
worker.failed.connect(self.gogAccountStatus.setText)
|
||||
worker.launched.connect(
|
||||
lambda process: self._track_gog_support_process(
|
||||
app_id, process, button
|
||||
)
|
||||
)
|
||||
worker.succeeded.connect(
|
||||
lambda: self._launch_after_gog_support(app_id, button)
|
||||
)
|
||||
worker.finished.connect(lambda: self._finish_gog_support_setup(worker))
|
||||
self.gog_support_workers.append(worker)
|
||||
SoundManager().play("game_launch")
|
||||
worker.start()
|
||||
|
||||
def _track_gog_support_process(
|
||||
self, app_id: str, process: subprocess.Popen, button=None
|
||||
) -> None:
|
||||
target = self.gog_api.get_launch_target(app_id)
|
||||
self.game_processes.append(process)
|
||||
self.target_exe = os.path.basename(target or app_id)
|
||||
self.current_running_button = button
|
||||
self._start_launch_output_reader(process)
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self._set_running_button_stop()
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||
self.checkProcessTimer.start(500)
|
||||
|
||||
def _launch_after_gog_support(self, app_id: str, button=None) -> None:
|
||||
self.game_processes = [
|
||||
process for process in self.game_processes
|
||||
if process.poll() is None
|
||||
]
|
||||
timer = self.checkProcessTimer
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
self.checkProcessTimer = None
|
||||
self._launch_gog_game(app_id, button, play_sound=False)
|
||||
|
||||
def _finish_gog_support_setup(self, worker: GOGSupportWorker) -> None:
|
||||
workers = getattr(self, "gog_support_workers", [])
|
||||
if worker in workers:
|
||||
workers.remove(worker)
|
||||
self.downloadActiveHeading.setVisible(False)
|
||||
self.downloadActiveCard.setVisible(False)
|
||||
action = _("Failed") if worker.error else _("Installed")
|
||||
redist = {"app_id": "gog-redist", "title": "GOG Redist", "cover": ""}
|
||||
_row, details = self._append_download_row(
|
||||
self.downloadCompletedTable, redist, action
|
||||
)
|
||||
if worker.error:
|
||||
details.setText(worker.error)
|
||||
self.resetPlayButton()
|
||||
|
||||
def _get_gog_download_error(self) -> str:
|
||||
lines = [line.strip() for line in self.gog_download_output.splitlines()]
|
||||
error_lines = [line for line in lines if "ERROR" in line.upper()]
|
||||
if error_lines:
|
||||
return error_lines[-1]
|
||||
useful = [line for line in lines if line and "[PROGRESS]" not in line]
|
||||
return useful[-1] if useful else _("Download process failed")
|
||||
|
||||
def _update_installed_gog_detail(self, app_id: str) -> None:
|
||||
install_uri = f"gog://install/{app_id}"
|
||||
if self.current_exec_line != install_uri:
|
||||
return
|
||||
if self.stackedWidget.currentWidget() is not self.currentDetailPage:
|
||||
return
|
||||
source = self.detail_page_manager._current_detail_source
|
||||
if source is None or source[0] != "game":
|
||||
return
|
||||
source[1]["exec_line"] = f"gog://launch/{app_id}"
|
||||
self.current_exec_line = source[1]["exec_line"]
|
||||
self.detail_page_manager._reopen_current_detail_page()
|
||||
@@ -109,9 +109,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _on_only_installed_changed(self, checked: bool) -> None:
|
||||
game_config.set_only_installed(checked)
|
||||
self.searchEdit.clear()
|
||||
self.games = []
|
||||
self._preserve_library_focus_after_load = True
|
||||
self.loadGames(force_load=True)
|
||||
display_filter = game_config.get_display_filter()
|
||||
cached_games = getattr(self, "_loaded_library_cache", {}).get(display_filter)
|
||||
if cached_games is None:
|
||||
self.games = []
|
||||
self.loadGames(force_load=True)
|
||||
return
|
||||
games = cached_games
|
||||
if checked:
|
||||
games = [
|
||||
game for game in games
|
||||
if not game[5].startswith(("gog://install/", "egs://install/"))
|
||||
]
|
||||
self.games = games
|
||||
self._preserve_library_focus_after_load = False
|
||||
self.game_library_manager.games = games
|
||||
self.game_library_manager.filtered_games = games
|
||||
self.game_library_manager._build_search_indices(games)
|
||||
self.game_library_manager.update_game_grid(
|
||||
is_filter=True, focus_first_card=False
|
||||
)
|
||||
|
||||
def _on_library_badge_view_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.badge_view_keys):
|
||||
@@ -125,6 +143,24 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
card.update_badge_visibility(display_filter)
|
||||
card.update_badge_view_mode(badge_view_mode)
|
||||
|
||||
def _on_library_layout_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.library_layout_keys):
|
||||
return
|
||||
layout_mode = self.library_layout_keys[index]
|
||||
card_orientation = "theme"
|
||||
if layout_mode in {"horizontal_vertical", "horizontal_top_vertical"}:
|
||||
layout_mode = layout_mode.removesuffix("_vertical")
|
||||
card_orientation = "vertical"
|
||||
elif layout_mode in {"horizontal", "horizontal_top"}:
|
||||
card_orientation = "horizontal"
|
||||
ui_config.set_library_layout_mode(layout_mode)
|
||||
ui_config.set_horizontal_card_orientation(card_orientation)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid"))
|
||||
self.game_library_manager.rebuild_library_layout(
|
||||
ui_config.get_library_layout_mode(theme_mode)
|
||||
)
|
||||
self.refresh_autoinstall_layout()
|
||||
|
||||
def _toggle_library_controls(self) -> None:
|
||||
self._position_library_controls_widget()
|
||||
self.libraryControlsAnimation.toggle(self.libraryControlsButton.isChecked())
|
||||
@@ -302,11 +338,12 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
display_filter = game_config.get_display_filter()
|
||||
only_installed = game_config.get_only_installed()
|
||||
self.filter_keys = ["all", "steam", "gog", "portproton", "favorites"]
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
|
||||
self.filter_labels = [
|
||||
_("All"),
|
||||
"Steam",
|
||||
"GOG",
|
||||
"Epic Games",
|
||||
"PortProton",
|
||||
_("Favorites"),
|
||||
]
|
||||
@@ -347,6 +384,36 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.gamesBadgeViewCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
controls_layout.addWidget(self.gamesBadgeViewCombo, 0, 2)
|
||||
|
||||
self.library_layout_keys = [
|
||||
"theme", "grid", "list", "vertical", "horizontal",
|
||||
"horizontal_vertical", "horizontal_top", "horizontal_top_vertical",
|
||||
]
|
||||
self.library_layout_labels = [
|
||||
_("Theme"), _("Grid"), _("List"), _("Table"),
|
||||
_("Horizontal Blocks"), _("Horizontal Cards"),
|
||||
_("Horizontal Top Blocks"), _("Horizontal Top Cards"),
|
||||
]
|
||||
self.gamesLayoutCombo = self._create_library_combo(
|
||||
self.library_layout_labels, _("Grid Type:")
|
||||
)
|
||||
selected_layout = ui_config.get_library_layout_mode("theme")
|
||||
if (
|
||||
selected_layout in {"horizontal", "horizontal_top"}
|
||||
and ui_config.get_horizontal_card_orientation("horizontal")
|
||||
== "vertical"
|
||||
):
|
||||
selected_layout += "_vertical"
|
||||
self._set_combo_current_key(
|
||||
self.gamesLayoutCombo, self.library_layout_keys, selected_layout
|
||||
)
|
||||
self.gamesLayoutCombo.currentIndexChanged.connect(
|
||||
self._on_library_layout_changed
|
||||
)
|
||||
self.gamesLayoutCombo.activated.connect(
|
||||
self._delay_library_controls_hover_close
|
||||
)
|
||||
controls_layout.addWidget(self.gamesLayoutCombo, 0, 3)
|
||||
|
||||
def _delay_library_controls_hover_close(self, _index: int = -1) -> None:
|
||||
self._library_controls_hover_close_delayed = True
|
||||
QTimer.singleShot(350, self._allow_library_controls_hover_close)
|
||||
@@ -389,6 +456,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
# Mark that a refresh is in progress
|
||||
self._refresh_in_progress = True
|
||||
self._loaded_library_cache = {}
|
||||
|
||||
# Clear the search field to ensure all games are shown after refresh
|
||||
self.searchEdit.clear()
|
||||
@@ -430,8 +498,11 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
if callable(source_refresh):
|
||||
source_refresh()
|
||||
|
||||
if not callable(source_loader) and self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if not callable(source_loader):
|
||||
if self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if self.egs_api.user_path.is_file():
|
||||
self._refresh_egs_library()
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
# Use a small delay to allow UI to update before starting the refresh
|
||||
@@ -629,6 +700,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
entry = parse_desktop_entry(desktop_path)
|
||||
if not entry:
|
||||
return
|
||||
self._known_portproton_desktops.add(desktop_path)
|
||||
description = entry.get("Comment", "")
|
||||
exec_line = entry.get("Exec", exe_path)
|
||||
cover_for_shortcut = dialog.last_cover_path if dialog.last_cover_path else user_cover
|
||||
@@ -670,11 +742,6 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
if steam_desc and steam_desc != final_desc:
|
||||
final_desc = steam_desc
|
||||
|
||||
# Use Steam name as fallback if better
|
||||
steam_name = steam_info.get("name", "")
|
||||
if steam_name and steam_name != final_name:
|
||||
final_name = steam_name
|
||||
|
||||
# Build full game_data tuple with all Steam data
|
||||
game_data = (
|
||||
final_name,
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtCore import QObject, Qt, QTimer
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QScroller,
|
||||
QSizePolicy,
|
||||
QTabBar,
|
||||
QTableWidget,
|
||||
QTreeWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -38,7 +46,7 @@ from portprotonqt.config import (
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox, FlowLayout
|
||||
from portprotonqt.debug_utils import get_selectable_gpu_list
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.localization import _, retranslate
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.portproton_api import get_user_conf_setting, set_user_conf_setting
|
||||
from portprotonqt.qt_utils import get_system_dpi_for_wine
|
||||
@@ -66,6 +74,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
def createPortProtonTab(self):
|
||||
"""PortProton Settings tab."""
|
||||
self.portProtonWidget = QWidget()
|
||||
self.portProtonWidget.setProperty("theme_style_name", "OTHER_PAGES_WIDGET_STYLE")
|
||||
self.portProtonWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
self.portProtonWidget.setObjectName("otherPage")
|
||||
layout = QVBoxLayout(self.portProtonWidget)
|
||||
@@ -99,6 +108,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
def create_section(title_text, theme):
|
||||
section_frame = QFrame()
|
||||
section_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
section_frame.setProperty("theme_style_name", "SETTINGS_FRAME_STYLE")
|
||||
section_frame.setStyleSheet(self.theme.SETTINGS_FRAME_STYLE)
|
||||
section_layout = QVBoxLayout(section_frame)
|
||||
section_layout.setContentsMargins(*theme.portProtonPageMargins)
|
||||
@@ -145,25 +155,43 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
selected_account = game_config.get_steam_account_id()
|
||||
selected_index = self.steamAccountCombo.findData(selected_account)
|
||||
self.steamAccountCombo.setCurrentIndex(max(selected_index, 0))
|
||||
steam_account_title = QLabel(_("Account") + " Steam:")
|
||||
steam_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(steam_account_title, self.steamAccountCombo)
|
||||
self.steamAccountTitle = QLabel(_("Account") + " Steam:")
|
||||
self.steamAccountTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(self.steamAccountTitle, self.steamAccountCombo)
|
||||
self.gogAccountStatus = QLabel()
|
||||
self.gogAccountStatus.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
self.gogAccountStatus.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.gogLoginButton = AutoSizeButton(_("Open login page"))
|
||||
self.gogLoginButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.gogLoginButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.gogLoginButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.gogLoginButton.clicked.connect(self._handle_gog_account_action)
|
||||
gog_buttons = QHBoxLayout()
|
||||
gog_buttons.addWidget(self.gogAccountStatus)
|
||||
gog_buttons.addWidget(self.gogLoginButton)
|
||||
gog_account_title = QLabel(_("Account") + " GOG:")
|
||||
gog_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(gog_account_title, gog_buttons)
|
||||
self.gogAccountTitle = QLabel(_("Account") + " GOG:")
|
||||
self.gogAccountTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(self.gogAccountTitle, gog_buttons)
|
||||
self._update_gog_account_state()
|
||||
self.egsAccountStatus = QLabel()
|
||||
self.egsAccountStatus.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
self.egsAccountStatus.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.egsLoginButton = AutoSizeButton(_("Open login page"))
|
||||
self.egsLoginButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.egsLoginButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.egsLoginButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.egsLoginButton.clicked.connect(self._handle_egs_account_action)
|
||||
egs_buttons = QHBoxLayout()
|
||||
egs_buttons.addWidget(self.egsAccountStatus)
|
||||
egs_buttons.addWidget(self.egsLoginButton)
|
||||
self.egsAccountTitle = QLabel(_("Account") + " Epic Games:")
|
||||
self.egsAccountTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(self.egsAccountTitle, egs_buttons)
|
||||
self._update_egs_account_state()
|
||||
|
||||
self.timeDetailCombo = CustomComboBox(theme=self.theme)
|
||||
self.timeDetailCombo.view().window().setWindowFlags(
|
||||
@@ -221,7 +249,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
uiForm.addRow(self.trayMenuModeTitle, self.trayMenuModeCombo)
|
||||
self.setTabOrder(self.timeDetailCombo, self.steamAccountCombo)
|
||||
self.setTabOrder(self.steamAccountCombo, self.gogLoginButton)
|
||||
self.setTabOrder(self.gogLoginButton, self.trayMenuModeCombo)
|
||||
self.setTabOrder(self.gogLoginButton, self.egsLoginButton)
|
||||
self.setTabOrder(self.egsLoginButton, self.trayMenuModeCombo)
|
||||
|
||||
self.gamepad_type_keys = ["auto", "xbox", "playstation"]
|
||||
self.gamepad_type_labels = [_("Auto"), "Xbox", "PlayStation"]
|
||||
@@ -359,6 +388,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
hide_control_hints_layout.addStretch()
|
||||
uiForm.addRow(hide_control_hints_layout)
|
||||
|
||||
self.forceEnglishCheckBox = QCheckBox()
|
||||
self.forceEnglishCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.forceEnglishCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.forceEnglishTitle = QLabel(_("Force English language"))
|
||||
self.forceEnglishTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.forceEnglishTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
|
||||
self.forceEnglishTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.forceEnglishCheckBox.setChecked(ui_config.get_force_english())
|
||||
force_english_layout = QHBoxLayout()
|
||||
force_english_layout.setContentsMargins(0, 0, 0, 0)
|
||||
force_english_layout.addWidget(self.forceEnglishCheckBox)
|
||||
force_english_layout.addWidget(self.forceEnglishTitle)
|
||||
force_english_layout.addStretch()
|
||||
uiForm.addRow(force_english_layout)
|
||||
|
||||
self.soundsEnabledCheckBox = QCheckBox()
|
||||
self.soundsEnabledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.soundsEnabledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -397,6 +441,35 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
crash_reports_enabled_layout.addStretch()
|
||||
uiForm.addRow(crash_reports_enabled_layout)
|
||||
|
||||
self.disableAltDependencyCheckCheckBox: QCheckBox | None = None
|
||||
if self._is_alt_x86_64():
|
||||
self.disableAltDependencyCheckCheckBox = QCheckBox()
|
||||
self.disableAltDependencyCheckCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.disableAltDependencyCheckCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
disable_alt_dependency_check_title = QLabel(
|
||||
_("Disable ALT Linux i586 dependency check")
|
||||
)
|
||||
disable_alt_dependency_check_title.setSizePolicy(
|
||||
QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
disable_alt_dependency_check_title.setStyleSheet(
|
||||
self.theme.SETTINGS_TITLE_CHECKBOX_STYLE
|
||||
)
|
||||
disable_alt_dependency_check_title.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.disableAltDependencyCheckCheckBox.setChecked(
|
||||
ui_config.get_disable_alt_i586_dependency_check()
|
||||
)
|
||||
disable_alt_dependency_check_layout = QHBoxLayout()
|
||||
disable_alt_dependency_check_layout.setContentsMargins(0, 0, 0, 0)
|
||||
disable_alt_dependency_check_layout.addWidget(
|
||||
self.disableAltDependencyCheckCheckBox
|
||||
)
|
||||
disable_alt_dependency_check_layout.addWidget(
|
||||
disable_alt_dependency_check_title
|
||||
)
|
||||
disable_alt_dependency_check_layout.addStretch()
|
||||
uiForm.addRow(disable_alt_dependency_check_layout)
|
||||
|
||||
disable_runtime_download_layout = None
|
||||
if not os.getenv("FLATPAK_ID"):
|
||||
self.disableRuntimeDownloadCheckBox = QCheckBox()
|
||||
@@ -523,6 +596,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.integrateAppImageButton = AutoSizeButton(
|
||||
_("Integrate AppImage"), icon=self.theme_manager.get_icon("desktop", as_path=True)
|
||||
)
|
||||
self.integrateAppImageButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.integrateAppImageButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.integrateAppImageButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.integrateAppImageButton.clicked.connect(self.integrateAppImage)
|
||||
@@ -623,6 +697,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.proxyUrlEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.proxyUrlEdit.setPlaceholderText(_("Proxy URL"))
|
||||
self.proxyUrlEdit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.proxyUrlEdit.setProperty("theme_style_name", "LINE_EDIT_STYLE")
|
||||
self.proxyUrlEdit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
self.proxyUrlEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.proxyUrlTitle = QLabel(_("Proxy URL:"))
|
||||
@@ -637,6 +712,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.proxyUserEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.proxyUserEdit.setPlaceholderText(_("Proxy Username"))
|
||||
self.proxyUserEdit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.proxyUserEdit.setProperty("theme_style_name", "LINE_EDIT_STYLE")
|
||||
self.proxyUserEdit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
self.proxyUserEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.proxyUserTitle = QLabel(_("Proxy Username:"))
|
||||
@@ -649,6 +725,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.proxyPasswordEdit.setPlaceholderText(_("Proxy Password"))
|
||||
self.proxyPasswordEdit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.proxyPasswordEdit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.proxyPasswordEdit.setProperty("theme_style_name", "LINE_EDIT_STYLE")
|
||||
self.proxyPasswordEdit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
self.proxyPasswordEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.proxyPasswordTitle = QLabel(_("Proxy Password:"))
|
||||
@@ -667,18 +744,30 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
buttonsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
|
||||
|
||||
self.saveButton = AutoSizeButton(_("Save Settings"), icon=self.theme_manager.get_icon("save", as_path=True))
|
||||
self.saveButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.saveButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.saveButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.saveButton.clicked.connect(self.savePortProtonSettings)
|
||||
buttonsLayout.addWidget(self.saveButton)
|
||||
|
||||
self.resetSettingsButton = AutoSizeButton(_("Reset Settings"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.resetSettingsButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.resetSettingsButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.resetSettingsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.resetSettingsButton.clicked.connect(self.resetSettings)
|
||||
buttonsLayout.addWidget(self.resetSettingsButton)
|
||||
|
||||
self.globalGameSettingsButton = AutoSizeButton(
|
||||
_("Global Game Settings"), icon=self.theme_manager.get_icon("settings", as_path=True)
|
||||
)
|
||||
self.globalGameSettingsButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.globalGameSettingsButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.globalGameSettingsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.globalGameSettingsButton.clicked.connect(self.openGlobalGameSettings)
|
||||
buttonsLayout.addWidget(self.globalGameSettingsButton)
|
||||
|
||||
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)
|
||||
@@ -688,6 +777,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
buttonsLayout.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)
|
||||
@@ -696,6 +786,11 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
layout.addLayout(buttonsLayout)
|
||||
self.stackedWidget.addWidget(self.portProtonWidget)
|
||||
|
||||
def openGlobalGameSettings(self) -> None:
|
||||
from portprotonqt.dialogs.settings_dialog import ExeSettingsDialog
|
||||
|
||||
ExeSettingsDialog(self, self.theme, user_conf=True).exec()
|
||||
|
||||
def resetSettings(self):
|
||||
"""Reset settings and restart application."""
|
||||
msg_box = QMessageBox(self)
|
||||
@@ -747,6 +842,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
cache_config.clear_cache()
|
||||
self.gog_api.clear_library_cache()
|
||||
self.egs_api.clear_library_cache()
|
||||
|
||||
def integrateAppImage(self) -> None:
|
||||
"""Install the running AppImage in the user application menu."""
|
||||
@@ -839,6 +935,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def savePortProtonSettings(self):
|
||||
previous_economy_mode = ui_config.get_economy_mode()
|
||||
previous_force_english = ui_config.get_force_english()
|
||||
game_config.set_steam_account_id(
|
||||
str(self.steamAccountCombo.currentData())
|
||||
)
|
||||
@@ -874,6 +971,10 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
|
||||
hide_control_hints = self.hideControlHintsCheckBox.isChecked()
|
||||
ui_config.set_hide_control_hints(hide_control_hints)
|
||||
force_english = self.forceEnglishCheckBox.isChecked()
|
||||
ui_config.set_force_english(force_english)
|
||||
if previous_force_english != force_english:
|
||||
self._retranslate_interface()
|
||||
|
||||
gamepad_type_idx = self.gamepadTypeCombo.currentIndex()
|
||||
gamepad_type = self.gamepad_type_keys[gamepad_type_idx]
|
||||
@@ -995,10 +1096,59 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
ui_config.set_crash_reports_enabled(
|
||||
self.crashReportsEnabledCheckBox.isChecked()
|
||||
)
|
||||
if self.disableAltDependencyCheckCheckBox is not None:
|
||||
ui_config.set_disable_alt_i586_dependency_check(
|
||||
self.disableAltDependencyCheckCheckBox.isChecked()
|
||||
)
|
||||
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().reload_config()
|
||||
|
||||
def _retranslate_interface(self) -> None:
|
||||
"""Apply the selected language to the existing interface."""
|
||||
objects = [self, *self.findChildren(QObject)]
|
||||
for obj in objects:
|
||||
if isinstance(obj, QAction):
|
||||
obj.setText(retranslate(obj.text()))
|
||||
if isinstance(obj, (QLabel, QAbstractButton)):
|
||||
obj.setText(retranslate(obj.text()))
|
||||
if isinstance(obj, (QGroupBox, QMenu)):
|
||||
obj.setTitle(retranslate(obj.title()))
|
||||
if isinstance(obj, QLineEdit):
|
||||
obj.setPlaceholderText(retranslate(obj.placeholderText()))
|
||||
if isinstance(obj, QComboBox):
|
||||
for index in range(obj.count()):
|
||||
obj.setItemText(index, retranslate(obj.itemText(index)))
|
||||
if isinstance(obj, QTabBar):
|
||||
for index in range(obj.count()):
|
||||
obj.setTabText(index, retranslate(obj.tabText(index)))
|
||||
if isinstance(obj, QTableWidget):
|
||||
for column in range(obj.columnCount()):
|
||||
item = obj.horizontalHeaderItem(column)
|
||||
if item is not None:
|
||||
item.setText(retranslate(item.text()))
|
||||
for row in range(obj.rowCount()):
|
||||
for column in range(obj.columnCount()):
|
||||
item = obj.item(row, column)
|
||||
if item is not None:
|
||||
item.setText(retranslate(item.text()))
|
||||
if isinstance(obj, QTreeWidget):
|
||||
for column in range(obj.columnCount()):
|
||||
item = obj.headerItem()
|
||||
item.setText(column, retranslate(item.text(column)))
|
||||
self.steamAccountTitle.setText(_("Account") + " Steam:")
|
||||
self.gogAccountTitle.setText(_("Account") + " GOG:")
|
||||
self.egsAccountTitle.setText(_("Account") + " Epic Games:")
|
||||
self.autoDownloadPPDBTitle.setText(
|
||||
_("Auto download PPDB from") + " linux-gaming.ru"
|
||||
)
|
||||
self.enableThemeStoreTitle.setText(
|
||||
_("Enable Theme Store from %(source)s (%(warning)s)") % {
|
||||
"source": "linux-gaming.ru",
|
||||
"warning": _("third-party themes may be unsafe"),
|
||||
}
|
||||
)
|
||||
|
||||
def _apply_gamepad_type_setting(self) -> None:
|
||||
"""Apply configured gamepad type to current input manager."""
|
||||
input_manager = getattr(self, "input_manager", None)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import shlex
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -45,6 +46,9 @@ from portprotonqt.system_manager import (
|
||||
logger = get_logger(__name__)
|
||||
|
||||
AUDIO_MAX_VOLUME = 150
|
||||
RETURN_TO_DESKTOP_ENV = "PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT"
|
||||
SYSTEM_DEVICE_RETRY_INTERVAL_MS = 2000
|
||||
SYSTEM_DEVICE_RETRY_LIMIT = 5
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -165,6 +169,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def createSystemTab(self) -> None:
|
||||
"""System settings tab."""
|
||||
self.systemWidget = QWidget()
|
||||
self.systemWidget.setProperty("theme_style_name", "OTHER_PAGES_WIDGET_STYLE")
|
||||
self.systemWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
self.systemWidget.setObjectName("otherPage")
|
||||
layout = QVBoxLayout(self.systemWidget)
|
||||
@@ -263,6 +268,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
for index, section_title in enumerate(section_titles):
|
||||
button = AutoSizeButton(section_title)
|
||||
button.setCheckable(True)
|
||||
button.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.setProperty("sound_event", False)
|
||||
@@ -322,6 +328,14 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.audioBusy = False
|
||||
self.audioVolumeUpdating = False
|
||||
self.audioFocusSinkName = ""
|
||||
self.networkRetryTimer = QTimer(self)
|
||||
self.networkRetryTimer.setInterval(SYSTEM_DEVICE_RETRY_INTERVAL_MS)
|
||||
self.networkRetryTimer.timeout.connect(self.loadSystemNetworks)
|
||||
self.networkRetryCount = 0
|
||||
self.bluetoothRetryTimer = QTimer(self)
|
||||
self.bluetoothRetryTimer.setInterval(SYSTEM_DEVICE_RETRY_INTERVAL_MS)
|
||||
self.bluetoothRetryTimer.timeout.connect(self.loadSystemBluetoothDevices)
|
||||
self.bluetoothRetryCount = 0
|
||||
|
||||
def _startSystemBackgroundTasks(self) -> None:
|
||||
self.system_tab_index = self.stackedWidget.addWidget(self.systemWidget)
|
||||
@@ -404,6 +418,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def _createSystemSection(self, title_text: str) -> tuple[QFrame, QVBoxLayout]:
|
||||
section_frame = QFrame()
|
||||
section_frame.setProperty("theme_style_name", "SETTINGS_FRAME_STYLE")
|
||||
section_frame.setStyleSheet(self.theme.SETTINGS_FRAME_STYLE)
|
||||
section_layout = QVBoxLayout(section_frame)
|
||||
section_layout.setContentsMargins(15, 15, 15, 15)
|
||||
@@ -468,6 +483,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
buttons_layout = QHBoxLayout(self.networkActionButtonsWidget)
|
||||
buttons_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.systemRefreshButton = AutoSizeButton(_("Refresh"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.systemRefreshButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemRefreshButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemRefreshButton.clicked.connect(self.loadSystemNetworks)
|
||||
buttons_layout.addWidget(self.systemRefreshButton)
|
||||
@@ -514,6 +530,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.vpnDisconnectButton = AutoSizeButton(_("Disconnect"), icon=self.theme_manager.get_icon("stop", as_path=True))
|
||||
|
||||
self.vpnAddButton = AutoSizeButton(_("Add"), icon=self.theme_manager.get_icon("folder", as_path=True))
|
||||
self.vpnAddButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.vpnAddButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.vpnAddButton.clicked.connect(self.addVpnProfile)
|
||||
vpn_layout.addWidget(self.vpnAddButton)
|
||||
@@ -584,11 +601,13 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def _setupSystemBluetoothActionButtons(self, layout) -> None:
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.bluetoothRefreshButton = AutoSizeButton(_("Refresh"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.bluetoothRefreshButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.bluetoothRefreshButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.bluetoothRefreshButton.clicked.connect(self.loadSystemBluetoothDevices)
|
||||
buttons_layout.addWidget(self.bluetoothRefreshButton)
|
||||
|
||||
self.bluetoothScanButton = AutoSizeButton(_("Scan"), icon=self.theme_manager.get_icon("search", as_path=True))
|
||||
self.bluetoothScanButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.bluetoothScanButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.bluetoothScanButton.clicked.connect(self.scanSystemBluetoothDevices)
|
||||
buttons_layout.addWidget(self.bluetoothScanButton)
|
||||
@@ -638,6 +657,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def _setupSystemStorageActionButtons(self, layout) -> None:
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.storageRefreshButton = AutoSizeButton(_("Refresh"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.storageRefreshButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.storageRefreshButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.storageRefreshButton.clicked.connect(self.loadSystemStorageDevices)
|
||||
buttons_layout.addWidget(self.storageRefreshButton)
|
||||
@@ -710,11 +730,13 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def _setupSystemAudioActionButtons(self, layout) -> None:
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.audioRefreshButton = AutoSizeButton(_("Refresh"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.audioRefreshButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.audioRefreshButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.audioRefreshButton.clicked.connect(self.loadSystemAudioDevices)
|
||||
buttons_layout.addWidget(self.audioRefreshButton)
|
||||
|
||||
self.audioSetOutputButton = AutoSizeButton(_("Set output"), icon=self.theme_manager.get_icon("login", as_path=True))
|
||||
self.audioSetOutputButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.audioSetOutputButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.audioSetOutputButton.clicked.connect(self.setSelectedAudioSinkDefault)
|
||||
buttons_layout.addWidget(self.audioSetOutputButton)
|
||||
@@ -873,22 +895,36 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def _setupSystemPowerActionButtons(self, layout) -> None:
|
||||
buttons_layout = QHBoxLayout()
|
||||
|
||||
return_command = os.getenv(RETURN_TO_DESKTOP_ENV, "").strip()
|
||||
if return_command:
|
||||
self.systemReturnToDesktopButton = AutoSizeButton(
|
||||
_("Return to desktop"), icon=self.theme_manager.get_icon("exit", as_path=True)
|
||||
)
|
||||
self.systemReturnToDesktopButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemReturnToDesktopButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemReturnToDesktopButton.clicked.connect(self.returnToDesktop)
|
||||
buttons_layout.addWidget(self.systemReturnToDesktopButton)
|
||||
|
||||
self.systemLogoutButton = AutoSizeButton(_("Logout"), icon=self.theme_manager.get_icon("exit", as_path=True))
|
||||
self.systemLogoutButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemLogoutButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemLogoutButton.clicked.connect(self.logoutSystem)
|
||||
buttons_layout.addWidget(self.systemLogoutButton)
|
||||
|
||||
self.systemRebootButton = AutoSizeButton(_("Reboot"), icon=self.theme_manager.get_icon("reboot", as_path=True))
|
||||
self.systemRebootButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemRebootButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemRebootButton.clicked.connect(self.rebootSystem)
|
||||
buttons_layout.addWidget(self.systemRebootButton)
|
||||
|
||||
self.systemShutdownButton = AutoSizeButton(_("Shutdown"), icon=self.theme_manager.get_icon("shutdown", as_path=True))
|
||||
self.systemShutdownButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemShutdownButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemShutdownButton.clicked.connect(self.shutdownSystem)
|
||||
buttons_layout.addWidget(self.systemShutdownButton)
|
||||
|
||||
self.systemSuspendButton = AutoSizeButton(_("Suspend"), icon=self.theme_manager.get_icon("suspend", as_path=True))
|
||||
self.systemSuspendButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemSuspendButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemSuspendButton.clicked.connect(self.suspendSystem)
|
||||
buttons_layout.addWidget(self.systemSuspendButton)
|
||||
@@ -903,6 +939,20 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
logger.error("Failed to execute %s %s", command, action)
|
||||
|
||||
def returnToDesktop(self) -> None:
|
||||
command_text = os.getenv(RETURN_TO_DESKTOP_ENV, "").strip()
|
||||
try:
|
||||
command = shlex.split(command_text)
|
||||
except ValueError as error:
|
||||
logger.error("Invalid return-to-desktop command: %s", error)
|
||||
return
|
||||
if not command:
|
||||
logger.error("Cannot return to desktop: %s is empty", RETURN_TO_DESKTOP_ENV)
|
||||
return
|
||||
if QProcess.startDetached(command[0], command[1:]):
|
||||
return
|
||||
logger.error("Failed to execute return-to-desktop command: %s", command_text)
|
||||
|
||||
def rebootSystem(self) -> None:
|
||||
self._runSystemAction("reboot")
|
||||
|
||||
@@ -1197,6 +1247,14 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.vpnRows = payload.get("vpns", [])
|
||||
self.populateSystemNetworks(payload)
|
||||
self.setNetworkBusy(False)
|
||||
if payload.get("available"):
|
||||
self.networkRetryTimer.stop()
|
||||
self.networkRetryCount = 0
|
||||
elif operation == "load" and self.networkRetryCount < SYSTEM_DEVICE_RETRY_LIMIT:
|
||||
self.networkRetryCount += 1
|
||||
self.networkRetryTimer.start()
|
||||
elif operation == "load":
|
||||
self.networkRetryTimer.stop()
|
||||
if operation == "scan":
|
||||
logger.info("Network scan finished: wifi_networks=%d", len(self.networkRows))
|
||||
elif operation == "load":
|
||||
@@ -1206,9 +1264,14 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
len(self.vpnRows),
|
||||
)
|
||||
|
||||
def onNetworkOperationFailed(self, _operation: str, error_text: str) -> None:
|
||||
def onNetworkOperationFailed(self, operation: str, error_text: str) -> None:
|
||||
self.setNetworkBusy(False)
|
||||
logger.warning("Network operation failed: %s", error_text)
|
||||
if operation == "load" and self.networkRetryCount < SYSTEM_DEVICE_RETRY_LIMIT:
|
||||
self.networkRetryCount += 1
|
||||
self.networkRetryTimer.start()
|
||||
elif operation == "load":
|
||||
self.networkRetryTimer.stop()
|
||||
self.networkStatusLabel.setText("Error")
|
||||
self.wirelessEnabledCheckBox.blockSignals(True)
|
||||
self.wirelessEnabledCheckBox.setChecked(self.systemWirelessEnabled)
|
||||
@@ -1416,6 +1479,14 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.bluetoothRows = payload.get("devices", [])
|
||||
self.populateSystemBluetoothDevices(payload)
|
||||
self.setBluetoothBusy(False)
|
||||
if payload.get("available"):
|
||||
self.bluetoothRetryTimer.stop()
|
||||
self.bluetoothRetryCount = 0
|
||||
elif operation == "load" and self.bluetoothRetryCount < SYSTEM_DEVICE_RETRY_LIMIT:
|
||||
self.bluetoothRetryCount += 1
|
||||
self.bluetoothRetryTimer.start()
|
||||
elif operation == "load":
|
||||
self.bluetoothRetryTimer.stop()
|
||||
if operation != "load":
|
||||
QTimer.singleShot(800, self.loadSystemBluetoothDevices)
|
||||
if operation == "scan":
|
||||
@@ -1436,6 +1507,11 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.bluetoothRows = []
|
||||
self.bluetoothTable.setRowCount(0)
|
||||
self._setSystemSectionVisible(self.systemSectionBluetoothIndex, False)
|
||||
if self.bluetoothRetryCount < SYSTEM_DEVICE_RETRY_LIMIT:
|
||||
self.bluetoothRetryCount += 1
|
||||
self.bluetoothRetryTimer.start()
|
||||
else:
|
||||
self.bluetoothRetryTimer.stop()
|
||||
self.bluetoothEnabledCheckBox.blockSignals(True)
|
||||
self.bluetoothEnabledCheckBox.setChecked(self.systemBluetoothEnabled)
|
||||
self.bluetoothEnabledCheckBox.blockSignals(False)
|
||||
|
||||
@@ -219,6 +219,7 @@ class ThemeStoreMixin:
|
||||
def _create_theme_store_detail_header(self) -> QHBoxLayout:
|
||||
header = QHBoxLayout()
|
||||
self.themeStoreBackButton = AutoSizeButton(_("Back"))
|
||||
self.themeStoreBackButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.themeStoreBackButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreBackButton.setProperty("sound_event", "back")
|
||||
self.themeStoreBackButton.clicked.connect(self._show_theme_store_list)
|
||||
@@ -229,6 +230,7 @@ class ThemeStoreMixin:
|
||||
header.addWidget(self.themeStoreDetailTitle)
|
||||
header.addStretch(1)
|
||||
self.themeStoreDownloadButton = AutoSizeButton(_("Download"))
|
||||
self.themeStoreDownloadButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.themeStoreDownloadButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreDownloadButton.clicked.connect(self._download_current_store_theme)
|
||||
header.addWidget(self.themeStoreDownloadButton)
|
||||
@@ -266,10 +268,12 @@ class ThemeStoreMixin:
|
||||
def _add_theme_store_variant_buttons(self, scrollLayout: QVBoxLayout) -> None:
|
||||
variantLayout = QHBoxLayout()
|
||||
self.themeStoreDarkButton = AutoSizeButton(_("Dark"))
|
||||
self.themeStoreDarkButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.themeStoreDarkButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreDarkButton.clicked.connect(lambda: self._set_theme_store_preview_variant("dark"))
|
||||
variantLayout.addWidget(self.themeStoreDarkButton)
|
||||
self.themeStoreLightButton = AutoSizeButton(_("Light"))
|
||||
self.themeStoreLightButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.themeStoreLightButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreLightButton.clicked.connect(lambda: self._set_theme_store_preview_variant("light"))
|
||||
variantLayout.addWidget(self.themeStoreLightButton)
|
||||
@@ -578,6 +582,14 @@ class ThemeStoreMixin:
|
||||
def _set_theme_store_preview_variant(self, variant: str) -> None:
|
||||
self.themeStorePreviewVariant = variant
|
||||
active_style = getattr(self.theme, "ACTION_BUTTON_ACTIVE_STYLE", self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreDarkButton.setProperty(
|
||||
"theme_style_name",
|
||||
"ACTION_BUTTON_ACTIVE_STYLE" if variant == "dark" else "ACTION_BUTTON_STYLE",
|
||||
)
|
||||
self.themeStoreLightButton.setProperty(
|
||||
"theme_style_name",
|
||||
"ACTION_BUTTON_ACTIVE_STYLE" if variant == "light" else "ACTION_BUTTON_STYLE",
|
||||
)
|
||||
self.themeStoreDarkButton.setStyleSheet(
|
||||
active_style if variant == "dark" else self.theme.ACTION_BUTTON_STYLE
|
||||
)
|
||||
@@ -670,6 +682,8 @@ class ThemeStoreMixin:
|
||||
self.themeStoreStatusLabel.show()
|
||||
self.themeStoreStatusLabel.setText(_("Failed to install theme"))
|
||||
return
|
||||
for installed_theme in theme_names:
|
||||
self.theme_manager.invalidate_theme(installed_theme)
|
||||
variant = getattr(self, "themeStorePreviewVariant", "dark")
|
||||
theme_name = ui_config.resolve_theme(theme_names[0], variant)
|
||||
if theme_name not in theme_names:
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Callable
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtCore import QFileSystemWatcher, QTimer, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
@@ -10,17 +14,21 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide6.QtGui import QAction
|
||||
from shiboken6 import isValid
|
||||
|
||||
from portprotonqt.config import load_theme_metainfo, ui_config, window_config
|
||||
from portprotonqt.config.ui import _is_system_light_theme
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.image_utils import ImageCarousel
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.tabs.theme_store import THEME_STORE_ITEM, ThemeStoreMixin
|
||||
from portprotonqt.theme_manager import load_theme_screenshots
|
||||
from portprotonqt.theme_manager import _find_theme_folder, load_theme_screenshots
|
||||
from portprotonqt.tray_manager import restart_application_process
|
||||
|
||||
logger = get_logger(__name__)
|
||||
THEME_STYLE_BATCH_SIZE = 24
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
@@ -37,6 +45,10 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
def createThemeTab(self):
|
||||
"""Themes tab"""
|
||||
self.themeTabWidget = QWidget()
|
||||
self.themeTabWidget.setProperty(
|
||||
"theme_style_names",
|
||||
("OTHER_PAGES_WIDGET_STYLE", "THEME_TAB_FOCUS_STYLE"),
|
||||
)
|
||||
self.themeTabWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE + self.theme.THEME_TAB_FOCUS_STYLE)
|
||||
self.themeTabWidget.setObjectName("otherPage")
|
||||
mainLayout = QVBoxLayout(self.themeTabWidget)
|
||||
@@ -130,6 +142,7 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
self.themeInfoLayout.addWidget(self.themeMetainfoLabel)
|
||||
|
||||
self.applyButton = AutoSizeButton(_("Apply Theme"), icon=self.theme_manager.get_icon("apply", as_path=True))
|
||||
self.applyButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.applyButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.applyButton.setObjectName("themeApplyButton")
|
||||
self.applyButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -139,6 +152,7 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
_("Delete Theme"),
|
||||
icon=self.theme_manager.get_icon("delete", as_path=True),
|
||||
)
|
||||
self.deleteThemeButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.deleteThemeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.deleteThemeButton.setObjectName("themeDeleteButton")
|
||||
self.deleteThemeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -207,6 +221,50 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
|
||||
# Add widget to stackedWidget
|
||||
self.theme_tab_index = self.stackedWidget.addWidget(self.themeTabWidget)
|
||||
self._setup_theme_file_watcher()
|
||||
|
||||
def _setup_theme_file_watcher(self) -> None:
|
||||
self.themeFileWatcher = QFileSystemWatcher(self)
|
||||
self.themeFileWatcher.fileChanged.connect(self._schedule_theme_reload)
|
||||
self.themeFileWatcher.directoryChanged.connect(self._schedule_theme_reload)
|
||||
self.themeReloadTimer = QTimer(self)
|
||||
self.themeReloadTimer.setSingleShot(True)
|
||||
self.themeReloadTimer.setInterval(250)
|
||||
self.themeReloadTimer.timeout.connect(self._reload_active_custom_theme)
|
||||
self._watch_active_theme_files()
|
||||
|
||||
def _watch_active_theme_files(self) -> None:
|
||||
watcher = self.themeFileWatcher
|
||||
watched_paths = [*watcher.files(), *watcher.directories()]
|
||||
if watched_paths:
|
||||
watcher.removePaths(watched_paths)
|
||||
theme_name = self.current_theme_name
|
||||
if not self.theme_manager.is_custom_theme(theme_name):
|
||||
return
|
||||
theme_folder = _find_theme_folder(theme_name)
|
||||
if theme_folder is None:
|
||||
return
|
||||
paths = [theme_folder]
|
||||
for root, dirs, files in os.walk(theme_folder):
|
||||
dirs[:] = [name for name in dirs if name != "__pycache__"]
|
||||
paths.extend(os.path.join(root, name) for name in dirs)
|
||||
paths.extend(os.path.join(root, name) for name in files)
|
||||
watcher.addPaths(paths)
|
||||
|
||||
def _schedule_theme_reload(self, _path: str) -> None:
|
||||
self._watch_active_theme_files()
|
||||
self.themeReloadTimer.start()
|
||||
|
||||
def _reload_active_custom_theme(self) -> None:
|
||||
theme_name = self.current_theme_name
|
||||
if not self.theme_manager.is_custom_theme(theme_name):
|
||||
return
|
||||
self.theme_manager.invalidate_theme(theme_name)
|
||||
try:
|
||||
self._apply_theme_live(theme_name)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to reload theme '%s': %s", theme_name, e)
|
||||
self._watch_active_theme_files()
|
||||
|
||||
def _get_selected_custom_themes(self) -> list[str]:
|
||||
base_theme = self.themesCombo.currentText()
|
||||
@@ -280,13 +338,308 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
self.themesCombo.blockSignals(False)
|
||||
|
||||
def _apply_theme_and_restart(self, theme_name: str, variant: str) -> None:
|
||||
base_theme = ui_config.get_theme_bases([theme_name])[0]
|
||||
ui_config.set_theme(theme_name)
|
||||
ui_config.set_theme_variant(variant)
|
||||
self._apply_theme_live(theme_name)
|
||||
if variant == "auto":
|
||||
ui_config.set_theme(base_theme)
|
||||
ui_config.set_theme_variant(variant)
|
||||
|
||||
def _apply_theme_live(self, theme_name: str) -> None:
|
||||
old_theme = self.theme
|
||||
old_qicons, old_icon_paths = self.theme_manager.get_cached_icon_names(
|
||||
self.current_theme_name
|
||||
)
|
||||
theme_module = self.theme_manager.apply_theme(theme_name)
|
||||
if not theme_module:
|
||||
return
|
||||
ui_config.set_theme(theme_name)
|
||||
ui_config.set_theme_variant(variant)
|
||||
self._save_theme_tab_state()
|
||||
QTimer.singleShot(500, lambda: self.restart_application())
|
||||
widgets = [self, *self.findChildren(QWidget)]
|
||||
widget_styles = {widget.styleSheet() for widget in widgets}
|
||||
combined_styles = "".join(widget_styles)
|
||||
style_icon_paths = {
|
||||
path: icon_name
|
||||
for path, icon_name in old_icon_paths.items()
|
||||
if path in combined_styles
|
||||
}
|
||||
replacement_cache = getattr(self, "_theme_replacement_cache", {})
|
||||
replacement_key = (id(old_theme), id(theme_module))
|
||||
replacements = replacement_cache.get(replacement_key)
|
||||
if replacements is None:
|
||||
replacements = self._theme_style_replacements(old_theme, theme_module)
|
||||
replacement_cache[replacement_key] = replacements
|
||||
self._theme_replacement_cache = replacement_cache
|
||||
replacements = list(replacements)
|
||||
icon_replacements = self._theme_icon_path_replacements(
|
||||
theme_name, style_icon_paths
|
||||
)
|
||||
replacements.extend(icon_replacements)
|
||||
replace_style = self._theme_style_replacer(replacements, widget_styles)
|
||||
self.theme = theme_module
|
||||
self.current_theme_name = theme_name
|
||||
self._theme_change_in_progress = True
|
||||
self.setUpdatesEnabled(False)
|
||||
try:
|
||||
style_cache: dict[str, str] = {}
|
||||
style_updates = 0
|
||||
refresh_time = 0.0
|
||||
stylesheet_time = 0.0
|
||||
slow_updates: list[tuple[float, str]] = []
|
||||
deferred_updates: list[
|
||||
tuple[QWidget, str, Callable[[object], object] | None]
|
||||
] = []
|
||||
for widget in widgets:
|
||||
if hasattr(widget, "theme"):
|
||||
widget.theme = theme_module
|
||||
if hasattr(widget, "current_theme_name"):
|
||||
widget.current_theme_name = theme_name
|
||||
refresh_theme = getattr(widget, "refresh_theme", None)
|
||||
visible = widget is self or widget.isVisible()
|
||||
if callable(refresh_theme) and visible:
|
||||
refresh_started = time.perf_counter()
|
||||
refresh_theme(theme_module)
|
||||
refresh_time += time.perf_counter() - refresh_started
|
||||
style = widget.styleSheet()
|
||||
named_style = self._get_named_theme_style(widget, theme_module)
|
||||
if isinstance(named_style, str):
|
||||
new_style = named_style
|
||||
elif style in style_cache:
|
||||
new_style = style_cache[style]
|
||||
else:
|
||||
new_style = replace_style(style)
|
||||
style_cache[style] = new_style
|
||||
if not visible:
|
||||
deferred_updates.append((widget, new_style, refresh_theme))
|
||||
elif new_style != style:
|
||||
stylesheet_started = time.perf_counter()
|
||||
widget.setStyleSheet(new_style)
|
||||
update_time = time.perf_counter() - stylesheet_started
|
||||
stylesheet_time += update_time
|
||||
if update_time >= 0.005:
|
||||
widget_name = widget.objectName() or type(widget).__name__
|
||||
slow_updates.append((update_time, widget_name))
|
||||
style_updates += 1
|
||||
self._update_theme_components(theme_module, theme_name)
|
||||
if old_qicons or old_icon_paths:
|
||||
self._refresh_theme_icons(theme_name, old_qicons, old_icon_paths)
|
||||
self._refresh_theme_library_layout(old_theme, theme_module)
|
||||
self._refresh_open_detail_page()
|
||||
finally:
|
||||
self.setUpdatesEnabled(True)
|
||||
self.update()
|
||||
self._theme_change_in_progress = False
|
||||
generation = getattr(self, "_theme_update_generation", 0) + 1
|
||||
self._theme_update_generation = generation
|
||||
if deferred_updates:
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: self._apply_deferred_theme_updates(
|
||||
deferred_updates, theme_module, generation
|
||||
),
|
||||
)
|
||||
self.updateControlHints("force")
|
||||
if hasattr(self, "themeFileWatcher"):
|
||||
self._watch_active_theme_files()
|
||||
|
||||
def _apply_deferred_theme_updates(
|
||||
self,
|
||||
updates: list[tuple[QWidget, str, Callable[[object], object] | None]],
|
||||
theme: object,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != getattr(self, "_theme_update_generation", 0):
|
||||
return
|
||||
batch = updates[:THEME_STYLE_BATCH_SIZE]
|
||||
del updates[:THEME_STYLE_BATCH_SIZE]
|
||||
for widget, new_style, refresh_theme in batch:
|
||||
if not isValid(widget):
|
||||
continue
|
||||
if callable(refresh_theme):
|
||||
refresh_theme(theme)
|
||||
if new_style != widget.styleSheet():
|
||||
widget.setStyleSheet(new_style)
|
||||
if updates:
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: self._apply_deferred_theme_updates(
|
||||
updates, theme, generation
|
||||
),
|
||||
)
|
||||
|
||||
def _get_named_theme_style(self, widget: QWidget, theme: object) -> str | None:
|
||||
style_names = widget.property("theme_style_names")
|
||||
if not isinstance(style_names, (list, tuple)):
|
||||
style_name = widget.property("theme_style_name")
|
||||
style_names = (style_name,) if isinstance(style_name, str) else ()
|
||||
styles = [getattr(theme, name, None) for name in style_names]
|
||||
if not styles or not all(isinstance(style, str) for style in styles):
|
||||
return None
|
||||
return "".join(style for style in styles if isinstance(style, str))
|
||||
|
||||
def _refresh_theme_library_layout(
|
||||
self, old_theme: object, new_theme: object
|
||||
) -> None:
|
||||
if ui_config.get_library_layout_mode("theme") != "theme":
|
||||
return
|
||||
old_mode = getattr(old_theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
new_mode = getattr(new_theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
if old_mode == new_mode:
|
||||
return
|
||||
refresh_autoinstall = getattr(self, "refresh_autoinstall_layout", None)
|
||||
if callable(refresh_autoinstall):
|
||||
refresh_autoinstall()
|
||||
manager = getattr(self, "game_library_manager", None)
|
||||
layout = getattr(manager, "gamesListLayout", None)
|
||||
if manager is None or layout is None:
|
||||
return
|
||||
manager.rebuild_library_layout(str(new_mode).lower())
|
||||
|
||||
def _refresh_open_detail_page(self) -> None:
|
||||
manager = getattr(self, "detail_page_manager", None)
|
||||
if manager is None or not manager._can_rebuild_after_resize():
|
||||
return
|
||||
manager._reopen_current_detail_page()
|
||||
|
||||
def _theme_style_replacer(
|
||||
self, replacements: list[tuple[str, str]], styles: set[str] | None = None
|
||||
) -> Callable[[str], str]:
|
||||
replacement_map = {old: new for old, new in replacements if old}
|
||||
if not replacement_map:
|
||||
return lambda style: style
|
||||
partial_values = [
|
||||
old_value
|
||||
for old_value in replacement_map
|
||||
if styles is None
|
||||
or any(old_value in style and old_value != style for style in styles)
|
||||
]
|
||||
if not partial_values:
|
||||
return lambda style: replacement_map.get(style, style)
|
||||
partial_values.sort(key=len, reverse=True)
|
||||
pattern = re.compile("|".join(re.escape(value) for value in partial_values))
|
||||
|
||||
def replace_style(style: str) -> str:
|
||||
if not style:
|
||||
return style
|
||||
exact_style = replacement_map.get(style)
|
||||
if exact_style is not None:
|
||||
return exact_style
|
||||
return pattern.sub(lambda match: replacement_map[match.group(0)], style)
|
||||
|
||||
return replace_style
|
||||
|
||||
def _theme_icon_path_replacements(
|
||||
self, theme_name: str, old_paths: dict[str, str]
|
||||
) -> list[tuple[str, str]]:
|
||||
replacements = []
|
||||
for old_path, icon_name in old_paths.items():
|
||||
new_path = self.theme_manager.get_icon(icon_name, theme_name, as_path=True)
|
||||
if isinstance(new_path, str) and old_path != new_path:
|
||||
replacements.append((old_path, new_path))
|
||||
return replacements
|
||||
|
||||
def _update_theme_components(self, theme_module: object, theme_name: str) -> None:
|
||||
for component in vars(self).values():
|
||||
if hasattr(component, "theme"):
|
||||
component.theme = theme_module
|
||||
if hasattr(component, "current_theme_name"):
|
||||
component.current_theme_name = theme_name
|
||||
control_hints = getattr(self, "controlHintsWidget", None)
|
||||
hint_bar_style = getattr(theme_module, "HINT_BAR_STYLE", "")
|
||||
if isinstance(control_hints, QWidget):
|
||||
control_hints.setStyleSheet(hint_bar_style)
|
||||
hints_label_style = getattr(theme_module, "HINTS_LABEL_STYLE", "")
|
||||
for label in getattr(self, "hintTextLabels", ()):
|
||||
if isinstance(label, QWidget):
|
||||
label.setStyleSheet(hints_label_style)
|
||||
tray_manager = getattr(self, "tray_manager", None)
|
||||
if tray_manager is not None:
|
||||
tray_icon = self.theme_manager.get_icon("tray_portproton", theme_name)
|
||||
tray_manager.tray_icon.setIcon(tray_icon)
|
||||
|
||||
def _refresh_theme_icons(
|
||||
self, theme_name: str, qicons: dict[int, str], paths: dict[str, str]
|
||||
) -> None:
|
||||
for widget in self.findChildren(QWidget):
|
||||
control_hint_path = getattr(widget, "_icon_path", None)
|
||||
if isinstance(control_hint_path, str) and control_hint_path:
|
||||
icon_name = paths.get(control_hint_path)
|
||||
if icon_name:
|
||||
new_path = self.theme_manager.get_icon(
|
||||
icon_name, theme_name, as_path=True
|
||||
)
|
||||
set_paths = getattr(widget, "set_icon_paths", None)
|
||||
if isinstance(new_path, str) and callable(set_paths):
|
||||
set_paths((new_path,))
|
||||
icon_path = getattr(widget, "_icon", None)
|
||||
icon_name = paths.get(icon_path) if isinstance(icon_path, str) else None
|
||||
icon_setter = getattr(widget, "setIcon", None)
|
||||
if icon_name and callable(icon_setter):
|
||||
icon_setter(self.theme_manager.get_icon(icon_name, theme_name, as_path=True))
|
||||
if isinstance(widget, QAbstractButton):
|
||||
icon_name = qicons.get(widget.icon().cacheKey())
|
||||
if icon_name:
|
||||
widget.setIcon(self.theme_manager.get_icon(icon_name, theme_name))
|
||||
for action in self.findChildren(QAction):
|
||||
icon_name = qicons.get(action.icon().cacheKey())
|
||||
if icon_name:
|
||||
action.setIcon(self.theme_manager.get_icon(icon_name, theme_name))
|
||||
|
||||
def _theme_style_replacements(self, old_theme: object, new_theme: object) -> list[tuple[str, str]]:
|
||||
names = self._theme_attribute_names(old_theme) | self._theme_attribute_names(new_theme)
|
||||
candidates: dict[str, set[str]] = {}
|
||||
for name in sorted(names):
|
||||
if not name.endswith("_STYLE"):
|
||||
continue
|
||||
old_value = getattr(old_theme, name, None)
|
||||
new_value = getattr(new_theme, name, None)
|
||||
if isinstance(old_value, str) and isinstance(new_value, str) and old_value != new_value:
|
||||
candidates.setdefault(old_value, set()).add(new_value)
|
||||
replacements = [
|
||||
(old_value, next(iter(new_values)))
|
||||
for old_value, new_values in candidates.items()
|
||||
if len(new_values) == 1
|
||||
]
|
||||
return sorted(replacements, key=lambda pair: len(pair[0]), reverse=True)
|
||||
|
||||
def _theme_attribute_names(self, theme: object) -> set[str]:
|
||||
names = {name for name in vars(theme) if name.isupper()}
|
||||
custom_theme = getattr(theme, "custom_theme", None)
|
||||
if custom_theme is not None:
|
||||
names.update(name for name in vars(custom_theme) if name.isupper())
|
||||
generated = getattr(theme, "_generated_styles", None)
|
||||
if generated:
|
||||
names.update(name for name in generated if name.isupper())
|
||||
parent = getattr(theme, "_default_theme", None)
|
||||
if parent is not None:
|
||||
names.update(self._theme_attribute_names(parent))
|
||||
return names
|
||||
|
||||
def _on_system_theme_detected(self, is_light: bool) -> None:
|
||||
if ui_config.get_theme_variant() != "auto":
|
||||
return
|
||||
self._apply_system_theme_variant("light" if is_light else "dark")
|
||||
|
||||
def _on_qt_color_scheme_changed(self, color_scheme: Qt.ColorScheme) -> None:
|
||||
if getattr(self, "_theme_change_in_progress", False):
|
||||
return
|
||||
if ui_config.get_theme_variant() != "auto":
|
||||
return
|
||||
is_light = color_scheme == Qt.ColorScheme.Light
|
||||
if _is_system_light_theme() != is_light:
|
||||
return
|
||||
watcher = getattr(self, "system_theme_watcher", None)
|
||||
if watcher is not None:
|
||||
watcher._last_light = is_light
|
||||
self._on_system_theme_detected(is_light)
|
||||
|
||||
def _apply_system_theme_variant(self, variant: str) -> None:
|
||||
base_theme = ui_config.get_theme_base()
|
||||
theme_name = ui_config.resolve_theme(base_theme, variant)
|
||||
if theme_name == self.current_theme_name:
|
||||
return
|
||||
self._apply_theme_live(theme_name)
|
||||
ui_config.set_theme(base_theme)
|
||||
ui_config.set_theme_variant("auto")
|
||||
|
||||
def _save_theme_tab_state(self) -> None:
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
|
||||
@@ -24,9 +24,13 @@ from portprotonqt.dialogs.prefix_backup import PrefixBackupDialog, PrefixBackupJ
|
||||
from portprotonqt.dialogs.proton_manager import show_proton_manager
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.portproton_api import get_user_conf_setting, set_user_conf_setting
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
from portprotonqt.scripts_utils.prefix_backup import is_legacy_squashfs_backup
|
||||
from portprotonqt.settings_manager import get_available_prefix_options, get_available_wine_options
|
||||
from portprotonqt.settings_manager import (
|
||||
LG_WINE_ALIASES,
|
||||
get_available_prefix_options,
|
||||
get_available_wine_options,
|
||||
)
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -45,6 +49,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
def createWineTab(self):
|
||||
"""Wine Settings tab."""
|
||||
self.wineWidget = QWidget()
|
||||
self.wineWidget.setProperty("theme_style_name", "OTHER_PAGES_WIDGET_STYLE")
|
||||
self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(self.wineWidget)
|
||||
layout.setContentsMargins(10, 18, 10, 10)
|
||||
@@ -63,9 +68,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
formLayout.setSpacing(self.theme.wineSettingsSetSpacing)
|
||||
formLayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||
|
||||
self.wine_versions = get_available_wine_options(
|
||||
self.portproton_location, include_lg_aliases=True
|
||||
)
|
||||
self.wine_versions = get_available_wine_options(self.portproton_location)
|
||||
self.wineCombo = CustomComboBox(theme=self.theme)
|
||||
self.wineCombo.view().window().setWindowFlags(
|
||||
Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint
|
||||
@@ -83,7 +86,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
if self.wine_versions:
|
||||
self.wineCombo.setCurrentIndex(0)
|
||||
default_wine = get_user_conf_setting('PW_DEFAULT_WINE_USE')
|
||||
if default_wine:
|
||||
if default_wine and default_wine not in dict(LG_WINE_ALIASES):
|
||||
if self.wineCombo.findText(default_wine) == -1:
|
||||
self.wineCombo.addItem(default_wine)
|
||||
self.wineCombo.setCurrentText(default_wine)
|
||||
@@ -157,7 +160,6 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
tools_grid.setSpacing(6)
|
||||
|
||||
tools = [
|
||||
("default", _("Use by default")),
|
||||
("--winecfg", _("Wine Configuration")),
|
||||
("--winereg", _("Registry Editor")),
|
||||
("--winefile", _("File Explorer")),
|
||||
@@ -169,12 +171,10 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
row = i // 3
|
||||
col = i % 3
|
||||
btn = AutoSizeButton(tool_name, update_size=False)
|
||||
btn.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
if tool_cmd == "default":
|
||||
btn.clicked.connect(self.save_wine_defaults)
|
||||
else:
|
||||
btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t))
|
||||
btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t))
|
||||
tools_grid.addWidget(btn, row, col)
|
||||
|
||||
for col in range(3):
|
||||
@@ -199,6 +199,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
row = i // 2
|
||||
col = i % 2
|
||||
btn = AutoSizeButton(text, update_size=False)
|
||||
btn.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
if callback:
|
||||
@@ -225,16 +226,6 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
wine_progress_layout.addWidget(self.wine_progress_bar)
|
||||
layout.addLayout(wine_progress_layout)
|
||||
|
||||
def save_wine_defaults(self) -> None:
|
||||
default_wine = self.wineCombo.currentText().strip()
|
||||
raw_prefix = self.prefixCombo.currentText().strip()
|
||||
default_prefix = re.sub(r"[ \t]", "_", raw_prefix).upper() if raw_prefix else ""
|
||||
default_vulkan = self.defaultVulkanCombo.currentData()
|
||||
|
||||
set_user_conf_setting('PW_DEFAULT_WINE_USE', default_wine)
|
||||
set_user_conf_setting('PW_DEFAULT_PREFIX_NAME', default_prefix)
|
||||
set_user_conf_setting('PW_DEFAULT_VULKAN_USE', str(default_vulkan or ""))
|
||||
|
||||
def launch_generic_tool(self, cli_arg):
|
||||
wine = self.wineCombo.currentText()
|
||||
raw_prefix = self.prefixCombo.currentText().strip()
|
||||
@@ -468,9 +459,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
if not self.portproton_location:
|
||||
return
|
||||
|
||||
self.wine_versions = get_available_wine_options(
|
||||
self.portproton_location, include_lg_aliases=True
|
||||
)
|
||||
self.wine_versions = get_available_wine_options(self.portproton_location)
|
||||
self.wineCombo.clear()
|
||||
self.wineCombo.addItems(self.wine_versions)
|
||||
|
||||
|
||||
@@ -10,32 +10,80 @@ class MainWindowWorkersMixin:
|
||||
"bluetoothWorker": 12000,
|
||||
"storageWorker": 3000,
|
||||
"audioWorker": 3000,
|
||||
"autoInstallLoadThread": 3000,
|
||||
"autoInstallLoadThread": None,
|
||||
"autoInstallScriptLoadThread": None,
|
||||
"autoInstallCustomDataThread": None,
|
||||
"appimageUpdateWorker": 3000,
|
||||
"appImageIntegrationWorker": None,
|
||||
"gogdlUpdateWorker": None,
|
||||
"initialCommandWorker": None,
|
||||
"egs_library_worker": None,
|
||||
"egs_auth_worker": None,
|
||||
"gog_library_worker": None,
|
||||
"gog_metadata_worker": None,
|
||||
"gog_repair_worker": None,
|
||||
"store_dlc_worker": None,
|
||||
"gog_auth_worker": None,
|
||||
"gog_account_worker": None,
|
||||
"themeStoreListWorker": None,
|
||||
"themeStoreImageWorker": None,
|
||||
"themeStoreDetailImageWorker": None,
|
||||
"themeStoreDownloadWorker": None,
|
||||
}
|
||||
for worker_name, timeout_ms in worker_timeouts.items():
|
||||
self._stopWorkerThread(worker_name, timeout_ms)
|
||||
for workers_name in (
|
||||
"gog_support_workers",
|
||||
"_listWorkerPool",
|
||||
"_imageWorkerPool",
|
||||
"_detailImageWorkerPool",
|
||||
):
|
||||
self._stopWorkerThreads(workers_name)
|
||||
downloader = getattr(self, "downloader", None)
|
||||
for worker in list(getattr(downloader, "_active_threads", [])):
|
||||
self._stopWorker(worker, "downloader", None)
|
||||
for worker in getattr(self, "compatibility_workers", []):
|
||||
worker.join()
|
||||
self.compatibility_workers = []
|
||||
input_manager = getattr(self, "input_manager", None)
|
||||
if input_manager is not None:
|
||||
input_manager.cleanup()
|
||||
|
||||
def _stopWorkerThread(self, worker_name: str, timeout_ms: int = 3000) -> None:
|
||||
def _stopWorkerThread(
|
||||
self, worker_name: str, timeout_ms: int | None = 3000
|
||||
) -> None:
|
||||
worker = getattr(self, worker_name, None)
|
||||
if worker is None:
|
||||
return
|
||||
self._stopWorker(worker, worker_name, timeout_ms)
|
||||
setattr(self, worker_name, None)
|
||||
|
||||
def _stopWorkerThreads(self, workers_name: str) -> None:
|
||||
workers = getattr(self, workers_name, []) or []
|
||||
for worker in workers:
|
||||
self._stopWorker(worker, workers_name, None)
|
||||
setattr(self, workers_name, [])
|
||||
|
||||
def _stopWorker(
|
||||
self, worker: object, worker_name: str, timeout_ms: int | None
|
||||
) -> None:
|
||||
request_interruption = getattr(worker, "requestInterruption", None)
|
||||
cancel = getattr(worker, "cancel", None)
|
||||
try:
|
||||
if callable(request_interruption):
|
||||
request_interruption()
|
||||
if callable(cancel):
|
||||
cancel()
|
||||
|
||||
is_running = getattr(worker, "isRunning", None)
|
||||
wait_method = getattr(worker, "wait", None)
|
||||
if callable(is_running) and is_running() and callable(wait_method):
|
||||
wait_method(timeout_ms)
|
||||
if timeout_ms is None:
|
||||
wait_method()
|
||||
else:
|
||||
wait_method(timeout_ms)
|
||||
|
||||
if callable(is_running) and is_running():
|
||||
logger.warning("%s is still running during shutdown", worker_name)
|
||||
except RuntimeError as error:
|
||||
logger.debug("%s already deleted during shutdown: %s", worker_name, error)
|
||||
|
||||
setattr(self, worker_name, None)
|
||||
+172
-31
@@ -1,4 +1,5 @@
|
||||
import ast
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
@@ -6,17 +7,21 @@ import re
|
||||
import shutil
|
||||
import stat
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, cast
|
||||
import orjson
|
||||
from dbus_fast import BusType
|
||||
from dbus_fast.aio import MessageBus
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_security import (
|
||||
check_theme_directory_safety,
|
||||
is_safe_font_file,
|
||||
is_safe_image_file,
|
||||
)
|
||||
from PySide6.QtCore import QRectF, Qt
|
||||
from PySide6.QtCore import QRectF, Qt, QThread, Signal
|
||||
from PySide6.QtGui import QIcon, QFontDatabase, QPainter, QPixmap
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.config import CACHE_DIR, ui_config, load_theme_metainfo
|
||||
from portprotonqt.config.ui import _is_system_light_theme, _unwrap_variant
|
||||
from portprotonqt.qt_utils import get_device_pixel_ratio
|
||||
|
||||
# Icon caching for performance optimization
|
||||
@@ -71,6 +76,10 @@ SVG_KEEP_PAINT_VALUES = {"none", "transparent", "inherit", "initial", "unset", "
|
||||
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
||||
SVG_CACHE_VERSION = 2
|
||||
NON_INHERITED_THEME_CONSTANTS = {"ICON_COLORS"}
|
||||
SYSTEM_THEME_POLL_INTERVAL_MS = 200
|
||||
SYSTEM_THEME_POLL_STEP_MS = 200
|
||||
PORTAL_APPEARANCE_NAMESPACE = "org.freedesktop.appearance"
|
||||
PORTAL_COLOR_SCHEME_KEY = "color-scheme"
|
||||
|
||||
ET.register_namespace("", SVG_NAMESPACE)
|
||||
|
||||
@@ -81,6 +90,7 @@ THEMES_DIRS = [
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
|
||||
]
|
||||
_loaded_theme = None
|
||||
_loaded_font_signature = None
|
||||
|
||||
|
||||
def _read_dms_color_file(colors_file: str) -> bytes | None:
|
||||
@@ -548,20 +558,14 @@ def load_theme_fonts(theme_name):
|
||||
"""
|
||||
Load all fonts from selected theme if not already loaded.
|
||||
"""
|
||||
global _loaded_theme
|
||||
global _loaded_theme, _loaded_font_signature
|
||||
if _loaded_theme == theme_name:
|
||||
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
|
||||
return
|
||||
|
||||
def load_fonts_delayed():
|
||||
global _loaded_theme
|
||||
global _loaded_theme, _loaded_font_signature
|
||||
try:
|
||||
# Only remove fonts if this is a theme change (not initial load)
|
||||
current_loaded_theme = _loaded_theme # Capture the current value
|
||||
if current_loaded_theme is not None and current_loaded_theme != theme_name:
|
||||
# Run font removal in the GUI thread with delay
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
|
||||
import time
|
||||
import os
|
||||
start_time = time.time()
|
||||
@@ -591,7 +595,21 @@ def load_theme_fonts(theme_name):
|
||||
logger.warning("Skipping unsafe font file: %s", font_path)
|
||||
|
||||
# Limit number of fonts loaded to prevent too much blocking
|
||||
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
|
||||
font_files = sorted(font_files)[:10]
|
||||
font_signature_parts = []
|
||||
for filename in font_files:
|
||||
with open(os.path.join(fonts_folder, filename), "rb") as font_file:
|
||||
digest = hashlib.sha256(font_file.read()).hexdigest()
|
||||
font_signature_parts.append((filename, digest))
|
||||
font_signature = tuple(font_signature_parts)
|
||||
if _loaded_font_signature == font_signature:
|
||||
_loaded_theme = theme_name
|
||||
logger.debug("Fonts for theme '%s' already loaded, skipping", theme_name)
|
||||
return
|
||||
|
||||
# Remove fonts only when the selected files changed.
|
||||
if _loaded_theme is not None:
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
|
||||
for filename in font_files:
|
||||
if time.time() - start_time > timeout:
|
||||
@@ -608,6 +626,7 @@ def load_theme_fonts(theme_name):
|
||||
|
||||
# Update the global variable in the main thread
|
||||
_loaded_theme = theme_name
|
||||
_loaded_font_signature = font_signature
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
|
||||
|
||||
@@ -624,16 +643,26 @@ class ThemeWrapper:
|
||||
def __init__(self, custom_theme, metainfo=None, inherit_chain=None):
|
||||
self.custom_theme = custom_theme
|
||||
self.metainfo = metainfo or {}
|
||||
self.screenshots = load_theme_screenshots(
|
||||
self.metainfo.get("name")
|
||||
or custom_theme.__name__.split(".")[-1]
|
||||
)
|
||||
self._theme_name = custom_theme.__name__.split(".")[-1]
|
||||
self._screenshots = None
|
||||
parent_name = getattr(custom_theme, "THEME_INHERITS", "standart")
|
||||
self.parent_theme_name = _get_parent_theme_name(custom_theme.__name__.split(".")[-1], parent_name)
|
||||
self._inherit_chain = inherit_chain or []
|
||||
self._default_theme = None # Lazy-loaded default theme
|
||||
self._generated_styles = None # Lazy-generated standard styles with custom constants
|
||||
|
||||
def get_theme_override(self, name: str) -> Any | None:
|
||||
"""Return a value declared by the custom theme."""
|
||||
declared = getattr(self.custom_theme, "_declared_theme_names", set())
|
||||
return getattr(self.custom_theme, name, None) if name in declared else None
|
||||
|
||||
@property
|
||||
def screenshots(self) -> list[tuple[QPixmap, str]]:
|
||||
"""Load theme preview screenshots on first access."""
|
||||
if self._screenshots is None:
|
||||
self._screenshots = load_theme_screenshots(self._theme_name)
|
||||
return self._screenshots
|
||||
|
||||
def __getattr__(self, name):
|
||||
if hasattr(self.custom_theme, name):
|
||||
return getattr(self.custom_theme, name)
|
||||
@@ -818,6 +847,19 @@ def load_theme(theme_name, inherit_chain=None):
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
custom_theme = importlib.util.module_from_spec(spec)
|
||||
try:
|
||||
with open(styles_file, encoding="utf-8") as source_file:
|
||||
theme_tree = ast.parse(source_file.read(), filename=styles_file)
|
||||
declared_theme_names = {
|
||||
target.id
|
||||
for node in theme_tree.body
|
||||
if isinstance(node, ast.Assign)
|
||||
for target in node.targets
|
||||
if isinstance(target, ast.Name)
|
||||
}
|
||||
custom_theme.__dict__["_declared_theme_names"] = declared_theme_names
|
||||
except (OSError, SyntaxError):
|
||||
custom_theme.__dict__["_declared_theme_names"] = set()
|
||||
|
||||
# Temporarily add the theme directory to sys.path to support relative imports
|
||||
theme_dir = os.path.dirname(styles_file)
|
||||
@@ -887,23 +929,94 @@ def load_theme(theme_name, inherit_chain=None):
|
||||
if theme_name == "standart":
|
||||
return custom_theme
|
||||
meta = load_theme_metainfo(theme_name)
|
||||
wrapper = ThemeWrapper(custom_theme, metainfo=meta, inherit_chain=next_inherit_chain)
|
||||
wrapper.screenshots = load_theme_screenshots(theme_name)
|
||||
return wrapper
|
||||
return ThemeWrapper(custom_theme, metainfo=meta, inherit_chain=next_inherit_chain)
|
||||
raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
|
||||
|
||||
class SystemThemeWatcher(QThread):
|
||||
"""Watch desktop color scheme without blocking the UI thread."""
|
||||
|
||||
theme_changed = Signal(bool)
|
||||
|
||||
def __init__(self, initial_light: bool, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._last_light = initial_light
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
asyncio.run(self._watch_portal())
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug("Portal theme watcher unavailable: %s", e)
|
||||
self._poll_system_theme()
|
||||
|
||||
async def _watch_portal(self) -> None:
|
||||
bus = await MessageBus(bus_type=BusType.SESSION).connect()
|
||||
try:
|
||||
introspection = await bus.introspect(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
)
|
||||
proxy = bus.get_proxy_object(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
introspection,
|
||||
)
|
||||
interface = cast(Any, proxy.get_interface("org.freedesktop.portal.Settings"))
|
||||
value = await interface.call_read(
|
||||
PORTAL_APPEARANCE_NAMESPACE,
|
||||
PORTAL_COLOR_SCHEME_KEY,
|
||||
)
|
||||
if int(_unwrap_variant(value)) == 0:
|
||||
raise RuntimeError("Portal does not provide a color scheme")
|
||||
self._emit_portal_theme(value)
|
||||
interface.on_setting_changed(self._on_portal_setting_changed)
|
||||
while not self.isInterruptionRequested():
|
||||
await asyncio.sleep(SYSTEM_THEME_POLL_STEP_MS / 1000)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
|
||||
def _on_portal_setting_changed(self, namespace: str, key: str, value: object) -> None:
|
||||
if namespace == PORTAL_APPEARANCE_NAMESPACE and key == PORTAL_COLOR_SCHEME_KEY:
|
||||
self._emit_portal_theme(value)
|
||||
|
||||
def _emit_portal_theme(self, value: object) -> None:
|
||||
color_scheme = int(_unwrap_variant(value))
|
||||
if color_scheme not in (1, 2):
|
||||
return
|
||||
is_light = color_scheme == 2
|
||||
if is_light != self._last_light:
|
||||
self._last_light = is_light
|
||||
self.theme_changed.emit(is_light)
|
||||
|
||||
def _poll_system_theme(self) -> None:
|
||||
elapsed_ms = SYSTEM_THEME_POLL_INTERVAL_MS
|
||||
while not self.isInterruptionRequested():
|
||||
if elapsed_ms >= SYSTEM_THEME_POLL_INTERVAL_MS:
|
||||
is_light = _is_system_light_theme()
|
||||
if is_light != self._last_light:
|
||||
self._last_light = is_light
|
||||
self.theme_changed.emit(is_light)
|
||||
elapsed_ms = 0
|
||||
self.msleep(SYSTEM_THEME_POLL_STEP_MS)
|
||||
elapsed_ms += SYSTEM_THEME_POLL_STEP_MS
|
||||
|
||||
|
||||
class ThemeManager:
|
||||
"""
|
||||
Class for managing application themes.
|
||||
Implement Singleton pattern for single instance.
|
||||
"""
|
||||
_instance = None
|
||||
current_theme_name: str | None
|
||||
current_theme_module: Any | None
|
||||
_theme_module_cache: dict[str, Any]
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.current_theme_name = None
|
||||
cls._instance.current_theme_module = None
|
||||
cls._instance._theme_module_cache = {}
|
||||
return cls._instance
|
||||
|
||||
def get_available_themes(self) -> list:
|
||||
@@ -927,8 +1040,37 @@ class ThemeManager:
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove custom theme '%s': %s", theme_name, e)
|
||||
return False
|
||||
self.invalidate_theme(theme_name)
|
||||
return True
|
||||
|
||||
def invalidate_theme(self, theme_name: str) -> None:
|
||||
"""Discard a cached theme after its files change."""
|
||||
self._theme_module_cache.pop(theme_name, None)
|
||||
_icon_dirs_cache.pop(theme_name, None)
|
||||
marker = f"_{theme_name}_"
|
||||
for cache_key in list(_icon_cache):
|
||||
if marker in cache_key:
|
||||
_icon_cache.pop(cache_key, None)
|
||||
if self.current_theme_name == theme_name:
|
||||
self.current_theme_name = None
|
||||
self.current_theme_module = None
|
||||
|
||||
def get_cached_icon_names(self, theme_name: str) -> tuple[dict[int, str], dict[str, str]]:
|
||||
"""Return cached icon identifiers before a live theme change."""
|
||||
marker = f"_{theme_name}_"
|
||||
qicons = {}
|
||||
paths = {}
|
||||
for cache_key, icon in _icon_cache.items():
|
||||
marker_index = cache_key.rfind(marker)
|
||||
if marker_index < 0:
|
||||
continue
|
||||
icon_name = cache_key[:marker_index]
|
||||
if isinstance(icon, QIcon) and not icon.isNull():
|
||||
qicons[icon.cacheKey()] = icon_name
|
||||
elif isinstance(icon, str):
|
||||
paths[icon] = icon_name
|
||||
return qicons, paths
|
||||
|
||||
def apply_theme(self, theme_name: str):
|
||||
"""
|
||||
Apply selected theme if not already applied.
|
||||
@@ -937,22 +1079,21 @@ class ThemeManager:
|
||||
if self.current_theme_name == theme_name and self.current_theme_module is not None:
|
||||
return self.current_theme_module
|
||||
|
||||
try:
|
||||
theme_module = load_theme(theme_name)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
|
||||
theme_module = load_theme("standart")
|
||||
theme_name = "standart"
|
||||
ui_config.set_theme("standart")
|
||||
theme_module = self._theme_module_cache.get(theme_name)
|
||||
if theme_module is None:
|
||||
try:
|
||||
theme_module = load_theme(theme_name)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
|
||||
theme_name = "standart"
|
||||
theme_module = self._theme_module_cache.get(theme_name)
|
||||
if theme_module is None:
|
||||
theme_module = load_theme(theme_name)
|
||||
ui_config.set_theme(theme_name)
|
||||
self._theme_module_cache[theme_name] = theme_module
|
||||
|
||||
load_theme_fonts(theme_name)
|
||||
|
||||
# Clear icon cache when theme changes to rebuild it for the new theme
|
||||
if self.current_theme_name != theme_name:
|
||||
global _icon_dirs_cache
|
||||
if theme_name in _icon_dirs_cache:
|
||||
del _icon_dirs_cache[theme_name]
|
||||
|
||||
self.current_theme_name = theme_name
|
||||
self.current_theme_module = theme_module
|
||||
ui_config.set_theme(theme_name)
|
||||
@@ -960,7 +1101,7 @@ class ThemeManager:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().set_sounds_dirs(_resolve_sound_dirs(theme_name))
|
||||
|
||||
logger.info(f"Theme '{theme_name}' successfully applied")
|
||||
logger.info("Theme '%s' successfully applied", theme_name)
|
||||
return theme_module
|
||||
|
||||
def _get_theme_icon_colors(self, theme: object) -> dict:
|
||||
|
||||
@@ -157,6 +157,7 @@ class ThemeSecurityChecker:
|
||||
return False, self.errors
|
||||
|
||||
self._check_top_level(tree, theme_file, allow_absolute_imports)
|
||||
self._check_disabled_card_shadow(tree, theme_file)
|
||||
self._check_forbidden_patterns(tree, theme_file)
|
||||
|
||||
except Exception as e:
|
||||
@@ -239,6 +240,34 @@ class ThemeSecurityChecker:
|
||||
f"Top-level {type(node).__name__} is forbidden in file {theme_file}"
|
||||
)
|
||||
|
||||
def _check_disabled_card_shadow(
|
||||
self, tree: ast.Module, theme_file: str,
|
||||
) -> None:
|
||||
"""Reject the card shadow combination that breaks Qt painting."""
|
||||
values = {}
|
||||
for node in tree.body:
|
||||
if not isinstance(node, ast.Assign) or len(node.targets) != 1:
|
||||
continue
|
||||
target = node.targets[0]
|
||||
if not isinstance(target, ast.Name):
|
||||
continue
|
||||
if target.id not in {
|
||||
"color_shadow_card", "shadow_blur_radius", "shadow_offset",
|
||||
}:
|
||||
continue
|
||||
try:
|
||||
values[target.id] = ast.literal_eval(node.value)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if values == {
|
||||
"color_shadow_card": "#00000000",
|
||||
"shadow_blur_radius": 0,
|
||||
"shadow_offset": (0, 0),
|
||||
}:
|
||||
self._add_error(
|
||||
f"Disabled card shadow breaks Qt painting in file {theme_file}"
|
||||
)
|
||||
|
||||
def _check_import(
|
||||
self, node: ast.Import | ast.ImportFrom, theme_file: str,
|
||||
allow_absolute_imports: bool,
|
||||
|
||||
@@ -1487,6 +1487,7 @@ TAB_STYLE = f"""
|
||||
# HINT BAR STYLE
|
||||
HINT_BAR_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_transparent};
|
||||
max-height: 82px;
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -70,6 +70,25 @@ DOWNLOADS_SECTION_STYLE = f"""
|
||||
}}
|
||||
"""
|
||||
|
||||
STORE_DLC_LIST_STYLE = f"""
|
||||
QWidget#storeDlcList {{
|
||||
background: {color_surface_elevated};
|
||||
border: {border_none};
|
||||
border-radius: {border_radius_small};
|
||||
}}
|
||||
"""
|
||||
|
||||
STORE_DLC_ROW_STYLE = f"""
|
||||
QWidget#storeDlcRow {{
|
||||
background: transparent;
|
||||
border: {border_none};
|
||||
}}
|
||||
QWidget#storeDlcRow QLabel, QWidget#storeDlcRow QCheckBox {{
|
||||
background: transparent;
|
||||
color: {color_text};
|
||||
}}
|
||||
"""
|
||||
|
||||
DOWNLOADS_TABLE_STYLE = f"""
|
||||
QTableWidget {{
|
||||
background: {color_surface_elevated};
|
||||
@@ -107,6 +126,7 @@ STATUS_BAR_STYLE = f"""
|
||||
# HINT BAR STYLE
|
||||
HINT_BAR_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_transparent};
|
||||
max-height: 82px;
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -22,10 +22,57 @@ wineSettingsSetSpacing = 10
|
||||
themeStorePageSpacing = 10
|
||||
themeStoreCardDefaultWidth = 280
|
||||
themeStoreDetailCarouselMinHeight = 300
|
||||
horizontalTopGameLimit = 10
|
||||
fullLibraryTileGameCount = 4
|
||||
fullLibraryTileSize = (180, 180)
|
||||
fullLibraryTileColumns = 2
|
||||
fullLibraryTileRows = 2
|
||||
fullLibraryTileRadius = 10
|
||||
GAME_CARD_LIST = {
|
||||
"extra_margin": 8,
|
||||
"spacing": 12,
|
||||
"cover_load_size": 64,
|
||||
"cover_size": 56,
|
||||
"min_cover_size": 48,
|
||||
"cover_left_margin": 10,
|
||||
"row_height": 72,
|
||||
"min_row_height": 68,
|
||||
"cover_radius": 8,
|
||||
"border_radius": 18,
|
||||
}
|
||||
GAME_CARD_GRID = {
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
"card_height_ratio": 1.8,
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 0.56,
|
||||
"card_height_ratio": 0.78,
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
"layout_margins": (20, 20, 20, 20),
|
||||
"layout_spacing": 20,
|
||||
}
|
||||
GAME_CARD_VERTICAL = {
|
||||
**GAME_CARD_LIST,
|
||||
"layout_margins": (0, 0, 0, 0),
|
||||
"layout_spacing": 0,
|
||||
"header_height": 40,
|
||||
"header_margins": (4, 0, 4, 0),
|
||||
"header_spacing": 12,
|
||||
"header_cover_width": 66,
|
||||
"column_stretches": (4, 2, 2, 2),
|
||||
}
|
||||
downloadsSummaryHeight = 96
|
||||
downloadsTableHeaderHeight = 38
|
||||
downloadsTableRowHeight = 68
|
||||
downloadsCoverSize = (72, 48)
|
||||
downloadsCoverRadius = 6
|
||||
downloadsSectionSpacing = 14
|
||||
downloadsCellMargins = (8, 6, 8, 6)
|
||||
downloadsCellSpacing = 10
|
||||
@@ -33,6 +80,10 @@ downloadsActiveCardHeight = 190
|
||||
downloadsActiveCoverSize = (180, 120)
|
||||
downloadsActiveCardMargins = (16, 14, 16, 14)
|
||||
downloadsActiveCardSpacing = 14
|
||||
storeDlcDialogWidth = 480
|
||||
storeDlcVisibleRows = 3
|
||||
storeDlcCoverSize = (56, 36)
|
||||
storeDlcRowHeight = 52
|
||||
mangoHudSwitchesColumns = 4
|
||||
mangoHudSwitchesVerticalSpacing = 10
|
||||
mangoHudFpsColumns = 4
|
||||
|
||||
@@ -9,6 +9,10 @@ GAME_CARD_WINDOW_STYLE = f"""
|
||||
}}
|
||||
"""
|
||||
|
||||
FULL_LIBRARY_TILE_STYLE = GAME_CARD_WINDOW_STYLE
|
||||
|
||||
GAME_CARD_VERTICAL_STYLE = GAME_CARD_WINDOW_STYLE
|
||||
|
||||
# GAME NAME LABEL IN CARD (QLabel)
|
||||
GAME_CARD_NAME_LABEL_STYLE = f"""
|
||||
QLabel {{
|
||||
@@ -24,6 +28,34 @@ 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_muted};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_value};
|
||||
background: {color_transparent};
|
||||
}}
|
||||
"""
|
||||
|
||||
LIBRARY_HEADER_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_surface_elevated};
|
||||
border-radius: {border_radius_small};
|
||||
}}
|
||||
"""
|
||||
|
||||
LIBRARY_HEADER_LABEL_STYLE = f"""
|
||||
QLabel {{
|
||||
color: {color_text_muted};
|
||||
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()
|
||||
|
||||
@@ -13,6 +13,7 @@ logger = get_logger(__name__)
|
||||
_migrated = False
|
||||
|
||||
|
||||
# Remove after the legacy statistics migration period.
|
||||
def _migrate_last_launch_file(data_home: str) -> str:
|
||||
"""Migrate last_launch from old cache dir to XDG_DATA_HOME."""
|
||||
global _migrated
|
||||
@@ -43,6 +44,7 @@ def get_last_launch_path():
|
||||
_stats_migrated = False
|
||||
|
||||
|
||||
# Remove after the legacy statistics migration period.
|
||||
def _migrate_statistics_file(data_home: str) -> str:
|
||||
"""Migrate statistics from old cache/tmp dir to XDG_DATA_HOME."""
|
||||
global _stats_migrated
|
||||
|
||||
@@ -73,11 +73,17 @@ class TrayManager:
|
||||
self.tray_icon.setToolTip(self.app_name)
|
||||
|
||||
self.tray_menu = QMenu()
|
||||
self.minimal_mode = False
|
||||
|
||||
self.stop_game_action = QAction(_("Stop Game"), self.main_window)
|
||||
self.stop_game_action.setEnabled(False)
|
||||
self.stop_game_action.triggered.connect(self.stop_game)
|
||||
|
||||
self.pause_game_action = QAction(_("Pause Game"), self.main_window)
|
||||
self.pause_game_action.setEnabled(False)
|
||||
self.pause_game_action.triggered.connect(self.toggle_game_pause)
|
||||
|
||||
self.tray_menu.addAction(self.pause_game_action)
|
||||
self.tray_menu.addAction(self.stop_game_action)
|
||||
self.tray_menu.addSeparator()
|
||||
self.tray_menu.addSeparator()
|
||||
@@ -95,9 +101,12 @@ class TrayManager:
|
||||
def refresh_tray_menu(self):
|
||||
self.tray_menu.clear()
|
||||
|
||||
self.tray_menu.addAction(self.pause_game_action)
|
||||
self.tray_menu.addAction(self.stop_game_action)
|
||||
self.update_game_actions()
|
||||
if self.minimal_mode:
|
||||
return
|
||||
self.tray_menu.addSeparator()
|
||||
self.update_stop_game_action()
|
||||
|
||||
if display_config.get_tray_menu_mode() == "compact":
|
||||
self._populate_compact_menu()
|
||||
@@ -109,6 +118,11 @@ class TrayManager:
|
||||
exit_action.triggered.connect(self.force_exit)
|
||||
self.tray_menu.addAction(exit_action)
|
||||
|
||||
def set_minimal_mode(self) -> None:
|
||||
"""Show only the stop action in the tray menu."""
|
||||
self.minimal_mode = True
|
||||
self.refresh_tray_menu()
|
||||
|
||||
def _populate_detailed_menu(self) -> None:
|
||||
self._populate_tabs_flat()
|
||||
self.tray_menu.addSeparator()
|
||||
@@ -131,11 +145,71 @@ class TrayManager:
|
||||
self._populate_themes_menu(themes_menu)
|
||||
self.tray_menu.addMenu(themes_menu)
|
||||
|
||||
def update_stop_game_action(self) -> None:
|
||||
def update_game_actions(self) -> None:
|
||||
processes = self._game_process_tree()
|
||||
game_processes = getattr(self.main_window, "game_processes", [])
|
||||
target_exe = getattr(self.main_window, "target_exe", None)
|
||||
has_running_game = bool(game_processes or target_exe)
|
||||
self.stop_game_action.setEnabled(has_running_game)
|
||||
self.pause_game_action.setEnabled(bool(processes))
|
||||
paused = self._are_processes_paused(processes)
|
||||
self.pause_game_action.setText(_("Resume Game") if paused else _("Pause Game"))
|
||||
|
||||
def _are_processes_paused(self, processes: list[psutil.Process]) -> bool:
|
||||
if not processes:
|
||||
return False
|
||||
try:
|
||||
return all(
|
||||
process.status() == psutil.STATUS_STOPPED for process in processes
|
||||
)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
return False
|
||||
|
||||
def _game_process_tree(self) -> list[psutil.Process]:
|
||||
processes = {}
|
||||
for launcher in getattr(self.main_window, "game_processes", []):
|
||||
try:
|
||||
parent = psutil.Process(launcher.pid)
|
||||
for process in parent.children(recursive=True):
|
||||
processes[process.pid] = process
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
|
||||
target = str(getattr(self.main_window, "target_exe", "") or "").lower()
|
||||
if not target:
|
||||
return list(processes.values())
|
||||
for process in psutil.process_iter(attrs=["name"]):
|
||||
try:
|
||||
if str(process.info.get("name") or "").lower() != target:
|
||||
continue
|
||||
processes[process.pid] = process
|
||||
for child in process.children(recursive=True):
|
||||
processes[child.pid] = child
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
return list(processes.values())
|
||||
|
||||
def toggle_game_pause(self) -> None:
|
||||
processes = self._game_process_tree()
|
||||
if not processes:
|
||||
self.update_game_actions()
|
||||
return
|
||||
paused = self._are_processes_paused(processes)
|
||||
pause_signal = signal.SIGCONT if paused else signal.SIGSTOP
|
||||
failures = 0
|
||||
for process in processes:
|
||||
try:
|
||||
process.send_signal(pause_signal)
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as error:
|
||||
failures += 1
|
||||
logger.warning(
|
||||
"Failed to change pause state for process %s: %s",
|
||||
process.pid,
|
||||
error,
|
||||
)
|
||||
if failures == len(processes):
|
||||
QMessageBox.warning(self.main_window, _("Error"), _("Failed to pause game"))
|
||||
self.update_game_actions()
|
||||
|
||||
def handle_tray_click(self, reason):
|
||||
if reason == QSystemTrayIcon.ActivationReason.Context:
|
||||
@@ -181,7 +255,10 @@ class TrayManager:
|
||||
|
||||
def stop_game(self) -> None:
|
||||
if self.main_window.stop_running_game():
|
||||
self.update_stop_game_action()
|
||||
self.update_game_actions()
|
||||
if self.minimal_mode:
|
||||
self.tray_icon.hide()
|
||||
QApplication.quit()
|
||||
return
|
||||
|
||||
QMessageBox.warning(self.main_window, _("Error"), _("Failed to stop game"))
|
||||
@@ -303,13 +380,10 @@ class TrayManager:
|
||||
|
||||
def switch_theme(self, theme_name: str):
|
||||
try:
|
||||
ui_config.set_theme(theme_name)
|
||||
logger.info(f"Saved theme {theme_name}, restarting application to apply changes")
|
||||
restart_application_process()
|
||||
self.main_window._apply_theme_live(theme_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to switch theme to {theme_name}: {e}")
|
||||
ui_config.set_theme("standart")
|
||||
restart_application_process()
|
||||
self.main_window._apply_theme_live("standart")
|
||||
|
||||
def force_exit(self):
|
||||
self.main_window.close()
|
||||
|
||||
@@ -89,6 +89,12 @@ class VirtualKeyboard(QFrame):
|
||||
|
||||
self.setStyleSheet(self.theme.VIRTUAL_KEYBOARD_STYLE)
|
||||
|
||||
def refresh_theme(self, theme: Any) -> None:
|
||||
"""Refresh keyboard styling and animation settings."""
|
||||
self.theme = theme
|
||||
self.keyboard_animations.theme = theme
|
||||
self.setStyleSheet(theme.VIRTUAL_KEYBOARD_STYLE)
|
||||
|
||||
def set_slide_animation_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable slide animation for show/hide."""
|
||||
self.enable_slide_animation = enabled
|
||||
|
||||
+16
-17
@@ -2,7 +2,7 @@
|
||||
name = "portprotonqt"
|
||||
version = "1.4.1"
|
||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||
requires-python = ">=3.10"
|
||||
requires-python = ">=3.11"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0" }
|
||||
authors = [
|
||||
@@ -15,7 +15,6 @@ classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
@@ -24,19 +23,19 @@ classifiers = [
|
||||
]
|
||||
dependencies = [
|
||||
"babel>=2.18.0",
|
||||
"evdev>=1.9.3",
|
||||
"evdev>=2.0.0",
|
||||
"libarchive-c>=5.3",
|
||||
"orjson>=3.11.8",
|
||||
"pillow>=12.2.0",
|
||||
"orjson>=3.12.0",
|
||||
"pillow>=12.3.0",
|
||||
"psutil>=7.2.2",
|
||||
"pyside6>=6.11.0",
|
||||
"rapidfuzz>=3.14.5",
|
||||
"pyside6>=6.11.2",
|
||||
"rapidfuzz>=3.14.6",
|
||||
"qrcode>=8.2",
|
||||
"requests>=2.33.1",
|
||||
"tqdm>=4.67.3",
|
||||
"requests>=2.34.2",
|
||||
"tqdm>=4.70.1",
|
||||
"vdf>=3.4",
|
||||
"websocket-client>=1.9.0",
|
||||
"dbus-fast>=5.0.4",
|
||||
"websocket-client>=1.9.2",
|
||||
"dbus-fast>=5.0.22",
|
||||
"pefile>=2024.8.26",
|
||||
]
|
||||
|
||||
@@ -51,10 +50,10 @@ package = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.5.1",
|
||||
"pyright>=1.1.408",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-mock>=3.14.0",
|
||||
"pre-commit>=4.6.2",
|
||||
"pyright>=1.1.414",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
@@ -66,9 +65,9 @@ known-third-party = ["portprotonqt"]
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [".eggs", ".git", "build", "dist", "portprotonqt.egg-info", ".venv", "venv", "dev-scripts", "portprotonqt/themes", ".vscode"]
|
||||
target-version = "py310"
|
||||
target-version = "py311"
|
||||
|
||||
[tool.pyright]
|
||||
pythonVersion = "3.10"
|
||||
pythonVersion = "3.11"
|
||||
pythonPlatform = "Linux"
|
||||
ignore = [".eggs", ".git", "build", "dist", "portprotonqt.egg-info", ".venv", "venv", "dev-scripts", "portprotonqt/themes", ".vscode"]
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user