Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f819e495f2
|
||
|
|
4e998edae8
|
||
|
|
6ccfeffdd8
|
||
|
|
3ce2acca89
|
||
|
|
2092a08e82
|
||
|
|
79a32d392e
|
||
|
|
f1a34d352b
|
||
|
|
7afd6d0baf | ||
|
|
a0a602daf5 | ||
|
|
9d797e8072
|
||
|
|
b695ec6a2a
|
||
|
|
d562bbf18d
|
||
|
|
cf4185007a
|
||
|
|
e8dbaeb9f7
|
||
|
|
2ec44c6a5b
|
||
|
|
257f3c85cf
|
||
|
|
53f1f5857c | ||
|
|
ae1d991286
|
||
|
|
693f0cb7f4
|
||
|
|
85af880000
|
||
|
|
ed1ba4dd28
|
||
|
|
de29dd92a9
|
||
|
|
6fa6f1af2a | ||
|
|
16ef000ed2
|
||
|
|
299a3cf89e | ||
|
|
056faafd82
|
||
|
|
4615b922c2
|
||
|
|
e842840101
|
||
|
|
8e76a50b1d
|
||
|
|
e7cf33cdc0
|
||
|
|
3c47d636f2
|
||
|
|
0b253a4d6e
|
||
|
|
ded84a6130
|
||
|
|
4a17f438fd | ||
|
|
3e6e138ff9
|
||
|
|
2f7dc41c49
|
||
|
|
ce1ca0d026
|
||
|
|
6ffdd4988f
|
||
|
|
debe88f2bc
|
||
|
|
efeac02ece
|
||
|
|
08d45ddc70
|
||
|
|
7a62207063
|
||
|
|
1deaaf74d8
|
||
|
|
a45792307e
|
||
|
|
a52d4a1f49
|
||
|
|
a5ea3e220f
|
||
|
|
10b498acb7
|
||
|
|
d9f3e7fbb8
|
||
|
|
998a4234e5
|
||
|
|
97c1addedc
|
||
|
|
b9a8a07a04
|
||
|
|
9e5fd9d1a7
|
||
|
|
cea7b40b82
|
||
|
|
2a89bd7573
|
||
|
|
c66538e32f
|
||
|
|
d2e06fcbc3 | ||
|
|
4b56725559
|
||
|
|
14cf5c73f8
|
||
|
|
347685e4f0
|
||
|
|
5e512279f3
|
||
|
|
05e7366706
|
||
|
|
862bfe4f8b
|
||
|
|
12b0889566 | ||
|
|
a62bfecfd3
|
||
|
|
7ec1602be7
|
||
|
|
7ae1904bb6
|
||
|
|
527cceccc2
|
||
|
|
42eff0d44b
|
||
|
|
12f3d3b753
|
||
|
|
3afe35929f
|
||
|
|
32e363d328
|
||
|
|
6443b71c01
|
||
|
|
d5db14db54
|
||
|
|
db2cc0365d
|
||
|
|
ab073c5413
|
||
|
|
2212c7c23a
|
||
|
|
3a3ae4be2e
|
||
|
|
3d9151df7b | ||
|
|
844fcff2da | ||
|
|
cf542d5ce2 | ||
|
|
a6c25bad98
|
||
|
|
0f0155cfb7
|
||
|
|
8b7849b3fd
|
||
|
|
e0d8f587fa
|
||
|
|
393d5cce98
|
||
|
|
cf7a14983d
|
||
|
|
024a1670eb
|
||
|
|
6cc3487d59 | ||
|
|
0ce8317763 | ||
|
|
c3e1222237 | ||
|
|
bdc52c8f02 |
No files matched your search
@@ -7,7 +7,7 @@ on:
|
||||
- main
|
||||
- scripts
|
||||
paths:
|
||||
- 'build-aux/vk_gpu_info.c'
|
||||
- 'build-aux/src/vk_gpu_info.c'
|
||||
- '.gitea/workflows/build-vk-gpu-info-debian.yml'
|
||||
|
||||
jobs:
|
||||
@@ -34,17 +34,16 @@ jobs:
|
||||
|
||||
- name: Compile vk_gpu_info
|
||||
run: |
|
||||
cd build-aux
|
||||
gcc -o vk_gpu_info vk_gpu_info.c -lvulkan
|
||||
mkdir -p build-aux/bin
|
||||
gcc -o build-aux/bin/vk_gpu_info build-aux/src/vk_gpu_info.c -lvulkan
|
||||
|
||||
- name: Test vk_gpu_info execution
|
||||
run: |
|
||||
cd build-aux
|
||||
# Check if the executable was created
|
||||
if [ -f "./vk_gpu_info" ]; then
|
||||
if [ -f "build-aux/bin/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 ./vk_gpu_info || echo "vk_gpu_info ran but may have failed due to lack of Vulkan hardware (expected in CI)"
|
||||
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)"
|
||||
else
|
||||
echo "ERROR: vk_gpu_info executable was not created"
|
||||
exit 1
|
||||
@@ -54,4 +53,4 @@ jobs:
|
||||
uses: https://git.linux-gaming.ru/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: vk_gpu_info-debian11-binary
|
||||
path: build-aux/vk_gpu_info
|
||||
path: build-aux/bin/vk_gpu_info
|
||||
@@ -17,7 +17,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 1.3.1
|
||||
VERSION: 1.4.0
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [42, 43, 44, rawhide]
|
||||
fedora_version: [43, 44, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -128,9 +128,11 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools meson ninja-build python3-devel \
|
||||
dnf upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf install -y --disablerepo=fedora-cisco-openh264 \
|
||||
git rpmdevtools meson ninja-build python3-devel \
|
||||
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
|
||||
gcc gettext
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
@@ -172,7 +174,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libvulkan-dev meson ninja-build nodejs npm \
|
||||
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
|
||||
pkgconf python3
|
||||
|
||||
- name: Checkout
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [42, 43, 44, rawhide]
|
||||
fedora_version: [43, 44, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -135,9 +135,11 @@ jobs:
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools meson ninja-build python3-devel \
|
||||
dnf upgrade -y --exclude=openh264,mozilla-openh264
|
||||
dnf install -y --disablerepo=fedora-cisco-openh264 \
|
||||
git rpmdevtools meson ninja-build python3-devel \
|
||||
systemd-rpm-macros vulkan-loader-devel redhat-rpm-config nodejs npm \
|
||||
gcc gettext
|
||||
gcc gettext 'pkgconfig(sdl3)'
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
@@ -257,7 +259,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y build-essential debhelper dpkg-dev gettext git \
|
||||
libvulkan-dev meson ninja-build nodejs npm \
|
||||
libsdl3-dev libvulkan-dev meson ninja-build nodejs npm \
|
||||
pkgconf python3
|
||||
|
||||
- name: Checkout PR
|
||||
|
||||
@@ -43,3 +43,5 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
|
||||
|
||||
# AI
|
||||
.codex
|
||||
CODE_QUALITY_PLAN.md
|
||||
.repowise
|
||||
@@ -394,6 +394,7 @@ 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
|
||||
@@ -401,7 +402,9 @@ 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_detail_pages.py # Detail page gradient stops, wave background modes, palette handling
|
||||
├── 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
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
@@ -426,7 +429,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 numbers, not translated strings)
|
||||
- Keep tests locale-independent: assert stable identifiers or numbers instead of translated strings; resolve values created through `_()` from the same application mapping using stable identifiers; use `monkeypatch` to provide deterministic text when testing translated search or matching
|
||||
- Test regression scenarios from git fix commits
|
||||
|
||||
### Key Regression Areas
|
||||
@@ -442,6 +445,7 @@ 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 | — |
|
||||
|
||||
---
|
||||
|
||||
@@ -874,21 +878,113 @@ install_data(
|
||||
|
||||
```
|
||||
PortProtonQt/
|
||||
├── 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
|
||||
├── 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
|
||||
└── .pre-commit-config.yaml
|
||||
```
|
||||
|
||||
@@ -946,6 +1042,6 @@ PortProtonQt/
|
||||
|
||||
---
|
||||
|
||||
**Last updated:** 2026-07-20
|
||||
**Version:** 1.2
|
||||
**Last updated:** 2026-08-11
|
||||
**Version:** 1.3
|
||||
**Status:** Release
|
||||
@@ -5,21 +5,50 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
### 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
|
||||
- Обновлен контейнер (SLR) до v4.0.20260805
|
||||
- Вертикальная навигация геймпадом в сетках настроек больше не переходит между столбцами
|
||||
- Удалена сборка для Fedora 42
|
||||
- Версия Windows по умолчанию изменена с 10 на 11
|
||||
- Обновлены все иконки подсказок управления
|
||||
- API и ссылки PPDB переведены с `ppdb.linux-gaming.ru` на `linux-gaming.ru`
|
||||
- Блок игровой статистики на полной детальной странице помещён в тематизируемый контейнер, как в компактном режиме
|
||||
- Кэш EXE-иконок унифицирован для карточек и детальных страниц
|
||||
@@ -33,6 +62,18 @@
|
||||
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
|
||||
|
||||
### Fixed
|
||||
- Настройки прокси теперь передаются `curl` при загрузке файлов скриптами PortProton
|
||||
- Устранена повторная перестройка индексов Steam и PPDB для каждой карточки в экономном режиме
|
||||
- Скрытие бейджей больше не скрывает ленту источника игры на карточках библиотеки
|
||||
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
|
||||
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
|
||||
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
|
||||
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
|
||||
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
|
||||
- Исправлено обрезание подсказок управления при переносе на вторую строку
|
||||
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
|
||||
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
|
||||
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
|
||||
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
|
||||
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
|
||||
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
|
||||
@@ -43,11 +84,21 @@
|
||||
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
|
||||
- Исправлено зависание интерфейса после завершения игры
|
||||
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
|
||||
- Распаковка WINE/Proton больше не меняет рабочий каталог приложения и отклоняет пути из архива, выходящие за каталог установки
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @Rubin13
|
||||
- @rogerio-mattos
|
||||
- @Vekt0R_null
|
||||
- @CastroFidel
|
||||
|
||||
---
|
||||
|
||||
## [1.3.2] - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
- Исправлено определение SteamID по `AutoLogin` или единственному профилю `userdata`, если в `loginusers.vdf` отсутствует `MostRecent`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[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-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>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-6382-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
</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-5471-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск 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-6382-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
|
||||
</div>
|
||||
|
||||
### Установка (devel)
|
||||
|
||||
@@ -39,15 +39,21 @@ 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/update-desktop-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/qt6/plugins/imageformats/libqwebp.so \
|
||||
/usr/lib/7zip/7z*
|
||||
|
||||
# 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.3.1
|
||||
pkgver=1.4.0
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton and Steam"
|
||||
arch=('any')
|
||||
arch=('x86_64')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
|
||||
@@ -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=('any')
|
||||
arch=('x86_64')
|
||||
url="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-requests' 'python-babel' 'python-evdev' 'python-orjson'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.3.1
|
||||
%global pypi_version 1.4.0
|
||||
%global oname PortProtonQt
|
||||
%global build_timestamp %(date +"%Y%m%d")
|
||||
%global _python_no_extras_requires 1
|
||||
@@ -22,6 +22,7 @@ BuildRequires: git
|
||||
BuildRequires: gettext
|
||||
BuildRequires: systemd-rpm-macros
|
||||
BuildRequires: vulkan-loader-devel
|
||||
BuildRequires: pkgconfig(sdl3)
|
||||
BuildRequires: gcc
|
||||
|
||||
Obsoletes: python3-%{pypi_name}-git < %{version}-%{release}
|
||||
@@ -42,7 +43,6 @@ 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,7 +120,6 @@ 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.3.1
|
||||
%global pypi_version 1.4.0
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
@@ -19,6 +19,7 @@ BuildRequires: git
|
||||
BuildRequires: gettext
|
||||
BuildRequires: systemd-rpm-macros
|
||||
BuildRequires: vulkan-loader-devel
|
||||
BuildRequires: pkgconfig(sdl3)
|
||||
BuildRequires: gcc
|
||||
|
||||
Obsoletes: python3-%{pypi_name} < %{version}-%{release}
|
||||
@@ -39,7 +40,6 @@ 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,7 +118,6 @@ 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
|
||||
|
||||
@@ -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 and Steam</summary>
|
||||
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton и Steam</summary>
|
||||
<summary>Modern GUI for managing and launching games from PortProton, Steam, and GOG</summary>
|
||||
<summary xml:lang="ru">Современный интерфейс для управления и запуска игр из PortProton, Steam и GOG</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
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.
|
||||
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.
|
||||
</p>
|
||||
<p xml:lang="ru">
|
||||
Современный и удобный интерфейс для управления и запуска игр из PortProton и Steam. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
|
||||
Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
@@ -24,9 +24,15 @@
|
||||
</developer>
|
||||
|
||||
<releases>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<release version="1.4.0" date="2026-08-23">
|
||||
<description></description>
|
||||
</release>
|
||||
<release version="1.3.2" date="2026-08-10">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.3.0" date="2026-07-13">
|
||||
<description/>
|
||||
</release>
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 38 KiB |
@@ -135,8 +135,10 @@ rm_from_array () {
|
||||
}
|
||||
|
||||
fatal () {
|
||||
#TODO
|
||||
pw_notify_send -i error "$@"
|
||||
if [[ -z "$*" ]] ; then
|
||||
set -- "Fatal error"
|
||||
fi
|
||||
python_module dbus_tools notify -i error "$@" &>/dev/null
|
||||
print_error "$@"
|
||||
stop_portproton
|
||||
exit 1
|
||||
@@ -459,6 +461,26 @@ 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" ]]
|
||||
@@ -482,7 +504,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\""
|
||||
-H 'Pragma: no-cache' -L ${FIRST_URL[*]} -o \"$dest\" ${curl_proxy_args[*]}"
|
||||
|
||||
|
||||
[[ "$?" != 0 ]] && return 1 || return 0
|
||||
@@ -491,10 +513,10 @@ try_download () {
|
||||
# Normal download
|
||||
print_info "Download $filename from ${FIRST_URL[0]}..."
|
||||
if [[ "$silent" == "true" ]] ; then
|
||||
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' \
|
||||
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' \
|
||||
-H 'Pragma: no-cache' -L "${FIRST_URL[@]}" -o "$dest" 2>&1
|
||||
else
|
||||
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' \
|
||||
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' \
|
||||
-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'
|
||||
|
||||
@@ -543,48 +565,6 @@ 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
|
||||
@@ -2001,7 +1981,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]}")/../../../../dev-scripts/vk_gpu_info"
|
||||
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../bin/vk_gpu_info"
|
||||
if [[ -x "${vk_gpu_info_from_repo}" ]] ; then
|
||||
vk_gpu_info_cmd="${vk_gpu_info_from_repo}"
|
||||
else
|
||||
@@ -2311,7 +2291,7 @@ pw_create_gui_png () {
|
||||
pw_create_unique_exe () {
|
||||
BASEDIR_GAME="$(dirname "$PW_EXE_FILE")"
|
||||
if [[ -d "$BASEDIR_GAME" ]] ; then
|
||||
pushd "$BASEDIR_GAME" || fatal
|
||||
pushd "$BASEDIR_GAME" || fatal "Failed to enter game directory: $BASEDIR_GAME"
|
||||
BASENAME_GAME_EXE="$(basename "$PW_EXE_FILE")"
|
||||
if [[ -n "$1" ]] ; then
|
||||
BASENAME_GAME="$(basename "$1" .exe).exe"
|
||||
@@ -2324,7 +2304,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
|
||||
popd || fatal "Failed to return from game directory: $BASEDIR_GAME"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -4089,6 +4069,7 @@ steamplay_launch () {
|
||||
fi
|
||||
|
||||
print_info "$proton_runner in use."
|
||||
printf '%s\n' 'PORTPROTONQT_GAME_LAUNCH_STARTED'
|
||||
pw_launch_wrapper \
|
||||
${PW_RUN_GAMESCOPE} \
|
||||
env \
|
||||
@@ -4114,6 +4095,7 @@ 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}" &
|
||||
@@ -4213,8 +4195,13 @@ gui_proton_downloader () {
|
||||
return 0
|
||||
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")
|
||||
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
|
||||
if [[ -z "$METADATA" ]] ; then
|
||||
fatal "failed to fetch metadata"
|
||||
fi
|
||||
|
||||
@@ -19,7 +19,6 @@ 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
|
||||
@@ -55,7 +54,7 @@ else
|
||||
export PW_FIND_PPDB=1
|
||||
fi
|
||||
|
||||
export PORT_SCRIPTS_PATH PORT_IMG_PATH PORT_DATA_PATH PORT_CONF_PATH
|
||||
export PORT_SCRIPTS_PATH PORT_DATA_PATH PORT_CONF_PATH
|
||||
export PW_LOG_FILE="${PORT_DATA_PATH}/PortProton.log"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
@@ -148,7 +147,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
|
||||
pushd "${PORT_DATA_PATH}/data/prefixes/" 1>/dev/null || fatal "Failed to enter prefixes directory"
|
||||
for pfx_dir in ./* ; do
|
||||
[[ -d "$pfx_dir" ]] || continue
|
||||
pfx_dir_new="${pfx_dir//[[:blank:]]/_}"
|
||||
@@ -156,7 +155,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
|
||||
popd 1>/dev/null || fatal "Failed to return from prefixes directory"
|
||||
|
||||
create_new_dir "${PORT_WINE_TMP_PATH}"/gecko
|
||||
create_new_dir "${PORT_WINE_TMP_PATH}"/mono
|
||||
@@ -164,12 +163,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
|
||||
cd "${PORT_SCRIPTS_PATH}" || fatal "Scripts directory not found: ${PORT_SCRIPTS_PATH}"
|
||||
|
||||
# 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
|
||||
cd "${XDG_DATA_HOME:-$HOME}" || fatal "Data directory not found: ${XDG_DATA_HOME:-$HOME}"
|
||||
|
||||
[[ ! -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"
|
||||
@@ -472,7 +471,7 @@ Usage examples:
|
||||
--winecmd)
|
||||
get_wine_and_pfx "$2" "$3"
|
||||
start_portproton
|
||||
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal
|
||||
cd "${PORT_DATA_PATH}/data/prefixes/${PW_PREFIX_NAME}/drive_c" || fatal "Prefix drive_c directory not found"
|
||||
PW_USE_TERMINAL=1 pw_run cmd
|
||||
stop_portproton
|
||||
;;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
###BASE VARIABLES###
|
||||
export PW_PROTON_LG_VER="PROTON_LG_10-30"
|
||||
export PW_WINE_LG_VER="WINE_LG_11-10"
|
||||
export PW_LIBS_VER="_v3.0.20260218"
|
||||
export PW_LIBS_VER="_v4.0.20260805"
|
||||
export PW_PLUGINS_VER="_v20"
|
||||
export D7VK_VAR_VER="v1.10"
|
||||
export DXVK_NEW_VER="2.7.1-509"
|
||||
@@ -39,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 "10"
|
||||
check_variables PW_WINDOWS_VER "11"
|
||||
check_variables PW_USE_GSTREAMER "1"
|
||||
check_variables PW_USE_D3D_EXTRAS "1"
|
||||
check_variables PW_PREFIX_NAME "DEFAULT"
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
#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,3 +1,15 @@
|
||||
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,6 +5,7 @@ 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_REPO="Boria138/PortProtonQt"
|
||||
GITHUB_REPOS=("Boria138/PortProtonQt" "linux-gaming-ru/PortProtonQt")
|
||||
GITEA_REPO="Linux-Gaming/PortProtonQt"
|
||||
GITEA_URL="https://git.linux-gaming.ru"
|
||||
|
||||
@@ -10,8 +10,10 @@ jq_total='[.[].assets[] | select(.name | test("\\.(zsync|sha256)$|steam-compat")
|
||||
echo "=== PortProtonQt Download Stats ==="
|
||||
echo
|
||||
|
||||
echo "--- GitHub (${GITHUB_REPO}) ---"
|
||||
GH_RAW=$(curl -sf "https://api.github.com/repos/${GITHUB_REPO}/releases?per_page=100")
|
||||
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')
|
||||
GH_COUNT=$(echo "$GH_RAW" | jq "$jq_total")
|
||||
GH_RELEASES=$(echo "$GH_RAW" | jq 'length')
|
||||
echo "Releases: $GH_RELEASES"
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/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.3.1',
|
||||
version: '1.4.0',
|
||||
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_quoted('APP_VERSION', meson.project_version())
|
||||
version_conf.set_quoted('APP_COMMIT', '')
|
||||
version_conf.set('APP_VERSION', meson.project_version())
|
||||
version_conf.set('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_quoted('APP_COMMIT', git_commit.stdout().strip())
|
||||
version_conf.set('APP_COMMIT', git_commit.stdout().strip())
|
||||
endif
|
||||
endif
|
||||
endif
|
||||
@@ -36,19 +36,10 @@ purelibdir_opt = get_option('python_purelibdir')
|
||||
pythondir = purelibdir_opt != '' ? purelibdir_opt: python3.get_install_dir(pure: true)
|
||||
pkgdatadir = pythondir / meson.project_name()
|
||||
|
||||
# C compiler for vk_gpu_info
|
||||
# Native C build dependencies and sources
|
||||
cc = meson.get_compiler('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(),
|
||||
)
|
||||
sdl3_dep = dependency('sdl3', required: true)
|
||||
gamepad_sources = files('build-aux/src/gamepad_backend.c')
|
||||
|
||||
# Install Python package
|
||||
subdir('portprotonqt')
|
||||
@@ -67,7 +58,7 @@ install_data(configured_portprotonqt,
|
||||
)
|
||||
|
||||
# Build and install vk_gpu_info executable
|
||||
vk_gpu_info_sources = files('build-aux/vk_gpu_info.c')
|
||||
vk_gpu_info_sources = files('build-aux/src/vk_gpu_info.c')
|
||||
vk_gpu_info_deps = [dependency('vulkan', required: true)]
|
||||
|
||||
executable('vk_gpu_info',
|
||||
@@ -120,11 +111,6 @@ 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',
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
APP_VERSION = @APP_VERSION@
|
||||
APP_COMMIT = @APP_COMMIT@
|
||||
@@ -1,7 +1,6 @@
|
||||
import argparse
|
||||
import sys
|
||||
import os
|
||||
import importlib
|
||||
import subprocess
|
||||
import threading
|
||||
import urllib.error
|
||||
@@ -12,16 +11,6 @@ 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,
|
||||
@@ -60,6 +49,20 @@ 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.0"
|
||||
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():
|
||||
@@ -198,6 +201,24 @@ 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()
|
||||
@@ -328,11 +349,13 @@ 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,
|
||||
@@ -675,6 +698,27 @@ 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,8 +6,9 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtCore import QStandardPaths, QThread, Signal
|
||||
|
||||
from portprotonqt.config import get_portproton_location, migrate_legacy_shortcut
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
@@ -53,10 +54,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, icon: Path) -> str:
|
||||
def _rewrite_main_desktop(content: str, appimage: 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={icon}", content, flags=re.MULTILINE)
|
||||
content = re.sub(r"^Icon=.*$", f"Icon={APP_ID}", content, flags=re.MULTILINE)
|
||||
if re.search(r"^TryExec=", content, flags=re.MULTILINE):
|
||||
return re.sub(
|
||||
r"^TryExec=.*$",
|
||||
@@ -68,7 +69,7 @@ def _rewrite_main_desktop(content: str, appimage: Path, icon: Path) -> str:
|
||||
return content.replace("Type=Application\n", f"Type=Application\nTryExec={appimage}\n", 1)
|
||||
|
||||
|
||||
def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
|
||||
def _mode_desktop(mode: str, appimage: 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 (
|
||||
@@ -79,7 +80,7 @@ def _mode_desktop(mode: str, appimage: Path, icon: Path) -> str:
|
||||
"Type=Application\n"
|
||||
f"Comment={action}\n"
|
||||
"Terminal=false\n"
|
||||
f"Icon={icon}\n"
|
||||
f"Icon={APP_ID}\n"
|
||||
f"StartupWMClass={APP_ID}\n"
|
||||
"Categories=Game;\n"
|
||||
"NoDisplay=true\n"
|
||||
@@ -92,17 +93,21 @@ 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(
|
||||
_rewrite_main_desktop(content, appimage, icon),
|
||||
encoding="utf-8",
|
||||
main_path.write_text(rewritten_content, encoding="utf-8")
|
||||
desktop_dir = Path(
|
||||
QStandardPaths.writableLocation(QStandardPaths.StandardLocation.DesktopLocation)
|
||||
)
|
||||
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, icon), encoding="utf-8")
|
||||
path.write_text(_mode_desktop(mode, appimage), encoding="utf-8")
|
||||
|
||||
|
||||
def _set_default_mime_handlers() -> None:
|
||||
@@ -130,17 +135,8 @@ def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
|
||||
f"share/applications/{APP_ID}.desktop",
|
||||
),
|
||||
)
|
||||
icon = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
".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 desktop or not icon:
|
||||
icon = appdir / f"{APP_ID}.svg"
|
||||
if not desktop or not icon.is_file():
|
||||
raise FileNotFoundError("AppImage desktop metadata is incomplete")
|
||||
return desktop, icon
|
||||
|
||||
@@ -156,18 +152,25 @@ 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 = appimages_dir / ".icons"
|
||||
icon_dir = data_home / "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}{resolved_icon.suffix or '.svg'}"
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
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"],
|
||||
)
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
shutil.copy2(resolved_icon, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination)
|
||||
_set_default_mime_handlers()
|
||||
try:
|
||||
subprocess.run(
|
||||
@@ -177,6 +180,8 @@ 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
|
||||
FALLBACK_UPDATE_INFO = (
|
||||
"gh-releases-zsync|Boria138|PortProtonQt|latest|*x86_64.AppImage.zsync"
|
||||
GITHUB_UPDATE_INFO = (
|
||||
"gh-releases-zsync|linux-gaming-ru|PortProtonQt|latest|*x86_64.AppImage.zsync"
|
||||
)
|
||||
CHANGELOG_URL = (
|
||||
"https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/raw/branch/main/"
|
||||
"CHANGELOG.md"
|
||||
)
|
||||
CHANGELOG_FALLBACK_URL = (
|
||||
"https://raw.githubusercontent.com/Boria138/PortProtonQt/refs/heads/main/"
|
||||
CHANGELOG_GITHUB_URL = (
|
||||
"https://raw.githubusercontent.com/linux-gaming-ru/PortProtonQt/refs/heads/main/"
|
||||
"CHANGELOG.md"
|
||||
)
|
||||
CHANGELOG_TIMEOUT = 10
|
||||
@@ -129,16 +129,22 @@ def _run_appimageupdatetool(
|
||||
|
||||
|
||||
def _download_changelog() -> str:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
session = get_requests_session()
|
||||
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 ""
|
||||
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 ""
|
||||
|
||||
|
||||
def _extract_latest_version_changelog(changelog: str, current_version: str = "") -> str:
|
||||
@@ -306,15 +312,9 @@ class AppImageUpdateWorker(QThread):
|
||||
self.update_info = previous_info
|
||||
return result
|
||||
|
||||
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:
|
||||
from portprotonqt.portproton_api import get_user_conf_setting
|
||||
|
||||
appimage_path = _appimage_path()
|
||||
if not ui_config.get_auto_appimage_updates() or not appimage_path:
|
||||
return
|
||||
@@ -330,14 +330,10 @@ class AppImageUpdateWorker(QThread):
|
||||
self._run_update(tool_path, appimage_path)
|
||||
return
|
||||
|
||||
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
|
||||
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)
|
||||
|
||||
if check_code is None or check_code.returncode != 1:
|
||||
return
|
||||
|
||||
@@ -428,6 +428,10 @@ 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]
|
||||
@@ -500,8 +504,6 @@ 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_string
|
||||
from portprotonqt.config.validators import validate_bool, validate_string
|
||||
|
||||
|
||||
class GameConfig(BaseConfig):
|
||||
@@ -26,6 +26,15 @@ 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,7 +223,16 @@ def _extract_launcher_tail(parts: list[str]) -> list[str] | None:
|
||||
return None
|
||||
|
||||
command_name = os.path.basename(parts[0])
|
||||
if parts[0].lower().endswith(".appimage") or command_name == "portprotonqt" or command_name == "start.sh":
|
||||
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"
|
||||
):
|
||||
tail = parts[1:]
|
||||
if tail[:1] == ["--silent"]:
|
||||
tail = tail[1:]
|
||||
@@ -274,7 +283,11 @@ 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) -> int:
|
||||
def migrate_legacy_shortcut(
|
||||
portproton_path: str,
|
||||
desktop_dir: str | None = None,
|
||||
launcher_command: list[str] | None = None,
|
||||
) -> int:
|
||||
"""Migrate legacy PortProton shortcuts in known desktop directories."""
|
||||
flatpak_id = os.getenv("FLATPAK_ID", "").strip()
|
||||
user_home = os.path.expanduser("~")
|
||||
@@ -293,7 +306,8 @@ def migrate_legacy_shortcut(portproton_path: str, desktop_dir: str | None = None
|
||||
except OSError as error:
|
||||
logger.warning("Failed to read legacy PortProton config %s: %s", PORTPROTON_CONFIG_FILE, error)
|
||||
|
||||
launcher_command = _get_current_launcher_command()
|
||||
if launcher_command is None:
|
||||
launcher_command = _get_current_launcher_command()
|
||||
desktop_paths = (
|
||||
portproton_path,
|
||||
os.path.join(user_home, ".local", "share", "applications"),
|
||||
|
||||
@@ -348,6 +348,15 @@ 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_badge_view_mode(self) -> str:
|
||||
"""Get badge view mode ('detailed' or 'compact')."""
|
||||
mode = self._get_str("badge_view_mode", "detailed")
|
||||
|
||||
@@ -456,6 +456,7 @@ 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
|
||||
@@ -466,7 +467,7 @@ 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.exec(game_card.mapToGlobal(pos))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
# Normal menu for games with valid exe or from Steam
|
||||
@@ -632,7 +633,7 @@ class ContextMenuManager:
|
||||
if actions:
|
||||
menu.setActiveAction(actions[0])
|
||||
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
menu.popup(game_card.mapToGlobal(pos))
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
@@ -897,12 +898,14 @@ class ContextMenuManager:
|
||||
if not exe_path:
|
||||
return
|
||||
|
||||
from portprotonqt.time_utils import get_statistics_path
|
||||
from portprotonqt.time_utils import get_statistics_path, remove_last_launch
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
return result, y - spacing
|
||||
|
||||
|
||||
class FlowLayout(QLayout):
|
||||
@@ -160,7 +160,7 @@ class FlowLayout(QLayout):
|
||||
|
||||
for i, item in enumerate(self.itemList):
|
||||
widget = item.widget()
|
||||
if widget and widget.isVisible():
|
||||
if widget and not widget.isHidden():
|
||||
visible_items.append(item)
|
||||
visible_indices.append(i)
|
||||
s = item.sizeHint()
|
||||
@@ -191,6 +191,11 @@ 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)
|
||||
|
||||
@@ -671,7 +676,7 @@ class AutoSizeButton(QPushButton):
|
||||
|
||||
if icon_color and self._icon_name:
|
||||
colored_path = self.theme_manager.get_colored_icon_path(
|
||||
self._icon_name,
|
||||
icon_path,
|
||||
icon_color,
|
||||
self.current_theme_name
|
||||
)
|
||||
@@ -837,7 +842,7 @@ class AutoHideScrollArea(QScrollArea):
|
||||
self.fade_duration_ms = fade_duration_ms
|
||||
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOn)
|
||||
self.setStyleSheet("background: transparent;")
|
||||
self.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
|
||||
self._v_scrollbar = self.verticalScrollBar()
|
||||
self._v_scrollbar.installEventFilter(self)
|
||||
|
||||
@@ -19,15 +19,16 @@ def get_cached_vk_gpu_info() -> str:
|
||||
|
||||
if _vk_gpu_info_output is None:
|
||||
try:
|
||||
dev_scripts_path = os.path.join(
|
||||
vk_gpu_info_path = os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
|
||||
"dev-scripts",
|
||||
"build-aux",
|
||||
"bin",
|
||||
"vk_gpu_info"
|
||||
)
|
||||
|
||||
if os.path.exists(dev_scripts_path):
|
||||
if os.path.exists(vk_gpu_info_path):
|
||||
result = subprocess.run(
|
||||
[dev_scripts_path],
|
||||
[vk_gpu_info_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/t/opisanie-kategorii-portproton-pomoshh/1642"
|
||||
" https://linux-gaming.ru/forum/help/portprotonqt-pomosch"
|
||||
)
|
||||
lines.append("-" * 61)
|
||||
|
||||
|
||||
@@ -118,7 +118,16 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
|
||||
effect = QGraphicsOpacityEffect(image_label)
|
||||
image_label.setGraphicsEffect(effect)
|
||||
|
||||
duration = theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
||||
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)
|
||||
animation = QPropertyAnimation(effect, QByteArray(b"opacity"), image_label)
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(effect.opacity())
|
||||
@@ -644,11 +653,6 @@ 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):
|
||||
|
||||
@@ -323,28 +323,6 @@ 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,
|
||||
|
||||
@@ -79,6 +79,7 @@ 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,9 +8,13 @@ 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
|
||||
QScrollArea, QWidget, QScroller, QApplication, QSizePolicy, QStyle,
|
||||
QStyledItemDelegate, QStyleOptionViewItem
|
||||
)
|
||||
from PySide6.QtCore import (
|
||||
Qt, QObject, Signal, QMimeDatabase, QModelIndex, QPersistentModelIndex,
|
||||
QThreadPool, QRunnable, Slot, QSize, QTimer
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QThreadPool, QRunnable, Slot, QSize, QTimer
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.main_window import MainWindow
|
||||
@@ -32,6 +36,25 @@ 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):
|
||||
@@ -170,12 +193,12 @@ class FileExplorer(DraggableDialog):
|
||||
self.main_layout.addWidget(self.hints_widget)
|
||||
|
||||
if self.input_manager:
|
||||
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)
|
||||
)
|
||||
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)
|
||||
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):
|
||||
@@ -225,15 +248,41 @@ 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 = theme_manager.get_icon(icon_name)
|
||||
icon = self._get_list_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)
|
||||
@@ -309,6 +358,7 @@ 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)
|
||||
@@ -530,15 +580,17 @@ class FileExplorer(DraggableDialog):
|
||||
try:
|
||||
if self.directory_only:
|
||||
item = QListWidgetItem("./")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
folder_icon = self._get_list_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 = theme_manager.get_icon("folder")
|
||||
folder_icon = self._get_list_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)
|
||||
@@ -546,9 +598,10 @@ class FileExplorer(DraggableDialog):
|
||||
|
||||
for d in sorted(dirs):
|
||||
item = QListWidgetItem(f"{d}/")
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
folder_icon = self._get_list_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:
|
||||
@@ -566,6 +619,7 @@ 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"
|
||||
@@ -573,9 +627,10 @@ class FileExplorer(DraggableDialog):
|
||||
or mime_type.name() in ("application/vnd.squashfs", "application/x-squashfs")
|
||||
or mime_type.inherits("application/vnd.squashfs")
|
||||
):
|
||||
ppack_icon = theme_manager.get_icon("ppack")
|
||||
ppack_icon = self._get_list_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)
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ from PySide6.QtWidgets import (
|
||||
QHBoxLayout, QProgressBar, QFrame, QSizePolicy, QAbstractItemView,
|
||||
QStackedWidget, QPushButton
|
||||
)
|
||||
from PySide6.QtCore import Qt, QTimer, QEvent, QObject
|
||||
from PySide6.QtGui import QGuiApplication
|
||||
from PySide6.QtCore import QEvent, QMimeData, QObject, Qt, QTimer, QUrl
|
||||
from PySide6.QtGui import QDesktopServices, QDragEnterEvent, QDropEvent, QGuiApplication
|
||||
from shiboken6 import isValid
|
||||
|
||||
from portprotonqt.config import get_portproton_start_command, ui_config
|
||||
from portprotonqt.dialogs.base import DraggableDialog
|
||||
@@ -21,7 +22,9 @@ 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 WineLoadingThread, get_cpu_level
|
||||
from portprotonqt.dialogs.wine_loader import (
|
||||
InstalledWineSizeThread, WineLoadingThread, get_cpu_level,
|
||||
)
|
||||
from portprotonqt.dialogs.wine_downloader import DownloadThread
|
||||
from portprotonqt.dialogs.wine_extractor import ExtractionThread
|
||||
from portprotonqt.preloader import Preloader
|
||||
@@ -29,6 +32,8 @@ 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')
|
||||
|
||||
|
||||
class ProtonManager(DraggableDialog):
|
||||
@@ -47,6 +52,8 @@ 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()
|
||||
@@ -62,6 +69,48 @@ 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()
|
||||
@@ -213,34 +262,45 @@ 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:
|
||||
self.input_manager.button_event.connect(
|
||||
lambda *args: update_dialog_hints(
|
||||
def update_hints(*_args: object) -> None:
|
||||
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.dpad_moved.connect(
|
||||
lambda *args: update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
)
|
||||
self.input_manager.connect_surface_updates('proton_manager_dialog', update_hints)
|
||||
update_dialog_hints(
|
||||
self.hints_labels, self.main_window, self.input_manager,
|
||||
theme_manager, self.current_theme_name
|
||||
)
|
||||
|
||||
def _open_wine_folder(self) -> None:
|
||||
if not self.portproton_location:
|
||||
return
|
||||
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)
|
||||
@@ -249,9 +309,47 @@ class ProtonManager(DraggableDialog):
|
||||
|
||||
def on_wine_data_loaded(self, metadata):
|
||||
self.wine_metadata = metadata
|
||||
self.refresh_wine_tabs()
|
||||
if hasattr(self, 'content_stack'):
|
||||
self.content_stack.setCurrentIndex(1)
|
||||
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, sorted(filtered_entries, key=version_sort_key)))
|
||||
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
|
||||
self.selected_assets.clear()
|
||||
self.tab_widget.clear()
|
||||
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)
|
||||
|
||||
def on_wine_data_load_error(self, error_msg):
|
||||
logger.error(f"Wine data loading failed: {error_msg}")
|
||||
@@ -341,12 +439,6 @@ 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', '')
|
||||
@@ -357,18 +449,28 @@ class ProtonManager(DraggableDialog):
|
||||
entry['filename'] = url_filename
|
||||
all_entries.append(entry)
|
||||
all_entries.sort(key=version_sort_key)
|
||||
table.setRowCount(len(all_entries))
|
||||
table = self._create_empty_wine_tab(source_name, len(all_entries))
|
||||
for row_index, entry in enumerate(all_entries):
|
||||
self.add_asset_row_from_json(table, row_index, entry, source_name)
|
||||
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"
|
||||
@@ -506,6 +608,24 @@ 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
|
||||
@@ -562,8 +682,7 @@ class ProtonManager(DraggableDialog):
|
||||
version_item = QTableWidgetItem(version_name)
|
||||
version_item.setFlags(version_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 1, version_item)
|
||||
size_str = self.get_directory_size(version_path)
|
||||
size_item = QTableWidgetItem(size_str)
|
||||
size_item = QTableWidgetItem(_("Unknown"))
|
||||
size_item.setFlags(size_item.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
table.setItem(row_index, 2, size_item)
|
||||
for col in range(table.columnCount()):
|
||||
@@ -577,30 +696,6 @@ 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
|
||||
@@ -952,10 +1047,20 @@ 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']}"
|
||||
@@ -1026,12 +1131,13 @@ 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))
|
||||
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}")
|
||||
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}")
|
||||
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):
|
||||
@@ -1041,12 +1147,13 @@ 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}")
|
||||
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}")
|
||||
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}")
|
||||
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):
|
||||
@@ -1060,23 +1167,25 @@ class ProtonManager(DraggableDialog):
|
||||
self.current_extraction_thread.error.connect(extraction_error)
|
||||
self.current_extraction_thread.start()
|
||||
except Exception as e:
|
||||
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}")
|
||||
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}")
|
||||
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:
|
||||
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}")
|
||||
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}")
|
||||
logger.warning("PortProton location not provided, skipping extraction")
|
||||
self.current_download_index += 1
|
||||
QTimer.singleShot(100, self.start_next_download)
|
||||
@@ -1154,7 +1263,11 @@ class ProtonManager(DraggableDialog):
|
||||
super().reject()
|
||||
|
||||
|
||||
def show_proton_manager(parent=None, portproton_location=None, input_manager=None):
|
||||
def show_proton_manager(
|
||||
parent=None, portproton_location=None, input_manager=None, local_archives=None
|
||||
):
|
||||
dialog = ProtonManager(parent, portproton_location, input_manager=input_manager)
|
||||
dialog.show()
|
||||
if local_archives:
|
||||
dialog.install_local_archives(local_archives)
|
||||
return dialog
|
||||
@@ -211,7 +211,11 @@ class ExeSettingsDialog(
|
||||
|
||||
if self.input_manager:
|
||||
self.input_manager.enable_settings_mode(self)
|
||||
self.input_manager.gamepad_hotplug.connect(self._update_vkbasalt_toggle_key_visibility)
|
||||
self.input_manager.connect_surface_signal(
|
||||
'settings_dialog',
|
||||
self.input_manager.gamepad_hotplug,
|
||||
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'
|
||||
@@ -219,12 +223,12 @@ class ExeSettingsDialog(
|
||||
self.main_layout.addWidget(self.hints_widget)
|
||||
|
||||
if self.input_manager:
|
||||
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)
|
||||
)
|
||||
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)
|
||||
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
|
||||
self.init_virtual_keyboard()
|
||||
|
||||
@@ -267,6 +267,7 @@ 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
|
||||
@@ -612,7 +613,7 @@ class GamescopeSettingsMixin:
|
||||
selector_layout.addWidget(self.gamescope_category_combo)
|
||||
|
||||
self.gamescope_category_stack = QStackedWidget()
|
||||
self.gamescope_category_stack.setStyleSheet("background: transparent;")
|
||||
self.gamescope_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
selector_layout.addWidget(self.gamescope_category_stack)
|
||||
|
||||
toggle_lookup = dict(GAMESCOPE_TOGGLE_SPECS)
|
||||
@@ -1027,6 +1028,35 @@ class GamescopeSettingsMixin:
|
||||
def _filter_gamescope_settings(self, search_text):
|
||||
"""Filter Gamescope groups based on search text."""
|
||||
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)
|
||||
@@ -1034,8 +1064,34 @@ class GamescopeSettingsMixin:
|
||||
if not search_text:
|
||||
group_box.setVisible(True)
|
||||
continue
|
||||
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)
|
||||
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
|
||||
@@ -385,6 +385,7 @@ 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
|
||||
@@ -567,7 +568,7 @@ class MangoHudSettingsMixin:
|
||||
selector_layout.addWidget(self.mangohud_category_combo)
|
||||
|
||||
self.mangohud_category_stack = QStackedWidget()
|
||||
self.mangohud_category_stack.setStyleSheet("background: transparent;")
|
||||
self.mangohud_category_stack.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
selector_layout.addWidget(self.mangohud_category_stack)
|
||||
|
||||
toggle_lookup = dict(MANGOHUD_TOGGLE_SPECS)
|
||||
@@ -1231,9 +1232,40 @@ class MangoHudSettingsMixin:
|
||||
|
||||
def _filter_mangohud_settings(self, search_text):
|
||||
"""Filter MangoHud groups based on search text."""
|
||||
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
|
||||
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()
|
||||
for group_box in self.mangohud_tab.findChildren(QGroupBox):
|
||||
if not config_visible:
|
||||
group_box.setVisible(group_box is self.mangohud_actions_group)
|
||||
@@ -1241,11 +1273,38 @@ class MangoHudSettingsMixin:
|
||||
if not search_text:
|
||||
group_box.setVisible(True)
|
||||
continue
|
||||
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)
|
||||
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
|
||||
|
||||
def _get_default_mangohud_config(self) -> str | None:
|
||||
"""Read DEFAULT_MANGOHUD_CONFIG from scripts/var."""
|
||||
|
||||
@@ -356,6 +356,28 @@ 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,6 +31,20 @@ 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)
|
||||
@@ -48,10 +62,6 @@ 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 = []
|
||||
|
||||
@@ -63,23 +73,25 @@ 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(entry_path, exist_ok=True)
|
||||
os.makedirs(target_path, exist_ok=True)
|
||||
if entry.mode:
|
||||
try:
|
||||
os.chmod(entry_path, entry.mode)
|
||||
os.chmod(target_path, entry.mode)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
if entry.mtime:
|
||||
deferred_times.append((entry_path, entry.mtime))
|
||||
deferred_times.append((target_path, entry.mtime))
|
||||
|
||||
elif entry.isfile:
|
||||
parent_dir = os.path.dirname(entry_path)
|
||||
parent_dir = os.path.dirname(target_path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
with open(entry_path, 'wb') as f:
|
||||
with open(target_path, 'wb') as f:
|
||||
for block in entry.get_blocks():
|
||||
if self._should_stop():
|
||||
return
|
||||
@@ -87,28 +99,27 @@ class ExtractionThread(QThread):
|
||||
|
||||
if entry.mode:
|
||||
try:
|
||||
os.chmod(entry_path, entry.mode)
|
||||
os.chmod(target_path, entry.mode)
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
if entry.mtime:
|
||||
try:
|
||||
os.utime(entry_path, (entry.mtime, entry.mtime))
|
||||
os.utime(target_path, (entry.mtime, entry.mtime))
|
||||
except (OSError, PermissionError):
|
||||
pass
|
||||
|
||||
elif entry.issym:
|
||||
parent_dir = os.path.dirname(entry_path)
|
||||
parent_dir = os.path.dirname(target_path)
|
||||
if parent_dir:
|
||||
os.makedirs(parent_dir, exist_ok=True)
|
||||
|
||||
if os.path.lexists(entry_path):
|
||||
os.remove(entry_path)
|
||||
if os.path.lexists(target_path):
|
||||
os.remove(target_path)
|
||||
|
||||
link_path = entry.linkpath
|
||||
if link_path is not None:
|
||||
try:
|
||||
os.symlink(link_path, entry_path)
|
||||
os.symlink(link_path, target_path)
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
|
||||
@@ -152,8 +163,7 @@ class ExtractionThread(QThread):
|
||||
self.finished.emit(self.archive_path, True)
|
||||
|
||||
finally:
|
||||
os.chdir(original_dir)
|
||||
os.umask(old_umask)
|
||||
self.eta.emit(0)
|
||||
|
||||
except Exception as e:
|
||||
if not self._should_stop():
|
||||
|
||||
@@ -13,6 +13,33 @@ 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.
|
||||
@@ -64,7 +91,18 @@ class WineLoadingThread(QThread):
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
json_url = "https://git.linux-gaming.ru/Boria138/PortProton-Wine-Metadata/raw/branch/main/wine_metadata_all.json"
|
||||
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
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as tmp:
|
||||
temp_path = tmp.name
|
||||
@@ -90,3 +128,17 @@ 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:
|
||||
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)
|
||||
)
|
||||
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)
|
||||
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
|
||||
|
||||
def setup_ui(self):
|
||||
|
||||
@@ -262,9 +262,9 @@ class GameCard(QFrame):
|
||||
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" 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.steam_visible = str(game_source).lower() == "steam"
|
||||
self.gog_visible = str(game_source).lower() == "gog"
|
||||
self.portproton_visible = str(game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
config_name = "GAME_CARD_LIST" if self.list_layout else "GAME_CARD_GRID"
|
||||
@@ -350,8 +350,6 @@ class GameCard(QFrame):
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
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(
|
||||
@@ -360,8 +358,6 @@ class GameCard(QFrame):
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
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(
|
||||
@@ -370,8 +366,6 @@ class GameCard(QFrame):
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
if self.economy_mode:
|
||||
self.portprotonLabel.setVisible(False)
|
||||
|
||||
if self.ppdb_visible:
|
||||
self.ppdbLabel = ClickableLabel(
|
||||
@@ -597,7 +591,7 @@ class GameCard(QFrame):
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
if not is_visible or ribbon is None or hidden_badges:
|
||||
if not is_visible or ribbon is None:
|
||||
if ribbon is not None:
|
||||
ribbon.setVisible(False)
|
||||
continue
|
||||
@@ -751,9 +745,9 @@ class GameCard(QFrame):
|
||||
|
||||
self.display_filter = display_filter
|
||||
self.economy_mode = ui_config.get_economy_mode()
|
||||
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.steam_visible = str(self.game_source).lower() == "steam"
|
||||
self.gog_visible = str(self.game_source).lower() == "gog"
|
||||
self.portproton_visible = str(self.game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
@@ -761,9 +755,9 @@ class GameCard(QFrame):
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
|
||||
try:
|
||||
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.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
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,6 +6,7 @@ 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
|
||||
@@ -25,6 +26,7 @@ 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
|
||||
@@ -49,6 +51,7 @@ 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"
|
||||
@@ -76,19 +79,59 @@ 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 response.json().get("assets", []) if item.get("name") == asset_name),
|
||||
(item for item in release.get("assets", []) if item.get("name") == asset_name),
|
||||
None,
|
||||
)
|
||||
if not asset:
|
||||
raise OSError(f"gogdl release asset not found: {asset_name}")
|
||||
return self._download_gogdl_asset(asset)
|
||||
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
|
||||
|
||||
def _download_gogdl_asset(self, asset: dict) -> str:
|
||||
target = self.data_dir / "bin/gogdl"
|
||||
@@ -452,6 +495,8 @@ 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,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import struct
|
||||
import io
|
||||
import pefile
|
||||
@@ -15,6 +16,36 @@ 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(
|
||||
@@ -44,6 +75,10 @@ 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
|
||||
|
||||
@@ -55,14 +90,18 @@ 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 Exception as e:
|
||||
logger.error("Error generating thumbnail: %s", e)
|
||||
except IconExtractorError as error:
|
||||
logger.warning("Icon extraction failed: %s", error)
|
||||
return False
|
||||
except Exception as error:
|
||||
logger.error("Error generating thumbnail: %s", error)
|
||||
return False
|
||||
|
||||
|
||||
class IconExtractorError(Exception):
|
||||
"""Base exception for icon extraction errors."""
|
||||
|
||||
|
||||
class IconExtractor:
|
||||
"""Extracts icons from NE and PE files."""
|
||||
|
||||
@@ -72,7 +111,10 @@ 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."""
|
||||
"""Extract the best quality icon and return it as image data.
|
||||
|
||||
Raises IconExtractorError when the source cannot be read or parsed.
|
||||
"""
|
||||
try:
|
||||
with open(self.file_path, "rb") as f:
|
||||
if not self._parse_file(f):
|
||||
@@ -86,9 +128,10 @@ class IconExtractor:
|
||||
if data is None:
|
||||
return None
|
||||
return self._icon_data_to_image(data)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to extract icon from %s: %s", self.file_path, e)
|
||||
return None
|
||||
except Exception as error:
|
||||
raise IconExtractorError(
|
||||
f"Failed to extract icon from {self.file_path}: {error}"
|
||||
) from error
|
||||
|
||||
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
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.icon_extractor import generate_thumbnail
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -396,15 +396,16 @@ 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("#333333"))
|
||||
placeholder_pixmap.fill(QColor(theme.color_placeholder_bg))
|
||||
painter = QPainter(placeholder_pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.setPen(QPen(QColor(theme.color_white)))
|
||||
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
callback(placeholder_pixmap)
|
||||
@@ -432,9 +433,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("#333333"))
|
||||
pixmap.fill(QColor(theme.color_placeholder_bg))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.setPen(QPen(QColor(theme.color_white)))
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
return pixmap
|
||||
@@ -690,9 +691,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("#333333"))
|
||||
placeholder_pixmap.fill(QColor(self.theme.color_placeholder_bg))
|
||||
painter = QPainter(placeholder_pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.setPen(QPen(QColor(self.theme.color_white)))
|
||||
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
self.imageLabel.setPixmap(placeholder_pixmap)
|
||||
|
||||
@@ -0,0 +1,603 @@
|
||||
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 GameCard
|
||||
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(GameCard)
|
||||
if card.isVisible() and card.isEnabled()
|
||||
]
|
||||
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 _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()
|
||||
@@ -0,0 +1,373 @@
|
||||
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")
|
||||
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
|
||||
@@ -0,0 +1,131 @@
|
||||
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,
|
||||
}
|
||||
@@ -0,0 +1,711 @@
|
||||
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}")
|
||||
@@ -0,0 +1,537 @@
|
||||
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 GameCard
|
||||
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, GameCard):
|
||||
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(GameCard)
|
||||
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, GameCard) 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
|
||||
@@ -0,0 +1,317 @@
|
||||
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}")
|
||||
@@ -0,0 +1,611 @@
|
||||
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 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, GameCard | 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(GameCard):
|
||||
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._parent.goBackDetailPage(self._parent.currentDetailPage)
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
|
||||
class InputMixin:
|
||||
if TYPE_CHECKING:
|
||||
def __getattr__(self, name: str) -> Any: ...
|
||||
@@ -0,0 +1,404 @@
|
||||
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)
|
||||
@@ -0,0 +1,514 @@
|
||||
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])
|
||||
@@ -0,0 +1,496 @@
|
||||
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
|
||||
@@ -2063,6 +2063,9 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
|
||||
@@ -2087,6 +2090,9 @@ msgstr ""
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2120,6 +2126,9 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2353,6 +2362,9 @@ msgstr ""
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
@@ -2365,6 +2377,13 @@ msgstr ""
|
||||
msgid "Link:"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to delete theme."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-07-28 16:30+0000\n"
|
||||
"PO-Revision-Date: 2026-08-16 16:24+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.7.1\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -2073,7 +2073,6 @@ msgstr "GOG-Anmeldung fehlgeschlagen: {0}"
|
||||
msgid "GOG account connected"
|
||||
msgstr "GOG-Konto verknüpft"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Log out"
|
||||
msgstr "Abmelden"
|
||||
|
||||
@@ -2161,6 +2160,9 @@ msgstr "Alle"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Anzeigefilter:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Nur installierte"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detailliert"
|
||||
|
||||
@@ -2187,6 +2189,9 @@ msgstr "Accounts"
|
||||
msgid "Auto"
|
||||
msgstr "Auto"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "Account"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Vereinfacht"
|
||||
|
||||
@@ -2220,6 +2225,9 @@ msgstr "Minimiert im Tray starten"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Tab „Automatische Installation“ ausblenden"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
|
||||
@@ -2456,6 +2464,10 @@ msgstr "Theme auswählen:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Theme anwenden"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Theme auswählen:"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Kein Link"
|
||||
|
||||
@@ -2468,6 +2480,14 @@ msgstr "Autor:"
|
||||
msgid "Link:"
|
||||
msgstr "Link:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Theme auswählen:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Laden der Themes fehlgeschlagen"
|
||||
|
||||
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-07-25 17:07+0000\n"
|
||||
"PO-Revision-Date: 2026-08-11 19:26+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.6.1\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -2163,6 +2163,9 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Solo instalados"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
|
||||
@@ -2189,6 +2192,9 @@ msgstr ""
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2222,6 +2228,9 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2455,6 +2464,10 @@ msgstr ""
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Borrar el archivo"
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
@@ -2467,6 +2480,14 @@ 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-07-28 11:58+0500\n"
|
||||
"POT-Creation-Date: 2026-08-21 21:05+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"
|
||||
@@ -2059,6 +2059,9 @@ msgstr ""
|
||||
msgid "Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr ""
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr ""
|
||||
|
||||
@@ -2083,6 +2086,9 @@ msgstr ""
|
||||
msgid "Auto"
|
||||
msgstr ""
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr ""
|
||||
|
||||
@@ -2116,6 +2122,9 @@ msgstr ""
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2350,6 +2359,9 @@ msgstr ""
|
||||
msgid "Apply Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Theme"
|
||||
msgstr ""
|
||||
|
||||
msgid "No link"
|
||||
msgstr ""
|
||||
|
||||
@@ -2362,6 +2374,13 @@ 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: EMAIL@ADDRESS\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"PO-Revision-Date: 2026-08-20 21:25+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.6.1\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -153,9 +153,8 @@ msgstr "Excluir do PortProton"
|
||||
msgid "Launch Game"
|
||||
msgstr "Iniciar Jogo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repair"
|
||||
msgstr "Pareado"
|
||||
msgstr "Reparar"
|
||||
|
||||
msgid "Remove from Desktop"
|
||||
msgstr "Remover da Área de Trabalho"
|
||||
@@ -173,7 +172,7 @@ msgid "Delete"
|
||||
msgstr "Excluir"
|
||||
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir Pasta do Jogo"
|
||||
@@ -372,9 +371,8 @@ msgstr "Sistema"
|
||||
msgid "Themes"
|
||||
msgstr "Temas"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Downloads"
|
||||
msgstr "Baixar"
|
||||
msgstr "Downloads"
|
||||
|
||||
msgid "Warning"
|
||||
msgstr "Aviso"
|
||||
@@ -960,14 +958,15 @@ msgid "No cover selected"
|
||||
msgstr "Nenhuma capa selecionada"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr ""
|
||||
msgstr "Relatório de compatibilidade"
|
||||
|
||||
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 "Mouse"
|
||||
msgstr "Fechar"
|
||||
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
@@ -2023,131 +2022,114 @@ msgstr "Desmontar"
|
||||
msgid "Set output"
|
||||
msgstr "Definir saída"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Queued"
|
||||
msgstr "Usado"
|
||||
msgstr "Na fila"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Completed"
|
||||
msgstr "Atualização concluída"
|
||||
msgstr "Concluído"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Clear List"
|
||||
msgstr "Limpar Tudo"
|
||||
msgstr "Limpar lista"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Now Downloading"
|
||||
msgstr "Baixando"
|
||||
msgstr "Baixando agora"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Waiting…"
|
||||
msgstr "Aviso"
|
||||
msgstr "Aguardando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disk: "
|
||||
msgstr "Link:"
|
||||
msgstr "Disco: "
|
||||
|
||||
#, fuzzy
|
||||
msgid "Game Title"
|
||||
msgstr "Largura do jogo"
|
||||
msgstr "Título do jogo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Started at"
|
||||
msgstr "Estado"
|
||||
msgstr "Iniciado em"
|
||||
|
||||
msgid "Type"
|
||||
msgstr "Tipo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Store"
|
||||
msgstr "Armazenamento"
|
||||
msgstr "Loja"
|
||||
|
||||
msgid "Copy the link to clipboard"
|
||||
msgstr ""
|
||||
msgstr "Copiar o link para a área de transferência"
|
||||
|
||||
msgid "Failed to log out of GOG account"
|
||||
msgstr ""
|
||||
msgstr "Falha ao sair da conta GOG"
|
||||
|
||||
msgid "Refreshing…"
|
||||
msgstr "Atualizando…"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Unknown error"
|
||||
msgstr "Jogo Desconhecido"
|
||||
msgstr "Erro desconhecido"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "GOG login failed: {0}"
|
||||
msgstr "Arquivo: {0}"
|
||||
msgstr "Falha no login da GOG: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "GOG account connected"
|
||||
msgstr "Conectado"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Log out"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
msgid "Open login page"
|
||||
msgstr ""
|
||||
msgstr "Abrir página de login"
|
||||
|
||||
msgid "GOG account not connected"
|
||||
msgstr ""
|
||||
msgstr "Conta GOG não conectada"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing GOG library… {0}/{1}"
|
||||
msgstr ""
|
||||
msgstr "Atualizando biblioteca da GOG… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Falha ao iniciar o jogo: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
msgstr "Selecionar pasta de instalação"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
msgstr "Outra operação da GOG já está em execução"
|
||||
|
||||
msgid "GOG installation not found"
|
||||
msgstr ""
|
||||
msgstr "Instalação da GOG não encontrada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select GOG game folder"
|
||||
msgstr "Excluir Pasta"
|
||||
msgstr "Selecionar pasta do jogo da GOG"
|
||||
|
||||
msgid "Selected folder is not this GOG game"
|
||||
msgstr ""
|
||||
msgstr "A pasta selecionada não corresponde a este jogo da GOG"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Delete '{0}' and all files in its installation folder?"
|
||||
msgstr ""
|
||||
msgstr "Excluir '{0}' e todos os arquivos da sua pasta de instalação?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starting"
|
||||
msgstr "Configuração"
|
||||
msgstr "Iniciando"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Download: {0} MiB/s"
|
||||
msgstr "Baixando… {0}%"
|
||||
msgstr "Baixando: {0} MiB/s"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Disk: {0} MiB/s"
|
||||
msgstr ""
|
||||
msgstr "Disco: {0} MiB/s"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "ETA: {0}"
|
||||
msgstr ", ETA: {}s"
|
||||
msgstr "Tempo estimado: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed"
|
||||
msgstr "Detalhado"
|
||||
msgstr "Falhou"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Repaired"
|
||||
msgstr "Pareado"
|
||||
msgstr "Reparado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Download process failed"
|
||||
msgstr "Download em Andamento"
|
||||
msgstr "O processo de download falhou"
|
||||
|
||||
msgid "Quick Launch"
|
||||
msgstr "Início Rápido"
|
||||
@@ -2176,6 +2158,9 @@ msgstr "Todos"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Filtro de Exibição:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Apenas instalados"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detalhado"
|
||||
|
||||
@@ -2196,13 +2181,15 @@ msgstr ""
|
||||
msgid "PortProton Settings"
|
||||
msgstr "Configurações do PortProton"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Accounts"
|
||||
msgstr "Montar"
|
||||
msgstr "Contas"
|
||||
|
||||
msgid "Auto"
|
||||
msgstr "Automático"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "Conta"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Resumido"
|
||||
|
||||
@@ -2236,11 +2223,14 @@ 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 "Enable UI Sounds"
|
||||
msgstr "Ativar sons da interface"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
msgstr "Mostrar relatório de compatibilidade após a falha do jogo"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Desativar download de runtime"
|
||||
@@ -2263,9 +2253,8 @@ msgstr "Baixar automaticamente o PPDB de"
|
||||
msgid "Auto update AppImage"
|
||||
msgstr "Atualização automática do AppImage"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Integrate AppImage"
|
||||
msgstr "Atualização automática do AppImage"
|
||||
msgstr "Integrar AppImage"
|
||||
|
||||
msgid "Force system DPI for Wine"
|
||||
msgstr "Forçar DPI do sistema para o Wine"
|
||||
@@ -2337,11 +2326,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 ""
|
||||
msgstr "AppImage integrado em: {path}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Failed to integrate AppImage: {error}"
|
||||
msgstr "Falha ao renomear o arquivo: {error}"
|
||||
msgstr "Falha ao integrar o AppImage: {error}"
|
||||
|
||||
msgid "Failed to update xdg-autostart entry."
|
||||
msgstr "Falha ao atualizar a entrada xdg-autostart."
|
||||
@@ -2405,7 +2394,7 @@ msgid "Output"
|
||||
msgstr "Saída"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Encerrar sessão"
|
||||
msgstr "Sair"
|
||||
|
||||
msgid "Reboot"
|
||||
msgstr "Reiniciar"
|
||||
@@ -2471,6 +2460,10 @@ msgstr "Selecionar Tema:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Aplicar Tema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Selecionar Tema:"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Sem link"
|
||||
|
||||
@@ -2483,6 +2476,14 @@ msgstr "Autor:"
|
||||
msgid "Link:"
|
||||
msgstr "Link:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Selecionar Tema:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Falha ao carregar temas"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefixo:"
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-07-24 20:10+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"PO-Revision-Date: 2026-08-17 06:15+0000\n"
|
||||
"Last-Translator: Boria138 <boria138@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.6.1\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"Generated-By: Babel 2.18.0\n"
|
||||
|
||||
msgid "Stop Game"
|
||||
@@ -958,14 +958,15 @@ 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 "Открыть"
|
||||
@@ -2152,6 +2153,9 @@ msgstr "Все"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Фильтр:"
|
||||
|
||||
msgid "Only Installed"
|
||||
msgstr "Только Установленные"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Детальный"
|
||||
|
||||
@@ -2178,6 +2182,9 @@ msgstr "Аккаунты"
|
||||
msgid "Auto"
|
||||
msgstr "Авто"
|
||||
|
||||
msgid "Account"
|
||||
msgstr "Аккаунт"
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Упрощённый"
|
||||
|
||||
@@ -2211,11 +2218,14 @@ msgstr "Запускать свёрнутым"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Скрыть вкладку \"Автоустановка\""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Скрыть подсказки управления"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Включить звуки интерфейса"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr ""
|
||||
msgstr "Показать отчет о совместимости после сбоя игры"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Отключить скачивание контейнера"
|
||||
@@ -2444,6 +2454,10 @@ msgstr "Выбрать тему:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Применить тему"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Выбрать тему:"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Нет ссылки"
|
||||
|
||||
@@ -2456,6 +2470,14 @@ msgstr "Автор:"
|
||||
msgid "Link:"
|
||||
msgstr "Ссылка:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Выбрать тему:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Не удалось загрузить темы"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Префикс:"
|
||||
|
||||
|
||||
@@ -2115,6 +2115,10 @@ msgstr "全部"
|
||||
msgid "Display Filter:"
|
||||
msgstr "显示筛选:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Only Installed"
|
||||
msgstr "已安装"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "详细"
|
||||
|
||||
@@ -2142,6 +2146,9 @@ msgstr "挂载"
|
||||
msgid "Auto"
|
||||
msgstr "自动"
|
||||
|
||||
msgid "Account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "简要"
|
||||
|
||||
@@ -2175,6 +2182,9 @@ msgstr "启动时最小化到托盘"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "隐藏自动安装标签页"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
@@ -2408,6 +2418,10 @@ msgstr "选择主题:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "应用主题"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "选择主题:"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "无链接"
|
||||
|
||||
@@ -2420,6 +2434,14 @@ msgstr "作者:"
|
||||
msgid "Link:"
|
||||
msgstr "链接:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "选择主题:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "加载主题失败"
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "前缀:"
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import signal
|
||||
import shlex
|
||||
import subprocess
|
||||
import tempfile
|
||||
import psutil
|
||||
import re
|
||||
import time
|
||||
from queue import Empty, Queue
|
||||
@@ -77,7 +76,7 @@ from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
|
||||
QMessageBox, QApplication, QPushButton, QCheckBox)
|
||||
from PySide6.QtCore import Qt, QEvent, QUrl, Signal, QTimer, Slot, QProcess, QFileSystemWatcher, QObject
|
||||
from PySide6.QtCore import Qt, QEvent, QEventLoop, QUrl, Signal, QTimer, Slot, QProcess, QThread, QFileSystemWatcher, QObject
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QHideEvent, QShowEvent, QGuiApplication
|
||||
from typing import cast
|
||||
from collections.abc import Callable
|
||||
@@ -91,6 +90,7 @@ logger = get_logger(__name__)
|
||||
DISC_IMAGE_EXTENSIONS = (".iso", ".mdf", ".nrg")
|
||||
ALT_BIARCH_REPO = "x86_64-i586"
|
||||
ALT_BIARCH_URL = "https://www.altlinux.org/Biarch"
|
||||
GAME_LAUNCH_MARKER = "PORTPROTONQT_GAME_LAUNCH_STARTED"
|
||||
ALT_I586_BASE_PACKAGES = (
|
||||
"glibc-nss",
|
||||
"glibc-gconv-modules",
|
||||
@@ -323,6 +323,7 @@ class MainWindow(
|
||||
self.launch_exe = launch_exe # Store launch_exe path
|
||||
self._pending_log_exe: str | None = None
|
||||
self.appimageUpdateWorker: AppImageUpdateWorker | None = None
|
||||
self.gogdlUpdateWorker: QThread | None = None
|
||||
self.compatibility_report_ready.connect(self._show_compatibility_report)
|
||||
|
||||
self.game_library_manager = GameLibraryManager(self, self.theme, None)
|
||||
@@ -846,15 +847,10 @@ class MainWindow(
|
||||
line_text = line.strip()
|
||||
line_lower = line.lower()
|
||||
status = percent = None
|
||||
launch_started = False
|
||||
launch_started = line_text == GAME_LAUNCH_MARKER
|
||||
progress_match = re.fullmatch(r'([0-9]*\.?[0-9]+)%', line_text)
|
||||
if progress_match:
|
||||
percent = float(progress_match.group(1))
|
||||
elif "update prefix log:" in line_lower \
|
||||
or "the prefix has been updated" in line_lower \
|
||||
or "log wine:" in line_lower \
|
||||
or "log from runtime and wine:" in line_lower:
|
||||
launch_started = True
|
||||
elif "download " in line_lower and " from " in line_lower:
|
||||
filename_match = re.search(r'download\s+(.+?)\s+from\s+', line, re.IGNORECASE)
|
||||
filename = filename_match.group(1) if filename_match else line
|
||||
@@ -948,12 +944,11 @@ class MainWindow(
|
||||
self.games = []
|
||||
|
||||
def start_loading():
|
||||
if display_filter == "steam":
|
||||
self._load_steam_games_async(lambda games: self.games_loaded.emit(games))
|
||||
elif display_filter == "gog":
|
||||
self._load_gog_games_async(lambda games: self.games_loaded.emit(games))
|
||||
elif display_filter == "portproton":
|
||||
self._load_portproton_games_async(lambda games: self.games_loaded.emit(games))
|
||||
source_loader = getattr(
|
||||
self, f"_load_{display_filter}_games_async", None
|
||||
)
|
||||
if callable(source_loader):
|
||||
source_loader(lambda games: self.games_loaded.emit(games))
|
||||
elif display_filter == "favorites":
|
||||
def on_all_games_favorites(portproton_games, steam_games, gog_games):
|
||||
games = [game for game in portproton_games + steam_games + gog_games if game[0] in favorites]
|
||||
@@ -1038,6 +1033,7 @@ class MainWindow(
|
||||
def _load_gog_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
api = getattr(self, "gog_api", None) or GOGAPI()
|
||||
installed = api.load_installed()
|
||||
only_installed = game_config.get_only_installed()
|
||||
library = [game for game in api.load_library() if game.get("app_id")]
|
||||
if self._upgrade_legacy_gog_library(api, library, callback):
|
||||
return
|
||||
@@ -1045,27 +1041,38 @@ class MainWindow(
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
from portprotonqt.time_utils import get_statistics_path
|
||||
statistics_file = get_statistics_path()
|
||||
processed_count = 0
|
||||
|
||||
def on_game_info(game: dict, steam_info: dict) -> None:
|
||||
nonlocal processed_count
|
||||
app_id = str(game.get("app_id", ""))
|
||||
action = "launch" if api.is_game_installed(app_id, installed) else "install"
|
||||
games.append((
|
||||
str(game.get("title", app_id)),
|
||||
str(game.get("description", "")), str(game.get("cover", "")),
|
||||
app_id,
|
||||
steam_info.get("controller_support", ""),
|
||||
f"gog://{action}/{app_id}",
|
||||
_("Never"), format_playtime(0),
|
||||
steam_info.get("protondb_tier", ""),
|
||||
steam_info.get("anticheat_status", ""),
|
||||
0, 0,
|
||||
"gog",
|
||||
steam_info.get("anticheat_slug", ""),
|
||||
steam_info.get("ppdb_id", ""), steam_info.get("ppdb_rating", ""),
|
||||
steam_info.get("appid", ""),
|
||||
))
|
||||
is_installed = api.is_game_installed(app_id, installed)
|
||||
if not only_installed or is_installed:
|
||||
action = "launch" if is_installed else "install"
|
||||
target = api.get_launch_target(app_id) if is_installed else None
|
||||
playtime_seconds = get_playtime_for_exe(
|
||||
statistics_file, target, exact_path=True
|
||||
) if target else 0
|
||||
playtime_seconds = playtime_seconds or 0
|
||||
games.append((
|
||||
str(game.get("title", app_id)),
|
||||
str(game.get("description", "")), str(game.get("cover", "")),
|
||||
app_id,
|
||||
steam_info.get("controller_support", ""),
|
||||
f"gog://{action}/{app_id}",
|
||||
get_last_launch(f"gog-{app_id}") if target else _("Never"),
|
||||
format_playtime(playtime_seconds),
|
||||
steam_info.get("protondb_tier", ""),
|
||||
steam_info.get("anticheat_status", ""),
|
||||
get_last_launch_timestamp(f"gog-{app_id}") if target else 0,
|
||||
playtime_seconds,
|
||||
"gog",
|
||||
steam_info.get("anticheat_slug", ""),
|
||||
steam_info.get("ppdb_id", ""), steam_info.get("ppdb_rating", ""),
|
||||
steam_info.get("appid", ""),
|
||||
))
|
||||
processed_count += 1
|
||||
if processed_count == len(library):
|
||||
callback(games)
|
||||
@@ -1471,7 +1478,7 @@ class MainWindow(
|
||||
def on_pixmap(pixmap):
|
||||
if pixmap.isNull():
|
||||
if callback:
|
||||
callback([QColor("#1a1a1a")] * num_colors)
|
||||
callback([QColor(self.theme.color_default_fallback)] * num_colors)
|
||||
return
|
||||
|
||||
image = pixmap.toImage()
|
||||
@@ -1507,7 +1514,7 @@ class MainWindow(
|
||||
"""Extract color palette from a QPixmap directly."""
|
||||
if pixmap.isNull():
|
||||
if callback:
|
||||
callback([QColor("#1a1a1a")] * num_colors)
|
||||
callback([QColor(self.theme.color_default_fallback)] * num_colors)
|
||||
return
|
||||
|
||||
image = pixmap.toImage()
|
||||
@@ -1919,35 +1926,17 @@ class MainWindow(
|
||||
parent = parent.parent()
|
||||
|
||||
|
||||
def is_target_exe_running(self):
|
||||
"""Check if process named self.target_exe is running via psutil."""
|
||||
if not self.target_exe:
|
||||
return False
|
||||
for proc in psutil.process_iter(attrs=["name"]):
|
||||
if proc.info["name"].lower() == self.target_exe.lower():
|
||||
return True
|
||||
return False
|
||||
|
||||
def checkTargetExe(self):
|
||||
"""
|
||||
Check if game is running.
|
||||
If game process (target_exe) detected - set flag and update button.
|
||||
If game completed - reset flag, update button and stop timer.
|
||||
"""
|
||||
target_running = self.is_target_exe_running()
|
||||
"""Update launch state from the marker and PortProton process."""
|
||||
child_running = self._has_running_game_process()
|
||||
dependency_active = self._drain_launch_output_progress()
|
||||
|
||||
if dependency_active:
|
||||
# Dependencies are downloading/extracting - update button with progress
|
||||
self._set_running_button_progress()
|
||||
elif target_running or (self.game_launch_started and child_running):
|
||||
self.game_launch_started = True
|
||||
# Game started - set flag, update button to "Stop"
|
||||
self._set_running_button_stop()
|
||||
elif child_running:
|
||||
self._set_running_button_stop()
|
||||
elif not child_running:
|
||||
else:
|
||||
# Game completed - reset flag, reset button and stop timer
|
||||
self._analyze_short_launch()
|
||||
self.resetPlayButton()
|
||||
@@ -2025,10 +2014,12 @@ class MainWindow(
|
||||
elapsed = int((datetime.now() - start_time).total_seconds())
|
||||
if elapsed > 0:
|
||||
from portprotonqt.time_utils import save_playtime
|
||||
save_playtime(start_exe, elapsed)
|
||||
exact_path = getattr(self, "game_start_exact_path", False)
|
||||
save_playtime(start_exe, elapsed, exact_path=exact_path)
|
||||
self._update_playtime_after_exit(start_exe, elapsed)
|
||||
self.game_start_time = None
|
||||
self.game_start_exe = None
|
||||
self.game_start_exact_path = False
|
||||
self.game_launch_monotonic = None
|
||||
self.game_stopped_by_user = False
|
||||
|
||||
@@ -2049,7 +2040,7 @@ class MainWindow(
|
||||
updated_games = []
|
||||
changed = False
|
||||
for game in games:
|
||||
game_exe = extract_exec_target_path(game[5]) if len(game) > 5 else ""
|
||||
game_exe = self._get_game_executable(game[5]) if len(game) > 5 else ""
|
||||
if (
|
||||
len(game) <= 11
|
||||
or not game_exe
|
||||
@@ -2078,7 +2069,7 @@ class MainWindow(
|
||||
)
|
||||
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
card_exe = extract_exec_target_path(card.exec_line) or ""
|
||||
card_exe = self._get_game_executable(card.exec_line)
|
||||
if os.path.normpath(card_exe) != target_path:
|
||||
continue
|
||||
card.playtime_seconds = (card.playtime_seconds or 0) + additional_seconds
|
||||
@@ -2093,7 +2084,7 @@ class MainWindow(
|
||||
updated_games = []
|
||||
changed = False
|
||||
for game in games:
|
||||
game_exe = extract_exec_target_path(game[5]) if len(game) > 5 else ""
|
||||
game_exe = self._get_game_executable(game[5]) if len(game) > 5 else ""
|
||||
if (
|
||||
len(game) <= 10
|
||||
or not game_exe
|
||||
@@ -2122,7 +2113,7 @@ class MainWindow(
|
||||
)
|
||||
|
||||
for card in self.game_library_manager.game_card_cache.values():
|
||||
card_exe = extract_exec_target_path(card.exec_line) or ""
|
||||
card_exe = self._get_game_executable(card.exec_line)
|
||||
if os.path.normpath(card_exe) != target_path:
|
||||
continue
|
||||
card.last_launch = format_last_launch(launch_time)
|
||||
@@ -2131,6 +2122,13 @@ class MainWindow(
|
||||
self.game_library_manager.update_game_grid(focus_first_card=False)
|
||||
self._refresh_current_detail_time()
|
||||
|
||||
def _get_game_executable(self, exec_line: str) -> str:
|
||||
"""Resolve a library launch entry to its tracked executable."""
|
||||
if exec_line.startswith("gog://launch/"):
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
return str(self.gog_api.get_launch_target(app_id) or "")
|
||||
return extract_exec_target_path(exec_line) or ""
|
||||
|
||||
def _has_running_game_process(self) -> bool:
|
||||
return any(proc.poll() is None for proc in self.game_processes)
|
||||
|
||||
@@ -2309,13 +2307,21 @@ class MainWindow(
|
||||
]
|
||||
|
||||
def _get_installed_alt_package_names(self) -> list[str]:
|
||||
result = subprocess.run(
|
||||
["rpm", "-qa", "--qf", "%{NAME}\n"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
with ThreadPoolExecutor(max_workers=1) as executor:
|
||||
future = executor.submit(
|
||||
subprocess.run,
|
||||
["rpm", "-qa", "--qf", "%{NAME}\n"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
check=False,
|
||||
)
|
||||
event_flags = QEventLoop.ProcessEventsFlag.ExcludeUserInputEvents
|
||||
QApplication.processEvents(event_flags)
|
||||
while not future.done():
|
||||
QApplication.processEvents(event_flags)
|
||||
time.sleep(0.01)
|
||||
result = future.result()
|
||||
if result.returncode != 0:
|
||||
raise subprocess.SubprocessError(result.stderr.strip())
|
||||
|
||||
@@ -2442,7 +2448,9 @@ class MainWindow(
|
||||
if game:
|
||||
self._install_gog_game(game)
|
||||
|
||||
def _launch_gog_game(self, app_id: str, button=None) -> None:
|
||||
def _launch_gog_game(
|
||||
self, app_id: str, button=None, play_sound: bool = True
|
||||
) -> None:
|
||||
self.gog_api.ensure_launch_parameters(app_id)
|
||||
discovered_path = self.gog_api.get_installed_path(app_id)
|
||||
install_path = str(discovered_path) if discovered_path else ""
|
||||
@@ -2477,10 +2485,19 @@ class MainWindow(
|
||||
logger.error("Failed to launch GOG game %s: %s", app_id, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
SoundManager().play("game_launch")
|
||||
if play_sound:
|
||||
SoundManager().play("game_launch")
|
||||
self.game_processes.append(process)
|
||||
self.target_exe = target
|
||||
self.current_running_button = button
|
||||
launch_target = self.gog_api.get_launch_target(app_id)
|
||||
if launch_target:
|
||||
launch_time = datetime.now()
|
||||
self.game_start_time = launch_time
|
||||
self.game_start_exe = launch_target
|
||||
self.game_start_exact_path = True
|
||||
save_last_launch(f"gog-{app_id}", launch_time)
|
||||
self._update_last_launch_after_start(launch_target, launch_time)
|
||||
self._start_launch_output_reader(process)
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
@@ -2547,7 +2564,19 @@ class MainWindow(
|
||||
if not self.stop_running_game(update_button):
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to stop game"))
|
||||
else:
|
||||
if update_button:
|
||||
try:
|
||||
update_button.setText(_("Stop"))
|
||||
icon = self.theme_manager.get_icon("stop", as_path=True)
|
||||
update_button.setIcon(icon)
|
||||
except RuntimeError:
|
||||
update_button = None
|
||||
SoundManager().play("game_launch")
|
||||
if not self._check_alt_i586_dependencies_before_launch():
|
||||
if update_button:
|
||||
update_button.setText(_("Start"))
|
||||
icon = self.theme_manager.get_icon("play", as_path=True)
|
||||
update_button.setIcon(icon)
|
||||
return
|
||||
|
||||
# Save button reference for reset after game completion
|
||||
@@ -2575,9 +2604,8 @@ class MainWindow(
|
||||
errors="replace",
|
||||
bufsize=1,
|
||||
)
|
||||
SoundManager().play("game_launch")
|
||||
self.game_processes.append(process)
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
self.game_launch_monotonic = None
|
||||
self._start_launch_output_reader(process)
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
launch_time = datetime.now()
|
||||
@@ -2585,14 +2613,6 @@ class MainWindow(
|
||||
self.game_start_exe = file_to_check
|
||||
save_last_launch(exe_name, launch_time)
|
||||
self._update_last_launch_after_start(file_to_check, launch_time)
|
||||
if update_button:
|
||||
try:
|
||||
update_button.setText(_("Stop"))
|
||||
icon = self.theme_manager.get_icon("stop", as_path=True)
|
||||
update_button.setIcon(icon)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
# Reset dependency setup monitoring
|
||||
self.wine_download_seen = False
|
||||
self.wine_download_percent = 0.0
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
# Install Python source files
|
||||
install_data(
|
||||
['__init__.py',
|
||||
'app.py',
|
||||
'appimage_integration.py',
|
||||
'appimage_updater.py',
|
||||
'cli.py',
|
||||
@@ -12,13 +11,11 @@ install_data(
|
||||
'downloader.py',
|
||||
'compatibility_report.py',
|
||||
'game_card.py',
|
||||
'gamepad_backend.py',
|
||||
'game_library_manager.py',
|
||||
'gog_api.py',
|
||||
'howlongtobeat_api.py',
|
||||
'image_utils.py',
|
||||
'icon_extractor.py',
|
||||
'input_manager.py',
|
||||
'keyboard_layouts.py',
|
||||
'localization.py',
|
||||
'logger.py',
|
||||
@@ -39,6 +36,44 @@ install_data(
|
||||
install_dir: pythondir / meson.project_name(),
|
||||
)
|
||||
|
||||
install_data(
|
||||
configure_file(
|
||||
input: 'app.py',
|
||||
output: 'app.py',
|
||||
configuration: version_conf,
|
||||
),
|
||||
install_dir: pythondir / meson.project_name(),
|
||||
)
|
||||
|
||||
install_data(
|
||||
'native_gamepad.py',
|
||||
install_dir: pythondir / meson.project_name(),
|
||||
)
|
||||
|
||||
install_data(
|
||||
['input_manager/__init__.py',
|
||||
'input_manager/buttons.py',
|
||||
'input_manager/constants.py',
|
||||
'input_manager/dialog_modes.py',
|
||||
'input_manager/dpad.py',
|
||||
'input_manager/file_explorer.py',
|
||||
'input_manager/keyboard.py',
|
||||
'input_manager/mixin.py',
|
||||
'input_manager/runtime.py',
|
||||
'input_manager/settings.py',
|
||||
'input_manager/settings_visual.py',
|
||||
],
|
||||
install_dir: pythondir / meson.project_name() / 'input_manager',
|
||||
)
|
||||
|
||||
shared_library(
|
||||
'portprotonqt_gamepad',
|
||||
gamepad_sources,
|
||||
dependencies: sdl3_dep,
|
||||
install: true,
|
||||
install_dir: pkgdatadir,
|
||||
)
|
||||
|
||||
install_data(
|
||||
['compatibility_rules.json',
|
||||
'keyboard_layouts.json',
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import ctypes
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
GAMEPAD_LIBRARY_NAME = "libportprotonqt_gamepad.so"
|
||||
SYSTEM_GAMEPAD_LIBRARY = Path(__file__).parent / GAMEPAD_LIBRARY_NAME
|
||||
DEV_GAMEPAD_LIBRARY = (
|
||||
Path(__file__).parent.parent / "build-aux" / "lib" / GAMEPAD_LIBRARY_NAME
|
||||
)
|
||||
GAMEPAD_LIBRARY_PATH = (
|
||||
SYSTEM_GAMEPAD_LIBRARY
|
||||
if SYSTEM_GAMEPAD_LIBRARY.is_file()
|
||||
else DEV_GAMEPAD_LIBRARY
|
||||
)
|
||||
|
||||
|
||||
def _load_library() -> ctypes.CDLL:
|
||||
return ctypes.CDLL(str(GAMEPAD_LIBRARY_PATH))
|
||||
|
||||
|
||||
_library = _load_library()
|
||||
_handle = ctypes.c_void_p
|
||||
_library.portproton_gamepad_find.argtypes = []
|
||||
_library.portproton_gamepad_find.restype = _handle
|
||||
_library.portproton_gamepad_get_error.argtypes = []
|
||||
_library.portproton_gamepad_get_error.restype = ctypes.c_char_p
|
||||
_library.portproton_gamepad_close.argtypes = [_handle]
|
||||
_library.portproton_gamepad_close.restype = None
|
||||
_library.portproton_gamepad_connected.argtypes = [_handle]
|
||||
_library.portproton_gamepad_connected.restype = ctypes.c_bool
|
||||
_library.portproton_gamepad_update.argtypes = []
|
||||
_library.portproton_gamepad_update.restype = None
|
||||
_library.portproton_gamepad_get_button.argtypes = [_handle, ctypes.c_int]
|
||||
_library.portproton_gamepad_get_button.restype = ctypes.c_int
|
||||
_library.portproton_gamepad_get_axis.argtypes = [_handle, ctypes.c_int]
|
||||
_library.portproton_gamepad_get_axis.restype = ctypes.c_int16
|
||||
_library.portproton_gamepad_get_name.argtypes = [_handle]
|
||||
_library.portproton_gamepad_get_name.restype = ctypes.c_char_p
|
||||
_library.portproton_gamepad_get_instance_id.argtypes = [_handle]
|
||||
_library.portproton_gamepad_get_instance_id.restype = ctypes.c_uint32
|
||||
_library.portproton_gamepad_get_type.argtypes = [_handle]
|
||||
_library.portproton_gamepad_get_type.restype = ctypes.c_int
|
||||
_library.portproton_gamepad_shutdown.argtypes = []
|
||||
_library.portproton_gamepad_shutdown.restype = None
|
||||
|
||||
|
||||
class GamepadBackendError(RuntimeError):
|
||||
"""SDL failed while discovering the active gamepad."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class SDLGamepad:
|
||||
controller: int
|
||||
name: str
|
||||
path: str
|
||||
instance_id: int
|
||||
sdl_type: int
|
||||
|
||||
def close(self) -> None:
|
||||
if self.controller:
|
||||
_library.portproton_gamepad_close(self.controller)
|
||||
self.controller = 0
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
def connected(self) -> bool:
|
||||
return bool(_library.portproton_gamepad_connected(self.controller))
|
||||
|
||||
def update(self) -> None:
|
||||
_library.portproton_gamepad_update()
|
||||
|
||||
def get_button(self, button: int) -> int:
|
||||
return int(_library.portproton_gamepad_get_button(self.controller, button))
|
||||
|
||||
def get_axis(self, axis: int) -> int:
|
||||
return int(_library.portproton_gamepad_get_axis(self.controller, axis))
|
||||
|
||||
|
||||
def find_gamepad() -> SDLGamepad | None:
|
||||
controller = _library.portproton_gamepad_find()
|
||||
if not controller:
|
||||
error = _library.portproton_gamepad_get_error().decode(errors="replace")
|
||||
if error:
|
||||
raise GamepadBackendError(error)
|
||||
return None
|
||||
instance_id = int(_library.portproton_gamepad_get_instance_id(controller))
|
||||
name = _library.portproton_gamepad_get_name(controller).decode(errors="replace")
|
||||
return SDLGamepad(
|
||||
controller=controller,
|
||||
name=name,
|
||||
path=f"sdl3-gamepad:{instance_id}",
|
||||
instance_id=instance_id,
|
||||
sdl_type=int(_library.portproton_gamepad_get_type(controller)),
|
||||
)
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
_library.portproton_gamepad_shutdown()
|
||||
@@ -10,7 +10,7 @@ from PySide6.QtWidgets import QWidget
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
|
||||
_DEFAULT_PRELOADER = {
|
||||
PRELOADER_DEFAULT = {
|
||||
"style": "default",
|
||||
"speed": 180.0,
|
||||
"line_width": 20,
|
||||
@@ -18,6 +18,8 @@ _DEFAULT_PRELOADER = {
|
||||
"bat_flap_speed": 3.0,
|
||||
"bat_alpha": 220,
|
||||
"bat_color": "#bd93f9",
|
||||
"bat_eye_color": "#282a36",
|
||||
"bat_fang_color": "#f8f8f2",
|
||||
"pulse_count": 3,
|
||||
"pulse_max_radius": 40,
|
||||
"pulse_speed": 2.0,
|
||||
@@ -43,7 +45,7 @@ def _load_preloader_config() -> dict:
|
||||
theme = load_theme(ui_config.get_theme())
|
||||
except FileNotFoundError:
|
||||
theme = load_theme("standart")
|
||||
return getattr(theme, "PRELOADER", _DEFAULT_PRELOADER)
|
||||
return PRELOADER_DEFAULT | getattr(theme, "PRELOADER", {})
|
||||
|
||||
|
||||
class Preloader(QWidget):
|
||||
@@ -236,7 +238,7 @@ class Preloader(QWidget):
|
||||
|
||||
eye_r = size * 0.018
|
||||
blink = max(0.1, abs(math.sin((time.time() - self._pulse_start) * 0.4)))
|
||||
painter.setBrush(QBrush(QColor("#282a36")))
|
||||
painter.setBrush(QBrush(QColor(self._config["bat_eye_color"])))
|
||||
for side in (-1, 1):
|
||||
painter.drawEllipse(
|
||||
QPointF(side * head_r * 0.35, head_y),
|
||||
@@ -245,7 +247,7 @@ class Preloader(QWidget):
|
||||
|
||||
fang_len = size * 0.07
|
||||
fang_w = size * 0.015
|
||||
painter.setBrush(QBrush(QColor("#f8f8f2")))
|
||||
painter.setBrush(QBrush(QColor(self._config["bat_fang_color"])))
|
||||
for side in (-1, 1):
|
||||
path = QPainterPath()
|
||||
fx = side * head_r * 0.18
|
||||
@@ -297,7 +299,6 @@ class Preloader(QWidget):
|
||||
if parsed.isValid():
|
||||
return parsed
|
||||
|
||||
fallback = QColor(0, 120, 215)
|
||||
theme_manager = ThemeManager()
|
||||
theme = theme_manager.current_theme_module
|
||||
if theme is None:
|
||||
@@ -312,7 +313,7 @@ class Preloader(QWidget):
|
||||
parsed = self._parse_color_string(getattr(theme, attr_name))
|
||||
if parsed.isValid():
|
||||
return parsed
|
||||
return fallback
|
||||
return QColor(load_theme("standart").color_preloader)
|
||||
|
||||
def _parse_color_string(self, value):
|
||||
if isinstance(value, QColor):
|
||||
|
||||
@@ -11,7 +11,9 @@ from typing import Any, cast
|
||||
from dbus_fast import BusType, DBusError, Message, Variant
|
||||
from dbus_fast.aio import MessageBus
|
||||
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme
|
||||
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -347,6 +349,17 @@ async def _main() -> int:
|
||||
args = parse_args()
|
||||
try:
|
||||
if args.command == "notify":
|
||||
if args.icon in {"info", "warning", "error"}:
|
||||
theme_name = ui_config.get_theme()
|
||||
theme_manager = ThemeManager()
|
||||
load_theme(theme_name)
|
||||
icon_path = theme_manager.get_icon(
|
||||
args.icon,
|
||||
theme_name,
|
||||
as_path=True,
|
||||
)
|
||||
if isinstance(icon_path, str):
|
||||
args.icon = icon_path
|
||||
request = NotificationRequest(
|
||||
args.app,
|
||||
args.icon,
|
||||
|
||||
@@ -291,7 +291,7 @@ def get_advanced_settings(disabled_text, logical_core_options=None, locale_optio
|
||||
'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
|
||||
'type': 'combo',
|
||||
'options': ['11', '10', '7', 'XP'],
|
||||
'default': '10'
|
||||
'default': '11'
|
||||
})
|
||||
|
||||
# 5. DLL Overrides
|
||||
|
||||
@@ -63,6 +63,8 @@ class _SoundSlot:
|
||||
self._loaded_event: str | None = None
|
||||
|
||||
def play(self, event: str, url: QUrl) -> None:
|
||||
if self._loaded_event == event and self._effect.isPlaying():
|
||||
return
|
||||
if self._loaded_event != event:
|
||||
self._effect.setSource(url)
|
||||
self._loaded_event = event
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import threading
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections.abc import Callable
|
||||
from functools import lru_cache
|
||||
|
||||
import orjson
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
@@ -39,6 +41,7 @@ downloader = Downloader()
|
||||
_STEAM_APPS_CACHE = CacheManager(name="steam_apps")
|
||||
_ANTICHEAT_CACHE = CacheManager(name="anticheat")
|
||||
_PPDB_CACHE = CacheManager(name="ppdb")
|
||||
_CACHED_INDEX_LOCK = threading.RLock()
|
||||
SGDB_CACHE_DURATION = 24 * 60 * 60
|
||||
STEAM_CLIENT_ICON_SIZES = (16, 32, 64, 128, 256)
|
||||
|
||||
@@ -341,6 +344,8 @@ def clear_steam_api_caches() -> None:
|
||||
_STEAM_APPS_CACHE.clear_memory_cache()
|
||||
_ANTICHEAT_CACHE.clear_memory_cache()
|
||||
_PPDB_CACHE.clear_memory_cache()
|
||||
with _CACHED_INDEX_LOCK:
|
||||
_load_cached_data_and_index.cache_clear()
|
||||
logger.info("Cleared Steam API in-memory caches")
|
||||
|
||||
|
||||
@@ -469,6 +474,32 @@ def _read_cached_app_data(appid: int) -> dict:
|
||||
return cached
|
||||
|
||||
|
||||
@lru_cache(maxsize=4)
|
||||
def _load_cached_data_and_index(
|
||||
cache_path: str,
|
||||
cache_mtime_ns: int,
|
||||
build_index_func: Callable[[list], dict],
|
||||
) -> tuple[list, dict]:
|
||||
cached = _load_cached_json_file(cache_path)
|
||||
if not isinstance(cached, list):
|
||||
return [], {}
|
||||
return cached, build_index_func(cached)
|
||||
|
||||
|
||||
def _get_cached_data_and_index(
|
||||
cache_file: os.PathLike,
|
||||
build_index_func: Callable[[list], dict],
|
||||
) -> tuple[list, dict]:
|
||||
try:
|
||||
cache_mtime_ns = os.stat(cache_file).st_mtime_ns
|
||||
except OSError:
|
||||
return [], {}
|
||||
with _CACHED_INDEX_LOCK:
|
||||
return _load_cached_data_and_index(
|
||||
os.fspath(cache_file), cache_mtime_ns, build_index_func
|
||||
)
|
||||
|
||||
|
||||
def _get_cached_steam_cover_path(appid: int) -> str:
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
|
||||
@@ -490,20 +521,14 @@ def _read_cached_protondb_tier(appid: int) -> str:
|
||||
|
||||
def _read_cached_ppdb_id(game_name: str) -> str:
|
||||
cache_file = CacheManager().cache_dir / "ppdb_games.json"
|
||||
cached = _load_cached_json_file(str(cache_file))
|
||||
if not isinstance(cached, list):
|
||||
return ""
|
||||
ppdb_index = build_ppdb_index(cached)
|
||||
_, ppdb_index = _get_cached_data_and_index(cache_file, build_ppdb_index)
|
||||
entry = search_ppdb_entry(game_name, ppdb_index) or {}
|
||||
return str(entry.get("id", ""))
|
||||
|
||||
|
||||
def _read_cached_ppdb_rating(game_name: str) -> str:
|
||||
cache_file = CacheManager().cache_dir / "ppdb_games.json"
|
||||
cached = _load_cached_json_file(str(cache_file))
|
||||
if not isinstance(cached, list):
|
||||
return ""
|
||||
ppdb_index = build_ppdb_index(cached)
|
||||
_, ppdb_index = _get_cached_data_and_index(cache_file, build_ppdb_index)
|
||||
entry = search_ppdb_entry(game_name, ppdb_index) or {}
|
||||
return str(entry.get("overall_rating", ""))
|
||||
|
||||
@@ -538,11 +563,10 @@ def get_cached_steam_game_info(desktop_name: str, exec_line: str) -> dict:
|
||||
folder_name = os.path.basename(os.path.dirname(game_exe)) if game_exe else ""
|
||||
candidates = remove_duplicates(filter_candidates([desktop_name, exe_name, folder_name]))
|
||||
apps_path = CacheManager().cache_dir / "steam_apps.json"
|
||||
steam_apps = _load_cached_json_file(str(apps_path))
|
||||
if not isinstance(steam_apps, list):
|
||||
steam_apps, steam_apps_index = _get_cached_data_and_index(apps_path, build_index)
|
||||
if not steam_apps:
|
||||
return {}
|
||||
|
||||
steam_apps_index = build_index(steam_apps)
|
||||
matching_app = None
|
||||
for candidate in sorted(candidates, key=lambda s: len(s.split()), reverse=True):
|
||||
matching_app = search_app(candidate, steam_apps_index)
|
||||
|
||||
@@ -7,6 +7,7 @@ from PySide6.QtGui import QMouseEvent, QPainter, QPaintEvent, QPixmap
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QStackedWidget, QWidget
|
||||
|
||||
from portprotonqt.config import ui_config
|
||||
from portprotonqt.custom_widgets import FlowLayout
|
||||
from portprotonqt.input_manager import BUTTONS, GamepadType
|
||||
from portprotonqt.localization import _
|
||||
@@ -450,6 +451,12 @@ class MainWindowControlHintsMixin:
|
||||
"""Update control hints based on gamepad connection status and type."""
|
||||
if not hasattr(self, "hintsLabels"):
|
||||
return
|
||||
control_hints_widget = getattr(self, "controlHintsWidget", None)
|
||||
hide_control_hints = ui_config.get_hide_control_hints()
|
||||
if isinstance(control_hints_widget, QWidget):
|
||||
control_hints_widget.setVisible(not hide_control_hints)
|
||||
if hide_control_hints:
|
||||
return
|
||||
force_update = bool(args and args[0] == "force")
|
||||
is_gamepad_connected = self.input_manager.gamepad is not None
|
||||
gtype = self.input_manager.gamepad_type
|
||||
@@ -569,7 +576,6 @@ class MainWindowControlHintsMixin:
|
||||
container.setVisible(not is_gamepad_connected and not on_system_tab)
|
||||
|
||||
self._updateSystemGamepadHintTexts()
|
||||
control_hints_widget = getattr(self, "controlHintsWidget", None)
|
||||
if isinstance(control_hints_widget, QWidget):
|
||||
control_hints_widget.updateGeometry()
|
||||
control_hints_widget.update()
|
||||
|
||||
@@ -31,6 +31,7 @@ from portprotonqt.gog_api import GOGAPI, GOG_LOGIN_URL
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
|
||||
logger = get_logger(__name__)
|
||||
GOG_CANCEL_KILL_TIMEOUT_MS = 3000
|
||||
@@ -369,6 +370,8 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
self.gog_account_worker = None
|
||||
|
||||
def _refresh_gog_library(self) -> None:
|
||||
if not self.gog_api.auth_path.is_file():
|
||||
return
|
||||
if getattr(self, "gog_library_worker", None) is not None:
|
||||
return
|
||||
self.gogAccountStatus.setText(_("Refreshing…"))
|
||||
@@ -734,6 +737,7 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
worker.finished.connect(lambda: self._finish_gog_support_setup(worker))
|
||||
self.gog_support_workers.append(worker)
|
||||
SoundManager().play("game_launch")
|
||||
worker.start()
|
||||
|
||||
def _track_gog_support_process(
|
||||
@@ -743,15 +747,24 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
self.game_processes.append(process)
|
||||
self.target_exe = os.path.basename(target or app_id)
|
||||
self.current_running_button = button
|
||||
self._start_launch_output_reader(process)
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self._set_running_button_stop()
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||
self.checkProcessTimer.start(500)
|
||||
|
||||
def _launch_after_gog_support(self, app_id: str, button=None) -> None:
|
||||
self.game_processes = [
|
||||
process for process in self.game_processes
|
||||
if process.poll() is None
|
||||
]
|
||||
self._launch_gog_game(app_id, button)
|
||||
timer = self.checkProcessTimer
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
self.checkProcessTimer = None
|
||||
self._launch_gog_game(app_id, button, play_sound=False)
|
||||
|
||||
def _finish_gog_support_setup(self, worker: GOGSupportWorker) -> None:
|
||||
workers = getattr(self, "gog_support_workers", [])
|
||||
|
||||
@@ -6,8 +6,10 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
from PySide6.QtCore import QAbstractAnimation, QPoint, QStandardPaths, Qt, QTimer, Slot
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QGridLayout,
|
||||
QHBoxLayout,
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
@@ -30,6 +32,10 @@ from portprotonqt.config import (
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer
|
||||
from portprotonqt.dialogs.proton_manager import (
|
||||
WINE_ARCHIVE_EXTENSIONS,
|
||||
show_proton_manager,
|
||||
)
|
||||
from portprotonqt.image_utils import COVER_IMAGE_EXTENSIONS
|
||||
from portprotonqt.localization import _, get_metadata_language, read_metadata_translations
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -89,7 +95,19 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _on_library_filter_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.filter_keys):
|
||||
return
|
||||
game_config.set_display_filter(self.filter_keys[index])
|
||||
display_filter = self.filter_keys[index]
|
||||
game_config.set_display_filter(display_filter)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
)
|
||||
self._position_library_controls_widget()
|
||||
self.searchEdit.clear()
|
||||
self.games = []
|
||||
self._preserve_library_focus_after_load = True
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _on_only_installed_changed(self, checked: bool) -> None:
|
||||
game_config.set_only_installed(checked)
|
||||
self.searchEdit.clear()
|
||||
self.games = []
|
||||
self._preserve_library_focus_after_load = True
|
||||
@@ -123,15 +141,20 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
if controls_widget is not None:
|
||||
controls_widget.hide()
|
||||
|
||||
def _create_library_controls_widget(self) -> QHBoxLayout:
|
||||
def _create_library_controls_widget(self) -> QGridLayout:
|
||||
self.libraryControlsWidget = QWidget(self)
|
||||
controls_layout = QHBoxLayout(self.libraryControlsWidget)
|
||||
self.libraryControlsWidget.setObjectName("libraryControlsWidget")
|
||||
controls_layout = QGridLayout(self.libraryControlsWidget)
|
||||
controls_layout.setContentsMargins(5, 5, 5, 5)
|
||||
controls_layout.setSpacing(10)
|
||||
self.libraryControlsWidget.setStyleSheet(self.theme.LIBRARY_CONTROL_STYLE)
|
||||
return controls_layout
|
||||
|
||||
def _position_library_controls_widget(self) -> None:
|
||||
controls_layout = self.libraryControlsWidget.layout()
|
||||
if controls_layout is not None:
|
||||
controls_layout.invalidate()
|
||||
controls_layout.activate()
|
||||
size_hint = self.libraryControlsWidget.sizeHint()
|
||||
button_bottom = self.libraryControlsButton.mapTo(
|
||||
self,
|
||||
@@ -264,7 +287,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
button.setGeometry(x, y, size.width(), size.height())
|
||||
return
|
||||
|
||||
def _add_library_filter_controls(self, controls_layout: QHBoxLayout) -> None:
|
||||
def _add_library_filter_controls(self, controls_layout: QGridLayout) -> None:
|
||||
self.sort_keys = ["last_launch", "playtime", "alphabetical"]
|
||||
self.sort_labels = [_("Last launch"), _("Time spent"), _("Alphabetical")]
|
||||
self.gamesSortCombo = self._create_library_combo(self.sort_labels, _("Sort Method:"))
|
||||
@@ -275,19 +298,39 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
self.gamesSortCombo.currentIndexChanged.connect(self._on_library_sort_changed)
|
||||
self.gamesSortCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
controls_layout.addWidget(self.gamesSortCombo)
|
||||
controls_layout.addWidget(self.gamesSortCombo, 0, 0)
|
||||
|
||||
display_filter = game_config.get_display_filter()
|
||||
only_installed = game_config.get_only_installed()
|
||||
self.filter_keys = ["all", "steam", "gog", "portproton", "favorites"]
|
||||
self.filter_labels = [_("All"), "Steam", "GOG", "PortProton", _("Favorites")]
|
||||
self.filter_labels = [
|
||||
_("All"),
|
||||
"Steam",
|
||||
"GOG",
|
||||
"PortProton",
|
||||
_("Favorites"),
|
||||
]
|
||||
self.gamesDisplayCombo = self._create_library_combo(self.filter_labels, _("Display Filter:"))
|
||||
self._set_combo_current_key(
|
||||
self.gamesDisplayCombo,
|
||||
self.filter_keys,
|
||||
game_config.get_display_filter(),
|
||||
display_filter,
|
||||
)
|
||||
self.gamesDisplayCombo.currentIndexChanged.connect(self._on_library_filter_changed)
|
||||
self.gamesDisplayCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
controls_layout.addWidget(self.gamesDisplayCombo)
|
||||
|
||||
self.onlyInstalledCheckBox = QCheckBox(_("Only Installed"))
|
||||
self.onlyInstalledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.onlyInstalledCheckBox.setChecked(only_installed)
|
||||
self.onlyInstalledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self._register_gamepad_tooltip(self.onlyInstalledCheckBox, _("Only Installed"))
|
||||
self.onlyInstalledCheckBox.toggled.connect(self._on_only_installed_changed)
|
||||
|
||||
controls_layout.addWidget(self.gamesDisplayCombo, 0, 1)
|
||||
controls_layout.addWidget(self.onlyInstalledCheckBox, 1, 1, 1, 2)
|
||||
self.onlyInstalledCheckBox.setVisible(
|
||||
display_filter not in ("steam", "portproton")
|
||||
)
|
||||
|
||||
self.badge_view_keys = ["detailed", "compact", "hidden"]
|
||||
self.badge_view_labels = [_("Detailed"), _("Compact"), _("Hidden")]
|
||||
@@ -302,7 +345,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.gamesBadgeViewCombo.setEnabled(False)
|
||||
self.gamesBadgeViewCombo.currentIndexChanged.connect(self._on_library_badge_view_changed)
|
||||
self.gamesBadgeViewCombo.activated.connect(self._delay_library_controls_hover_close)
|
||||
controls_layout.addWidget(self.gamesBadgeViewCombo)
|
||||
controls_layout.addWidget(self.gamesBadgeViewCombo, 0, 2)
|
||||
|
||||
def _delay_library_controls_hover_close(self, _index: int = -1) -> None:
|
||||
self._library_controls_hover_close_delayed = True
|
||||
@@ -377,12 +420,18 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
if hasattr(self.game_library_manager, 'gamesListLayout'):
|
||||
self.game_library_manager.gamesListLayout.update()
|
||||
|
||||
if (
|
||||
self.gog_api.auth_path.is_file()
|
||||
and getattr(self, "gog_library_worker", None) is None
|
||||
):
|
||||
display_filter = game_config.get_display_filter()
|
||||
source_loader = getattr(
|
||||
self, f"_load_{display_filter}_games_async", None
|
||||
)
|
||||
source_refresh = getattr(
|
||||
self, f"_refresh_{display_filter}_library", None
|
||||
)
|
||||
if callable(source_refresh):
|
||||
source_refresh()
|
||||
|
||||
if not callable(source_loader) and self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
return
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
# Use a small delay to allow UI to update before starting the refresh
|
||||
@@ -486,15 +535,21 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
if event.mimeData().hasUrls():
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile().lower()
|
||||
if path.endswith(LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS):
|
||||
if path.endswith(
|
||||
LAUNCH_FILE_EXTENSIONS + PP_FILE_EXTENSIONS + WINE_ARCHIVE_EXTENSIONS
|
||||
):
|
||||
event.acceptProposedAction()
|
||||
return
|
||||
event.ignore()
|
||||
|
||||
def dropEvent(self, event):
|
||||
wine_archives = []
|
||||
for url in event.mimeData().urls():
|
||||
path = url.toLocalFile()
|
||||
path_lower = path.lower()
|
||||
if path_lower.endswith(WINE_ARCHIVE_EXTENSIONS) and os.path.isfile(path):
|
||||
wine_archives.append(path)
|
||||
continue
|
||||
if path_lower.endswith(BACKUP_EXTENSION):
|
||||
self._perform_restore(path)
|
||||
event.acceptProposedAction()
|
||||
@@ -507,6 +562,14 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
self.openAddGameDialog(path)
|
||||
event.acceptProposedAction()
|
||||
break
|
||||
if wine_archives:
|
||||
show_proton_manager(
|
||||
self,
|
||||
self.portproton_location,
|
||||
input_manager=self.input_manager,
|
||||
local_archives=wine_archives,
|
||||
)
|
||||
event.acceptProposedAction()
|
||||
|
||||
def openAddGameDialog(self, exe_path=None):
|
||||
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
|
||||
|
||||
@@ -36,7 +36,7 @@ from portprotonqt.config import (
|
||||
ui_config,
|
||||
)
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox, FlowLayout
|
||||
from portprotonqt.debug_utils import get_selectable_gpu_list
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -145,7 +145,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
selected_account = game_config.get_steam_account_id()
|
||||
selected_index = self.steamAccountCombo.findData(selected_account)
|
||||
self.steamAccountCombo.setCurrentIndex(max(selected_index, 0))
|
||||
steam_account_title = QLabel(f"{_('Account')} Steam:")
|
||||
steam_account_title = QLabel(_("Account") + " Steam:")
|
||||
steam_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(steam_account_title, self.steamAccountCombo)
|
||||
self.gogAccountStatus = QLabel()
|
||||
@@ -160,7 +160,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
gog_buttons = QHBoxLayout()
|
||||
gog_buttons.addWidget(self.gogAccountStatus)
|
||||
gog_buttons.addWidget(self.gogLoginButton)
|
||||
gog_account_title = QLabel(f"{_('Account')} GOG:")
|
||||
gog_account_title = QLabel(_("Account") + " GOG:")
|
||||
gog_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(gog_account_title, gog_buttons)
|
||||
self._update_gog_account_state()
|
||||
@@ -344,6 +344,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
hide_autoinstall_layout.addStretch()
|
||||
uiForm.addRow(hide_autoinstall_layout)
|
||||
|
||||
self.hideControlHintsCheckBox = QCheckBox()
|
||||
self.hideControlHintsCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.hideControlHintsCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.hideControlHintsTitle = QLabel(_("Hide Control Hints"))
|
||||
self.hideControlHintsTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.hideControlHintsTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
|
||||
self.hideControlHintsTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.hideControlHintsCheckBox.setChecked(ui_config.get_hide_control_hints())
|
||||
hide_control_hints_layout = QHBoxLayout()
|
||||
hide_control_hints_layout.setContentsMargins(0, 0, 0, 0)
|
||||
hide_control_hints_layout.addWidget(self.hideControlHintsCheckBox)
|
||||
hide_control_hints_layout.addWidget(self.hideControlHintsTitle)
|
||||
hide_control_hints_layout.addStretch()
|
||||
uiForm.addRow(hide_control_hints_layout)
|
||||
|
||||
self.soundsEnabledCheckBox = QCheckBox()
|
||||
self.soundsEnabledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.soundsEnabledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -505,7 +520,9 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
auto_appimage_updates_layout.addWidget(self.autoAppImageUpdatesCheckBox)
|
||||
auto_appimage_updates_layout.addWidget(self.autoAppImageUpdatesTitle)
|
||||
auto_appimage_updates_layout.addStretch()
|
||||
self.integrateAppImageButton = AutoSizeButton(_("Integrate AppImage"))
|
||||
self.integrateAppImageButton = AutoSizeButton(
|
||||
_("Integrate AppImage"), icon=self.theme_manager.get_icon("desktop", as_path=True)
|
||||
)
|
||||
self.integrateAppImageButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.integrateAppImageButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.integrateAppImageButton.clicked.connect(self.integrateAppImage)
|
||||
@@ -645,8 +662,9 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
layout.addWidget(self.settingsScrollArea)
|
||||
|
||||
# Buttons (outside scroll area, always visible)
|
||||
buttonsLayout = QHBoxLayout()
|
||||
buttonsLayout.setSpacing(10)
|
||||
buttonsLayout = FlowLayout(center_rows=True)
|
||||
buttonsLayout.setContentsMargins(0, 0, 0, 0)
|
||||
buttonsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
|
||||
|
||||
self.saveButton = AutoSizeButton(_("Save Settings"), icon=self.theme_manager.get_icon("save", as_path=True))
|
||||
self.saveButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
@@ -854,6 +872,9 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
|
||||
display_config.set_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
|
||||
|
||||
hide_control_hints = self.hideControlHintsCheckBox.isChecked()
|
||||
ui_config.set_hide_control_hints(hide_control_hints)
|
||||
|
||||
gamepad_type_idx = self.gamepadTypeCombo.currentIndex()
|
||||
gamepad_type = self.gamepad_type_keys[gamepad_type_idx]
|
||||
gamepad_config.set_gamepad_type(gamepad_type)
|
||||
@@ -928,7 +949,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
|
||||
self.settingsDebounceTimer.start()
|
||||
|
||||
gamepad_connected = self.input_manager.find_gamepad() is not None
|
||||
gamepad_connected = self.input_manager.gamepad is not None
|
||||
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
|
||||
self.showFullScreen()
|
||||
|
||||
|
||||
@@ -42,10 +42,11 @@ class ThemeStoreCard(QFrame):
|
||||
super().__init__()
|
||||
self.theme_data = theme_data
|
||||
self.card_width = card_width
|
||||
self._hover_sound_pending = False
|
||||
self.setObjectName("themeStoreCard")
|
||||
self.setProperty("sound_event", "open")
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setMouseTracking(True)
|
||||
self.setFixedWidth(card_width)
|
||||
self.setStyleSheet(theme.THEME_STORE_CARD_STYLE)
|
||||
|
||||
@@ -54,6 +55,7 @@ class ThemeStoreCard(QFrame):
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.previewLabel = QLabel()
|
||||
self.previewLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
self.previewLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.previewLabel.setFixedHeight(int(card_width * 0.57))
|
||||
self.previewLabel.setStyleSheet(theme.THEME_STORE_PREVIEW_STYLE)
|
||||
@@ -61,15 +63,18 @@ class ThemeStoreCard(QFrame):
|
||||
|
||||
name = str(theme_data.get("name", ""))
|
||||
nameLabel = QLabel(name)
|
||||
nameLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
nameLabel.setStyleSheet(theme.THEME_STORE_CARD_TITLE_STYLE)
|
||||
layout.addWidget(nameLabel)
|
||||
|
||||
author = str(theme_data.get("author") or _("Unknown"))
|
||||
authorLabel = QLabel(_("by {0}").format(author))
|
||||
authorLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
authorLabel.setStyleSheet(theme.THEME_STORE_CARD_META_STYLE)
|
||||
layout.addWidget(authorLabel)
|
||||
|
||||
statsLabel = QLabel(self._stats_text())
|
||||
statsLabel.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
|
||||
statsLabel.setStyleSheet(theme.THEME_STORE_CARD_META_STYLE)
|
||||
layout.addWidget(statsLabel)
|
||||
|
||||
@@ -99,7 +104,24 @@ class ThemeStoreCard(QFrame):
|
||||
self.click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def enterEvent(self, event) -> None:
|
||||
self._hover_sound_pending = True
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event) -> None:
|
||||
self._hover_sound_pending = False
|
||||
super().leaveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event) -> None:
|
||||
if self._hover_sound_pending:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self._hover_sound_pending = False
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("open")
|
||||
self.clicked.emit(self.theme_data)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtWidgets import (
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QStackedWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
@@ -134,6 +135,15 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
self.applyButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.themeInfoLayout.addWidget(self.applyButton)
|
||||
|
||||
self.deleteThemeButton = AutoSizeButton(
|
||||
_("Delete Theme"),
|
||||
icon=self.theme_manager.get_icon("delete", as_path=True),
|
||||
)
|
||||
self.deleteThemeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.deleteThemeButton.setObjectName("themeDeleteButton")
|
||||
self.deleteThemeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.themeInfoLayout.addWidget(self.deleteThemeButton)
|
||||
|
||||
installedLayout.addLayout(self.themeInfoLayout)
|
||||
self.themeContentStack.addWidget(self.themeInstalledPage)
|
||||
self.themeStorePage = self._create_theme_store_page()
|
||||
@@ -150,6 +160,8 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
base_theme = self.themesCombo.currentText()
|
||||
variant = self.themeVariantCombo.currentData() or "light"
|
||||
theme_name = ui_config.resolve_theme(base_theme, variant)
|
||||
custom_themes = self._get_selected_custom_themes()
|
||||
self.deleteThemeButton.setVisible(bool(custom_themes))
|
||||
meta = load_theme_metainfo(theme_name)
|
||||
link = meta.get("author_link", "")
|
||||
link_html = f'<a href="{link}">{link}</a>' if link else _("No link")
|
||||
@@ -191,10 +203,50 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
)
|
||||
|
||||
self.applyButton.clicked.connect(on_apply)
|
||||
self.deleteThemeButton.clicked.connect(self._delete_selected_theme)
|
||||
|
||||
# Add widget to stackedWidget
|
||||
self.theme_tab_index = self.stackedWidget.addWidget(self.themeTabWidget)
|
||||
|
||||
def _get_selected_custom_themes(self) -> list[str]:
|
||||
base_theme = self.themesCombo.currentText()
|
||||
variants = {
|
||||
ui_config.resolve_theme(base_theme, "dark"),
|
||||
ui_config.resolve_theme(base_theme, "light"),
|
||||
}
|
||||
return [name for name in variants if self.theme_manager.is_custom_theme(name)]
|
||||
|
||||
def _delete_selected_theme(self) -> None:
|
||||
theme_names = self._get_selected_custom_themes()
|
||||
if not theme_names:
|
||||
return
|
||||
message = _("Delete theme '{0}'?").format(self.themesCombo.currentText())
|
||||
message_box = QMessageBox(self)
|
||||
message_box.setIcon(QMessageBox.Icon.Question)
|
||||
message_box.setWindowTitle(_("Confirm Deletion"))
|
||||
message_box.setText(message)
|
||||
message_box.setStandardButtons(
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
||||
)
|
||||
message_box.setDefaultButton(QMessageBox.StandardButton.No)
|
||||
message_box.setButtonText(QMessageBox.StandardButton.Yes, _("Yes"))
|
||||
message_box.setButtonText(QMessageBox.StandardButton.No, _("No"))
|
||||
if message_box.exec() != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
removal_results = [
|
||||
self.theme_manager.remove_custom_theme(name) for name in theme_names
|
||||
]
|
||||
if not all(removal_results):
|
||||
QMessageBox.warning(self, _("Error"), _("Failed to delete theme."))
|
||||
return
|
||||
active_theme_deleted = ui_config.get_theme_base() == self.themesCombo.currentText()
|
||||
self._refresh_theme_combo("standart")
|
||||
if active_theme_deleted:
|
||||
self._apply_theme_and_restart("standart", self.themeVariantCombo.currentData() or "light")
|
||||
return
|
||||
self.themesCombo.setCurrentIndex(-1)
|
||||
self.themesCombo.setCurrentIndex(0)
|
||||
|
||||
def _refresh_theme_store_visibility(self) -> None:
|
||||
store_index = self.themesCombo.findData(THEME_STORE_ITEM)
|
||||
if ui_config.get_enable_theme_store():
|
||||
|
||||
@@ -3,6 +3,7 @@ import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import xml.etree.ElementTree as ET
|
||||
import orjson
|
||||
@@ -67,8 +68,12 @@ SVG_PAINT_ATTR_PATTERN = re.compile(
|
||||
SVG_PAINT_ATTRIBUTES = {"fill", "stroke", "color", "stop-color", "flood-color", "lighting-color"}
|
||||
SVG_ANIMATION_TAGS = {"animate", "animateMotion", "animateTransform", "set"}
|
||||
SVG_KEEP_PAINT_VALUES = {"none", "transparent", "inherit", "initial", "unset", "freeze", "remove"}
|
||||
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
||||
SVG_CACHE_VERSION = 2
|
||||
NON_INHERITED_THEME_CONSTANTS = {"ICON_COLORS"}
|
||||
|
||||
ET.register_namespace("", SVG_NAMESPACE)
|
||||
|
||||
# Folder where all custom themes are located
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
THEMES_DIRS = [
|
||||
@@ -216,7 +221,7 @@ def _colored_svg_cache_path(icon_path: str, color: str) -> str | None:
|
||||
except OSError as e:
|
||||
logger.warning("Cannot stat SVG icon '%s': %s", icon_path, e)
|
||||
return None
|
||||
digest_source = f"{icon_path}:{stat.st_mtime_ns}:{color}".encode()
|
||||
digest_source = f"{SVG_CACHE_VERSION}:{icon_path}:{stat.st_mtime_ns}:{color}".encode()
|
||||
digest = hashlib.sha256(digest_source).hexdigest()[:16]
|
||||
return str(CACHE_DIR / "icons" / f"{digest}.svg")
|
||||
|
||||
@@ -905,6 +910,25 @@ class ThemeManager:
|
||||
"""Return list of available themes."""
|
||||
return list_themes()
|
||||
|
||||
def is_custom_theme(self, theme_name: str) -> bool:
|
||||
"""Return whether a theme is installed in the user theme directory."""
|
||||
if not _is_valid_theme_name(theme_name):
|
||||
return False
|
||||
theme_path = os.path.join(THEMES_DIRS[0], theme_name)
|
||||
return os.path.isdir(theme_path) and not os.path.islink(theme_path)
|
||||
|
||||
def remove_custom_theme(self, theme_name: str) -> bool:
|
||||
"""Remove a theme from the user theme directory."""
|
||||
if not self.is_custom_theme(theme_name):
|
||||
return False
|
||||
theme_path = os.path.join(THEMES_DIRS[0], theme_name)
|
||||
try:
|
||||
shutil.rmtree(theme_path)
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove custom theme '%s': %s", theme_name, e)
|
||||
return False
|
||||
return True
|
||||
|
||||
def apply_theme(self, theme_name: str):
|
||||
"""
|
||||
Apply selected theme if not already applied.
|
||||
|
||||
@@ -35,6 +35,7 @@ color_surface = "#F0F2F5"
|
||||
color_surface_elevated = "#E9ECEF"
|
||||
color_surface_hover = "#DEE2E6"
|
||||
color_text = "#ffffff"
|
||||
color_text_accent_hover = "#F8F9FC"
|
||||
color_surface_light = "#d2d3db"
|
||||
color_surface_mid = "#9394a5"
|
||||
color_text_dark = "#212529"
|
||||
@@ -199,6 +200,7 @@ PLAY_BUTTON_STYLE = f"""
|
||||
}}
|
||||
QPushButton:hover, QPushButton:pressed, QPushButton:focus {{
|
||||
background: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -216,10 +218,12 @@ ADDGAME_BACK_BUTTON_STYLE = f"""
|
||||
}}
|
||||
QPushButton:hover, QPushButton:pressed {{
|
||||
background: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
background: {color_accent};
|
||||
border: {border_thin} {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -238,6 +242,7 @@ LIBRARY_CONTROLS_BUTTON_STYLE = f"""
|
||||
QPushButton:hover, QPushButton:pressed, QPushButton:focus, QPushButton:checked {{
|
||||
background: {color_accent};
|
||||
border: {border_thin} {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -314,12 +319,12 @@ COMBOBOX_STYLE = f"""
|
||||
QComboBox:hover {{
|
||||
border: {border_medium} {color_accent};
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QComboBox:focus {{
|
||||
border: {border_medium} {color_accent};
|
||||
background-color: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QComboBox:disabled {{
|
||||
background: {color_surface_hover};
|
||||
@@ -385,7 +390,7 @@ COMBOBOX_STYLE = f"""
|
||||
}}
|
||||
QListView::item:focus {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
""" % (
|
||||
theme_manager.get_icon("down", current_theme_name, as_path=True),
|
||||
@@ -460,12 +465,11 @@ TAB_STYLE = f"""
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {color_accent};
|
||||
color: {color_text_dark};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QTabBar::tab:hover {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -498,21 +502,22 @@ QTableWidget::item:selected,
|
||||
QTableWidget::item:focus,
|
||||
QTableWidget::item:selected:focus {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
selection-background-color: {color_accent};
|
||||
}}
|
||||
QTableWidget::item:hover {{
|
||||
background: {color_transparent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QTableWidget::item:selected:hover {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
HINT_BAR_STYLE = f"""
|
||||
QWidget {{
|
||||
max-height: 40px;
|
||||
max-height: 82px;
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -530,15 +535,17 @@ ACTION_BUTTON_STYLE = f"""
|
||||
QPushButton:hover {{
|
||||
background: {color_accent};
|
||||
border: {border_medium} {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_medium} {color_accent};
|
||||
background-color: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -556,7 +563,7 @@ ACTION_BUTTON_ACTIVE_STYLE = f"""
|
||||
QPushButton:hover {{
|
||||
background: {color_accent};
|
||||
border: {border_medium} {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_bg};
|
||||
@@ -564,7 +571,7 @@ ACTION_BUTTON_ACTIVE_STYLE = f"""
|
||||
QPushButton:focus {{
|
||||
border: {border_medium} {color_accent};
|
||||
background-color: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
@@ -142,6 +142,7 @@ PLAY_BUTTON_STYLE = f"""
|
||||
}}
|
||||
QPushButton:hover, QPushButton:pressed, QPushButton:focus {{
|
||||
background: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -159,10 +160,12 @@ ADDGAME_BACK_BUTTON_STYLE = f"""
|
||||
}}
|
||||
QPushButton:hover, QPushButton:pressed {{
|
||||
background: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
background: {color_accent};
|
||||
border: {border_thin} {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -181,6 +184,7 @@ LIBRARY_CONTROLS_BUTTON_STYLE = f"""
|
||||
QPushButton:hover, QPushButton:pressed, QPushButton:focus, QPushButton:checked {{
|
||||
background: {color_accent};
|
||||
border: {border_thin} {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -272,10 +276,12 @@ COMBOBOX_STYLE = f"""
|
||||
QComboBox:hover {{
|
||||
border: {border_medium} {color_accent};
|
||||
background: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QComboBox:focus {{
|
||||
border: {border_medium} {color_accent};
|
||||
background-color: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QComboBox:disabled {{
|
||||
background: {color_combo_disabled_bg};
|
||||
@@ -340,7 +346,7 @@ COMBOBOX_STYLE = f"""
|
||||
}}
|
||||
QListView::item:focus {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
""" % (
|
||||
theme_manager.get_icon("down", current_theme_name, as_path=True),
|
||||
@@ -352,6 +358,7 @@ SETTINGS_TABLE_COMBOBOX_STYLE = f"""
|
||||
QComboBox#settingsTableCombo:focus {{
|
||||
background: {color_surface};
|
||||
border: {border_medium} {color_accent};
|
||||
color: {color_text};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -414,16 +421,17 @@ TAB_STYLE = f"""
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QTabBar::tab:hover {{
|
||||
background: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
HINT_BAR_STYLE = """
|
||||
QWidget {
|
||||
max-height: 40px;
|
||||
max-height: 82px;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -476,15 +484,16 @@ QTableWidget::item:selected,
|
||||
QTableWidget::item:focus,
|
||||
QTableWidget::item:selected:focus {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
selection-background-color: {color_accent};
|
||||
}}
|
||||
QTableWidget::item:hover {{
|
||||
background: {color_transparent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QTableWidget::item:selected:hover {{
|
||||
background: {color_accent};
|
||||
color: {color_text};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -501,14 +510,17 @@ ACTION_BUTTON_STYLE = f"""
|
||||
}}
|
||||
QPushButton:hover {{
|
||||
background: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
border: {border_medium} {color_accent};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_bg};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_medium} {color_accent};
|
||||
background-color: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -526,13 +538,16 @@ ACTION_BUTTON_ACTIVE_STYLE = f"""
|
||||
QPushButton:hover {{
|
||||
background: {color_accent};
|
||||
border: {border_medium} {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QPushButton:pressed {{
|
||||
background: {color_bg};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
QPushButton:focus {{
|
||||
border: {border_medium} {color_accent};
|
||||
background-color: {color_accent};
|
||||
color: {color_text_accent_hover};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style type="text/css">
|
||||
|
||||
.fil0 {fill:black}
|
||||
|
||||
</style></defs><g transform="matrix(-.99619 0 0 -.99619 -3.2034 6.568)" clip-rule="evenodd" fill-rule="evenodd" shape-rendering="geometricPrecision"><g stroke-width="0"><path d="m-33.501-15.9-5.6277 5.6272c-0.43985 0.43985-0.97478 0.6619-1.5968 0.6619h-8.1521c-0.71077 0-1.2909-0.58009-1.2909-1.2914v-13.19c0-0.7113 0.58009-1.2914 1.2909-1.2914h8.1521c0.62206 0 1.157 0.22205 1.5968 0.66136l5.6277 5.6277c0.8797 0.8797 0.8797 2.3145 0 3.1947z" color="#000000" fill="#3f424d"/><path d="m-48.877-26.133c-1.1188 0-2.041 0.92195-2.041 2.041v13.189c0 1.1191 0.92218 2.041 2.041 2.041h8.1523c0.80053 0 1.5614-0.31526 2.127-0.88086l5.627-5.627c1.1678-1.1685 1.168-3.0878 0-4.2559l-5.627-5.627v-2e-3c-0.56557-0.56488-1.3264-0.87891-2.127-0.87891zm0 1.5h8.1523c0.44358 0 0.75228 0.12766 1.0664 0.44141l5.627 5.627c0.59137 0.59136 0.59163 1.5428 0 2.1348l-5.627 5.627c-0.3141 0.3141-0.62283 0.44141-1.0664 0.44141h-8.1523c-0.3027 0-0.54102-0.23748-0.54102-0.54102v-13.189c0-0.30353 0.23832-0.54102 0.54102-0.54102z" color="#000000" fill="#fff"/><path d="m-26.756-12.35 5.6277 5.6277c0.43932 0.43985 0.66137 0.97478 0.66137 1.5968v8.1521c0 0.71077-0.58009 1.2909-1.2914 1.2909h-13.19c-0.7113 0-1.2914-0.58009-1.2914-1.2909v-8.1521c0-0.62206 0.22205-1.157 0.6619-1.5968l5.6272-5.6277c0.88023-0.8797 2.315-0.8797 3.1947 0z" color="#000000" fill="#3f424d"/><path d="m-28.354-13.756c-0.77175-8e-6 -1.5427 0.29112-2.127 0.875l-5.6289 5.6289c-0.5656 0.5656-0.88086 1.3264-0.88086 2.127v8.1504c0 1.1188 0.92195 2.041 2.041 2.041h13.191c1.1191 0 2.041-0.92218 2.041-2.041v-8.1504c0-0.80053-0.31598-1.5614-0.88086-2.127l-5.6289-5.6289c-0.58401-0.58401-1.3552-0.87499-2.127-0.875zm0 1.4922c0.38564-7e-6 0.77072 0.14768 1.0664 0.44336l5.627 5.627c0.31375 0.31413 0.44336 0.62478 0.44336 1.0684v8.1504c0 0.3027-0.23748 0.54102-0.54102 0.54102h-13.191c-0.30353 0-0.54102-0.23832-0.54102-0.54102v-8.1504c0-0.44358 0.12731-0.75231 0.44141-1.0664l5.6289-5.6289c0.29599-0.29581 0.68077-0.44335 1.0664-0.44336z" color="#000000" fill="#fff"/></g><path d="m-23.943-19.241 6.145-6.145c0.48028-0.4797 1.0644-0.72216 1.7436-0.72216h8.9014c0.77668 0 1.4095 0.63341 1.4095 1.4101v14.403c0 0.77668-0.63283 1.4101-1.4095 1.4101h-8.9014c-0.67924 0-1.2633-0.24246-1.7436-0.72274l-6.145-6.1445c-0.96056-0.96114-0.96056-2.5279 0-3.4884z" color="#000000" fill="#fff" style="-inkscape-stroke:none"/><path d="m-16.055-27.109c-0.91721 0-1.8013 0.3685-2.4492 1.0156l-6.1465 6.1445c-1.345 1.345-1.3447 3.5569 0 4.9023l6.1465 6.1445c0.64795 0.64795 1.532 1.0156 2.4492 1.0156h8.9023c1.3204-1e-7 2.4102-1.0903 2.4102-2.4102v-14.402c0-1.3199-1.0898-2.4102-2.4102-2.4102zm0 2h8.9023c0.23299 0 0.41016 0.17666 0.41016 0.41016v14.402c0 0.23349-0.17716 0.41016-0.41016 0.41016h-8.9023c-0.44127 0-0.72254-0.11707-1.0352-0.42969l-6.1465-6.1426v-2e-3c-0.57508-0.57691-0.57566-1.4986 0-2.0742l6.1445-6.1445c0.31266-0.31228 0.59584-0.42969 1.0371-0.42969z" color="#000000" fill="#3f424d" style="-inkscape-stroke:none"/><g stroke-width="0"><path d="m-29.951-22.644-5.6272-5.6277c-0.43985-0.43985-0.6619-0.97478-0.6619-1.5968v-8.1521c0-0.7113 0.58009-1.2909 1.2914-1.2909h13.19c0.7113 0 1.2914 0.57956 1.2914 1.2909v8.1521c0 0.62205-0.22205 1.157-0.66137 1.5968l-5.6277 5.6277c-0.8797 0.8797-2.3145 0.8797-3.1947 0z" color="#000000" fill="#3f424d"/><path d="m-34.949-40.062c-1.1187 0-2.041 0.92195-2.041 2.041v8.1523c0 0.80053 0.31526 1.5614 0.88086 2.127l5.6289 5.6289c1.1685 1.1678 3.0859 1.168 4.2539 0l5.6289-5.6289c0.56488-0.56557 0.88086-1.3264 0.88086-2.127v-8.1523c0-1.1191-0.92232-2.041-2.041-2.041zm0 1.5h13.191c0.3039 0 0.54102 0.23748 0.54102 0.54102v8.1523c0 0.44357-0.12961 0.75228-0.44336 1.0664l-5.627 5.6289c-0.59136 0.59136-1.5408 0.59163-2.1328 0l-5.6289-5.6289c-0.3141-0.3141-0.44141-0.62283-0.44141-1.0664v-8.1523c0-0.30353 0.23711-0.54102 0.54102-0.54102z" color="#000000" fill="#fff"/></g></g></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="#3f424d"><path d="m30.17 22.407 5.6063-5.6058c0.43817-0.43818 0.97107-0.65938 1.5907-0.65938h8.121c0.70806 0 1.286 0.57788 1.286 1.2865v13.14c0 0.70859-0.57788 1.2865-1.286 1.2865h-8.121c-0.61969 0-1.1526-0.2212-1.5907-0.65884l-5.6063-5.6063c-0.87635-0.87635-0.87635-2.3057 0-3.1825z"/><path d="m23.451 18.871-5.6063-5.6063c-0.43765-0.43817-0.65885-0.97107-0.65885-1.5907v-8.121c0-0.70806 0.57788-1.286 1.2865-1.286h13.14c0.70859 0 1.2865 0.57788 1.2865 1.286v8.121c0 0.61969-0.2212 1.1526-0.65938 1.5907l-5.6058 5.6063c-0.87688 0.87635-2.3062 0.87635-3.1825 0z"/><path d="m12.79 33.574c0.91372 0 1.7944-0.3671 2.4399-1.0117l6.1231-6.1211c1.3399-1.3399 1.3396-3.5433 0-4.8836l-6.1231-6.1211c-0.64548-0.64548-1.5262-1.0117-2.4399-1.0117h-8.8684c-1.3154 1e-6 -2.401 1.0861-2.401 2.401v14.347c0 1.3149 1.0856 2.401 2.401 2.401zm0-1.9924h-8.8684c-0.2321 0-0.4086-0.17599-0.4086-0.4086v-14.347c0-0.2326 0.17648-0.4086 0.4086-0.4086h8.8684c0.43959 0 0.71979 0.11662 1.0313 0.42805l6.1231 6.1192v2e-3c0.57289 0.57471 0.57347 1.4929 0 2.0663l-6.1211 6.1211c-0.31147 0.31109-0.59357 0.42805-1.0331 0.42805z"/><path d="m26.633 29.126 5.6058 5.6063c0.43817 0.43817 0.65938 0.97107 0.65938 1.5907v8.121c0 0.70859-0.57788 1.286-1.2865 1.286h-13.14c-0.70859 0-1.2865-0.57735-1.2865-1.286v-8.121c0-0.61968 0.2212-1.1526 0.65885-1.5907l5.6063-5.6063c0.87635-0.87635 2.3057-0.87635 3.1825 0z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,5 +1,2 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style type="text/css">
|
||||
|
||||
.fil0 {fill:black}
|
||||
|
||||
</style></defs><g transform="matrix(.99619 0 0 .99619 52.245 41.43)" clip-rule="evenodd" fill-rule="evenodd" shape-rendering="geometricPrecision"><g stroke-width="0"><path d="m-33.501-15.9-5.6277 5.6272c-0.43985 0.43985-0.97478 0.6619-1.5968 0.6619h-8.1521c-0.71077 0-1.2909-0.58009-1.2909-1.2914v-13.19c0-0.7113 0.58009-1.2914 1.2909-1.2914h8.1521c0.62206 0 1.157 0.22205 1.5968 0.66136l5.6277 5.6277c0.8797 0.8797 0.8797 2.3145 0 3.1947z" color="#000000" fill="#3f424d"/><path d="m-48.877-26.133c-1.1188 0-2.041 0.92195-2.041 2.041v13.189c0 1.1191 0.92218 2.041 2.041 2.041h8.1523c0.80053 0 1.5614-0.31526 2.127-0.88086l5.627-5.627c1.1678-1.1685 1.168-3.0878 0-4.2559l-5.627-5.627v-2e-3c-0.56557-0.56488-1.3264-0.87891-2.127-0.87891zm0 1.5h8.1523c0.44358 0 0.75228 0.12766 1.0664 0.44141l5.627 5.627c0.59137 0.59136 0.59163 1.5428 0 2.1348l-5.627 5.627c-0.3141 0.3141-0.62283 0.44141-1.0664 0.44141h-8.1523c-0.3027 0-0.54102-0.23748-0.54102-0.54102v-13.189c0-0.30353 0.23832-0.54102 0.54102-0.54102z" color="#000000" fill="#fff"/><path d="m-26.756-12.35 5.6277 5.6277c0.43932 0.43985 0.66137 0.97478 0.66137 1.5968v8.1521c0 0.71077-0.58009 1.2909-1.2914 1.2909h-13.19c-0.7113 0-1.2914-0.58009-1.2914-1.2909v-8.1521c0-0.62206 0.22205-1.157 0.6619-1.5968l5.6272-5.6277c0.88023-0.8797 2.315-0.8797 3.1947 0z" color="#000000" fill="#3f424d"/><path d="m-28.354-13.756c-0.77175-8e-6 -1.5427 0.29112-2.127 0.875l-5.6289 5.6289c-0.5656 0.5656-0.88086 1.3264-0.88086 2.127v8.1504c0 1.1188 0.92195 2.041 2.041 2.041h13.191c1.1191 0 2.041-0.92218 2.041-2.041v-8.1504c0-0.80053-0.31598-1.5614-0.88086-2.127l-5.6289-5.6289c-0.58401-0.58401-1.3552-0.87499-2.127-0.875zm0 1.4922c0.38564-7e-6 0.77072 0.14768 1.0664 0.44336l5.627 5.627c0.31375 0.31413 0.44336 0.62478 0.44336 1.0684v8.1504c0 0.3027-0.23748 0.54102-0.54102 0.54102h-13.191c-0.30353 0-0.54102-0.23832-0.54102-0.54102v-8.1504c0-0.44358 0.12731-0.75231 0.44141-1.0664l5.6289-5.6289c0.29599-0.29581 0.68077-0.44335 1.0664-0.44336z" color="#000000" fill="#fff"/></g><path d="m-23.943-19.241 6.145-6.145c0.48028-0.4797 1.0644-0.72216 1.7436-0.72216h8.9014c0.77668 0 1.4095 0.63341 1.4095 1.4101v14.403c0 0.77668-0.63283 1.4101-1.4095 1.4101h-8.9014c-0.67924 0-1.2633-0.24246-1.7436-0.72274l-6.145-6.1445c-0.96056-0.96114-0.96056-2.5279 0-3.4884z" color="#000000" fill="#fff" style="-inkscape-stroke:none"/><path d="m-16.055-27.109c-0.91721 0-1.8013 0.3685-2.4492 1.0156l-6.1465 6.1445c-1.345 1.345-1.3447 3.5569 0 4.9023l6.1465 6.1445c0.64795 0.64795 1.532 1.0156 2.4492 1.0156h8.9023c1.3204-1e-7 2.4102-1.0903 2.4102-2.4102v-14.402c0-1.3199-1.0898-2.4102-2.4102-2.4102zm0 2h8.9023c0.23299 0 0.41016 0.17666 0.41016 0.41016v14.402c0 0.23349-0.17716 0.41016-0.41016 0.41016h-8.9023c-0.44127 0-0.72254-0.11707-1.0352-0.42969l-6.1465-6.1426v-2e-3c-0.57508-0.57691-0.57566-1.4986 0-2.0742l6.1445-6.1445c0.31266-0.31228 0.59584-0.42969 1.0371-0.42969z" color="#000000" fill="#3f424d" style="-inkscape-stroke:none"/><g stroke-width="0"><path d="m-29.951-22.644-5.6272-5.6277c-0.43985-0.43985-0.6619-0.97478-0.6619-1.5968v-8.1521c0-0.7113 0.58009-1.2909 1.2914-1.2909h13.19c0.7113 0 1.2914 0.57956 1.2914 1.2909v8.1521c0 0.62205-0.22205 1.157-0.66137 1.5968l-5.6277 5.6277c-0.8797 0.8797-2.3145 0.8797-3.1947 0z" color="#000000" fill="#3f424d"/><path d="m-34.949-40.062c-1.1187 0-2.041 0.92195-2.041 2.041v8.1523c0 0.80053 0.31526 1.5614 0.88086 2.127l5.6289 5.6289c1.1685 1.1678 3.0859 1.168 4.2539 0l5.6289-5.6289c0.56488-0.56557 0.88086-1.3264 0.88086-2.127v-8.1523c0-1.1191-0.92232-2.041-2.041-2.041zm0 1.5h13.191c0.3039 0 0.54102 0.23748 0.54102 0.54102v8.1523c0 0.44357-0.12961 0.75228-0.44336 1.0664l-5.627 5.6289c-0.59136 0.59136-1.5408 0.59163-2.1328 0l-5.6289-5.6289c-0.3141-0.3141-0.44141-0.62283-0.44141-1.0664v-8.1523c0-0.30353 0.23711-0.54102 0.54102-0.54102z" color="#000000" fill="#fff"/></g></g></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g fill="#3f424d"><path d="m18.872 25.591-5.6063 5.6058c-0.43817 0.43818-0.97107 0.65938-1.5907 0.65938h-8.121c-0.70806 0-1.286-0.57788-1.286-1.2865v-13.14c0-0.70859 0.57788-1.2865 1.286-1.2865h8.121c0.61969 0 1.1526 0.2212 1.5907 0.65884l5.6063 5.6063c0.87635 0.87635 0.87635 2.3057 0 3.1825z"/><path d="m25.591 29.127 5.6063 5.6063c0.43765 0.43817 0.65885 0.97107 0.65885 1.5907v8.121c0 0.70806-0.57788 1.286-1.2865 1.286h-13.14c-0.70859 0-1.2865-0.57788-1.2865-1.286v-8.121c0-0.61969 0.2212-1.1526 0.65938-1.5907l5.6058-5.6063c0.87688-0.87635 2.3062-0.87635 3.1825 0z"/><path d="m36.251 14.424c-0.91372 0-1.7944 0.3671-2.4399 1.0117l-6.1231 6.1211c-1.3399 1.3399-1.3396 3.5433 0 4.8836l6.1231 6.1211c0.64548 0.64548 1.5262 1.0117 2.4399 1.0117h8.8684c1.3154-1e-6 2.401-1.0861 2.401-2.401v-14.347c0-1.3149-1.0856-2.401-2.401-2.401zm0 1.9924h8.8684c0.2321 0 0.4086 0.17599 0.4086 0.4086v14.347c0 0.2326-0.17648 0.4086-0.4086 0.4086h-8.8684c-0.43959 0-0.71979-0.11662-1.0313-0.42805l-6.1231-6.1192v-2e-3c-0.57289-0.57471-0.57347-1.4929 0-2.0663l6.1211-6.1211c0.31147-0.31109 0.59357-0.42805 1.0331-0.42805z"/><path d="m22.408 18.872-5.6058-5.6063c-0.43817-0.43817-0.65938-0.97107-0.65938-1.5907v-8.121c0-0.70859 0.57788-1.286 1.2865-1.286h13.14c0.70859 0 1.2865 0.57735 1.2865 1.286v8.121c0 0.61968-0.2212 1.1526-0.65885 1.5907l-5.6063 5.6063c-0.87635 0.87635-2.3057 0.87635-3.1825 0z"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1 +1,2 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m24 1a23 23 0 0 0-23 23 23 23 0 0 0 23 23 23 23 0 0 0 23-23 23 23 0 0 0-23-23zm0 8.7227c7.88 0 14.277 6.4196 14.277 14.277 0 7.88-6.4196 14.277-14.277 14.277-7.88 0-14.277-6.4196-14.277-14.277 0-7.88 6.4196-14.277 14.277-14.277zm0 4.084c-5.613 0-10.195 4.5784-10.195 10.191-1e-6 5.613 4.5823 10.195 10.195 10.195 5.6356 0 10.195-4.5823 10.195-10.195 1e-6 -5.613-4.5823-10.191-10.195-10.191z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 736 B After Width: | Height: | Size: 581 B |