Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1018019d2
|
No files matched your search
@@ -7,7 +7,7 @@ on:
|
||||
- main
|
||||
- scripts
|
||||
paths:
|
||||
- 'build-aux/src/vk_gpu_info.c'
|
||||
- 'build-aux/vk_gpu_info.c'
|
||||
- '.gitea/workflows/build-vk-gpu-info-debian.yml'
|
||||
|
||||
jobs:
|
||||
@@ -34,16 +34,17 @@ jobs:
|
||||
|
||||
- name: Compile vk_gpu_info
|
||||
run: |
|
||||
mkdir -p build-aux/bin
|
||||
gcc -o build-aux/bin/vk_gpu_info build-aux/src/vk_gpu_info.c -lvulkan
|
||||
cd build-aux
|
||||
gcc -o vk_gpu_info vk_gpu_info.c -lvulkan
|
||||
|
||||
- name: Test vk_gpu_info execution
|
||||
run: |
|
||||
cd build-aux
|
||||
# Check if the executable was created
|
||||
if [ -f "build-aux/bin/vk_gpu_info" ]; then
|
||||
if [ -f "./vk_gpu_info" ]; then
|
||||
echo "vk_gpu_info executable created successfully"
|
||||
# Try to run it (may fail if no Vulkan devices present, but that's OK for build test)
|
||||
timeout 10s build-aux/bin/vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
|
||||
timeout 10s ./vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
|
||||
else
|
||||
echo "ERROR: vk_gpu_info executable was not created"
|
||||
exit 1
|
||||
@@ -53,4 +54,4 @@ jobs:
|
||||
uses: https://git.linux-gaming.ru/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: vk_gpu_info-debian11-binary
|
||||
path: build-aux/bin/vk_gpu_info
|
||||
path: build-aux/vk_gpu_info
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 1.4.1
|
||||
VERSION: 1.3.1
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
fedora_version: [42, 43, 44, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -128,11 +128,9 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf install -y --disablerepo=fedora-cisco-openh264 \
|
||||
git rpmdevtools meson ninja-build python3-devel \
|
||||
dnf install -y git rpmdevtools meson ninja-build python3-devel \
|
||||
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
gcc gettext
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
@@ -174,7 +172,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
|
||||
libvulkan-dev meson ninja-build nodejs npm \
|
||||
pkgconf python3
|
||||
|
||||
- name: Checkout
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
debian: ${{ steps.check.outputs.debian }}
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
@@ -89,10 +89,10 @@ jobs:
|
||||
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/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
fedora_version: [42, 43, 44, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -135,11 +135,9 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf install -y --disablerepo=fedora-cisco-openh264 \
|
||||
git rpmdevtools meson ninja-build python3-devel \
|
||||
dnf install -y git rpmdevtools meson ninja-build python3-devel \
|
||||
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
gcc gettext
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
@@ -149,11 +147,11 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -185,7 +183,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
@@ -213,11 +211,11 @@ jobs:
|
||||
chown user -R ..
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -251,7 +249,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20260824@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
|
||||
image: debian:trixie-20260623@sha256:d07d1b51c39f51188e60be9b64e6bf769fa94e187f092bc32b91305cfa34ba5a
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
@@ -259,15 +257,15 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
|
||||
libvulkan-dev meson ninja-build nodejs npm \
|
||||
pkgconf python3
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
|
||||
@@ -43,5 +43,3 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
|
||||
|
||||
# AI
|
||||
.codex
|
||||
CODE_QUALITY_PLAN.md
|
||||
.repowise
|
||||
@@ -78,31 +78,6 @@ 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
|
||||
@@ -419,7 +394,6 @@ tests/
|
||||
├── test_portproton_api.py # PPDB API helpers, autoinstall localization fallback
|
||||
├── test_autoinstall_status.py # Autoinstall installed-status matching regressions
|
||||
├── test_migration.py # Desktop shortcut migration, prefix backup, squashfs
|
||||
├── test_file_explorer.py # File explorer themed icon state regressions
|
||||
├── test_icon_extractor.py # NE/PE icon extraction, DIB decoding, thumbnails
|
||||
├── test_dbus_tools.py # D-Bus tools (notifications, idle inhibit, power profiles)
|
||||
├── test_time_utils.py # Playtime parsing, last launch cache, formatting
|
||||
@@ -427,9 +401,7 @@ tests/
|
||||
├── test_theme_store.py # Theme store UI worker lifecycle and race regressions
|
||||
├── test_theme_manager.py # Theme AST injection, parent resolution, ThemeWrapper, style integrity
|
||||
├── test_theme_security.py # Theme security checker (allowlist AST, forbidden modules/methods, SVG/font/image safety)
|
||||
├── test_settings_search.py # MangoHud, vkBasalt, Gamescope settings search and layout regressions
|
||||
├── test_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
|
||||
└── test_proton_manager.py # Local Wine/Proton drag-and-drop and safe archive extraction
|
||||
└── test_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
@@ -454,7 +426,7 @@ pre-commit run pytest
|
||||
- Tests MUST mock external resources (network, system paths, env vars)
|
||||
- Tests MUST NOT depend on system state (installed apps, real Steam dirs)
|
||||
- Use `monkeypatch` for environment variables and function patching
|
||||
- Keep tests locale-independent: assert stable identifiers or numbers instead of translated strings; resolve values created through `_()` from the same application mapping using stable identifiers; use `monkeypatch` to provide deterministic text when testing translated search or matching
|
||||
- Keep tests locale-independent (assert numbers, not translated strings)
|
||||
- Test regression scenarios from git fix commits
|
||||
|
||||
### Key Regression Areas
|
||||
@@ -470,7 +442,6 @@ pre-commit run pytest
|
||||
| `classic-light/styles.py` | NameError: border_none not defined (no styles/constants.py) | 519edd1 |
|
||||
| `detail_pages` | DETAIL_PAGE_GRADIENT stops override, wave background modes | — |
|
||||
| `detail_pages` | Edit/Add shortcut buttons shown together for non-Steam games | 7e38d9a |
|
||||
| `wine_extractor` | Archive traversal and process-wide cwd changes during extraction | — |
|
||||
|
||||
---
|
||||
|
||||
@@ -903,113 +874,21 @@ install_data(
|
||||
|
||||
```
|
||||
PortProtonQt/
|
||||
├── portprotonqt/ # Python package
|
||||
│ ├── animations/ # UI animation modules
|
||||
│ │ ├── detail_background.py
|
||||
│ │ ├── detail_page.py
|
||||
│ │ ├── game_card.py
|
||||
│ │ ├── library_controls.py
|
||||
│ │ └── virtual_keyboard.py
|
||||
│ ├── config/ # Application configuration
|
||||
│ │ ├── base.py
|
||||
│ │ ├── cache.py
|
||||
│ │ ├── display.py
|
||||
│ │ ├── favorites.py
|
||||
│ │ ├── game.py
|
||||
│ │ ├── gamepad.py
|
||||
│ │ ├── portproton.py
|
||||
│ │ ├── proxy.py
|
||||
│ │ ├── ui.py
|
||||
│ │ ├── validators.py
|
||||
│ │ └── window.py
|
||||
│ ├── debug_utils/ # Debug data collection and processing
|
||||
│ │ ├── debug_log_manager.py
|
||||
│ │ ├── env_utils.py
|
||||
│ │ ├── game_debug.py
|
||||
│ │ ├── gpu_info.py
|
||||
│ │ ├── log_processor.py
|
||||
│ │ ├── system_info.py
|
||||
│ │ └── xorg_utils.py
|
||||
│ ├── detail_pages/ # Game detail page components
|
||||
│ │ ├── utils.py
|
||||
│ │ └── widgets.py
|
||||
│ ├── dialogs/ # Application dialogs
|
||||
│ │ ├── appimage_update.py
|
||||
│ │ ├── base.py
|
||||
│ │ ├── compatibility_report.py
|
||||
│ │ ├── dialog_utils.py
|
||||
│ │ ├── file_explorer.py
|
||||
│ │ ├── prefix_backup.py
|
||||
│ │ ├── proton_manager.py
|
||||
│ │ ├── settings_dialog.py
|
||||
│ │ ├── settings_gamescope.py
|
||||
│ │ ├── settings_mangohud.py
|
||||
│ │ ├── settings_vkbasalt.py
|
||||
│ │ ├── wine_downloader.py
|
||||
│ │ ├── wine_extractor.py
|
||||
│ │ ├── wine_loader.py
|
||||
│ │ └── winetricks_dialog.py
|
||||
│ ├── input_manager/ # Keyboard and gamepad input routing
|
||||
│ │ ├── buttons.py
|
||||
│ │ ├── constants.py
|
||||
│ │ ├── dialog_modes.py
|
||||
│ │ ├── dpad.py
|
||||
│ │ ├── file_explorer.py
|
||||
│ │ ├── keyboard.py
|
||||
│ │ ├── mixin.py
|
||||
│ │ ├── runtime.py
|
||||
│ │ ├── settings.py
|
||||
│ │ └── settings_visual.py
|
||||
│ ├── scripts_utils/ # Helpers used by PortProton scripts
|
||||
│ │ ├── dbus_tools.py
|
||||
│ │ ├── easyterm.py
|
||||
│ │ ├── graphics_detector.py
|
||||
│ │ ├── idle_inhibit.py
|
||||
│ │ ├── json_tools.py
|
||||
│ │ ├── mount_points.py
|
||||
│ │ ├── power_profiles.py
|
||||
│ │ ├── prefix_backup.py
|
||||
│ │ └── shortcut_tools.py
|
||||
│ ├── steam_api/ # Steam API, cache, and shortcuts
|
||||
│ │ ├── api.py
|
||||
│ │ ├── cache.py
|
||||
│ │ ├── shortcuts.py
|
||||
│ │ └── utils.py
|
||||
│ ├── system_manager/ # System settings backends
|
||||
│ │ ├── audio.py
|
||||
│ │ ├── bluetooth.py
|
||||
│ │ ├── common.py
|
||||
│ │ ├── network.py
|
||||
│ │ └── storage.py
|
||||
│ ├── tabs/ # Main window tabs and workers
|
||||
│ │ ├── autoinstall_tab.py
|
||||
│ │ ├── control_hints.py
|
||||
│ │ ├── gog_tab.py
|
||||
│ │ ├── library_tab.py
|
||||
│ │ ├── settings_tab.py
|
||||
│ │ ├── system_tab.py
|
||||
│ │ ├── theme_store.py
|
||||
│ │ ├── theme_store_workers.py
|
||||
│ │ ├── theme_tab.py
|
||||
│ │ ├── wine_tab.py
|
||||
│ │ └── workers.py
|
||||
│ ├── locales/ # Translation catalogs
|
||||
│ ├── terminal_schemes/ # Terminal color schemes
|
||||
│ ├── themes/ # Built-in themes and assets
|
||||
│ ├── app.py # Entry point
|
||||
│ ├── game_card.py # Game card widget
|
||||
│ ├── gog_api.py # GOG integration
|
||||
│ ├── main_window.py # Main window
|
||||
│ ├── native_gamepad.py # Native SDL3 gamepad backend
|
||||
│ ├── portproton_api.py # PortProton integration
|
||||
│ ├── theme_manager.py # Theme management
|
||||
│ └── logger.py # Logging
|
||||
├── tests/ # Unit tests
|
||||
├── build-aux/ # Build resources
|
||||
├── dev-scripts/ # Development scripts
|
||||
├── documentation/ # Documentation
|
||||
├── meson.build # Meson config
|
||||
├── pyproject.toml # Python config
|
||||
├── portprotonqt/ # Python package
|
||||
│ ├── app.py # Entry point
|
||||
│ ├── main_window.py # Main window
|
||||
│ ├── game_card.py # Game card widget
|
||||
│ ├── steam_api.py # Steam integration
|
||||
│ ├── egs_api.py # Epic Games integration
|
||||
│ ├── theme_manager.py # Theme management
|
||||
│ ├── logger.py # Logging
|
||||
│ └── themes/ # Theme files
|
||||
├── tests/ # Unit tests
|
||||
├── build-aux/ # Build resources
|
||||
├── dev-scripts/ # Development scripts
|
||||
├── documentation/ # Documentation
|
||||
├── meson.build # Meson config
|
||||
├── pyproject.toml # Python config
|
||||
└── .pre-commit-config.yaml
|
||||
```
|
||||
|
||||
@@ -1067,6 +946,6 @@ PortProtonQt/
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2026-08-11
|
||||
**Version:** 1.3
|
||||
**Last updated:** 2026-07-20
|
||||
**Version:** 1.2
|
||||
**Status:** Release
|
||||
@@ -5,94 +5,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлена настройка принудительного применения английского языка
|
||||
- Добавлена интеграция Epic Games Store с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
|
||||
- Добавлена поддержка сборок Proton CachyOS WineLand
|
||||
- Добавлена кнопка возврата на рабочий стол в системной вкладке при указании команды в `PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT`
|
||||
- Добавлены режимы библиотеки `horizontal` и `horizontal_top` с циклической навигацией и плиткой открытия полной сетки в топ-режиме
|
||||
- Добавлен режим библиотеки `vertical` с табличным отображением названия, последнего запуска, проведённого времени и источника игры
|
||||
|
||||
### Changed
|
||||
|
||||
- Поиск в библиотеке теперь находит совпадения в любой части названия и показывает одноимённые игры из разных источников
|
||||
- Тема теперь применяется без перезапуска приложения
|
||||
- Добавлена сборка 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/`
|
||||
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
|
||||
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
|
||||
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
|
||||
- Порог определения быстрого вылета Windows-программы увеличен с 5 до 7 секунд
|
||||
- Открытие карточек и тихий запуск унифицированы
|
||||
- `minazuki` добавлен в список конфликтующих с GameMode процессов
|
||||
|
||||
### Fixed
|
||||
|
||||
- Исправлено открытие карточек GOG через URI при старте приложения
|
||||
- Исправлен сброс состояния кнопки записи лога при переключении детальной страницы в компактный режим
|
||||
- Исправлено отсутствие вкладок Wi-Fi и Bluetooth при запуске до инициализации системных адаптеров
|
||||
- Исправлена ошибка, из-за которой не работал поиск по настройкам при отсутствии доступных параметров MangoHud или Gamescope
|
||||
- Фильтр установленных игр теперь переключается без повторной загрузки библиотеки
|
||||
- Некорректные изображения в кэше Steam теперь пропускаются при поиске обложек
|
||||
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
|
||||
|
||||
### Contributors
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @adem4ik
|
||||
|
||||
---
|
||||
|
||||
## [1.4.1] - 2026-08-26
|
||||
|
||||
### Changed
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
|
||||
### Fixed
|
||||
- Произведён откат Steam Runtime Sniper до версии V3 из-за несовместимости GStreamer внутри Proton 10.x с SLR 4
|
||||
|
||||
### Contributors
|
||||
|
||||
---
|
||||
|
||||
## [1.4.0] - 2026-08-23
|
||||
|
||||
### Added
|
||||
- Добавлен анализатор проблем в случае вылета Windows-программы
|
||||
- Добавлены анимированные и статические фоны детальной страницы `aurora`, `diagnostics`, `metaballs`, `leaf` и `veins`, вдохновлённые проектом omikuji
|
||||
- Добавлена интеграция GOG с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
|
||||
- Добавлена отдельная галочка `Only Installed`, сочетаемая с фильтрами источника и скрывающая неустановленные игры
|
||||
- Добавлено отображение иконок `.exe` вместо обложек на компактной детальной странице
|
||||
- Добавлено редактирование ярлыков Steam по AppID
|
||||
- Добавлены тихий и логируемый режимы запуска `.exe` через меню «Открыть с помощью»
|
||||
- Добавлена пользовательская интеграция AppImage
|
||||
- Добавлен однокнопочный установщик AppImage с загрузкой через Gitea или GitHub и автоматической интеграцией в систему
|
||||
- В игровые ярлыки добавлены действия для запуска в тихом режиме и с записью лога
|
||||
- Добавлен аргумент командной строки `--log` для запуска игры в режиме логирования
|
||||
- Добавлены звуки интерфейса
|
||||
- Добавлен выбор аккаунта Steam в настройках с автоматическим режимом по умолчанию и режимом `All` для добавления ярлыков всем пользователям
|
||||
- Добавлено безопасное чтение вариантов dark/light палитры matugen из Dank Material Shell
|
||||
- Добавлен поиск параметров MangoHud, vkBasalt и Gamescope по названиям и описаниям
|
||||
- Добавлена установка локальных архивов WINE/Proton перетаскиванием в библиотеку или менеджер версий
|
||||
- Добавлена кнопка открытия папки WINE в менеджере версий
|
||||
- Добавлена настройка для скрытия подсказок по управлению
|
||||
- Добавлена кнопка удаления пользовательских тем
|
||||
|
||||
### Changed
|
||||
- GitHub-зеркала [PortProtonQt](https://github.com/linux-gaming-ru/PortProtonQt) и [метаданных WINE/Proton](https://github.com/linux-gaming-ru/PortProtonQt-Wine-Metadata) перенесены в организацию `linux-gaming-ru`
|
||||
- Иконки системных уведомлений перенесены в темы и теперь соответствуют выбранной теме
|
||||
- Выбранное зеркало теперь используется для загрузки метаданных WINE/Proton, проверки обновлений AppImage и получения списка изменений
|
||||
- Обновлена ссылка на раздел помощи в логе ошибки
|
||||
- В AppImage добавлены `7z` для извлечения содержимого ISO и MDF-образов и `vk_gpu_info` для определения видеокарт Vulkan
|
||||
- Вертикальная навигация геймпадом в сетках настроек больше не переходит между столбцами
|
||||
- Удалена сборка для Fedora 42
|
||||
- Версия Windows по умолчанию изменена с 10 на 11
|
||||
- Обновлены все иконки подсказок управления
|
||||
- API и ссылки PPDB переведены с `ppdb.linux-gaming.ru` на `linux-gaming.ru`
|
||||
- Блок игровой статистики на полной детальной странице помещён в тематизируемый контейнер, как в компактном режиме
|
||||
- Кэш EXE-иконок унифицирован для карточек и детальных страниц
|
||||
@@ -106,18 +33,6 @@
|
||||
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
|
||||
|
||||
### Fixed
|
||||
- Настройки прокси теперь передаются `curl` при загрузке файлов скриптами PortProton
|
||||
- Устранена повторная перестройка индексов Steam и PPDB для каждой карточки в экономном режиме
|
||||
- Скрытие бейджей больше не скрывает ленту источника игры на карточках библиотеки
|
||||
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
|
||||
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
|
||||
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
|
||||
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
|
||||
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
|
||||
- Исправлено обрезание подсказок управления при переносе на вторую строку
|
||||
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
|
||||
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
|
||||
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
|
||||
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
|
||||
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
|
||||
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
|
||||
@@ -128,23 +43,14 @@
|
||||
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
|
||||
- Исправлено зависание интерфейса после завершения игры
|
||||
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
|
||||
- Распаковка WINE/Proton больше не меняет рабочий каталог приложения и отклоняет пути из архива, выходящие за каталог установки
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @Rubin13
|
||||
- @rogerio-mattos
|
||||
- @Vekt0R_null
|
||||
|
||||
---
|
||||
|
||||
## [1.3.2] - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
- Исправлено определение SteamID по `AutoLogin` или единственному профилю `userdata`, если в `loginusers.vdf` отсутствует `MostRecent`
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
[Desktop Entry]
|
||||
Name=Install PortProtonQt
|
||||
Name[ru]=Установить PortProtonQt
|
||||
Name[es]=Instalar PortProtonQt
|
||||
Comment=Download and install the latest PortProtonQt AppImage
|
||||
Comment[ru]=Скачать и установить последнюю версию PortProtonQt AppImage
|
||||
Exec=sh -c "(curl -fsSL https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/install.sh || curl -fsSL https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/main/install.sh) | sh"
|
||||
Icon=system-software-install
|
||||
StartupNotify=false
|
||||
Terminal=true
|
||||
Type=Application
|
||||
Categories=Game;
|
||||
@@ -5,8 +5,8 @@
|
||||
<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-10488-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>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-5471-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton and Steam. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
</div>
|
||||
|
||||
### Installation (devel)
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<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-10488-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam, Epic Games Store и 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-5471-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
|
||||
</div>
|
||||
|
||||
### Установка (devel)
|
||||
|
||||
@@ -39,28 +39,15 @@ pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
|
||||
|
||||
# Deploy dependencies
|
||||
# Qt libs have to be passed manually due to the app being a python script
|
||||
GAMEPAD_LIBRARY=$(find /usr/lib -type f \
|
||||
-path '*/site-packages/portprotonqt/libportprotonqt_gamepad.so' \
|
||||
-print -quit)
|
||||
test -n "$GAMEPAD_LIBRARY"
|
||||
./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* \
|
||||
/usr/lib/libQt6Gui.so* \
|
||||
/usr/lib/libQt6Multimedia.so* \
|
||||
/usr/lib/libQt6Network.so* \
|
||||
/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
|
||||
/usr/lib/qt6/plugins/imageformats/libqwebp.so
|
||||
|
||||
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
|
||||
# build/development content and PySide bindings not imported by PortProtonQt.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
pkgname=('portprotonqt')
|
||||
pkgver=1.4.1
|
||||
pkgver=1.3.1
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
|
||||
arch=('x86_64')
|
||||
arch=('any')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
|
||||
@@ -2,7 +2,7 @@ pkgname=('portprotonqt-git')
|
||||
pkgver=.
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam (development build)"
|
||||
arch=('x86_64')
|
||||
arch=('any')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.4.1
|
||||
%global pypi_version 1.3.1
|
||||
%global oname PortProtonQt
|
||||
%global build_timestamp %(date +"%Y%m%d")
|
||||
%global _python_no_extras_requires 1
|
||||
@@ -22,7 +22,6 @@ BuildRequires: git
|
||||
BuildRequires: gettext
|
||||
BuildRequires: systemd-rpm-macros
|
||||
BuildRequires: vulkan-loader-devel
|
||||
BuildRequires: pkgconfig(sdl3)
|
||||
BuildRequires: gcc
|
||||
|
||||
Obsoletes: python3-%{pypi_name}-git < %{version}-%{release}
|
||||
@@ -43,6 +42,7 @@ Requires: python3-pillow-qt
|
||||
Requires: python3-rapidfuzz
|
||||
Requires: python3-libarchive-c
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: SDL3
|
||||
Requires: qt6-qtsvg
|
||||
Requires: qt6-qtimageformats
|
||||
Requires: qt6-qtmultimedia
|
||||
@@ -120,6 +120,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
|
||||
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
|
||||
%{_datadir}/portproton/scripts/
|
||||
%{_datadir}/portproton/conf/
|
||||
%{_datadir}/portproton/img/
|
||||
%{bash_completions_dir}/portprotonqt
|
||||
%{fish_completions_dir}/portprotonqt.fish
|
||||
%{zsh_completions_dir}/_portprotonqt
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.4.1
|
||||
%global pypi_version 1.3.1
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
@@ -19,7 +19,6 @@ BuildRequires: git
|
||||
BuildRequires: gettext
|
||||
BuildRequires: systemd-rpm-macros
|
||||
BuildRequires: vulkan-loader-devel
|
||||
BuildRequires: pkgconfig(sdl3)
|
||||
BuildRequires: gcc
|
||||
|
||||
Obsoletes: python3-%{pypi_name} < %{version}-%{release}
|
||||
@@ -40,6 +39,7 @@ Requires: python3-pillow-qt
|
||||
Requires: python3-rapidfuzz
|
||||
Requires: python3-libarchive-c
|
||||
Requires: perl-Image-ExifTool
|
||||
Requires: SDL3
|
||||
Requires: qt6-qtsvg
|
||||
Requires: qt6-qtimageformats
|
||||
Requires: qt6-qtmultimedia
|
||||
@@ -118,6 +118,7 @@ install -Dpm 0644 ./completions/_portprotonqt -t %{buildroot}%{zsh_completions_d
|
||||
%{_datadir}/mime/packages/ru.linux_gaming.PortProtonQt.xml
|
||||
%{_datadir}/portproton/scripts/
|
||||
%{_datadir}/portproton/conf/
|
||||
%{_datadir}/portproton/img/
|
||||
%{bash_completions_dir}/portprotonqt
|
||||
%{fish_completions_dir}/portprotonqt.fish
|
||||
%{zsh_completions_dir}/_portprotonqt
|
||||
|
||||
@@ -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-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds;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-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;
|
||||
@@ -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-dosexec;application/vnd.microsoft.portable-executable;
|
||||
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;
|
||||
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-dosexec;application/vnd.microsoft.portable-executable;
|
||||
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;
|
||||
StartupNotify=true
|
||||
@@ -5,15 +5,15 @@
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>GPL-3.0-or-later</project_license>
|
||||
|
||||
<summary>Modern GUI for managing and launching games from PortProton, Steam, and GOG</summary>
|
||||
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton, Steam и GOG</summary>
|
||||
<summary>Modern GUI for managing and launching games from PortProton and Steam</summary>
|
||||
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton и Steam</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
A modern and intuitive interface for managing and launching games from PortProton, Steam, and GOG. Combines libraries in one place and simplifies running Windows games on Linux.
|
||||
A modern and intuitive interface for managing and launching games from PortProton and Steam. Combines libraries in one place and simplifies running Windows games on Linux.
|
||||
</p>
|
||||
<p xml:lang="ru">
|
||||
Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
|
||||
Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
@@ -24,17 +24,8 @@
|
||||
</developer>
|
||||
|
||||
<releases>
|
||||
<release version="1.4.1" date="2026-08-26">
|
||||
<description></description>
|
||||
</release>
|
||||
<release version="1.4.0" date="2026-08-23">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.3.2" date="2026-08-10">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<description/>
|
||||
<description></description>
|
||||
</release>
|
||||
<release version="1.3.0" date="2026-07-13">
|
||||
<description/>
|
||||
|
||||
@@ -10,10 +10,4 @@
|
||||
<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-info>
|
||||
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -135,10 +135,8 @@ rm_from_array () {
|
||||
}
|
||||
|
||||
fatal () {
|
||||
if [[ -z "$*" ]] ; then
|
||||
set -- "Fatal error"
|
||||
fi
|
||||
python_module dbus_tools notify -i error "$@" &>/dev/null
|
||||
#TODO
|
||||
pw_notify_send -i error "$@"
|
||||
print_error "$@"
|
||||
stop_portproton
|
||||
exit 1
|
||||
@@ -461,26 +459,6 @@ try_download () {
|
||||
no_mirror=true
|
||||
esac
|
||||
|
||||
local curl_proxy_args=()
|
||||
local _pp_conf=""
|
||||
if [[ -n "${XDG_CONFIG_HOME:-}" ]] \
|
||||
&& [[ -f "$XDG_CONFIG_HOME/PortProtonQt.conf" ]] ; then
|
||||
_pp_conf="$XDG_CONFIG_HOME/PortProtonQt.conf"
|
||||
elif [[ -f "$HOME/.config/PortProtonQt.conf" ]] ; then
|
||||
_pp_conf="$HOME/.config/PortProtonQt.conf"
|
||||
fi
|
||||
local _pp_url="" _pp_user="" _pp_pass=""
|
||||
if [[ -n "$_pp_conf" ]] ; then
|
||||
_pp_url="$(grep "proxy_url" "$_pp_conf" | awk -F"= " '{print $2}')"
|
||||
if [[ -n "$_pp_url" ]] ; then
|
||||
curl_proxy_args=(-x "$_pp_url")
|
||||
_pp_user="$(grep "proxy_user" "$_pp_conf" | awk -F"= " '{print $2}')"
|
||||
_pp_pass="$(grep "proxy_password" "$_pp_conf" | awk -F"= " '{print $2}')"
|
||||
[[ -n "$_pp_user" && -n "$_pp_pass" ]] \
|
||||
&& curl_proxy_args+=(-u "$_pp_user:$_pp_pass")
|
||||
fi
|
||||
fi
|
||||
|
||||
local filename
|
||||
if [[ -n "${PW_AUTOINSTALL_EXE}" ]] \
|
||||
&& [[ "$no_mirror" == "true" ]]
|
||||
@@ -504,7 +482,7 @@ try_download () {
|
||||
if check_gamescope_session ; then
|
||||
$PW_TERM "echo ; echo ; echo \"Downloading: $filename. Please wait...\" \
|
||||
; curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\" ${curl_proxy_args[*]}"
|
||||
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\""
|
||||
|
||||
|
||||
[[ "$?" != 0 ]] && return 1 || return 0
|
||||
@@ -513,10 +491,10 @@ try_download () {
|
||||
# Normal download
|
||||
print_info "Download $filename from ${FIRST_URL[0]}..."
|
||||
if [[ "$silent" == "true" ]] ; then
|
||||
curl "${curl_proxy_args[@]}" -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1
|
||||
else
|
||||
curl "${curl_proxy_args[@]}" -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
curl -f -# --connect-timeout 5 --speed-limit 1 --speed-time 5 -A 'Mozilla/5.0 (compatible; Konqueror/2.1.1; X11)' -H 'Cache-Control: no-cache, no-store' \
|
||||
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1 \
|
||||
| tr '\r' '\n' | sed -ur 's|[# ]+||g;s|100||g;s|0-||g;s|.*=.*||g'
|
||||
|
||||
@@ -533,21 +511,6 @@ try_download () {
|
||||
return 0
|
||||
}
|
||||
|
||||
prepare_epic_wrapper () {
|
||||
local expected_hash="1feb21e21e19cbb34e881791c8f2557769f59231e46850915d49a6d0c1ce4583"
|
||||
export PW_EPIC_WRAPPER_EXE="${PORT_WINE_TMP_PATH}/plugins${PW_PLUGINS_VER}/epic/EpicGamesLauncher.exe"
|
||||
if [[ ! -f "$PW_EPIC_WRAPPER_EXE" ]] ; then
|
||||
create_new_dir "$(dirname "$PW_EPIC_WRAPPER_EXE")"
|
||||
try_download \
|
||||
"https://github.com/BananaWorks07/heroic-epic-integration/releases/download/v${PW_EPIC_WRAPPER_VER}/EpicGamesLauncher.exe" \
|
||||
"$PW_EPIC_WRAPPER_EXE" no_mirror || return 1
|
||||
fi
|
||||
if [[ "$(sha256sum "$PW_EPIC_WRAPPER_EXE" | awk '{print $1}')" != "$expected_hash" ]] ; then
|
||||
try_remove_file "$PW_EPIC_WRAPPER_EXE"
|
||||
fatal "Epic launcher wrapper checksum verification failed"
|
||||
fi
|
||||
}
|
||||
|
||||
var_winedlloverride_update () {
|
||||
export WINEDLLOVERRIDES="${1}${WINEDLLOVERRIDES:+;$WINEDLLOVERRIDES}"
|
||||
}
|
||||
@@ -580,6 +543,48 @@ var_ld_library_path_update () {
|
||||
fi
|
||||
}
|
||||
|
||||
# GUI NOTIFY SEND
|
||||
pw_notify_send () {
|
||||
local icon title body app timeout OPTIND
|
||||
app="ru.linux_gaming.PortProtonQt"
|
||||
timeout="5000"
|
||||
OPTIND="1"
|
||||
while getopts a:i:t: opt ; do
|
||||
case "$opt" in
|
||||
a)
|
||||
app=$OPTARG
|
||||
;;
|
||||
i)
|
||||
case "$OPTARG" in
|
||||
info) local OPTARG="${PORT_IMG_PATH}/notify/info.svg";;
|
||||
warning) local OPTARG="${PORT_IMG_PATH}/notify/warning.svg";;
|
||||
error) local OPTARG="${PORT_IMG_PATH}/notify/error.svg";;
|
||||
esac
|
||||
icon=$OPTARG
|
||||
;;
|
||||
t)
|
||||
timeout=$OPTARG
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0
|
||||
[-a] name application (PortProtonQt default)
|
||||
[-i] info, warning, error (none default)
|
||||
[-t] timeout in ms (5000 default)" >&2
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shift $(( OPTIND - 1 ))
|
||||
[[ "$1" == "--" ]] && shift
|
||||
title="$1" ; shift
|
||||
body="$*"
|
||||
|
||||
python_module dbus_tools notify \
|
||||
-a "$app" -i "$icon" -t "$timeout" -- "$title" "$body" &>/dev/null
|
||||
return 0
|
||||
}
|
||||
export -f pw_notify_send
|
||||
|
||||
check_gamescope_session () {
|
||||
if [[ -n "$GAMESCOPE_IN_USE" ]]
|
||||
then [[ "$GAMESCOPE_IN_USE" == 1 ]] && return 0 || return 1
|
||||
@@ -1996,7 +2001,7 @@ pw_check_vulkan_extensions () {
|
||||
local vk_gpu_info_cmd
|
||||
ext_key="supports_$(echo "${ext_name}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../bin/vk_gpu_info"
|
||||
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../../dev-scripts/vk_gpu_info"
|
||||
if [[ -x "${vk_gpu_info_from_repo}" ]] ; then
|
||||
vk_gpu_info_cmd="${vk_gpu_info_from_repo}"
|
||||
else
|
||||
@@ -2306,7 +2311,7 @@ pw_create_gui_png () {
|
||||
pw_create_unique_exe () {
|
||||
BASEDIR_GAME="$(dirname "$PW_EXE_FILE")"
|
||||
if [[ -d "$BASEDIR_GAME" ]] ; then
|
||||
pushd "$BASEDIR_GAME" || fatal "Failed to enter game directory: $BASEDIR_GAME"
|
||||
pushd "$BASEDIR_GAME" || fatal
|
||||
BASENAME_GAME_EXE="$(basename "$PW_EXE_FILE")"
|
||||
if [[ -n "$1" ]] ; then
|
||||
BASENAME_GAME="$(basename "$1" .exe).exe"
|
||||
@@ -2319,7 +2324,7 @@ pw_create_unique_exe () {
|
||||
print_warning "There are no arguments for creating a symbolic link! Skip it..."
|
||||
fi
|
||||
try_remove_file "${PW_EXE_FILE}.ppdb"
|
||||
popd || fatal "Failed to return from game directory: $BASEDIR_GAME"
|
||||
popd || fatal
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -3009,7 +3014,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 minazuki ; then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn ; then
|
||||
export GAMEMODERUN=0
|
||||
export PW_GAMEMODERUN_SLR=""
|
||||
local current_power_profile
|
||||
@@ -3749,6 +3754,10 @@ start_portproton () {
|
||||
*) get_and_set_reg_file --delete 'Software\Wine\Drivers' 'Audio' ;;
|
||||
esac
|
||||
|
||||
if [[ -n "${PW_NETWORK_BLOCKLIST:-}" ]] ; then
|
||||
export PW_LD_PRELOAD="${PW_LD_PRELOAD:+${PW_LD_PRELOAD}:}libppqt-network-filter.so"
|
||||
fi
|
||||
|
||||
add_in_start_portproton
|
||||
|
||||
if [[ "$FULL_LN" == russian ]] ; then
|
||||
@@ -4084,7 +4093,6 @@ steamplay_launch () {
|
||||
fi
|
||||
|
||||
print_info "$proton_runner in use."
|
||||
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
|
||||
pw_launch_wrapper \
|
||||
${PW_RUN_GAMESCOPE} \
|
||||
env \
|
||||
@@ -4110,7 +4118,6 @@ portwine_launch () {
|
||||
fi
|
||||
|
||||
[[ $PW_LOG != 1 ]] && debug_timer --start -s "PW_TIME_IN_GAME"
|
||||
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
|
||||
if [[ -n "${LAUNCH_URI}" ]] ; then
|
||||
export WINEFSYNC="1"
|
||||
pw_run start /high /b "${LAUNCH_URI}" &
|
||||
@@ -4170,12 +4177,7 @@ EOF
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
*.exe)
|
||||
if [[ -n "${PW_EPIC_WRAPPER_EXE:-}" ]] ; then
|
||||
pw_run "${PW_VD_TMP[@]}" "${PW_EPIC_WRAPPER_EXE}" \
|
||||
"${PW_EXE_FILE}" "$@"
|
||||
else
|
||||
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
|
||||
fi
|
||||
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
|
||||
;;
|
||||
*.bat|*.cmd)
|
||||
PW_USE_TERMINAL=1
|
||||
@@ -4215,13 +4217,8 @@ gui_proton_downloader () {
|
||||
return 0
|
||||
fi
|
||||
|
||||
METADATA_CLOUD_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
METADATA_GITHUB_URL="https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt-Wine-Metadata/main/wine_metadata_all.json"
|
||||
if [[ "${MIRROR}" == CLOUD ]] ; then
|
||||
METADATA=$(curl -fL -A 'PortProton' -s "$METADATA_CLOUD_URL")
|
||||
else
|
||||
METADATA=$(curl -fL -s "$METADATA_GITHUB_URL")
|
||||
fi
|
||||
METADATA_URL="https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
METADATA=$(curl -A 'PortProton' -s "$METADATA_URL")
|
||||
if [[ -z "$METADATA" ]] ; then
|
||||
fatal "failed to fetch metadata"
|
||||
fi
|
||||
|
||||
@@ -19,6 +19,7 @@ else
|
||||
PORT_SCRIPTS_PATH="$(dirname "$(realpath "$0")")"
|
||||
fi
|
||||
|
||||
PORT_IMG_PATH="$(dirname "$PORT_SCRIPTS_PATH")/img"
|
||||
PORT_CONF_PATH="$(dirname "$PORT_SCRIPTS_PATH")/conf"
|
||||
|
||||
if [[ -z "$PORT_DATA_PATH" ]] ; then
|
||||
@@ -54,7 +55,7 @@ else
|
||||
export PW_FIND_PPDB=1
|
||||
fi
|
||||
|
||||
export PORT_SCRIPTS_PATH PORT_DATA_PATH PORT_CONF_PATH
|
||||
export PORT_SCRIPTS_PATH PORT_IMG_PATH PORT_DATA_PATH PORT_CONF_PATH
|
||||
export PW_LOG_FILE="${PORT_DATA_PATH}/PortProton.log"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
@@ -73,9 +74,6 @@ fi
|
||||
|
||||
export PW_START_PID="$$"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${PORT_SCRIPTS_PATH}/var"
|
||||
|
||||
read -r -a pw_full_command_line <<< "$0 $*"
|
||||
export pw_full_command_line
|
||||
export orig_IFS="$IFS"
|
||||
@@ -122,9 +120,6 @@ elif [[ "$1" =~ ^--(debug|launch|edit-db)$ && "${2,,}" =~ \.(exe|bat|cmd|msi|reg
|
||||
elif [[ -f "$OLDPWD/$2" ]] ; then
|
||||
PW_EXE_FILE="$(realpath -s "$OLDPWD/$2")"
|
||||
fi
|
||||
elif [[ "$1" == "--epic-wrapper" && "${2,,}" =~ \.exe$ ]] ; then
|
||||
prepare_epic_wrapper || fatal "Failed to prepare Epic launcher wrapper"
|
||||
PW_EXE_FILE="$(realpath -s "$2")"
|
||||
fi
|
||||
export PW_EXE_FILE
|
||||
|
||||
@@ -153,7 +148,7 @@ create_new_dir "${PORT_DATA_PATH}/data/prefixes/DEFAULT"
|
||||
create_new_dir "${PORT_DATA_PATH}/data/prefixes/DOTNET"
|
||||
try_force_link_dir "${PORT_DATA_PATH}/data/prefixes" "${PORT_DATA_PATH}"
|
||||
|
||||
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal "Failed to enter prefixes directory"
|
||||
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal
|
||||
for pfx_dir in ./* ; do
|
||||
[[ -d "$pfx_dir" ]] || continue
|
||||
pfx_dir_new="${pfx_dir//[[:blank:]]/_}"
|
||||
@@ -161,7 +156,7 @@ for pfx_dir in ./* ; do
|
||||
mv -- "${PORT_DATA_PATH}/data/prefixes/$pfx_dir" "${PORT_DATA_PATH}/data/prefixes/${pfx_dir_new^^}"
|
||||
fi
|
||||
done
|
||||
popd 1>/dev/null || fatal "Failed to return from prefixes directory"
|
||||
popd 1>/dev/null || fatal
|
||||
|
||||
create_new_dir "${PORT_WINE_TMP_PATH}"/gecko
|
||||
create_new_dir "${PORT_WINE_TMP_PATH}"/mono
|
||||
@@ -169,9 +164,12 @@ create_new_dir "${PORT_WINE_TMP_PATH}"/mono
|
||||
export PW_VULKAN_DIR="${PORT_WINE_TMP_PATH}/VULKAN"
|
||||
create_new_dir "${PW_VULKAN_DIR}"
|
||||
|
||||
cd "${PORT_SCRIPTS_PATH}" || fatal "Scripts directory not found: ${PORT_SCRIPTS_PATH}"
|
||||
cd "${PORT_SCRIPTS_PATH}" || fatal
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${PORT_SCRIPTS_PATH}/var"
|
||||
# HACK: Avoid inheriting a system scripts cwd in pressure-vessel.
|
||||
cd "${XDG_DATA_HOME:-$HOME}" || fatal "Data directory not found: ${XDG_DATA_HOME:-$HOME}"
|
||||
cd "${XDG_DATA_HOME:-$HOME}" || fatal
|
||||
|
||||
[[ ! -f "$VKBASALT_CONFIG_FILE" ]] && cp -f "${PORT_CONF_PATH}/vkBasalt.conf" "$VKBASALT_CONFIG_FILE"
|
||||
[[ ! -f "$DXVK_CONFIG_FILE" ]] && cp -f "${PORT_CONF_PATH}/dxvk.conf" "$DXVK_CONFIG_FILE"
|
||||
@@ -474,7 +472,7 @@ Usage examples:
|
||||
--winecmd)
|
||||
get_wine_and_pfx "$2" "$3"
|
||||
start_portproton
|
||||
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found"
|
||||
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal
|
||||
PW_USE_TERMINAL=1 pw_run cmd
|
||||
stop_portproton
|
||||
;;
|
||||
@@ -579,10 +577,6 @@ Usage examples:
|
||||
portwine_launch
|
||||
stop_portproton
|
||||
;;
|
||||
--epic-wrapper)
|
||||
portwine_launch "${@:3}"
|
||||
stop_portproton
|
||||
;;
|
||||
--stop)
|
||||
start_path="$(realpath "$0")"
|
||||
pgrep -f -- "${start_path}.*--autoinstall" | while read -r autoinstall_pid ; do
|
||||
|
||||
@@ -16,7 +16,6 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
|
||||
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
|
||||
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
|
||||
export PW_SPECIALK_VER="SK_26_5_15"
|
||||
export PW_EPIC_WRAPPER_VER="0.4"
|
||||
export PW_GALLIUM_NINE_VER="0.10"
|
||||
export DOTPFX_VER="48v9"
|
||||
export DEFPFX_VER="v1"
|
||||
@@ -40,7 +39,7 @@ check_variables PW_USE_NTSYNC "0"
|
||||
check_variables PW_USE_GAMEMODE "0"
|
||||
check_variables PW_USE_INHIBIT_SLEEP "1"
|
||||
check_variables PW_USE_TERMINAL "0"
|
||||
check_variables PW_WINDOWS_VER "11"
|
||||
check_variables PW_WINDOWS_VER "10"
|
||||
check_variables PW_USE_GSTREAMER "1"
|
||||
check_variables PW_USE_D3D_EXTRAS "1"
|
||||
check_variables PW_PREFIX_NAME "DEFAULT"
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
#include <SDL3/SDL.h>
|
||||
#include <stdbool.h>
|
||||
#include <stdint.h>
|
||||
#include <stdlib.h>
|
||||
|
||||
typedef struct {
|
||||
SDL_Gamepad *gamepad;
|
||||
SDL_JoystickID instance_id;
|
||||
int sdl_type;
|
||||
} PortProtonGamepad;
|
||||
|
||||
static PortProtonGamepad *open_gamepad(SDL_JoystickID instance_id)
|
||||
{
|
||||
SDL_Gamepad *gamepad = SDL_OpenGamepad(instance_id);
|
||||
if (gamepad == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
PortProtonGamepad *result = malloc(sizeof(*result));
|
||||
if (result == NULL) {
|
||||
SDL_CloseGamepad(gamepad);
|
||||
SDL_SetError("Failed to allocate gamepad handle");
|
||||
return NULL;
|
||||
}
|
||||
result->gamepad = gamepad;
|
||||
result->instance_id = instance_id;
|
||||
result->sdl_type = SDL_GetGamepadTypeForID(instance_id);
|
||||
return result;
|
||||
}
|
||||
|
||||
PortProtonGamepad *portproton_gamepad_find(void)
|
||||
{
|
||||
SDL_ClearError();
|
||||
if (!SDL_InitSubSystem(SDL_INIT_GAMEPAD)) {
|
||||
return NULL;
|
||||
}
|
||||
SDL_PumpEvents();
|
||||
SDL_UpdateGamepads();
|
||||
int count = 0;
|
||||
SDL_JoystickID *gamepads = SDL_GetGamepads(&count);
|
||||
if (gamepads == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
if (count == 0) {
|
||||
SDL_free(gamepads);
|
||||
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;
|
||||
}
|
||||
|
||||
const char *portproton_gamepad_get_error(void)
|
||||
{
|
||||
const char *error = SDL_GetError();
|
||||
return error != NULL ? error : "";
|
||||
}
|
||||
|
||||
void portproton_gamepad_close(PortProtonGamepad *gamepad)
|
||||
{
|
||||
if (gamepad == NULL) {
|
||||
return;
|
||||
}
|
||||
if (gamepad->gamepad != NULL) {
|
||||
SDL_CloseGamepad(gamepad->gamepad);
|
||||
}
|
||||
free(gamepad);
|
||||
}
|
||||
|
||||
bool portproton_gamepad_connected(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL && gamepad->gamepad != NULL &&
|
||||
SDL_GamepadConnected(gamepad->gamepad);
|
||||
}
|
||||
|
||||
void portproton_gamepad_update(void)
|
||||
{
|
||||
SDL_UpdateGamepads();
|
||||
}
|
||||
|
||||
int portproton_gamepad_get_button(const PortProtonGamepad *gamepad, int button)
|
||||
{
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return SDL_GetGamepadButton(gamepad->gamepad, button);
|
||||
}
|
||||
|
||||
int16_t portproton_gamepad_get_axis(const PortProtonGamepad *gamepad, int axis)
|
||||
{
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
return 0;
|
||||
}
|
||||
return SDL_GetGamepadAxis(gamepad->gamepad, axis);
|
||||
}
|
||||
|
||||
const char *portproton_gamepad_get_name(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
if (gamepad == NULL || gamepad->gamepad == NULL) {
|
||||
return "";
|
||||
}
|
||||
const char *name = SDL_GetGamepadName(gamepad->gamepad);
|
||||
return name != NULL ? name : "";
|
||||
}
|
||||
|
||||
uint32_t portproton_gamepad_get_instance_id(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL ? gamepad->instance_id : 0;
|
||||
}
|
||||
|
||||
int portproton_gamepad_get_type(const PortProtonGamepad *gamepad)
|
||||
{
|
||||
return gamepad != NULL ? gamepad->sdl_type : 0;
|
||||
}
|
||||
|
||||
void portproton_gamepad_shutdown(void)
|
||||
{
|
||||
SDL_QuitSubSystem(SDL_INIT_GAMEPAD);
|
||||
}
|
||||
@@ -1,21 +1,3 @@
|
||||
portprotonqt (1.4.1) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Wed, 26 Aug 2026 13:03:46 +0500
|
||||
|
||||
portprotonqt (1.4.0) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Sun, 23 Aug 2026 14:32:36 +0500
|
||||
|
||||
portprotonqt (1.3.2) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
-- Ivan Mazhukin <vanyamajukin@outlook.com> Mon, 10 Aug 2026 22:02:11 +0500
|
||||
|
||||
portprotonqt (1.3.1) unstable; urgency=medium
|
||||
|
||||
* New upstream release.
|
||||
|
||||
@@ -5,7 +5,6 @@ Maintainer: Ivan Mazhukin <vanyamajukin@outlook.com>
|
||||
Build-Depends:
|
||||
debhelper-compat (= 13),
|
||||
gettext,
|
||||
libsdl3-dev,
|
||||
libvulkan-dev,
|
||||
meson,
|
||||
ninja-build,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
GITHUB_REPOS=("Boria138/PortProtonQt" "linux-gaming-ru/PortProtonQt")
|
||||
GITHUB_REPO="Boria138/PortProtonQt"
|
||||
GITEA_REPO="Linux-Gaming/PortProtonQt"
|
||||
GITEA_URL="https://git.linux-gaming.ru"
|
||||
|
||||
@@ -10,10 +10,8 @@ jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat")
|
||||
echo "=== PortProtonQt Download Stats ==="
|
||||
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"
|
||||
done | jq -s 'add')
|
||||
echo "--- GitHub (${GITHUB_REPO}) ---"
|
||||
GH_RAW=$(curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=100")
|
||||
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
|
||||
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
|
||||
echo "Releases: $GH_RELEASES"
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run PortProtonQt against a fake AppImage update server."""
|
||||
|
||||
import argparse
|
||||
import http.server
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
APPIMAGE_MAGIC_OFFSET = 8
|
||||
APPIMAGE_TYPE_2_MAGIC = b"AI\x02"
|
||||
TEST_APPIMAGE_SIZE_BYTES = 128 * 1024 * 1024
|
||||
HTTP_COPY_BUFFER_SIZE = 1024 * 1024
|
||||
OLD_APPIMAGE_FILLER = b"OLD-APPIMAGE-BLOCK\n"
|
||||
UPDATED_APPIMAGE_FILLER = b"UPDATED-APPIMAGE-BLOCK\n"
|
||||
|
||||
|
||||
class UpdateHandler(http.server.BaseHTTPRequestHandler):
|
||||
"""Serve fake update metadata and the new AppImage."""
|
||||
|
||||
test_dir = Path()
|
||||
server_url = ""
|
||||
old_version = "0.1.12"
|
||||
new_version = "1.2.0"
|
||||
new_appimage_name = "PortProtonQt-1.2.0-x86_64.AppImage"
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
print(f"[server] {fmt % args}")
|
||||
|
||||
def do_GET(self):
|
||||
if self.path.endswith(".zsync"):
|
||||
self._send_zsync()
|
||||
elif self.path.startswith("/download/"):
|
||||
self._send_download()
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def _send_download(self) -> None:
|
||||
filename = self.path.split("/download/", 1)[1]
|
||||
path = self.test_dir / filename
|
||||
if not path.exists():
|
||||
self.send_error(404, f"Not found: {filename}")
|
||||
return
|
||||
self._send_file(path)
|
||||
|
||||
def _send_zsync(self) -> None:
|
||||
filename = self.path.rsplit("/", 1)[-1]
|
||||
path = self.test_dir / filename
|
||||
if not path.exists():
|
||||
self.send_error(404, f"Not found: {filename}")
|
||||
return
|
||||
self._send_file(path)
|
||||
|
||||
def _send_file(self, path: Path) -> None:
|
||||
file_size = path.stat().st_size
|
||||
byte_range = self._parse_range(file_size)
|
||||
start = 0
|
||||
end = file_size - 1
|
||||
if byte_range is not None:
|
||||
start, end = byte_range
|
||||
self.send_response(206)
|
||||
self.send_header("Content-Range", f"bytes {start}-{end}/{file_size}")
|
||||
else:
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/octet-stream")
|
||||
self.send_header("Content-Length", str(end - start + 1))
|
||||
self.end_headers()
|
||||
with open(path, "rb") as appimage_file:
|
||||
appimage_file.seek(start)
|
||||
remaining = end - start + 1
|
||||
while remaining > 0:
|
||||
chunk = appimage_file.read(min(HTTP_COPY_BUFFER_SIZE, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
self.wfile.write(chunk)
|
||||
remaining -= len(chunk)
|
||||
|
||||
def _parse_range(self, file_size: int) -> tuple[int, int] | None:
|
||||
range_header = self.headers.get("Range", "")
|
||||
if not range_header.startswith("bytes="):
|
||||
return None
|
||||
|
||||
range_spec = range_header.removeprefix("bytes=").split(",", 1)[0].strip()
|
||||
start_text, separator, end_text = range_spec.partition("-")
|
||||
if not separator:
|
||||
return None
|
||||
try:
|
||||
if not start_text:
|
||||
length = int(end_text)
|
||||
return max(file_size - length, 0), file_size - 1
|
||||
start = int(start_text)
|
||||
end = int(end_text) if end_text else file_size - 1
|
||||
except ValueError:
|
||||
return None
|
||||
if start < 0 or end < start or start >= file_size:
|
||||
return None
|
||||
return start, min(end, file_size - 1)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("old_version", nargs="?", default="0.1.12")
|
||||
parser.add_argument("new_version", nargs="?", default="1.2.0")
|
||||
parser.add_argument("--port", type=int, default=0)
|
||||
parser.add_argument("--keep", action="store_true")
|
||||
parser.add_argument("--version", default=None,
|
||||
help="Simulate current app version (e.g. --version 0.1.8)")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def write_appimage_base(path: Path) -> None:
|
||||
true_path = shutil.which("true") or "/usr/bin/true"
|
||||
shutil.copyfile(true_path, path)
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def update_info(server_url: str, new_appimage_name: str) -> str:
|
||||
return f"zsync|{server_url}/{new_appimage_name}.zsync"
|
||||
|
||||
|
||||
def finalize_appimage(path: Path, test_dir: Path, update_info_text: str, filler: bytes) -> None:
|
||||
update_info_file = test_dir / f"{path.name}.upd_info"
|
||||
update_info_file.write_text(update_info_text, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["objcopy", "--add-section", f".upd_info={update_info_file}", str(path)],
|
||||
check=True,
|
||||
)
|
||||
with open(path, "r+b") as appimage_file:
|
||||
appimage_file.seek(APPIMAGE_MAGIC_OFFSET)
|
||||
appimage_file.write(APPIMAGE_TYPE_2_MAGIC)
|
||||
appimage_file.seek(0, os.SEEK_END)
|
||||
while appimage_file.tell() < TEST_APPIMAGE_SIZE_BYTES:
|
||||
appimage_file.write(
|
||||
filler[:TEST_APPIMAGE_SIZE_BYTES - appimage_file.tell()]
|
||||
)
|
||||
path.chmod(0o755)
|
||||
|
||||
|
||||
def write_zsync(test_dir: Path, server_url: str, new_appimage_name: str) -> None:
|
||||
new_appimage = test_dir / new_appimage_name
|
||||
zsync_file = test_dir / f"{new_appimage_name}.zsync"
|
||||
download_url = f"{server_url}/download/{new_appimage_name}"
|
||||
subprocess.run(
|
||||
[
|
||||
"zsyncmake",
|
||||
"-e",
|
||||
"-u",
|
||||
download_url,
|
||||
"-o",
|
||||
str(zsync_file),
|
||||
"-f",
|
||||
new_appimage_name,
|
||||
str(new_appimage),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def start_server(args: argparse.Namespace, test_dir: Path, new_appimage_name: str):
|
||||
server = http.server.HTTPServer(("127.0.0.1", args.port), UpdateHandler)
|
||||
server_url = f"http://127.0.0.1:{server.server_port}"
|
||||
UpdateHandler.test_dir = test_dir
|
||||
UpdateHandler.server_url = server_url
|
||||
UpdateHandler.old_version = args.old_version
|
||||
UpdateHandler.new_version = args.new_version
|
||||
UpdateHandler.new_appimage_name = new_appimage_name
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
return server, server_url
|
||||
|
||||
|
||||
def python_command() -> str:
|
||||
venv_python = REPO_ROOT / ".venv" / "bin" / "python"
|
||||
if venv_python.exists():
|
||||
return str(venv_python)
|
||||
return sys.executable
|
||||
|
||||
|
||||
def run_gui(env: dict[str, str]) -> int:
|
||||
proc = subprocess.Popen(
|
||||
[python_command(), "-m", "portprotonqt.app"],
|
||||
cwd=str(REPO_ROOT),
|
||||
env=env,
|
||||
)
|
||||
return proc.wait()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
if args.version:
|
||||
args.old_version = args.version
|
||||
test_dir = Path(tempfile.mkdtemp(prefix="ppqt-appimage-update-"))
|
||||
old_appimage = test_dir / f"PortProtonQt-{args.old_version}-x86_64.AppImage"
|
||||
new_appimage_name = f"PortProtonQt-{args.new_version}-x86_64.AppImage"
|
||||
new_appimage = test_dir / new_appimage_name
|
||||
server = None
|
||||
|
||||
try:
|
||||
write_appimage_base(old_appimage)
|
||||
write_appimage_base(new_appimage)
|
||||
server, server_url = start_server(args, test_dir, new_appimage_name)
|
||||
update_info_text = update_info(server_url, new_appimage_name)
|
||||
finalize_appimage(
|
||||
old_appimage,
|
||||
test_dir,
|
||||
update_info_text,
|
||||
OLD_APPIMAGE_FILLER,
|
||||
)
|
||||
finalize_appimage(
|
||||
new_appimage,
|
||||
test_dir,
|
||||
update_info_text,
|
||||
UPDATED_APPIMAGE_FILLER,
|
||||
)
|
||||
write_zsync(test_dir, server_url, new_appimage_name)
|
||||
|
||||
env = os.environ.copy()
|
||||
env["APPIMAGE"] = str(old_appimage)
|
||||
env["PORTPROTONQT_VERSION"] = args.old_version
|
||||
|
||||
print("=== AppImage update GUI test ===")
|
||||
print(f"Test dir : {test_dir}")
|
||||
print(f"Old AppImage : {old_appimage}")
|
||||
print(f"New AppImage : {new_appimage}")
|
||||
print(f"AppImage size : {TEST_APPIMAGE_SIZE_BYTES // 1024 // 1024} MiB")
|
||||
print(f"HTTP server : {server_url}")
|
||||
print(f"Update info : {update_info_text}")
|
||||
print("")
|
||||
print("In GUI: wait for the update dialog, press Update, watch progress.")
|
||||
print("After success, old AppImage should be replaced by the new file.")
|
||||
print("")
|
||||
|
||||
raise SystemExit(run_gui(env))
|
||||
finally:
|
||||
if server is not None:
|
||||
server.shutdown()
|
||||
if args.keep:
|
||||
print(f"Keeping test dir: {test_dir}")
|
||||
else:
|
||||
shutil.rmtree(test_dir, ignore_errors=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -14,7 +14,6 @@
|
||||
- [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)
|
||||
@@ -235,36 +234,14 @@ NAV_BUTTON_STYLE = f"""
|
||||
You can control the game library card layout directly from the theme via `styles.py`:
|
||||
|
||||
```python
|
||||
# "grid", "list", "vertical", "horizontal", or "horizontal_top"
|
||||
# "grid" (default) or "list"
|
||||
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.
|
||||
|
||||
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`:
|
||||
Card geometry is controlled by `GAME_CARD_LIST` and `GAME_CARD_GRID`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
@@ -281,7 +258,6 @@ GAME_CARD_LIST = {
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
@@ -289,43 +265,12 @@ GAME_CARD_GRID = {
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"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,
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
```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.
|
||||
|
||||
@@ -507,53 +452,6 @@ 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,7 +14,6 @@
|
||||
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
|
||||
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
|
||||
- [Настройка фона](#настройка-фона)
|
||||
- [Фон библиотеки](#фон-библиотеки)
|
||||
- [Прелоадер](#прелоадер)
|
||||
- [Уголок источника (Лента)](#уголок-источника-лента)
|
||||
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
|
||||
@@ -235,36 +234,14 @@ NAV_BUTTON_STYLE = f"""
|
||||
Вы можете управлять типом отображения карточек библиотеки прямо из темы через `styles.py`:
|
||||
|
||||
```python
|
||||
# "grid", "list", "vertical", "horizontal" или "horizontal_top"
|
||||
# "grid" (по умолчанию) или "list"
|
||||
LIBRARY_LAYOUT_MODE = "grid"
|
||||
```
|
||||
|
||||
- `grid`: многоколоночная сетка карточек (классическое поведение).
|
||||
- `list`: горизонтальные карточки-строки (формат лаунчера).
|
||||
- `vertical`: полноширинная таблица с названием игры, последним запуском,
|
||||
временем в игре и библиотекой.
|
||||
- `horizontal`: одна горизонтально прокручиваемая лента со всеми играми,
|
||||
- `horizontal_top`: первые `horizontalTopGameLimit` игр из текущих результатов
|
||||
фильтрации и сортировки, затем плитка из следующих игр для открытия полной
|
||||
сетки.
|
||||
|
||||
Плитка перехода в режиме `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`:
|
||||
Геометрия карточек управляется через `GAME_CARD_LIST` и `GAME_CARD_GRID`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
@@ -281,7 +258,6 @@ GAME_CARD_LIST = {
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
@@ -289,17 +265,6 @@ GAME_CARD_GRID = {
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"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,
|
||||
}
|
||||
```
|
||||
|
||||
Значения задаются в логических пикселях. `cover_radius` применяется к
|
||||
@@ -307,26 +272,6 @@ GAME_CARD_HORIZONTAL = {
|
||||
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
|
||||
определяется настройкой размера карточек библиотеки.
|
||||
|
||||
Параметры анимации карточек можно отдельно переопределить в
|
||||
`GAME_CARD_LIST`, `GAME_CARD_GRID` или `GAME_CARD_HORIZONTAL`: тип анимации,
|
||||
ширину обводки, pulse, углы и цвета градиента, цвета и прозрачность
|
||||
fill/stripe/glow, масштаб, длительности и easing-кривые. Например,
|
||||
`horizontal_top` может задавать собственные тип `scale`, значения масштаба,
|
||||
длительность и easing, а полная сетка — отдельные параметры `gradient`.
|
||||
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`.
|
||||
|
||||
```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` как в сеточном, так и в списочном режиме.
|
||||
|
||||
@@ -507,54 +452,6 @@ 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` управляет стилем и анимацией индикатора загрузки. Если не определён, используется стандартный спиннер.
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
set -eu
|
||||
|
||||
REPOSITORY="Linux-Gaming/PortProtonQt"
|
||||
SERVER="https://git.linux-gaming.ru"
|
||||
GITHUB_REPOSITORY="linux-gaming-ru/PortProtonQt"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || {
|
||||
echo "PortProtonQt installer requires curl." >&2
|
||||
exit 1
|
||||
}
|
||||
command -v mktemp >/dev/null 2>&1 || {
|
||||
echo "PortProtonQt installer requires mktemp." >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
ARCH=$(uname -m)
|
||||
case "$ARCH" in
|
||||
amd64) ARCH="x86_64" ;;
|
||||
arm64) ARCH="aarch64" ;;
|
||||
esac
|
||||
|
||||
GITEA_API="${SERVER}/api/v1/repos/${REPOSITORY}/releases/latest"
|
||||
GITHUB_API="https://api.github.com/repos/${GITHUB_REPOSITORY}/releases/latest"
|
||||
APPIMAGE_PATH=$(mktemp "${TMPDIR:-/tmp}/portprotonqt.XXXXXX.AppImage")
|
||||
DOWNLOADED=false
|
||||
|
||||
trap 'rm -f "$APPIMAGE_PATH"' EXIT HUP INT TERM
|
||||
|
||||
for RELEASE_API in "$GITEA_API" "$GITHUB_API"; do
|
||||
APPIMAGE_URL=$(curl -fsSL "$RELEASE_API" |
|
||||
tr ',' '\n' |
|
||||
sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\.AppImage\)".*/\1/p' |
|
||||
grep -- "-${ARCH}\.AppImage$" |
|
||||
head -n 1 || true)
|
||||
if [ -z "$APPIMAGE_URL" ]; then
|
||||
continue
|
||||
fi
|
||||
echo "Downloading PortProtonQt AppImage from ${APPIMAGE_URL}..."
|
||||
if curl -fL --progress-bar "$APPIMAGE_URL" -o "$APPIMAGE_PATH"; then
|
||||
DOWNLOADED=true
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$DOWNLOADED" != true ]; then
|
||||
echo "Failed to download PortProtonQt AppImage for ${ARCH}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
chmod 755 "$APPIMAGE_PATH"
|
||||
|
||||
if ! PORTPROTONQT_INTEGRATE_APPIMAGE=1 "$APPIMAGE_PATH"; then
|
||||
echo "Failed to integrate PortProtonQt AppImage." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "PortProtonQt was installed and integrated."
|
||||
@@ -1,5 +1,5 @@
|
||||
project('portprotonqt', 'c',
|
||||
version: '1.4.1',
|
||||
version: '1.3.1',
|
||||
meson_version: '>= 0.61.2',
|
||||
license: 'GPL-3.0',
|
||||
)
|
||||
@@ -13,15 +13,15 @@ conf = configuration_data()
|
||||
conf.set('PYTHON', python3.full_path())
|
||||
|
||||
version_conf = configuration_data()
|
||||
version_conf.set('APP_VERSION', meson.project_version())
|
||||
version_conf.set('APP_COMMIT', '')
|
||||
version_conf.set_quoted('APP_VERSION', meson.project_version())
|
||||
version_conf.set_quoted('APP_COMMIT', '')
|
||||
|
||||
if get_option('nightly')
|
||||
git = find_program('git', required: false)
|
||||
if git.found()
|
||||
git_commit = run_command(git, 'rev-parse', '--short', 'HEAD', check: false)
|
||||
if git_commit.returncode() == 0
|
||||
version_conf.set('APP_COMMIT', git_commit.stdout().strip())
|
||||
version_conf.set_quoted('APP_COMMIT', git_commit.stdout().strip())
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
@@ -36,10 +36,19 @@ purelibdir_opt = get_option('python_purelibdir')
|
||||
pythondir = purelibdir_opt != '' ? purelibdir_opt: python3.get_install_dir(pure: true)
|
||||
pkgdatadir = pythondir / meson.project_name()
|
||||
|
||||
# Native C build dependencies and sources
|
||||
# C compiler for vk_gpu_info
|
||||
cc = meson.get_compiler('c')
|
||||
sdl3_dep = dependency('sdl3', required: true)
|
||||
gamepad_sources = files('build-aux/src/gamepad_backend.c')
|
||||
|
||||
configured_version = configure_file(
|
||||
input: 'portprotonqt/_version.py.in',
|
||||
output: '_version.py',
|
||||
configuration: version_conf,
|
||||
)
|
||||
|
||||
install_data(
|
||||
configured_version,
|
||||
install_dir: pythondir / meson.project_name(),
|
||||
)
|
||||
|
||||
# Install Python package
|
||||
subdir('portprotonqt')
|
||||
@@ -58,7 +67,7 @@ install_data(configured_portprotonqt,
|
||||
)
|
||||
|
||||
# Build and install vk_gpu_info executable
|
||||
vk_gpu_info_sources = files('build-aux/src/vk_gpu_info.c')
|
||||
vk_gpu_info_sources = files('build-aux/vk_gpu_info.c')
|
||||
vk_gpu_info_deps = [dependency('vulkan', required: true)]
|
||||
|
||||
executable('vk_gpu_info',
|
||||
@@ -111,6 +120,11 @@ install_subdir(
|
||||
install_dir: datadir / 'portproton',
|
||||
)
|
||||
|
||||
install_subdir(
|
||||
'build-aux/share/portproton/img',
|
||||
install_dir: datadir / 'portproton',
|
||||
)
|
||||
|
||||
install_subdir(
|
||||
'build-aux/share/portproton/scripts/portwine_db',
|
||||
install_dir: datadir / 'portproton' / 'scripts',
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
APP_VERSION = @APP_VERSION@
|
||||
APP_COMMIT = @APP_COMMIT@
|
||||
@@ -30,16 +30,7 @@ class DetailBackgroundAnimations:
|
||||
if effect not in self.effects:
|
||||
self.remove(page)
|
||||
return False
|
||||
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
|
||||
config = theme.DETAIL_PAGE_BACKGROUNDS
|
||||
self.remove(page)
|
||||
state = {
|
||||
"effect": effect,
|
||||
@@ -56,7 +47,7 @@ class DetailBackgroundAnimations:
|
||||
self._paint(page, state)
|
||||
|
||||
page.paintEvent = paint_event
|
||||
if animated:
|
||||
if not mode.startswith("static_"):
|
||||
self._start_timer(page, state)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from math import radians, sin
|
||||
from typing import Any
|
||||
import warnings
|
||||
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
|
||||
@@ -23,25 +22,6 @@ 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",
|
||||
self.theme.GAME_CARD_ANIMATION.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
|
||||
@@ -69,20 +49,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._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.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
def _easing_curve(self, easing_key: str) -> QEasingCurve:
|
||||
easing_type = QEasingCurve.Type[self._config_value(easing_key)]
|
||||
easing_type = QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION[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._config_value("scale_anim_duration"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["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)
|
||||
@@ -109,47 +89,18 @@ 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._config_value("thickness_anim_duration"))
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
|
||||
animation_type = self._animation_type()
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self._config_value("gradient_anim_duration"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["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._config_value("scale_anim_duration"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
|
||||
def start_pulse_animation(self):
|
||||
"""Start pulse animation for border width when hovered or focused."""
|
||||
@@ -159,11 +110,11 @@ class GameCardAnimations:
|
||||
self._stop_animation("pulse_anim")
|
||||
|
||||
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(self._config_value("pulse_anim_duration"))
|
||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
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.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.start()
|
||||
|
||||
def handle_enter_event(self):
|
||||
@@ -175,10 +126,10 @@ class GameCardAnimations:
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self._animation_type()
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
|
||||
self._start_thickness_animation(
|
||||
self._config_value("hover_border_width"),
|
||||
self.theme.GAME_CARD_ANIMATION["hover_border_width"],
|
||||
"thickness_easing_curve",
|
||||
True,
|
||||
)
|
||||
@@ -186,21 +137,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._config_value("hover_scale"), "scale_easing_curve")
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["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._animation_type()
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._stop_animation("gradient_anim")
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
|
||||
self._stop_animation("pulse_anim")
|
||||
self._start_thickness_animation(
|
||||
self._config_value("default_border_width"),
|
||||
self.theme.GAME_CARD_ANIMATION["default_border_width"],
|
||||
"thickness_easing_curve_out",
|
||||
False,
|
||||
)
|
||||
@@ -214,10 +165,10 @@ class GameCardAnimations:
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self._animation_type()
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
|
||||
self._start_thickness_animation(
|
||||
self._config_value("focus_border_width"),
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"],
|
||||
"thickness_easing_curve",
|
||||
True,
|
||||
)
|
||||
@@ -225,21 +176,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._config_value("focus_scale"), "scale_easing_curve")
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["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._animation_type()
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._stop_animation("gradient_anim")
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
|
||||
self._stop_animation("pulse_anim")
|
||||
self._start_thickness_animation(
|
||||
self._config_value("default_border_width"),
|
||||
self.theme.GAME_CARD_ANIMATION["default_border_width"],
|
||||
"thickness_easing_curve_out",
|
||||
False,
|
||||
)
|
||||
@@ -252,39 +203,39 @@ class GameCardAnimations:
|
||||
pen = QPen()
|
||||
pen.setWidth(self.game_card._borderWidth)
|
||||
fill_brush = QBrush(Qt.BrushStyle.NoBrush)
|
||||
animation_type = self._animation_type()
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
|
||||
center = self.game_card.rect().center()
|
||||
gradient = QConicalGradient(center, self.game_card._gradientAngle)
|
||||
for stop in self._config_value("gradient_colors"):
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["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._optional_config_value(
|
||||
fill_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
"fill_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
fill_alpha = int(self._optional_config_value("fill_alpha", 90))
|
||||
fill_alpha = int(self.theme.GAME_CARD_ANIMATION.get("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._optional_config_value(
|
||||
stripe_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
"stripe_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
stripe_alpha = int(self._optional_config_value("stripe_alpha", 255))
|
||||
stripe_alpha = int(self.theme.GAME_CARD_ANIMATION.get("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._optional_config_value(
|
||||
glow_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
"stripe_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
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_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_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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
import importlib
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.error
|
||||
@@ -11,6 +12,16 @@ from logging import Logger
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
|
||||
try:
|
||||
version_module = importlib.import_module("portprotonqt._version")
|
||||
APP_COMMIT = version_module.APP_COMMIT
|
||||
APP_VERSION = version_module.APP_VERSION
|
||||
except ImportError:
|
||||
APP_COMMIT = ""
|
||||
APP_VERSION = "1.3.1"
|
||||
|
||||
__app_version__ = os.environ.get("PORTPROTONQT_VERSION", APP_VERSION)
|
||||
|
||||
from PySide6.QtCore import QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
@@ -49,20 +60,6 @@ from portprotonqt.cli import (
|
||||
)
|
||||
from portprotonqt.localization import _, get_steam_language
|
||||
|
||||
APP_VERSION = "@APP_VERSION@"
|
||||
APP_COMMIT = "@APP_COMMIT@"
|
||||
if APP_VERSION == "@APP" "_VERSION@":
|
||||
APP_VERSION = "1.4.1"
|
||||
try:
|
||||
APP_COMMIT = subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode("utf-8").strip()
|
||||
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
|
||||
APP_COMMIT = ""
|
||||
|
||||
__app_version__ = APP_VERSION
|
||||
|
||||
COUNTER_DOWNLOAD_URL = "http://cloud.linux-gaming.ru:8081/api/download/{version}"
|
||||
|
||||
def get_version():
|
||||
@@ -201,24 +198,6 @@ def is_restore_prefix_request(args: argparse.Namespace) -> bool:
|
||||
def main():
|
||||
parsed_args = parse_args()
|
||||
|
||||
if os.environ.get("PORTPROTONQT_INTEGRATE_APPIMAGE") == "1":
|
||||
from portprotonqt.appimage_integration import integrate_appimage
|
||||
|
||||
setup_logger(parsed_args.debug_level)
|
||||
try:
|
||||
destination = integrate_appimage()
|
||||
restart_env = os.environ.copy()
|
||||
restart_env.pop("PORTPROTONQT_INTEGRATE_APPIMAGE", None)
|
||||
subprocess.Popen(
|
||||
[str(destination), *sys.argv[1:]],
|
||||
env=restart_env,
|
||||
start_new_session=True,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, KeyError) as error:
|
||||
get_logger(__name__).error("Failed to integrate AppImage: %s", error)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
# Handle --reinstall-steam-compat-tool flag
|
||||
if parsed_args.reinstall_steam_compat_tool:
|
||||
success = reinstall_steam_compat_tool()
|
||||
@@ -312,7 +291,6 @@ 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)
|
||||
@@ -326,11 +304,14 @@ 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 = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
launch_action = "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)
|
||||
@@ -339,18 +320,7 @@ def main():
|
||||
app_id = args.file_or_url.removeprefix("gog://launch/")
|
||||
if app_id.isdigit():
|
||||
gog_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"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}"
|
||||
ipc_message = f"gog:{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:
|
||||
@@ -358,13 +328,11 @@ def main():
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
from requests import RequestException
|
||||
from portprotonqt.appimage_updater import (
|
||||
APPIMAGE_UPDATE_START_DELAY_MS,
|
||||
AppImageUpdateWorker,
|
||||
)
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.port_data_path_selector import ask_portdata_path, is_portdata_path_read_write
|
||||
from portprotonqt.portproton_api import (
|
||||
PortProtonAPI,
|
||||
@@ -419,7 +387,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 and not egs_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_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)
|
||||
@@ -503,59 +471,13 @@ 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:
|
||||
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() -> None:
|
||||
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
|
||||
def handle_launch_exe():
|
||||
window.handle_launch_exe(exe_path, log_mode=args.log)
|
||||
QTimer.singleShot(0, handle_launch_exe)
|
||||
elif autoinstall_path:
|
||||
def handle_autoinstall():
|
||||
@@ -570,13 +492,9 @@ def main():
|
||||
window._perform_restore(restore_prefix_path)
|
||||
QTimer.singleShot(0, handle_restore_prefix)
|
||||
elif gog_launch_uri:
|
||||
def handle_gog_launch() -> None:
|
||||
handle_game_request(gog_launch_uri, is_silent_launch, args.log)
|
||||
def handle_gog_launch():
|
||||
window.toggleGame(gog_launch_uri)
|
||||
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():
|
||||
@@ -595,24 +513,20 @@ 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:")
|
||||
):
|
||||
if not msg.startswith("silent:"):
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
# Ensure the window is visible and not minimized
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
|
||||
# Ensure active state for strict focus policies
|
||||
window.setWindowState(
|
||||
window.windowState() | Qt.WindowState.WindowActive
|
||||
)
|
||||
# Ensure window is in active state for systems with strict focus policies
|
||||
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
|
||||
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
@@ -624,17 +538,12 @@ def main():
|
||||
display_config.set_fullscreen(False)
|
||||
window.showNormal()
|
||||
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
if msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
launch_path = msg.split(":", 1)[1].strip()
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
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)
|
||||
window.handle_launch_exe(launch_path, log_mode=log_mode)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -647,15 +556,9 @@ def main():
|
||||
elif msg.startswith("gog:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id.isdigit():
|
||||
handle_game_request(f"gog://launch/{app_id}", False)
|
||||
window.toggleGame(f"gog://launch/{app_id}")
|
||||
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:
|
||||
@@ -698,20 +601,16 @@ def main():
|
||||
and getattr(window.input_manager, "gamepad", None) is not None
|
||||
)
|
||||
launch_minimized = (
|
||||
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
|
||||
)
|
||||
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
|
||||
)
|
||||
if launch_minimized:
|
||||
logger.info("Launching in tray")
|
||||
@@ -776,27 +675,6 @@ def main():
|
||||
window.appimageUpdateWorker.finished.connect(window.appimageUpdateWorker.deleteLater)
|
||||
QTimer.singleShot(APPIMAGE_UPDATE_START_DELAY_MS, window.appimageUpdateWorker.start)
|
||||
|
||||
class GOGDLUpdateWorker(QThread):
|
||||
"""Update gogdl without blocking the UI thread."""
|
||||
|
||||
def __init__(self, api: GOGAPI) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.api.update_gogdl()
|
||||
except (OSError, RequestException):
|
||||
logger.exception("Failed to update gogdl")
|
||||
|
||||
gogdl_update_worker = GOGDLUpdateWorker(window.gog_api)
|
||||
window.gogdlUpdateWorker = gogdl_update_worker
|
||||
gogdl_update_worker.finished.connect(
|
||||
lambda: setattr(window, "gogdlUpdateWorker", None)
|
||||
)
|
||||
gogdl_update_worker.finished.connect(gogdl_update_worker.deleteLater)
|
||||
gogdl_update_worker.start()
|
||||
|
||||
# Execute the initial PortProton command after the UI is set up
|
||||
class InitialCommandWorker(QThread):
|
||||
"""Worker thread to run initial PortProton command without blocking UI."""
|
||||
|
||||
@@ -6,21 +6,19 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QStandardPaths, QThread, Signal
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from portprotonqt.config import get_portproton_location, migrate_legacy_shortcut
|
||||
from portprotonqt.localization import _
|
||||
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-ms-dos-exec;application/x-executable;"
|
||||
"application/x-dosexec;application/vnd.microsoft.portable-executable;"
|
||||
)
|
||||
INTEGRATION_MODES = ("log", "silent")
|
||||
@@ -55,10 +53,10 @@ def _remove_existing_integration(applications_dir: Path, icon_dir: Path) -> None
|
||||
(icon_dir / f"{APP_ID}{suffix}").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _rewrite_main_desktop(content: str, appimage: Path) -> str:
|
||||
def _rewrite_main_desktop(content: str, appimage: Path, icon: Path) -> str:
|
||||
exec_line = f"Exec={shlex.join([str(appimage), '%u'])}"
|
||||
content = re.sub(r"^Exec=.*$", exec_line, content, count=1, flags=re.MULTILINE)
|
||||
content = re.sub(r"^Icon=.*$", f"Icon={APP_ID}", content, flags=re.MULTILINE)
|
||||
content = re.sub(r"^Icon=.*$", f"Icon={icon}", content, flags=re.MULTILINE)
|
||||
if re.search(r"^TryExec=", content, flags=re.MULTILINE):
|
||||
return re.sub(
|
||||
r"^TryExec=.*$",
|
||||
@@ -70,7 +68,7 @@ def _rewrite_main_desktop(content: str, appimage: Path) -> str:
|
||||
return content.replace("Type=Application\n", f"Type=Application\nTryExec={appimage}\n", 1)
|
||||
|
||||
|
||||
def _mode_desktop(mode: str, appimage: Path) -> str:
|
||||
def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
|
||||
action = _("Run in logging mode") if mode == "log" else _("Run in silent mode")
|
||||
exec_line = shlex.join([str(appimage), f"--{mode}", "%u"])
|
||||
return (
|
||||
@@ -81,7 +79,7 @@ def _mode_desktop(mode: str, appimage: Path) -> str:
|
||||
"Type=Application\n"
|
||||
f"Comment={action}\n"
|
||||
"Terminal=false\n"
|
||||
f"Icon={APP_ID}\n"
|
||||
f"Icon={icon}\n"
|
||||
f"StartupWMClass={APP_ID}\n"
|
||||
"Categories=Game;\n"
|
||||
"NoDisplay=true\n"
|
||||
@@ -94,21 +92,17 @@ def _install_desktop_files(
|
||||
source: Path,
|
||||
applications_dir: Path,
|
||||
appimage: Path,
|
||||
icon: Path,
|
||||
) -> None:
|
||||
content = source.read_text(encoding="utf-8")
|
||||
rewritten_content = _rewrite_main_desktop(content, appimage)
|
||||
main_path = applications_dir / f"{APP_ID}.desktop"
|
||||
main_path.write_text(rewritten_content, encoding="utf-8")
|
||||
desktop_dir = Path(
|
||||
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
main_path.write_text(
|
||||
_rewrite_main_desktop(content, appimage, icon),
|
||||
encoding="utf-8",
|
||||
)
|
||||
desktop_dir.mkdir(parents=True, exist_ok=True)
|
||||
desktop_path = desktop_dir / f"{APP_ID}.desktop"
|
||||
desktop_path.write_text(rewritten_content, encoding="utf-8")
|
||||
desktop_path.chmod(EXECUTABLE_MODE)
|
||||
for mode in INTEGRATION_MODES:
|
||||
path = applications_dir / f"{APP_ID}.{mode}.desktop"
|
||||
path.write_text(_mode_desktop(mode, appimage), encoding="utf-8")
|
||||
path.write_text(_mode_desktop(mode, appimage, icon), encoding="utf-8")
|
||||
|
||||
|
||||
def _set_default_mime_handlers() -> None:
|
||||
@@ -136,31 +130,19 @@ def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
|
||||
f"share/applications/{APP_ID}.desktop",
|
||||
),
|
||||
)
|
||||
icon = appdir / f"{APP_ID}.svg"
|
||||
if not desktop or not icon.is_file():
|
||||
raise FileNotFoundError("AppImage desktop metadata is incomplete")
|
||||
return desktop, icon
|
||||
|
||||
|
||||
def _install_mime_package(appdir: Path, data_home: Path) -> None:
|
||||
source = _find_appimage_asset(
|
||||
icon = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
f"share/mime/packages/{MIME_PACKAGE_NAME}",
|
||||
f"usr/share/mime/packages/{MIME_PACKAGE_NAME}",
|
||||
".DirIcon",
|
||||
f"{APP_ID}.svg",
|
||||
f"{APP_ID}.png",
|
||||
f"usr/share/icons/hicolor/scalable/apps/{APP_ID}.svg",
|
||||
f"share/icons/hicolor/scalable/apps/{APP_ID}.svg",
|
||||
),
|
||||
)
|
||||
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,
|
||||
)
|
||||
if not desktop or not icon:
|
||||
raise FileNotFoundError("AppImage desktop metadata is incomplete")
|
||||
return desktop, icon
|
||||
|
||||
|
||||
def integrate_appimage() -> Path:
|
||||
@@ -174,26 +156,18 @@ def integrate_appimage() -> Path:
|
||||
appimages_dir = Path.home() / "AppImages"
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
applications_dir = data_home / "applications"
|
||||
icon_dir = data_home / "icons"
|
||||
icon_dir = appimages_dir / ".icons"
|
||||
appimages_dir.mkdir(parents=True, exist_ok=True)
|
||||
applications_dir.mkdir(parents=True, exist_ok=True)
|
||||
icon_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
destination = appimages_dir / "portprotonqt.appimage"
|
||||
resolved_icon = icon_source.resolve()
|
||||
icon = icon_dir / f"{APP_ID}.svg"
|
||||
_copy_appimage(source, destination)
|
||||
legacy_appimage = Path.home() / "Applications/PortProtonQt.AppImage"
|
||||
if legacy_appimage.resolve() != destination.resolve():
|
||||
portproton_location = get_portproton_location()
|
||||
migrate_legacy_shortcut(
|
||||
portproton_location or "",
|
||||
launcher_command=[str(destination), "--silent"],
|
||||
)
|
||||
icon = icon_dir / f"{APP_ID}{resolved_icon.suffix or '.svg'}"
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
_copy_appimage(source, destination)
|
||||
shutil.copy2(resolved_icon, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination)
|
||||
_install_mime_package(appdir, data_home)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination, icon)
|
||||
_set_default_mime_handlers()
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -203,8 +177,6 @@ def integrate_appimage() -> Path:
|
||||
)
|
||||
except (FileNotFoundError, subprocess.SubprocessError) as error:
|
||||
logger.warning("Failed to update desktop database: %s", error)
|
||||
if legacy_appimage.resolve() != destination.resolve():
|
||||
legacy_appimage.unlink(missing_ok=True)
|
||||
return destination
|
||||
|
||||
|
||||
|
||||
@@ -28,15 +28,15 @@ APPIMAGE_UPDATE_CHECK_TIMEOUT = 120
|
||||
APPIMAGE_UPDATE_TIMEOUT = 900
|
||||
APPIMAGE_UPDATE_START_DELAY_MS = 5000
|
||||
EXECUTABLE_MODE = 0o755
|
||||
GITHUB_UPDATE_INFO = (
|
||||
"gh-releases-zsync|linux-gaming-ru|PortProtonQt|latest|*x86_64.AppImage.zsync"
|
||||
FALLBACK_UPDATE_INFO = (
|
||||
"gh-releases-zsync|Boria138|PortProtonQt|latest|*x86_64.AppImage.zsync"
|
||||
)
|
||||
CHANGELOG_URL = (
|
||||
"https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/"
|
||||
"CHANGELOG.md"
|
||||
)
|
||||
CHANGELOG_GITHUB_URL = (
|
||||
"https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/refs/heads/main/"
|
||||
CHANGELOG_FALLBACK_URL = (
|
||||
"https://raw.githubusercontent.com/Boria138/PortProtonQt/refs/heads/main/"
|
||||
"CHANGELOG.md"
|
||||
)
|
||||
CHANGELOG_TIMEOUT = 10
|
||||
@@ -129,22 +129,16 @@ def _run_appimageupdatetool(
|
||||
|
||||
|
||||
def _download_changelog() -> str:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
session = get_requests_session()
|
||||
url = (
|
||||
CHANGELOG_URL
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD"
|
||||
else CHANGELOG_GITHUB_URL
|
||||
)
|
||||
try:
|
||||
with session.get(url, timeout=CHANGELOG_TIMEOUT) as response:
|
||||
response.raise_for_status()
|
||||
data = response.content[:CHANGELOG_MAX_CHARS]
|
||||
return data.decode("utf-8", errors="replace")
|
||||
except (OSError, requests.RequestException) as error:
|
||||
logger.debug("Failed to download changelog from %s: %s", url, error)
|
||||
return ""
|
||||
for url in (CHANGELOG_URL, CHANGELOG_FALLBACK_URL):
|
||||
try:
|
||||
with session.get(url, timeout=CHANGELOG_TIMEOUT) as response:
|
||||
response.raise_for_status()
|
||||
data = response.content[:CHANGELOG_MAX_CHARS]
|
||||
return data.decode("utf-8", errors="replace")
|
||||
except (OSError, requests.RequestException) as error:
|
||||
logger.debug("Failed to download changelog from %s: %s", url, error)
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_latest_version_changelog(changelog: str, current_version: str = "") -> str:
|
||||
@@ -312,9 +306,15 @@ class AppImageUpdateWorker(QThread):
|
||||
self.update_info = previous_info
|
||||
return result
|
||||
|
||||
def run(self) -> None:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
def _is_check_failure(self, result: subprocess.CompletedProcess[str] | None) -> bool:
|
||||
if result is None:
|
||||
return True
|
||||
if result.returncode not in (0, 1):
|
||||
return True
|
||||
output = f"{result.stdout}\n{result.stderr}"
|
||||
return " Error:" in output or "Failed to fetch" in output
|
||||
|
||||
def run(self) -> None:
|
||||
appimage_path = _appimage_path()
|
||||
if not ui_config.get_auto_appimage_updates() or not appimage_path:
|
||||
return
|
||||
@@ -330,10 +330,14 @@ class AppImageUpdateWorker(QThread):
|
||||
self._run_update(tool_path, appimage_path)
|
||||
return
|
||||
|
||||
selected_info = (
|
||||
"" if get_user_conf_setting("MIRROR") == "CLOUD" else GITHUB_UPDATE_INFO
|
||||
)
|
||||
check_code = self._check_for_update(tool_path, appimage_path, selected_info)
|
||||
update_infos = ["", FALLBACK_UPDATE_INFO]
|
||||
check_code = None
|
||||
selected_info = ""
|
||||
for update_info in update_infos:
|
||||
check_code = self._check_for_update(tool_path, appimage_path, update_info)
|
||||
if not self._is_check_failure(check_code):
|
||||
selected_info = update_info
|
||||
break
|
||||
|
||||
if check_code is None or check_code.returncode != 1:
|
||||
return
|
||||
|
||||
@@ -28,7 +28,7 @@ from portprotonqt.scripts_utils.graphics_detector import analyze_executable
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CRASH_THRESHOLD_SECONDS = 7
|
||||
CRASH_THRESHOLD_SECONDS = 5
|
||||
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
|
||||
PE_MACHINE_AMD64 = 0x8664
|
||||
COMPATIBILITY_SCAN_BYTES = 4_194_304
|
||||
@@ -428,10 +428,6 @@ def _compatibility_suggestions(
|
||||
needs_dotnet = needs_dotnet or "WPF" in findings.get("Frameworks", [])
|
||||
if needs_dotnet and prefix_name != "DOTNET":
|
||||
suggestions.append("Use the DOTNET prefix.")
|
||||
installers = findings.get("Installer", [])
|
||||
wine_version = environment.get("PW_WINE_USE", "")
|
||||
if installers and not wine_version.startswith("WINE_LG"):
|
||||
suggestions.append("Use WINE_LG for installers.")
|
||||
winetricks_log = environment.get("PW_WINETRICKS_LOG", "").lower()
|
||||
missing_vcrun = [
|
||||
components[-1]
|
||||
@@ -504,6 +500,8 @@ def analyze_launch(launch: CompatibilityLaunch, portproton_path: str) -> str:
|
||||
glibc_32_compatibility, glibc_32_suggestions = _glibc_32_compatibility()
|
||||
suggestions.extend(glibc_32_suggestions)
|
||||
lines = [
|
||||
"Compatibility report",
|
||||
"",
|
||||
f"Executable: {launch.executable}",
|
||||
f"Exit code: {launch.exit_code if launch.exit_code is not None else 'unknown'}",
|
||||
f"Runtime before exit: {launch.duration:.1f} s",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Game-related configuration settings."""
|
||||
from portprotonqt.config.base import BaseConfig
|
||||
from portprotonqt.config.validators import validate_bool, validate_string
|
||||
from portprotonqt.config.validators import validate_string
|
||||
|
||||
|
||||
class GameConfig(BaseConfig):
|
||||
@@ -26,15 +26,6 @@ class GameConfig(BaseConfig):
|
||||
validate_string(filter_value, "display_filter", min_len=1, max_len=50)
|
||||
self._save_value("display_filter", filter_value, "str")
|
||||
|
||||
def get_only_installed(self) -> bool:
|
||||
"""Get whether only installed games are displayed."""
|
||||
return self._get_bool("only_installed", False)
|
||||
|
||||
def set_only_installed(self, enabled: bool) -> None:
|
||||
"""Set whether only installed games are displayed."""
|
||||
validate_bool(enabled, "only_installed")
|
||||
self._save_value("only_installed", enabled, "bool")
|
||||
|
||||
def get_steam_account_id(self) -> str:
|
||||
"""Get selected Steam account ID or automatic selection."""
|
||||
return self._get_str("steam_account_id", "auto")
|
||||
|
||||
@@ -223,16 +223,7 @@ def _extract_launcher_tail(parts: list[str]) -> list[str] | None:
|
||||
return None
|
||||
|
||||
command_name = os.path.basename(parts[0])
|
||||
appimage_name = command_name.casefold()
|
||||
is_portproton_appimage = re.fullmatch(
|
||||
r"portproton(?:qt)?(?:[-_.].*)?\.appimage",
|
||||
appimage_name,
|
||||
) is not None
|
||||
if (
|
||||
is_portproton_appimage
|
||||
or command_name == "portprotonqt"
|
||||
or command_name == "start.sh"
|
||||
):
|
||||
if parts[0].lower().endswith(".appimage") or command_name == "portprotonqt" or command_name == "start.sh":
|
||||
tail = parts[1:]
|
||||
if tail[:1] == ["--silent"]:
|
||||
tail = tail[1:]
|
||||
@@ -283,11 +274,7 @@ def _get_desktop_paths(desktop_dir: str | None) -> tuple[str, ...]:
|
||||
return (os.path.join(os.path.expanduser("~"), "Desktop"),)
|
||||
|
||||
|
||||
def migrate_legacy_shortcut(
|
||||
portproton_path: str,
|
||||
desktop_dir: str | None = None,
|
||||
launcher_command: list[str] | None = None,
|
||||
) -> int:
|
||||
def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None) -> int:
|
||||
"""Migrate legacy PortProton shortcuts in known desktop directories."""
|
||||
flatpak_id = os.getenv("FLATPAK_ID", "").strip()
|
||||
user_home = os.path.expanduser("~")
|
||||
@@ -306,8 +293,7 @@ def migrate_legacy_shortcut(
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read legacy PortProton config %s: %s", PORTPROTON_CONFIG_FILE, error)
|
||||
|
||||
if launcher_command is None:
|
||||
launcher_command = _get_current_launcher_command()
|
||||
launcher_command = _get_current_launcher_command()
|
||||
desktop_paths = (
|
||||
portproton_path,
|
||||
os.path.join(user_home, ".local", "share", "applications"),
|
||||
@@ -546,8 +532,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_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):
|
||||
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):
|
||||
logger.error("Executable not found: %s", exe_path)
|
||||
return None
|
||||
if not portproton_path:
|
||||
@@ -560,7 +546,7 @@ def create_desktop_file(
|
||||
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_store_uri and not has_icon_source
|
||||
"applications-games" if is_gog_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")
|
||||
|
||||
@@ -348,31 +348,6 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(hide, "hide_autoinstall_tab")
|
||||
self._save_value("hide_autoinstall_tab", hide, "bool")
|
||||
|
||||
def get_hide_control_hints(self) -> bool:
|
||||
"""Get hide control hints setting."""
|
||||
return self._get_bool("hide_control_hints", False)
|
||||
|
||||
def set_hide_control_hints(self, hide: bool) -> None:
|
||||
"""Set hide control hints setting."""
|
||||
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")
|
||||
@@ -446,7 +421,7 @@ class UIConfig(BaseConfig):
|
||||
|
||||
def get_auto_download_ppdb(self) -> bool:
|
||||
"""Get PPDB auto-download setting."""
|
||||
return self._get_download_bool("auto_download_ppdb", False)
|
||||
return self._get_download_bool("auto_download_ppdb", True)
|
||||
|
||||
def set_auto_download_ppdb(self, enabled: bool) -> None:
|
||||
"""Set PPDB auto-download setting."""
|
||||
|
||||
@@ -456,7 +456,6 @@ class ContextMenuManager:
|
||||
menu = QMenu(self.parent)
|
||||
menu.setWindowFlags(menu.windowFlags() | Qt.WindowType.Popup | Qt.WindowType.FramelessWindowHint)
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
menu.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
# For non-Steam games, check if exe exists
|
||||
@@ -467,16 +466,16 @@ class ContextMenuManager:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
# Normal menu for games with valid exe or from Steam
|
||||
is_uninstalled_store_game = (
|
||||
game_card.game_source in ("gog", "egs")
|
||||
and game_card.exec_line.startswith(("gog://install/", "egs://install/"))
|
||||
is_uninstalled_gog = (
|
||||
game_card.game_source == "gog"
|
||||
and game_card.exec_line.startswith("gog://install/")
|
||||
)
|
||||
is_running = self._is_game_running(game_card)
|
||||
if not is_uninstalled_store_game:
|
||||
if not is_uninstalled_gog:
|
||||
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)
|
||||
@@ -545,69 +544,6 @@ class ContextMenuManager:
|
||||
lambda: self.parent._import_gog_game(game)
|
||||
)
|
||||
|
||||
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)
|
||||
)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
@@ -696,7 +632,7 @@ class ContextMenuManager:
|
||||
if actions:
|
||||
menu.setActiveAction(actions[0])
|
||||
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
@@ -961,14 +897,12 @@ class ContextMenuManager:
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
from portprotonqt.time_utils import get_statistics_path, remove_last_launch
|
||||
from portprotonqt.time_utils import get_statistics_path
|
||||
statistics_file = get_statistics_path()
|
||||
target_path = os.path.normpath(exe_path)
|
||||
exe_name = os.path.splitext(os.path.basename(target_path))[0]
|
||||
remove_last_launch(exe_name)
|
||||
if not os.path.exists(statistics_file):
|
||||
return
|
||||
|
||||
target_path = os.path.normpath(exe_path)
|
||||
kept_lines = []
|
||||
removed = False
|
||||
|
||||
@@ -1060,15 +994,15 @@ class ContextMenuManager:
|
||||
"""Add the game desktop entry to the XDG applications directory."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_store_uri and not os.path.exists(desktop_path):
|
||||
if not is_gog_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_store_uri:
|
||||
if not is_gog_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@@ -1076,9 +1010,9 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
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)
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
@@ -1131,16 +1065,16 @@ class ContextMenuManager:
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_store_uri and not os.path.exists(desktop_path):
|
||||
if not is_gog_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_store_uri:
|
||||
if not is_gog_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@@ -1148,9 +1082,9 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
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)
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
|
||||
@@ -135,7 +135,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale, center_rows=True):
|
||||
y += int(row_max_height * global_scale) + spacing
|
||||
i = j
|
||||
|
||||
return result, y - spacing
|
||||
return result, y
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
@@ -160,7 +160,7 @@ class FlowLayout(QLayout):
|
||||
|
||||
for i, item in enumerate(self.itemList):
|
||||
widget = item.widget()
|
||||
if widget and not widget.isHidden():
|
||||
if widget and widget.isVisible():
|
||||
visible_items.append(item)
|
||||
visible_indices.append(i)
|
||||
s = item.sizeHint()
|
||||
@@ -191,11 +191,6 @@ class FlowLayout(QLayout):
|
||||
self._invalidate_cache()
|
||||
super().invalidate()
|
||||
|
||||
def setSpacing(self, spacing: int) -> None:
|
||||
self._spacing = spacing
|
||||
self._invalidate_cache()
|
||||
super().setSpacing(spacing)
|
||||
|
||||
def count(self) -> int:
|
||||
return len(self.itemList)
|
||||
|
||||
@@ -676,7 +671,7 @@ class AutoSizeButton(QPushButton):
|
||||
|
||||
if icon_color and self._icon_name:
|
||||
colored_path = self.theme_manager.get_colored_icon_path(
|
||||
icon_path,
|
||||
self._icon_name,
|
||||
icon_color,
|
||||
self.current_theme_name
|
||||
)
|
||||
@@ -842,7 +837,7 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self.fade_duration_ms = fade_duration_ms
|
||||
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||
self.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
self.setStyleSheet("background: transparent;")
|
||||
|
||||
self._v_scrollbar = self.verticalScrollBar()
|
||||
self._v_scrollbar.installEventFilter(self)
|
||||
|
||||
@@ -19,16 +19,15 @@ def get_cached_vk_gpu_info() -> str:
|
||||
|
||||
if _vk_gpu_info_output is None:
|
||||
try:
|
||||
vk_gpu_info_path = os.path.join(
|
||||
dev_scripts_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
"build-aux",
|
||||
"bin",
|
||||
"dev-scripts",
|
||||
"vk_gpu_info"
|
||||
)
|
||||
|
||||
if os.path.exists(vk_gpu_info_path):
|
||||
if os.path.exists(dev_scripts_path):
|
||||
result = subprocess.run(
|
||||
[vk_gpu_info_path],
|
||||
[dev_scripts_path],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
|
||||
@@ -322,7 +322,7 @@ def generate_system_info(
|
||||
lines.append(_("Debug log mode was launched"))
|
||||
lines.append(
|
||||
_("To diagnose the problem, copy the ENTIRE log to the site:") +
|
||||
" https://linux-gaming.ru/forum/help/portprotonqt-pomosch"
|
||||
" https://linux-gaming.ru/t/opisanie-kategorii-portproton-pomoshh/1642"
|
||||
)
|
||||
lines.append("-" * 61)
|
||||
|
||||
|
||||
@@ -86,9 +86,6 @@ 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."""
|
||||
@@ -313,7 +310,6 @@ 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)
|
||||
@@ -369,75 +365,6 @@ 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:
|
||||
@@ -604,9 +531,7 @@ class DetailPageManager:
|
||||
|
||||
current_exe = self._get_current_exe(exec_line)
|
||||
play_button = self._create_play_button(exec_line, current_exe)
|
||||
source = str(game_source).lower()
|
||||
install_uri = exec_line.startswith(f"{source}://install/")
|
||||
if source in {"gog", "egs"} and install_uri:
|
||||
if str(game_source).lower() == "gog" and exec_line.startswith("gog://install/"):
|
||||
play_button.setText(_("Install"))
|
||||
play_button.setIcon(
|
||||
self.main_window.theme_manager.get_icon("update", as_path=True)
|
||||
@@ -645,7 +570,7 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() not in {"gog", "egs"}
|
||||
str(game_source).lower() != "gog"
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
@@ -663,26 +588,21 @@ class DetailPageManager:
|
||||
if str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
source in {"gog", "egs"}
|
||||
str(game_source).lower() == "gog"
|
||||
and appid
|
||||
and exec_line.startswith(f"{source}://launch/")
|
||||
and exec_line.startswith("gog://launch/")
|
||||
):
|
||||
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:
|
||||
gog_exe = self.main_window.gog_api.get_launch_target(str(appid))
|
||||
if not gog_exe:
|
||||
return buttons_layout
|
||||
if source == "gog":
|
||||
store_api.ensure_launch_parameters(str(appid))
|
||||
self.main_window.gog_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(
|
||||
store_exe, game_source=source,
|
||||
gog_exe, game_source="gog",
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(settings_button)
|
||||
@@ -690,9 +610,7 @@ class DetailPageManager:
|
||||
_("Create Log"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
)
|
||||
log_button.clicked.connect(
|
||||
lambda: self.toggleDebugLog(store_exe, log_button)
|
||||
)
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(gog_exe, log_button))
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
folder_button = self._make_action_button(
|
||||
@@ -700,7 +618,7 @@ class DetailPageManager:
|
||||
self.main_window.theme_manager.get_icon("search", as_path=True),
|
||||
)
|
||||
folder_button.clicked.connect(
|
||||
lambda: self._open_executable_folder(store_exe)
|
||||
lambda: self._open_executable_folder(gog_exe)
|
||||
)
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "steam" and appid:
|
||||
@@ -915,32 +833,17 @@ 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:
|
||||
|
||||
@@ -4,9 +4,9 @@ import os
|
||||
import re
|
||||
from weakref import WeakKeyDictionary
|
||||
from collections.abc import Callable
|
||||
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsBlurEffect, QGraphicsOpacityEffect, QGraphicsPixmapItem, QGraphicsScene
|
||||
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsOpacityEffect
|
||||
from PySide6.QtCore import QTimer, Qt, QObject, Signal, QPropertyAnimation, QByteArray
|
||||
from PySide6.QtGui import QBrush, QImage, QPainter, QPalette, QPixmap
|
||||
from PySide6.QtGui import 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_cover_background(detail_page, get_animated_cover_pixmap(image_label), main_window)
|
||||
_setup_palette_stylesheet(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_cover_background(detail_page, pixmap, main_window)
|
||||
_setup_palette_stylesheet(detail_page, pixmap, main_window)
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping pixmap update")
|
||||
|
||||
@@ -118,16 +118,7 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
|
||||
effect = QGraphicsOpacityEffect(image_label)
|
||||
image_label.setGraphicsEffect(effect)
|
||||
|
||||
animation_type = theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||
duration_key = "detail_page_fade_duration"
|
||||
default_duration = 350
|
||||
if animation_type.startswith("slide_"):
|
||||
duration_key = "detail_page_slide_duration"
|
||||
default_duration = 500
|
||||
elif animation_type == "bounce":
|
||||
duration_key = "detail_page_bounce_duration"
|
||||
default_duration = 400
|
||||
duration = theme.GAME_CARD_ANIMATION.get(duration_key, default_duration)
|
||||
duration = theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
||||
animation = QPropertyAnimation(effect, QByteArray(b"opacity"), image_label)
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(effect.opacity())
|
||||
@@ -138,75 +129,25 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
|
||||
animation.start()
|
||||
|
||||
|
||||
def setup_cover_background(
|
||||
detail_page: QWidget, pixmap: QPixmap | None, main_window, theme=None
|
||||
) -> None:
|
||||
def _setup_palette_stylesheet(detail_page: QWidget, pixmap: QPixmap | None, main_window) -> 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, active_theme)
|
||||
_on_palette_ready(palette, detail_page, main_window)
|
||||
|
||||
num_colors = getattr(active_theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
|
||||
num_colors = getattr(main_window.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 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:
|
||||
def _on_palette_ready(palette: list, detail_page: QWidget, main_window) -> None:
|
||||
"""Handle palette ready callback."""
|
||||
if not _is_detail_page_valid(detail_page):
|
||||
return
|
||||
_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)
|
||||
_apply_palette_stylesheet(detail_page, palette, main_window)
|
||||
|
||||
|
||||
def _build_palette_stops(dark_palette: list) -> str:
|
||||
@@ -278,16 +219,14 @@ def _resolve_gradient_config(theme: object) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _apply_palette_stylesheet(
|
||||
detail_page: QWidget, palette: list, main_window, theme
|
||||
) -> None:
|
||||
def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window) -> 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(theme)
|
||||
stops = _resolve_gradient_stops(theme, dark_palette)
|
||||
gradient_config = _resolve_gradient_config(main_window.theme)
|
||||
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
|
||||
gradient_type = gradient_config.get("type", "linear")
|
||||
|
||||
if gradient_type == 'radial':
|
||||
@@ -320,7 +259,7 @@ def _apply_palette_stylesheet(
|
||||
"""
|
||||
|
||||
detail_page.setStyleSheet(stylesheet)
|
||||
_setup_wave_background(detail_page, dark_palette, theme)
|
||||
_setup_wave_background(detail_page, dark_palette, main_window.theme)
|
||||
detail_page.update()
|
||||
logger.debug("Stylesheet updated with palette")
|
||||
except RuntimeError:
|
||||
@@ -339,7 +278,7 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
|
||||
return
|
||||
_detail_backgrounds.remove(detail_page)
|
||||
|
||||
if bg_mode not in {"waves", "static_waves"}:
|
||||
if bg_mode == "gradient":
|
||||
_remove_wave_background(detail_page)
|
||||
return
|
||||
|
||||
@@ -705,6 +644,11 @@ def _check_desktop_file(
|
||||
return autoinstall_exe.lower() in (exec_name, real_exec_name)
|
||||
|
||||
|
||||
def _get_autoinstall_exe_name(script_name: str) -> str:
|
||||
autoinstall_exe, _autoinstall_path = _get_autoinstall_exe_target(script_name)
|
||||
return autoinstall_exe
|
||||
|
||||
|
||||
def _get_autoinstall_exe_target(script_name: str) -> tuple[str, str]:
|
||||
script_path = script_name
|
||||
if not os.path.isfile(script_path):
|
||||
|
||||
@@ -84,7 +84,6 @@ 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)
|
||||
@@ -140,7 +139,7 @@ def _add_source_corner(
|
||||
) -> None:
|
||||
"""Add source corner badge to cover frame."""
|
||||
source = game_source.lower()
|
||||
if source not in ("steam", "gog", "egs", "portproton") or theme_manager is None:
|
||||
if source not in ("steam", "gog", "portproton") or theme_manager is None:
|
||||
return
|
||||
|
||||
icon_name = f"badge_{source}"
|
||||
@@ -183,7 +182,6 @@ 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)
|
||||
@@ -194,7 +192,6 @@ 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)
|
||||
@@ -204,7 +201,6 @@ 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(
|
||||
@@ -222,7 +218,6 @@ 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(
|
||||
@@ -328,6 +323,28 @@ def create_protondb_badge(
|
||||
return badge, True
|
||||
|
||||
|
||||
def create_steam_badge(
|
||||
parent: QWidget,
|
||||
appid: str,
|
||||
main_window,
|
||||
) -> ClickableLabel:
|
||||
"""Create Steam badge."""
|
||||
steam_icon = main_window.theme_manager.get_icon("badge_steam", as_path=True)
|
||||
badge = ClickableLabel(
|
||||
"Steam",
|
||||
icon=steam_icon,
|
||||
parent=parent,
|
||||
icon_size=getattr(main_window.theme, "BADGE", {}).get("icon_size", 16),
|
||||
icon_space=5,
|
||||
)
|
||||
badge.setStyleSheet(main_window.theme.STEAM_BADGE_STYLE)
|
||||
_apply_badge_view_mode(badge, main_window.theme)
|
||||
badge.clicked.connect(
|
||||
lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}"))
|
||||
)
|
||||
return badge
|
||||
|
||||
|
||||
def create_portproton_badge(
|
||||
parent: QWidget,
|
||||
main_window,
|
||||
@@ -402,7 +419,6 @@ 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)
|
||||
@@ -445,7 +461,6 @@ 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)
|
||||
|
||||
|
||||
@@ -79,7 +79,6 @@ class AppImageUpdateDialog(DraggableDialog):
|
||||
|
||||
if self.input_manager:
|
||||
self.input_manager.enable_appimage_update_mode(self)
|
||||
self.finished.connect(self.input_manager.disable_appimage_update_mode)
|
||||
|
||||
def _make_button(self, text: str) -> AutoSizeButton:
|
||||
button = AutoSizeButton(text)
|
||||
|
||||
@@ -8,13 +8,9 @@ from PIL import Image, ImageQt
|
||||
from PySide6.QtGui import QPixmap, QIcon, QImage, QImageReader
|
||||
from PySide6.QtWidgets import (
|
||||
QVBoxLayout, QHBoxLayout, QLabel, QListWidget, QListWidgetItem,
|
||||
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy, QStyle,
|
||||
QStyledItemDelegate, QStyleOptionViewItem
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
Qt, QObject, Signal, QMimeDatabase, QModelIndex, QPersistentModelIndex,
|
||||
QThreadPool, QRunnable, Slot, QSize, QTimer
|
||||
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QThreadPool, QRunnable, Slot, QSize, QTimer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
@@ -36,25 +32,6 @@ DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
|
||||
THUMBNAIL_PRELOAD_ROWS = 5
|
||||
THUMBNAIL_QUEUE_LIMIT = 24
|
||||
THUMBNAIL_SCROLL_DELAY_MS = 80
|
||||
THEMED_ICON_ROLE = Qt.ItemDataRole.UserRole.value + 1
|
||||
|
||||
|
||||
class FileIconDelegate(QStyledItemDelegate):
|
||||
"""Draw themed list icons in their active mode on hover."""
|
||||
|
||||
def initStyleOption(
|
||||
self,
|
||||
option: QStyleOptionViewItem,
|
||||
index: QModelIndex | QPersistentModelIndex,
|
||||
) -> None:
|
||||
super().initStyleOption(option, index)
|
||||
if not index.data(THEMED_ICON_ROLE):
|
||||
return
|
||||
if not option.state & QStyle.StateFlag.State_MouseOver:
|
||||
return
|
||||
pixmap = option.icon.pixmap(option.decorationSize, QIcon.Mode.Active)
|
||||
if not pixmap.isNull():
|
||||
option.icon = QIcon(pixmap)
|
||||
|
||||
|
||||
class FileExplorer(DraggableDialog):
|
||||
@@ -193,12 +170,12 @@ class FileExplorer(DraggableDialog):
|
||||
self.main_layout.addWidget(self.hints_widget)
|
||||
|
||||
if self.input_manager:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('file_explorer', update_hints)
|
||||
self.input_manager.button_event.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
self.input_manager.dpad_moved.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
|
||||
def async_load_thumbnails(self, files, mime_db):
|
||||
@@ -248,41 +225,15 @@ class FileExplorer(DraggableDialog):
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
item = self.file_items.get(file_path)
|
||||
if item:
|
||||
item.setData(THEMED_ICON_ROLE, None)
|
||||
item.setIcon(icon)
|
||||
|
||||
def _get_file_type_icon(self, file_path: str) -> QIcon | None:
|
||||
icon_name = THEMED_LAUNCH_ICON_NAMES.get(os.path.splitext(file_path)[1].lower())
|
||||
if not icon_name:
|
||||
return None
|
||||
icon = self._get_list_icon(icon_name)
|
||||
icon = theme_manager.get_icon(icon_name)
|
||||
return icon if isinstance(icon, QIcon) and not icon.isNull() else None
|
||||
|
||||
def _get_list_icon(self, icon_name: str) -> QIcon:
|
||||
icon_path = theme_manager.get_icon(icon_name, as_path=True)
|
||||
if not isinstance(icon_path, str) or not os.path.exists(icon_path):
|
||||
return QIcon()
|
||||
|
||||
icon = QIcon()
|
||||
icon.addFile(icon_path, QSize(), QIcon.Mode.Normal)
|
||||
colors = getattr(self.theme, "ICON_COLORS", {})
|
||||
if not isinstance(colors, dict):
|
||||
return icon
|
||||
|
||||
modes = {
|
||||
"hover": QIcon.Mode.Active,
|
||||
"focused": QIcon.Mode.Selected,
|
||||
"disabled": QIcon.Mode.Disabled,
|
||||
}
|
||||
for state, mode in modes.items():
|
||||
color = colors.get(f"{icon_name}_{state}", colors.get(f"*_{state}"))
|
||||
if not isinstance(color, str):
|
||||
continue
|
||||
colored_path = theme_manager.get_colored_icon_path(icon_name, color)
|
||||
if colored_path:
|
||||
icon.addFile(colored_path, QSize(), mode)
|
||||
return icon
|
||||
|
||||
def schedule_thumbnail_loading(self) -> None:
|
||||
"""Schedule thumbnail loading after scroll events settle."""
|
||||
self.thumbnail_timer.start(THUMBNAIL_SCROLL_DELAY_MS)
|
||||
@@ -358,7 +309,6 @@ class FileExplorer(DraggableDialog):
|
||||
self.main_layout.addWidget(self.path_label)
|
||||
|
||||
self.file_list = QListWidget()
|
||||
self.file_list.setItemDelegate(FileIconDelegate(self.file_list))
|
||||
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE + self.theme.SCROLL_STYLE)
|
||||
self.file_list.itemClicked.connect(self.handle_item_click)
|
||||
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
|
||||
@@ -580,17 +530,15 @@ class FileExplorer(DraggableDialog):
|
||||
try:
|
||||
if self.directory_only:
|
||||
item = QListWidgetItem("./")
|
||||
folder_icon = self._get_list_icon("folder")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
if isinstance(folder_icon, QIcon):
|
||||
item.setIcon(folder_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
self.file_list.addItem(item)
|
||||
if self.current_path != "/":
|
||||
item = QListWidgetItem("../")
|
||||
folder_icon = self._get_list_icon("folder")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
if isinstance(folder_icon, QIcon):
|
||||
item.setIcon(folder_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
items = os.listdir(self.current_path)
|
||||
@@ -598,10 +546,9 @@ class FileExplorer(DraggableDialog):
|
||||
|
||||
for d in sorted(dirs):
|
||||
item = QListWidgetItem(f"{d}/")
|
||||
folder_icon = self._get_list_icon("folder")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
if isinstance(folder_icon, QIcon):
|
||||
item.setIcon(folder_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
if not self.directory_only:
|
||||
@@ -619,7 +566,6 @@ class FileExplorer(DraggableDialog):
|
||||
file_type_icon = self._get_file_type_icon(file_path)
|
||||
if file_type_icon:
|
||||
item.setIcon(file_type_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
if (
|
||||
f.lower().endswith(".ppack")
|
||||
or mime_type.name() == "application/x-portproton-prefix-backup"
|
||||
@@ -627,10 +573,9 @@ class FileExplorer(DraggableDialog):
|
||||
or mime_type.name() in ("application/vnd.squashfs", "application/x-squashfs")
|
||||
or mime_type.inherits("application/vnd.squashfs")
|
||||
):
|
||||
ppack_icon = self._get_list_icon("ppack")
|
||||
ppack_icon = theme_manager.get_icon("ppack")
|
||||
if isinstance(ppack_icon, QIcon):
|
||||
item.setIcon(ppack_icon)
|
||||
item.setData(THEMED_ICON_ROLE, True)
|
||||
self.file_items[file_path] = item
|
||||
self.file_list.addItem(item)
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
@@ -12,9 +11,8 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView,
|
||||
QStackedWidget, QPushButton
|
||||
)
|
||||
from PySide6.QtCore import QEvent, QMimeData, QObject, Qt, QTimer, QUrl
|
||||
from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QGuiApplication
|
||||
from shiboken6 import isValid
|
||||
from PySide6.QtCore import Qt, QTimer, QEvent, QObject
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
|
||||
from portprotonqt.config import get_portproton_start_command, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
@@ -23,9 +21,7 @@ from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.version_utils import version_sort_key
|
||||
from portprotonqt.dialogs.dialog_utils import create_dialog_hints_widget, update_dialog_hints
|
||||
from portprotonqt.dialogs.wine_loader import (
|
||||
InstalledWineSizeThread, WineLoadingThread, get_cpu_level,
|
||||
)
|
||||
from portprotonqt.dialogs.wine_loader import WineLoadingThread, get_cpu_level
|
||||
from portprotonqt.dialogs.wine_downloader import DownloadThread
|
||||
from portprotonqt.dialogs.wine_extractor import ExtractionThread
|
||||
from portprotonqt.preloader import Preloader
|
||||
@@ -33,42 +29,6 @@ from portprotonqt.steam_api import get_steam_compatibilitytools_dir, get_steam_h
|
||||
|
||||
logger = get_logger(__name__)
|
||||
theme_manager = ThemeManager()
|
||||
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):
|
||||
@@ -87,8 +47,6 @@ class ProtonManager(DraggableDialog):
|
||||
self.input_manager = input_manager
|
||||
self.initial_command_executed = False
|
||||
self.wine_loading_thread = None
|
||||
self._installed_size_tables = {}
|
||||
self.setAcceptDrops(True)
|
||||
|
||||
self.main_window = None
|
||||
parent_widget = self.parent()
|
||||
@@ -104,48 +62,6 @@ class ProtonManager(DraggableDialog):
|
||||
if self.input_manager:
|
||||
self.enable_proton_manager_mode()
|
||||
|
||||
@staticmethod
|
||||
def _get_dropped_wine_archives(mime_data: QMimeData) -> list[str]:
|
||||
if not mime_data.hasUrls():
|
||||
return []
|
||||
archive_paths = []
|
||||
for url in mime_data.urls():
|
||||
if not url.isLocalFile():
|
||||
continue
|
||||
archive_path = url.toLocalFile()
|
||||
if os.path.isfile(archive_path) and archive_path.lower().endswith(
|
||||
WINE_ARCHIVE_EXTENSIONS
|
||||
):
|
||||
archive_paths.append(archive_path)
|
||||
return archive_paths
|
||||
|
||||
def dragEnterEvent(self, event: QDragEnterEvent) -> None:
|
||||
if self._get_dropped_wine_archives(event.mimeData()):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event: QDropEvent) -> None:
|
||||
archive_paths = self._get_dropped_wine_archives(event.mimeData())
|
||||
if not archive_paths or self.is_downloading:
|
||||
event.ignore()
|
||||
return
|
||||
self.install_local_archives(archive_paths)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def install_local_archives(self, archive_paths: list[str]) -> None:
|
||||
self.assets_to_download = [
|
||||
{
|
||||
'source_name': 'proton_lg',
|
||||
'asset_name': os.path.basename(path),
|
||||
'local_path': path,
|
||||
}
|
||||
for path in archive_paths
|
||||
]
|
||||
self.current_download_index = 0
|
||||
self.is_downloading = True
|
||||
self.start_next_download()
|
||||
|
||||
def _init_gamepad_tooltip(self) -> None:
|
||||
self._gamepad_tooltip_map = {}
|
||||
self.gamepad_tooltip = QLabel()
|
||||
@@ -297,45 +213,34 @@ class ProtonManager(DraggableDialog):
|
||||
self.clear_btn = QPushButton(_('Clear All'))
|
||||
self.clear_btn.clicked.connect(self.clear_selection)
|
||||
self.clear_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.open_folder_btn = QPushButton(_('Open Folder'))
|
||||
self.open_folder_btn.clicked.connect(self._open_wine_folder)
|
||||
self.open_folder_btn.setEnabled(bool(self.portproton_location))
|
||||
self.open_folder_btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_btn_dialog = QPushButton(_('Cancel'))
|
||||
self.cancel_btn_dialog.clicked.connect(self.reject)
|
||||
self.cancel_btn_dialog.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button_layout.addWidget(self.download_btn)
|
||||
button_layout.addWidget(self.clear_btn)
|
||||
button_layout.addWidget(self.open_folder_btn)
|
||||
button_layout.addWidget(self.cancel_btn_dialog)
|
||||
layout.addLayout(button_layout)
|
||||
|
||||
self.tab_widget.currentChanged.connect(self.tab_changed)
|
||||
|
||||
if self.input_manager and self.main_window:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.input_manager.button_event.connect(
|
||||
lambda *args: update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
)
|
||||
self.input_manager.dpad_moved.connect(
|
||||
lambda *args: update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
)
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
|
||||
def _open_wine_folder(self) -> None:
|
||||
if not self.portproton_location:
|
||||
return
|
||||
wine_folder = os.path.join(self.portproton_location, "data", "dist")
|
||||
try:
|
||||
os.makedirs(wine_folder, exist_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to create Wine folder %s: %s", wine_folder, error)
|
||||
return
|
||||
if not QDesktopServices.openUrl(QUrl.fromLocalFile(wine_folder)):
|
||||
logger.warning("Failed to open Wine folder %s", wine_folder)
|
||||
|
||||
def start_loading_wine_data(self):
|
||||
self.wine_loading_thread = WineLoadingThread()
|
||||
self.wine_loading_thread.loading_complete.connect(self.on_wine_data_loaded)
|
||||
@@ -344,47 +249,9 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
def on_wine_data_loaded(self, metadata):
|
||||
self.wine_metadata = metadata
|
||||
self._start_incremental_wine_tabs(metadata)
|
||||
|
||||
def _start_incremental_wine_tabs(self, metadata: dict) -> None:
|
||||
self.cpu_level = get_cpu_level()
|
||||
logger.info("Detected CPU level: %s", self.cpu_level)
|
||||
tabs = []
|
||||
for source_name, entries in metadata.items():
|
||||
filtered_entries = self.filter_entries_by_cpu_level(entries, source_name)
|
||||
tabs.append((source_name, sort_wine_entries(filtered_entries, source_name)))
|
||||
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
|
||||
self.selected_assets.clear()
|
||||
self.tab_widget.clear()
|
||||
self._wine_tab_queue = tabs
|
||||
self._wine_current_tab = None
|
||||
QTimer.singleShot(0, self._add_next_wine_row_batch)
|
||||
|
||||
def _add_next_wine_row_batch(self) -> None:
|
||||
if self._wine_current_tab is None:
|
||||
if not self._wine_tab_queue:
|
||||
self.create_installed_tab()
|
||||
self.update_selection_display()
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
return
|
||||
source_name, entries = self._wine_tab_queue.pop(0)
|
||||
table = self._create_empty_wine_tab(source_name, len(entries))
|
||||
self._wine_current_tab = (source_name, entries, table, 0)
|
||||
|
||||
source_name, entries, table, row_index = self._wine_current_tab
|
||||
batch_end = min(row_index + WINE_TABLE_ROW_BATCH_SIZE, len(entries))
|
||||
table.setUpdatesEnabled(False)
|
||||
try:
|
||||
for index in range(row_index, batch_end):
|
||||
self.add_asset_row_from_json(table, index, entries[index], source_name)
|
||||
finally:
|
||||
table.setUpdatesEnabled(True)
|
||||
if batch_end == len(entries):
|
||||
logger.info("Successfully created tab for %s", source_name)
|
||||
self._wine_current_tab = None
|
||||
else:
|
||||
self._wine_current_tab = (source_name, entries, table, batch_end)
|
||||
QTimer.singleShot(0, self._add_next_wine_row_batch)
|
||||
self.refresh_wine_tabs()
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
|
||||
def on_wine_data_load_error(self, error_msg):
|
||||
logger.error(f"Wine data loading failed: {error_msg}")
|
||||
@@ -474,6 +341,12 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
def create_tab_from_entries(self, source_name, entries):
|
||||
try:
|
||||
tab = QWidget()
|
||||
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
layout.setSpacing(5)
|
||||
table = self.create_table_widget()
|
||||
all_entries = []
|
||||
for entry in entries:
|
||||
url = entry.get('url', '')
|
||||
@@ -483,29 +356,19 @@ class ProtonManager(DraggableDialog):
|
||||
if url_filename:
|
||||
entry['filename'] = url_filename
|
||||
all_entries.append(entry)
|
||||
all_entries = sort_wine_entries(all_entries, source_name)
|
||||
table = self._create_empty_wine_tab(source_name, len(all_entries))
|
||||
all_entries.sort(key=version_sort_key)
|
||||
table.setRowCount(len(all_entries))
|
||||
for row_index, entry in enumerate(all_entries):
|
||||
self.add_asset_row_from_json(table, row_index, entry, source_name)
|
||||
layout.addWidget(table, 1)
|
||||
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper()
|
||||
self.tab_widget.addTab(tab, tab_name)
|
||||
logger.info(f"Successfully created tab for {source_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating tab for {source_name}: {e}")
|
||||
return False
|
||||
|
||||
def _create_empty_wine_tab(self, source_name: str, row_count: int) -> QTableWidget:
|
||||
tab = QWidget()
|
||||
tab.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
layout = QVBoxLayout(tab)
|
||||
layout.setContentsMargins(5, 5, 5, 5)
|
||||
layout.setSpacing(5)
|
||||
table = self.create_table_widget()
|
||||
table.setRowCount(row_count)
|
||||
layout.addWidget(table, 1)
|
||||
tab_name = (self.get_short_source_name(source_name) or "UNKNOWN").upper()
|
||||
self.tab_widget.addTab(tab, tab_name)
|
||||
return table
|
||||
|
||||
def get_short_source_name(self, full_name):
|
||||
if full_name is None:
|
||||
return "UNKNOWN"
|
||||
@@ -643,24 +506,6 @@ class ProtonManager(DraggableDialog):
|
||||
self.add_installed_row(table, row_index, version_name, version_path)
|
||||
layout.addWidget(table, 1)
|
||||
self.tab_widget.addTab(tab, _("Installed"))
|
||||
size_thread = InstalledWineSizeThread(installed_versions, self)
|
||||
self._installed_size_tables[size_thread] = table
|
||||
size_thread.sizes_loaded.connect(self._on_installed_sizes_loaded)
|
||||
size_thread.finished.connect(self._on_installed_size_thread_finished)
|
||||
size_thread.finished.connect(size_thread.deleteLater)
|
||||
size_thread.start()
|
||||
|
||||
def _on_installed_sizes_loaded(self, sizes: list[str]) -> None:
|
||||
table = self._installed_size_tables.get(self.sender())
|
||||
if table is None or not isValid(table):
|
||||
return
|
||||
for row_index, size in enumerate(sizes):
|
||||
size_item = table.item(row_index, 2)
|
||||
if size_item is not None and size:
|
||||
size_item.setText(size)
|
||||
|
||||
def _on_installed_size_thread_finished(self) -> None:
|
||||
self._installed_size_tables.pop(self.sender(), None)
|
||||
|
||||
def get_installed_versions(self) -> list[tuple[str, str]]:
|
||||
portproton_location = self.portproton_location
|
||||
@@ -717,7 +562,8 @@ class ProtonManager(DraggableDialog):
|
||||
version_item = QTableWidgetItem(version_name)
|
||||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 1, version_item)
|
||||
size_item = QTableWidgetItem(_("Unknown"))
|
||||
size_str = self.get_directory_size(version_path)
|
||||
size_item = QTableWidgetItem(size_str)
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
@@ -731,6 +577,30 @@ class ProtonManager(DraggableDialog):
|
||||
def on_installed_version_toggled(self, state):
|
||||
self.update_selection_display()
|
||||
|
||||
def get_directory_size(self, path):
|
||||
try:
|
||||
total_size = 0
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
if total_size == 0:
|
||||
return "0 B"
|
||||
elif total_size < 1024:
|
||||
return f"{total_size}.0 B"
|
||||
elif total_size < 1024 * 1024:
|
||||
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
|
||||
elif total_size < 1024 * 1024 * 1024:
|
||||
return f"{int(total_size / (1024 * 1024))}.{int((total_size / (1024 * 1024) * 10) % 10)} MiB"
|
||||
elif total_size < 1024 * 1024 * 1024 * 1024:
|
||||
return f"{int(total_size / (1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024) * 10) % 10)} GiB"
|
||||
else:
|
||||
return f"{int(total_size / (1024 * 1024 * 1024 * 1024))}.{int((total_size / (1024 * 1024 * 1024 * 1024) * 10) % 10)} TiB"
|
||||
except Exception as e:
|
||||
logger.debug("Failed to format size: %s", e)
|
||||
return _("Unknown")
|
||||
|
||||
def convert_size_to_bytes(self, size_str):
|
||||
if not size_str or size_str == _("Unknown"):
|
||||
return 0
|
||||
@@ -1082,20 +952,10 @@ class ProtonManager(DraggableDialog):
|
||||
QMessageBox.information(self, _("Downloading Complete"), _("All selected WINE/Proton have been downloaded!"))
|
||||
return
|
||||
asset_data = self.assets_to_download[self.current_download_index]
|
||||
local_path = asset_data.get('local_path')
|
||||
if local_path:
|
||||
self.download_progress.setValue(0)
|
||||
self.download_frame.setVisible(True)
|
||||
self.download_frame.setStyleSheet(self.theme.GETWINE_WINDOW_STYLE)
|
||||
self.download_btn.setEnabled(False)
|
||||
self.clear_btn.setEnabled(False)
|
||||
self.start_extraction_for_asset(asset_data, local_path)
|
||||
return
|
||||
self.download_asset(asset_data)
|
||||
|
||||
def download_asset(self, asset_data):
|
||||
temp_dir = tempfile.mkdtemp(prefix="portproton_wine_")
|
||||
asset_data['cleanup_dir'] = temp_dir
|
||||
filename = os.path.join(temp_dir, asset_data['asset_name'])
|
||||
download_url = asset_data['download_url']
|
||||
download_info = f"{asset_data['source_name'].upper()} - {asset_data['asset_name']}"
|
||||
@@ -1166,13 +1026,12 @@ class ProtonManager(DraggableDialog):
|
||||
else:
|
||||
logger.error(f"Failed to extract: {archive_path}")
|
||||
QMessageBox.critical(self, _("Extraction Error"), _("Failed to extract archive: {0}").format(archive_path))
|
||||
cleanup_dir = asset_data.get('cleanup_dir')
|
||||
if cleanup_dir:
|
||||
try:
|
||||
shutil.rmtree(cleanup_dir)
|
||||
logger.debug(f"Cleaned up temporary directory: {cleanup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
|
||||
temp_dir = os.path.dirname(filepath)
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.debug(f"Cleaned up temporary directory: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Waiting for extraction thread to finish...")
|
||||
if not self.current_extraction_thread.wait(500):
|
||||
@@ -1182,13 +1041,12 @@ class ProtonManager(DraggableDialog):
|
||||
def extraction_error(error_msg):
|
||||
logger.error(f"Extraction error: {error_msg}")
|
||||
QMessageBox.critical(self, "Extraction Error", f"Failed to extract archive: {error_msg}")
|
||||
cleanup_dir = asset_data.get('cleanup_dir')
|
||||
if cleanup_dir:
|
||||
try:
|
||||
shutil.rmtree(cleanup_dir)
|
||||
logger.debug(f"Cleaned up temporary directory after error: {cleanup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
|
||||
temp_dir = os.path.dirname(filepath)
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.debug(f"Cleaned up temporary directory after error: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
|
||||
if self.current_extraction_thread and self.current_extraction_thread.isRunning():
|
||||
logger.debug("Waiting for extraction thread to finish after error...")
|
||||
if not self.current_extraction_thread.wait(500):
|
||||
@@ -1202,25 +1060,23 @@ class ProtonManager(DraggableDialog):
|
||||
self.current_extraction_thread.error.connect(extraction_error)
|
||||
self.current_extraction_thread.start()
|
||||
except Exception as e:
|
||||
cleanup_dir = asset_data.get('cleanup_dir')
|
||||
if cleanup_dir:
|
||||
try:
|
||||
shutil.rmtree(cleanup_dir)
|
||||
logger.debug(f"Cleaned up temporary directory after exception: {cleanup_dir}")
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {cleanup_error}")
|
||||
temp_dir = os.path.dirname(filepath)
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.debug(f"Cleaned up temporary directory after exception: {temp_dir}")
|
||||
except Exception as cleanup_error:
|
||||
logger.warning(f"Could not clean up temporary directory {temp_dir}: {cleanup_error}")
|
||||
logger.error(f"Error starting extraction thread for {filepath}: {e}")
|
||||
QMessageBox.critical(self, "Extraction Error", f"Failed to start extraction: {e}")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
else:
|
||||
cleanup_dir = asset_data.get('cleanup_dir')
|
||||
if cleanup_dir:
|
||||
try:
|
||||
shutil.rmtree(cleanup_dir)
|
||||
logger.debug(f"Cleaned up temporary directory: {cleanup_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {cleanup_dir}: {e}")
|
||||
temp_dir = os.path.dirname(filepath)
|
||||
try:
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.debug(f"Cleaned up temporary directory: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not clean up temporary directory {temp_dir}: {e}")
|
||||
logger.warning("PortProton location not provided, skipping extraction")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
@@ -1298,11 +1154,7 @@ class ProtonManager(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
def show_proton_manager(
|
||||
parent=None, portproton_location=None, input_manager=None, local_archives=None
|
||||
):
|
||||
def show_proton_manager(parent=None, portproton_location=None, input_manager=None):
|
||||
dialog = ProtonManager(parent, portproton_location, input_manager=input_manager)
|
||||
dialog.show()
|
||||
if local_archives:
|
||||
dialog.install_local_archives(local_archives)
|
||||
return dialog
|
||||
@@ -211,11 +211,7 @@ class ExeSettingsDialog(
|
||||
|
||||
if self.input_manager:
|
||||
self.input_manager.enable_settings_mode(self)
|
||||
self.input_manager.connect_surface_signal(
|
||||
'settings_dialog',
|
||||
self.input_manager.gamepad_hotplug,
|
||||
self._update_vkbasalt_toggle_key_visibility,
|
||||
)
|
||||
self.input_manager.gamepad_hotplug.connect(self._update_vkbasalt_toggle_key_visibility)
|
||||
|
||||
self.hints_widget, self.hints_labels = create_dialog_hints_widget(
|
||||
self.theme, self.main_window, self.input_manager, context='settings'
|
||||
@@ -223,12 +219,12 @@ class ExeSettingsDialog(
|
||||
self.main_layout.addWidget(self.hints_widget)
|
||||
|
||||
if self.input_manager:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('settings_dialog', update_hints)
|
||||
self.input_manager.button_event.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
self.input_manager.dpad_moved.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
|
||||
self.init_virtual_keyboard()
|
||||
@@ -443,7 +439,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(_("Edit PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
self.open_ppdb_button = AutoSizeButton(_("Open 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)
|
||||
@@ -799,7 +795,7 @@ class ExeSettingsDialog(
|
||||
line_edit.setEnabled(False)
|
||||
line_edit.setStyleSheet(self.theme.SETTINGS_DISABLED_INPUT_STYLE)
|
||||
|
||||
if setting['key'] == 'PW_RUN_AFTER_EXE':
|
||||
if setting['key'] in ('PW_RUN_AFTER_EXE', 'PW_NETWORK_BLOCKLIST'):
|
||||
text_container = QWidget()
|
||||
text_container.setProperty("ppqt_run_after_exe_widget", True)
|
||||
text_layout = QHBoxLayout(text_container)
|
||||
@@ -814,7 +810,7 @@ class ExeSettingsDialog(
|
||||
if is_blocked:
|
||||
browse_button.setEnabled(False)
|
||||
|
||||
def open_run_after_exe_selector(
|
||||
def open_setting_file_selector(
|
||||
_checked: bool = False,
|
||||
target_line_edit=line_edit,
|
||||
file_filter="",
|
||||
@@ -836,7 +832,7 @@ class ExeSettingsDialog(
|
||||
)
|
||||
file_explorer.exec()
|
||||
|
||||
browse_button.clicked.connect(open_run_after_exe_selector)
|
||||
browse_button.clicked.connect(open_setting_file_selector)
|
||||
text_layout.addWidget(browse_button)
|
||||
self.advanced_table.setCellWidget(row, 1, text_container)
|
||||
else:
|
||||
|
||||
@@ -267,7 +267,6 @@ class GamescopeSettingsMixin:
|
||||
self.gamescope_toggle_widgets = {}
|
||||
self.gamescope_toggle_widget_keys = {}
|
||||
self.gamescope_category_groups = {}
|
||||
self.gamescope_search_widget = None
|
||||
self.gamescope_resolution_widgets = {}
|
||||
self.gamescope_actions_group = None
|
||||
self.gamescope_toggle_group = None
|
||||
@@ -613,7 +612,7 @@ class GamescopeSettingsMixin:
|
||||
selector_layout.addWidget(self.gamescope_category_combo)
|
||||
|
||||
self.gamescope_category_stack = QStackedWidget()
|
||||
self.gamescope_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
self.gamescope_category_stack.setStyleSheet("background: transparent;")
|
||||
selector_layout.addWidget(self.gamescope_category_stack)
|
||||
|
||||
toggle_lookup = dict(GAMESCOPE_TOGGLE_SPECS)
|
||||
@@ -1027,38 +1026,7 @@ 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()
|
||||
if layout is not None:
|
||||
layout.removeWidget(checkbox)
|
||||
self._remove_gamescope_search_page()
|
||||
matching = []
|
||||
for key, checkbox in self.gamescope_toggle_widgets.items():
|
||||
description = GAMESCOPE_TOGGLE_DESCRIPTIONS.get(key, '')
|
||||
matches = not search_text or search_text in f"{checkbox.text()} {description}".lower()
|
||||
checkbox.setVisible(matches)
|
||||
if matches:
|
||||
matching.append((key, checkbox))
|
||||
if search_text:
|
||||
result_widget, result_layout = self._create_gamescope_search_page()
|
||||
for index, (_key, checkbox) in enumerate(matching):
|
||||
result_layout.addWidget(checkbox, index // 2, (index % 2) * 2 + 1)
|
||||
self.gamescope_category_combo.setVisible(False)
|
||||
self.gamescope_category_stack.setCurrentWidget(result_widget)
|
||||
else:
|
||||
category_positions = {}
|
||||
for key, checkbox in matching:
|
||||
category = next((name for name, keys in GAMESCOPE_TOGGLE_CATEGORIES.items() if key in keys), _("Other"))
|
||||
widget = self.gamescope_category_groups[category]
|
||||
index = category_positions.get(category, 0)
|
||||
cast(QGridLayout, widget.layout()).addWidget(checkbox, index // 2, (index % 2) * 2 + 1)
|
||||
category_positions[category] = index + 1
|
||||
self.gamescope_category_combo.setVisible(True)
|
||||
self.on_gamescope_category_changed(self.gamescope_category_combo.currentText())
|
||||
self._update_gamescope_category_stack_height()
|
||||
for group_box in self.gamescope_tab.findChildren(QGroupBox):
|
||||
if not gamescope_enabled:
|
||||
group_box.setVisible(group_box is self.gamescope_actions_group)
|
||||
@@ -1066,34 +1034,8 @@ class GamescopeSettingsMixin:
|
||||
if not search_text:
|
||||
group_box.setVisible(True)
|
||||
continue
|
||||
content_widgets = group_box.findChildren(QLabel) + group_box.findChildren(QCheckBox)
|
||||
content_match = any(search_text in widget.text().lower() for widget in content_widgets)
|
||||
group_box.setVisible(
|
||||
search_text in group_box.title().lower() or content_match
|
||||
or group_box is self.gamescope_toggle_group and bool(matching)
|
||||
)
|
||||
|
||||
def _create_gamescope_search_page(self) -> tuple[QWidget, QGridLayout]:
|
||||
source_widget = next(iter(self.gamescope_category_groups.values()))
|
||||
source_layout = cast(QGridLayout, source_widget.layout())
|
||||
widget = QWidget()
|
||||
layout = QGridLayout(widget)
|
||||
margins = source_layout.contentsMargins()
|
||||
layout.setContentsMargins(margins.left(), margins.top(), margins.right(), margins.bottom())
|
||||
layout.setHorizontalSpacing(source_layout.horizontalSpacing())
|
||||
layout.setVerticalSpacing(source_layout.verticalSpacing())
|
||||
for column in range(source_layout.columnCount()):
|
||||
layout.setColumnStretch(column, source_layout.columnStretch(column))
|
||||
self.gamescope_category_stack.addWidget(widget)
|
||||
self.gamescope_search_widget = widget
|
||||
return widget, layout
|
||||
|
||||
def _remove_gamescope_search_page(self) -> None:
|
||||
widget = getattr(self, 'gamescope_search_widget', None)
|
||||
if widget is None:
|
||||
return
|
||||
for checkbox in widget.findChildren(QCheckBox):
|
||||
checkbox.setParent(self.gamescope_toggle_group)
|
||||
self.gamescope_category_stack.removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
self.gamescope_search_widget = None
|
||||
group_text = group_box.title().lower()
|
||||
label_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QLabel))
|
||||
checkbox_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QCheckBox))
|
||||
content_text = f"{label_text} {checkbox_text}"
|
||||
group_box.setVisible(search_text in group_text or search_text in content_text)
|
||||
@@ -385,7 +385,6 @@ class MangoHudSettingsMixin:
|
||||
self.mangohud_toggle_widget_keys = {}
|
||||
self.mangohud_fps_widgets = {}
|
||||
self.mangohud_category_groups = {}
|
||||
self.mangohud_search_widget = None
|
||||
self.mangohud_gpu_options = []
|
||||
self.mangohud_actions_group = None
|
||||
self.mangohud_presets_group = None
|
||||
@@ -568,7 +567,7 @@ class MangoHudSettingsMixin:
|
||||
selector_layout.addWidget(self.mangohud_category_combo)
|
||||
|
||||
self.mangohud_category_stack = QStackedWidget()
|
||||
self.mangohud_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
self.mangohud_category_stack.setStyleSheet("background: transparent;")
|
||||
selector_layout.addWidget(self.mangohud_category_stack)
|
||||
|
||||
toggle_lookup = dict(MANGOHUD_TOGGLE_SPECS)
|
||||
@@ -1232,42 +1231,9 @@ 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():
|
||||
layout = checkbox.parentWidget().layout()
|
||||
if layout is not None:
|
||||
layout.removeWidget(checkbox)
|
||||
self._remove_mangohud_search_page()
|
||||
matching = []
|
||||
for key, checkbox in self.mangohud_toggle_widgets.items():
|
||||
description = MANGOHUD_TOGGLE_DESCRIPTIONS.get(key, '')
|
||||
matches = not search_text or search_text in f"{checkbox.text()} {description}".lower()
|
||||
checkbox.setVisible(matches)
|
||||
if matches:
|
||||
matching.append((key, checkbox))
|
||||
columns = self.theme.mangoHudSwitchesColumns
|
||||
if search_text:
|
||||
result_widget, result_layout = self._create_mangohud_search_page()
|
||||
for index, (_key, checkbox) in enumerate(matching):
|
||||
result_layout.addWidget(checkbox, index // columns, (index % columns) * 2 + 1)
|
||||
self.mangohud_category_combo.setVisible(False)
|
||||
self.mangohud_category_stack.setCurrentWidget(result_widget)
|
||||
else:
|
||||
category_positions = {}
|
||||
for key, checkbox in matching:
|
||||
category = next((name for name, keys in MANGOHUD_TOGGLE_CATEGORIES.items() if key in keys), _("Other"))
|
||||
widget = self.mangohud_category_groups[category]
|
||||
index = category_positions.get(category, 0)
|
||||
cast(QGridLayout, widget.layout()).addWidget(
|
||||
checkbox, index // columns, (index % columns) * 2 + 1
|
||||
)
|
||||
category_positions[category] = index + 1
|
||||
self.mangohud_category_combo.setVisible(True)
|
||||
self.on_mangohud_category_changed(self.mangohud_category_combo.currentText())
|
||||
self._update_mangohud_category_stack_height()
|
||||
mangohud_enabled = self.current_settings.get('PW_MANGOHUD') == '1'
|
||||
mangohud_user_conf_enabled = self.current_settings.get('PW_MANGOHUD_USER_CONF') == '1'
|
||||
config_visible = mangohud_enabled and not mangohud_user_conf_enabled
|
||||
for group_box in self.mangohud_tab.findChildren(QGroupBox):
|
||||
if not config_visible:
|
||||
group_box.setVisible(group_box is self.mangohud_actions_group)
|
||||
@@ -1275,38 +1241,11 @@ class MangoHudSettingsMixin:
|
||||
if not search_text:
|
||||
group_box.setVisible(True)
|
||||
continue
|
||||
content_widgets = group_box.findChildren(QLabel) + group_box.findChildren(QCheckBox)
|
||||
content_match = any(search_text in widget.text().lower() for widget in content_widgets)
|
||||
group_box.setVisible(
|
||||
search_text in group_box.title().lower() or content_match
|
||||
or group_box is self.mangohud_toggle_group and bool(matching)
|
||||
)
|
||||
|
||||
def _create_mangohud_search_page(self) -> tuple[QWidget, QGridLayout]:
|
||||
source_widget = next(iter(self.mangohud_category_groups.values()))
|
||||
source_layout = cast(QGridLayout, source_widget.layout())
|
||||
widget = QWidget()
|
||||
layout = QGridLayout(widget)
|
||||
margins = source_layout.contentsMargins()
|
||||
layout.setContentsMargins(margins.left(), margins.top(), margins.right(), margins.bottom())
|
||||
layout.setHorizontalSpacing(source_layout.horizontalSpacing())
|
||||
layout.setVerticalSpacing(source_layout.verticalSpacing())
|
||||
columns = self.theme.mangoHudSwitchesColumns
|
||||
for column in range(columns * 2 + 1):
|
||||
layout.setColumnStretch(column, source_layout.columnStretch(column))
|
||||
self.mangohud_category_stack.addWidget(widget)
|
||||
self.mangohud_search_widget = widget
|
||||
return widget, layout
|
||||
|
||||
def _remove_mangohud_search_page(self) -> None:
|
||||
widget = getattr(self, 'mangohud_search_widget', None)
|
||||
if widget is None:
|
||||
return
|
||||
for checkbox in widget.findChildren(QCheckBox):
|
||||
checkbox.setParent(self.mangohud_toggle_group)
|
||||
self.mangohud_category_stack.removeWidget(widget)
|
||||
widget.deleteLater()
|
||||
self.mangohud_search_widget = None
|
||||
group_text = group_box.title().lower()
|
||||
label_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QLabel))
|
||||
checkbox_text = ' '.join(widget.text().lower() for widget in group_box.findChildren(QCheckBox))
|
||||
content_text = f"{label_text} {checkbox_text}"
|
||||
group_box.setVisible(search_text in group_text or search_text in content_text)
|
||||
|
||||
def _get_default_mangohud_config(self) -> str | None:
|
||||
"""Read DEFAULT_MANGOHUD_CONFIG from scripts/var."""
|
||||
|
||||
@@ -356,28 +356,6 @@ class VkBasaltSettingsMixin:
|
||||
vkbasalt_enabled = self.current_settings.get('PW_VKBASALT') == '1'
|
||||
user_conf_enabled = self.current_settings.get('PW_VKBASALT_USER_CONF') == '1'
|
||||
config_visible = vkbasalt_enabled and not user_conf_enabled
|
||||
if self.vkbasalt_shaders_layout is not None:
|
||||
shaders_group = self.vkbasalt_shaders_group
|
||||
group_match = not search_text or (
|
||||
shaders_group is not None and search_text in shaders_group.title().lower()
|
||||
)
|
||||
matching = [
|
||||
(shader, checkbox)
|
||||
for shader, checkbox in self.vkbasalt_shader_widgets.items()
|
||||
if group_match or search_text in shader.lower()
|
||||
]
|
||||
matching_widgets = {checkbox for _, checkbox in matching}
|
||||
for checkbox in self.vkbasalt_shader_widgets.values():
|
||||
self.vkbasalt_shaders_layout.removeWidget(checkbox)
|
||||
checkbox.setVisible(group_match or checkbox in matching_widgets)
|
||||
columns = self.theme.mangoHudSwitchesColumns
|
||||
for index, (_, checkbox) in enumerate(matching):
|
||||
self.vkbasalt_shaders_layout.addWidget(
|
||||
checkbox,
|
||||
index // columns,
|
||||
(index % columns) * 2 + 1,
|
||||
)
|
||||
self.vkbasalt_shaders_layout.activate()
|
||||
for group_box in self.vkbasalt_tab.findChildren(QGroupBox):
|
||||
if not config_visible:
|
||||
group_box.setVisible(group_box is self.vkbasalt_actions_group)
|
||||
|
||||
@@ -31,20 +31,6 @@ class ExtractionThread(QThread):
|
||||
self._mutex.unlock()
|
||||
return not running
|
||||
|
||||
def _get_entry_path(self, entry_path: str, link_path: str | None = None) -> str:
|
||||
extract_root = os.path.realpath(self.extract_dir)
|
||||
target_path = os.path.realpath(os.path.join(extract_root, entry_path))
|
||||
if os.path.commonpath((extract_root, target_path)) != extract_root:
|
||||
raise ValueError(f"Archive entry escapes extraction directory: {entry_path}")
|
||||
if link_path is None:
|
||||
return target_path
|
||||
link_target = os.path.realpath(
|
||||
os.path.join(os.path.dirname(target_path), link_path)
|
||||
)
|
||||
if os.path.commonpath((extract_root, link_target)) != extract_root:
|
||||
raise ValueError(f"Archive link escapes extraction directory: {entry_path}")
|
||||
return target_path
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.progress.emit(0)
|
||||
@@ -62,6 +48,10 @@ class ExtractionThread(QThread):
|
||||
last_progress = -1
|
||||
last_bytes_read = 0
|
||||
|
||||
original_dir = os.getcwd()
|
||||
old_umask = os.umask(0)
|
||||
os.chdir(self.extract_dir)
|
||||
|
||||
try:
|
||||
deferred_times = []
|
||||
|
||||
@@ -73,25 +63,23 @@ class ExtractionThread(QThread):
|
||||
entry_path = entry.pathname
|
||||
if entry_path is None:
|
||||
continue
|
||||
link_path = os.fsdecode(entry.linkpath) if entry.issym else None
|
||||
target_path = self._get_entry_path(entry_path, link_path)
|
||||
|
||||
if entry.isdir:
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
os.makedirs(entry_path, exist_ok=True)
|
||||
if entry.mode:
|
||||
try:
|
||||
os.chmod(target_path, entry.mode)
|
||||
os.chmod(entry_path, entry.mode)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
if entry.mtime:
|
||||
deferred_times.append((target_path, entry.mtime))
|
||||
deferred_times.append((entry_path, entry.mtime))
|
||||
|
||||
elif entry.isfile:
|
||||
parent_dir = os.path.dirname(target_path)
|
||||
parent_dir = os.path.dirname(entry_path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
with open(target_path, 'wb') as f:
|
||||
with open(entry_path, 'wb') as f:
|
||||
for block in entry.get_blocks():
|
||||
if self._should_stop():
|
||||
return
|
||||
@@ -99,27 +87,28 @@ class ExtractionThread(QThread):
|
||||
|
||||
if entry.mode:
|
||||
try:
|
||||
os.chmod(target_path, entry.mode)
|
||||
os.chmod(entry_path, entry.mode)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
if entry.mtime:
|
||||
try:
|
||||
os.utime(target_path, (entry.mtime, entry.mtime))
|
||||
os.utime(entry_path, (entry.mtime, entry.mtime))
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
elif entry.issym:
|
||||
parent_dir = os.path.dirname(target_path)
|
||||
parent_dir = os.path.dirname(entry_path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
if os.path.lexists(target_path):
|
||||
os.remove(target_path)
|
||||
if os.path.lexists(entry_path):
|
||||
os.remove(entry_path)
|
||||
|
||||
link_path = entry.linkpath
|
||||
if link_path is not None:
|
||||
try:
|
||||
os.symlink(link_path, target_path)
|
||||
os.symlink(link_path, entry_path)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
@@ -163,7 +152,8 @@ class ExtractionThread(QThread):
|
||||
self.finished.emit(self.archive_path, True)
|
||||
|
||||
finally:
|
||||
self.eta.emit(0)
|
||||
os.chdir(original_dir)
|
||||
os.umask(old_umask)
|
||||
|
||||
except Exception as e:
|
||||
if not self._should_stop():
|
||||
|
||||
@@ -13,33 +13,6 @@ from portprotonqt.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def get_directory_size(path: str) -> str:
|
||||
try:
|
||||
total_size = 0
|
||||
for dirpath, _dirnames, filenames in os.walk(path):
|
||||
for filename in filenames:
|
||||
filepath = os.path.join(dirpath, filename)
|
||||
if os.path.exists(filepath):
|
||||
total_size += os.path.getsize(filepath)
|
||||
if total_size == 0:
|
||||
return "0 B"
|
||||
if total_size < 1024:
|
||||
return f"{total_size}.0 B"
|
||||
if total_size < 1024 * 1024:
|
||||
return f"{int(total_size / 1024)}.{int((total_size / 1024 * 10) % 10)} KiB"
|
||||
if total_size < 1024 * 1024 * 1024:
|
||||
value = total_size / (1024 * 1024)
|
||||
return f"{int(value)}.{int((value * 10) % 10)} MiB"
|
||||
if total_size < 1024 * 1024 * 1024 * 1024:
|
||||
value = total_size / (1024 * 1024 * 1024)
|
||||
return f"{int(value)}.{int((value * 10) % 10)} GiB"
|
||||
value = total_size / (1024 * 1024 * 1024 * 1024)
|
||||
return f"{int(value)}.{int((value * 10) % 10)} TiB"
|
||||
except Exception as error:
|
||||
logger.debug("Failed to format size: %s", error)
|
||||
return ""
|
||||
|
||||
|
||||
def get_cpu_level():
|
||||
"""
|
||||
Determine CPU level based on feature flags.
|
||||
@@ -91,18 +64,7 @@ class WineLoadingThread(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
cloud_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
github_url = (
|
||||
"https://raw.githubusercontent.com/linux-gaming-ru/"
|
||||
"PortProtonQt-Wine-Metadata/main/wine_metadata_all.json"
|
||||
)
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
json_url = (
|
||||
cloud_url
|
||||
if get_user_conf_setting("MIRROR") == "CLOUD"
|
||||
else github_url
|
||||
)
|
||||
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as tmp:
|
||||
temp_path = tmp.name
|
||||
@@ -128,17 +90,3 @@ class WineLoadingThread(QThread):
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading metadata: {e}")
|
||||
self.loading_error.emit(str(e))
|
||||
|
||||
|
||||
class InstalledWineSizeThread(QThread):
|
||||
"""Load installed Wine directory sizes outside the UI thread."""
|
||||
|
||||
sizes_loaded = Signal(object)
|
||||
|
||||
def __init__(self, versions: list[tuple[str, str]], parent: QWidget | None = None):
|
||||
super().__init__(parent)
|
||||
self.versions = versions
|
||||
|
||||
def run(self) -> None:
|
||||
sizes = [get_directory_size(path) for _name, path in self.versions]
|
||||
self.sizes_loaded.emit(sizes)
|
||||
@@ -86,12 +86,12 @@ class WinetricksDialog(DraggableDialog):
|
||||
self.main_layout.addWidget(self.hints_widget)
|
||||
|
||||
if self.input_manager:
|
||||
def update_hints(*_args: object) -> None:
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name,
|
||||
)
|
||||
self.input_manager.connect_surface_updates('winetricks_dialog', update_hints)
|
||||
self.input_manager.button_event.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
self.input_manager.dpad_moved.connect(
|
||||
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
)
|
||||
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
|
||||
def setup_ui(self):
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
"""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" / "egs"
|
||||
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.data_dir / "bin/legendary.version"
|
||||
self.games_dir = Path.home() / "Games"
|
||||
|
||||
def get_legendary_path(self) -> str | None:
|
||||
"""Return an available Legendary executable."""
|
||||
bundled = self.data_dir / "bin/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.data_dir / "bin/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.data_dir / "bin/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", {})
|
||||
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", "")),
|
||||
"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))
|
||||
@@ -1,7 +1,6 @@
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtGui import (
|
||||
QPainter,
|
||||
@@ -13,12 +12,8 @@ from PySide6.QtGui import (
|
||||
QIcon,
|
||||
QPen,
|
||||
QLinearGradient,
|
||||
QEnterEvent,
|
||||
QFocusEvent,
|
||||
QMouseEvent,
|
||||
QPaintEvent,
|
||||
)
|
||||
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -29,7 +24,6 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QStackedLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
)
|
||||
from portprotonqt.image_utils import (
|
||||
cleanup_animated_cover,
|
||||
@@ -205,123 +199,11 @@ 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 AnimatedCard(QFrame):
|
||||
animation_base_size: tuple[int, int]
|
||||
class GameCard(QFrame):
|
||||
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)
|
||||
@@ -331,6 +213,8 @@ class GameCard(AnimatedCard):
|
||||
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,
|
||||
@@ -373,46 +257,33 @@ class GameCard(AnimatedCard):
|
||||
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()
|
||||
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.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.list_layout = self.layout_mode == "list"
|
||||
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.steam_visible = (str(game_source).lower() == "steam" and not self.economy_mode)
|
||||
self.gog_visible = (str(game_source).lower() == "gog" and not self.economy_mode)
|
||||
self.portproton_visible = (str(game_source).lower() == "portproton" and not self.economy_mode)
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
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"
|
||||
config_name = "GAME_CARD_LIST" if self.list_layout else "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = getattr(self.theme, config_name, {})
|
||||
default_margin = 8 if self.list_layout else 20
|
||||
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
|
||||
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.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()
|
||||
|
||||
self.shadow = QGraphicsDropShadowEffect(self)
|
||||
self.shadow.setBlurRadius(self.theme.shadow_blur_radius)
|
||||
@@ -430,7 +301,6 @@ class GameCard(AnimatedCard):
|
||||
|
||||
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)
|
||||
@@ -440,8 +310,8 @@ class GameCard(AnimatedCard):
|
||||
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() in ("gog", "egs")
|
||||
and self.exec_line.startswith(("gog://install/", "egs://install/"))
|
||||
str(self.game_source).lower() == "gog"
|
||||
and self.exec_line.startswith("gog://install/")
|
||||
):
|
||||
self.coverOpacity = QGraphicsOpacityEffect(self.coverLabel)
|
||||
self.coverOpacity.setOpacity(self.theme.missing_exe_cover_opacity)
|
||||
@@ -479,7 +349,9 @@ class GameCard(AnimatedCard):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.steamLabel.setVisible(self.steam_visible and not self.vertical_layout)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
if self.economy_mode:
|
||||
self.steamLabel.setVisible(False)
|
||||
|
||||
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
|
||||
self.gogLabel = SourceCorner(
|
||||
@@ -487,15 +359,9 @@ class GameCard(AnimatedCard):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
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)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
if self.economy_mode:
|
||||
self.gogLabel.setVisible(False)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
@@ -503,9 +369,9 @@ class GameCard(AnimatedCard):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and not self.vertical_layout
|
||||
)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
if self.economy_mode:
|
||||
self.portprotonLabel.setVisible(False)
|
||||
|
||||
if self.ppdb_visible:
|
||||
self.ppdbLabel = ClickableLabel(
|
||||
@@ -551,34 +417,9 @@ class GameCard(AnimatedCard):
|
||||
self.nameLabel.setContentsMargins(0, 0, 10, 0)
|
||||
else:
|
||||
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
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.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||
self.layout_.addWidget(self.nameLabel)
|
||||
if self.list_layout:
|
||||
self.layout_.addStretch()
|
||||
|
||||
font_size = self.nameLabel.font().pointSizeF()
|
||||
@@ -595,76 +436,6 @@ class GameCard(AnimatedCard):
|
||||
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 = getattr(theme, config_name, {})
|
||||
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 on_cover_loaded(self, pixmap):
|
||||
self.animated_cover_path = ""
|
||||
self.base_pixmap = pixmap
|
||||
@@ -677,8 +448,7 @@ class GameCard(AnimatedCard):
|
||||
height = cover_size
|
||||
else:
|
||||
width = self.base_card_width
|
||||
layout_config = getattr(self, "card_layout_cfg", {})
|
||||
height = int(self.base_card_width * layout_config.get("cover_aspect_ratio", 1.5))
|
||||
height = int(self.base_card_width * 1.5)
|
||||
if self._set_animated_cover(cover_path, width, height):
|
||||
return
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback()
|
||||
@@ -821,14 +591,13 @@ class GameCard(AnimatedCard):
|
||||
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
|
||||
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
|
||||
source_ribbons = [
|
||||
(self.steam_visible and not self.vertical_layout, self.steamLabel),
|
||||
(self.gog_visible and not self.vertical_layout, self.gogLabel),
|
||||
(self.egs_visible and not self.vertical_layout, self.egsLabel),
|
||||
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
if not is_visible or ribbon is None:
|
||||
if not is_visible or ribbon is None or hidden_badges:
|
||||
if ribbon is not None:
|
||||
ribbon.setVisible(False)
|
||||
continue
|
||||
@@ -846,7 +615,6 @@ class GameCard(AnimatedCard):
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -869,15 +637,7 @@ class GameCard(AnimatedCard):
|
||||
int(self.card_layout_cfg.get("cover_size", 56) * self._scale),
|
||||
)
|
||||
margin_left = self.card_layout_cfg.get("cover_left_margin", 10)
|
||||
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.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)
|
||||
@@ -991,24 +751,19 @@ class GameCard(AnimatedCard):
|
||||
|
||||
self.display_filter = display_filter
|
||||
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.steam_visible = (str(self.game_source).lower() == "steam" and not self.economy_mode)
|
||||
self.gog_visible = (str(self.game_source).lower() == "gog" and not self.economy_mode)
|
||||
self.portproton_visible = (str(self.game_source).lower() == "portproton" and not self.economy_mode)
|
||||
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 and show_source_ribbons)
|
||||
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
|
||||
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and show_source_ribbons
|
||||
)
|
||||
self.steamLabel.setVisible(self.steam_visible and not hidden_badges)
|
||||
self.gogLabel.setVisible(self.gog_visible and not hidden_badges)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible and not hidden_badges)
|
||||
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)
|
||||
@@ -1198,6 +953,76 @@ class GameCard(AnimatedCard):
|
||||
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)
|
||||
@@ -1206,31 +1031,28 @@ class GameCard(AnimatedCard):
|
||||
self.set_animated_cover_paused(False)
|
||||
super().showEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
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:
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self.click()
|
||||
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)
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Protocol
|
||||
from portprotonqt.game_card import AnimatedCard, GameCard
|
||||
from typing import Protocol
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.search_utils import (
|
||||
SearchOptimizer,
|
||||
ThreadedSearch,
|
||||
@@ -8,40 +7,15 @@ from portprotonqt.search_utils import (
|
||||
search_index,
|
||||
)
|
||||
from PySide6.QtWidgets import QAbstractButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import QRect, QRectF, Qt, QTimer
|
||||
from PySide6.QtGui import QPaintEvent, QPainter, QPainterPath, QPixmap, QRegion
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import 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, 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."""
|
||||
|
||||
@@ -74,15 +48,8 @@ class GameLibraryManager:
|
||||
self.card_width = ui_config.get_card_width()
|
||||
self.layout_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.gamesListWidget: QWidget | None = None
|
||||
self.gamesListLayout: FlowLayout | QHBoxLayout | QVBoxLayout | None = None
|
||||
self.gamesListLayout: FlowLayout | 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
|
||||
@@ -102,58 +69,23 @@ 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)
|
||||
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
|
||||
if isinstance(library_background, dict):
|
||||
stack_layout = QGridLayout(self.gamesLibraryWidget)
|
||||
stack_layout.setContentsMargins(*library_background["margins"])
|
||||
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)
|
||||
else:
|
||||
layout = QVBoxLayout(self.gamesLibraryWidget)
|
||||
self.libraryContentLayout = layout
|
||||
layout = QVBoxLayout(self.gamesLibraryWidget)
|
||||
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)
|
||||
if self.layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = self.theme.GAME_CARD_HORIZONTAL
|
||||
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
|
||||
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
|
||||
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
|
||||
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
elif self.layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
|
||||
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.gamesListLayout = FlowLayout(self.gamesListWidget)
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
|
||||
scrollArea.setWidget(self.gamesListWidget)
|
||||
@@ -174,7 +106,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 in {"list", "vertical", "horizontal", "horizontal_top"}:
|
||||
if self.layout_mode == "list":
|
||||
self.sizeSlider.setVisible(False)
|
||||
self._set_card_width_from_slider()
|
||||
|
||||
@@ -191,125 +123,30 @@ 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 = self.theme.GAME_CARD_HORIZONTAL
|
||||
self.gamesListLayout = QHBoxLayout()
|
||||
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
|
||||
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
|
||||
elif layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout()
|
||||
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.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
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.full_library_open or self.layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
if self.layout_mode == "list":
|
||||
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"
|
||||
ui_config.set_card_width(self.card_width)
|
||||
if self.layout_mode != "list":
|
||||
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 fixed-size layouts."""
|
||||
"""Use max card width for list layout."""
|
||||
if self.sizeSlider is None:
|
||||
return
|
||||
if self.full_library_open or self.layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
if self.layout_mode == "list":
|
||||
self.card_width = self.sizeSlider.maximum()
|
||||
else:
|
||||
self.card_width = self.sizeSlider.value()
|
||||
@@ -362,7 +199,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.animations.handle_leave_event()
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = None
|
||||
@@ -373,29 +210,10 @@ 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
|
||||
pixmap = card.coverLabel.pixmap()
|
||||
if pixmap is None or pixmap.isNull():
|
||||
return
|
||||
library_config = self.theme.LIBRARY_BACKGROUND
|
||||
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(
|
||||
self.libraryBackgroundLabel, 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
|
||||
@@ -433,11 +251,10 @@ class GameLibraryManager:
|
||||
try:
|
||||
if self.main_window.current_hovered_card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.animations.handle_leave_event()
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
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
|
||||
@@ -458,14 +275,9 @@ 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
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
if not self.full_library_open:
|
||||
self.layout_mode = theme_mode
|
||||
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
if self.sizeSlider is not None:
|
||||
self.sizeSlider.setVisible(
|
||||
not self.full_library_open
|
||||
and self.layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
|
||||
)
|
||||
self.sizeSlider.setVisible(self.layout_mode != "list")
|
||||
old_card_width = self.card_width
|
||||
self._set_card_width_from_slider()
|
||||
self.main_window.card_width = self.card_width
|
||||
@@ -480,9 +292,6 @@ 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
|
||||
|
||||
@@ -577,7 +386,6 @@ 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()
|
||||
@@ -596,9 +404,7 @@ class GameLibraryManager:
|
||||
self._update_search_results(search_text)
|
||||
else:
|
||||
# Full update: sorting, removal/addition, reorganization
|
||||
games_list = self.filtered_games
|
||||
if self.layout_mode != "horizontal_top" and not games_list:
|
||||
games_list = self.games
|
||||
games_list = self.filtered_games if self.filtered_games else self.games
|
||||
favorites = favorites_config.get_games()
|
||||
sort_method = game_config.get_sort_method()
|
||||
|
||||
@@ -627,13 +433,6 @@ 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}
|
||||
@@ -726,68 +525,8 @@ 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
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
SDL_INIT_GAMEPAD = 0x00002000
|
||||
SDL_GAMEPAD_TYPE_STANDARD = 1
|
||||
SDL_GAMEPAD_TYPE_XBOX360 = 2
|
||||
SDL_GAMEPAD_TYPE_XBOXONE = 3
|
||||
SDL_GAMEPAD_TYPE_PS3 = 4
|
||||
SDL_GAMEPAD_TYPE_PS4 = 5
|
||||
SDL_GAMEPAD_TYPE_PS5 = 6
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
|
||||
SDL_GAMEPAD_TYPE_GAMECUBE = 11
|
||||
SDL_GAMEPAD_TYPE_STEAM = 12
|
||||
SDL3_XBOX_LIKE_TYPES = {
|
||||
SDL_GAMEPAD_TYPE_STANDARD,
|
||||
SDL_GAMEPAD_TYPE_XBOX360,
|
||||
SDL_GAMEPAD_TYPE_XBOXONE,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
|
||||
SDL_GAMEPAD_TYPE_GAMECUBE,
|
||||
SDL_GAMEPAD_TYPE_STEAM,
|
||||
}
|
||||
SDL3_PLAYSTATION_TYPES = {
|
||||
SDL_GAMEPAD_TYPE_PS3,
|
||||
SDL_GAMEPAD_TYPE_PS4,
|
||||
SDL_GAMEPAD_TYPE_PS5,
|
||||
}
|
||||
|
||||
SDL_GAMEPAD_BUTTON_SOUTH = 0
|
||||
SDL_GAMEPAD_BUTTON_EAST = 1
|
||||
SDL_GAMEPAD_BUTTON_WEST = 2
|
||||
SDL_GAMEPAD_BUTTON_NORTH = 3
|
||||
SDL_GAMEPAD_BUTTON_BACK = 4
|
||||
SDL_GAMEPAD_BUTTON_GUIDE = 5
|
||||
SDL_GAMEPAD_BUTTON_START = 6
|
||||
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
|
||||
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
|
||||
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
|
||||
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
|
||||
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
|
||||
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
|
||||
SDL_GAMEPAD_AXIS_LEFTX = 0
|
||||
SDL_GAMEPAD_AXIS_LEFTY = 1
|
||||
SDL_GAMEPAD_AXIS_RIGHTX = 2
|
||||
SDL_GAMEPAD_AXIS_RIGHTY = 3
|
||||
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
|
||||
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
|
||||
|
||||
|
||||
class GamepadType(Enum):
|
||||
XBOX = "Xbox"
|
||||
PLAYSTATION = "PlayStation"
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
|
||||
def _get_sdl3_error(sdl: ctypes.CDLL) -> str:
|
||||
error = sdl.SDL_GetError()
|
||||
if not error:
|
||||
return ""
|
||||
return error.decode(errors="replace")
|
||||
|
||||
|
||||
def _configure_sdl3_gamepad_api(sdl: ctypes.CDLL) -> None:
|
||||
sdl.SDL_InitSubSystem.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_InitSubSystem.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepads.argtypes = [ctypes.POINTER(ctypes.c_int)]
|
||||
sdl.SDL_GetGamepads.restype = ctypes.POINTER(ctypes.c_uint32)
|
||||
sdl.SDL_GetGamepadTypeForID.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_GetGamepadTypeForID.restype = ctypes.c_int
|
||||
sdl.SDL_GetGamepadNameForID.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_GetGamepadNameForID.restype = ctypes.c_char_p
|
||||
sdl.SDL_OpenGamepad.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_OpenGamepad.restype = ctypes.c_void_p
|
||||
sdl.SDL_CloseGamepad.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_CloseGamepad.restype = None
|
||||
sdl.SDL_GamepadConnected.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_GamepadConnected.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepadName.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_GetGamepadName.restype = ctypes.c_char_p
|
||||
sdl.SDL_GetGamepadButton.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
sdl.SDL_GetGamepadButton.restype = ctypes.c_bool
|
||||
sdl.SDL_GetGamepadAxis.argtypes = [ctypes.c_void_p, ctypes.c_int]
|
||||
sdl.SDL_GetGamepadAxis.restype = ctypes.c_int16
|
||||
sdl.SDL_UpdateGamepads.argtypes = []
|
||||
sdl.SDL_UpdateGamepads.restype = None
|
||||
sdl.SDL_PumpEvents.argtypes = []
|
||||
sdl.SDL_PumpEvents.restype = None
|
||||
sdl.SDL_QuitSubSystem.argtypes = [ctypes.c_uint32]
|
||||
sdl.SDL_QuitSubSystem.restype = None
|
||||
sdl.SDL_GetError.argtypes = []
|
||||
sdl.SDL_GetError.restype = ctypes.c_char_p
|
||||
sdl.SDL_free.argtypes = [ctypes.c_void_p]
|
||||
sdl.SDL_free.restype = None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_sdl3() -> ctypes.CDLL | None:
|
||||
library_names = (
|
||||
ctypes.util.find_library("SDL3"),
|
||||
"libSDL3.so.0",
|
||||
"libSDL3.so",
|
||||
)
|
||||
for library_name in library_names:
|
||||
if not library_name:
|
||||
continue
|
||||
try:
|
||||
sdl = ctypes.CDLL(library_name)
|
||||
_configure_sdl3_gamepad_api(sdl)
|
||||
except (AttributeError, OSError) as e:
|
||||
logger.debug("Failed to load SDL3 from %s: %s", library_name, e)
|
||||
continue
|
||||
if sdl.SDL_InitSubSystem(SDL_INIT_GAMEPAD):
|
||||
return sdl
|
||||
logger.debug("Failed to initialize SDL3 gamepad subsystem: %s", _get_sdl3_error(sdl))
|
||||
return None
|
||||
|
||||
|
||||
def _decode_sdl3_name(name: bytes | None) -> str:
|
||||
if not name:
|
||||
return ""
|
||||
return name.decode(errors="replace")
|
||||
|
||||
|
||||
@dataclass
|
||||
class SDLGamepad:
|
||||
sdl: ctypes.CDLL
|
||||
controller: ctypes.c_void_p
|
||||
name: str
|
||||
path: str
|
||||
instance_id: int
|
||||
|
||||
def close(self) -> None:
|
||||
try:
|
||||
self.sdl.SDL_CloseGamepad(self.controller)
|
||||
except (AttributeError, OSError) as e:
|
||||
logger.debug("Failed to close SDL gamepad: %s", e)
|
||||
|
||||
def connected(self) -> bool:
|
||||
return bool(self.sdl.SDL_GamepadConnected(self.controller))
|
||||
|
||||
def update(self) -> None:
|
||||
self.sdl.SDL_UpdateGamepads()
|
||||
|
||||
def get_button(self, button: int) -> int:
|
||||
return int(self.sdl.SDL_GetGamepadButton(self.controller, button))
|
||||
|
||||
def get_axis(self, axis: int) -> int:
|
||||
return int(self.sdl.SDL_GetGamepadAxis(self.controller, axis))
|
||||
|
||||
|
||||
def find_gamepad() -> SDLGamepad | None:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is None:
|
||||
return None
|
||||
sdl.SDL_PumpEvents()
|
||||
sdl.SDL_UpdateGamepads()
|
||||
count = ctypes.c_int()
|
||||
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
|
||||
if not gamepads:
|
||||
return None
|
||||
try:
|
||||
return _open_first_sdl3_gamepad(sdl, gamepads, count.value)
|
||||
finally:
|
||||
sdl.SDL_free(gamepads)
|
||||
|
||||
|
||||
def _open_first_sdl3_gamepad(
|
||||
sdl: ctypes.CDLL,
|
||||
gamepads: Any,
|
||||
count: int,
|
||||
) -> SDLGamepad | None:
|
||||
for index in range(count):
|
||||
instance_id = int(gamepads[index])
|
||||
gamepad = sdl.SDL_OpenGamepad(instance_id)
|
||||
if not gamepad:
|
||||
logger.debug("Skipping SDL3 gamepad %s: %s", instance_id, _get_sdl3_error(sdl))
|
||||
continue
|
||||
return SDLGamepad(
|
||||
sdl=sdl,
|
||||
controller=gamepad,
|
||||
name=_decode_sdl3_name(sdl.SDL_GetGamepadName(gamepad)),
|
||||
path=f"sdl3-gamepad:{instance_id}",
|
||||
instance_id=instance_id,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def detect_gamepad_type(gamepad: SDLGamepad) -> GamepadType:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is None:
|
||||
return GamepadType.XBOX
|
||||
count = ctypes.c_int()
|
||||
gamepads = sdl.SDL_GetGamepads(ctypes.byref(count))
|
||||
if not gamepads:
|
||||
return GamepadType.XBOX
|
||||
try:
|
||||
gamepad_type = _find_sdl3_gamepad_type(sdl, gamepads, count.value, gamepad.name)
|
||||
finally:
|
||||
sdl.SDL_free(gamepads)
|
||||
return gamepad_type or GamepadType.XBOX
|
||||
|
||||
|
||||
def _find_sdl3_gamepad_type(
|
||||
sdl: ctypes.CDLL,
|
||||
gamepads: Any,
|
||||
count: int,
|
||||
target_name: str,
|
||||
) -> GamepadType | None:
|
||||
for index in range(count):
|
||||
gamepad_id = gamepads[index]
|
||||
sdl_name = _decode_sdl3_name(sdl.SDL_GetGamepadNameForID(gamepad_id))
|
||||
if count > 1 and sdl_name.casefold() != target_name.casefold():
|
||||
continue
|
||||
gamepad_type = _gamepad_type_from_sdl3_value(sdl.SDL_GetGamepadTypeForID(gamepad_id))
|
||||
if gamepad_type is not None:
|
||||
return gamepad_type
|
||||
return None
|
||||
|
||||
|
||||
def _gamepad_type_from_sdl3_value(sdl_type: int) -> GamepadType | None:
|
||||
if sdl_type in SDL3_PLAYSTATION_TYPES:
|
||||
return GamepadType.PLAYSTATION
|
||||
if sdl_type in SDL3_XBOX_LIKE_TYPES:
|
||||
return GamepadType.XBOX
|
||||
return None
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
sdl = _load_sdl3()
|
||||
if sdl is not None:
|
||||
sdl.SDL_QuitSubSystem(SDL_INIT_GAMEPAD)
|
||||
@@ -6,7 +6,6 @@ import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
@@ -26,7 +25,6 @@ GOG_LOGIN_URL = (
|
||||
)
|
||||
GOGDL_RELEASE_URL = "https://api.github.com/repos/Heroic-Games-Launcher/heroic-gogdl/releases/latest"
|
||||
GOGDL_AUTH_TIMEOUT = 60
|
||||
GOGDL_UPDATE_INTERVAL = 30 * 24 * 60 * 60
|
||||
GOG_USER_TIMEOUT = 15
|
||||
GOG_METADATA_WORKERS = 4
|
||||
GOG_SETUP_MARKER_VERSION = 1
|
||||
@@ -51,7 +49,6 @@ class GOGAPI:
|
||||
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.library_path = self.data_dir / "library.json"
|
||||
self.installed_path = self.data_dir / "installed.json"
|
||||
self.sizes_path = self.data_dir / "sizes.json"
|
||||
@@ -79,59 +76,19 @@ class GOGAPI:
|
||||
existing = self.get_gogdl_path()
|
||||
if existing:
|
||||
return existing
|
||||
asset, release_tag = self._get_latest_gogdl_release()
|
||||
return self._install_gogdl_release(asset, release_tag)
|
||||
|
||||
def update_gogdl(self) -> str:
|
||||
"""Update the bundled gogdl binary to the latest official release."""
|
||||
bundled = self.data_dir / "bin/gogdl"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
try:
|
||||
last_check = self.gogdl_version_path.stat().st_mtime
|
||||
except OSError:
|
||||
last_check = 0
|
||||
if time.time() - last_check < GOGDL_UPDATE_INTERVAL:
|
||||
return str(bundled)
|
||||
self.gogdl_version_path.touch()
|
||||
asset, release_tag = self._get_latest_gogdl_release()
|
||||
if (
|
||||
bundled.is_file()
|
||||
and os.access(bundled, os.X_OK)
|
||||
and self._get_gogdl_release_tag() == release_tag
|
||||
):
|
||||
return str(bundled)
|
||||
return self._install_gogdl_release(asset, release_tag)
|
||||
|
||||
def _get_latest_gogdl_release(self) -> tuple[dict, str]:
|
||||
"""Return the matching asset and tag from the latest gogdl release."""
|
||||
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(platform.machine())
|
||||
if not architecture:
|
||||
raise OSError(f"Unsupported gogdl architecture: {platform.machine()}")
|
||||
response = requests.get(GOGDL_RELEASE_URL, timeout=15)
|
||||
response.raise_for_status()
|
||||
release = response.json()
|
||||
asset_name = f"gogdl_linux_{architecture}"
|
||||
asset = next(
|
||||
(item for item in release.get("assets", []) if item.get("name") == asset_name),
|
||||
(item for item in response.json().get("assets", []) if item.get("name") == asset_name),
|
||||
None,
|
||||
)
|
||||
if not asset:
|
||||
raise OSError(f"gogdl release asset not found: {asset_name}")
|
||||
release_tag = str(release.get("tag_name", ""))
|
||||
if not release_tag:
|
||||
raise OSError("gogdl release tag not found")
|
||||
return asset, release_tag
|
||||
|
||||
def _get_gogdl_release_tag(self) -> str:
|
||||
try:
|
||||
return self.gogdl_version_path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def _install_gogdl_release(self, asset: dict, release_tag: str) -> str:
|
||||
path = self._download_gogdl_asset(asset)
|
||||
self.gogdl_version_path.write_text(release_tag, encoding="utf-8")
|
||||
return path
|
||||
return self._download_gogdl_asset(asset)
|
||||
|
||||
def _download_gogdl_asset(self, asset: dict) -> str:
|
||||
target = self.data_dir / "bin/gogdl"
|
||||
@@ -495,8 +452,6 @@ class GOGAPI:
|
||||
"""Run GOG setup and expose its process for UI cancellation."""
|
||||
process = subprocess.Popen(
|
||||
command, cwd=install_path, start_new_session=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, errors="replace", bufsize=1,
|
||||
)
|
||||
if process_callback:
|
||||
process_callback(process)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import io
|
||||
import pefile
|
||||
@@ -16,36 +15,6 @@ RT_GROUP_ICON = 14
|
||||
THUMBNAIL_SIZE = 128
|
||||
|
||||
|
||||
def _resolve_batch_icon_target(inputfile: str) -> str | None:
|
||||
"""Return an executable launched by a neighboring command file."""
|
||||
input_dir = os.path.dirname(inputfile)
|
||||
try:
|
||||
command_files = sorted(
|
||||
filename for filename in os.listdir(input_dir)
|
||||
if filename.lower().endswith((".bat", ".cmd"))
|
||||
)
|
||||
except (OSError, ValueError) as error:
|
||||
logger.warning("Failed to inspect command files in %s: %s", input_dir, error)
|
||||
return None
|
||||
|
||||
for filename in command_files:
|
||||
batch_path = os.path.join(input_dir, filename)
|
||||
try:
|
||||
with open(batch_path, encoding="utf-8") as batch_file:
|
||||
for line in batch_file:
|
||||
for token in shlex.split(line, posix=False):
|
||||
exe_name = token.strip('"')
|
||||
exe_path = os.path.join(input_dir, exe_name)
|
||||
if (
|
||||
exe_name.lower().endswith(".exe")
|
||||
and os.path.isfile(exe_path)
|
||||
):
|
||||
return exe_path
|
||||
except (OSError, UnicodeError, ValueError) as error:
|
||||
logger.warning("Failed to process command file %s: %s", batch_path, error)
|
||||
return None
|
||||
|
||||
|
||||
def get_exe_icon_cache_path(exe_path: str) -> str:
|
||||
"""Return the shared cache path for an executable icon."""
|
||||
cache_home = os.getenv(
|
||||
@@ -75,10 +44,6 @@ def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resi
|
||||
try:
|
||||
extractor = IconExtractor(inputfile)
|
||||
data = extractor.get_icon()
|
||||
if data is None:
|
||||
target_path = _resolve_batch_icon_target(inputfile)
|
||||
if target_path:
|
||||
data = IconExtractor(target_path).get_icon()
|
||||
if data is None:
|
||||
return False
|
||||
|
||||
@@ -90,18 +55,14 @@ def generate_thumbnail(inputfile: str, outfile: str, size: int = 128, force_resi
|
||||
im.save(outfile, "PNG")
|
||||
logger.info("Thumbnail successfully saved to %s", outfile)
|
||||
return True
|
||||
except IconExtractorError as error:
|
||||
logger.warning("Icon extraction failed: %s", error)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error("Error generating thumbnail: %s", error)
|
||||
except Exception as e:
|
||||
logger.error("Error generating thumbnail: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
class IconExtractorError(Exception):
|
||||
"""Base exception for icon extraction errors."""
|
||||
|
||||
|
||||
class IconExtractor:
|
||||
"""Extracts icons from NE and PE files."""
|
||||
|
||||
@@ -111,10 +72,7 @@ class IconExtractor:
|
||||
self._groups = [] # List of group icon data
|
||||
|
||||
def get_icon(self) -> io.BytesIO | None:
|
||||
"""Extract the best quality icon and return it as image data.
|
||||
|
||||
Raises IconExtractorError when the source cannot be read or parsed.
|
||||
"""
|
||||
"""Extract the best quality icon and return it as image data."""
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
if not self._parse_file(f):
|
||||
@@ -128,10 +86,9 @@ class IconExtractor:
|
||||
if data is None:
|
||||
return None
|
||||
return self._icon_data_to_image(data)
|
||||
except Exception as error:
|
||||
raise IconExtractorError(
|
||||
f"Failed to extract icon from {self.file_path}: {error}"
|
||||
) from error
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract icon from %s: %s", self.file_path, e)
|
||||
return None
|
||||
|
||||
def _parse_file(self, f) -> bool:
|
||||
"""Identify file type and call appropriate parser."""
|
||||
|
||||
@@ -9,7 +9,7 @@ from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraph
|
||||
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
|
||||
from PIL import Image, ImageQt, ImageSequence
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -23,7 +23,6 @@ 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()
|
||||
@@ -397,16 +396,15 @@ def load_pixmap_async(
|
||||
def process_image():
|
||||
theme_manager = ThemeManager()
|
||||
current_theme_name = ui_config.get_theme()
|
||||
theme = theme_manager.current_theme_module or load_theme(current_theme_name)
|
||||
|
||||
def finish_with(pixmap: QPixmap):
|
||||
# Check if pixmap is valid before attempting to scale it
|
||||
if pixmap.isNull():
|
||||
# Create a default placeholder pixmap instead of trying to scale a null pixmap
|
||||
placeholder_pixmap = QPixmap(width, height)
|
||||
placeholder_pixmap.fill(QColor(theme.color_placeholder_bg))
|
||||
placeholder_pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(placeholder_pixmap)
|
||||
painter.setPen(QPen(QColor(theme.color_white)))
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
callback(placeholder_pixmap)
|
||||
@@ -434,9 +432,9 @@ def load_pixmap_async(
|
||||
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
|
||||
return pixmap
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor(theme.color_placeholder_bg))
|
||||
pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setPen(QPen(QColor(theme.color_white)))
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
return pixmap
|
||||
@@ -490,11 +488,7 @@ def load_pixmap_async(
|
||||
finish_with(pixmap)
|
||||
|
||||
finish_with(load_fallback_pixmap())
|
||||
downloader.download_async(
|
||||
cover, local_path,
|
||||
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
@@ -529,11 +523,7 @@ 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=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
@@ -564,11 +554,7 @@ def load_pixmap_async(
|
||||
finish_with(pixmap)
|
||||
|
||||
finish_with(load_fallback_pixmap())
|
||||
downloader.download_async(
|
||||
cover, local_path,
|
||||
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Error processing remote image URL %s: %s", cover, str(e))
|
||||
@@ -619,7 +605,6 @@ def round_corners(pixmap, radius):
|
||||
painter.end()
|
||||
return rounded
|
||||
|
||||
|
||||
class FullscreenDialog(QDialog):
|
||||
"""
|
||||
Dialog for viewing images without standard controls.
|
||||
@@ -705,9 +690,9 @@ class FullscreenDialog(QDialog):
|
||||
if pixmap.isNull():
|
||||
# Create a default placeholder pixmap instead of trying to scale a null pixmap
|
||||
placeholder_pixmap = QPixmap(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
|
||||
placeholder_pixmap.fill(QColor(self.theme.color_placeholder_bg))
|
||||
placeholder_pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(placeholder_pixmap)
|
||||
painter.setPen(QPen(QColor(self.theme.color_white)))
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
self.imageLabel.setPixmap(placeholder_pixmap)
|
||||
|
||||
@@ -1,632 +0,0 @@
|
||||
import time
|
||||
import os
|
||||
import math
|
||||
from typing import Protocol, cast, Any
|
||||
from evdev import UInput, ecodes
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QMenu, QMessageBox, QTableWidget, QSlider
|
||||
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 AnimatedCard
|
||||
from portprotonqt.config import display_config, window_config
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
BUTTONS,
|
||||
GamepadType as GamepadType,
|
||||
PAD_AXIS_RIGHT_Y,
|
||||
PAD_DPAD_X,
|
||||
PAD_DPAD_Y,
|
||||
)
|
||||
from portprotonqt.native_gamepad import SDLGamepad
|
||||
from portprotonqt.input_manager.buttons import ButtonInputMixin
|
||||
from portprotonqt.input_manager.dialog_modes import DialogInputModesMixin
|
||||
from portprotonqt.input_manager.dpad import DpadInputMixin
|
||||
from portprotonqt.input_manager.file_explorer import FileExplorerInputMixin
|
||||
from portprotonqt.input_manager.keyboard import KeyboardInputMixin
|
||||
from portprotonqt.input_manager.runtime import GamepadRuntimeMixin
|
||||
from portprotonqt.input_manager.settings import SettingsInputMixin
|
||||
from portprotonqt.input_manager.settings_visual import SettingsVisualNavigationMixin
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class MainWindowProtocol(Protocol):
|
||||
def activateFocusedWidget(self) -> None:
|
||||
...
|
||||
def goBackDetailPage(self, page: QWidget | None) -> None:
|
||||
...
|
||||
def switchTab(self, index: int) -> None:
|
||||
...
|
||||
def openAddGameDialog(self, exe_path: str | None = None) -> None:
|
||||
...
|
||||
def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None:
|
||||
...
|
||||
def on_slider_released(self) -> None:
|
||||
...
|
||||
def on_auto_slider_released(self) -> None:
|
||||
...
|
||||
def isActiveWindow(self) -> bool:
|
||||
...
|
||||
def refreshGames(self) -> None:
|
||||
...
|
||||
def handleSystemTableGamepadAction(self, table: QTableWidget, action: str) -> bool:
|
||||
...
|
||||
def handleSystemGamepadAction(self, action: str) -> bool:
|
||||
...
|
||||
stackedWidget: QStackedWidget
|
||||
tabButtons: dict[int, QWidget]
|
||||
gamesListWidget: QWidget
|
||||
autoInstallContainer: QWidget | None
|
||||
currentDetailPage: QWidget | None
|
||||
current_exec_line: str | None
|
||||
current_add_game_dialog: AddGameDialog | None
|
||||
game_library_manager: Any # GameLibraryManager - using Any to avoid circular import
|
||||
auto_size_slider: QSlider | None
|
||||
|
||||
class MouseEmulationThread(QThread):
|
||||
"""Thread for creating UInput virtual mouse device without blocking UI."""
|
||||
|
||||
finished = Signal(bool) # Emitted when done (True = success, False = failed)
|
||||
|
||||
def run(self):
|
||||
"""Run UInput device creation in background thread."""
|
||||
try:
|
||||
if not os.path.exists('/dev/uinput'):
|
||||
logger.error("EMUL: /dev/uinput does not exist")
|
||||
self.finished.emit(False)
|
||||
return
|
||||
|
||||
if not os.access('/dev/uinput', os.W_OK):
|
||||
logger.error("EMUL: No write access to /dev/uinput")
|
||||
self.finished.emit(False)
|
||||
return
|
||||
|
||||
ui = UInput({
|
||||
ecodes.EV_KEY: [ecodes.BTN_LEFT, ecodes.BTN_RIGHT],
|
||||
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
|
||||
}, name="Virtual DPad Mouse")
|
||||
|
||||
self.finished.emit(True)
|
||||
# Store device in thread result
|
||||
self._device = ui
|
||||
|
||||
except PermissionError as e:
|
||||
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
|
||||
self.finished.emit(False)
|
||||
except Exception as ex:
|
||||
logger.error(f"EMUL: Error creating virtual mouse: {ex}", exc_info=True)
|
||||
self.finished.emit(False)
|
||||
|
||||
def get_device(self) -> UInput | None:
|
||||
"""Get the created UInput device after thread finishes."""
|
||||
return getattr(self, '_device', None)
|
||||
|
||||
|
||||
class InputManager(
|
||||
FileExplorerInputMixin,
|
||||
DialogInputModesMixin,
|
||||
SettingsInputMixin,
|
||||
SettingsVisualNavigationMixin,
|
||||
ButtonInputMixin,
|
||||
DpadInputMixin,
|
||||
KeyboardInputMixin,
|
||||
GamepadRuntimeMixin,
|
||||
QObject,
|
||||
):
|
||||
"""
|
||||
Manages input from gamepads and keyboards for navigating the application interface.
|
||||
Supports gamepad hotplugging, button and axis events, and keyboard shortcuts.
|
||||
"""
|
||||
# Signals for gamepad events
|
||||
button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release)
|
||||
dpad_moved = Signal(int, int, float) # Signal for D-pad movements
|
||||
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
|
||||
gamepad_hotplug = Signal(str) # 'add' or 'remove'
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
main_window: MainWindowProtocol,
|
||||
axis_deadzone: float = 0.5,
|
||||
initial_axis_move_delay: float = 0.5,
|
||||
repeat_axis_move_delay: float = 0.15
|
||||
):
|
||||
super().__init__(cast(QObject, main_window))
|
||||
self._parent = main_window
|
||||
self._gamepad_handling_enabled = True
|
||||
self.gamepad_type = self._get_configured_gamepad_type()
|
||||
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
|
||||
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
|
||||
self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None)
|
||||
self._parent.autoInstallContainer = getattr(self._parent, 'autoInstallContainer', None)
|
||||
self.axis_deadzone = axis_deadzone
|
||||
self.initial_axis_move_delay = initial_axis_move_delay
|
||||
self.repeat_axis_move_delay = repeat_axis_move_delay
|
||||
self.current_axis_delay = initial_axis_move_delay
|
||||
self.last_move_time = 0.0
|
||||
self.axis_moving = False
|
||||
self.gamepad: SDLGamepad | None = None
|
||||
self.running = True
|
||||
self._is_fullscreen = display_config.get_fullscreen()
|
||||
self.lt_pressed = False
|
||||
self.rt_pressed = False
|
||||
self.last_trigger_time = 0.0
|
||||
self.trigger_cooldown = 0.2
|
||||
|
||||
# Mouse emulation attributes
|
||||
self.mouse_emulation_enabled = True
|
||||
self.ui = None
|
||||
self.mouse_emulation_thread: MouseEmulationThread | None = None
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
|
||||
# Axis parameters (will be filled from kernel)
|
||||
self.center_x = 127 # X axis center
|
||||
self.center_y = 127 # Y axis center
|
||||
self.min_value = 0 # axis minimum
|
||||
self.max_value = 255 # axis maximum
|
||||
self.deadzone_value = 15 # deadzone from kernel (flat parameter)
|
||||
self.scroll_axis_code = PAD_AXIS_RIGHT_Y
|
||||
self.scroll_center = self.center_y
|
||||
self.scroll_min_value = self.min_value
|
||||
self.scroll_max_value = self.max_value
|
||||
self.scroll_deadzone_value = self.deadzone_value
|
||||
|
||||
self.sensitivity = 20.0
|
||||
|
||||
# Dynamic attributes for different modes (declared here to satisfy type checkers)
|
||||
self.winetricks_dialog: Any = None
|
||||
self.settings_dialog: Any = None
|
||||
self.file_explorer: Any = None
|
||||
self.proton_manager_dialog: Any = None
|
||||
self.appimage_update_dialog: Any = None
|
||||
self._input_surfaces: list[Any] = []
|
||||
self._input_surface_base_state: bool | None = None
|
||||
self.scroll_accumulator = 0.0
|
||||
self.scroll_sensitivity = 0.15
|
||||
self.scroll_threshold = 0.2
|
||||
self.last_update = time.time()
|
||||
self.update_interval = 0.016 # ~60 FPS
|
||||
self.emulation_active = False
|
||||
self.emulation_triggered = False
|
||||
self.start_held = False
|
||||
self.select_held = False
|
||||
self.pending_menu_fullscreen_time = 0.0
|
||||
self.guide_held = False
|
||||
# Variables for key combination handling
|
||||
self.guide_pressed_time = 0
|
||||
self.select_pressed_time = 0
|
||||
self.guide_timer = QTimer(self)
|
||||
self.guide_timer.setSingleShot(True)
|
||||
self.guide_timer.timeout.connect(self._handle_guide_timeout)
|
||||
self.guide_combination_timeout = 0.3 # 300ms timeout for combination
|
||||
self.in_guide_combination_attempt = False # Flag to track if we're in a guide+select combination attempt
|
||||
self._button_states: dict[int, int] = {}
|
||||
self._hat_states: dict[int, tuple[int, int]] = {}
|
||||
self._axis_states: dict[int, int] = {}
|
||||
self._gamepad_polling_suspended = False
|
||||
self._last_gamepad_check_time = 0.0
|
||||
self._last_gamepad_error: str | None = None
|
||||
|
||||
# Focus check timer for emulation flag (runs in main thread)
|
||||
self.focus_check_timer = QTimer(self)
|
||||
self.focus_check_timer.timeout.connect(self._update_emulation_flag)
|
||||
self.focus_check_timer.start(100) # Check every 100ms
|
||||
|
||||
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
|
||||
|
||||
if self.mouse_emulation_enabled:
|
||||
# Initialize mouse emulation asynchronously to avoid blocking startup
|
||||
QTimer.singleShot(0, self._async_enable_mouse_emulation)
|
||||
|
||||
# FileExplorer specific attributes
|
||||
self.file_explorer = None
|
||||
self.nav_timer = QTimer(self)
|
||||
self.nav_timer.timeout.connect(self.handle_navigation_repeat)
|
||||
self.current_direction = 0
|
||||
self.last_nav_time = 0
|
||||
self.initial_nav_delay = 0.1 # Initial delay before first repeat (sec)
|
||||
self.repeat_nav_delay = 0.05 # Interval between repeats (sec)
|
||||
self.stick_activated = False
|
||||
self.stick_value = 0 # Current stick value (for smoothness)
|
||||
self.dead_zone = 8000 # Stick deadzone
|
||||
|
||||
self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower()
|
||||
|
||||
# Add variables for continuous D-pad movement
|
||||
self.dpad_timer = QTimer(self)
|
||||
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
|
||||
self.current_dpad_code = None # Tracks the current D-pad axis (e.g., ABS_HAT0X, ABS_HAT0Y)
|
||||
self.current_dpad_value = 0 # Tracks the current D-pad direction value (e.g., -1, 1)
|
||||
|
||||
# Connect signals to slots
|
||||
self.button_event.connect(
|
||||
self.handle_button_slot,
|
||||
Qt.ConnectionType.QueuedConnection,
|
||||
)
|
||||
self.dpad_moved.connect(
|
||||
self.handle_dpad_slot,
|
||||
Qt.ConnectionType.QueuedConnection,
|
||||
)
|
||||
self.toggle_fullscreen.connect(self.handle_fullscreen_slot)
|
||||
|
||||
# Install wheel event filter
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.installEventFilter(self)
|
||||
|
||||
self.init_gamepad()
|
||||
|
||||
def _async_enable_mouse_emulation(self):
|
||||
"""Asynchronously enable mouse emulation to avoid blocking startup."""
|
||||
logger.info("EMUL: Attempting to create UInput virtual mouse...")
|
||||
self.mouse_emulation_thread = MouseEmulationThread()
|
||||
self.mouse_emulation_thread.finished.connect(self._on_mouse_emulation_finished)
|
||||
self.mouse_emulation_thread.start()
|
||||
|
||||
def _on_mouse_emulation_finished(self, success: bool):
|
||||
"""Handle mouse emulation thread completion."""
|
||||
if success and self.mouse_emulation_thread:
|
||||
device = self.mouse_emulation_thread.get_device()
|
||||
if device:
|
||||
self.ui = device
|
||||
self.mouse_emulation_enabled = True
|
||||
logger.info("EMUL: Virtual mouse created successfully")
|
||||
else:
|
||||
self.mouse_emulation_enabled = False
|
||||
|
||||
def _update_emulation_flag(self):
|
||||
"""Update emulation_active flag based on Qt app focus (main thread only)."""
|
||||
# True when mouse emulation is enabled (works regardless of focus)
|
||||
# The difference is whether other inputs are disabled (based on focus)
|
||||
self.emulation_active = self.mouse_emulation_enabled
|
||||
if not self.emulation_active:
|
||||
self.emulation_triggered = False
|
||||
|
||||
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
|
||||
"""Common navigation logic for game cards in a container."""
|
||||
if container is None:
|
||||
return
|
||||
game_cards = [
|
||||
card for card in container.findChildren(AnimatedCard)
|
||||
if card.isVisible() and card.isEnabled()
|
||||
]
|
||||
if self._navigate_horizontal_library(game_cards, code, value):
|
||||
return
|
||||
focused = QApplication.focusWidget()
|
||||
if game_cards and focused not in game_cards:
|
||||
self._focus_grid_card(game_cards[0])
|
||||
return
|
||||
moved = self._navigate_card_grid(game_cards, code, value)
|
||||
if not moved and code == PAD_DPAD_Y and value < 0 and focused in game_cards:
|
||||
first_row = self._get_card_grid_rows(game_cards)[0]
|
||||
if focused in first_row:
|
||||
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _navigate_horizontal_library(
|
||||
self, cards: list[QWidget], code: int, value: int
|
||||
) -> bool:
|
||||
manager = getattr(self._parent, "game_library_manager", None)
|
||||
if getattr(manager, "layout_mode", "grid") not in {
|
||||
"horizontal", "horizontal_top"
|
||||
}:
|
||||
return False
|
||||
layout = getattr(manager, "gamesListLayout", None)
|
||||
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 = {}
|
||||
y_tolerance = 10 # Allow slight variations in y-position
|
||||
for card in cards:
|
||||
y = card.pos().y()
|
||||
for row_y in rows:
|
||||
if abs(y - row_y) <= y_tolerance:
|
||||
rows[row_y].append(card)
|
||||
break
|
||||
else:
|
||||
rows[y] = [card]
|
||||
sorted_rows = [row for _y, row in sorted(rows.items())]
|
||||
for row_cards in sorted_rows:
|
||||
row_cards.sort(key=lambda card: card.pos().x())
|
||||
return sorted_rows
|
||||
|
||||
def _grid_navigation_target(
|
||||
self, rows: list[list[QWidget]], focused: QWidget, code: int, value: int
|
||||
) -> QWidget | None:
|
||||
row_index = next(index for index, row in enumerate(rows) if focused in row)
|
||||
if code == PAD_DPAD_X:
|
||||
flat_cards = [card for row in rows for card in row]
|
||||
card_index = flat_cards.index(focused) + value
|
||||
return flat_cards[card_index] if 0 <= card_index < len(flat_cards) else None
|
||||
target_row_index = row_index + value
|
||||
if not 0 <= target_row_index < len(rows):
|
||||
return None
|
||||
focused_x = focused.pos().x() + focused.width() / 2
|
||||
return min(
|
||||
rows[target_row_index],
|
||||
key=lambda card: abs(card.pos().x() + card.width() / 2 - focused_x),
|
||||
)
|
||||
|
||||
def _focus_grid_card(self, card: QWidget) -> None:
|
||||
card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
scroll_area = card.parentWidget()
|
||||
while scroll_area and not isinstance(scroll_area, QScrollArea):
|
||||
scroll_area = scroll_area.parentWidget()
|
||||
if isinstance(scroll_area, QScrollArea):
|
||||
scroll_area.ensureWidgetVisible(card, 50, 50)
|
||||
|
||||
def _navigate_card_grid(self, cards: list[QWidget], code: int, value: int) -> bool:
|
||||
"""Move focus through a card grid using its visual layout."""
|
||||
if not cards or value == 0:
|
||||
return False
|
||||
rows = self._get_card_grid_rows(cards)
|
||||
focused = QApplication.focusWidget()
|
||||
if focused not in cards:
|
||||
return False
|
||||
target = self._grid_navigation_target(rows, focused, code, value)
|
||||
if target is None:
|
||||
return False
|
||||
self._focus_grid_card(target)
|
||||
return True
|
||||
|
||||
def handle_navigation_repeat(self):
|
||||
"""Smooth movement repeat with variable speed for FileExplorer"""
|
||||
try:
|
||||
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
|
||||
return
|
||||
|
||||
if self.current_direction != 0:
|
||||
now = time.time()
|
||||
# Dynamic interval based on stick_value
|
||||
dynamic_delay = self.repeat_nav_delay / self.stick_value
|
||||
if now - self.last_nav_time >= dynamic_delay:
|
||||
self.file_explorer.move_selection(self.current_direction)
|
||||
self.last_nav_time = now
|
||||
except Exception as e:
|
||||
logger.error(f"Error in navigation repeat: {e}")
|
||||
|
||||
def disable_mouse_emulation(self):
|
||||
"""Disable mouse emulation mode (closes virtual mouse device)."""
|
||||
logger.info("EMUL: Disabling mouse emulation...")
|
||||
|
||||
# Stop the thread if still running
|
||||
thread = self.mouse_emulation_thread
|
||||
if thread and thread.isRunning():
|
||||
thread.wait()
|
||||
self.mouse_emulation_thread = None
|
||||
|
||||
if self.ui:
|
||||
try:
|
||||
self.ui.close()
|
||||
logger.info("EMUL: Virtual mouse closed")
|
||||
except Exception as e:
|
||||
logger.error("EMUL: Error closing virtual mouse: %s", e)
|
||||
self.ui = None
|
||||
self.mouse_emulation_enabled = False
|
||||
self.stick_x_raw = 0
|
||||
self.stick_y_raw = 0
|
||||
self.scroll_accumulator = 0.0
|
||||
|
||||
|
||||
def handle_scroll(self, raw_value):
|
||||
"""Handle scrolling from right stick Y"""
|
||||
if not self._is_mouse_emulation_active() or not self.ui:
|
||||
return
|
||||
|
||||
# Normalize from center
|
||||
centered_value = raw_value - self.scroll_center
|
||||
|
||||
if abs(centered_value) < self.scroll_deadzone_value:
|
||||
self.scroll_accumulator = 0.0
|
||||
return
|
||||
|
||||
# Normalize value (-1.0 to 1.0)
|
||||
range_val = (self.scroll_max_value - self.scroll_min_value) / 2
|
||||
if range_val <= 0:
|
||||
return
|
||||
normalized = centered_value / range_val
|
||||
|
||||
# Accumulate scroll
|
||||
self.scroll_accumulator += normalized * self.scroll_sensitivity
|
||||
|
||||
# Send scroll events
|
||||
while abs(self.scroll_accumulator) >= self.scroll_threshold:
|
||||
scroll_step = 1 if self.scroll_accumulator > 0 else -1
|
||||
self.scroll_wheel(-scroll_step)
|
||||
self.scroll_accumulator -= scroll_step * self.scroll_threshold
|
||||
|
||||
def update_mouse_position(self):
|
||||
"""Constant update of mouse position based on stick state"""
|
||||
if not self.ui or not self.emulation_active:
|
||||
return
|
||||
|
||||
# Center values
|
||||
x = self.stick_x_raw - self.center_x
|
||||
y = self.stick_y_raw - self.center_y
|
||||
|
||||
# Apply deadzone from kernel
|
||||
magnitude = math.sqrt(x * x + y * y)
|
||||
|
||||
if magnitude < self.deadzone_value:
|
||||
return
|
||||
|
||||
if magnitude > 0:
|
||||
norm_x = x / magnitude
|
||||
norm_y = y / magnitude
|
||||
else:
|
||||
return
|
||||
|
||||
# Normalize to axis range
|
||||
max_range = (self.max_value - self.min_value) / 2
|
||||
adjusted_magnitude = (magnitude - self.deadzone_value) / (max_range - self.deadzone_value)
|
||||
adjusted_magnitude = max(0.0, min(1.0, adjusted_magnitude))
|
||||
|
||||
# Non-linear curve
|
||||
adjusted_magnitude = math.pow(adjusted_magnitude, 1.5)
|
||||
|
||||
speed = adjusted_magnitude * self.sensitivity
|
||||
dx = int(norm_x * speed)
|
||||
dy = int(norm_y * speed)
|
||||
|
||||
if dx != 0 or dy != 0:
|
||||
self.move_mouse(dx, dy)
|
||||
|
||||
def move_mouse(self, dx, dy):
|
||||
"""Move system cursor"""
|
||||
if self.ui:
|
||||
self.ui.write(ecodes.EV_REL, ecodes.REL_X, dx)
|
||||
self.ui.write(ecodes.EV_REL, ecodes.REL_Y, dy)
|
||||
self.ui.syn()
|
||||
|
||||
def scroll_wheel(self, steps):
|
||||
"""Mouse wheel scroll"""
|
||||
if self.ui:
|
||||
self.ui.write(ecodes.EV_REL, ecodes.REL_WHEEL, steps)
|
||||
self.ui.syn()
|
||||
|
||||
def click_left(self):
|
||||
"""Left mouse button click"""
|
||||
if self.ui:
|
||||
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 1)
|
||||
self.ui.syn()
|
||||
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 0)
|
||||
self.ui.syn()
|
||||
|
||||
def click_right(self):
|
||||
"""Right mouse button click"""
|
||||
if self.ui:
|
||||
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 1)
|
||||
self.ui.syn()
|
||||
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 0)
|
||||
self.ui.syn()
|
||||
|
||||
@Slot(bool)
|
||||
def handle_fullscreen_slot(self, enable: bool) -> None:
|
||||
try:
|
||||
window = self._parent
|
||||
if not isinstance(window, QWidget):
|
||||
return
|
||||
if enable and not self._is_fullscreen:
|
||||
if not window.isFullScreen():
|
||||
window_config.set_geometry(window.width(), window.height())
|
||||
window.showFullScreen()
|
||||
self._is_fullscreen = True
|
||||
elif not enable and self._is_fullscreen:
|
||||
window.showNormal()
|
||||
width, height = window_config.get_geometry()
|
||||
if width > 0 and height > 0:
|
||||
window.resize(width, height)
|
||||
self._is_fullscreen = False
|
||||
window_config.set_geometry(width, height)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
|
||||
|
||||
def disable_gamepad_handling(self) -> None:
|
||||
"""Disable gamepad event handling."""
|
||||
self._gamepad_handling_enabled = False
|
||||
self.dpad_timer.stop()
|
||||
self.nav_timer.stop()
|
||||
|
||||
def enable_gamepad_handling(self) -> None:
|
||||
"""Enable gamepad event handling."""
|
||||
self._gamepad_handling_enabled = True
|
||||
|
||||
def suspend_gamepad_polling(self) -> None:
|
||||
"""Disable PPQT gamepad handling while keeping mouse emulation available."""
|
||||
self._gamepad_polling_suspended = True
|
||||
self._gamepad_handling_enabled = False
|
||||
self.dpad_timer.stop()
|
||||
self.nav_timer.stop()
|
||||
|
||||
def resume_gamepad_polling(self) -> None:
|
||||
"""Resume SDL controller polling after the external game exits."""
|
||||
self._gamepad_polling_suspended = False
|
||||
self._gamepad_handling_enabled = True
|
||||
self.check_gamepad()
|
||||
|
||||
def _handle_guide_timeout(self) -> None:
|
||||
if self.guide_held:
|
||||
time_since_guide = time.time() - self.guide_pressed_time
|
||||
time_since_select = time.time() - self.select_pressed_time
|
||||
|
||||
if (self.select_pressed_time > self.guide_pressed_time and
|
||||
time_since_select <= self.guide_combination_timeout and
|
||||
time_since_guide <= self.guide_combination_timeout):
|
||||
logger.debug("Guide + Select combination detected, refreshing game grid")
|
||||
self._parent.refreshGames()
|
||||
else:
|
||||
logger.debug("Guide button pressed alone")
|
||||
|
||||
self._reset_guide_combination()
|
||||
|
||||
|
||||
def _handle_common_ui_elements(self, button_code):
|
||||
"""
|
||||
Common handler for common UI elements like QMessageBox, QMenu, etc.
|
||||
Returns True if the event was handled, False otherwise.
|
||||
"""
|
||||
# Check for popup widgets first
|
||||
popup = QApplication.activePopupWidget()
|
||||
if popup:
|
||||
if isinstance(popup, QMessageBox):
|
||||
self._handle_qmessagebox_button(popup, button_code)
|
||||
SoundManager().play("click")
|
||||
return True
|
||||
elif isinstance(popup, QMenu):
|
||||
if button_code in BUTTONS['confirm']:
|
||||
SoundManager().play("click")
|
||||
if popup.activeAction():
|
||||
popup.activeAction().trigger()
|
||||
popup.close()
|
||||
elif button_code in BUTTONS['back']:
|
||||
SoundManager().play("back")
|
||||
popup.close()
|
||||
return True
|
||||
|
||||
# Check for top-level QMessageBox specifically
|
||||
active = QApplication.activeWindow()
|
||||
if isinstance(active, QMessageBox):
|
||||
self._handle_qmessagebox_button(active, button_code)
|
||||
return True
|
||||
|
||||
# Check for top-level message boxes (additional check)
|
||||
for widget in QApplication.topLevelWidgets():
|
||||
if isinstance(widget, QMessageBox) and widget.isVisible() and widget != active:
|
||||
self._handle_qmessagebox_button(widget, button_code)
|
||||
return True
|
||||
|
||||
return False # Not handled by common handler
|
||||
|
||||
def _handle_qmessagebox_button(self, msg_box, button_code):
|
||||
"""
|
||||
Unified handler for QMessageBox across all modes.
|
||||
For single button dialogs, A button accepts the dialog.
|
||||
For multiple button dialogs, navigate between buttons and allow selection.
|
||||
"""
|
||||
if button_code in BUTTONS['confirm']:
|
||||
# Check if there's a focused button in the message box
|
||||
focused_widget = msg_box.focusWidget()
|
||||
if focused_widget:
|
||||
# If a specific button is focused, click/activate it
|
||||
focused_widget.click()
|
||||
else:
|
||||
# If no button is focused, accept with default behavior
|
||||
msg_box.accept()
|
||||
elif button_code in BUTTONS['back']:
|
||||
# For back button, reject the dialog (typically cancels or selects cancel button)
|
||||
msg_box.reject()
|
||||
@@ -1,377 +0,0 @@
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import QPoint, QTimer, Slot, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QLineEdit,
|
||||
QListView,
|
||||
QMenu,
|
||||
QSlider,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
BUTTONS,
|
||||
SIZE_SLIDER_STEP,
|
||||
SYSTEM_AUDIO_SECTION_INDEX,
|
||||
SYSTEM_VOLUME_STEP,
|
||||
)
|
||||
from portprotonqt.input_manager.mixin import InputMixin
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class ButtonInputMixin(InputMixin):
|
||||
@Slot(int, int)
|
||||
def handle_button_slot(self, button_code: int, value: int) -> None:
|
||||
if self._route_surface_button(button_code, value):
|
||||
return
|
||||
self._handle_default_button(button_code, value)
|
||||
|
||||
def _handle_default_button(self, button_code: int, value: int) -> None:
|
||||
if value == 1 and self._handle_common_ui_elements(button_code):
|
||||
return
|
||||
|
||||
active_window = QApplication.activeWindow()
|
||||
if self._route_button_to_keyboard(active_window, button_code, value):
|
||||
return
|
||||
|
||||
if value == 0:
|
||||
return
|
||||
|
||||
if not self._gamepad_handling_enabled:
|
||||
return
|
||||
try:
|
||||
app = QApplication.instance()
|
||||
active = QApplication.activeWindow()
|
||||
focused = QApplication.focusWidget()
|
||||
if not app or not active:
|
||||
return
|
||||
|
||||
if self._route_button_to_text_input(active, focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_guide_combination(button_code, time.time()):
|
||||
return
|
||||
|
||||
if self._handle_combo_button(focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_list_view_button(focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_popup_menu_button(QApplication.activePopupWidget(), button_code):
|
||||
return
|
||||
|
||||
if self._handle_active_window_button(active, button_code):
|
||||
return
|
||||
|
||||
if self._open_focused_context_menu(focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_system_table_button(focused, button_code):
|
||||
return
|
||||
|
||||
if self._handle_system_quick_button(button_code):
|
||||
return
|
||||
|
||||
self._handle_standard_button(focused, button_code, value)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_button_slot: {e}", exc_info=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
|
||||
if isinstance(focused, QTableWidget):
|
||||
current_row = focused.currentRow()
|
||||
current_col = max(focused.currentColumn(), 0)
|
||||
item = focused.item(current_row, current_col) if current_row >= 0 else None
|
||||
if item is None and current_row >= 0:
|
||||
item = focused.item(current_row, 0)
|
||||
point = (
|
||||
focused.visualItemRect(item).center()
|
||||
if item is not None
|
||||
else focused.viewport().rect().center()
|
||||
)
|
||||
focused.customContextMenuRequested.emit(point)
|
||||
return True
|
||||
if focused.contextMenuPolicy() == Qt.ContextMenuPolicy.CustomContextMenu:
|
||||
focused.customContextMenuRequested.emit(focused.rect().center())
|
||||
return True
|
||||
if not isinstance(focused, GameCard):
|
||||
return False
|
||||
position = QPoint(focused.width() // 2, focused.height() // 2)
|
||||
menu = focused._show_context_menu(position)
|
||||
if menu:
|
||||
menu.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _handle_combo_button(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if not isinstance(focused, QComboBox):
|
||||
return False
|
||||
if button_code in BUTTONS['confirm']:
|
||||
focused.showPopup()
|
||||
return True
|
||||
if button_code in BUTTONS['back'] and focused.view().isVisible():
|
||||
focused.hidePopup()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_popup_menu_button(self, popup: QWidget | None, button_code: int) -> bool:
|
||||
if not isinstance(popup, QMenu):
|
||||
return False
|
||||
if button_code in BUTTONS['confirm']:
|
||||
action = popup.activeAction()
|
||||
if action is not None and action.isEnabled():
|
||||
action.trigger()
|
||||
popup.close()
|
||||
return True
|
||||
if button_code in BUTTONS['back']:
|
||||
popup.close()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _route_button_to_text_input(
|
||||
self, active: QWidget, focused: QWidget | None, button_code: int
|
||||
) -> bool:
|
||||
keyboard = (
|
||||
getattr(active, 'keyboard', None)
|
||||
if isinstance(active, AddGameDialog)
|
||||
else getattr(self._parent, 'keyboard', None)
|
||||
)
|
||||
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
|
||||
if keyboard:
|
||||
keyboard.show_for_widget(focused)
|
||||
return True
|
||||
if button_code not in BUTTONS['prev_dir']:
|
||||
return False
|
||||
combo = focused if isinstance(focused, QComboBox) else None
|
||||
if combo is None and focused is not None:
|
||||
combo = self._find_parent_combo(focused)
|
||||
if combo is not None and combo.isEditable():
|
||||
line_edit = combo.lineEdit()
|
||||
if line_edit is not None and keyboard:
|
||||
line_edit.setFocus()
|
||||
keyboard.show_for_widget(line_edit)
|
||||
return True
|
||||
current_tab = self._parent.stackedWidget.currentIndex()
|
||||
search_name = 'searchEdit' if current_tab == 0 else 'autoInstallSearchLineEdit'
|
||||
search_edit = getattr(self._parent, search_name, None) if current_tab in (0, 1) else None
|
||||
if search_edit is None:
|
||||
return False
|
||||
search_edit.setFocus()
|
||||
return True
|
||||
|
||||
def _find_parent_combo(self, widget: QWidget) -> QComboBox | None:
|
||||
parent = widget.parentWidget()
|
||||
while parent:
|
||||
if isinstance(parent, QComboBox):
|
||||
return parent
|
||||
parent = parent.parentWidget()
|
||||
return None
|
||||
|
||||
def _activate_list_view_selection(self, view: QListView) -> None:
|
||||
index = view.currentIndex()
|
||||
if not index.isValid():
|
||||
return
|
||||
combo = self._find_parent_combo(view)
|
||||
if combo:
|
||||
combo.setCurrentIndex(index.row())
|
||||
combo.hidePopup()
|
||||
combo.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
view.activated.emit(index)
|
||||
view.clicked.emit(index)
|
||||
view.hide()
|
||||
|
||||
def _handle_list_view_button(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if not isinstance(focused, QListView):
|
||||
return False
|
||||
if button_code in BUTTONS['confirm']:
|
||||
self._activate_list_view_selection(focused)
|
||||
return True
|
||||
if button_code not in BUTTONS['back']:
|
||||
return False
|
||||
combo = self._find_parent_combo(focused)
|
||||
if combo:
|
||||
combo.hidePopup()
|
||||
combo.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
else:
|
||||
focused.clearSelection()
|
||||
focused.hide()
|
||||
return False
|
||||
|
||||
def _handle_system_table_button(self, focused: QWidget | None, button_code: int) -> bool:
|
||||
if not isinstance(focused, QTableWidget):
|
||||
return False
|
||||
handler = getattr(self._parent, "handleSystemTableGamepadAction", None)
|
||||
if not callable(handler):
|
||||
return False
|
||||
routes = (
|
||||
('confirm', "confirm", "click"),
|
||||
('back', "back", "back"),
|
||||
('prev_dir', "prev_dir", "click"),
|
||||
('add_game', "add_game", "click"),
|
||||
)
|
||||
for button_name, action, sound in routes:
|
||||
if button_code in BUTTONS[button_name] and handler(focused, action):
|
||||
SoundManager().play(sound)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_active_window_button(self, active: QWidget, button_code: int) -> bool:
|
||||
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
|
||||
active.reject()
|
||||
return True
|
||||
if not isinstance(active, FullscreenDialog):
|
||||
return False
|
||||
if button_code in BUTTONS['prev_tab']:
|
||||
active.show_prev()
|
||||
elif button_code in BUTTONS['next_tab']:
|
||||
active.show_next()
|
||||
elif button_code in BUTTONS['back']:
|
||||
active.close()
|
||||
return True
|
||||
|
||||
def _handle_system_quick_button(self, button_code: int) -> bool:
|
||||
handler = getattr(self._parent, "handleSystemGamepadAction", None)
|
||||
if not callable(handler) or button_code not in BUTTONS['add_game']:
|
||||
return False
|
||||
if not handler("add_game"):
|
||||
return False
|
||||
SoundManager().play("open")
|
||||
return True
|
||||
|
||||
def _handle_standard_button(
|
||||
self, focused: QWidget | None, button_code: int, value: int
|
||||
) -> None:
|
||||
if button_code in BUTTONS['confirm']:
|
||||
if not isinstance(focused, GameCard):
|
||||
SoundManager().play_widget_sound(focused)
|
||||
QTimer.singleShot(0, self._parent.activateFocusedWidget)
|
||||
elif button_code in BUTTONS['back']:
|
||||
self._handle_standard_back()
|
||||
elif button_code in BUTTONS['add_game']:
|
||||
if self._parent.stackedWidget.currentIndex() == 0:
|
||||
SoundManager().play("open")
|
||||
self._parent.openAddGameDialog()
|
||||
elif button_code in BUTTONS['prev_tab']:
|
||||
self._switch_visible_tab(-1)
|
||||
elif button_code in BUTTONS['next_tab']:
|
||||
self._switch_visible_tab(1)
|
||||
elif button_code in BUTTONS['increase_size'] and value > 0:
|
||||
self._adjust_active_slider(1)
|
||||
elif button_code in BUTTONS['decrease_size'] and value > 0:
|
||||
self._adjust_active_slider(-1)
|
||||
|
||||
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)
|
||||
show_list = getattr(self._parent, '_show_theme_store_list', None)
|
||||
if store_stack is not None and store_stack.currentWidget() == detail_page:
|
||||
if callable(show_list):
|
||||
show_list()
|
||||
return
|
||||
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
|
||||
|
||||
def _adjust_active_slider(self, direction: int) -> bool:
|
||||
current_tab = self._parent.stackedWidget.currentIndex()
|
||||
if current_tab == getattr(self._parent, "system_tab_index", -1):
|
||||
return self._adjust_system_volume(direction)
|
||||
if current_tab == 0:
|
||||
manager = getattr(self._parent, 'game_library_manager', None)
|
||||
slider = getattr(manager, 'sizeSlider', None)
|
||||
callback = getattr(self._parent, 'on_slider_released', None)
|
||||
elif current_tab == 1:
|
||||
slider = getattr(self._parent, 'auto_size_slider', None)
|
||||
callback = getattr(self._parent, 'on_auto_slider_released', None)
|
||||
elif current_tab == getattr(self._parent, "theme_tab_index", -1):
|
||||
slider = getattr(self._parent, 'themeStoreSizeSlider', None)
|
||||
callback = getattr(self._parent, '_on_theme_store_slider_released', None)
|
||||
else:
|
||||
return False
|
||||
if not isinstance(slider, QSlider):
|
||||
return False
|
||||
slider.setValue(slider.value() + direction * SIZE_SLIDER_STEP)
|
||||
if callable(callback):
|
||||
callback()
|
||||
return True
|
||||
|
||||
def _adjust_system_volume(self, direction: int) -> bool:
|
||||
section_stack = getattr(self._parent, "systemSectionStack", None)
|
||||
slider = getattr(self._parent, "audioVolumeSlider", None)
|
||||
if (
|
||||
not isinstance(section_stack, QStackedWidget)
|
||||
or section_stack.currentIndex() != SYSTEM_AUDIO_SECTION_INDEX
|
||||
):
|
||||
return False
|
||||
if not isinstance(slider, QSlider):
|
||||
return False
|
||||
slider.setValue(slider.value() + direction * SYSTEM_VOLUME_STEP)
|
||||
apply_volume = getattr(self._parent, "_applySelectedAudioVolume", None)
|
||||
if callable(apply_volume):
|
||||
apply_volume()
|
||||
return True
|
||||
|
||||
def _handle_guide_combination(self, button_code: int, current_time: float) -> bool:
|
||||
if button_code in BUTTONS['guide']:
|
||||
self.guide_held = True
|
||||
self.guide_pressed_time = current_time
|
||||
self.in_guide_combination_attempt = True
|
||||
self.guide_timer.start(int(self.guide_combination_timeout * 1000))
|
||||
return True
|
||||
if button_code not in BUTTONS['menu'] or not self.guide_held:
|
||||
return False
|
||||
self.select_pressed_time = current_time
|
||||
if current_time - self.guide_pressed_time > self.guide_combination_timeout:
|
||||
self._reset_guide_combination()
|
||||
return False
|
||||
self.guide_timer.stop()
|
||||
logger.debug("Guide + Select combination detected, refreshing game grid")
|
||||
self._parent.refreshGames()
|
||||
self._reset_guide_combination()
|
||||
return True
|
||||
|
||||
def _reset_guide_combination(self) -> None:
|
||||
self.guide_held = False
|
||||
self.in_guide_combination_attempt = False
|
||||
self.guide_pressed_time = 0
|
||||
self.select_pressed_time = 0
|
||||
|
||||
def _route_button_to_keyboard(
|
||||
self, active_window: QWidget | None, button_code: int, value: int
|
||||
) -> bool:
|
||||
if isinstance(active_window, AddGameDialog):
|
||||
focused = QApplication.focusWidget()
|
||||
if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit):
|
||||
active_window.show_keyboard_for_widget(focused)
|
||||
return True
|
||||
keyboard = getattr(active_window, 'keyboard', None)
|
||||
if keyboard and keyboard.isVisible():
|
||||
self.handle_virtual_keyboard(button_code, value)
|
||||
return True
|
||||
dialog_keyboard = getattr(active_window, 'keyboard', None) if active_window else None
|
||||
if isinstance(dialog_keyboard, VirtualKeyboard) and dialog_keyboard.isVisible():
|
||||
self.handle_virtual_keyboard(button_code, value)
|
||||
return True
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
if keyboard and keyboard.isVisible():
|
||||
self.handle_virtual_keyboard(button_code, value)
|
||||
return True
|
||||
return False
|
||||
@@ -1,131 +0,0 @@
|
||||
from enum import Enum
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
SDL_GAMEPAD_TYPE_STANDARD = 1
|
||||
SDL_GAMEPAD_TYPE_XBOX360 = 2
|
||||
SDL_GAMEPAD_TYPE_XBOXONE = 3
|
||||
SDL_GAMEPAD_TYPE_PS3 = 4
|
||||
SDL_GAMEPAD_TYPE_PS4 = 5
|
||||
SDL_GAMEPAD_TYPE_PS5 = 6
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO = 7
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT = 8
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT = 9
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR = 10
|
||||
SDL_GAMEPAD_TYPE_GAMECUBE = 11
|
||||
SDL_GAMEPAD_TYPE_STEAM = 12
|
||||
SDL_GAMEPAD_BUTTON_SOUTH = 0
|
||||
SDL_GAMEPAD_BUTTON_EAST = 1
|
||||
SDL_GAMEPAD_BUTTON_WEST = 2
|
||||
SDL_GAMEPAD_BUTTON_NORTH = 3
|
||||
SDL_GAMEPAD_BUTTON_BACK = 4
|
||||
SDL_GAMEPAD_BUTTON_GUIDE = 5
|
||||
SDL_GAMEPAD_BUTTON_START = 6
|
||||
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER = 9
|
||||
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER = 10
|
||||
SDL_GAMEPAD_BUTTON_DPAD_UP = 11
|
||||
SDL_GAMEPAD_BUTTON_DPAD_DOWN = 12
|
||||
SDL_GAMEPAD_BUTTON_DPAD_LEFT = 13
|
||||
SDL_GAMEPAD_BUTTON_DPAD_RIGHT = 14
|
||||
SDL_GAMEPAD_AXIS_LEFTX = 0
|
||||
SDL_GAMEPAD_AXIS_LEFTY = 1
|
||||
SDL_GAMEPAD_AXIS_RIGHTX = 2
|
||||
SDL_GAMEPAD_AXIS_RIGHTY = 3
|
||||
SDL_GAMEPAD_AXIS_LEFT_TRIGGER = 4
|
||||
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER = 5
|
||||
|
||||
|
||||
class GamepadType(Enum):
|
||||
XBOX = "Xbox"
|
||||
PLAYSTATION = "PlayStation"
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
|
||||
SDL3_XBOX_LIKE_TYPES = {
|
||||
SDL_GAMEPAD_TYPE_STANDARD,
|
||||
SDL_GAMEPAD_TYPE_XBOX360,
|
||||
SDL_GAMEPAD_TYPE_XBOXONE,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_PRO,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_LEFT,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_RIGHT,
|
||||
SDL_GAMEPAD_TYPE_NINTENDO_SWITCH_JOYCON_PAIR,
|
||||
SDL_GAMEPAD_TYPE_GAMECUBE,
|
||||
SDL_GAMEPAD_TYPE_STEAM,
|
||||
}
|
||||
SDL3_PLAYSTATION_TYPES = {
|
||||
SDL_GAMEPAD_TYPE_PS3,
|
||||
SDL_GAMEPAD_TYPE_PS4,
|
||||
SDL_GAMEPAD_TYPE_PS5,
|
||||
}
|
||||
|
||||
PAD_BUTTON_SOUTH = 1000
|
||||
PAD_BUTTON_EAST = 1001
|
||||
PAD_BUTTON_WEST = 1002
|
||||
PAD_BUTTON_NORTH = 1003
|
||||
PAD_BUTTON_SELECT = 1004
|
||||
PAD_BUTTON_GUIDE = 1005
|
||||
PAD_BUTTON_START = 1006
|
||||
PAD_BUTTON_LEFT_SHOULDER = 1007
|
||||
PAD_BUTTON_RIGHT_SHOULDER = 1008
|
||||
PAD_AXIS_LEFT_TRIGGER = 1100
|
||||
PAD_AXIS_RIGHT_TRIGGER = 1101
|
||||
PAD_AXIS_LEFT_X = 1200
|
||||
PAD_AXIS_LEFT_Y = 1201
|
||||
PAD_AXIS_RIGHT_X = 1202
|
||||
PAD_AXIS_RIGHT_Y = 1203
|
||||
PAD_DPAD_X = 1300
|
||||
PAD_DPAD_Y = 1301
|
||||
INPUT_AXIS_SCALE = 32767
|
||||
KEY_BACKSPACE = Qt.Key.Key_Backspace.value
|
||||
KEY_DOWN = Qt.Key.Key_Down.value
|
||||
KEY_ENTER = Qt.Key.Key_Enter.value
|
||||
KEY_ESCAPE = Qt.Key.Key_Escape.value
|
||||
KEY_F5 = Qt.Key.Key_F5.value
|
||||
KEY_F10 = Qt.Key.Key_F10.value
|
||||
KEY_F11 = Qt.Key.Key_F11.value
|
||||
KEY_LEFT = Qt.Key.Key_Left.value
|
||||
KEY_RETURN = Qt.Key.Key_Return.value
|
||||
KEY_RIGHT = Qt.Key.Key_Right.value
|
||||
KEY_UP = Qt.Key.Key_Up.value
|
||||
KEY_E = ord("e")
|
||||
KEY_Q = ord("q")
|
||||
MOD_CTRL = 1
|
||||
MOD_SHIFT = 2
|
||||
SIZE_SLIDER_STEP = 10
|
||||
SYSTEM_AUDIO_SECTION_INDEX = 4
|
||||
SYSTEM_VOLUME_STEP = 5
|
||||
|
||||
BUTTONS = {
|
||||
'confirm': {PAD_BUTTON_SOUTH},
|
||||
'back': {PAD_BUTTON_EAST},
|
||||
'add_game': {PAD_BUTTON_WEST},
|
||||
'prev_dir': {PAD_BUTTON_NORTH},
|
||||
'prev_tab': {PAD_BUTTON_LEFT_SHOULDER},
|
||||
'next_tab': {PAD_BUTTON_RIGHT_SHOULDER},
|
||||
'context_menu': {PAD_BUTTON_START},
|
||||
'menu': {PAD_BUTTON_SELECT},
|
||||
'guide': {PAD_BUTTON_GUIDE},
|
||||
'increase_size': {PAD_AXIS_RIGHT_TRIGGER},
|
||||
'decrease_size': {PAD_AXIS_LEFT_TRIGGER},
|
||||
}
|
||||
|
||||
SDL_CONTROLLER_BUTTON_TO_PAD = {
|
||||
SDL_GAMEPAD_BUTTON_SOUTH: PAD_BUTTON_SOUTH,
|
||||
SDL_GAMEPAD_BUTTON_EAST: PAD_BUTTON_EAST,
|
||||
SDL_GAMEPAD_BUTTON_WEST: PAD_BUTTON_WEST,
|
||||
SDL_GAMEPAD_BUTTON_NORTH: PAD_BUTTON_NORTH,
|
||||
SDL_GAMEPAD_BUTTON_BACK: PAD_BUTTON_SELECT,
|
||||
SDL_GAMEPAD_BUTTON_GUIDE: PAD_BUTTON_GUIDE,
|
||||
SDL_GAMEPAD_BUTTON_START: PAD_BUTTON_START,
|
||||
SDL_GAMEPAD_BUTTON_LEFT_SHOULDER: PAD_BUTTON_LEFT_SHOULDER,
|
||||
SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER: PAD_BUTTON_RIGHT_SHOULDER,
|
||||
}
|
||||
|
||||
SDL_CONTROLLER_AXIS_TO_PAD = {
|
||||
SDL_GAMEPAD_AXIS_LEFTX: PAD_AXIS_LEFT_X,
|
||||
SDL_GAMEPAD_AXIS_LEFTY: PAD_AXIS_LEFT_Y,
|
||||
SDL_GAMEPAD_AXIS_RIGHTX: PAD_AXIS_RIGHT_X,
|
||||
SDL_GAMEPAD_AXIS_RIGHTY: PAD_AXIS_RIGHT_Y,
|
||||
SDL_GAMEPAD_AXIS_LEFT_TRIGGER: PAD_AXIS_LEFT_TRIGGER,
|
||||
SDL_GAMEPAD_AXIS_RIGHT_TRIGGER: PAD_AXIS_RIGHT_TRIGGER,
|
||||
}
|
||||
@@ -1,711 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, cast
|
||||
|
||||
from PySide6.QtCore import QObject, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QListView,
|
||||
QListWidget,
|
||||
QMenu,
|
||||
QStackedWidget,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.input_manager.constants import BUTTONS, PAD_DPAD_X, PAD_DPAD_Y
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.input_manager.mixin import InputMixin
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InputSurface:
|
||||
name: str
|
||||
dialog: Any
|
||||
button_handler: Callable[[int, int], None]
|
||||
dpad_handler: Callable[[int, int, float], None]
|
||||
connections: list[tuple[Any, Callable[..., Any]]] = field(default_factory=list)
|
||||
|
||||
|
||||
class DialogInputModesMixin(InputMixin):
|
||||
# WINETRICKS MODE
|
||||
def enable_winetricks_mode(self, winetricks_dialog):
|
||||
"""Setup gamepad handling for WinetricksDialog"""
|
||||
try:
|
||||
self._setup_mode_handlers(
|
||||
winetricks_dialog,
|
||||
self.handle_winetricks_button,
|
||||
self.handle_winetricks_dpad,
|
||||
'winetricks_dialog'
|
||||
)
|
||||
logger.debug("Gamepad handling successfully connected for WinetricksDialog")
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting gamepad handlers for Winetricks: {e}")
|
||||
|
||||
def disable_winetricks_mode(self):
|
||||
"""Restore original main window handlers"""
|
||||
try:
|
||||
if self.winetricks_dialog:
|
||||
self._restore_original_handlers('winetricks_dialog')
|
||||
logger.debug("Gamepad handling successfully restored from Winetricks")
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring gamepad handlers from Winetricks: {e}")
|
||||
|
||||
def handle_winetricks_button(self, button_code, value):
|
||||
if self.winetricks_dialog is None or value == 0:
|
||||
return
|
||||
|
||||
try:
|
||||
# Handle common UI elements like QMessageBox, QMenu, etc.
|
||||
if self._handle_common_ui_elements(button_code):
|
||||
return
|
||||
|
||||
# Winetricks-specific button handling
|
||||
focused = QApplication.focusWidget()
|
||||
|
||||
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
|
||||
if isinstance(focused, QTableWidget):
|
||||
self.handle_table_confirm(focused)
|
||||
return
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X: Install
|
||||
self.winetricks_dialog.install_selected(force=False)
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y: Force Install
|
||||
self.winetricks_dialog.install_selected(force=True)
|
||||
|
||||
elif button_code in BUTTONS['back']: # B: Cancel
|
||||
self.winetricks_dialog.reject()
|
||||
|
||||
elif button_code in BUTTONS['prev_tab']: # LB
|
||||
current_index = self.winetricks_dialog.tab_widget.currentIndex()
|
||||
new_index = max(0, current_index - 1)
|
||||
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
|
||||
if new_index != current_index:
|
||||
SoundManager().play("tab_switch")
|
||||
self._focus_first_row_in_current_table()
|
||||
|
||||
elif button_code in BUTTONS['next_tab']: # RB
|
||||
current_index = self.winetricks_dialog.tab_widget.currentIndex()
|
||||
new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1)
|
||||
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
|
||||
if new_index != current_index:
|
||||
SoundManager().play("tab_switch")
|
||||
self._focus_first_row_in_current_table()
|
||||
|
||||
else:
|
||||
self._parent.activateFocusedWidget()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_winetricks_button: {e}")
|
||||
|
||||
def handle_winetricks_dpad(self, code, value, now):
|
||||
if self.winetricks_dialog is None:
|
||||
return
|
||||
try:
|
||||
if value == 0: # Release
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
self.current_dpad_value = 0
|
||||
return
|
||||
|
||||
# Timer setup
|
||||
if self.current_dpad_code != code or self.current_dpad_value != value:
|
||||
self.dpad_timer.stop()
|
||||
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
|
||||
self.dpad_timer.start()
|
||||
self.current_dpad_code = code
|
||||
self.current_dpad_value = value
|
||||
|
||||
table = self._get_current_table()
|
||||
if not table or table.rowCount() == 0:
|
||||
return
|
||||
|
||||
current_row = table.currentRow()
|
||||
|
||||
if code == PAD_DPAD_Y: # Up/Down
|
||||
step = -1 if value < 0 else 1
|
||||
new_row = current_row + step
|
||||
|
||||
# Skip hidden rows
|
||||
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
|
||||
new_row += step
|
||||
|
||||
# Bounds check
|
||||
if new_row < 0:
|
||||
new_row = current_row
|
||||
if new_row >= table.rowCount():
|
||||
new_row = current_row
|
||||
|
||||
if new_row != current_row:
|
||||
table.setCurrentCell(new_row, 0)
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_winetricks_dpad: {e}")
|
||||
|
||||
def _get_current_table(self):
|
||||
if self.winetricks_dialog:
|
||||
current_container = self.winetricks_dialog.tab_widget.currentWidget()
|
||||
if isinstance(current_container, QStackedWidget):
|
||||
current_table = current_container.widget(1)
|
||||
if isinstance(current_table, QTableWidget):
|
||||
return current_table
|
||||
return None
|
||||
|
||||
def _focus_first_row_in_current_table(self):
|
||||
table = self._get_current_table()
|
||||
if table and table.rowCount() > 0:
|
||||
table.setCurrentCell(0, 0)
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _get_navigable_menu_actions(self, menu: QMenu) -> list:
|
||||
return [
|
||||
action
|
||||
for action in menu.actions()
|
||||
if not action.isSeparator() and action.isEnabled() and action.isVisible()
|
||||
]
|
||||
|
||||
def _navigate_menu_actions(self, menu: QMenu, direction_down: bool) -> None:
|
||||
actions = self._get_navigable_menu_actions(menu)
|
||||
if not actions:
|
||||
return
|
||||
current_action = menu.activeAction()
|
||||
if current_action not in actions:
|
||||
target_index = 0 if direction_down else len(actions) - 1
|
||||
menu.setActiveAction(actions[target_index])
|
||||
SoundManager().play("navigate")
|
||||
return
|
||||
current_index = actions.index(current_action)
|
||||
step = 1 if direction_down else -1
|
||||
next_index = (current_index + step) % len(actions)
|
||||
menu.setActiveAction(actions[next_index])
|
||||
SoundManager().play("navigate")
|
||||
|
||||
# TABLE NAVIGATION METHODS
|
||||
def handle_table_navigation(self, table: QTableWidget, code: int, value: int):
|
||||
"""
|
||||
Handle navigation in table
|
||||
|
||||
Args:
|
||||
table: QTableWidget for navigation handling
|
||||
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
|
||||
value: Event value (direction)
|
||||
"""
|
||||
row_count = table.rowCount()
|
||||
if row_count <= 0:
|
||||
return
|
||||
current_row = table.currentRow()
|
||||
if current_row < 0:
|
||||
current_row = 0
|
||||
table.setCurrentCell(0, 0)
|
||||
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
# Vertical navigation
|
||||
if value > 0: # Down
|
||||
new_row = min(current_row + 1, row_count - 1)
|
||||
elif value < 0: # Up
|
||||
new_row = max(current_row - 1, 0)
|
||||
else:
|
||||
return
|
||||
|
||||
table.setCurrentCell(new_row, table.currentColumn())
|
||||
item = table.item(new_row, table.currentColumn())
|
||||
if item:
|
||||
table.scrollToItem(
|
||||
item,
|
||||
QAbstractItemView.ScrollHint.PositionAtCenter
|
||||
)
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
elif code == PAD_DPAD_X and value != 0:
|
||||
# Horizontal navigation
|
||||
col_count = table.columnCount()
|
||||
current_col = table.currentColumn()
|
||||
if current_col < 0:
|
||||
current_col = 0
|
||||
|
||||
if value < 0: # Left
|
||||
new_col = max(current_col - 1, 0)
|
||||
elif value > 0: # Right
|
||||
new_col = min(current_col + 1, col_count - 1)
|
||||
else:
|
||||
return
|
||||
|
||||
table.setCurrentCell(table.currentRow(), new_col)
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
|
||||
def handle_table_confirm(self, table: QTableWidget):
|
||||
"""
|
||||
Handle confirmation (e.g., A press) for table
|
||||
|
||||
Args:
|
||||
table: QTableWidget for confirmation handling
|
||||
"""
|
||||
current_row = table.currentRow()
|
||||
current_col = table.currentColumn()
|
||||
if current_row >= 0 and current_col >= 0:
|
||||
cell_widget = table.cellWidget(current_row, current_col)
|
||||
if isinstance(cell_widget, QCheckBox) and cell_widget.isEnabled():
|
||||
cell_widget.setChecked(not cell_widget.isChecked())
|
||||
return True
|
||||
if cell_widget is not None:
|
||||
checkbox = cell_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
checkbox.setChecked(not checkbox.isChecked())
|
||||
return True
|
||||
|
||||
# Check if the cell contains a checkbox
|
||||
item = table.item(current_row, current_col)
|
||||
if item 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)
|
||||
return True
|
||||
|
||||
# Call custom confirm callback if exists
|
||||
callback = getattr(table, '_on_confirm_callback', None) # type: ignore
|
||||
if callback and callable(callback):
|
||||
callback(table, current_row, current_col)
|
||||
return True
|
||||
|
||||
# WIDGET NAVIGATION METHODS
|
||||
def setup_widget_navigation(self, widget: QWidget, navigation_type: str = "default", **kwargs):
|
||||
"""
|
||||
Set navigation for widget
|
||||
|
||||
Args:
|
||||
widget: QWidget for navigation setup
|
||||
navigation_type: Navigation type ('table', 'list', 'combo', 'default')
|
||||
**kwargs: Additional parameters for navigation
|
||||
"""
|
||||
widget.installEventFilter(cast(QObject, self))
|
||||
# Use direct assignment for custom navigation properties, with type ignore for pyright
|
||||
widget._navigation_type = navigation_type # type: ignore
|
||||
for key, value in kwargs.items():
|
||||
setattr(widget, f'_{key}', value)
|
||||
|
||||
def handle_widget_navigation(self, widget: QWidget, code: int, value: int):
|
||||
"""
|
||||
Handle navigation in widget
|
||||
|
||||
Args:
|
||||
widget: QWidget for navigation handling
|
||||
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
|
||||
value: Event value (direction)
|
||||
"""
|
||||
nav_type = getattr(widget, '_navigation_type', 'default') # type: ignore
|
||||
|
||||
if nav_type == 'table' and isinstance(widget, QTableWidget):
|
||||
self.handle_table_navigation(widget, code, value)
|
||||
elif nav_type == 'list' and isinstance(widget, QListWidget):
|
||||
self.handle_list_navigation(widget, code, value)
|
||||
elif nav_type == 'combo' and isinstance(widget, QComboBox):
|
||||
self.handle_combo_navigation(widget, code, value)
|
||||
else:
|
||||
# Default navigation behavior
|
||||
if isinstance(widget, QTableWidget):
|
||||
self.handle_table_navigation(widget, code, value)
|
||||
elif isinstance(widget, QListWidget):
|
||||
self.handle_list_navigation(widget, code, value)
|
||||
elif isinstance(widget, QComboBox):
|
||||
self.handle_combo_navigation(widget, code, value)
|
||||
|
||||
def handle_list_navigation(self, list_widget: QListWidget, code: int, value: int):
|
||||
"""
|
||||
Handle navigation in list
|
||||
|
||||
Args:
|
||||
list_widget: QListWidget for navigation handling
|
||||
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
|
||||
value: Event value (direction)
|
||||
"""
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
model = list_widget.model()
|
||||
current_index = list_widget.currentIndex()
|
||||
if model and current_index.isValid():
|
||||
row_count = model.rowCount()
|
||||
current_row = current_index.row()
|
||||
if value > 0: # Down
|
||||
next_row = min(current_row + 1, row_count - 1)
|
||||
list_widget.setCurrentIndex(model.index(next_row, current_index.column()))
|
||||
elif value < 0: # Up
|
||||
prev_row = max(current_row - 1, 0)
|
||||
list_widget.setCurrentIndex(model.index(prev_row, current_index.column()))
|
||||
list_widget.scrollTo(list_widget.currentIndex(), QListView.ScrollHint.PositionAtCenter)
|
||||
|
||||
def handle_combo_navigation(self, combo_widget: QComboBox, code: int, value: int):
|
||||
"""
|
||||
Handle navigation in combo widget
|
||||
|
||||
Args:
|
||||
combo_widget: QComboBox for navigation handling
|
||||
code: Event code (usually ABS_HAT0X or ABS_HAT0Y)
|
||||
value: Event value (direction)
|
||||
"""
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
current_index = combo_widget.currentIndex()
|
||||
if value > 0: # Down
|
||||
new_index = min(current_index + 1, combo_widget.count() - 1)
|
||||
elif value < 0: # Up
|
||||
new_index = max(current_index - 1, 0)
|
||||
else:
|
||||
return
|
||||
|
||||
if new_index != current_index:
|
||||
combo_widget.setCurrentIndex(new_index)
|
||||
|
||||
def _setup_mode_handlers(self, dialog_instance, button_handler, dpad_handler, dialog_attr_name):
|
||||
"""Register a dialog as the active input surface."""
|
||||
if not self._input_surfaces:
|
||||
self._input_surface_base_state = self._gamepad_handling_enabled
|
||||
previous_surface = next(
|
||||
(surface for surface in self._input_surfaces if surface.name == dialog_attr_name),
|
||||
None,
|
||||
)
|
||||
if previous_surface is not None:
|
||||
for signal, callback in previous_surface.connections:
|
||||
signal.disconnect(callback)
|
||||
self._input_surfaces = [
|
||||
surface for surface in self._input_surfaces
|
||||
if surface.name != dialog_attr_name
|
||||
]
|
||||
surface = InputSurface(
|
||||
dialog_attr_name,
|
||||
dialog_instance,
|
||||
button_handler,
|
||||
dpad_handler,
|
||||
)
|
||||
self._input_surfaces.append(surface)
|
||||
setattr(self, dialog_attr_name, dialog_instance)
|
||||
self._gamepad_handling_enabled = True
|
||||
self._reset_surface_navigation()
|
||||
|
||||
def _restore_original_handlers(self, dialog_attr_name):
|
||||
"""Remove a dialog input surface and restore the preceding surface."""
|
||||
surface = next(
|
||||
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
|
||||
None,
|
||||
)
|
||||
if surface is None:
|
||||
logger.warning("Cannot restore input surface: %s is not registered", dialog_attr_name)
|
||||
return
|
||||
for signal, callback in surface.connections:
|
||||
signal.disconnect(callback)
|
||||
self._input_surfaces.remove(surface)
|
||||
setattr(self, dialog_attr_name, None)
|
||||
if not self._input_surfaces and self._input_surface_base_state is not None:
|
||||
self._gamepad_handling_enabled = self._input_surface_base_state
|
||||
self._input_surface_base_state = None
|
||||
self._reset_surface_navigation()
|
||||
|
||||
def connect_surface_updates(
|
||||
self,
|
||||
dialog_attr_name: str,
|
||||
callback: Callable[..., Any],
|
||||
) -> None:
|
||||
"""Connect a callback for the lifetime of a dialog input surface."""
|
||||
surface = next(
|
||||
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
|
||||
None,
|
||||
)
|
||||
if surface is None:
|
||||
logger.warning("Cannot connect input surface: %s is not registered", dialog_attr_name)
|
||||
return
|
||||
for signal in (self.button_event, self.dpad_moved):
|
||||
signal.connect(callback)
|
||||
surface.connections.append((signal, callback))
|
||||
|
||||
def connect_surface_signal(
|
||||
self,
|
||||
dialog_attr_name: str,
|
||||
signal: Any,
|
||||
callback: Callable[..., Any],
|
||||
) -> None:
|
||||
"""Connect one signal for the lifetime of a dialog input surface."""
|
||||
surface = next(
|
||||
(item for item in reversed(self._input_surfaces) if item.name == dialog_attr_name),
|
||||
None,
|
||||
)
|
||||
if surface is None:
|
||||
logger.warning("Cannot connect input surface: %s is not registered", dialog_attr_name)
|
||||
return
|
||||
signal.connect(callback)
|
||||
surface.connections.append((signal, callback))
|
||||
|
||||
def _reset_surface_navigation(self) -> None:
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
self.current_dpad_value = 0
|
||||
|
||||
def _route_surface_button(self, button_code: int, value: int) -> bool:
|
||||
if not self._input_surfaces:
|
||||
return False
|
||||
self._input_surfaces[-1].button_handler(button_code, value)
|
||||
return True
|
||||
|
||||
def _route_surface_dpad(self, code: int, value: int, current_time: float) -> bool:
|
||||
if not self._input_surfaces:
|
||||
return False
|
||||
self._input_surfaces[-1].dpad_handler(code, value, current_time)
|
||||
return True
|
||||
|
||||
# PROTON MANAGER SUPPORT
|
||||
def enable_proton_manager_mode(self, proton_manager_dialog):
|
||||
"""Setup gamepad handling for ProtonManagerDialog"""
|
||||
try:
|
||||
self._setup_mode_handlers(
|
||||
proton_manager_dialog,
|
||||
self.handle_proton_manager_button,
|
||||
self.handle_proton_manager_dpad,
|
||||
'proton_manager_dialog'
|
||||
)
|
||||
logger.debug("Gamepad handling successfully connected for ProtonManager")
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting gamepad handlers for ProtonManager: {e}")
|
||||
|
||||
def disable_proton_manager_mode(self):
|
||||
"""Restore original main window handlers"""
|
||||
try:
|
||||
if self.proton_manager_dialog:
|
||||
self._restore_original_handlers('proton_manager_dialog')
|
||||
logger.debug("Gamepad handling successfully restored from ProtonManager")
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring gamepad handlers from ProtonManager: {e}")
|
||||
|
||||
def handle_proton_manager_button(self, button_code, value):
|
||||
if self.proton_manager_dialog is None or value == 0:
|
||||
return
|
||||
|
||||
try:
|
||||
# Handle common UI elements like QMessageBox, QMenu, etc.
|
||||
if self._handle_common_ui_elements(button_code):
|
||||
return
|
||||
|
||||
# ProtonManager-specific button handling
|
||||
focused = QApplication.focusWidget()
|
||||
|
||||
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
|
||||
if isinstance(focused, QTableWidget):
|
||||
current_row = focused.currentRow()
|
||||
if current_row >= 0:
|
||||
checkbox_widget = focused.cellWidget(current_row, 0)
|
||||
if checkbox_widget:
|
||||
checkbox = checkbox_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
checkbox.setChecked(not checkbox.isChecked())
|
||||
return
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X: Download
|
||||
self.proton_manager_dialog.download_selected()
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y: Clear
|
||||
self.proton_manager_dialog.clear_selection()
|
||||
|
||||
elif button_code in BUTTONS['back']: # B: Cancel/Close
|
||||
# Cancel any active downloads/extractions before closing
|
||||
if (self.proton_manager_dialog.current_extraction_thread and
|
||||
self.proton_manager_dialog.current_extraction_thread.isRunning()) or \
|
||||
(self.proton_manager_dialog.current_download_thread and
|
||||
hasattr(self.proton_manager_dialog.current_download_thread, 'isRunning') and
|
||||
self.proton_manager_dialog.current_download_thread.isRunning()):
|
||||
# If there's an active download/extraction, cancel it
|
||||
self.proton_manager_dialog.cancel_current_download()
|
||||
else:
|
||||
# If no active processes, just close the dialog
|
||||
self.proton_manager_dialog.reject()
|
||||
|
||||
elif button_code in BUTTONS['prev_tab']: # LB: Previous tab
|
||||
current_index = self.proton_manager_dialog.tab_widget.currentIndex()
|
||||
new_index = max(0, current_index - 1)
|
||||
self.proton_manager_dialog.tab_widget.setCurrentIndex(new_index)
|
||||
if new_index != current_index:
|
||||
SoundManager().play("tab_switch")
|
||||
self._focus_first_row_in_current_proton_manager_table()
|
||||
|
||||
elif button_code in BUTTONS['next_tab']: # RB: Next tab
|
||||
current_index = self.proton_manager_dialog.tab_widget.currentIndex()
|
||||
new_index = min(self.proton_manager_dialog.tab_widget.count() - 1, current_index + 1)
|
||||
self.proton_manager_dialog.tab_widget.setCurrentIndex(new_index)
|
||||
if new_index != current_index:
|
||||
SoundManager().play("tab_switch")
|
||||
self._focus_first_row_in_current_proton_manager_table()
|
||||
|
||||
else:
|
||||
self._parent.activateFocusedWidget()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_proton_manager_button: {e}")
|
||||
|
||||
def handle_proton_manager_dpad(self, code, value, now):
|
||||
if self.proton_manager_dialog is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if value == 0: # Release
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
self.current_dpad_value = 0
|
||||
return
|
||||
|
||||
# Timer setup
|
||||
if self.current_dpad_code != code or self.current_dpad_value != value:
|
||||
self.dpad_timer.stop()
|
||||
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
|
||||
self.dpad_timer.start()
|
||||
self.current_dpad_code = code
|
||||
self.current_dpad_value = value
|
||||
|
||||
table = self._get_current_proton_manager_table()
|
||||
if not table or table.rowCount() == 0:
|
||||
return
|
||||
|
||||
current_row = table.currentRow()
|
||||
|
||||
if code == PAD_DPAD_Y: # Up/Down
|
||||
step = -1 if value < 0 else 1
|
||||
new_row = current_row + step
|
||||
|
||||
# Skip hidden rows
|
||||
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
|
||||
new_row += step
|
||||
|
||||
# Bounds check
|
||||
if new_row < 0:
|
||||
new_row = current_row
|
||||
if new_row >= table.rowCount():
|
||||
new_row = current_row
|
||||
|
||||
if new_row != current_row:
|
||||
table.setCurrentCell(new_row, 0)
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_proton_manager_dpad: {e}")
|
||||
|
||||
def _get_current_proton_manager_table(self):
|
||||
if self.proton_manager_dialog:
|
||||
current_container = self.proton_manager_dialog.tab_widget.currentWidget()
|
||||
if current_container:
|
||||
table = current_container.findChild(QTableWidget)
|
||||
return table
|
||||
return None
|
||||
|
||||
def _focus_first_row_in_current_proton_manager_table(self):
|
||||
table = self._get_current_proton_manager_table()
|
||||
if table and table.rowCount() > 0:
|
||||
table.setCurrentCell(0, 0)
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def enable_appimage_update_mode(self, appimage_update_dialog):
|
||||
"""Setup gamepad handling for AppImageUpdateDialog"""
|
||||
try:
|
||||
self._setup_mode_handlers(
|
||||
appimage_update_dialog,
|
||||
self.handle_appimage_update_button,
|
||||
self.handle_appimage_update_dpad,
|
||||
'appimage_update_dialog'
|
||||
)
|
||||
logger.debug("Gamepad handling successfully connected for AppImageUpdateDialog")
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting gamepad handlers for AppImageUpdateDialog: {e}")
|
||||
|
||||
def disable_appimage_update_mode(self):
|
||||
"""Restore original main window handlers"""
|
||||
try:
|
||||
if self.appimage_update_dialog:
|
||||
self._restore_original_handlers('appimage_update_dialog')
|
||||
logger.debug("Gamepad handling successfully restored from AppImageUpdateDialog")
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring gamepad handlers from AppImageUpdateDialog: {e}")
|
||||
|
||||
def handle_appimage_update_button(self, button_code, value):
|
||||
if self.appimage_update_dialog is None or value == 0:
|
||||
return
|
||||
|
||||
try:
|
||||
if self._handle_common_ui_elements(button_code):
|
||||
return
|
||||
|
||||
focused = QApplication.focusWidget()
|
||||
|
||||
if button_code in BUTTONS['confirm']:
|
||||
if isinstance(focused, AutoSizeButton):
|
||||
focused.click()
|
||||
return
|
||||
|
||||
elif button_code in BUTTONS['back']:
|
||||
self.appimage_update_dialog.reject()
|
||||
return
|
||||
|
||||
self._parent.activateFocusedWidget()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_appimage_update_button: {e}")
|
||||
|
||||
def handle_appimage_update_dpad(self, code, value, now):
|
||||
if self.appimage_update_dialog is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if value == 0:
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
self.current_dpad_value = 0
|
||||
return
|
||||
|
||||
if self.current_dpad_code != code or self.current_dpad_value != value:
|
||||
self.dpad_timer.stop()
|
||||
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
|
||||
self.dpad_timer.start()
|
||||
self.current_dpad_code = code
|
||||
self.current_dpad_value = value
|
||||
|
||||
changelog = self.appimage_update_dialog.changelog_text
|
||||
buttons = self.appimage_update_dialog.action_buttons
|
||||
|
||||
if changelog and changelog.hasFocus():
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
scrollbar = changelog.verticalScrollBar()
|
||||
at_bottom = scrollbar.value() >= scrollbar.maximum()
|
||||
at_top = scrollbar.value() <= scrollbar.minimum()
|
||||
if value > 0 and at_bottom:
|
||||
if buttons:
|
||||
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if value < 0 and at_top:
|
||||
return
|
||||
step = 40
|
||||
scrollbar.setValue(scrollbar.value() + (step if value > 0 else -step))
|
||||
return
|
||||
|
||||
if code == PAD_DPAD_Y and value != 0 and buttons:
|
||||
focused = QApplication.focusWidget()
|
||||
if focused in buttons:
|
||||
if value < 0 and focused == buttons[0]:
|
||||
if changelog:
|
||||
changelog.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
|
||||
if code == PAD_DPAD_X and value != 0 and buttons:
|
||||
focused = QApplication.focusWidget()
|
||||
if focused in buttons:
|
||||
idx = buttons.index(focused)
|
||||
if value < 0:
|
||||
new_idx = max(0, idx - 1)
|
||||
else:
|
||||
new_idx = min(len(buttons) - 1, idx + 1)
|
||||
buttons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
else:
|
||||
buttons[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_appimage_update_dpad: {e}")
|
||||
@@ -1,537 +0,0 @@
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import Qt, Slot
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QDialog,
|
||||
QListView,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, NavLabel
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.game_card import AnimatedCard
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
PAD_AXIS_LEFT_X,
|
||||
PAD_AXIS_LEFT_Y,
|
||||
PAD_DPAD_X,
|
||||
PAD_DPAD_Y,
|
||||
)
|
||||
from portprotonqt.input_manager.mixin import InputMixin
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DpadInputMixin(InputMixin):
|
||||
def handle_dpad_repeat(self) -> None:
|
||||
"""Handle repeated D-pad input while the D-pad is held."""
|
||||
if self.current_dpad_code is not None and self.current_dpad_value != 0:
|
||||
now = time.time()
|
||||
if (now - self.last_move_time) >= self.current_axis_delay:
|
||||
self.handle_dpad_slot(self.current_dpad_code, self.current_dpad_value, now)
|
||||
self.last_move_time = now
|
||||
self.current_axis_delay = self.repeat_axis_move_delay
|
||||
|
||||
def _get_theme_tab_focusables(self) -> list[QWidget]:
|
||||
"""Return focusable widgets for the themes tab gamepad navigation."""
|
||||
if self._is_theme_store_visible():
|
||||
return self._get_theme_store_focusables()
|
||||
|
||||
widgets = []
|
||||
for attr_name in (
|
||||
"themesCombo",
|
||||
"themeVariantCombo",
|
||||
"screenshotsCarousel",
|
||||
"applyButton",
|
||||
"deleteThemeButton",
|
||||
):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if self._is_visible_enabled_widget(widget):
|
||||
widgets.append(widget)
|
||||
return widgets
|
||||
|
||||
def _is_visible_enabled_widget(self, widget: object) -> bool:
|
||||
return isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled()
|
||||
|
||||
def _is_theme_store_visible(self) -> bool:
|
||||
content_stack = getattr(self._parent, "themeContentStack", None)
|
||||
store_page = getattr(self._parent, "themeStorePage", None)
|
||||
return content_stack is not None and content_stack.currentWidget() == store_page
|
||||
|
||||
def _get_theme_store_focusables(self) -> list[QWidget]:
|
||||
store_stack = getattr(self._parent, "themeStoreStack", None)
|
||||
detail_page = getattr(self._parent, "themeStoreDetailPage", None)
|
||||
if store_stack is not None and store_stack.currentWidget() == detail_page:
|
||||
return self._get_theme_store_detail_focusables()
|
||||
|
||||
widgets = []
|
||||
for attr_name in ("themesCombo", "themeStoreSortCombo"):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if self._is_visible_enabled_widget(widget):
|
||||
widgets.append(widget)
|
||||
widgets.extend(self._get_theme_store_cards())
|
||||
return widgets
|
||||
|
||||
def _get_theme_store_detail_focusables(self) -> list[QWidget]:
|
||||
widgets = []
|
||||
for attr_name in (
|
||||
"themeStoreBackButton",
|
||||
"themeStoreDownloadButton",
|
||||
"themeStoreCarousel",
|
||||
"themeStoreDarkButton",
|
||||
"themeStoreLightButton",
|
||||
):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if self._is_visible_enabled_widget(widget):
|
||||
widgets.append(widget)
|
||||
return widgets
|
||||
|
||||
def _get_theme_store_cards(self) -> list[QWidget]:
|
||||
grid_widget = getattr(self._parent, "themeStoreGridWidget", None)
|
||||
if not isinstance(grid_widget, QWidget):
|
||||
return []
|
||||
cards = grid_widget.findChildren(QWidget, "themeStoreCard")
|
||||
return [card for card in cards if card.isVisible() and card.isEnabled()]
|
||||
|
||||
def _handle_theme_tab_navigation(self, code: int, value: int) -> bool:
|
||||
"""Handle D-pad focus movement in themes tab."""
|
||||
theme_tab_index = getattr(self._parent, "theme_tab_index", None)
|
||||
if theme_tab_index is None or self._parent.stackedWidget.currentIndex() != theme_tab_index:
|
||||
return False
|
||||
|
||||
if self._is_theme_store_visible() and self._handle_theme_store_grid_navigation(code, value):
|
||||
return True
|
||||
|
||||
focused = QApplication.focusWidget()
|
||||
if code == PAD_DPAD_X and focused in self._get_theme_carousels():
|
||||
self._scroll_theme_carousel(focused, value)
|
||||
return True
|
||||
|
||||
focusables = self._get_theme_tab_focusables()
|
||||
if not focusables:
|
||||
return False
|
||||
|
||||
focused = QApplication.focusWidget()
|
||||
if focused not in focusables:
|
||||
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
current_index = focusables.index(focused)
|
||||
if code == PAD_DPAD_Y and value > 0 or code == PAD_DPAD_X and value > 0:
|
||||
next_index = (current_index + 1) % len(focusables)
|
||||
else:
|
||||
next_index = (current_index - 1) % len(focusables)
|
||||
focusables[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
self._ensure_theme_store_widget_visible(focusables[next_index])
|
||||
return True
|
||||
|
||||
def _handle_theme_store_grid_navigation(self, code: int, value: int) -> bool:
|
||||
cards = self._get_theme_store_cards()
|
||||
return self._navigate_card_grid(cards, code, value)
|
||||
|
||||
def _ensure_theme_store_widget_visible(self, widget: QWidget) -> None:
|
||||
scroll_area = getattr(self._parent, "themeStoreScrollArea", None)
|
||||
if isinstance(scroll_area, QScrollArea):
|
||||
scroll_area.ensureWidgetVisible(widget)
|
||||
|
||||
def _get_theme_carousels(self) -> list[QWidget]:
|
||||
widgets = []
|
||||
for attr_name in ("screenshotsCarousel", "themeStoreCarousel"):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if self._is_visible_enabled_widget(widget):
|
||||
widgets.append(widget)
|
||||
return widgets
|
||||
|
||||
def _scroll_theme_carousel(self, carousel: QWidget, value: int) -> None:
|
||||
if value > 0:
|
||||
scroll_right = getattr(carousel, "scroll_right", None)
|
||||
if callable(scroll_right):
|
||||
scroll_right()
|
||||
elif value < 0:
|
||||
scroll_left = getattr(carousel, "scroll_left", None)
|
||||
if callable(scroll_left):
|
||||
scroll_left()
|
||||
|
||||
def _focus_first_dialog_widget(self, dialog: QDialog) -> bool:
|
||||
focusables = dialog.findChildren(
|
||||
QWidget,
|
||||
options=Qt.FindChildOption.FindChildrenRecursively,
|
||||
)
|
||||
focusables = [widget for widget in focusables if widget.focusPolicy() & Qt.FocusPolicy.StrongFocus]
|
||||
if not focusables:
|
||||
return False
|
||||
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _handle_add_game_checkbox_dpad(self, dialog: AddGameDialog, code: int, value: int) -> bool:
|
||||
if code not in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
|
||||
return False
|
||||
if code == PAD_AXIS_LEFT_X:
|
||||
if abs(value) < self.dead_zone:
|
||||
return True
|
||||
value = 1 if value > self.dead_zone else -1
|
||||
checkboxes = [
|
||||
dialog.add_to_steam_checkbox,
|
||||
dialog.add_to_menu_checkbox,
|
||||
dialog.add_to_desktop_checkbox,
|
||||
]
|
||||
focused = QApplication.focusWidget()
|
||||
if not isinstance(focused, QCheckBox) or focused not in checkboxes:
|
||||
return False
|
||||
current_index = checkboxes.index(focused)
|
||||
next_index = current_index + (1 if value > 0 else -1)
|
||||
if not 0 <= next_index < len(checkboxes):
|
||||
return False
|
||||
checkboxes[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _handle_dialog_dpad(self, active: QWidget, code: int, value: int) -> bool:
|
||||
focused = QApplication.focusWidget()
|
||||
if isinstance(active, QMessageBox) and not isinstance(focused, QTableWidget):
|
||||
if not focused or not active.focusWidget():
|
||||
self._focus_first_dialog_widget(active)
|
||||
elif code in (PAD_DPAD_X, PAD_DPAD_Y):
|
||||
active.focusNextChild() if value > 0 else active.focusPreviousChild()
|
||||
return True
|
||||
if isinstance(active, AddGameDialog) and code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
|
||||
return self._handle_add_game_checkbox_dpad(active, code, value)
|
||||
if not isinstance(active, QDialog):
|
||||
return False
|
||||
if code not in (PAD_DPAD_X, PAD_DPAD_Y):
|
||||
return False
|
||||
if code == PAD_DPAD_Y and isinstance(focused, QTableWidget):
|
||||
return False
|
||||
if not focused or not active.focusWidget():
|
||||
self._focus_first_dialog_widget(active)
|
||||
return True
|
||||
active.focusNextChild() if value > 0 else active.focusPreviousChild()
|
||||
return True
|
||||
|
||||
def _handle_popup_dpad(self, popup: QWidget | None, code: int, value: int) -> bool:
|
||||
if not isinstance(popup, QMenu):
|
||||
return False
|
||||
if code == PAD_DPAD_Y:
|
||||
self._navigate_menu_actions(popup, direction_down=value > 0)
|
||||
return True
|
||||
|
||||
def _handle_list_dpad(self, focused: QWidget | None, code: int, value: int) -> bool:
|
||||
if not isinstance(focused, QListView) or code != PAD_DPAD_Y:
|
||||
return False
|
||||
model = focused.model()
|
||||
current_index = focused.currentIndex()
|
||||
if not model or not current_index.isValid():
|
||||
return True
|
||||
next_row = current_index.row() + (1 if value > 0 else -1)
|
||||
next_row = max(0, min(next_row, model.rowCount() - 1))
|
||||
focused.setCurrentIndex(model.index(next_row, current_index.column()))
|
||||
focused.scrollTo(focused.currentIndex(), QListView.ScrollHint.PositionAtCenter)
|
||||
return True
|
||||
|
||||
def _handle_fullscreen_dpad(self, active: QWidget, code: int, value: int) -> bool:
|
||||
if not isinstance(active, FullscreenDialog) or code != PAD_DPAD_X:
|
||||
return False
|
||||
if value < 0:
|
||||
active.show_prev()
|
||||
else:
|
||||
active.show_next()
|
||||
return True
|
||||
|
||||
def _handle_system_section_horizontal(self, code: int, value: int, initial_press: bool) -> bool:
|
||||
if code != PAD_DPAD_X or not initial_press:
|
||||
return False
|
||||
if self._parent.stackedWidget.currentIndex() != getattr(self._parent, "system_tab_index", -1):
|
||||
return False
|
||||
switch_relative = getattr(self._parent, "switchSystemSectionRelative", None)
|
||||
if not callable(switch_relative) or not switch_relative(1 if value > 0 else -1):
|
||||
return False
|
||||
section_stack = getattr(self._parent, "systemSectionStack", None)
|
||||
section_buttons = getattr(self._parent, "systemSectionButtons", [])
|
||||
if section_stack is None or not section_buttons:
|
||||
return True
|
||||
current_index = section_stack.currentIndex()
|
||||
if 0 <= current_index < len(section_buttons):
|
||||
section_buttons[current_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _focus_toolbar_from_first_card(self, code: int, value: int) -> bool:
|
||||
focused = QApplication.focusWidget()
|
||||
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, AnimatedCard):
|
||||
return False
|
||||
if current_index == 0:
|
||||
container = self._parent.gamesListWidget
|
||||
toolbar_widgets = self._get_library_toolbar_widgets()
|
||||
focus_target = toolbar_widgets[0] if toolbar_widgets else None
|
||||
else:
|
||||
container = self._parent.autoInstallContainer
|
||||
focus_target = getattr(self._parent, 'autoInstallSearchLineEdit', None)
|
||||
if container is None or focus_target is None:
|
||||
return False
|
||||
cards: list[QWidget] = [
|
||||
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]:
|
||||
return False
|
||||
focus_target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _handle_system_section_vertical(self, code: int, value: int) -> bool:
|
||||
if code != PAD_DPAD_Y:
|
||||
return False
|
||||
if self._parent.stackedWidget.currentIndex() != getattr(self._parent, "system_tab_index", -1):
|
||||
return False
|
||||
focused = QApplication.focusWidget()
|
||||
section_buttons = getattr(self._parent, "systemSectionButtons", [])
|
||||
if focused not in section_buttons:
|
||||
return False
|
||||
if value < 0:
|
||||
return True
|
||||
section_stack = getattr(self._parent, "systemSectionStack", None)
|
||||
focus_targets = getattr(self._parent, "systemSectionFocusTargets", [])
|
||||
if section_stack is None:
|
||||
return True
|
||||
current_index = section_stack.currentIndex()
|
||||
if 0 <= current_index < len(focus_targets):
|
||||
target = focus_targets[current_index]
|
||||
if target is not None and target.isVisible() and target.isEnabled():
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
@Slot(int, int, float)
|
||||
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
|
||||
if self._route_surface_dpad(code, value, current_time):
|
||||
return
|
||||
self._handle_default_dpad(code, value, current_time)
|
||||
|
||||
def _handle_default_dpad(self, code: int, value: int, current_time: float) -> None:
|
||||
active_window = QApplication.activeWindow()
|
||||
if isinstance(active_window, AddGameDialog):
|
||||
keyboard = getattr(active_window, 'keyboard', None)
|
||||
else:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
|
||||
if value == 0:
|
||||
self._reset_dpad_repeat()
|
||||
return
|
||||
|
||||
is_initial_press = not self.axis_moving
|
||||
self._start_dpad_repeat(code, value, current_time)
|
||||
|
||||
if keyboard and keyboard.isVisible() and self._handle_keyboard_dpad(keyboard, code, value):
|
||||
return
|
||||
|
||||
if not self._gamepad_handling_enabled:
|
||||
return
|
||||
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
|
||||
logger.error("gamesListWidget not available yet, skipping D-pad navigation")
|
||||
return
|
||||
|
||||
try:
|
||||
self._route_dpad_navigation(code, value, is_initial_press)
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True)
|
||||
|
||||
def _route_dpad_navigation(self, code: int, value: int, is_initial_press: bool) -> None:
|
||||
app = QApplication.instance()
|
||||
active = QApplication.activeWindow()
|
||||
focused = QApplication.focusWidget()
|
||||
popup = QApplication.activePopupWidget()
|
||||
if not app or not active:
|
||||
return
|
||||
combo_popup = (
|
||||
isinstance(focused, QListView)
|
||||
and self._find_parent_combo(focused) is not None
|
||||
)
|
||||
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
|
||||
if self._handle_popup_dpad(popup, code, value):
|
||||
return
|
||||
if self._handle_list_dpad(focused, code, value):
|
||||
return
|
||||
if self._handle_fullscreen_dpad(active, code, value):
|
||||
return
|
||||
if self._handle_system_section_horizontal(code, value, is_initial_press):
|
||||
return
|
||||
if isinstance(focused, QTableWidget):
|
||||
self.handle_table_navigation(focused, code, value)
|
||||
return
|
||||
if self._focus_toolbar_from_first_card(code, value):
|
||||
return
|
||||
if self._handle_library_dpad(code, value):
|
||||
return
|
||||
if code in (PAD_DPAD_X, PAD_DPAD_Y) and self._handle_theme_tab_navigation(code, value):
|
||||
return
|
||||
if self._handle_system_section_vertical(code, value):
|
||||
return
|
||||
if self._handle_detail_page_dpad(code, value):
|
||||
return
|
||||
self._handle_page_vertical_dpad(code, value)
|
||||
|
||||
def _handle_detail_page_dpad(self, code: int, value: int) -> bool:
|
||||
direction = self._normalize_dpad_direction(code, value)
|
||||
if direction is None:
|
||||
return False
|
||||
focused = QApplication.focusWidget()
|
||||
page = self._parent.stackedWidget.currentWidget()
|
||||
current_detail_page = getattr(self._parent, "currentDetailPage", None)
|
||||
if not isinstance(focused, AutoSizeButton) or page != current_detail_page:
|
||||
return False
|
||||
parent = focused.parentWidget()
|
||||
if parent is None:
|
||||
return False
|
||||
rows = self._get_detail_button_rows(parent)
|
||||
if len(rows) == 1 and len(rows[0]) <= 1:
|
||||
return False
|
||||
target = self._get_detail_button_target(rows, focused, direction)
|
||||
if target is None:
|
||||
return False
|
||||
self._focus_detail_button(target)
|
||||
return True
|
||||
|
||||
def _handle_library_dpad(self, code: int, value: int) -> bool:
|
||||
if code not in (PAD_DPAD_X, PAD_DPAD_Y):
|
||||
return False
|
||||
current_index = self._parent.stackedWidget.currentIndex()
|
||||
if current_index not in (0, 1):
|
||||
return False
|
||||
if self._handle_toolbar_navigation(code, value):
|
||||
return True
|
||||
if code == PAD_DPAD_Y and value < 0 and self._focus_tab_from_search(current_index):
|
||||
return True
|
||||
container = (
|
||||
self._parent.gamesListWidget
|
||||
if current_index == 0
|
||||
else self._parent.autoInstallContainer
|
||||
)
|
||||
if container is not None:
|
||||
self._navigate_game_cards(container, current_index, code, value)
|
||||
return True
|
||||
|
||||
def _handle_page_vertical_dpad(self, code: int, value: int) -> bool:
|
||||
if code != PAD_DPAD_Y or value == 0:
|
||||
return False
|
||||
focused = QApplication.focusWidget()
|
||||
if focused is None:
|
||||
return False
|
||||
if value < 0:
|
||||
focused.focusPreviousChild()
|
||||
return True
|
||||
if not isinstance(focused, NavLabel):
|
||||
focused.focusNextChild()
|
||||
return True
|
||||
page = self._parent.stackedWidget.currentWidget()
|
||||
focusables = page.findChildren(
|
||||
QWidget,
|
||||
options=Qt.FindChildOption.FindChildrenRecursively,
|
||||
)
|
||||
focusables = [
|
||||
widget for widget in focusables
|
||||
if widget.focusPolicy() & Qt.FocusPolicy.StrongFocus
|
||||
]
|
||||
if not focusables:
|
||||
return False
|
||||
focusables[0].setFocus()
|
||||
return True
|
||||
|
||||
def _normalize_dpad_direction(self, code: int, value: int) -> tuple[int, int] | None:
|
||||
if code in (PAD_DPAD_X, PAD_DPAD_Y):
|
||||
return (code, value) if value != 0 else None
|
||||
if code not in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y) or abs(value) < self.dead_zone:
|
||||
return None
|
||||
normalized_code = PAD_DPAD_X if code == PAD_AXIS_LEFT_X else PAD_DPAD_Y
|
||||
return normalized_code, 1 if value > self.dead_zone else -1
|
||||
|
||||
def _get_detail_button_rows(self, parent: QWidget) -> list[list[AutoSizeButton]]:
|
||||
buttons = parent.findChildren(
|
||||
AutoSizeButton,
|
||||
options=Qt.FindChildOption.FindDirectChildrenOnly,
|
||||
)
|
||||
buttons = [button for button in buttons if button.isVisible() and button.isEnabled()]
|
||||
centers = {button: button.geometry().center() for button in buttons}
|
||||
rows: list[list[AutoSizeButton]] = []
|
||||
for button in sorted(buttons, key=lambda item: (centers[item].y(), centers[item].x())):
|
||||
if not rows or abs(centers[button].y() - centers[rows[-1][0]].y()) > 24:
|
||||
rows.append([button])
|
||||
else:
|
||||
rows[-1].append(button)
|
||||
for row in rows:
|
||||
row.sort(key=lambda item: centers[item].x())
|
||||
return rows
|
||||
|
||||
def _get_detail_button_target(
|
||||
self, rows: list[list[AutoSizeButton]], focused: AutoSizeButton,
|
||||
direction: tuple[int, int],
|
||||
) -> AutoSizeButton | None:
|
||||
row_index = next((index for index, row in enumerate(rows) if focused in row), -1)
|
||||
if row_index < 0:
|
||||
return None
|
||||
code, value = direction
|
||||
if code == PAD_DPAD_X:
|
||||
flat = [button for row in rows for button in row]
|
||||
index = flat.index(focused) + value
|
||||
return flat[index] if 0 <= index < len(flat) else None
|
||||
target_row_index = row_index + value
|
||||
if not 0 <= target_row_index < len(rows):
|
||||
return None
|
||||
focused_x = focused.geometry().center().x()
|
||||
return min(
|
||||
rows[target_row_index],
|
||||
key=lambda button: abs(button.geometry().center().x() - focused_x),
|
||||
)
|
||||
|
||||
def _focus_detail_button(self, target: AutoSizeButton) -> None:
|
||||
target.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
parent = target.parentWidget()
|
||||
while parent and not isinstance(parent, QScrollArea):
|
||||
parent = parent.parentWidget()
|
||||
if isinstance(parent, QScrollArea):
|
||||
parent.ensureWidgetVisible(target, 20, 20)
|
||||
|
||||
def _reset_dpad_repeat(self) -> None:
|
||||
self.current_dpad_code = None
|
||||
self.current_dpad_value = 0
|
||||
self.axis_moving = False
|
||||
self.current_axis_delay = self.initial_axis_move_delay
|
||||
self.dpad_timer.stop()
|
||||
|
||||
def _start_dpad_repeat(self, code: int, value: int, current_time: float) -> None:
|
||||
self.current_dpad_code = code
|
||||
self.current_dpad_value = value
|
||||
if self.axis_moving:
|
||||
return
|
||||
self.axis_moving = True
|
||||
self.last_move_time = current_time
|
||||
self.current_axis_delay = self.initial_axis_move_delay
|
||||
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))
|
||||
|
||||
def _handle_keyboard_dpad(self, keyboard: VirtualKeyboard, code: int, value: int) -> bool:
|
||||
directions = {
|
||||
PAD_DPAD_X: (keyboard.move_focus_left, keyboard.move_focus_right),
|
||||
PAD_AXIS_LEFT_X: (keyboard.move_focus_left, keyboard.move_focus_right),
|
||||
PAD_DPAD_Y: (keyboard.move_focus_up, keyboard.move_focus_down),
|
||||
PAD_AXIS_LEFT_Y: (keyboard.move_focus_up, keyboard.move_focus_down),
|
||||
}
|
||||
actions = directions.get(code)
|
||||
if actions is None:
|
||||
return False
|
||||
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
|
||||
if abs(value) < self.dead_zone:
|
||||
self._reset_dpad_repeat()
|
||||
return True
|
||||
value = 1 if value > self.dead_zone else -1
|
||||
actions[1 if value > 0 else 0]()
|
||||
return True
|
||||
@@ -1,317 +0,0 @@
|
||||
import os
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QDialog,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.input_manager.constants import (
|
||||
BUTTONS,
|
||||
PAD_AXIS_LEFT_X,
|
||||
PAD_AXIS_LEFT_Y,
|
||||
PAD_DPAD_X,
|
||||
PAD_DPAD_Y,
|
||||
)
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.input_manager.mixin import InputMixin
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class FileExplorerInputMixin(InputMixin):
|
||||
# FILE EXPLORER MODE
|
||||
def enable_file_explorer_mode(self, file_explorer):
|
||||
"""Configure gamepad handling for FileExplorer"""
|
||||
try:
|
||||
self._setup_mode_handlers(
|
||||
file_explorer,
|
||||
self.handle_file_explorer_button,
|
||||
self.handle_file_explorer_dpad,
|
||||
'file_explorer'
|
||||
)
|
||||
logger.debug("Gamepad handling successfully connected for FileExplorer")
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting gamepad handlers for FileExplorer: {e}")
|
||||
|
||||
def disable_file_explorer_mode(self):
|
||||
"""Restore original handlers (return default)"""
|
||||
try:
|
||||
if self.file_explorer:
|
||||
# Additional cleanup for file explorer
|
||||
self.nav_timer.stop()
|
||||
self._restore_original_handlers('file_explorer')
|
||||
logger.debug("Gamepad handling successfully restored")
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring gamepad handlers: {e}")
|
||||
|
||||
def handle_file_explorer_button(self, button_code, value):
|
||||
active_window = QApplication.activeWindow()
|
||||
dialog_keyboard = getattr(active_window, 'keyboard', None) if active_window else None
|
||||
if isinstance(dialog_keyboard, VirtualKeyboard) and dialog_keyboard.isVisible():
|
||||
self.handle_virtual_keyboard(button_code, value)
|
||||
return
|
||||
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
if keyboard and keyboard.isVisible():
|
||||
self.handle_virtual_keyboard(button_code, value)
|
||||
return
|
||||
|
||||
if value == 0: # Ignore releases
|
||||
return
|
||||
|
||||
try:
|
||||
# 1. Handle Popups (Menus)
|
||||
popup = QApplication.activePopupWidget()
|
||||
if isinstance(popup, QMenu):
|
||||
if button_code in BUTTONS['confirm']: # A button
|
||||
if popup.activeAction():
|
||||
popup.activeAction().trigger()
|
||||
popup.close()
|
||||
return
|
||||
elif button_code in BUTTONS['back']: # B button
|
||||
popup.close()
|
||||
return
|
||||
return # Skip other handling if menu is open
|
||||
|
||||
active_window = QApplication.activeWindow()
|
||||
if isinstance(active_window, QDialog) and active_window != self.file_explorer:
|
||||
if button_code in BUTTONS['confirm']: # A button
|
||||
focused_widget = QApplication.focusWidget()
|
||||
if isinstance(focused_widget, QLineEdit):
|
||||
keyboard = getattr(active_window, 'keyboard', None)
|
||||
if not keyboard:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
if keyboard:
|
||||
focused_widget.setFocus()
|
||||
keyboard.show_for_widget(focused_widget)
|
||||
elif isinstance(focused_widget, QPushButton):
|
||||
focused_widget.click()
|
||||
else:
|
||||
active_window.accept()
|
||||
elif button_code in BUTTONS['back']: # B button
|
||||
active_window.reject()
|
||||
return
|
||||
|
||||
# 2. Validate File Explorer state
|
||||
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
|
||||
logger.debug("No file explorer or file_list available")
|
||||
return
|
||||
|
||||
focused_widget = QApplication.focusWidget()
|
||||
|
||||
# 3. Handle Buttons
|
||||
if button_code in BUTTONS['confirm']: # A button
|
||||
# Check if a drive button is focused
|
||||
if isinstance(focused_widget, AutoSizeButton) and \
|
||||
hasattr(self.file_explorer, 'drive_buttons') and \
|
||||
focused_widget in self.file_explorer.drive_buttons:
|
||||
self.file_explorer.select_drive()
|
||||
return
|
||||
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
return
|
||||
|
||||
selected = self.file_explorer.file_list.currentItem().text()
|
||||
full_path = os.path.join(self.file_explorer.current_path, selected)
|
||||
|
||||
if os.path.isdir(full_path):
|
||||
self.file_explorer.current_path = os.path.normpath(full_path)
|
||||
self.file_explorer.update_file_list()
|
||||
elif not self.file_explorer.directory_only:
|
||||
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.file_explorer.accept()
|
||||
else:
|
||||
logger.debug(f"Selected item is not a directory: {full_path}")
|
||||
|
||||
elif button_code in BUTTONS['context_menu']: # Start button
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
return
|
||||
|
||||
current_item = self.file_explorer.file_list.currentItem()
|
||||
if current_item:
|
||||
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
|
||||
self.file_explorer.show_folder_context_menu(item_rect.center())
|
||||
|
||||
elif button_code in BUTTONS['add_game']: # X button
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
return
|
||||
|
||||
selected = self.file_explorer.file_list.currentItem().text()
|
||||
full_path = os.path.join(self.file_explorer.current_path, selected)
|
||||
|
||||
if os.path.isdir(full_path):
|
||||
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.file_explorer.accept()
|
||||
|
||||
elif button_code in BUTTONS['back']: # B button
|
||||
self.file_explorer.close()
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y button
|
||||
self.file_explorer.previous_dir()
|
||||
|
||||
else:
|
||||
self._handle_default_button(button_code, value)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in FileExplorer button handler: {e}")
|
||||
|
||||
def handle_file_explorer_dpad(self, code, value, current_time):
|
||||
try:
|
||||
active_window = QApplication.activeWindow()
|
||||
keyboard = getattr(active_window, 'keyboard', None) if active_window else None
|
||||
if keyboard is None:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
|
||||
if keyboard and keyboard.isVisible():
|
||||
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
|
||||
normalized_value = value
|
||||
if code == PAD_AXIS_LEFT_X:
|
||||
if abs(value) < self.dead_zone:
|
||||
return
|
||||
normalized_value = 1 if value > self.dead_zone else -1
|
||||
if normalized_value > 0:
|
||||
keyboard.move_focus_right()
|
||||
elif normalized_value < 0:
|
||||
keyboard.move_focus_left()
|
||||
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
|
||||
normalized_value = value
|
||||
if code == PAD_AXIS_LEFT_Y:
|
||||
if abs(value) < self.dead_zone:
|
||||
return
|
||||
normalized_value = 1 if value > self.dead_zone else -1
|
||||
if normalized_value > 0:
|
||||
keyboard.move_focus_down()
|
||||
elif normalized_value < 0:
|
||||
keyboard.move_focus_up()
|
||||
return
|
||||
|
||||
# 1. Handle Popups (Menus)
|
||||
popup = QApplication.activePopupWidget()
|
||||
if isinstance(popup, QMenu):
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
self._navigate_menu_actions(popup, direction_down=value > 0)
|
||||
return
|
||||
|
||||
focused_widget = QApplication.focusWidget()
|
||||
if isinstance(active_window, QDialog) and active_window != self.file_explorer:
|
||||
if not focused_widget or not active_window.focusWidget():
|
||||
focusables = active_window.findChildren(
|
||||
QWidget,
|
||||
options=Qt.FindChildOption.FindChildrenRecursively
|
||||
)
|
||||
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
|
||||
if focusables:
|
||||
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if code == PAD_DPAD_X and value != 0:
|
||||
if value > 0:
|
||||
active_window.focusNextChild()
|
||||
elif value < 0:
|
||||
active_window.focusPreviousChild()
|
||||
return
|
||||
if code == PAD_DPAD_Y and value != 0 and not isinstance(focused_widget, QTableWidget):
|
||||
if value > 0:
|
||||
active_window.focusNextChild()
|
||||
elif value < 0:
|
||||
active_window.focusPreviousChild()
|
||||
return
|
||||
|
||||
# 2. Validate State
|
||||
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
|
||||
return
|
||||
|
||||
focused_widget = QApplication.focusWidget()
|
||||
|
||||
# 3. Handle Drive Buttons Navigation (Horizontal)
|
||||
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X) and \
|
||||
hasattr(self.file_explorer, 'drive_buttons') and \
|
||||
self.file_explorer.drive_buttons:
|
||||
if code == PAD_DPAD_X and value == 0:
|
||||
return
|
||||
|
||||
is_drive_button = (
|
||||
isinstance(focused_widget, AutoSizeButton) and
|
||||
focused_widget in self.file_explorer.drive_buttons
|
||||
)
|
||||
if code == PAD_AXIS_LEFT_X:
|
||||
if abs(value) < self.dead_zone or not is_drive_button:
|
||||
return
|
||||
value = 1 if value > self.dead_zone else -1
|
||||
|
||||
if not is_drive_button:
|
||||
# Focus first drive button if not currently on one
|
||||
self.file_explorer.drive_buttons[0].setFocus()
|
||||
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
|
||||
return
|
||||
|
||||
current_idx = self.file_explorer.drive_buttons.index(focused_widget)
|
||||
next_idx = current_idx
|
||||
|
||||
if value < 0: # Left
|
||||
next_idx = max(current_idx - 1, 0)
|
||||
elif value > 0: # Right
|
||||
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
|
||||
|
||||
if next_idx != current_idx:
|
||||
self.file_explorer.drive_buttons[next_idx].setFocus()
|
||||
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
|
||||
return
|
||||
|
||||
# 4. Handle Vertical Navigation (File List vs Drive Buttons)
|
||||
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
|
||||
# Move from buttons to list
|
||||
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
|
||||
if code == PAD_DPAD_Y and value > 0 and self.file_explorer.file_list.count() > 0:
|
||||
self.file_explorer.file_list.setFocus()
|
||||
self.file_explorer.file_list.setCurrentRow(0)
|
||||
self.file_explorer.file_list.scrollToItem(self.file_explorer.file_list.currentItem())
|
||||
return
|
||||
|
||||
# D-pad: Fixed speed
|
||||
if code == PAD_DPAD_Y:
|
||||
if value != 0:
|
||||
self.current_direction = value
|
||||
self.stick_value = 1.0
|
||||
if not self.nav_timer.isActive():
|
||||
self.file_explorer.move_selection(self.current_direction)
|
||||
self.last_nav_time = current_time
|
||||
self.nav_timer.start(int(self.initial_nav_delay * 1000))
|
||||
else:
|
||||
self.current_direction = 0
|
||||
self.nav_timer.stop()
|
||||
|
||||
# Stick: Analog speed
|
||||
elif code == PAD_AXIS_LEFT_Y:
|
||||
if abs(value) < self.dead_zone:
|
||||
if self.stick_activated:
|
||||
self.current_direction = 0
|
||||
self.nav_timer.stop()
|
||||
self.stick_activated = False
|
||||
return
|
||||
|
||||
normalized_value = (abs(value) - self.dead_zone) / (32768 - self.dead_zone)
|
||||
speed_factor = 0.3 + (normalized_value * 0.7)
|
||||
|
||||
self.current_direction = -1 if value < 0 else 1
|
||||
self.stick_value = speed_factor
|
||||
self.stick_activated = True
|
||||
|
||||
if not self.nav_timer.isActive():
|
||||
self.file_explorer.move_selection(self.current_direction)
|
||||
self.last_nav_time = current_time
|
||||
self.nav_timer.start(int(self.initial_nav_delay * 1000))
|
||||
|
||||
# 5. Fallback
|
||||
else:
|
||||
self._handle_default_dpad(code, value, current_time)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in FileExplorer dpad handler: {e}")
|
||||
@@ -1,611 +0,0 @@
|
||||
import os
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
from shiboken6 import isValid
|
||||
from PySide6.QtCore import QEvent, QObject, QPoint, Qt
|
||||
from PySide6.QtGui import QKeySequence
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractScrollArea,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QTableWidget,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.game_card import AnimatedCard, GameCard
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
BUTTONS,
|
||||
KEY_BACKSPACE,
|
||||
KEY_DOWN,
|
||||
KEY_E,
|
||||
KEY_ENTER,
|
||||
KEY_ESCAPE,
|
||||
KEY_F5,
|
||||
KEY_F10,
|
||||
KEY_F11,
|
||||
KEY_LEFT,
|
||||
KEY_Q,
|
||||
KEY_RETURN,
|
||||
KEY_RIGHT,
|
||||
KEY_UP,
|
||||
MOD_CTRL,
|
||||
MOD_SHIFT,
|
||||
PAD_DPAD_X,
|
||||
PAD_DPAD_Y,
|
||||
)
|
||||
from portprotonqt.input_manager.mixin import InputMixin
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class KeyboardInputMixin(InputMixin):
|
||||
def handle_virtual_keyboard(self, button_code: int, value: int) -> None:
|
||||
active_window = QApplication.activeWindow()
|
||||
keyboard = getattr(active_window, 'keyboard', None) if active_window else None
|
||||
if keyboard is None:
|
||||
keyboard = getattr(self._parent, 'keyboard', None)
|
||||
|
||||
if not keyboard or not isinstance(keyboard, VirtualKeyboard) or not keyboard.isVisible():
|
||||
return
|
||||
|
||||
# Handle gamepad buttons
|
||||
if button_code in BUTTONS['confirm']: # A/Cross button - confirm
|
||||
if value == 1:
|
||||
keyboard.activateFocusedKey()
|
||||
elif button_code in BUTTONS['back']: # B/Circle button - hide keyboard
|
||||
if value == 1:
|
||||
keyboard.hide()
|
||||
# Return focus to input field
|
||||
if keyboard.current_input_widget:
|
||||
keyboard.current_input_widget.setFocus()
|
||||
elif button_code in BUTTONS['prev_tab']: # LB/L1 - switch layout
|
||||
if value == 1:
|
||||
keyboard.on_lang_click()
|
||||
elif button_code in BUTTONS['next_tab']: # RB/R1 - toggle Shift
|
||||
if value == 1:
|
||||
keyboard.on_shift_click(not keyboard.shift_pressed)
|
||||
elif button_code in BUTTONS['context_menu']: # Start button - confirm
|
||||
if value == 1:
|
||||
keyboard.activateFocusedKey()
|
||||
elif button_code in BUTTONS['menu']: # Select button - hide keyboard
|
||||
if value == 1:
|
||||
keyboard.hide()
|
||||
# Return focus to input field
|
||||
if keyboard.current_input_widget:
|
||||
keyboard.current_input_widget.setFocus()
|
||||
elif button_code in BUTTONS['add_game']: # X button - Backspace (now holdable)
|
||||
if value == 1:
|
||||
keyboard.on_backspace_pressed()
|
||||
elif value == 0:
|
||||
keyboard.stop_backspace_repeat()
|
||||
|
||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
if event.type() == QEvent.Type.Show:
|
||||
if isinstance(obj, QMenu):
|
||||
SoundManager().play("open")
|
||||
elif isinstance(obj, QWidget) and obj.windowType() == Qt.WindowType.Popup:
|
||||
parent = obj.parent()
|
||||
while isinstance(parent, QObject):
|
||||
if isinstance(parent, QComboBox):
|
||||
if not parent.property("_sound_activated_connected"):
|
||||
parent.activated.connect(
|
||||
lambda _index: SoundManager().play("toggle"),
|
||||
)
|
||||
parent.setProperty("_sound_activated_connected", True)
|
||||
break
|
||||
parent = parent.parent()
|
||||
|
||||
if event.type() == QEvent.Type.Wheel:
|
||||
combo = obj if isinstance(obj, QComboBox) else None
|
||||
parent = obj.parent() if isinstance(obj, QObject) else None
|
||||
while combo is None and isinstance(parent, QObject):
|
||||
if isinstance(parent, QComboBox):
|
||||
combo = parent
|
||||
break
|
||||
parent = parent.parent()
|
||||
if isinstance(combo, QComboBox) and not combo.view().isVisible():
|
||||
# Find parent scroll area
|
||||
scrollable = combo.parent()
|
||||
while scrollable:
|
||||
if isinstance(scrollable, QAbstractScrollArea):
|
||||
old_focus = QApplication.focusWidget()
|
||||
QApplication.sendEvent(scrollable.viewport(), event)
|
||||
if old_focus:
|
||||
old_focus.setFocus()
|
||||
return True
|
||||
scrollable = scrollable.parent()
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
if self._redirect_gamecard_input_to_search(event):
|
||||
return True
|
||||
key = self._qt_event_to_input_key(event)
|
||||
if key is not None:
|
||||
return self._handle_input_key_press(key, self._qt_modifiers_to_input(event))
|
||||
if event.type() == QEvent.Type.KeyRelease:
|
||||
key = self._qt_event_to_input_key(event)
|
||||
if key is not None:
|
||||
return self._handle_input_key_release(key)
|
||||
|
||||
if event.type() == QEvent.Type.MouseButtonPress:
|
||||
button_method = getattr(event, "button", None)
|
||||
button = button_method() if callable(button_method) else None
|
||||
if button == Qt.MouseButton.ExtraButton1:
|
||||
self._handle_back_mouse_button()
|
||||
return True
|
||||
|
||||
if event.type() == QEvent.Type.MouseButtonRelease:
|
||||
button_method = getattr(event, "button", None)
|
||||
button = button_method() if callable(button_method) else None
|
||||
if button == Qt.MouseButton.LeftButton:
|
||||
SoundManager().play_widget_sound(obj)
|
||||
|
||||
# Ensure obj is a QObject
|
||||
if not isinstance(obj, QObject):
|
||||
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
|
||||
return False
|
||||
|
||||
return QObject.eventFilter(cast(QObject, self), obj, event)
|
||||
|
||||
def _qt_event_to_input_key(self, event: QEvent) -> int | None:
|
||||
native_key = self._native_scan_to_input_key(event)
|
||||
if native_key is not None:
|
||||
return native_key
|
||||
text_method = getattr(event, "text", None)
|
||||
text = text_method() if callable(text_method) else ""
|
||||
text = text if isinstance(text, str) else ""
|
||||
normalized_text = text.lower()
|
||||
if len(normalized_text) == 1 and normalized_text.isprintable():
|
||||
return ord(normalized_text)
|
||||
key_method = getattr(event, "key", None)
|
||||
key_value = key_method() if callable(key_method) else 0
|
||||
key = key_value if isinstance(key_value, int) else 0
|
||||
name = QKeySequence(key).toString()
|
||||
if len(name) == 1 and name.isprintable():
|
||||
return ord(name.lower())
|
||||
key_names = {
|
||||
"Backspace": KEY_BACKSPACE,
|
||||
"Down": KEY_DOWN,
|
||||
"Enter": KEY_ENTER,
|
||||
"Esc": KEY_ESCAPE,
|
||||
"F5": KEY_F5,
|
||||
"F10": KEY_F10,
|
||||
"F11": KEY_F11,
|
||||
"Left": KEY_LEFT,
|
||||
"Return": KEY_RETURN,
|
||||
"Right": KEY_RIGHT,
|
||||
"Up": KEY_UP,
|
||||
}
|
||||
return key_names.get(name)
|
||||
|
||||
def _native_scan_to_input_key(self, event: QEvent) -> int | None:
|
||||
scan_method = getattr(event, "nativeScanCode", None)
|
||||
scan_value = scan_method() if callable(scan_method) else 0
|
||||
scan_code = scan_value if isinstance(scan_value, int) else 0
|
||||
scan_keys = {
|
||||
16: KEY_Q,
|
||||
18: KEY_E,
|
||||
24: KEY_Q,
|
||||
26: KEY_E,
|
||||
}
|
||||
return scan_keys.get(scan_code)
|
||||
|
||||
def _qt_modifiers_to_input(self, event: QEvent) -> int:
|
||||
modifiers_method = getattr(event, "modifiers", None)
|
||||
modifiers = modifiers_method() if callable(modifiers_method) else None
|
||||
input_modifiers = 0
|
||||
if isinstance(modifiers, Qt.KeyboardModifier) and modifiers & Qt.KeyboardModifier.ControlModifier:
|
||||
input_modifiers |= MOD_CTRL
|
||||
if isinstance(modifiers, Qt.KeyboardModifier) and modifiers & Qt.KeyboardModifier.ShiftModifier:
|
||||
input_modifiers |= MOD_SHIFT
|
||||
return input_modifiers
|
||||
|
||||
def _handle_back_mouse_button(self) -> None:
|
||||
active_win = QApplication.activeWindow()
|
||||
focused = self._focused_widget()
|
||||
if isinstance(focused, QLineEdit):
|
||||
return
|
||||
if isinstance(active_win, QDialog):
|
||||
SoundManager().play("back")
|
||||
active_win.reject()
|
||||
return
|
||||
self._parent.goBackDetailPage(self._parent.currentDetailPage)
|
||||
|
||||
def _focused_widget(self) -> QWidget | None:
|
||||
focused = QApplication.focusWidget()
|
||||
if focused is None or not isValid(focused):
|
||||
return None
|
||||
return focused
|
||||
|
||||
_GAMECARD_SEARCH_TABS: dict[int, str] = {
|
||||
0: 'searchEdit',
|
||||
1: 'autoInstallSearchLineEdit',
|
||||
}
|
||||
|
||||
def _redirect_gamecard_input_to_search(self, event: QEvent) -> bool:
|
||||
focused = self._focused_widget()
|
||||
if not isinstance(focused, GameCard):
|
||||
return False
|
||||
tab_index = self._parent.stackedWidget.currentIndex()
|
||||
attr_name = self._GAMECARD_SEARCH_TABS.get(tab_index)
|
||||
if attr_name is None:
|
||||
return False
|
||||
text_method = getattr(event, "text", None)
|
||||
text = text_method() if callable(text_method) else ""
|
||||
text = text if isinstance(text, str) else ""
|
||||
if len(text) != 1 or not text.isprintable():
|
||||
return False
|
||||
search_edit = getattr(self._parent, attr_name, None)
|
||||
if not isinstance(search_edit, QLineEdit) or not isValid(search_edit):
|
||||
return False
|
||||
search_edit.setFocus()
|
||||
search_edit.insert(text)
|
||||
return True
|
||||
|
||||
def _activate_focused_widget(self, focused: QWidget | None) -> None:
|
||||
if focused is None or not isValid(focused):
|
||||
return
|
||||
try:
|
||||
self._parent.activateFocusedWidget()
|
||||
except RuntimeError as e:
|
||||
logger.debug("Focused widget was deleted before activation: %s", e)
|
||||
|
||||
def _handle_input_key_press(self, key: int, modifiers: int) -> bool:
|
||||
if self._handle_input_system_key(key, modifiers):
|
||||
return True
|
||||
if self._handle_input_file_explorer_key(key):
|
||||
return True
|
||||
if self._handle_input_text_key(key):
|
||||
return True
|
||||
if self._handle_input_dialog_key(key):
|
||||
return True
|
||||
if self._handle_input_tab_key(key):
|
||||
return True
|
||||
if self._handle_input_arrow_press(key):
|
||||
return True
|
||||
return self._handle_input_action_key(key, modifiers)
|
||||
|
||||
def _handle_input_system_key(self, key: int, modifiers: int) -> bool:
|
||||
if key == KEY_F5:
|
||||
self._parent.refreshGames()
|
||||
return True
|
||||
if key == KEY_Q and modifiers & MOD_CTRL:
|
||||
app = QApplication.instance()
|
||||
if app is not None:
|
||||
app.quit()
|
||||
return True
|
||||
if key == KEY_F11 and not self._is_gamescope_session:
|
||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_input_file_explorer_key(self, key: int) -> bool:
|
||||
file_explorer = self.file_explorer
|
||||
if not file_explorer:
|
||||
return False
|
||||
focused = self._focused_widget()
|
||||
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
|
||||
return False
|
||||
if key in (KEY_RETURN, KEY_ENTER):
|
||||
self._activate_file_explorer_focus()
|
||||
return True
|
||||
if key == KEY_BACKSPACE:
|
||||
file_explorer.previous_dir()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _activate_file_explorer_focus(self) -> None:
|
||||
file_explorer = self.file_explorer
|
||||
if file_explorer is None:
|
||||
return
|
||||
focused = self._focused_widget()
|
||||
if (
|
||||
isinstance(focused, AutoSizeButton) and
|
||||
hasattr(file_explorer, 'drive_buttons') and
|
||||
focused in file_explorer.drive_buttons
|
||||
):
|
||||
file_explorer.select_drive()
|
||||
return
|
||||
if file_explorer.file_list.count() > 0:
|
||||
self._activate_file_explorer_item(file_explorer.file_list)
|
||||
return
|
||||
self._activate_focused_widget(focused)
|
||||
|
||||
def _activate_file_explorer_item(self, focused: QListWidget) -> None:
|
||||
file_explorer = self.file_explorer
|
||||
if file_explorer is None:
|
||||
return
|
||||
current_item = focused.currentItem()
|
||||
if current_item is None and focused.count() > 0:
|
||||
focused.setCurrentRow(0)
|
||||
current_item = focused.currentItem()
|
||||
if not current_item:
|
||||
return
|
||||
selected = current_item.text()
|
||||
full_path = os.path.join(file_explorer.current_path, selected)
|
||||
if os.path.isdir(full_path):
|
||||
if selected == "../":
|
||||
file_explorer.previous_dir()
|
||||
else:
|
||||
file_explorer.current_path = os.path.normpath(full_path)
|
||||
file_explorer.update_file_list()
|
||||
elif not file_explorer.directory_only:
|
||||
file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
file_explorer.accept()
|
||||
|
||||
def _handle_input_text_key(self, key: int) -> bool:
|
||||
focused = self._focused_widget()
|
||||
if isinstance(focused, QLineEdit) and key in (KEY_LEFT, KEY_RIGHT):
|
||||
if key == KEY_LEFT:
|
||||
focused.cursorBackward(False, 1)
|
||||
else:
|
||||
focused.cursorForward(False, 1)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _focused_editable_combo(self, focused: QWidget | None) -> bool:
|
||||
if isinstance(focused, QComboBox) and focused.isEditable():
|
||||
return True
|
||||
parent = focused.parentWidget() if focused else None
|
||||
return isinstance(parent, QComboBox) and parent.isEditable()
|
||||
|
||||
def _handle_input_dialog_key(self, key: int) -> bool:
|
||||
active_win = QApplication.activeWindow()
|
||||
focused = self._focused_widget()
|
||||
if isinstance(active_win, FullscreenDialog):
|
||||
return self._handle_input_fullscreen_dialog_key(active_win, key)
|
||||
if key != KEY_ESCAPE:
|
||||
return False
|
||||
settings_dialog = self.settings_dialog
|
||||
if settings_dialog is not None:
|
||||
open_combo = self._get_open_settings_combo()
|
||||
if open_combo:
|
||||
open_combo.hidePopup()
|
||||
settings_dialog.advanced_table.setFocus()
|
||||
return True
|
||||
if isinstance(focused, QLineEdit):
|
||||
return False
|
||||
if isinstance(active_win, QDialog):
|
||||
active_win.reject()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_input_fullscreen_dialog_key(self, active_win: FullscreenDialog, key: int) -> bool:
|
||||
if key in (KEY_ESCAPE, KEY_RETURN, KEY_ENTER, KEY_BACKSPACE):
|
||||
active_win.close()
|
||||
return True
|
||||
if key == KEY_LEFT:
|
||||
active_win.show_prev()
|
||||
return True
|
||||
if key == KEY_RIGHT:
|
||||
active_win.show_next()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_input_tab_key(self, key: int) -> bool:
|
||||
if key not in (KEY_LEFT, KEY_RIGHT):
|
||||
return False
|
||||
focused = self._focused_widget()
|
||||
active = QApplication.activeWindow()
|
||||
if self.file_explorer or isinstance(active, QMessageBox | QDialog):
|
||||
return False
|
||||
if isinstance(focused, AnimatedCard | QLineEdit | QTableWidget | AutoSizeButton | QCheckBox):
|
||||
return False
|
||||
return self._switch_visible_tab(-1 if key == KEY_LEFT else 1)
|
||||
|
||||
def _focus_tab_from_search(self, current_index: int) -> bool:
|
||||
focused = self._focused_widget()
|
||||
search_edit = (
|
||||
getattr(self._parent, 'searchEdit', None)
|
||||
if current_index == 0
|
||||
else getattr(self._parent, 'autoInstallSearchLineEdit', None)
|
||||
)
|
||||
if focused is not search_edit:
|
||||
return False
|
||||
tab_button = self._parent.tabButtons.get(current_index)
|
||||
if tab_button is None or not tab_button.isVisible():
|
||||
return False
|
||||
tab_button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _get_library_toolbar_widgets(self) -> list[QWidget]:
|
||||
widgets = []
|
||||
for attr_name in (
|
||||
"quickLaunchButton",
|
||||
"addGameButton",
|
||||
"searchEdit",
|
||||
"refreshButton",
|
||||
"deleteMissingExeButton",
|
||||
"libraryControlsButton",
|
||||
):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
|
||||
widgets.append(widget)
|
||||
return widgets
|
||||
|
||||
def _get_library_filter_widgets(self) -> list[QWidget]:
|
||||
controls_widget = getattr(self._parent, "libraryControlsWidget", None)
|
||||
if not isinstance(controls_widget, QWidget) or not controls_widget.isVisible():
|
||||
return []
|
||||
widgets = []
|
||||
for attr_name in (
|
||||
"gamesSortCombo",
|
||||
"gamesDisplayCombo",
|
||||
"onlyInstalledCheckBox",
|
||||
"gamesBadgeViewCombo",
|
||||
):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
|
||||
widgets.append(widget)
|
||||
return widgets
|
||||
|
||||
def _focus_library_filter_controls(self) -> bool:
|
||||
controls_button = getattr(self._parent, "libraryControlsButton", None)
|
||||
if not isinstance(controls_button, AutoSizeButton):
|
||||
return False
|
||||
if not controls_button.isChecked():
|
||||
controls_button.setChecked(True)
|
||||
toggle_controls = getattr(self._parent, "_toggle_library_controls", None)
|
||||
if callable(toggle_controls):
|
||||
toggle_controls()
|
||||
filter_widgets = self._get_library_filter_widgets()
|
||||
if not filter_widgets:
|
||||
return False
|
||||
filter_widgets[0].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _focus_first_game_card(self, container: QWidget | None) -> bool:
|
||||
if container is None:
|
||||
return False
|
||||
for card in container.findChildren(AnimatedCard):
|
||||
if card.isVisible() and card.isEnabled():
|
||||
card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
scroll_area = container.parentWidget()
|
||||
while scroll_area and not isinstance(scroll_area, QScrollArea):
|
||||
scroll_area = scroll_area.parentWidget()
|
||||
if isinstance(scroll_area, QScrollArea):
|
||||
scroll_area.ensureWidgetVisible(card, 50, 50)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _focus_first_library_card(self) -> bool:
|
||||
container = getattr(self._parent, "gamesListWidget", None)
|
||||
return self._focus_first_game_card(container)
|
||||
|
||||
def _get_autoinstall_toolbar_widgets(self) -> list[QWidget]:
|
||||
widgets = []
|
||||
for attr_name in ("autoInstallSearchLineEdit", "autoInstallRefreshButton"):
|
||||
widget = getattr(self._parent, attr_name, None)
|
||||
if isinstance(widget, QWidget) and widget.isVisible() and widget.isEnabled():
|
||||
widgets.append(widget)
|
||||
return widgets
|
||||
|
||||
def _focus_first_autoinstall_card(self) -> bool:
|
||||
container = getattr(self._parent, "autoInstallContainer", None)
|
||||
return self._focus_first_game_card(container)
|
||||
|
||||
def _handle_toolbar_navigation(self, code: int, value: int) -> bool:
|
||||
current_index = self._parent.stackedWidget.currentIndex()
|
||||
if value == 0 or current_index not in (0, 1):
|
||||
return False
|
||||
if current_index == 0:
|
||||
toolbar_widgets = self._get_library_toolbar_widgets()
|
||||
focus_first_card = self._focus_first_library_card
|
||||
else:
|
||||
toolbar_widgets = self._get_autoinstall_toolbar_widgets()
|
||||
focus_first_card = self._focus_first_autoinstall_card
|
||||
focused = self._focused_widget()
|
||||
filter_widgets = self._get_library_filter_widgets() if current_index == 0 else []
|
||||
if focused in filter_widgets:
|
||||
if code == PAD_DPAD_X:
|
||||
widget_index = filter_widgets.index(cast(QWidget, focused))
|
||||
next_index = widget_index + (1 if value > 0 else -1)
|
||||
if 0 <= next_index < len(filter_widgets):
|
||||
filter_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if code != PAD_DPAD_Y:
|
||||
return False
|
||||
if value < 0:
|
||||
controls_button = getattr(self._parent, "libraryControlsButton", None)
|
||||
if isinstance(controls_button, QWidget):
|
||||
controls_button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
return focus_first_card()
|
||||
if focused not in toolbar_widgets:
|
||||
return False
|
||||
if code == PAD_DPAD_X:
|
||||
widget_index = toolbar_widgets.index(cast(QWidget, focused))
|
||||
next_index = widget_index + (1 if value > 0 else -1)
|
||||
if 0 <= next_index < len(toolbar_widgets):
|
||||
toolbar_widgets[next_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if code != PAD_DPAD_Y:
|
||||
return False
|
||||
if value > 0:
|
||||
controls_button = getattr(self._parent, "libraryControlsButton", None)
|
||||
if focused is controls_button and self._focus_library_filter_controls():
|
||||
return True
|
||||
return focus_first_card()
|
||||
tab_button = self._parent.tabButtons.get(current_index)
|
||||
if tab_button is not None and tab_button.isVisible():
|
||||
tab_button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _switch_visible_tab(self, step: int) -> bool:
|
||||
idx = self._parent.stackedWidget.currentIndex()
|
||||
visible = [i for i, btn in self._parent.tabButtons.items() if btn.isVisible()]
|
||||
visible.sort()
|
||||
if not visible:
|
||||
return False
|
||||
try:
|
||||
current_pos = visible.index(idx)
|
||||
except ValueError:
|
||||
current_pos = 0
|
||||
new_idx = visible[(current_pos + step) % len(visible)]
|
||||
self._parent.switchTab(new_idx)
|
||||
self._parent.tabButtons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
|
||||
def _handle_input_arrow_press(self, key: int) -> bool:
|
||||
dpad = {
|
||||
KEY_UP: (PAD_DPAD_Y, -1),
|
||||
KEY_DOWN: (PAD_DPAD_Y, 1),
|
||||
KEY_LEFT: (PAD_DPAD_X, -1),
|
||||
KEY_RIGHT: (PAD_DPAD_X, 1),
|
||||
}.get(key)
|
||||
if dpad is None:
|
||||
return False
|
||||
self.dpad_moved.emit(dpad[0], dpad[1], time.time())
|
||||
return True
|
||||
|
||||
def _handle_input_action_key(self, key: int, modifiers: int) -> bool:
|
||||
focused = self._focused_widget()
|
||||
if isinstance(focused, GameCard) and key == KEY_F10 and modifiers & MOD_SHIFT:
|
||||
pos = QPoint(focused.width() // 2, focused.height() // 2)
|
||||
focused._show_context_menu(pos)
|
||||
return True
|
||||
if key in (KEY_RETURN, KEY_ENTER):
|
||||
if isinstance(focused, QTableWidget):
|
||||
self.handle_table_confirm(focused)
|
||||
else:
|
||||
self._activate_focused_widget(focused)
|
||||
return True
|
||||
elif key in (KEY_ESCAPE, KEY_BACKSPACE):
|
||||
return self._handle_input_back_key(focused)
|
||||
elif key == KEY_E and not isinstance(focused, QLineEdit):
|
||||
if self._parent.stackedWidget.currentIndex() == 0:
|
||||
self._parent.openAddGameDialog()
|
||||
return True
|
||||
return False
|
||||
|
||||
def _handle_input_back_key(self, focused: QWidget | None) -> bool:
|
||||
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
|
||||
return False
|
||||
self._handle_standard_back()
|
||||
return True
|
||||
|
||||
def _handle_input_key_release(self, key: int) -> bool:
|
||||
if key in (KEY_UP, KEY_DOWN):
|
||||
self.dpad_moved.emit(PAD_DPAD_Y, 0, time.time())
|
||||
return True
|
||||
elif key in (KEY_LEFT, KEY_RIGHT):
|
||||
self.dpad_moved.emit(PAD_DPAD_X, 0, time.time())
|
||||
return True
|
||||
return False
|
||||
@@ -1,6 +0,0 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
class InputMixin:
|
||||
if TYPE_CHECKING:
|
||||
def __getattr__(self, name: str) -> Any: ...
|
||||
@@ -1,404 +0,0 @@
|
||||
import time
|
||||
from typing import cast
|
||||
|
||||
from PySide6.QtCore import QObject, QTimer
|
||||
from PySide6.QtWidgets import QApplication
|
||||
|
||||
from portprotonqt.config import display_config, gamepad_config
|
||||
from portprotonqt.input_manager.constants import (
|
||||
BUTTONS,
|
||||
GamepadType,
|
||||
INPUT_AXIS_SCALE,
|
||||
PAD_AXIS_LEFT_TRIGGER,
|
||||
PAD_AXIS_LEFT_X,
|
||||
PAD_AXIS_LEFT_Y,
|
||||
PAD_AXIS_RIGHT_TRIGGER,
|
||||
PAD_AXIS_RIGHT_Y,
|
||||
PAD_BUTTON_START,
|
||||
PAD_DPAD_X,
|
||||
PAD_DPAD_Y,
|
||||
SDL3_PLAYSTATION_TYPES,
|
||||
SDL3_XBOX_LIKE_TYPES,
|
||||
SDL_GAMEPAD_BUTTON_DPAD_DOWN,
|
||||
SDL_GAMEPAD_BUTTON_DPAD_LEFT,
|
||||
SDL_GAMEPAD_BUTTON_DPAD_RIGHT,
|
||||
SDL_GAMEPAD_BUTTON_DPAD_UP,
|
||||
SDL_CONTROLLER_AXIS_TO_PAD,
|
||||
SDL_CONTROLLER_BUTTON_TO_PAD,
|
||||
)
|
||||
from portprotonqt.input_manager.mixin import InputMixin
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.native_gamepad import (
|
||||
GAMEPAD_LIBRARY_PATH,
|
||||
GamepadBackendError,
|
||||
SDLGamepad,
|
||||
find_gamepad,
|
||||
shutdown as shutdown_gamepad_backend,
|
||||
)
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class GamepadRuntimeMixin(InputMixin):
|
||||
def init_gamepad(self) -> None:
|
||||
self.gamepad_hotplug.connect(self._on_gamepad_hotplug)
|
||||
self._initial_gamepad_check = True
|
||||
self.check_gamepad()
|
||||
self._initial_gamepad_check = False
|
||||
self.gamepad_poll_timer = QTimer(cast(QObject, self))
|
||||
self.gamepad_poll_timer.setInterval(10)
|
||||
self.gamepad_poll_timer.timeout.connect(self._poll_gamepad)
|
||||
self.gamepad_poll_timer.start()
|
||||
logger.info(
|
||||
"Gamepad support initialized with SDL3 events (library: %s)",
|
||||
GAMEPAD_LIBRARY_PATH,
|
||||
)
|
||||
|
||||
def _on_gamepad_hotplug(self, action: str) -> None:
|
||||
try:
|
||||
if self._gamepad_polling_suspended:
|
||||
return
|
||||
if action == 'add':
|
||||
self.check_gamepad()
|
||||
elif action == 'remove':
|
||||
had_gamepad = self.gamepad is not None
|
||||
if self.gamepad:
|
||||
self.gamepad.close()
|
||||
self.gamepad = None
|
||||
self._reset_input_state()
|
||||
self._refresh_gamepad_ui()
|
||||
self.check_gamepad()
|
||||
|
||||
if had_gamepad and not self.gamepad:
|
||||
SoundManager().play("gamepad_off")
|
||||
if had_gamepad and not self.gamepad and display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
|
||||
self.toggle_fullscreen.emit(False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in hotplug handler: {e}", exc_info=True)
|
||||
|
||||
def check_gamepad(self) -> None:
|
||||
try:
|
||||
if self._gamepad_polling_suspended:
|
||||
return
|
||||
new_gamepad = self.find_gamepad()
|
||||
|
||||
if new_gamepad:
|
||||
if self.gamepad and new_gamepad.path == self.gamepad.path:
|
||||
new_gamepad.close()
|
||||
return
|
||||
if self.gamepad:
|
||||
self.gamepad.close()
|
||||
self.detect_gamepad_axes(new_gamepad)
|
||||
logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}")
|
||||
self.gamepad = new_gamepad
|
||||
self._reset_input_state()
|
||||
self._last_gamepad_check_time = 0.0
|
||||
self.gamepad_type = self._get_effective_gamepad_type(new_gamepad)
|
||||
self._refresh_gamepad_ui()
|
||||
if not self._initial_gamepad_check:
|
||||
SoundManager().play("gamepad_connect")
|
||||
|
||||
if display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
|
||||
self.toggle_fullscreen.emit(True)
|
||||
|
||||
elif self.gamepad:
|
||||
logger.info("Gamepad no longer detected")
|
||||
self.gamepad.close()
|
||||
self.gamepad = None
|
||||
self._reset_input_state()
|
||||
self._refresh_gamepad_ui()
|
||||
SoundManager().play("gamepad_off")
|
||||
|
||||
if display_config.get_auto_fullscreen_gamepad() and not display_config.get_fullscreen():
|
||||
self.toggle_fullscreen.emit(False)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking gamepad: {e}", exc_info=True)
|
||||
|
||||
def _reset_input_state(self) -> None:
|
||||
"""Reset cached joystick state when the active device changes."""
|
||||
self._button_states.clear()
|
||||
self._hat_states.clear()
|
||||
self._axis_states.clear()
|
||||
self.stick_x_raw = self.center_x
|
||||
self.stick_y_raw = self.center_y
|
||||
self.scroll_accumulator = 0.0
|
||||
self.lt_pressed = False
|
||||
self.rt_pressed = False
|
||||
self.start_held = False
|
||||
self.select_held = False
|
||||
self.pending_menu_fullscreen_time = 0.0
|
||||
self.guide_held = False
|
||||
self.emulation_triggered = False
|
||||
self.gamepad_type = self._get_configured_gamepad_type()
|
||||
|
||||
def _get_configured_gamepad_type(self) -> GamepadType:
|
||||
"""Return manual gamepad type from config or Unknown for auto mode."""
|
||||
value = gamepad_config.get_gamepad_type()
|
||||
if value == "playstation":
|
||||
return GamepadType.PLAYSTATION
|
||||
if value == "xbox":
|
||||
return GamepadType.XBOX
|
||||
return GamepadType.UNKNOWN
|
||||
|
||||
def _get_effective_gamepad_type(self, gamepad: SDLGamepad) -> GamepadType:
|
||||
"""Use manual gamepad type when configured, otherwise auto-detect."""
|
||||
configured_type = self._get_configured_gamepad_type()
|
||||
if configured_type != GamepadType.UNKNOWN:
|
||||
return configured_type
|
||||
return self._detect_gamepad_type(gamepad)
|
||||
|
||||
def apply_gamepad_type_setting(self) -> None:
|
||||
"""Apply configured gamepad type to the current device."""
|
||||
if self.gamepad is None:
|
||||
self.gamepad_type = self._get_configured_gamepad_type()
|
||||
return
|
||||
self.gamepad_type = self._get_effective_gamepad_type(self.gamepad)
|
||||
|
||||
def _detect_gamepad_type(self, gamepad: SDLGamepad) -> GamepadType:
|
||||
"""Read gamepad type from SDL or fall back to Xbox."""
|
||||
if gamepad.sdl_type in SDL3_PLAYSTATION_TYPES:
|
||||
return GamepadType.PLAYSTATION
|
||||
if gamepad.sdl_type in SDL3_XBOX_LIKE_TYPES:
|
||||
return GamepadType.XBOX
|
||||
return GamepadType.XBOX
|
||||
|
||||
def _refresh_gamepad_ui(self) -> None:
|
||||
"""Refresh control hints and virtual keyboard after gamepad changes."""
|
||||
update_hints = getattr(self._parent, "updateControlHints", None)
|
||||
if callable(update_hints):
|
||||
update_hints()
|
||||
keyboard = getattr(self._parent, "keyboard", None)
|
||||
if keyboard and hasattr(keyboard, "update_keyboard"):
|
||||
keyboard.update_keyboard()
|
||||
|
||||
def find_gamepad(self) -> SDLGamepad | None:
|
||||
"""Find the first SDL3 gamepad with a standardized mapping."""
|
||||
try:
|
||||
gamepad = find_gamepad()
|
||||
self._last_gamepad_error = None
|
||||
return gamepad
|
||||
except GamepadBackendError as error:
|
||||
error_message = str(error)
|
||||
if error_message != self._last_gamepad_error:
|
||||
logger.error("Error finding gamepad: %s", error_message)
|
||||
self._last_gamepad_error = error_message
|
||||
except Exception as e:
|
||||
logger.error(f"Error finding gamepad: {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
def detect_gamepad_axes(self, device: SDLGamepad) -> None:
|
||||
"""Use normalized SDL axis ranges for navigation and mouse emulation."""
|
||||
self.min_value = -INPUT_AXIS_SCALE
|
||||
self.max_value = INPUT_AXIS_SCALE
|
||||
self.center_x = 0
|
||||
self.center_y = 0
|
||||
self.deadzone_value = 4000
|
||||
self.scroll_axis_code = PAD_AXIS_RIGHT_Y
|
||||
self.scroll_center = 0
|
||||
self.scroll_min_value = -INPUT_AXIS_SCALE
|
||||
self.scroll_max_value = INPUT_AXIS_SCALE
|
||||
self.scroll_deadzone_value = self.deadzone_value
|
||||
self.stick_x_raw = self.center_x
|
||||
self.stick_y_raw = self.center_y
|
||||
logger.info("Gamepad axes configured for SDL backend: %s", device.name)
|
||||
|
||||
def _handle_pending_menu_fullscreen(self, current_time: float) -> None:
|
||||
if not self.pending_menu_fullscreen_time:
|
||||
return
|
||||
if current_time < self.pending_menu_fullscreen_time:
|
||||
return
|
||||
self.pending_menu_fullscreen_time = 0.0
|
||||
if self.start_held or self.in_guide_combination_attempt:
|
||||
return
|
||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||
|
||||
def _poll_button_events(self, gamepad: SDLGamepad, current_time: float) -> None:
|
||||
"""Emit button changes using SDL's standardized controller mapping."""
|
||||
for button_index, button_code in SDL_CONTROLLER_BUTTON_TO_PAD.items():
|
||||
value = gamepad.get_button(button_index)
|
||||
self._handle_button_value(button_index, button_code, value, current_time)
|
||||
|
||||
def _handle_button_value(self, button_index: int, button_code: int, value: int, current_time: float) -> None:
|
||||
if self._button_states.get(button_index) == value:
|
||||
return
|
||||
self._button_states[button_index] = value
|
||||
if button_code in BUTTONS['guide']:
|
||||
self.guide_held = value == 1
|
||||
if button_code in BUTTONS['menu']:
|
||||
self.select_held = value == 1
|
||||
if button_code == PAD_BUTTON_START:
|
||||
self.start_held = value == 1
|
||||
emulation_combo = self._handle_emulation_combo(button_code, value)
|
||||
if self._is_mouse_emulation_active() and value == 1:
|
||||
if button_code in BUTTONS['confirm']:
|
||||
self.click_left()
|
||||
elif button_code in BUTTONS['back']:
|
||||
self.click_right()
|
||||
if emulation_combo:
|
||||
return
|
||||
if self._should_skip_regular_events():
|
||||
return
|
||||
self.button_event.emit(button_code, value)
|
||||
if self._should_schedule_menu_fullscreen(button_code, value, emulation_combo):
|
||||
self.pending_menu_fullscreen_time = current_time + self.guide_combination_timeout
|
||||
|
||||
def _handle_emulation_combo(self, button_code: int, value: int) -> bool:
|
||||
emulation_combo = value == 1 and (
|
||||
(button_code in BUTTONS['menu'] and self.start_held) or
|
||||
(button_code == PAD_BUTTON_START and self.select_held)
|
||||
)
|
||||
if emulation_combo:
|
||||
self.emulation_triggered = not self.emulation_triggered
|
||||
self.pending_menu_fullscreen_time = 0.0
|
||||
return emulation_combo
|
||||
|
||||
def _should_schedule_menu_fullscreen(self, button_code: int, value: int, emulation_combo: bool) -> bool:
|
||||
return (
|
||||
value == 1 and button_code in BUTTONS['menu'] and
|
||||
not emulation_combo and
|
||||
not self._is_gamescope_session and not self.in_guide_combination_attempt and
|
||||
self._parent.isActiveWindow()
|
||||
)
|
||||
|
||||
def _poll_hat_events(self, gamepad: SDLGamepad, current_time: float) -> None:
|
||||
"""Read D-pad state from SDL's standardized gamepad buttons."""
|
||||
hat_value = (
|
||||
self._read_dpad_button_axis(gamepad, SDL_GAMEPAD_BUTTON_DPAD_LEFT, SDL_GAMEPAD_BUTTON_DPAD_RIGHT),
|
||||
self._read_dpad_button_axis(gamepad, SDL_GAMEPAD_BUTTON_DPAD_UP, SDL_GAMEPAD_BUTTON_DPAD_DOWN),
|
||||
)
|
||||
previous_value = self._hat_states.get(0, (0, 0))
|
||||
self._hat_states[0] = hat_value
|
||||
|
||||
if self._is_mouse_emulation_active():
|
||||
if previous_value[0] != hat_value[0]:
|
||||
if hat_value[0] < 0:
|
||||
self.move_mouse(-10, 0)
|
||||
elif hat_value[0] > 0:
|
||||
self.move_mouse(10, 0)
|
||||
if previous_value[1] != hat_value[1]:
|
||||
if hat_value[1] > 0:
|
||||
self.move_mouse(0, -10)
|
||||
elif hat_value[1] < 0:
|
||||
self.move_mouse(0, 10)
|
||||
|
||||
if self._should_skip_regular_events():
|
||||
return
|
||||
|
||||
if previous_value[0] != hat_value[0]:
|
||||
self.dpad_moved.emit(PAD_DPAD_X, hat_value[0], current_time)
|
||||
if previous_value[1] != hat_value[1]:
|
||||
self.dpad_moved.emit(PAD_DPAD_Y, hat_value[1], current_time)
|
||||
|
||||
def _read_dpad_button_axis(self, gamepad: SDLGamepad, negative_button: int, positive_button: int) -> int:
|
||||
negative = gamepad.get_button(negative_button)
|
||||
positive = gamepad.get_button(positive_button)
|
||||
return positive - negative
|
||||
|
||||
def _poll_axis_events(self, gamepad: SDLGamepad, current_time: float) -> None:
|
||||
"""Emit axis changes using the same evdev-like codes used by the UI."""
|
||||
for axis_index, axis_code in SDL_CONTROLLER_AXIS_TO_PAD.items():
|
||||
raw_value = gamepad.get_axis(axis_index)
|
||||
previous_value = self._axis_states.get(axis_index)
|
||||
if previous_value == raw_value:
|
||||
continue
|
||||
self._axis_states[axis_index] = raw_value
|
||||
if axis_code == PAD_AXIS_LEFT_X:
|
||||
self.stick_x_raw = raw_value
|
||||
elif axis_code == PAD_AXIS_LEFT_Y:
|
||||
self.stick_y_raw = raw_value
|
||||
elif axis_code == self.scroll_axis_code:
|
||||
self.handle_scroll(raw_value)
|
||||
if self._should_skip_regular_events():
|
||||
continue
|
||||
if axis_code in (PAD_AXIS_LEFT_TRIGGER, PAD_AXIS_RIGHT_TRIGGER):
|
||||
self._emit_trigger_event(axis_code, raw_value, current_time)
|
||||
continue
|
||||
if axis_code not in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
|
||||
continue
|
||||
self.dpad_moved.emit(axis_code, raw_value, current_time)
|
||||
|
||||
def _emit_trigger_event(self, axis_code: int, raw_value: int, current_time: float) -> None:
|
||||
"""Convert SDL trigger axis values into press/release button events."""
|
||||
if current_time - self.last_trigger_time < self.trigger_cooldown:
|
||||
return
|
||||
is_pressed = raw_value > 16384
|
||||
if axis_code == PAD_AXIS_LEFT_TRIGGER and is_pressed != self.lt_pressed:
|
||||
self.lt_pressed = is_pressed
|
||||
self.button_event.emit(axis_code, int(is_pressed))
|
||||
self.last_trigger_time = current_time
|
||||
elif axis_code == PAD_AXIS_RIGHT_TRIGGER and is_pressed != self.rt_pressed:
|
||||
self.rt_pressed = is_pressed
|
||||
self.button_event.emit(axis_code, int(is_pressed))
|
||||
self.last_trigger_time = current_time
|
||||
|
||||
def _is_mouse_emulation_active(self) -> bool:
|
||||
"""Return True when the gamepad currently drives mouse emulation."""
|
||||
return self.mouse_emulation_enabled and self.emulation_active and self.emulation_triggered
|
||||
|
||||
def _should_skip_regular_events(self) -> bool:
|
||||
"""Skip regular UI events while mouse emulation owns the controller."""
|
||||
return self._is_mouse_emulation_active() and QApplication.activeWindow() is not None
|
||||
|
||||
def _poll_gamepad(self) -> None:
|
||||
if not self.running or self._gamepad_polling_suspended:
|
||||
return
|
||||
current_time = time.time()
|
||||
active_gamepad = self.gamepad
|
||||
if not active_gamepad:
|
||||
if current_time - self._last_gamepad_check_time >= 1.0:
|
||||
self.gamepad_hotplug.emit('add')
|
||||
self._last_gamepad_check_time = current_time
|
||||
return
|
||||
try:
|
||||
active_gamepad.update()
|
||||
if not active_gamepad.connected():
|
||||
self.gamepad_hotplug.emit('remove')
|
||||
return
|
||||
self._poll_button_events(active_gamepad, current_time)
|
||||
self._handle_pending_menu_fullscreen(current_time)
|
||||
self._poll_hat_events(active_gamepad, current_time)
|
||||
self._poll_axis_events(active_gamepad, current_time)
|
||||
if (
|
||||
current_time - self.last_update >= self.update_interval and
|
||||
self.mouse_emulation_enabled and self.emulation_active and self.emulation_triggered
|
||||
):
|
||||
self.update_mouse_position()
|
||||
self.last_update = current_time
|
||||
except Exception as error:
|
||||
logger.error("Unexpected error in gamepad polling: %s", error, exc_info=True)
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""
|
||||
Proper shutdown of gamepad and udev monitor.
|
||||
"""
|
||||
try:
|
||||
# Mouse emulation cleanup
|
||||
self.disable_mouse_emulation()
|
||||
|
||||
# Stop focus check timer
|
||||
self.focus_check_timer.stop()
|
||||
|
||||
# Flag to stop udev monitor loop
|
||||
self.running = False
|
||||
|
||||
# Stop all timers
|
||||
if hasattr(self, 'gamepad_poll_timer'):
|
||||
self.gamepad_poll_timer.stop()
|
||||
self.dpad_timer.stop()
|
||||
self.nav_timer.stop()
|
||||
|
||||
if self.gamepad:
|
||||
self.gamepad.close()
|
||||
|
||||
self.gamepad = None
|
||||
self._reset_input_state()
|
||||
self.gamepad_type = self._get_configured_gamepad_type()
|
||||
shutdown_gamepad_backend()
|
||||
|
||||
logger.info("Gamepad cleanup completed")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during cleanup: {e}", exc_info=True)
|
||||
@@ -1,514 +0,0 @@
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractItemView,
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QLineEdit,
|
||||
QPushButton,
|
||||
QTableWidget,
|
||||
)
|
||||
|
||||
from portprotonqt.input_manager.constants import (
|
||||
BUTTONS,
|
||||
PAD_AXIS_LEFT_X,
|
||||
PAD_AXIS_LEFT_Y,
|
||||
PAD_DPAD_X,
|
||||
PAD_DPAD_Y,
|
||||
)
|
||||
from portprotonqt.input_manager.mixin import InputMixin
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class SettingsInputMixin(InputMixin):
|
||||
# SETTINGS MODE
|
||||
def enable_settings_mode(self, settings_dialog):
|
||||
"""Setup gamepad handling for ExeSettingsDialog"""
|
||||
try:
|
||||
self._setup_mode_handlers(
|
||||
settings_dialog,
|
||||
self.handle_settings_button,
|
||||
self.handle_settings_dpad,
|
||||
'settings_dialog'
|
||||
)
|
||||
logger.debug("Gamepad handling successfully connected for SettingsDialog")
|
||||
except Exception as e:
|
||||
logger.error(f"Error connecting gamepad handlers for SettingsDialog: {e}")
|
||||
|
||||
def disable_settings_mode(self):
|
||||
"""Restore original main window handlers"""
|
||||
try:
|
||||
if self.settings_dialog:
|
||||
self._restore_original_handlers('settings_dialog')
|
||||
logger.debug("Gamepad handling successfully restored from Settings")
|
||||
except Exception as e:
|
||||
logger.error(f"Error restoring gamepad handlers from Settings: {e}")
|
||||
|
||||
def handle_settings_button(self, button_code, value):
|
||||
if self.settings_dialog is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# 1. Virtual Keyboard Handling
|
||||
kb = getattr(self.settings_dialog, 'keyboard', None)
|
||||
if kb and kb.isVisible():
|
||||
if button_code in BUTTONS['back']:
|
||||
if value != 0: # Only handle press, not release
|
||||
kb.hide()
|
||||
if kb.current_input_widget:
|
||||
kb.current_input_widget.setFocus()
|
||||
return # Return early to avoid dialog closing logic
|
||||
elif button_code in (BUTTONS['confirm'] | BUTTONS['context_menu']):
|
||||
if value != 0: # Only handle press, not release
|
||||
kb.activateFocusedKey()
|
||||
return
|
||||
elif button_code in BUTTONS['prev_tab']:
|
||||
if value != 0: # Only handle press, not release
|
||||
kb.on_lang_click()
|
||||
return
|
||||
elif button_code in BUTTONS['next_tab']:
|
||||
if value != 0: # Only handle press, not release
|
||||
kb.on_shift_click(not kb.shift_pressed)
|
||||
return
|
||||
elif button_code in BUTTONS['add_game']:
|
||||
if value != 0: # Press event
|
||||
kb.on_backspace_pressed()
|
||||
else: # Release event
|
||||
kb.stop_backspace_repeat()
|
||||
return
|
||||
|
||||
# Handle common UI elements like QMessageBox, QMenu, etc.
|
||||
# Only handle press events (value != 0), ignore release events
|
||||
if value != 0 and self._handle_common_ui_elements(button_code):
|
||||
return
|
||||
|
||||
# Handle other QDialogs
|
||||
if value != 0: # Only handle press events, not releases
|
||||
popup = QApplication.activePopupWidget()
|
||||
if isinstance(popup, QDialog):
|
||||
if button_code in BUTTONS['confirm']:
|
||||
popup.accept()
|
||||
elif button_code in BUTTONS['back']:
|
||||
popup.reject()
|
||||
return
|
||||
|
||||
# 3. Combo Box popup handling in settings (Advanced + MangoHud)
|
||||
table = self._get_current_settings_table()
|
||||
open_combo = self._get_open_settings_combo()
|
||||
|
||||
# B Button - Close combo or dialog
|
||||
if button_code in BUTTONS['back']:
|
||||
if open_combo:
|
||||
open_combo.hidePopup()
|
||||
if open_combo.isEditable() and open_combo.lineEdit() is not None:
|
||||
open_combo.lineEdit().setFocus()
|
||||
else:
|
||||
open_combo.setFocus()
|
||||
else:
|
||||
self.settings_dialog.reject()
|
||||
return
|
||||
|
||||
if button_code in BUTTONS['context_menu']:
|
||||
handler = getattr(self.settings_dialog, "handle_settings_context_menu", None)
|
||||
if callable(handler) and handler():
|
||||
return
|
||||
|
||||
# A Button - Confirm
|
||||
if button_code in BUTTONS['confirm']:
|
||||
if open_combo:
|
||||
view = open_combo.view()
|
||||
if view.currentIndex().isValid():
|
||||
open_combo.setCurrentIndex(view.currentIndex().row())
|
||||
open_combo.hidePopup()
|
||||
if open_combo.isEditable() and open_combo.lineEdit() is not None:
|
||||
open_combo.lineEdit().setFocus()
|
||||
else:
|
||||
open_combo.setFocus()
|
||||
return
|
||||
|
||||
# Standard interaction
|
||||
focused = QApplication.focusWidget()
|
||||
if isinstance(focused, QCheckBox) and focused.isEnabled():
|
||||
focused.setChecked(not focused.isChecked())
|
||||
return
|
||||
if isinstance(focused, QPushButton) and focused.isEnabled():
|
||||
focused.click()
|
||||
return
|
||||
combo_from_focus = None
|
||||
if isinstance(focused, QComboBox):
|
||||
combo_from_focus = focused
|
||||
else:
|
||||
parent = focused.parentWidget() if focused else None
|
||||
if isinstance(parent, QComboBox):
|
||||
combo_from_focus = parent
|
||||
if combo_from_focus and combo_from_focus.isEnabled():
|
||||
combo_from_focus.showPopup()
|
||||
combo_from_focus.setFocus()
|
||||
return
|
||||
if isinstance(focused, QTableWidget) and table and focused.currentRow() >= 0:
|
||||
# Main settings (checkboxes)
|
||||
if self.settings_dialog and table == self.settings_dialog.settings_table:
|
||||
self.handle_table_confirm(focused)
|
||||
return
|
||||
|
||||
# Advanced settings
|
||||
cell = focused.cellWidget(focused.currentRow(), 1)
|
||||
if isinstance(cell, QComboBox) and cell.isEnabled():
|
||||
cell.showPopup()
|
||||
cell.setFocus()
|
||||
return
|
||||
if isinstance(cell, QLineEdit):
|
||||
cell.setFocus()
|
||||
self.settings_dialog.show_virtual_keyboard(cell)
|
||||
return
|
||||
|
||||
if isinstance(focused, QLineEdit):
|
||||
self.settings_dialog.show_virtual_keyboard(focused)
|
||||
return
|
||||
if self._is_current_settings_tool_tab():
|
||||
if isinstance(focused, QCheckBox):
|
||||
focused.toggle()
|
||||
return
|
||||
if isinstance(focused, QPushButton):
|
||||
focused.click()
|
||||
return
|
||||
if isinstance(focused, QComboBox):
|
||||
focused.showPopup()
|
||||
return
|
||||
self._parent.activateFocusedWidget()
|
||||
return
|
||||
|
||||
# 4. Global Shortcuts
|
||||
if button_code in BUTTONS['add_game']: # X: Apply
|
||||
self.settings_dialog.apply_changes()
|
||||
|
||||
elif button_code in BUTTONS['prev_dir']: # Y: Search + Keyboard
|
||||
focused = QApplication.focusWidget()
|
||||
focused_combo = None
|
||||
if isinstance(focused, QTableWidget) and self.settings_dialog:
|
||||
table = self._get_current_settings_table()
|
||||
if table is not None and table == self.settings_dialog.advanced_table:
|
||||
row = focused.currentRow()
|
||||
if row >= 0:
|
||||
cell_widget = focused.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, QComboBox):
|
||||
focused_combo = cell_widget
|
||||
if focused_combo is None:
|
||||
if isinstance(focused, QComboBox):
|
||||
focused_combo = focused
|
||||
else:
|
||||
parent = focused.parentWidget() if focused else None
|
||||
if isinstance(parent, QComboBox):
|
||||
focused_combo = parent
|
||||
if focused_combo and focused_combo.isEditable() and focused_combo.isEnabled():
|
||||
line_edit = focused_combo.lineEdit()
|
||||
if line_edit is not None:
|
||||
line_edit.setFocus()
|
||||
self.settings_dialog.show_virtual_keyboard(line_edit)
|
||||
return
|
||||
self.settings_dialog.search_edit.setFocus()
|
||||
self.settings_dialog.show_virtual_keyboard(self.settings_dialog.search_edit)
|
||||
|
||||
elif button_code in BUTTONS['prev_tab']: # LB
|
||||
current_index = self.settings_dialog.tab_widget.currentIndex()
|
||||
idx = max(0, current_index - 1)
|
||||
self.settings_dialog.tab_widget.setCurrentIndex(idx)
|
||||
if idx != current_index:
|
||||
SoundManager().play("tab_switch")
|
||||
self._focus_first_row_in_current_settings_table()
|
||||
|
||||
elif button_code in BUTTONS['next_tab']: # RB
|
||||
current_index = self.settings_dialog.tab_widget.currentIndex()
|
||||
idx = min(self.settings_dialog.tab_widget.count() - 1, current_index + 1)
|
||||
self.settings_dialog.tab_widget.setCurrentIndex(idx)
|
||||
if idx != current_index:
|
||||
SoundManager().play("tab_switch")
|
||||
self._focus_first_row_in_current_settings_table()
|
||||
|
||||
else:
|
||||
self._parent.activateFocusedWidget()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_settings_button: {e}")
|
||||
|
||||
def handle_settings_dpad(self, code, value, now):
|
||||
if self.settings_dialog is None:
|
||||
return
|
||||
|
||||
try:
|
||||
# 1. Virtual Keyboard Navigation
|
||||
kb = getattr(self.settings_dialog, 'keyboard', None)
|
||||
if kb and kb.isVisible():
|
||||
normalized_value = 0
|
||||
|
||||
# Normalize Stick vs D-pad
|
||||
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y): # Sticks
|
||||
if abs(value) < self.dead_zone:
|
||||
self.current_dpad_code = None
|
||||
self.current_dpad_value = 0
|
||||
self.dpad_timer.stop()
|
||||
return
|
||||
normalized_value = 1 if value > self.dead_zone else -1
|
||||
else: # D-pad
|
||||
normalized_value = value
|
||||
|
||||
if normalized_value != 0:
|
||||
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
|
||||
if normalized_value > 0:
|
||||
kb.move_focus_right()
|
||||
else:
|
||||
kb.move_focus_left()
|
||||
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
|
||||
if normalized_value > 0:
|
||||
kb.move_focus_down()
|
||||
else:
|
||||
kb.move_focus_up()
|
||||
return
|
||||
|
||||
if self._is_current_settings_tool_tab():
|
||||
focused = QApplication.focusWidget()
|
||||
open_combo = self._get_open_settings_combo()
|
||||
if open_combo:
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
view = open_combo.view()
|
||||
model = view.model()
|
||||
current_index = view.currentIndex()
|
||||
if model and current_index.isValid():
|
||||
row_count = model.rowCount()
|
||||
current_row = current_index.row()
|
||||
if value > 0:
|
||||
next_row = min(current_row + 1, row_count - 1)
|
||||
else:
|
||||
next_row = max(current_row - 1, 0)
|
||||
new_index = model.index(next_row, current_index.column())
|
||||
view.setCurrentIndex(new_index)
|
||||
view.scrollTo(new_index, QAbstractItemView.ScrollHint.PositionAtCenter)
|
||||
return
|
||||
|
||||
if code not in (PAD_DPAD_X, PAD_DPAD_Y, PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
|
||||
return
|
||||
|
||||
normalized_value = value
|
||||
if code in (PAD_AXIS_LEFT_X, PAD_AXIS_LEFT_Y):
|
||||
if abs(value) < self.dead_zone:
|
||||
normalized_value = 0
|
||||
else:
|
||||
normalized_value = 1 if value > 0 else -1
|
||||
|
||||
if normalized_value == 0:
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
self.current_dpad_value = 0
|
||||
return
|
||||
|
||||
if code in (PAD_DPAD_X, PAD_DPAD_Y):
|
||||
if self.current_dpad_code != code or self.current_dpad_value != normalized_value:
|
||||
self.dpad_timer.stop()
|
||||
self.dpad_timer.setInterval(120 if self.dpad_timer.isActive() else 220)
|
||||
self.dpad_timer.start()
|
||||
self.current_dpad_code = code
|
||||
self.current_dpad_value = normalized_value
|
||||
|
||||
sections = self._get_mangohud_nav_sections()
|
||||
if not sections:
|
||||
return
|
||||
|
||||
if not focused or not self._find_widget_in_sections(focused, sections):
|
||||
self._focus_first_row_in_current_settings_table()
|
||||
return
|
||||
|
||||
if code in (PAD_DPAD_X, PAD_AXIS_LEFT_X):
|
||||
self._move_mangohud_horizontal(focused, normalized_value, sections)
|
||||
elif code in (PAD_DPAD_Y, PAD_AXIS_LEFT_Y):
|
||||
self._move_mangohud_vertical(focused, normalized_value, sections)
|
||||
return
|
||||
|
||||
# 2. Combo Box Navigation (within Advanced Table)
|
||||
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:
|
||||
cell_widget = table.cellWidget(table.currentRow(), 1)
|
||||
if isinstance(cell_widget, QComboBox) and cell_widget.view().isVisible():
|
||||
if code == PAD_DPAD_Y and value != 0:
|
||||
idx = cell_widget.currentIndex()
|
||||
new_idx = max(0, idx - 1) if value < 0 else min(cell_widget.count() - 1, idx + 1)
|
||||
if new_idx != idx:
|
||||
cell_widget.setCurrentIndex(new_idx)
|
||||
return # Consume event
|
||||
|
||||
# 3. Standard Table Navigation
|
||||
if value == 0:
|
||||
self.dpad_timer.stop()
|
||||
self.current_dpad_code = None
|
||||
self.current_dpad_value = 0
|
||||
return
|
||||
|
||||
if self.current_dpad_code != code or self.current_dpad_value != value:
|
||||
self.dpad_timer.stop()
|
||||
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)
|
||||
self.dpad_timer.start()
|
||||
self.current_dpad_code = code
|
||||
self.current_dpad_value = value
|
||||
|
||||
current_row = table.currentRow()
|
||||
|
||||
if code == PAD_DPAD_Y: # Up/Down
|
||||
step = -1 if value < 0 else 1
|
||||
new_row = current_row + step
|
||||
|
||||
while 0 <= new_row < table.rowCount() and table.isRowHidden(new_row):
|
||||
new_row += step
|
||||
|
||||
if 0 <= new_row < table.rowCount():
|
||||
focus_column = 1
|
||||
table.setCurrentCell(new_row, focus_column)
|
||||
self._focus_settings_advanced_value_widget(table, new_row)
|
||||
|
||||
elif code == PAD_DPAD_X: # Left/Right
|
||||
if self._move_settings_inline_button_focus(table, value):
|
||||
return
|
||||
current_col = table.currentColumn()
|
||||
if value < 0: # Left
|
||||
if current_col > 0:
|
||||
new_col = max(0, current_col - 1)
|
||||
table.setCurrentCell(current_row, new_col)
|
||||
if new_col == 1:
|
||||
self._focus_settings_advanced_value_widget(table, current_row)
|
||||
else:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
else: # Right
|
||||
if current_col < table.columnCount() - 1:
|
||||
new_col = min(table.columnCount() - 1, current_col + 1)
|
||||
table.setCurrentCell(current_row, new_col)
|
||||
if new_col == 1:
|
||||
self._focus_settings_advanced_value_widget(table, current_row)
|
||||
else:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in handle_settings_dpad: {e}")
|
||||
|
||||
def _get_current_settings_table(self) -> QTableWidget | None:
|
||||
if not self.settings_dialog:
|
||||
return None
|
||||
getter = getattr(self.settings_dialog, "_get_current_settings_table", None)
|
||||
if callable(getter):
|
||||
table = getter()
|
||||
if isinstance(table, QTableWidget):
|
||||
return table
|
||||
return None
|
||||
|
||||
def _is_current_settings_tool_tab(self):
|
||||
if not self.settings_dialog:
|
||||
return False
|
||||
current_tab = self.settings_dialog.tab_widget.currentWidget()
|
||||
return current_tab in (
|
||||
getattr(self.settings_dialog, "mangohud_tab", None),
|
||||
getattr(self.settings_dialog, "vkbasalt_tab", None),
|
||||
getattr(self.settings_dialog, "gamescope_tab", None),
|
||||
)
|
||||
|
||||
def _get_open_settings_combo(self):
|
||||
"""Return currently opened combo popup in settings dialog if any."""
|
||||
if not self.settings_dialog:
|
||||
return None
|
||||
for combo in self.settings_dialog.findChildren(
|
||||
QComboBox, options=Qt.FindChildOption.FindChildrenRecursively
|
||||
):
|
||||
if combo.isVisible() and combo.view().isVisible():
|
||||
return combo
|
||||
return None
|
||||
|
||||
def _focus_settings_advanced_value_widget(self, table, row):
|
||||
"""Focus value widget in settings row when available."""
|
||||
if not self.settings_dialog:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
|
||||
if table == self.settings_dialog.settings_table:
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if cell_widget is not None:
|
||||
checkbox = cell_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
checkbox.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
|
||||
if table == getattr(self.settings_dialog, "favorites_table", None):
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, (QCheckBox, QComboBox, QLineEdit)):
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if cell_widget is not None:
|
||||
checkbox = cell_widget.findChild(QCheckBox)
|
||||
if checkbox and checkbox.isEnabled():
|
||||
checkbox.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
|
||||
if table != self.settings_dialog.advanced_table:
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
cell_widget = table.cellWidget(row, 1)
|
||||
if isinstance(cell_widget, QComboBox):
|
||||
line_edit = cell_widget.lineEdit()
|
||||
if cell_widget.isEditable() and line_edit is not None:
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
else:
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if isinstance(cell_widget, QLineEdit):
|
||||
cell_widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
if (
|
||||
cell_widget is not None
|
||||
and cell_widget.property("ppqt_run_after_exe_widget")
|
||||
):
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
if line_edit and line_edit.isEnabled():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return
|
||||
table.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _move_settings_inline_button_focus(self, table: QTableWidget, value: int) -> bool:
|
||||
"""Move focus between inline line edit and button in settings cell."""
|
||||
if not self.settings_dialog or table != self.settings_dialog.advanced_table:
|
||||
return False
|
||||
if table.currentColumn() != 1:
|
||||
return False
|
||||
|
||||
cell_widget = table.cellWidget(table.currentRow(), 1)
|
||||
if cell_widget is None or not cell_widget.property("ppqt_run_after_exe_widget"):
|
||||
return False
|
||||
|
||||
line_edit = cell_widget.findChild(QLineEdit)
|
||||
button = cell_widget.findChild(QPushButton)
|
||||
focused = QApplication.focusWidget()
|
||||
if value > 0 and focused == line_edit and button and button.isEnabled():
|
||||
button.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
if value < 0 and focused == button and line_edit and line_edit.isEnabled():
|
||||
line_edit.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _focus_first_row_in_current_settings_table(self):
|
||||
table = self._get_current_settings_table()
|
||||
if table and table.rowCount() > 0:
|
||||
col = 1
|
||||
table.setCurrentCell(0, col)
|
||||
self._focus_settings_advanced_value_widget(table, 0)
|
||||
return
|
||||
|
||||
if self._is_current_settings_tool_tab():
|
||||
sections = self._get_mangohud_nav_sections()
|
||||
if sections and sections[0]:
|
||||
self._focus_mangohud_widget(sections[0][0])
|
||||
@@ -1,496 +0,0 @@
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QCheckBox, QPushButton, QScrollArea, QSlider, QWidget
|
||||
|
||||
from portprotonqt.input_manager.mixin import InputMixin
|
||||
|
||||
|
||||
class SettingsVisualNavigationMixin(InputMixin):
|
||||
def _get_mangohud_nav_sections(self):
|
||||
"""Return settings tool focusable widgets grouped by visual sections."""
|
||||
if not self.settings_dialog:
|
||||
return []
|
||||
current_tab = self.settings_dialog.tab_widget.currentWidget()
|
||||
is_mangohud = current_tab == getattr(self.settings_dialog, "mangohud_tab", None)
|
||||
is_vkbasalt = current_tab == getattr(self.settings_dialog, "vkbasalt_tab", None)
|
||||
is_gamescope = current_tab == getattr(self.settings_dialog, "gamescope_tab", None)
|
||||
if not is_mangohud and not is_vkbasalt and not is_gamescope:
|
||||
return []
|
||||
|
||||
sections = []
|
||||
|
||||
value_widgets = []
|
||||
if is_mangohud:
|
||||
value_widgets = [
|
||||
widget for key, widget in self.settings_dialog.mangohud_widgets.items()
|
||||
if key != 'fps_limit_method' and widget.isVisible() and widget.isEnabled()
|
||||
]
|
||||
elif is_gamescope:
|
||||
value_widgets = [
|
||||
widget for widget in self.settings_dialog.gamescope_widgets.values()
|
||||
if widget.isVisible() and widget.isEnabled()
|
||||
]
|
||||
value_widgets.extend([
|
||||
widget for widget in self.settings_dialog.gamescope_resolution_widgets.values()
|
||||
if widget.isVisible() and widget.isEnabled()
|
||||
])
|
||||
|
||||
mangohud_tab = self.settings_dialog.tab_widget.currentWidget()
|
||||
preset_buttons = []
|
||||
if mangohud_tab:
|
||||
preset_buttons = [
|
||||
widget for widget in mangohud_tab.findChildren(
|
||||
QPushButton, options=Qt.FindChildOption.FindChildrenRecursively
|
||||
)
|
||||
if widget.isVisible() and widget.isEnabled()
|
||||
]
|
||||
preset_section = self._sort_widgets_by_position(preset_buttons) if preset_buttons else []
|
||||
|
||||
toggle_widgets = []
|
||||
if is_vkbasalt:
|
||||
toggle_widgets = [
|
||||
checkbox for checkbox in self.settings_dialog.vkbasalt_shader_widgets.values()
|
||||
if checkbox.isVisible() and checkbox.isEnabled()
|
||||
]
|
||||
else:
|
||||
category_combo_attr = 'mangohud_category_combo' if is_mangohud else 'gamescope_category_combo'
|
||||
category_stack_attr = 'mangohud_category_stack' if is_mangohud else 'gamescope_category_stack'
|
||||
category_combo = getattr(self.settings_dialog, category_combo_attr, None)
|
||||
if category_combo and category_combo.isVisible() and category_combo.isEnabled():
|
||||
toggle_widgets.append(category_combo)
|
||||
category_stack = getattr(self.settings_dialog, category_stack_attr, None)
|
||||
if category_stack:
|
||||
category_widget = category_stack.currentWidget()
|
||||
if category_widget:
|
||||
category_checkboxes = [
|
||||
checkbox for checkbox in category_widget.findChildren(
|
||||
QCheckBox, options=Qt.FindChildOption.FindChildrenRecursively
|
||||
)
|
||||
if checkbox.isVisible() and checkbox.isEnabled()
|
||||
]
|
||||
toggle_widgets.extend(self._sort_widgets_by_position(category_checkboxes))
|
||||
toggle_section = toggle_widgets if toggle_widgets else []
|
||||
|
||||
fps_section = []
|
||||
if is_mangohud:
|
||||
fps_widgets = []
|
||||
fps_limit_method = self.settings_dialog.mangohud_widgets.get('fps_limit_method')
|
||||
if fps_limit_method and fps_limit_method.isVisible() and fps_limit_method.isEnabled():
|
||||
fps_widgets.append(fps_limit_method)
|
||||
|
||||
fps_widgets.extend([
|
||||
checkbox for checkbox in self.settings_dialog.mangohud_fps_widgets.values()
|
||||
if checkbox.isVisible() and checkbox.isEnabled()
|
||||
])
|
||||
if fps_widgets:
|
||||
fps_section = self._sort_widgets_by_position(fps_widgets)
|
||||
|
||||
if is_vkbasalt:
|
||||
extra_edit = getattr(self.settings_dialog, 'vkbasalt_cas_slider', None)
|
||||
else:
|
||||
extra_edit_attr = 'mangohud_extra_edit' if is_mangohud else 'gamescope_extra_edit'
|
||||
extra_edit = getattr(self.settings_dialog, extra_edit_attr, None)
|
||||
extra_section = [extra_edit] if extra_edit and extra_edit.isVisible() and extra_edit.isEnabled() else []
|
||||
|
||||
if is_mangohud:
|
||||
if preset_section:
|
||||
sections.append(preset_section)
|
||||
if toggle_section:
|
||||
sections.append(toggle_section)
|
||||
if value_widgets:
|
||||
sections.append(self._sort_widgets_by_position(value_widgets))
|
||||
if fps_section:
|
||||
sections.append(fps_section)
|
||||
if extra_section:
|
||||
sections.append(extra_section)
|
||||
elif is_gamescope:
|
||||
# Gamescope UI order: presets -> toggles -> values -> extra.
|
||||
if preset_section:
|
||||
sections.append(preset_section)
|
||||
if toggle_section:
|
||||
sections.append(toggle_section)
|
||||
if value_widgets:
|
||||
sections.append(self._sort_widgets_by_position(value_widgets))
|
||||
if extra_section:
|
||||
sections.append(extra_section)
|
||||
else:
|
||||
if preset_section:
|
||||
sections.append(preset_section)
|
||||
if extra_section:
|
||||
sections.append(extra_section)
|
||||
if toggle_section:
|
||||
sections.append(self._sort_widgets_by_position(toggle_section))
|
||||
|
||||
return sections
|
||||
|
||||
def _sort_widgets_by_position(self, widgets):
|
||||
"""Sort widgets by their y/x position in settings dialog coordinates."""
|
||||
return sorted(
|
||||
widgets,
|
||||
key=lambda widget: (
|
||||
widget.mapTo(self.settings_dialog, widget.rect().topLeft()).y(),
|
||||
widget.mapTo(self.settings_dialog, widget.rect().topLeft()).x(),
|
||||
),
|
||||
)
|
||||
|
||||
def _find_widget_in_sections(self, widget, sections):
|
||||
"""Find widget position in sections list."""
|
||||
for section_index, section in enumerate(sections):
|
||||
for widget_index, item in enumerate(section):
|
||||
if item is widget:
|
||||
return section_index, widget_index
|
||||
return None
|
||||
|
||||
def _focus_mangohud_widget(self, widget):
|
||||
"""Focus MangoHud widget and ensure it is visible in scroll area."""
|
||||
self._ensure_mangohud_widget_visible(widget)
|
||||
widget.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _ensure_mangohud_widget_visible(self, widget):
|
||||
"""Auto-scroll MangoHud tab to the currently focused widget."""
|
||||
if not self.settings_dialog:
|
||||
return
|
||||
mangohud_tab = self.settings_dialog.tab_widget.currentWidget()
|
||||
if not mangohud_tab:
|
||||
return
|
||||
scroll_area = mangohud_tab.findChild(QScrollArea)
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(widget, 20, 20)
|
||||
|
||||
def _move_mangohud_horizontal(self, focused, direction, sections):
|
||||
"""Move focus left/right inside current MangoHud section."""
|
||||
position = self._find_widget_in_sections(focused, sections)
|
||||
if not position:
|
||||
return
|
||||
if isinstance(focused, QSlider):
|
||||
focused.setValue(focused.value() + direction)
|
||||
return
|
||||
section_index, _widget_index = position
|
||||
target = self._find_mangohud_grid_horizontal_target(
|
||||
focused, direction, sections[section_index]
|
||||
)
|
||||
if target:
|
||||
self._focus_mangohud_widget(target)
|
||||
return
|
||||
target = self._find_mangohud_neighbor_in_section(
|
||||
focused, sections[section_index], direction, is_vertical=False
|
||||
)
|
||||
if target:
|
||||
self._focus_mangohud_widget(target)
|
||||
|
||||
def _move_mangohud_vertical(self, focused, direction, sections):
|
||||
"""Move focus up/down inside section, then across sections."""
|
||||
position = self._find_widget_in_sections(focused, sections)
|
||||
if not position:
|
||||
return
|
||||
settings_dialog = self.settings_dialog
|
||||
if not settings_dialog:
|
||||
return
|
||||
|
||||
current_tab = settings_dialog.tab_widget.currentWidget()
|
||||
is_mangohud = current_tab == getattr(settings_dialog, "mangohud_tab", None)
|
||||
is_vkbasalt = current_tab == getattr(settings_dialog, "vkbasalt_tab", None)
|
||||
section_index, _widget_index = position
|
||||
if is_vkbasalt and isinstance(focused, QSlider) and direction > 0:
|
||||
target = self._find_vkbasalt_first_effect_widget()
|
||||
if target:
|
||||
self._focus_mangohud_widget(target)
|
||||
return
|
||||
|
||||
toggle_boundary_reached = False
|
||||
if self._is_mangohud_fps_widget(focused):
|
||||
fps_target = self._find_mangohud_fps_vertical_target(
|
||||
focused, direction, sections[section_index]
|
||||
)
|
||||
if fps_target:
|
||||
self._focus_mangohud_widget(fps_target)
|
||||
return
|
||||
if self._is_mangohud_toggle_widget(focused):
|
||||
toggle_target = self._find_mangohud_toggle_vertical_target(
|
||||
focused, direction, sections[section_index]
|
||||
)
|
||||
if toggle_target:
|
||||
self._focus_mangohud_widget(toggle_target)
|
||||
return
|
||||
toggle_boundary_reached = True
|
||||
|
||||
fps_limit_method = None
|
||||
if is_mangohud:
|
||||
fps_limit_method = settings_dialog.mangohud_widgets.get('fps_limit_method')
|
||||
if direction > 0 and is_mangohud and fps_limit_method and focused is fps_limit_method:
|
||||
fps_checkboxes = [
|
||||
checkbox for checkbox in settings_dialog.mangohud_fps_widgets.values()
|
||||
if checkbox.isVisible() and checkbox.isEnabled()
|
||||
]
|
||||
if fps_checkboxes:
|
||||
first_fps_checkbox = self._sort_widgets_by_position(fps_checkboxes)[0]
|
||||
self._focus_mangohud_widget(first_fps_checkbox)
|
||||
return
|
||||
|
||||
category_combo = None
|
||||
category_stack = None
|
||||
if not is_vkbasalt:
|
||||
category_combo_attr = 'mangohud_category_combo' if is_mangohud else 'gamescope_category_combo'
|
||||
category_stack_attr = 'mangohud_category_stack' if is_mangohud else 'gamescope_category_stack'
|
||||
category_combo = getattr(settings_dialog, category_combo_attr, None)
|
||||
category_stack = getattr(settings_dialog, category_stack_attr, None)
|
||||
if direction > 0 and category_combo and focused is category_combo and category_stack:
|
||||
current_category_widget = category_stack.currentWidget()
|
||||
if current_category_widget:
|
||||
category_checkboxes = [
|
||||
checkbox for checkbox in current_category_widget.findChildren(
|
||||
QCheckBox, options=Qt.FindChildOption.FindChildrenRecursively
|
||||
)
|
||||
if checkbox.isVisible() and checkbox.isEnabled()
|
||||
]
|
||||
if category_checkboxes:
|
||||
first_checkbox = self._sort_widgets_by_position(category_checkboxes)[0]
|
||||
self._focus_mangohud_widget(first_checkbox)
|
||||
return
|
||||
|
||||
if not toggle_boundary_reached:
|
||||
target_in_section = self._find_mangohud_neighbor_in_section(
|
||||
focused, sections[section_index], direction, is_vertical=True
|
||||
)
|
||||
if target_in_section:
|
||||
self._focus_mangohud_widget(target_in_section)
|
||||
return
|
||||
|
||||
target_section_index = section_index + direction
|
||||
if target_section_index < 0 or target_section_index >= len(sections):
|
||||
return
|
||||
|
||||
target_section = sections[target_section_index]
|
||||
if not target_section:
|
||||
return
|
||||
|
||||
if category_combo and category_combo in target_section:
|
||||
self._focus_mangohud_widget(category_combo)
|
||||
return
|
||||
|
||||
if target_section_index == 1:
|
||||
self._focus_mangohud_widget(target_section[0])
|
||||
return
|
||||
|
||||
fps_limit_method = None
|
||||
if is_mangohud:
|
||||
fps_limit_method = settings_dialog.mangohud_widgets.get('fps_limit_method')
|
||||
if fps_limit_method and fps_limit_method in target_section:
|
||||
self._focus_mangohud_widget(fps_limit_method)
|
||||
return
|
||||
|
||||
current_center = focused.mapTo(settings_dialog, focused.rect().center()).x()
|
||||
target_widget = min(
|
||||
target_section,
|
||||
key=lambda widget: abs(widget.mapTo(settings_dialog, widget.rect().center()).x() - current_center),
|
||||
)
|
||||
self._focus_mangohud_widget(target_widget)
|
||||
|
||||
def _find_mangohud_neighbor_in_section(self, focused, section, direction, is_vertical):
|
||||
"""Find closest focusable neighbor in current section by direction."""
|
||||
settings_dialog = self.settings_dialog
|
||||
if not settings_dialog:
|
||||
return None
|
||||
focused_center = focused.mapTo(settings_dialog, focused.rect().center())
|
||||
fx = focused_center.x()
|
||||
fy = focused_center.y()
|
||||
candidates = []
|
||||
|
||||
for widget in section:
|
||||
if widget is focused:
|
||||
continue
|
||||
center = widget.mapTo(settings_dialog, widget.rect().center())
|
||||
dx = center.x() - fx
|
||||
dy = center.y() - fy
|
||||
|
||||
if is_vertical:
|
||||
if direction < 0 and dy >= -4:
|
||||
continue
|
||||
if direction > 0 and dy <= 4:
|
||||
continue
|
||||
score = abs(dy) + abs(dx) * 3
|
||||
else:
|
||||
if direction < 0 and dx >= -4:
|
||||
continue
|
||||
if direction > 0 and dx <= 4:
|
||||
continue
|
||||
score = abs(dx) + abs(dy) * 3
|
||||
|
||||
candidates.append((score, widget))
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
candidates.sort(key=lambda item: item[0])
|
||||
return candidates[0][1]
|
||||
|
||||
def _find_mangohud_grid_horizontal_target(
|
||||
self, focused: QWidget, direction: int, section: list[QWidget]
|
||||
) -> QWidget | None:
|
||||
"""Navigate left/right in MangoHud grids with row wrap at edges."""
|
||||
settings_dialog = self.settings_dialog
|
||||
if not settings_dialog:
|
||||
return None
|
||||
sorted_widgets = sorted(
|
||||
section,
|
||||
key=lambda widget: (
|
||||
widget.mapTo(settings_dialog, widget.rect().center()).y(),
|
||||
widget.mapTo(settings_dialog, widget.rect().center()).x(),
|
||||
),
|
||||
)
|
||||
rows = []
|
||||
tolerance = 24
|
||||
for widget in sorted_widgets:
|
||||
y = widget.mapTo(settings_dialog, widget.rect().center()).y()
|
||||
if not rows:
|
||||
rows.append([widget])
|
||||
continue
|
||||
last_y = rows[-1][0].mapTo(settings_dialog, rows[-1][0].rect().center()).y()
|
||||
if abs(y - last_y) <= tolerance:
|
||||
rows[-1].append(widget)
|
||||
else:
|
||||
rows.append([widget])
|
||||
for row in rows:
|
||||
row.sort(key=lambda widget: widget.mapTo(settings_dialog, widget.rect().center()).x())
|
||||
|
||||
row_idx = -1
|
||||
col_idx = -1
|
||||
for index, row in enumerate(rows):
|
||||
if focused in row:
|
||||
row_idx = index
|
||||
col_idx = row.index(focused)
|
||||
break
|
||||
if row_idx == -1:
|
||||
return None
|
||||
|
||||
if direction > 0:
|
||||
if col_idx + 1 < len(rows[row_idx]):
|
||||
return rows[row_idx][col_idx + 1]
|
||||
if row_idx + 1 < len(rows):
|
||||
return rows[row_idx + 1][0]
|
||||
return None
|
||||
if direction < 0:
|
||||
if col_idx - 1 >= 0:
|
||||
return rows[row_idx][col_idx - 1]
|
||||
if row_idx - 1 >= 0:
|
||||
return rows[row_idx - 1][-1]
|
||||
return None
|
||||
return None
|
||||
|
||||
def _is_mangohud_fps_widget(self, widget):
|
||||
"""Check if widget belongs to MangoHud FPS section."""
|
||||
if not self.settings_dialog:
|
||||
return False
|
||||
return widget in set(self.settings_dialog.mangohud_fps_widgets.values())
|
||||
|
||||
def _is_mangohud_toggle_widget(self, widget: QWidget) -> bool:
|
||||
"""Check if widget belongs to MangoHud/Gamescope toggle checkbox section."""
|
||||
if not self.settings_dialog:
|
||||
return False
|
||||
current_tab = self.settings_dialog.tab_widget.currentWidget()
|
||||
if current_tab == getattr(self.settings_dialog, "mangohud_tab", None):
|
||||
toggle_keys = getattr(self.settings_dialog, 'mangohud_toggle_widget_keys', {})
|
||||
return isinstance(widget, QCheckBox) and widget in toggle_keys
|
||||
if current_tab == getattr(self.settings_dialog, "vkbasalt_tab", None):
|
||||
toggle_widgets = getattr(self.settings_dialog, 'vkbasalt_shader_widgets', {})
|
||||
return isinstance(widget, QCheckBox) and widget in set(toggle_widgets.values())
|
||||
elif current_tab == getattr(self.settings_dialog, "gamescope_tab", None):
|
||||
toggle_keys = getattr(self.settings_dialog, 'gamescope_toggle_widget_keys', {})
|
||||
else:
|
||||
toggle_keys = {}
|
||||
return isinstance(widget, QCheckBox) and widget in toggle_keys
|
||||
|
||||
def _find_mangohud_toggle_vertical_target(
|
||||
self, focused: QWidget, direction: int, section: list[QWidget]
|
||||
) -> QWidget | None:
|
||||
"""Navigate toggle checkboxes down/up with automatic next/prev column jump."""
|
||||
toggle_widgets = [widget for widget in section if self._is_mangohud_toggle_widget(widget)]
|
||||
if not toggle_widgets:
|
||||
return None
|
||||
if self._is_current_vkbasalt_tab():
|
||||
return self._find_vkbasalt_effect_vertical_target(focused, direction, toggle_widgets)
|
||||
return self._find_mangohud_vertical_grid_target(focused, direction, toggle_widgets)
|
||||
|
||||
def _is_current_vkbasalt_tab(self) -> bool:
|
||||
if not self.settings_dialog:
|
||||
return False
|
||||
current_tab = self.settings_dialog.tab_widget.currentWidget()
|
||||
return current_tab == getattr(self.settings_dialog, "vkbasalt_tab", None)
|
||||
|
||||
def _find_vkbasalt_first_effect_widget(self) -> QWidget | None:
|
||||
if not self.settings_dialog:
|
||||
return None
|
||||
widgets = [
|
||||
widget for widget in self.settings_dialog.vkbasalt_shader_widgets.values()
|
||||
if widget.isVisible() and widget.isEnabled()
|
||||
]
|
||||
if not widgets:
|
||||
return None
|
||||
return self._sort_widgets_by_position(widgets)[0]
|
||||
|
||||
def _find_vkbasalt_effect_vertical_target(
|
||||
self, focused: QWidget, direction: int, widgets: list[QWidget]
|
||||
) -> QWidget | None:
|
||||
if not self.settings_dialog:
|
||||
return None
|
||||
target = self._find_mangohud_vertical_grid_target(focused, direction, widgets)
|
||||
if direction >= 0 or target is None:
|
||||
return target
|
||||
focused_pos = focused.mapTo(self.settings_dialog, focused.rect().center())
|
||||
target_pos = target.mapTo(self.settings_dialog, target.rect().center())
|
||||
if target_pos.x() < focused_pos.x() and target_pos.y() > focused_pos.y():
|
||||
return None
|
||||
return target
|
||||
|
||||
def _find_mangohud_fps_vertical_target(self, focused, direction, section):
|
||||
"""Navigate FPS widgets down/up with automatic next/prev column jump."""
|
||||
fps_widgets = [widget for widget in section if self._is_mangohud_fps_widget(widget)]
|
||||
if not fps_widgets:
|
||||
return None
|
||||
return self._find_mangohud_vertical_grid_target(focused, direction, fps_widgets)
|
||||
|
||||
def _find_mangohud_vertical_grid_target(
|
||||
self, focused: QWidget, direction: int, widgets: list[QWidget]
|
||||
) -> QWidget | None:
|
||||
"""Find a vertical target in grid columns."""
|
||||
settings_dialog = self.settings_dialog
|
||||
if not settings_dialog:
|
||||
return None
|
||||
sorted_widgets = sorted(
|
||||
widgets,
|
||||
key=lambda widget: (
|
||||
widget.mapTo(settings_dialog, widget.rect().center()).x(),
|
||||
widget.mapTo(settings_dialog, widget.rect().center()).y(),
|
||||
),
|
||||
)
|
||||
columns = []
|
||||
tolerance = 24
|
||||
for widget in sorted_widgets:
|
||||
x = widget.mapTo(settings_dialog, widget.rect().center()).x()
|
||||
if not columns:
|
||||
columns.append([widget])
|
||||
continue
|
||||
last_x = columns[-1][0].mapTo(settings_dialog, columns[-1][0].rect().center()).x()
|
||||
if abs(x - last_x) <= tolerance:
|
||||
columns[-1].append(widget)
|
||||
else:
|
||||
columns.append([widget])
|
||||
|
||||
col_idx = -1
|
||||
row_idx = -1
|
||||
for index, column in enumerate(columns):
|
||||
if focused in column:
|
||||
col_idx = index
|
||||
row_idx = column.index(focused)
|
||||
break
|
||||
if col_idx == -1:
|
||||
return None
|
||||
|
||||
if direction > 0:
|
||||
if row_idx + 1 < len(columns[col_idx]):
|
||||
return columns[col_idx][row_idx + 1]
|
||||
return None
|
||||
|
||||
if direction < 0:
|
||||
if row_idx - 1 >= 0:
|
||||
return columns[col_idx][row_idx - 1]
|
||||
return None
|
||||
return None
|
||||
@@ -169,15 +169,6 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -443,15 +434,6 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
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 ""
|
||||
|
||||
@@ -851,6 +833,9 @@ msgstr ""
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Later"
|
||||
msgstr ""
|
||||
|
||||
@@ -1137,7 +1122,7 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit PPDB"
|
||||
msgid "Open PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
@@ -2008,39 +1993,9 @@ 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 ""
|
||||
|
||||
@@ -2108,9 +2063,6 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
|
||||
@@ -2135,9 +2087,6 @@ msgstr ""
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2171,12 +2120,6 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2342,9 +2285,6 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
@@ -2413,9 +2353,6 @@ msgstr ""
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
@@ -2428,13 +2365,6 @@ msgstr ""
|
||||
msgid "Link:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to delete theme."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-09-02 20:25+0000\n"
|
||||
"PO-Revision-Date: 2026-07-28 16:30+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.7.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -174,15 +174,6 @@ msgstr "Löschen"
|
||||
msgid "Import"
|
||||
msgstr "Import"
|
||||
|
||||
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"
|
||||
|
||||
@@ -454,15 +445,6 @@ msgstr "Abhängigkeiten installieren"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Notwendige 32-Bit-Software fehlen. Jetzt installieren?"
|
||||
|
||||
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."
|
||||
|
||||
@@ -922,6 +904,9 @@ 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"
|
||||
|
||||
@@ -1216,8 +1201,8 @@ msgstr "Wert"
|
||||
msgid "Description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
msgid "Edit PPDB"
|
||||
msgstr "PPDB bearbeiten"
|
||||
msgid "Open PPDB"
|
||||
msgstr "PPDB öffnen"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "PPDB zurücksetzen"
|
||||
@@ -2088,6 +2073,7 @@ msgstr "GOG-Anmeldung fehlgeschlagen: {0}"
|
||||
msgid "GOG account connected"
|
||||
msgstr "GOG-Konto verknüpft"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Log out"
|
||||
msgstr "Abmelden"
|
||||
|
||||
@@ -2105,39 +2091,9 @@ 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"
|
||||
|
||||
@@ -2205,9 +2161,6 @@ msgstr "Alle"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Anzeigefilter:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Nur installierte"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detailliert"
|
||||
|
||||
@@ -2234,9 +2187,6 @@ msgstr "Accounts"
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "Account"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Vereinfacht"
|
||||
|
||||
@@ -2270,12 +2220,6 @@ msgstr "Minimiert im Tray starten"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Tab „Automatische Installation“ ausblenden"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Steuerungshinweise ausblenden"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "Englische Sprache erzwingen"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
|
||||
@@ -2445,9 +2389,6 @@ msgstr "Wiedergabegeräte"
|
||||
msgid "Output"
|
||||
msgstr "Ausgang"
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr "Zum Desktop zurückkehren"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Abmelden"
|
||||
|
||||
@@ -2515,9 +2456,6 @@ msgstr "Theme auswählen:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Theme anwenden"
|
||||
|
||||
msgid "Delete Theme"
|
||||
msgstr "Theme löschen"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Kein Link"
|
||||
|
||||
@@ -2530,13 +2468,6 @@ msgstr "Autor:"
|
||||
msgid "Link:"
|
||||
msgstr "Link:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Theme '{0}' löschen?"
|
||||
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Das Theme konnte nicht gelöscht werden."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefix:"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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-07-25 17:07+0000\n"
|
||||
"Last-Translator: gallegonovato <gallegonovato@noreply.codeberg.org>\n"
|
||||
"Language-Team: Spanish <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/es/>\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -176,15 +176,6 @@ msgstr "Borrar"
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Actualizar"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir la carpeta del juego"
|
||||
|
||||
@@ -456,17 +447,6 @@ msgstr "Instalar las dependencias"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Faltan algunas dependencias de 32 bits. ¿Las instalas ahora?"
|
||||
|
||||
#, 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."
|
||||
|
||||
@@ -940,6 +920,9 @@ 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"
|
||||
|
||||
@@ -1230,7 +1213,7 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit PPDB"
|
||||
msgid "Open PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
@@ -2106,40 +2089,9 @@ 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 ""
|
||||
|
||||
@@ -2211,9 +2163,6 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Solo instalados"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
|
||||
@@ -2240,9 +2189,6 @@ msgstr ""
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2276,12 +2222,6 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2448,10 +2388,6 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "Añadir al escritorio"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
@@ -2519,10 +2455,6 @@ msgstr ""
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Borrar el archivo"
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
@@ -2535,14 +2467,6 @@ msgstr ""
|
||||
msgid "Link:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Error al cargar los temas"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefijo:"
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-09-01 17:22+0500\n"
|
||||
"POT-Creation-Date: 2026-07-28 11:58+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,15 +165,6 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -439,15 +430,6 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
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 ""
|
||||
|
||||
@@ -847,6 +829,9 @@ msgstr ""
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Later"
|
||||
msgstr ""
|
||||
|
||||
@@ -1133,7 +1118,7 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit PPDB"
|
||||
msgid "Open PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
@@ -2004,39 +1989,9 @@ 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 ""
|
||||
|
||||
@@ -2104,9 +2059,6 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
|
||||
@@ -2131,9 +2083,6 @@ msgstr ""
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2167,12 +2116,6 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2339,9 +2282,6 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
@@ -2410,9 +2350,6 @@ msgstr ""
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
@@ -2425,13 +2362,6 @@ msgstr ""
|
||||
msgid "Link:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to delete theme."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-08-28 22:26+0000\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
|
||||
"portprotonqt/portprotonqt/pt_BR/>\n"
|
||||
@@ -18,7 +18,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n > 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -153,8 +153,9 @@ msgstr "Excluir do PortProton"
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "Reparar"
|
||||
msgstr "Pareado"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "Remover da Área de Trabalho"
|
||||
@@ -172,18 +173,7 @@ msgid "Delete"
|
||||
msgstr "Excluir"
|
||||
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Atualizar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "Ativar overlay mangoapp"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "Ativar overlay mangoapp"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir Pasta do Jogo"
|
||||
@@ -382,8 +372,9 @@ msgstr "Sistema"
|
||||
msgid "Themes"
|
||||
msgstr "Temas"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Downloads"
|
||||
msgstr "Downloads"
|
||||
msgstr "Baixar"
|
||||
|
||||
msgid "Warning"
|
||||
msgstr "Aviso"
|
||||
@@ -455,18 +446,6 @@ 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?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Selecionar pasta do jogo da GOG"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "Outra operação da GOG já está em execução"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefixo não encontrado"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
@@ -928,6 +907,9 @@ 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"
|
||||
|
||||
@@ -978,15 +960,14 @@ msgid "No cover selected"
|
||||
msgstr "Nenhuma capa selecionada"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr "Relatório de compatibilidade"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
"O programa foi encerrado. As informações de compatibilidade foram coletadas "
|
||||
"automaticamente."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "Fechar"
|
||||
msgstr "Mouse"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
@@ -1221,8 +1202,8 @@ msgstr "Valor"
|
||||
msgid "Description"
|
||||
msgstr "Descrição"
|
||||
|
||||
msgid "Edit PPDB"
|
||||
msgstr "Editar PPDB"
|
||||
msgid "Open PPDB"
|
||||
msgstr "Abrir PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Limpar PPDB"
|
||||
@@ -2042,149 +2023,131 @@ msgstr "Desmontar"
|
||||
msgid "Set output"
|
||||
msgstr "Definir saída"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Queued"
|
||||
msgstr "Na fila"
|
||||
msgstr "Usado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Completed"
|
||||
msgstr "Concluído"
|
||||
msgstr "Atualização concluída"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Clear List"
|
||||
msgstr "Limpar lista"
|
||||
msgstr "Limpar Tudo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Now Downloading"
|
||||
msgstr "Baixando agora"
|
||||
msgstr "Baixando"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "Aguardando…"
|
||||
msgstr "Aviso"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "Disco: "
|
||||
msgstr "Link:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Game Title"
|
||||
msgstr "Título do jogo"
|
||||
msgstr "Largura do jogo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
msgstr "Iniciado em"
|
||||
msgstr "Estado"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Tipo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Store"
|
||||
msgstr "Loja"
|
||||
msgstr "Armazenamento"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr "Copiar o link para a área de transferência"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr "Falha ao sair da conta GOG"
|
||||
msgstr ""
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "Atualizando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Unknown error"
|
||||
msgstr "Erro desconhecido"
|
||||
msgstr "Jogo Desconhecido"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr "Falha no login da GOG: {0}"
|
||||
msgstr "Arquivo: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "GOG account connected"
|
||||
msgstr "Conta GOG conectada"
|
||||
msgstr "Conectado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Log out"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr "Abrir página de login"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr "Conta GOG não conectada"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr "Atualizando biblioteca da GOG… {0}/{1}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "Falha ao sair da conta GOG"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "Copiar o link para a área de transferência"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Falha no login da GOG: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account connected"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account not connected"
|
||||
msgstr "Conta GOG não conectada"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Atualizando biblioteca da GOG… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Falha ao iniciar o jogo: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Selecionar pasta de instalação"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "Baixando agora"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Outra operação da GOG já está em execução"
|
||||
msgstr ""
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr "Instalação da GOG não encontrada"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "Selecionar pasta do jogo da GOG"
|
||||
msgstr "Excluir Pasta"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr "A pasta selecionada não corresponde a este jogo da GOG"
|
||||
msgstr ""
|
||||
|
||||
#, 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?"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "Iniciando"
|
||||
msgstr "Configuração"
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr "Baixando: {0} MiB/s"
|
||||
msgstr "Baixando… {0}%"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr "Disco: {0} MiB/s"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr "Tempo estimado: {0}"
|
||||
msgstr ", ETA: {}s"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
msgstr "Falhou"
|
||||
msgstr "Detalhado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repaired"
|
||||
msgstr "Reparado"
|
||||
msgstr "Pareado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "O processo de download falhou"
|
||||
msgstr "Download em Andamento"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Início Rápido"
|
||||
@@ -2213,9 +2176,6 @@ msgstr "Todos"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Filtro de Exibição:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Apenas instalados"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detalhado"
|
||||
|
||||
@@ -2236,15 +2196,13 @@ msgstr ""
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Configurações do PortProton"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Accounts"
|
||||
msgstr "Contas"
|
||||
msgstr "Montar"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Automático"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "Conta"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Resumido"
|
||||
|
||||
@@ -2278,17 +2236,11 @@ msgstr "Iniciar na bandeja"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Ocultar Aba de Instalação Automática"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Ocultar dicas de controle"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
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"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
@@ -2311,8 +2263,9 @@ msgstr "Baixar automaticamente o PPDB de"
|
||||
msgid "Auto update AppImage"
|
||||
msgstr "Atualização automática do AppImage"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Integrate AppImage"
|
||||
msgstr "Integrar AppImage"
|
||||
msgstr "Atualização automática do AppImage"
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr "Forçar DPI do sistema para o Wine"
|
||||
@@ -2384,11 +2337,11 @@ msgstr "Tem certeza de que deseja limpar o cache? Esta ação não pode ser desf
|
||||
|
||||
#, python-brace-format
|
||||
msgid "AppImage integrated at: {path}"
|
||||
msgstr "AppImage integrado em: {path}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr "Falha ao integrar o AppImage: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr "Falha ao atualizar a entrada xdg-autostart."
|
||||
@@ -2451,10 +2404,6 @@ msgstr "Dispositivos de reprodução"
|
||||
msgid "Output"
|
||||
msgstr "Saída"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "Adicionar à Área de Trabalho"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
@@ -2522,9 +2471,6 @@ msgstr "Selecionar Tema:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Aplicar Tema"
|
||||
|
||||
msgid "Delete Theme"
|
||||
msgstr "Excluir tema"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Sem link"
|
||||
|
||||
@@ -2537,13 +2483,6 @@ msgstr "Autor:"
|
||||
msgid "Link:"
|
||||
msgstr "Link:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Excluir o tema '{0}'?"
|
||||
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Não foi possível excluir o tema."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefixo:"
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-09-05 17:10+0000\n"
|
||||
"Last-Translator: adem4ik <adem4ik@noreply.codeberg.org>\n"
|
||||
"PO-Revision-Date: 2026-07-24 20:10+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
"Language: 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.6.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -175,15 +175,6 @@ msgstr "Удалить"
|
||||
msgid "Import"
|
||||
msgstr "Импорт"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "Отключить наложение EOS"
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "Включить наложение EOS"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Открыть папку с игрой"
|
||||
|
||||
@@ -269,7 +260,7 @@ msgstr "Не удалось удалить старый файл .desktop: {erro
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Удалён старый файл .desktop для '{game_name}'"
|
||||
msgstr "Удален старый файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
@@ -452,15 +443,6 @@ msgstr "Установить зависимости"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Отсутствуют 32-битные зависимости. Установить их сейчас?"
|
||||
|
||||
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 "Игра не найдена."
|
||||
|
||||
@@ -576,7 +558,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"
|
||||
@@ -612,8 +594,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 "Использовать режим запуска в контейнере (рекомендуемый по умолчанию)"
|
||||
@@ -637,7 +619,7 @@ msgid "Stable"
|
||||
msgstr "Стабильный"
|
||||
|
||||
msgid "Vulkan Backend"
|
||||
msgstr "Бэкенд Vulkan"
|
||||
msgstr "Vulkan рендеринг"
|
||||
|
||||
msgid ""
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
@@ -791,9 +773,9 @@ msgid ""
|
||||
" single node reduces memory-access latency and limits costly core-to-core"
|
||||
" switches."
|
||||
msgstr ""
|
||||
"Узел NUMA для привязки процессора. В многоядерных системах процессоры "
|
||||
"разделены на узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка "
|
||||
"игры к одному узлу уменьшает задержку доступа к памяти и ограничивает "
|
||||
"Узел NUMA для аффинности CPU. В многоядерных системах CPU разделены на "
|
||||
"узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка игры к "
|
||||
"одному узлу уменьшает задержку доступа к памяти и ограничивает "
|
||||
"дорогостоящие переключения между ядрами."
|
||||
|
||||
msgid "Sound Driver"
|
||||
@@ -923,6 +905,9 @@ msgstr "Доступно обновление"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Доступно новое обновление AppImage."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Позже"
|
||||
|
||||
@@ -973,15 +958,14 @@ msgid "No cover selected"
|
||||
msgstr "Обложка не выбрана"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr "Отчёт о совместимости"
|
||||
msgstr ""
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
"Программа остановилась. Информация о совместимости была собрана "
|
||||
"автоматически."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Close"
|
||||
msgstr "Закрыть"
|
||||
msgstr "Мышь"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Открыть"
|
||||
@@ -1166,7 +1150,7 @@ msgstr "Загрузка: {0} ({1}%)"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Extracting: {0}"
|
||||
msgstr "Распаковка: {0}"
|
||||
msgstr "Распаковка {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ", ETA: {}s"
|
||||
@@ -1216,8 +1200,8 @@ msgstr "Значение"
|
||||
msgid "Description"
|
||||
msgstr "Описание"
|
||||
|
||||
msgid "Edit PPDB"
|
||||
msgstr "Редактировать PPDB"
|
||||
msgid "Open PPDB"
|
||||
msgstr "Открыть PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Очистить PPDB"
|
||||
@@ -1280,7 +1264,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"
|
||||
@@ -1298,10 +1282,10 @@ msgid "Enable adaptive sync"
|
||||
msgstr "Включить адаптивную синхронизацию"
|
||||
|
||||
msgid "Output width"
|
||||
msgstr "Ширина вывода"
|
||||
msgstr "Выходная ширина"
|
||||
|
||||
msgid "Output height"
|
||||
msgstr "Высота вывода"
|
||||
msgstr "Выходная высота"
|
||||
|
||||
msgid "Game width"
|
||||
msgstr "Ширина игры"
|
||||
@@ -1322,7 +1306,7 @@ msgid "Upscaler type"
|
||||
msgstr "Режим масштабирования"
|
||||
|
||||
msgid "Upscaler filter"
|
||||
msgstr "Фильтр масштабирования"
|
||||
msgstr "Режим интерполяции"
|
||||
|
||||
msgid "Upscaler sharpness (0-20)"
|
||||
msgstr "Улучшение резкости (0-20)"
|
||||
@@ -1819,7 +1803,7 @@ msgid "Local time"
|
||||
msgstr "Местное время"
|
||||
|
||||
msgid "Time without label"
|
||||
msgstr "Скрывать слово \"Time\" перед временем"
|
||||
msgstr "Скрыть слово \"Time\" перед временем"
|
||||
|
||||
msgid "Force trilinear filtering"
|
||||
msgstr "Включить трилинейную фильтрацию"
|
||||
@@ -1902,7 +1886,7 @@ msgid "Failed to read default MangoHud config."
|
||||
msgstr "Ошибка при чтении стандартной конфигурации MangoHud."
|
||||
|
||||
msgid "System MangoHud config has been written."
|
||||
msgstr "Конфигурация MangoHud записана в системный конфиг."
|
||||
msgstr "Конфигурация записана в системный конфиг."
|
||||
|
||||
msgid "Failed to write system MangoHud config."
|
||||
msgstr "Ошибка при записи конфигурации MangoHud."
|
||||
@@ -2100,39 +2084,9 @@ 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 уже выполняется"
|
||||
|
||||
@@ -2175,7 +2129,7 @@ msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
msgid "Delete cards without executable"
|
||||
msgstr "Удалить карточки без исполняемых файлов"
|
||||
msgstr "Удалить карточки без исполняемого файла"
|
||||
|
||||
msgid "Library Settings"
|
||||
msgstr "Настройки Библиотеки"
|
||||
@@ -2198,9 +2152,6 @@ msgstr "Все"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Фильтр:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Только Установленные"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Детальный"
|
||||
|
||||
@@ -2227,9 +2178,6 @@ msgstr "Аккаунты"
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "Аккаунт"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Упрощённый"
|
||||
|
||||
@@ -2246,7 +2194,7 @@ msgid "Gamepad Type:"
|
||||
msgstr "Тип геймпада:"
|
||||
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Запускать приложение в полноэкранном режиме"
|
||||
msgstr "Запуск приложения в полноэкранном режиме"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
|
||||
@@ -2255,25 +2203,19 @@ 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 "Скрывать вкладку \"Автоустановка\""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Скрывать подсказки управления"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "Принудительно использовать английский язык"
|
||||
msgstr "Скрыть вкладку \"Автоустановка\""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Включить звуки интерфейса"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Показывать отчёт о совместимости после сбоя игры"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Отключить скачивание контейнера"
|
||||
@@ -2435,9 +2377,6 @@ msgstr "Устройства воспроизведения"
|
||||
msgid "Output"
|
||||
msgstr "Вывод"
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr "Вернуться на рабочий стол"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Выйти"
|
||||
|
||||
@@ -2505,9 +2444,6 @@ msgstr "Выбрать тему:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Применить тему"
|
||||
|
||||
msgid "Delete Theme"
|
||||
msgstr "Удалить тему"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Нет ссылки"
|
||||
|
||||
@@ -2520,13 +2456,6 @@ msgstr "Автор:"
|
||||
msgid "Link:"
|
||||
msgstr "Ссылка:"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Удалить тему '{0}'?"
|
||||
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Не удалось удалить тему."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Префикс:"
|
||||
|
||||
|
||||
@@ -169,17 +169,6 @@ msgstr "删除"
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr "更新"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "启用 mangoapp 叠加层"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "启用 mangoapp 叠加层"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "打开游戏文件夹"
|
||||
|
||||
@@ -443,17 +432,6 @@ msgstr "安装依赖"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, 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 "未找到游戏。"
|
||||
|
||||
@@ -884,6 +862,9 @@ msgstr "有可用更新"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "有新的 AppImage 更新可用。"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "更新"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "稍后"
|
||||
|
||||
@@ -1176,8 +1157,8 @@ msgstr "值"
|
||||
msgid "Description"
|
||||
msgstr "描述"
|
||||
|
||||
msgid "Edit PPDB"
|
||||
msgstr "编辑 PPDB"
|
||||
msgid "Open PPDB"
|
||||
msgstr "打开 PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
@@ -2059,42 +2040,9 @@ 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 ""
|
||||
|
||||
@@ -2167,10 +2115,6 @@ msgstr "全部"
|
||||
msgid "Display Filter:"
|
||||
msgstr "显示筛选:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Only Installed"
|
||||
msgstr "已安装"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "详细"
|
||||
|
||||
@@ -2198,9 +2142,6 @@ msgstr "挂载"
|
||||
msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "简要"
|
||||
|
||||
@@ -2234,12 +2175,6 @@ msgstr "启动时最小化到托盘"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "隐藏自动安装标签页"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
@@ -2406,10 +2341,6 @@ msgstr "播放设备"
|
||||
msgid "Output"
|
||||
msgstr "输出"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "添加到桌面"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "注销"
|
||||
|
||||
@@ -2477,10 +2408,6 @@ msgstr "选择主题:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "应用主题"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "选择主题:"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "无链接"
|
||||
|
||||
@@ -2493,14 +2420,6 @@ msgstr "作者:"
|
||||
msgid "Link:"
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "选择主题:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "加载主题失败"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
|
||||
@@ -8,8 +8,6 @@ from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_translation_sources: dict[str, str] = {}
|
||||
|
||||
LOCALE_MAP = {
|
||||
'ru': 'russian',
|
||||
'en': 'english',
|
||||
@@ -38,8 +36,6 @@ 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")
|
||||
@@ -56,31 +52,10 @@ except FileNotFoundError:
|
||||
localedir=_system_localedir,
|
||||
fallback=True,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
|
||||
_ = 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'
|
||||
|
||||
@@ -112,23 +87,6 @@ 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.
|
||||
|
||||