Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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.3.2
|
||||
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,4 @@ portprotonqt/locales/*/LC_MESSAGES/*.mo
|
||||
|
||||
# AI
|
||||
.codex
|
||||
CODE_QUALITY_PLAN.md
|
||||
@@ -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
|
||||
@@ -9,17 +9,26 @@
|
||||
- Добавлен анализатор проблем в случае вылета 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
|
||||
- Удалена сборка для Fedora 42
|
||||
- Версия Windows по умолчанию изменена с 10 на 11
|
||||
- Обновлены все иконки подсказок управления
|
||||
- API и ссылки PPDB переведены с `ppdb.linux-gaming.ru` на `linux-gaming.ru`
|
||||
- Блок игровой статистики на полной детальной странице помещён в тематизируемый контейнер, как в компактном режиме
|
||||
- Кэш EXE-иконок унифицирован для карточек и детальных страниц
|
||||
@@ -33,6 +42,15 @@
|
||||
- `sherrydmn` добавлен в список конфликтующих с GameMode процессов
|
||||
|
||||
### Fixed
|
||||
- Обновление сетки библиотеки теперь загружает только источник, выбранный одиночным фильтром
|
||||
- Исправлено обрезание кнопок настроек PortProtonQt в узких окнах
|
||||
- Исправлено удаление даты последнего запуска при удалении ярлыка игры
|
||||
- Исправлено извлечение иконок для лаунчеров, запускающих целевой EXE через соседний `.bat` или `.cmd`
|
||||
- Исправлено зависание интерфейса при проверке RPM-пакетов перед запуском игры
|
||||
- Исправлено обрезание подсказок управления при переносе на вторую строку
|
||||
- Исправлены подлагивания интерфейса при загрузке списка WINE/Proton и подсчёте размеров установленных версий
|
||||
- Исправлена перекраска иконок встроенного файлового менеджера через `ICON_COLORS`
|
||||
- Исправлено применение длительности fade-анимации к появлению обложки при анимациях `slide` и `bounce` детальной страницы
|
||||
- Исправлено игнорирование параметров темы для размеров, отступов, скруглений и тени карточек в сеточном и списочном режимах
|
||||
- Исправлены медленная загрузка превью и повторные сетевые запросы при прокрутке магазина тем
|
||||
- Исправлен непоследовательный порядок навигации по карточкам игр с помощью D-pad
|
||||
@@ -43,14 +61,23 @@
|
||||
- Исправлено состояние кнопки остановки при переключении детальной страницы на компактный вид
|
||||
- Исправлено зависание интерфейса после завершения игры
|
||||
- Исправлено определение основного аккаунта Steam по самому новому `Timestamp` в `loginusers.vdf` с резервным поиском по `AutoLogin` или единственному профилю `userdata` и поддержкой `MostRecent` для совместимости со старыми версиями Steam
|
||||
- Распаковка WINE/Proton больше не меняет рабочий каталог приложения и отклоняет пути из архива, выходящие за каталог установки
|
||||
|
||||
### Contributors
|
||||
- @Dervart
|
||||
- @Rubin13
|
||||
- @rogerio-mattos
|
||||
- @Vekt0R_null
|
||||
|
||||
---
|
||||
|
||||
## [1.3.2] - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
- Исправлено определение SteamID по `AutoLogin` или единственному профилю `userdata`, если в `loginusers.vdf` отсутствует `MostRecent`
|
||||
|
||||
---
|
||||
|
||||
## [1.3.1] - 2026-07-18
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -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/Boria138/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,10 +39,15 @@ pacman -Rdd --noconfirm libpipewire qt6-multimedia-ffmpeg
|
||||
|
||||
# Deploy dependencies
|
||||
# Qt libs have to be passed manually due to the app being a python script
|
||||
GAMEPAD_LIBRARY=$(find /usr/lib -type f \
|
||||
-path '*/site-packages/portprotonqt/libportprotonqt_gamepad.so' \
|
||||
-print -quit)
|
||||
test -n "$GAMEPAD_LIBRARY"
|
||||
./quick-sharun \
|
||||
/usr/bin/portprotonqt* \
|
||||
/usr/bin/update-desktop-database \
|
||||
/usr/bin/vk_gpu_info \
|
||||
"$GAMEPAD_LIBRARY" \
|
||||
/usr/lib/libQt6Core.so* \
|
||||
/usr/lib/libQt6Gui.so* \
|
||||
/usr/lib/libQt6Multimedia.so* \
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
pkgname=('portprotonqt')
|
||||
pkgver=1.3.1
|
||||
pkgver=1.3.2
|
||||
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.3.2
|
||||
%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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 1.3.1
|
||||
%global pypi_version 1.3.2
|
||||
%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
|
||||
|
||||
@@ -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,12 @@
|
||||
</developer>
|
||||
|
||||
<releases>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<release version="1.3.2" date="2026-08-10">
|
||||
<description></description>
|
||||
</release>
|
||||
<release version="1.3.1" date="2026-07-17">
|
||||
<description/>
|
||||
</release>
|
||||
<release version="1.3.0" date="2026-07-13">
|
||||
<description/>
|
||||
</release>
|
||||
|
||||
@@ -2001,7 +2001,7 @@ pw_check_vulkan_extensions () {
|
||||
local vk_gpu_info_cmd
|
||||
ext_key="supports_$(echo "${ext_name}" | tr '[:upper:]' '[:lower:]')"
|
||||
|
||||
vk_gpu_info_from_repo="$(dirname "${BASH_SOURCE[0]}")/../../../../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
|
||||
@@ -4089,6 +4089,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 +4115,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}" &
|
||||
|
||||
@@ -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,9 @@
|
||||
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,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="Boria138/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.3.2',
|
||||
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',
|
||||
|
||||
@@ -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.3.2"
|
||||
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,16 @@ 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:
|
||||
integrate_appimage()
|
||||
except (OSError, subprocess.SubprocessError, KeyError) as error:
|
||||
get_logger(__name__).error("Failed to integrate AppImage: %s", error)
|
||||
return 1
|
||||
|
||||
# Handle --reinstall-steam-compat-tool flag
|
||||
if parsed_args.reinstall_steam_compat_tool:
|
||||
success = reinstall_steam_compat_tool()
|
||||
@@ -328,11 +341,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 +690,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,7 +6,7 @@ import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from PySide6.QtCore import QStandardPaths, QThread, Signal
|
||||
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -53,10 +53,10 @@ def _remove_existing_integration(applications_dir: Path, icon_dir: Path) -> None
|
||||
(icon_dir / f"{APP_ID}{suffix}").unlink(missing_ok=True)
|
||||
|
||||
|
||||
def _rewrite_main_desktop(content: str, appimage: Path, 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 +68,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 +79,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 +92,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 +134,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 +151,18 @@ def integrate_appimage() -> Path:
|
||||
appimages_dir = Path.home() / "AppImages"
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
applications_dir = data_home / "applications"
|
||||
icon_dir = 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'}"
|
||||
icon = icon_dir / f"{APP_ID}.svg"
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
_copy_appimage(source, destination)
|
||||
shutil.copy2(resolved_icon, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination)
|
||||
_set_default_mime_handlers()
|
||||
try:
|
||||
subprocess.run(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
@@ -90,3 +117,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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,531 @@
|
||||
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"):
|
||||
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,500 @@
|
||||
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 vertical target in grid columns with column-to-column wrap."""
|
||||
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]
|
||||
if col_idx + 1 < len(columns):
|
||||
return columns[col_idx + 1][0]
|
||||
return None
|
||||
|
||||
if direction < 0:
|
||||
if row_idx - 1 >= 0:
|
||||
return columns[col_idx][row_idx - 1]
|
||||
if col_idx - 1 >= 0:
|
||||
return columns[col_idx - 1][-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 ""
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
@@ -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-16 21:24+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 ""
|
||||
|
||||
|
||||
@@ -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-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-07-22 12:06+0000\n"
|
||||
"PO-Revision-Date: 2026-07-31 06:07+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.7.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,10 @@ msgstr "Todos"
|
||||
msgid "Display Filter:"
|
||||
msgstr "Filtro de Exibição:"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Only Installed"
|
||||
msgstr "Instalado"
|
||||
|
||||
msgid "Detailed"
|
||||
msgstr "Detalhado"
|
||||
|
||||
@@ -2196,13 +2182,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 ""
|
||||
|
||||
msgid "Brief"
|
||||
msgstr "Resumido"
|
||||
|
||||
@@ -2236,11 +2224,14 @@ msgstr "Iniciar na bandeja"
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Ocultar Aba de Instalação Automática"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
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 +2254,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 +2327,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."
|
||||
|
||||
@@ -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 "Отключить скачивание контейнера"
|
||||
|
||||
@@ -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 "启用界面音效"
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -1050,22 +1046,24 @@ class MainWindow(
|
||||
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"
|
||||
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", ""),
|
||||
))
|
||||
processed_count += 1
|
||||
if processed_count == len(library):
|
||||
callback(games)
|
||||
@@ -1471,7 +1469,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 +1505,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 +1917,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()
|
||||
@@ -2309,13 +2289,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())
|
||||
|
||||
@@ -2547,7 +2535,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 +2575,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 +2584,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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -369,6 +369,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…"))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -67,8 +67,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 +220,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")
|
||||
|
||||
|
||||
@@ -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 |
@@ -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="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></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-23zm-9.0156 12.477c0.38467 0 0.76906 0.14406 1.0586 0.43359l7.957 7.957 7.959-7.957c0.579-0.57906 1.5381-0.57906 2.1172 0 0.57906 0.5956 0.57906 1.5392 0 2.1348l-7.957 7.957 7.957 7.959c0.57906 0.579 0.57906 1.5381 0 2.1172-0.29784 0.29781-0.67802 0.44727-1.0586 0.44727s-0.76079-0.14947-1.0586-0.44727l-7.959-7.959-7.957 7.9414c-0.29784 0.29781-0.67806 0.44726-1.0586 0.44726-0.38057 0-0.76275-0.14946-1.0605-0.44726-0.57906-0.579-0.57906-1.5381 0-2.1172l7.959-7.957-7.959-7.959c-0.57906-0.579-0.57906-1.5381 0-2.1172 0.2895-0.28953 0.67588-0.43359 1.0605-0.43359z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 961 B After Width: | Height: | Size: 831 B |
@@ -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="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></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="m39.43 5.2129c-12.075 0.0276-22.278-0.0073546-33.557 1.5488-2.7371 0.37765-4.8756 4.0026-4.873 6.7656l0.015625 17.988c0.00451 4.8315 6.0295 6.7439 9.4492 7.9219 4.1391 1.4258 20.597 4.9154 31.791 2.5508 2.7034-0.57104 4.749-3.3209 4.7422-6.084l-0.056641-23.467c-0.009676-3.9677-4.6888-7.231-7.5117-7.2246zm-25.598 11.6h3.457v11.916h7.0664v2.459h-10.523v-14.375zm17.723 0h2.6133v14.375h-3.2344v-10.986l-3.9199 2.1699v-2.7246l4.541-2.834z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1015 B After Width: | Height: | Size: 627 B |
@@ -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="m10.465 8.5637c4.1391-1.4258 20.596-4.9156 31.79-2.551 2.7034 0.57104 4.7508 3.32 4.744 6.0831l-0.05739 23.467c-0.0097 3.9677-4.6895 7.2319-7.5124 7.2255-12.075-0.0276-22.278 0.0069-33.557-1.5493-2.7371-0.37765-4.8753-4.0033-4.8727-6.7663l0.01681-17.988c0.0045-4.8315 6.0288-6.743 9.4485-7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 10.765c3.5492-1.2226 17.661-4.2149 27.259-2.1874 2.3181 0.48964 4.0736 2.8468 4.0678 5.216l-0.04921 20.123c-0.0083 3.4022-4.0211 6.2011-6.4416 6.1956-10.354-0.02367-19.103 0.0059-28.774-1.3285-2.347-0.32383-4.1804-3.4327-4.1782-5.802l0.01441-15.424c0.0039-4.1428 5.1694-5.7819 8.1017-6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.071 16.788h3.4578v11.925h7.0708v2.4604h-10.529zm12.59 11.105q0-1.1969 0.66496-2.0836t1.7289-1.3521q0.84229-0.37681 1.9727-0.75363 1.1304-0.37681 1.7511-0.79796 0.62063-0.42114 0.62063-0.99745v-1.8397q0-0.68713-0.55414-0.90878-0.59847-0.24382-1.8619-0.24382-1.5294 0-4.0341 0.55414v-2.4382q2.2166-0.46548 4.6326-0.46548 2.6155 0 3.879 0.79796 1.2856 0.79796 1.2856 2.6599v1.9949q0 1.1304-0.68713 1.9727-0.66496 0.82012-1.7289 1.2413-0.99745 0.42114-2.0392 0.75363-1.1304 0.39898-1.7954 0.88662-0.6428 0.48764-0.6428 1.1526v0.73146h6.9156v2.416h-10.107z" fill="#3f424d" stroke-width="0" aria-label="L2"/></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="m33.146 5.2129c-9.545 0.050775-19.577 2.2822-22.682 3.3516-3.4197 1.178-9.4447 3.0884-9.4492 7.9199l-0.015625 17.988c-0.0026 2.763 2.1359 6.388 4.873 6.7656 11.279 1.5562 21.482 1.5232 33.557 1.5508 2.8229 0.006401 7.502-3.2589 7.5117-7.2266l0.056641-23.467c0.0068-2.7631-2.0388-5.511-4.7422-6.082-2.7985-0.59115-5.9277-0.81771-9.1094-0.80078zm-2.5645 11.354c1.7437 0 3.0366 0.26686 3.8789 0.79883 0.85707 0.53197 1.2852 1.4169 1.2852 2.6582v1.9961c0 0.7536-0.22941 1.4111-0.6875 1.9727-0.44331 0.54675-1.0192 0.95945-1.7285 1.2402-0.66497 0.28076-1.3446 0.53225-2.0391 0.75391-0.7536 0.26599-1.3516 0.56163-1.7949 0.88672-0.42853 0.32509-0.64258 0.70904-0.64258 1.1523v0.73242h6.9141v2.416h-10.105l-0.001953-3.2812c0-0.79793 0.22271-1.4929 0.66602-2.084 0.44331-0.59113 1.0192-1.0413 1.7285-1.3516 0.56153-0.25121 1.219-0.50269 1.9727-0.75391 0.7536-0.25121 1.3382-0.51611 1.752-0.79688 0.41375-0.28076 0.61914-0.61384 0.61914-0.99805v-1.8398c0-0.45809-0.18331-0.76044-0.55274-0.9082-0.39898-0.16255-1.021-0.24414-1.8633-0.24414-1.0196 0-2.3634 0.18526-4.0332 0.55469v-2.4395c1.4777-0.31032 3.0221-0.46484 4.6328-0.46484zm-17.512 0.2207h3.459v11.926h7.0703v2.4609h-10.529v-14.387z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 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="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></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="m26.906 1.4434c-4.3493 0.17987-8.2904 3.156-9.4727 7.5684l-6.6152 24.684c-1.4551 5.431 1.7979 11.066 7.2285 12.521 5.431 1.4551 11.064-1.7979 12.52-7.2285l6.6152-24.686c1.4551-5.431-1.7979-11.064-7.2285-12.52-1.0183-0.27283-2.0432-0.38135-3.0469-0.33984zm-7.4043 16.848h8.9961c0.85403 0 1.5469 0.49798 1.5469 1.1133 0 0.61533-0.69288 1.1152-1.5469 1.1152h-8.9961c-0.85401 0-1.5469-0.49993-1.5469-1.1152 0-0.61533 0.69288-1.1133 1.5469-1.1133zm0 4.5957h8.9961c0.85403 0 1.5469 0.49798 1.5469 1.1133 0 0.61533-0.69288 1.1133-1.5469 1.1133h-8.9961c-0.85401 0-1.5469-0.49798-1.5469-1.1133 0-0.61533 0.69288-1.1133 1.5469-1.1133zm0 4.5938h8.9961c0.85403 0 1.5469 0.4999 1.5469 1.1152s-0.69288 1.1133-1.5469 1.1133h-8.9961c-0.85401 0-1.5469-0.49795-1.5469-1.1133s0.69288-1.1152 1.5469-1.1152z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 977 B |
@@ -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="m20.375 10.836v21.898l4.4513 1.4106v-18.447c0-1.1417 0.63761-1.8498 1.4528-1.5795 1.2195 0.33732 1.2205 1.7172 1.2205 2.2383v7.34c0.69194 0.30705 1.3479 0.46456 1.9511 0.46456 0.90817 0 1.6684-0.36594 2.2003-1.06 0.57517-0.75032 0.87844-1.8745 0.87844-3.2519 0-3.9851-1.3499-5.7267-5.562-7.1711-1.3341-0.45192-4.3372-1.3851-6.5925-1.8413zm-1.6344 13.688-6.8797 2.441c-0.02379 0.0087-1.7358 0.57835-2.724 1.3092-0.35029 0.25948-0.50605 0.57737-0.44766 0.89955 0.11028 0.60112 0.89455 1.164 2.099 1.5035 2.5191 0.83248 5.1622 1.0445 7.7159 0.62504l0.23228-0.03801v-1.9131l-2.0863 0.75596c-0.96438 0.34597-2.458 0.42428-3.2646 0.16049-0.5795-0.19028-0.70734-0.49524-0.70951-0.71795-0.0043-0.26596 0.17441-0.64535 1.022-0.95023l5.0426-1.8033zm13.269 1.1529c-0.59973-0.0075-1.1869 0.016-1.7442 0.07603-2.1515 0.23785-3.7118 0.78326-3.7291 0.78975l-0.09714 0.0338v2.3692l5.0299-1.7695c0.96439-0.34597 2.4538-0.42428 3.2604-0.16048 0.5795 0.19028 0.70734 0.49523 0.70951 0.71795 0.0021 0.26596-0.17234 0.64329-1.0178 0.94601l-7.9819 2.8465v2.2594l10.706-3.8432c0.01297-0.0043 1.421-0.53074 1.968-1.2205 0.19245-0.24218 0.25306-0.4904 0.17737-0.73907-0.09298-0.30488-0.49051-0.89192-2.0863-1.3979-1.4887-0.56436-3.3954-0.88519-5.1946-0.908z" fill="#3f424d" stroke-width="2.1623"/></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-23zm-3.625 9.8359c2.2553 0.4562 5.2577 1.3899 6.5918 1.8418 4.2121 1.4444 5.5625 3.1868 5.5625 7.1719 0 1.3774-0.30374 2.5016-0.87891 3.252-0.5319 0.69406-1.291 1.0586-2.1992 1.0586-0.6032 0-1.2592-0.15584-1.9512-0.46289v-7.3418c0-0.5211-0.001203-1.901-1.2207-2.2383-0.81519-0.2703-1.4531 0.43838-1.4531 1.5801v18.447l-4.4512-1.4102v-21.898zm-1.6348 13.688v2.2734l-5.043 1.8027c-0.84759 0.30488-1.0258 0.68326-1.0215 0.94922 0.00217 0.22271 0.12948 0.52847 0.70898 0.71875 0.8066 0.26379 2.3012 0.18581 3.2656-0.16016l2.0859-0.75586v1.9121l-0.23242 0.039062c-2.5537 0.41946-5.1957 0.20748-7.7148-0.625-1.2044-0.3395-1.9893-0.90279-2.0996-1.5039-0.05839-0.32218 0.096976-0.64091 0.44727-0.90039 0.9882-0.73085 2.7008-1.2999 2.7246-1.3086l6.8789-2.4414zm13.27 1.1543c1.7992 0.02281 3.7066 0.34189 5.1953 0.90625 1.5958 0.50598 1.993 1.0936 2.0859 1.3984 0.07569 0.24867 0.014715 0.49805-0.17774 0.74023-0.547 0.68976-1.9558 1.2144-1.9688 1.2188l-10.705 3.8438v-2.2598l7.9824-2.8457c0.84546-0.30272 1.0197-0.67935 1.0176-0.94531-0.00217-0.22272-0.13144-0.52847-0.71094-0.71875-0.8066-0.2638-2.2954-0.18581-3.2598 0.16016l-5.0293 1.7695v-2.3691l0.097656-0.033203c0.0173-0.00649 1.577-0.55121 3.7285-0.78906 0.5573-0.06003 1.1444-0.083672 1.7441-0.076172z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 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="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></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="m8.5703 5.2129c-2.8229-0.0064-7.502 3.2569-7.5117 7.2246l-0.056641 23.467c-0.0068 2.7631 2.0388 5.5129 4.7422 6.084 11.194 2.3646 27.652-1.125 31.791-2.5508 3.4197-1.178 9.4447-3.0904 9.4492-7.9219l0.015625-17.988c0.0026-2.763-2.1359-6.388-4.873-6.7656-11.279-1.5562-21.482-1.5212-33.557-1.5488zm4.2871 11.6h6.4688c1.9197 0 3.3075 0.23643 4.1641 0.70898 0.85653 0.45779 1.2852 1.2334 1.2852 2.3262v2.5234c0 0.84173-0.30274 1.5068-0.9082 1.9941-0.59069 0.47255-1.3817 0.7753-2.3711 0.9082l4.166 5.9141h-3.9883l-3.7656-5.6484h-1.5938v5.6484h-3.457v-14.375zm19.67 0h2.6152v14.375h-3.2344v-10.986l-3.9219 2.1699v-2.7246l4.541-2.834zm-16.213 2.459v3.9863h2.9668c0.8122 0 1.3668-0.10381 1.6621-0.31055 0.29535-0.22151 0.44336-0.64149 0.44336-1.2617v-1.0195c0-0.53163-0.1634-0.89396-0.48828-1.0859-0.31011-0.20674-0.8493-0.30859-1.6172-0.30859h-2.9668z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.0 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="m37.534 8.5637c-4.1391-1.4258-20.596-4.9156-31.79-2.551-2.7034 0.57104-4.7508 3.32-4.744 6.0831l0.057386 23.467c0.00968 3.9677 4.6895 7.2319 7.5124 7.2255 12.075-0.0276 22.278 0.0069 33.557-1.5493 2.7371-0.37765 4.8753-4.0033 4.8727-6.7663l-0.01681-17.988c-0.0045-4.8315-6.0288-6.743-9.4485-7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.605 10.765c-3.5492-1.2226-17.661-4.2149-27.259-2.1874-2.3181 0.48964-4.0736 2.8468-4.0678 5.216l0.049207 20.123c0.00828 3.4022 4.0211 6.2011 6.4416 6.1956 10.354-0.02367 19.103 0.0059 28.774-1.3285 2.347-0.32383 4.1804-3.4327 4.1782-5.802l-0.01441-15.424c-0.0039-4.1428-5.1694-5.7819-8.1017-6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.095 16.788h6.4725q2.8816 0 4.1672 0.70931 1.2856 0.68715 1.2856 2.3274v2.5269q0 1.2635-0.90881 1.9949-0.88664 0.70931-2.3718 0.90881l4.1672 5.9183h-3.9899l-3.7682-5.6523h-1.596v5.6523h-3.4579zm6.4281 6.4503q1.2191 0 1.6624-0.31032 0.44332-0.33249 0.44332-1.2635v-1.0196q0-0.79798-0.48765-1.0861-0.46549-0.31032-1.6181-0.31032h-2.9702v3.9899zm8.1127 4.6549q0-1.197 0.66498-2.0836t1.7289-1.3521q0.84231-0.37682 1.9728-0.75364t1.7511-0.79798 0.62065-0.99747v-1.8398q0-0.68715-0.55415-0.90881-0.59848-0.24383-1.8619-0.24383-1.5295 0-4.0342 0.55415v-2.4383q2.2166-0.46549 4.6327-0.46549 2.6156 0 3.879 0.79798 1.2856 0.79798 1.2856 2.6599v1.9949q0 1.1305-0.68715 1.9728-0.66498 0.82014-1.7289 1.2413-0.99747 0.42115-2.0393 0.75364-1.1305 0.39899-1.7954 0.88664-0.64281 0.48765-0.64281 1.1526v0.73148h6.9158v2.4161h-10.108z" fill="#3f424d" stroke-width="0" aria-label="R2"/></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="m14.852 5.2129c-3.1817-0.016925-6.3089 0.20963-9.1074 0.80078-2.7034 0.57104-4.7509 3.3189-4.7441 6.082l0.056641 23.467c0.00968 3.9677 4.6908 7.233 7.5137 7.2266 12.075-0.027599 22.278 0.005419 33.557-1.5508 2.7371-0.37765 4.8756-4.0026 4.873-6.7656l-0.017578-17.988c-0.0045-4.8315-6.0295-6.7419-9.4492-7.9199-3.1043-1.0694-13.137-3.3008-22.682-3.3516zm16.705 11.354c1.7437 0 3.0366 0.26684 3.8789 0.79883 0.85707 0.53199 1.2852 1.4169 1.2852 2.6582v1.9961c0 0.75367-0.2294 1.4111-0.6875 1.9727-0.44332 0.54676-1.0192 0.95946-1.7285 1.2402-0.66498 0.28077-1.3445 0.53225-2.0391 0.75391-0.75367 0.26599-1.3517 0.56162-1.7949 0.88672-0.42854 0.3251-0.64258 0.70904-0.64258 1.1523v0.73242h6.916v2.416h-10.109l0.001953-3.2812c0-0.798 0.22074-1.491 0.66406-2.082s1.0192-1.0432 1.7285-1.3535c0.56154-0.25121 1.219-0.50269 1.9727-0.75391s1.3382-0.5161 1.752-0.79688 0.62107-0.61384 0.62109-0.99805v-1.8398c0-0.4581-0.18525-0.76043-0.55469-0.9082-0.39899-0.16255-1.019-0.24414-1.8613-0.24414-1.0197 0-2.3654 0.18525-4.0352 0.55469v-2.4395c1.4777-0.31033 3.0221-0.46484 4.6328-0.46484zm-19.461 0.2207h6.4727c1.9211 0 3.3089 0.23806 4.166 0.71094 0.85707 0.4581 1.2852 1.2327 1.2852 2.3262v2.5273c0 0.84233-0.30233 1.5065-0.9082 1.9941-0.59109 0.47287-1.381 0.77716-2.3711 0.91016l4.166 5.918h-3.9902l-3.7676-5.6523h-1.5957v5.6523h-3.457v-14.387zm3.457 2.4609v3.9902h2.9707c0.81273 0 1.3666-0.10367 1.6621-0.31055 0.29555-0.22166 0.44336-0.643 0.44336-1.2637v-1.0195c0-0.53199-0.16318-0.89386-0.48828-1.0859-0.31033-0.20688-0.84878-0.31055-1.6172-0.31055h-2.9707z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 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="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></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="m21.094 1.4434c-1.0037-0.041509-2.0286 0.067012-3.0469 0.33984-5.4306 1.4551-8.6836 7.0885-7.2285 12.52l6.6152 24.686c1.4551 5.4306 7.0885 8.6836 12.52 7.2285 5.4306-1.4551 8.6836-7.0905 7.2285-12.521l-6.6152-24.684c-1.1823-4.4124-5.1233-7.3885-9.4727-7.5684zm2.9395 18.973c0.54232 0 0.98242 0.4097 0.98242 0.91602v5.3359c0 0.50631-0.4401 0.91602-0.98242 0.91602s-0.98242-0.4097-0.98242-0.91602v-5.3359c0-0.50631 0.4401-0.91602 0.98242-0.91602zm-5.9609 0.7207c0.35102-0.00376 0.7 0.16534 0.89258 0.4668l2.9492 4.6191c0.28012 0.43853 0.12786 0.99127-0.3418 1.2363-0.46962 0.24507-1.0773 0.088871-1.3574-0.34961l-2.9512-4.6191c-0.28012-0.43847-0.12786-0.99322 0.3418-1.2383 0.14677-0.07658 0.30725-0.11353 0.4668-0.11523zm11.855 0c0.15956 0.00171 0.32003 0.038654 0.4668 0.11523 0.46966 0.24505 0.62386 0.7998 0.34375 1.2383l-2.9512 4.6191c-0.2801 0.43848-0.88775 0.59467-1.3574 0.34961-0.46966-0.24511-0.62386-0.79785-0.34375-1.2363l2.9512-4.6191c0.19258-0.30146 0.5396-0.4705 0.89062-0.4668z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.2 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="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" 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-23zm-9.0176 12.477h18.037c0.84398 0 1.5059 0.67846 1.5059 1.5059v18.037c0 0.82743-0.67846 1.5059-1.5059 1.5059h-18.037c-0.82743 0-1.5059-0.67846-1.5059-1.5059v-18.037c1e-6 -0.82743 0.67846-1.5059 1.5059-1.5059zm1.5215 3.0098v15.027h15.025v-15.027h-15.025z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 682 B After Width: | Height: | Size: 521 B |
@@ -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="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></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 10.461c0.58626 0 1.1725 0.26376 1.4727 0.78906l10.225 17.709c0.30016 0.5253 0.30016 1.1817 0 1.707-0.319 0.52529-0.88209 0.8457-1.4824 0.8457h-20.447-0.001953c-0.59959-6.95e-4 -1.1631-0.32098-1.4629-0.8457-0.30016-0.5253-0.30016-1.1817 0-1.707l10.225-17.709c0.3002-0.5253 0.8864-0.78906 1.4727-0.78906zm-0.009766 5.0664-7.2793 12.588h14.539l-7.2598-12.588z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 628 B |
@@ -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="m21.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></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-23zm-2.9844 12.475h6.1621l7.5898 21.049h-5.125l-1.8809-5.5449h-7.6855l-1.8496 5.5449h-4.9941l7.7832-21.049zm2.8535 4.2168-2.6582 7.9141h5.4492l-2.6602-7.9141h-0.13086z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 600 B After Width: | Height: | Size: 433 B |
@@ -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="m15.973 13.476h8.5299q3.0163 0 4.6379 0.45406 1.6541 0.42163 2.3352 1.3946 0.68109 0.94056 0.68109 2.6595v2.5946q0 0.87569-0.71353 1.6541-0.68109 0.77839-1.6216 1.0703v0.16216q1.2325 0.12973 2.2379 1.0703 1.0379 0.90812 1.0379 2.0433v3.2433q0 2.5622-2.0433 3.6325t-6.3244 1.0703h-8.7569zm8.5299 8.5623q1.2 0 1.7838-0.1946t0.77839-0.61623q0.22703-0.45406 0.22703-1.2973v-1.0379q0-0.74596-0.1946-1.1027-0.1946-0.3892-0.81082-0.55136-0.58379-0.16216-1.8811-0.16216h-3.373v4.9622zm0.12973 8.8866q1.8487 0 2.6271-0.42163t0.77839-1.3622v-1.6865q0-1.1676-0.61623-1.6541-0.58379-0.4865-2.1081-0.4865h-4.2812v5.6109z" fill="#3f424d" stroke-width=".67675" aria-label="B"/></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-23zm-8.0273 12.477h8.5293c2.0109 0 3.5576 0.15042 4.6387 0.45313 1.1027 0.28109 1.8819 0.74588 2.3359 1.3945 0.45406 0.62704 0.67969 1.5142 0.67969 2.6602v2.5938c0 0.58379-0.2372 1.1354-0.71289 1.6543-0.45406 0.51893-0.99409 0.87571-1.6211 1.0703v0.16211c0.82167 0.086486 1.568 0.44327 2.2383 1.0703 0.69193 0.60541 1.0371 1.2862 1.0371 2.043v3.2441c0 1.7081-0.68077 2.9193-2.043 3.6328s-3.4702 1.0703-6.3242 1.0703h-8.7578v-21.049zm5.0605 3.5996v4.9629h3.4688c0.8 1e-6 1.396-0.065579 1.7852-0.19531s0.64762-0.3361 0.77734-0.61719c0.15135-0.30271 0.22852-0.73472 0.22852-1.2969v-1.0371c0-0.49731-0.065579-0.86569-0.19531-1.1035-0.12973-0.25947-0.39973-0.44267-0.81055-0.55078-0.38919-0.10811-1.016-0.16211-1.8809-0.16211h-3.373zm0 8.2383v5.6113h3.5996c1.2325 0 2.108-0.14079 2.627-0.42188 0.51893-0.28109 0.7793-0.73623 0.7793-1.3633v-1.6855c1e-6 -0.7784-0.20637-1.33-0.61719-1.6543-0.38919-0.32433-1.0912-0.48633-2.1074-0.48633h-4.2812z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.2 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="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m13.757 18h2.8844v9.9476h5.8983v2.0524h-8.7827zm10.724 0h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.0169-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="LB"/></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="m6.3652 12.914c-2.9719 1e-6 -5.3652 2.3933-5.3652 5.3652v11.441c0 2.9719 2.3933 5.3652 5.3652 5.3652h35.27c2.9719 0 5.3652-2.3933 5.3652-5.3652v-11.441c0-2.9719-2.3933-5.3652-5.3652-5.3652h-35.27zm7.3926 5.0859h2.8828v9.9473h5.8984v2.0527h-8.7812v-12zm10.723 0h4.8633c1.1464 0 2.0282 0.087192 2.6445 0.25977 0.62866 0.16025 1.0712 0.42512 1.3301 0.79492 0.25886 0.35747 0.38867 0.8623 0.38867 1.5156v1.4785c0 0.33282-0.13506 0.64752-0.40625 0.94336-0.25886 0.29584-0.56636 0.49844-0.92383 0.60937v0.09375c0.46841 0.049307 0.89327 0.2519 1.2754 0.60938 0.39445 0.34515 0.5918 0.73456 0.5918 1.166v1.8477c0 0.9738-0.38942 1.6655-1.166 2.0723-0.7766 0.40678-1.9784 0.60938-3.6055 0.60938h-4.9922v-12zm2.8848 2.0527v2.8281h1.9785c0.45609 0 0.79573-0.037368 1.0176-0.11133 0.22188-0.07396 0.3694-0.18936 0.44336-0.34961 0.086286-0.17257 0.12891-0.41974 0.12891-0.74023v-0.5918c0-0.28351-0.037368-0.49331-0.11133-0.62891-0.07396-0.14792-0.22673-0.25282-0.46094-0.31445-0.22188-0.061633-0.57921-0.091797-1.0723-0.091797h-1.9238zm0 4.6953v3.1992h2.0527c0.7026 0 1.2022-0.079988 1.498-0.24024 0.29584-0.16025 0.44336-0.41792 0.44336-0.77539v-0.96289c0-0.44376-0.11736-0.75846-0.35156-0.94336-0.22188-0.1849-0.62185-0.27734-1.2012-0.27734h-2.4414z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.4 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="m37.506 42.702c-5.8157 2.6863-40.223 11.738-34.399-8.8905 2.3364-10.846-2.0972-14.476-2.0781-24.008 0.0097-4.8346 4.6895-8.812 7.5124-8.8042 12.075 0.03363 22.278-0.00841 33.557 1.8878 2.7371 0.46016 4.8753 4.878 4.8727 8.2446l-0.01681 21.918c-0.0045 5.8871-6.0288 8.2162-9.4485 9.6516z" fill="#3f424d" stroke-width="1.2669"/><path d="m35.577 40.02c-12.867 3.4211-33.737 9.1348-29.564-6.2972 1.4436-7.4093-1.0628-14.813-2.081-22.197-0.80858-3.739 4.3887-7.2714 6.8092-7.2647 10.354 0.028841 19.103-0.00719 28.774 1.6188 2.347 0.39458 4.1804 4.1827 4.1782 7.0696l-0.01441 18.794c-0.0039 5.0479-5.1694 7.0452-8.1017 8.2759z" fill="#fff" stroke-width="1.0863"/><path d="m13.052 16.169h3.5064v12.092h7.17v2.4949h-10.676zm16.408 2.4949h-4.4953v-2.4949h12.475v2.4949h-4.4728v12.092h-3.5064z" fill="#3f424d" stroke-width="0" aria-label="LT"/></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="m8.541 1c-2.8229-0.0078-7.502 3.9681-7.5117 8.8027-0.0191 9.532 4.4145 13.162 2.0781 24.008-5.824 20.628 28.583 11.577 34.398 8.8906 3.4197-1.4354 9.4447-3.7652 9.4492-9.6523l0.015625-21.918c0.0026-3.3666-2.1359-7.784-4.873-8.2441-11.279-1.8962-21.482-1.8531-33.557-1.8867zm4.5117 15.17h3.5059v12.092h7.1699v2.4941h-10.676v-14.586zm11.912 0h12.475v2.4941h-4.4727v12.092h-3.5059v-12.092h-4.4961v-2.4941z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 969 B After Width: | Height: | Size: 593 B |
@@ -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="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m12.943 18h5.3991q2.4037 0 3.4761 0.59168 1.0724 0.57319 1.0724 1.9414v2.1079q0 1.0539-0.75809 1.6641-0.7396 0.59168-1.9784 0.75809l3.4761 4.9368h-3.3282l-3.1433-4.7149h-1.3313v4.7149h-2.8844zm5.3621 5.3806q1.0169 0 1.3867-0.25886 0.3698-0.27735 0.3698-1.0539v-0.85054q0-0.66564-0.40678-0.90601-0.38829-0.25886-1.3498-0.25886h-2.4777v3.3282zm6.9892-5.3806h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.017-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="RB"/></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="m6.3652 12.914c-2.9719 1e-6 -5.3652 2.3933-5.3652 5.3652v11.441c0 2.9719 2.3933 5.3652 5.3652 5.3652h35.27c2.9719 0 5.3652-2.3933 5.3652-5.3652v-11.441c0-2.9719-2.3933-5.3652-5.3652-5.3652h-35.27zm6.5781 5.0859h5.3984c1.6025 0 2.7616 0.19734 3.4766 0.5918 0.71493 0.38213 1.0723 1.0293 1.0723 1.9414v2.1074c0 0.7026-0.25242 1.2573-0.75781 1.6641-0.49307 0.39445-1.1526 0.64687-1.9785 0.75781l3.4766 4.9375h-3.3281l-3.1445-4.7148h-1.3301v4.7148h-2.8848v-12zm12.352 0h4.8613c1.1464 0 2.0282 0.087192 2.6445 0.25977 0.62866 0.16025 1.0732 0.42512 1.332 0.79492 0.25886 0.35747 0.38867 0.8623 0.38867 1.5156v1.4785c0 0.33282-0.13702 0.64752-0.4082 0.94336-0.25886 0.29584-0.56636 0.49844-0.92383 0.60937v0.09375c0.46841 0.049307 0.89327 0.2519 1.2754 0.60938 0.39445 0.34515 0.5918 0.73456 0.5918 1.166v1.8477c0 0.9738-0.38746 1.6655-1.1641 2.0723-0.7766 0.40678-1.9784 0.60938-3.6055 0.60938h-4.9922v-12zm-9.4668 2.0527v3.3281h2.4766c0.67793 0 1.1402-0.087192 1.3867-0.25976 0.24653-0.1849 0.37109-0.53504 0.37109-1.0527v-0.85156c0-0.44376-0.13702-0.746-0.4082-0.90625-0.25886-0.17257-0.7086-0.25781-1.3496-0.25781h-2.4766zm12.352 0v2.8281h1.9766c0.45609 0 0.79566-0.037368 1.0176-0.11133 0.22188-0.07396 0.3694-0.18936 0.44336-0.34961 0.086286-0.17257 0.13086-0.41974 0.13086-0.74023v-0.5918c0-0.28351-0.037368-0.49331-0.11133-0.62891-0.07396-0.14792-0.22868-0.25282-0.46289-0.31445-0.22188-0.061633-0.57921-0.091797-1.0723-0.091797h-1.9219zm0 4.6953v3.1992h2.0508c0.7026 0 1.2022-0.079988 1.498-0.24024 0.29584-0.16025 0.44336-0.41792 0.44336-0.77539v-0.96289c0-0.44376-0.1154-0.75846-0.34961-0.94336-0.22188-0.1849-0.6238-0.27734-1.2031-0.27734h-2.4395z" fill="#3f424d"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.8 KiB |