Compare commits
	
		
			107 Commits
		
	
	
		
			v0.1.6
			...
			cde92885d4
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| cde92885d4 | |||
| 120c7b319c | |||
| 596aed0077 | |||
| 6fc6cb1e02 | |||
| 186e28a19b | |||
| 28e4d1e77c | |||
| fff1f888c4 | |||
| fdd5a0a3d5 | |||
| 792e52d981 | |||
| 84d5e46a74 | |||
| 4bc764d568 | |||
| 9a18aa037e | |||
| ed62d2d1c4 | |||
| accc9b18b6 | |||
| 82249d7eab | |||
| 476c896940 | |||
| b1047ba18e | |||
| 987199d8e6 | |||
|  | ef1acd4581 | ||
| 96f884904c | |||
| b856a2afae | |||
| 55ef0030e6 | |||
| 8aaeaa4824 | |||
| f55372b480 | |||
| 4d6f32f053 | |||
| a2f5141b20 | |||
| e3cb2857e7 | |||
| efe8a35832 | |||
| 61fae97dad | |||
| 5442100f64 | |||
| 2d6ef84798 | |||
|  | f4aee15b5d | ||
| 87a65108a5 | |||
| bb617708ac | |||
| 1cf332cd87 | |||
| 577ad4d3a3 | |||
| ef3f2d6e96 | |||
| 657d7728a6 | |||
| 9452bfda2e | |||
| 7eb2db0d68 | |||
| 6ef7a03366 | |||
| e5af354b56 | |||
| e6e5f6c8ea | |||
| 84306bb31b | |||
| 60af4d1482 | |||
| 692e11b21d | |||
| b1a804811e | |||
| 9a30cfaea7 | |||
| 5dd2f71f5e | |||
| dba172361b | |||
| a9c70b8818 | |||
| 135ace732f | |||
| 8b727f64e1 | |||
| a8eb591da5 | |||
| fe4ca1ee87 | |||
| ffe3e9d3d6 | |||
| 49d39b5d61 | |||
|  | 03566da704 | ||
|  | 7f996ab6a0 | ||
|  | 9e17978155 | ||
| 5d0185b1b4 | |||
| 5c134be04e | |||
| 8c66695192 | |||
| 7a141d8e46 | |||
| abb2377fb7 | |||
| 75f4f346de | |||
| 87a9f85272 | |||
| 240f685ece | |||
| af4e3e95bb | |||
| 017d9a42cf | |||
| 18b7c4054b | |||
| dd7f71b70a | |||
| 8fd44c575b | |||
| 65b43c1572 | |||
| f35276abfe | |||
| 6fea9a9a7e | |||
| 5189474631 | |||
|  | 416cc6a268 | ||
|  | 3b44ed5252 | ||
| c8c45dda06 | |||
| 3f9f794e6f | |||
| ba9d8b76d8 | |||
| e99c71c1f8 | |||
| baec62d1cb | |||
| cb76961e4f | |||
|  | 081cd07253 | ||
| b5efee29ea | |||
| 69360f7e7e | |||
|  | 39712f0591 | ||
|  | 60b508af18 | ||
|  | b6637b4163 | ||
|  | 6d9eed42f8 | ||
| 7372e3b7f5 | |||
| e0d5bd7993 | |||
|  | 12f8067af1 | ||
|  | 716a813ca9 | ||
| c62cc6853f | |||
| 2e018b4690 | |||
| ad5b25f713 | |||
| 3fb8201305 | |||
| 04d8302d6c | |||
|  | f868b21178 | ||
|  | ebe25b41d8 | ||
|  | fae6cad52d | ||
|  | 42bce11ada | ||
| f088c01768 | |||
| e7eee85ed4 | 
| @@ -12,17 +12,27 @@ jobs: | ||||
|     name: Build AppImage | ||||
|     runs-on: ubuntu-22.04 | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Install required dependencies | ||||
|         run: | | ||||
|             sudo apt update | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme | ||||
|  | ||||
|       - name: Install tools | ||||
|       - name: Upgrade pip toolchain | ||||
|         run: | | ||||
|             pip3 install git+https://github.com/Boria138/appimage-builder.git | ||||
|             pip3 install uv | ||||
|           python3 -m pip install --upgrade \ | ||||
|             pip setuptools setuptools-scm wheel packaging build | ||||
|  | ||||
|       - name: Install appimage-builder | ||||
|         run: | | ||||
|           git clone https://github.com/Boria138/appimage-builder | ||||
|           cd appimage-builder | ||||
|           pip install . | ||||
|  | ||||
|       - name: Install uv | ||||
|         run: | | ||||
|           pip install uv | ||||
|  | ||||
|       - name: Build AppImage | ||||
|         run: | | ||||
| @@ -63,7 +73,7 @@ jobs: | ||||
|           echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros | ||||
|  | ||||
|       - name: Checkout repo | ||||
|         uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Copy fedora.spec | ||||
|         run: | | ||||
| @@ -84,7 +94,7 @@ jobs: | ||||
|     name: Build Arch Package | ||||
|     runs-on: ubuntu-22.04 | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023 | ||||
|       image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
| @@ -124,7 +134,7 @@ jobs: | ||||
|           su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" | ||||
|  | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Upload Arch package | ||||
|         uses: https://gitea.com/actions/gitea-upload-artifact@v4 | ||||
|   | ||||
| @@ -8,7 +8,7 @@ on: | ||||
|  | ||||
| env: | ||||
|   # Common version, will be used for tagging the release | ||||
|   VERSION: 0.1.6 | ||||
|   VERSION: 0.1.7 | ||||
|   PKGDEST: "/tmp/portprotonqt" | ||||
|   PACKAGE: "portprotonqt" | ||||
|   GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} | ||||
| @@ -23,12 +23,22 @@ jobs: | ||||
|       - name: Install required dependencies | ||||
|         run: | | ||||
|             sudo apt update | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme | ||||
|  | ||||
|       - name: Install tools | ||||
|       - name: Upgrade pip toolchain | ||||
|         run: | | ||||
|             pip3 install git+https://github.com/Boria138/appimage-builder.git | ||||
|             pip3 install uv | ||||
|           python3 -m pip install --upgrade \ | ||||
|             pip setuptools setuptools-scm wheel packaging build | ||||
|  | ||||
|       - name: Install appimage-builder | ||||
|         run: | | ||||
|           git clone https://github.com/Boria138/appimage-builder | ||||
|           cd appimage-builder | ||||
|           pip install . | ||||
|  | ||||
|       - name: Install uv | ||||
|         run: | | ||||
|           pip install uv | ||||
|  | ||||
|       - name: Build AppImage | ||||
|         run: | | ||||
| @@ -170,6 +180,8 @@ jobs: | ||||
|  | ||||
|       - name: Release | ||||
|         uses: https://gitea.com/actions/gitea-release-action@v1 | ||||
|         env: | ||||
|             NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18 | ||||
|         with: | ||||
|           body_path: changelog.txt | ||||
|           token: ${{ env.GITEA_TOKEN }} | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| name: Check Translations | ||||
| name: Check Translations (disabled until yaspeller is fixed) | ||||
| run-name: Check spelling in translation files | ||||
| on: | ||||
|   push: | ||||
| @@ -12,10 +12,11 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   check-translations: | ||||
|     if: false | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Python | ||||
|         uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | ||||
|   | ||||
| @@ -18,7 +18,7 @@ jobs: | ||||
|       fedora:   ${{ steps.check.outputs.fedora }} | ||||
|       arch:     ${{ steps.check.outputs.arch }} | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|  | ||||
| @@ -63,7 +63,7 @@ jobs: | ||||
|     needs: changes | ||||
|     if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch' | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Install required dependencies | ||||
|         run: | | ||||
| @@ -115,7 +115,7 @@ jobs: | ||||
|           echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros | ||||
|  | ||||
|       - name: Checkout repo | ||||
|         uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Copy fedora-git.spec | ||||
|         run: | | ||||
| @@ -138,7 +138,7 @@ jobs: | ||||
|     needs: changes | ||||
|     if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023 | ||||
|       image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
| @@ -178,7 +178,7 @@ jobs: | ||||
|           su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" | ||||
|  | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Upload Arch package | ||||
|         uses: https://gitea.com/actions/gitea-upload-artifact@v4 | ||||
|   | ||||
| @@ -20,10 +20,10 @@ jobs: | ||||
|     name: Check code | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Node.js | ||||
|         uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | ||||
|         uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 | ||||
|         with: | ||||
|           node-version: 20 | ||||
|  | ||||
|   | ||||
| @@ -11,7 +11,7 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Python | ||||
|         uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | ||||
|   | ||||
| @@ -8,12 +8,12 @@ on: | ||||
| jobs: | ||||
|   renovate: | ||||
|     runs-on: ubuntu-latest | ||||
|     container: ghcr.io/renovatebot/renovate:latest@sha256:46b57bb9816dec6409e7be57e0e5f7b26d214281044f5aedd3b160be178475e2 | ||||
|     container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6 | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Node.js | ||||
|         uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 | ||||
|         uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 | ||||
|         with: | ||||
|           node-version: 20 | ||||
|  | ||||
|   | ||||
| @@ -11,12 +11,12 @@ repos: | ||||
|       - id: check-yaml | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/uv-pre-commit | ||||
|     rev: 0.8.9 | ||||
|     rev: 0.8.22 | ||||
|     hooks: | ||||
|       - id: uv-lock | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.12.8 | ||||
|     rev: v0.14.0 | ||||
|     hooks: | ||||
|       - id: ruff-check | ||||
|  | ||||
|   | ||||
							
								
								
									
										51
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						| @@ -3,6 +3,54 @@ | ||||
| Все заметные изменения в этом проекте фиксируются в этом файле. | ||||
| Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). | ||||
|  | ||||
| ## [Unreleased] | ||||
|  | ||||
| ### Added | ||||
| - В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению | ||||
|  | ||||
| ### Changed | ||||
| - При завершении автоустановки приложение больше не перезапускается | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлено наложение карточек при смене фильтра игр | ||||
|  | ||||
|  | ||||
| ### Contributors | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## [0.1.7] - 2025-10-12 | ||||
|  | ||||
| ### Added | ||||
| - Возможность скроллинга библиотеки мышью или пальцем | ||||
| - Импорт и экспорт бекапа префикса | ||||
| - Диалог для управление Winetricks | ||||
| - Кнопки для удаления префикса, wine или proton | ||||
| - Все настройки Wine с оригинального PortProton | ||||
| - Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках | ||||
| - Вкладка автоустановок | ||||
| - В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита | ||||
|  | ||||
| ### Changed | ||||
| - Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр | ||||
| - В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений | ||||
| - Исправлено зависание при добавлении или удалении игры в Wayland | ||||
| - Исправлено зависание при поиске игр | ||||
| - Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity) | ||||
| - Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада | ||||
| - Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена | ||||
| - При сохранении настроек теперь не меняется размер окна | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## [0.1.6] - 2025-09-23 | ||||
|  | ||||
| ### Added | ||||
| @@ -16,12 +64,13 @@ | ||||
| ### Changed | ||||
| - Управления с геймпада теперь перехватывается только если окно в фокусе | ||||
|  | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON | ||||
| - Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -54,8 +54,6 @@ PortProtonQt использует код и зависимости от след | ||||
| - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE). | ||||
| - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE). | ||||
| - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md). | ||||
| - [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/) | ||||
|  | ||||
| Полный текст лицензий см. в файле [LICENSE](LICENSE). | ||||
|  | ||||
| > [!WARNING] | ||||
|   | ||||
							
								
								
									
										15
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,6 @@ | ||||
| - [X] Адаптировать структуру проекта для поддержки инструментов сборки | ||||
| - [X] Добавить возможность управления с геймпада | ||||
| - [ ] Добавить возможность управления с тачскрина | ||||
| - [X] Добавить возможность управления с тачскрина (Формально и так есть) | ||||
| - [X] Добавить возможность управления с мыши и клавиатуры | ||||
| - [X] Добавить систему тем [Документация](documentation/theme_guide) | ||||
| - [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено) | ||||
| @@ -11,18 +11,18 @@ | ||||
| - [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800) | ||||
| - [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots) | ||||
| - [X] Получать описания и названия игр из базы данных Steam | ||||
| - [X] Получать обложки для игр из SteamGridDB или CDN Steam | ||||
| - [X] Получать обложки для игр из CDN Steam | ||||
| - [X] Оптимизировать работу со Steam API для ускорения времени запуска | ||||
| - [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley) | ||||
| - [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода | ||||
| - [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода | ||||
| - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) | ||||
| - [X] Избавиться от вызовов yad | ||||
| - [X] Реализовать собственный системный трей вместо использования трея PortProton | ||||
| - [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.) | ||||
| - [X] Добавить экранную клавиатуру в поиск | ||||
| - [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) | ||||
| - [X] Добавить индикацию запуска приложения | ||||
| - [X] Достигнуть паритета функциональности с Ingame | ||||
| - [ ] Достигнуть паритета функциональности с PortProton | ||||
| - [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов) | ||||
| - [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}` | ||||
| - [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/) | ||||
| - [X] Добавить переводы в переопределения | ||||
| @@ -49,7 +49,7 @@ | ||||
| - [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter) | ||||
| - [X] Добавить систему избранного для карточек | ||||
| - [X] Заменить все `print` на `logging` | ||||
| - [ ] Привести все логи к единому языку | ||||
| - [X] Привести все логи к единому языку | ||||
| - [X] Уменьшить количество подстановок в переводах | ||||
| - [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog) | ||||
| - [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py` | ||||
| @@ -62,7 +62,6 @@ | ||||
| - [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63) | ||||
| - [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)) | ||||
| - [X] Скопировать логику управления с D-pad на стрелки с клавиатуры | ||||
| - [ ] Доделать светлую тему | ||||
| - [ ] Добавить подсказки к управлению с геймпада | ||||
| - [X] Добавить подсказки к управлению с геймпада | ||||
| - [X] Добавить миниатюры к выбору файлов в диалоге добавления игры | ||||
| - [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры | ||||
|   | ||||
| @@ -1,16 +1,11 @@ | ||||
| version: 1 | ||||
| script: | ||||
|   # 1) чистим старый AppDir | ||||
|   - rm -rf AppDir || true | ||||
|   # 2) создаём структуру каталога | ||||
|   - mkdir -p AppDir/usr/local/lib/python3.10/dist-packages | ||||
|   # 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml | ||||
|   - uv venv | ||||
|   - uv pip install --no-cache-dir ../ | ||||
|   # 4) копируем всё из .venv в AppDir | ||||
|   - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages | ||||
|   - cp -r share AppDir/usr | ||||
|   # 5) чистим от ненужных модулей и бинарников | ||||
|   - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ | ||||
|   - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate} | ||||
|   - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*} | ||||
| @@ -19,7 +14,6 @@ script: | ||||
| AppDir: | ||||
|   path: ./AppDir | ||||
|   after_bundle: | ||||
|     # Документация, справка, примеры | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/man || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/doc || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/doc-base || true | ||||
| @@ -35,17 +29,14 @@ AppDir: | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/metainfo || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/include || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true | ||||
|     # Статика и отладка | ||||
|     - find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true | ||||
|     # Strip ELF бинарников (исключая Python extensions) | ||||
|     - "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true" | ||||
|     # Удаление пустых папок | ||||
|     - find $TARGET_APPDIR -type d -empty -delete || true | ||||
|   app_info: | ||||
|     id: ru.linux_gaming.PortProtonQt | ||||
|     name: PortProtonQt | ||||
|     icon: ru.linux_gaming.PortProtonQt | ||||
|     version: 0.1.6 | ||||
|     version: 0.1.7 | ||||
|     exec: usr/bin/python3 | ||||
|     exec_args: "-m portprotonqt.app $@" | ||||
|   apt: | ||||
| @@ -63,16 +54,18 @@ AppDir: | ||||
|       - libxcb-cursor0 | ||||
|       - libimage-exiftool-perl | ||||
|       - xdg-utils | ||||
|       - cabextract | ||||
|       - curl | ||||
|       - 7zip | ||||
|       - unzip | ||||
|       - unrar | ||||
|     exclude: | ||||
|       # Документация и man-страницы | ||||
|       - "*-doc" | ||||
|       - "*-man" | ||||
|       - manpages | ||||
|       - mandb | ||||
|       # Статические библиотеки | ||||
|       - "*-dev" | ||||
|       - "*-static" | ||||
|       # Дебаг-символы | ||||
|       - "*-dbg" | ||||
|       - "*-dbgsym" | ||||
|   runtime: | ||||
| @@ -83,3 +76,4 @@ AppDir: | ||||
| AppImage: | ||||
|   sign-key: None | ||||
|   arch: x86_64 | ||||
|   comp: zstd | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| pkgname=portprotonqt | ||||
| pkgver=0.1.6 | ||||
| pkgver=0.1.7 | ||||
| pkgrel=1 | ||||
| pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" | ||||
| arch=('any') | ||||
| url="https://git.linux-gaming.ru/Boria138/PortProtonQt" | ||||
| license=('GPL-3.0') | ||||
| depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client') | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') | ||||
| makedepends=('python-'{'build','installer','setuptools','wheel'}) | ||||
| source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver") | ||||
| sha256sums=('SKIP') | ||||
|   | ||||
| @@ -6,7 +6,7 @@ arch=('any') | ||||
| url="https://git.linux-gaming.ru/Boria138/PortProtonQt" | ||||
| license=('GPL-3.0') | ||||
| depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client') | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') | ||||
| makedepends=('python-'{'build','installer','setuptools','wheel'}) | ||||
| source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git") | ||||
| sha256sums=('SKIP') | ||||
|   | ||||
| @@ -46,6 +46,11 @@ Requires:       python3-pillow | ||||
| Requires:       perl-Image-ExifTool | ||||
| Requires:       xdg-utils | ||||
| Requires:       python3-beautifulsoup4 | ||||
| Requires:       cabextract | ||||
| Requires:       gzip | ||||
| Requires:       unzip | ||||
| Requires:       curl | ||||
| Requires:       unrar | ||||
|  | ||||
| %description -n python3-%{pypi_name}-git | ||||
| This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup. | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| %global pypi_name portprotonqt | ||||
| %global pypi_version 0.1.6 | ||||
| %global pypi_version 0.1.7 | ||||
| %global oname PortProtonQt | ||||
| %global _python_no_extras_requires 1 | ||||
|  | ||||
| @@ -43,6 +43,11 @@ Requires:       python3-pillow | ||||
| Requires:       perl-Image-ExifTool | ||||
| Requires:       xdg-utils | ||||
| Requires:       python3-beautifulsoup4 | ||||
| Requires:       cabextract | ||||
| Requires:       gzip | ||||
| Requires:       unzip | ||||
| Requires:       curl | ||||
| Requires:       unrar | ||||
|  | ||||
| %description -n python3-%{pypi_name} | ||||
| This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup. | ||||
|   | ||||
| @@ -217,7 +217,7 @@ | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "watch_dogs 2", | ||||
|     "status": "Broken" | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "zero hour", | ||||
|   | ||||
							
								
								
									
										12688
									
								
								data/games_appid.json
									
									
									
									
									
								
							
							
						
						| @@ -1,4 +1,96 @@ | ||||
| [ | ||||
|   { | ||||
|     "normalized_title": "dirt rally 2.0 game of the year", | ||||
|     "slug": "dirt-rally-2-0-game-of-the-year-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "deus ex human revolution director’s cut", | ||||
|     "slug": "deus-ex-human-revolution-director-s-cut" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "freelancer", | ||||
|     "slug": "freelancer" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "everspace", | ||||
|     "slug": "everspace" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "blades of time limited", | ||||
|     "slug": "blades-of-time-limited-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "chorus", | ||||
|     "slug": "chorus" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "tom clancy's splinter cell pandora tomorrow", | ||||
|     "slug": "tom-clancys-splinter-cell-pandora-tomorrow" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "the alters", | ||||
|     "slug": "the-alters" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "hard reset redux", | ||||
|     "slug": "hard-reset-redux" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "far cry 5", | ||||
|     "slug": "far-cry-5" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "metal eden", | ||||
|     "slug": "metal-eden" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "indiana jones and the great circle", | ||||
|     "slug": "indiana-jones-and-the-great-circle" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "old world", | ||||
|     "slug": "old-world" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "witchfire", | ||||
|     "slug": "witchfire" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "prototype", | ||||
|     "slug": "prototype" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "mandragora whispers of the witch tree", | ||||
|     "slug": "mandragora-whispers-of-the-witch-tree" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "grand theft auto v (gta 5)", | ||||
|     "slug": "grand-theft-auto-v-gta-5" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "lifeless planet premier", | ||||
|     "slug": "lifeless-planet-premier-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "warcraft iii the frozen throne", | ||||
|     "slug": "warcraft-iii-the-frozen-throne" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "star wars republic commando", | ||||
|     "slug": "star-wars-republic-commando" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "hollow knight silksong", | ||||
|     "slug": "hollow-knight-silksong" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "arma reforger", | ||||
|     "slug": "arma-reforger" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "arma 3", | ||||
|     "slug": "arma-3" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "astroneer", | ||||
|     "slug": "astroneer" | ||||
| @@ -195,10 +287,6 @@ | ||||
|     "normalized_title": "slitterhead", | ||||
|     "slug": "slitterhead" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "indiana jones and the great circle", | ||||
|     "slug": "indiana-jones-and-the-great-circle" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "crossout", | ||||
|     "slug": "crossout" | ||||
|   | ||||
| @@ -2,6 +2,7 @@ | ||||
|  | ||||
| import argparse | ||||
| import re | ||||
| import subprocess | ||||
| from pathlib import Path | ||||
| from datetime import date | ||||
|  | ||||
| @@ -134,6 +135,12 @@ def main(): | ||||
|         print(f"Updated version from {old} to {new} in {len(updated)} files:") | ||||
|         for p in sorted(updated): | ||||
|             print(f" - {p}") | ||||
|  | ||||
|         try: | ||||
|             subprocess.run(["uv", "lock"], check=True) | ||||
|             print("Regenerated uv.lock") | ||||
|         except subprocess.CalledProcessError as e: | ||||
|             print(f"Failed to regenerate uv.lock: {e}") | ||||
|     else: | ||||
|         print(f"No occurrences of version {old} found in specified files.") | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ Current translation status: | ||||
|  | ||||
| | Locale | Progress | Translated | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 204 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 204 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 204 of 204 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ | ||||
|  | ||||
| | Локаль | Прогресс | Переведено | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 204 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 204 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 204 из 204 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,41 @@ | ||||
| import sys | ||||
| import os | ||||
| import subprocess | ||||
| from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo | ||||
| from PySide6.QtWidgets import QApplication | ||||
| from PySide6.QtGui import QIcon | ||||
| from portprotonqt.main_window import MainWindow | ||||
| from portprotonqt.config_utils import save_fullscreen_config | ||||
| from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location | ||||
| from portprotonqt.logger import get_logger, setup_logger | ||||
| from portprotonqt.cli import parse_args | ||||
|  | ||||
| __app_id__ = "ru.linux_gaming.PortProtonQt" | ||||
| __app_name__ = "PortProtonQt" | ||||
| __app_version__ = "0.1.6" | ||||
| __app_version__ = "0.1.7" | ||||
|  | ||||
| def get_version(): | ||||
|     try: | ||||
|         commit = subprocess.check_output( | ||||
|             ['git', 'rev-parse', '--short', 'HEAD'], | ||||
|             stderr=subprocess.DEVNULL | ||||
|         ).decode('utf-8').strip() | ||||
|         return f"{__app_version__} ({commit})" | ||||
|     except (subprocess.CalledProcessError, FileNotFoundError, OSError): | ||||
|         return __app_version__ | ||||
|  | ||||
| def main(): | ||||
|     os.environ['PW_CLI'] = '1' | ||||
|     os.environ['PROCESS_LOG'] = '1' | ||||
|     os.environ['START_FROM_STEAM'] = '1' | ||||
|  | ||||
|     portproton_path = get_portproton_location() | ||||
|  | ||||
|     if portproton_path is None: | ||||
|         return | ||||
|  | ||||
|     script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh') | ||||
|     subprocess.run([script_path, 'cli', '--initial']) | ||||
|  | ||||
|     app = QApplication(sys.argv) | ||||
|     app.setWindowIcon(QIcon.fromTheme(__app_id__)) | ||||
|     app.setDesktopFileName(__app_id__) | ||||
| @@ -34,7 +58,8 @@ def main(): | ||||
|     else: | ||||
|         logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language") | ||||
|  | ||||
|     window = MainWindow(app_name=__app_name__) | ||||
|     version = get_version() | ||||
|     window = MainWindow(app_name=__app_name__, version=version) | ||||
|  | ||||
|     if args.fullscreen: | ||||
|         logger.info("Launching in fullscreen mode due to --fullscreen flag") | ||||
|   | ||||
| @@ -1,17 +1,15 @@ | ||||
| import argparse | ||||
| from portprotonqt.logger import get_logger | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| def parse_args(): | ||||
|     """ | ||||
|     Парсит аргументы командной строки. | ||||
|     Parses command-line arguments. | ||||
|     """ | ||||
|     parser = argparse.ArgumentParser(description="PortProtonQt CLI") | ||||
|     parser.add_argument( | ||||
|         "--fullscreen", | ||||
|         action="store_true", | ||||
|         help="Запустить приложение в полноэкранном режиме и сохранить эту настройку" | ||||
|         help="Launch the application in fullscreen mode and save this setting" | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--debug-level", | ||||
|   | ||||
| @@ -259,6 +259,25 @@ def save_rumble_config(rumble_enabled): | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_gamepad_type(): | ||||
|     """Reads the gamepad type from the [Gamepad] section. | ||||
|     Returns 'xbox' if the parameter is missing. | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"): | ||||
|         save_gamepad_type("xbox") | ||||
|         return "xbox" | ||||
|     return cp.get("Gamepad", "type", fallback="xbox").lower() | ||||
|  | ||||
| def save_gamepad_type(gpad_type): | ||||
|     """Saves the gamepad type to the [Gamepad] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Gamepad" not in cp: | ||||
|         cp["Gamepad"] = {} | ||||
|     cp["Gamepad"]["type"] = gpad_type | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def ensure_default_proxy_config(): | ||||
|     """Ensures the [Proxy] section exists in the configuration file. | ||||
|     Creates it with empty values if missing. | ||||
| @@ -408,3 +427,22 @@ def save_favorite_folders(folders): | ||||
|     cp["FavoritesFolders"]["folders"] = f'"{fav_str}"' | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_minimize_to_tray(): | ||||
|     """Reads the minimize-to-tray setting from the [Display] section. | ||||
|     Returns True if the parameter is missing (default: minimize to tray). | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"): | ||||
|         save_minimize_to_tray(True) | ||||
|         return True | ||||
|     return cp.getboolean("Display", "minimize_to_tray", fallback=True) | ||||
|  | ||||
| def save_minimize_to_tray(minimize_to_tray): | ||||
|     """Saves the minimize-to-tray setting to the [Display] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Display" not in cp: | ||||
|         cp["Display"] = {} | ||||
|     cp["Display"]["minimize_to_tray"] = str(minimize_to_tray) | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|   | ||||
| @@ -29,7 +29,7 @@ class ContextMenuSignals(QObject): | ||||
| class ContextMenuManager: | ||||
|     """Manages context menu actions for game management in PortProtonQt.""" | ||||
|  | ||||
|     def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback): | ||||
|     def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager): | ||||
|         """ | ||||
|         Initialize the ContextMenuManager. | ||||
|  | ||||
| @@ -45,7 +45,8 @@ class ContextMenuManager: | ||||
|         self.theme = theme | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.load_games = load_games_callback | ||||
|         self.update_game_grid = update_game_grid_callback | ||||
|         self.game_library_manager = game_library_manager | ||||
|         self.update_game_grid = game_library_manager.update_game_grid | ||||
|         self.legendary_path = os.path.join( | ||||
|             os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), | ||||
|             "PortProtonQt", "legendary_cache", "legendary" | ||||
| @@ -62,7 +63,7 @@ class ContextMenuManager: | ||||
|                 self.parent.statusBar().showMessage, | ||||
|                 Qt.ConnectionType.QueuedConnection | ||||
|             ) | ||||
|             logger.debug("Connected show_status_message signal to statusBar") | ||||
|             logger.debug("Connected show_status_message signal to status bar") | ||||
|         self.signals.show_warning_dialog.connect( | ||||
|             self._show_warning_dialog, | ||||
|             Qt.ConnectionType.QueuedConnection | ||||
| @@ -74,28 +75,28 @@ class ContextMenuManager: | ||||
|  | ||||
|     def _show_warning_dialog(self, title: str, message: str): | ||||
|         """Show a warning dialog in the main thread.""" | ||||
|         logger.debug("Showing warning dialog: %s - %s", title, message) | ||||
|         logger.debug("Displaying warning dialog: %s - %s", title, message) | ||||
|         QMessageBox.warning(self.parent, title, message) | ||||
|  | ||||
|     def _show_info_dialog(self, title: str, message: str): | ||||
|         """Show an info dialog in the main thread.""" | ||||
|         logger.debug("Showing info dialog: %s - %s", title, message) | ||||
|         logger.debug("Displaying info dialog: %s - %s", title, message) | ||||
|         QMessageBox.information(self.parent, title, message) | ||||
|  | ||||
|     def _show_status_message(self, message: str, timeout: int = 3000): | ||||
|         """Show a status message on the status bar if available.""" | ||||
|         if self.parent.statusBar(): | ||||
|             self.parent.statusBar().showMessage(message, timeout) | ||||
|             logger.debug("Direct status message: %s", message) | ||||
|             logger.debug("Displayed status message: %s", message) | ||||
|         else: | ||||
|             logger.warning("Status bar not available for message: %s", message) | ||||
|             logger.warning("Status bar unavailable for message: %s", message) | ||||
|  | ||||
|     def _check_portproton(self): | ||||
|         """Check if PortProton is available.""" | ||||
|         if self.portproton_location is None: | ||||
|             self.signals.show_warning_dialog.emit( | ||||
|                 _("Error"), | ||||
|                 _("PortProton is not found") | ||||
|                 _("PortProton directory not found") | ||||
|             ) | ||||
|             return False | ||||
|         return True | ||||
| @@ -119,7 +120,7 @@ class ContextMenuManager: | ||||
|                 installed_games = orjson.loads(f.read()) | ||||
|             return app_name in installed_games | ||||
|         except (OSError, orjson.JSONDecodeError) as e: | ||||
|             logger.error("Failed to read installed.json: %s", e) | ||||
|             logger.error("Error reading installed.json: %s", e) | ||||
|             return False | ||||
|  | ||||
|     def _is_game_running(self, game_card) -> bool: | ||||
| @@ -155,7 +156,7 @@ class ContextMenuManager: | ||||
|         try: | ||||
|             item = file_explorer.file_list.itemAt(pos) | ||||
|             if not item: | ||||
|                 logger.debug("No item selected at position %s", pos) | ||||
|                 logger.debug("No folder selected at position %s", pos) | ||||
|                 return | ||||
|             selected = item.text() | ||||
|             if not selected.endswith("/"): | ||||
| @@ -202,7 +203,7 @@ class ContextMenuManager: | ||||
|             global_pos = file_explorer.file_list.mapToGlobal(pos) | ||||
|             menu.exec(global_pos) | ||||
|         except Exception as e: | ||||
|             logger.error("Error showing folder context menu: %s", e) | ||||
|             logger.error("Error displaying folder context menu: %s", e) | ||||
|  | ||||
|     def toggle_favorite_folder(self, file_explorer, folder_path, add): | ||||
|         """Adds or removes a folder from favorites.""" | ||||
| @@ -211,12 +212,12 @@ class ContextMenuManager: | ||||
|             if folder_path not in favorite_folders: | ||||
|                 favorite_folders.append(folder_path) | ||||
|                 save_favorite_folders(favorite_folders) | ||||
|                 logger.info(f"Folder added to favorites: {folder_path}") | ||||
|                 logger.info("Added folder to favorites: %s", folder_path) | ||||
|         else: | ||||
|             if folder_path in favorite_folders: | ||||
|                 favorite_folders.remove(folder_path) | ||||
|                 save_favorite_folders(favorite_folders) | ||||
|                 logger.info(f"Folder removed from favorites: {folder_path}") | ||||
|                 logger.info("Removed folder from favorites: %s", folder_path) | ||||
|         file_explorer.update_drives_list() | ||||
|  | ||||
|     def _get_safe_icon(self, icon_name: str) -> QIcon: | ||||
| @@ -607,10 +608,10 @@ class ContextMenuManager: | ||||
|         exe_path = get_egs_executable(app_name, self.legendary_config_path) | ||||
|         if exe_path and os.path.exists(exe_path): | ||||
|             if not generate_thumbnail(exe_path, icon_path, size=128): | ||||
|                 logger.error(f"Failed to generate thumbnail from exe: {exe_path}") | ||||
|                 logger.error("Failed to generate thumbnail for EGS game: %s", exe_path) | ||||
|                 icon_path = "" | ||||
|         else: | ||||
|             logger.error(f"No executable found for EGS game: {app_name}") | ||||
|             logger.error("No executable found for EGS game: %s", app_name) | ||||
|             icon_path = "" | ||||
|  | ||||
|         egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops") | ||||
| @@ -750,7 +751,7 @@ Icon={icon_path} | ||||
|                     if not exec_line: | ||||
|                         self.signals.show_warning_dialog.emit( | ||||
|                             _("Error"), | ||||
|                             _("No executable command in .desktop file for '{game_name}'").format(game_name=game_name) | ||||
|                             _("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name) | ||||
|                         ) | ||||
|                         return None | ||||
|                 else: | ||||
| @@ -762,7 +763,7 @@ Icon={icon_path} | ||||
|             except Exception as e: | ||||
|                 self.signals.show_warning_dialog.emit( | ||||
|                     _("Error"), | ||||
|                     _("Failed to read .desktop file: {error}").format(error=str(e)) | ||||
|                     _("Error reading .desktop file: {error}").format(error=str(e)) | ||||
|                 ) | ||||
|                 return None | ||||
|         else: | ||||
| @@ -784,7 +785,7 @@ Icon={icon_path} | ||||
|         try: | ||||
|             entry_exec_split = shlex.split(exec_line) | ||||
|             if not entry_exec_split: | ||||
|                 logger.debug("Invalid executable command for '%s': %s", game_name, exec_line) | ||||
|                 logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line) | ||||
|                 return None | ||||
|             if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3: | ||||
|                 exe_path = entry_exec_split[2] | ||||
| @@ -793,11 +794,11 @@ Icon={icon_path} | ||||
|             else: | ||||
|                 exe_path = entry_exec_split[-1] | ||||
|             if not exe_path or not os.path.exists(exe_path): | ||||
|                 logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None") | ||||
|                 logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None") | ||||
|                 return None | ||||
|             return exe_path | ||||
|         except Exception as e: | ||||
|             logger.debug("Failed to parse executable for '%s': %s", game_name, e) | ||||
|             logger.debug("Error parsing executable for game '%s': %s", game_name, e) | ||||
|             return None | ||||
|  | ||||
|     def _remove_file(self, file_path, error_message, success_message, game_name, location=""): | ||||
| @@ -859,9 +860,16 @@ Icon={icon_path} | ||||
|                         _("Failed to delete custom data: {error}").format(error=str(e)) | ||||
|                     ) | ||||
|  | ||||
|         # Reload games list and update grid | ||||
|         self.load_games() | ||||
|         self.update_game_grid() | ||||
|         self.update_game_grid = self.game_library_manager.remove_game_incremental | ||||
|         self.game_library_manager.remove_game_incremental(game_name, exec_line) | ||||
|  | ||||
|     def add_game_incremental(self, game_data: tuple): | ||||
|         """Add game after .desktop creation.""" | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|         # Assume game_data is built from new .desktop (name, desc, cover, etc.) | ||||
|         self.game_library_manager.add_game_incremental(game_data) | ||||
|         self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0])) | ||||
|  | ||||
|     def add_to_menu(self, game_name, exec_line): | ||||
|         """Copy the .desktop file to ~/.local/share/applications.""" | ||||
| @@ -936,7 +944,7 @@ Icon={icon_path} | ||||
|         icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png") | ||||
|         if not os.path.exists(icon_path): | ||||
|             if not generate_thumbnail(exe_path, icon_path, size=128): | ||||
|                 logger.error(f"Failed to generate thumbnail for {exe_path}") | ||||
|                 logger.error("Failed to generate thumbnail for game: %s", exe_path) | ||||
|  | ||||
|         desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip() | ||||
|         os.makedirs(desktop_dir, exist_ok=True) | ||||
| @@ -1072,7 +1080,7 @@ Icon={icon_path} | ||||
|         exe_path = self._parse_exe_path(exec_line, game_name) | ||||
|         if not exe_path: | ||||
|             return | ||||
|         logger.debug("Adding '%s' to Steam", game_name) | ||||
|         logger.debug("Adding game '%s' to Steam", game_name) | ||||
|         try: | ||||
|             success, message = add_to_steam(game_name, exec_line, cover_path) | ||||
|             self.signals.show_info_dialog.emit( | ||||
| @@ -1115,7 +1123,7 @@ Icon={icon_path} | ||||
|             exe_path = self._parse_exe_path(exec_line, game_name) | ||||
|             if not exe_path: | ||||
|                 return | ||||
|             logger.debug("Removing non-EGS game '%s' from Steam", game_name) | ||||
|             logger.debug("Removing game '%s' from Steam", game_name) | ||||
|             try: | ||||
|                 success, message = remove_from_steam(game_name, exec_line) | ||||
|                 self.signals.show_info_dialog.emit( | ||||
|   | ||||
| Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB | 
| Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB | 
| Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB | 
| Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB | 
| Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB | 
| Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB | 
| Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB | 
| @@ -5,29 +5,29 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter | ||||
|  | ||||
| def compute_layout(nat_sizes, rect_width, spacing, max_scale): | ||||
|     """ | ||||
|     Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек. | ||||
|     nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота). | ||||
|     rect_width: доступная ширина контейнера. | ||||
|     spacing: отступ между элементами (горизонтальный и вертикальный). | ||||
|     max_scale: максимальный коэффициент масштабирования (например, 1.0). | ||||
|     Computes the layout of elements considering spacing and potential scaling of cards. | ||||
|     nat_sizes: Array (N, 2) with natural sizes of elements (width, height). | ||||
|     rect_width: Available container width. | ||||
|     spacing: Spacing between elements (horizontal and vertical). | ||||
|     max_scale: Maximum scaling factor (e.g., 1.0). | ||||
|  | ||||
|     Возвращает: | ||||
|       result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height]. | ||||
|       total_height: итоговая высота всех рядов. | ||||
|     Returns: | ||||
|       result: Array (N, 4), where each row contains [x, y, new_width, new_height]. | ||||
|       total_height: Total height of all rows. | ||||
|     """ | ||||
|     N = nat_sizes.shape[0] | ||||
|     result = np.zeros((N, 4), dtype=np.int32) | ||||
|     y = 0 | ||||
|     i = 0 | ||||
|     min_margin = 20  # Минимальный отступ по краям | ||||
|     min_margin = 20  # Minimum margin on edges | ||||
|  | ||||
|     # Определяем максимальное количество элементов в ряду и общий масштаб | ||||
|     # Determine the maximum number of items per row and overall scale | ||||
|     max_items_per_row = 0 | ||||
|     global_scale = 1.0 | ||||
|     max_row_x_start = min_margin  # Начальная позиция x самого длинного ряда | ||||
|     max_row_x_start = min_margin  # Starting x position of the widest row | ||||
|     temp_i = 0 | ||||
|  | ||||
|     # Первый проход: находим максимальное количество элементов в ряду | ||||
|     # First pass: Find the maximum number of items in a row | ||||
|     while temp_i < N: | ||||
|         sum_width = 0 | ||||
|         count = 0 | ||||
| @@ -42,23 +42,23 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale): | ||||
|  | ||||
|         if count > max_items_per_row: | ||||
|             max_items_per_row = count | ||||
|             # Вычисляем масштаб для самого заполненного ряда | ||||
|             # Calculate scale for the most populated row | ||||
|             available_width = rect_width - spacing * (count - 1) - 2 * min_margin | ||||
|             desired_scale = available_width / sum_width if sum_width > 0 else 1.0 | ||||
|             global_scale = desired_scale if desired_scale < max_scale else max_scale | ||||
|             # Сохраняем начальную позицию x для самого длинного ряда | ||||
|             # Store starting x position for the widest row | ||||
|             scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1) | ||||
|             max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2) | ||||
|         temp_i = temp_j | ||||
|  | ||||
|     # Второй проход: размещаем элементы | ||||
|     # Second pass: Place elements | ||||
|     while i < N: | ||||
|         sum_width = 0 | ||||
|         row_max_height = 0 | ||||
|         count = 0 | ||||
|         j = i | ||||
|  | ||||
|         # Подбираем количество элементов для текущего ряда | ||||
|         # Determine the number of items for the current row | ||||
|         while j < N: | ||||
|             w = nat_sizes[j, 0] | ||||
|             if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin: | ||||
| @@ -70,16 +70,16 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale): | ||||
|                 row_max_height = h | ||||
|             j += 1 | ||||
|  | ||||
|         # Используем глобальный масштаб для всех рядов | ||||
|         # Use global scale for all rows | ||||
|         scale = global_scale | ||||
|         scaled_row_width = int(sum_width * scale) + spacing * (count - 1) | ||||
|  | ||||
|         # Определяем начальную координату x | ||||
|         # Determine starting x coordinate | ||||
|         if count == max_items_per_row: | ||||
|             # Центрируем полный ряд | ||||
|             # Center the full row | ||||
|             x = max(min_margin, (rect_width - scaled_row_width) // 2) | ||||
|         else: | ||||
|             # Выравниваем неполный ряд по левому краю, совмещая с началом самого длинного ряда | ||||
|             # Align incomplete row to the left, matching the widest row's start | ||||
|             x = max_row_x_start | ||||
|  | ||||
|         for k in range(i, j): | ||||
| @@ -99,9 +99,9 @@ class FlowLayout(QLayout): | ||||
|     def __init__(self, parent=None): | ||||
|         super().__init__(parent) | ||||
|         self.itemList = [] | ||||
|         self.setContentsMargins(20, 20, 20, 20)  # Отступы по краям | ||||
|         self._spacing = 20  # Отступ для анимации и предотвращения перекрытий | ||||
|         self._max_scale = 1.0  # Отключено масштабирование в layout | ||||
|         self.setContentsMargins(20, 20, 20, 20)  # Margins around the layout | ||||
|         self._spacing = 20  # Spacing for animation and overlap prevention | ||||
|         self._max_scale = 1.0  # Scaling disabled in layout | ||||
|  | ||||
|     def addItem(self, item: QLayoutItem) -> None: | ||||
|         self.itemList.append(item) | ||||
| @@ -126,7 +126,21 @@ class FlowLayout(QLayout): | ||||
|         return True | ||||
|  | ||||
|     def heightForWidth(self, width): | ||||
|         return self.doLayout(QRect(0, 0, width, 0), True) | ||||
|         # Аналогично фильтруем видимые для тестового расчёта высоты | ||||
|         visible_items = [] | ||||
|         nat_sizes = np.empty((0, 2), dtype=np.int32) | ||||
|         for item in self.itemList: | ||||
|             if item.widget() and item.widget().isVisible(): | ||||
|                 visible_items.append(item) | ||||
|                 s = item.sizeHint() | ||||
|                 new_row = np.array([[s.width(), s.height()]], dtype=np.int32) | ||||
|                 nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row | ||||
|  | ||||
|         if len(visible_items) == 0: | ||||
|             return 0 | ||||
|  | ||||
|         _, total_height = compute_layout(nat_sizes, width, self._spacing, self._max_scale) | ||||
|         return total_height | ||||
|  | ||||
|     def setGeometry(self, rect): | ||||
|         super().setGeometry(rect) | ||||
| @@ -145,26 +159,46 @@ class FlowLayout(QLayout): | ||||
|         return size | ||||
|  | ||||
|     def doLayout(self, rect, testOnly): | ||||
|         N = len(self.itemList) | ||||
|         if N == 0: | ||||
|         N_total = len(self.itemList) | ||||
|         if N_total == 0: | ||||
|             return 0 | ||||
|  | ||||
|         nat_sizes = np.empty((N, 2), dtype=np.int32) | ||||
|         # Фильтруем только видимые элементы | ||||
|         visible_items = [] | ||||
|         visible_indices = []  # Индексы в оригинальном itemList для установки геометрии | ||||
|         nat_sizes = np.empty((0, 2), dtype=np.int32) | ||||
|         for i, item in enumerate(self.itemList): | ||||
|             s = item.sizeHint() | ||||
|             nat_sizes[i, 0] = s.width() | ||||
|             nat_sizes[i, 1] = s.height() | ||||
|             if item.widget() and item.widget().isVisible(): | ||||
|                 visible_items.append(item) | ||||
|                 visible_indices.append(i) | ||||
|                 s = item.sizeHint() | ||||
|                 new_row = np.array([[s.width(), s.height()]], dtype=np.int32) | ||||
|                 nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row | ||||
|  | ||||
|         N = len(visible_items) | ||||
|         if N == 0: | ||||
|             # Если все скрыты, устанавливаем нулевые геометрии для всех | ||||
|             if not testOnly: | ||||
|                 for item in self.itemList: | ||||
|                     item.setGeometry(QRect()) | ||||
|             return 0 | ||||
|  | ||||
|         geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale) | ||||
|  | ||||
|         if not testOnly: | ||||
|             for i, item in enumerate(self.itemList): | ||||
|                 x = geom_array[i, 0] + rect.x() | ||||
|                 y = geom_array[i, 1] + rect.y() | ||||
|                 w = geom_array[i, 2] | ||||
|                 h = geom_array[i, 3] | ||||
|             # Устанавливаем геометрии только для видимых | ||||
|             for idx, (_vis_idx, item) in enumerate(zip(visible_indices, visible_items, strict=True)): | ||||
|                 x = geom_array[idx, 0] + rect.x() | ||||
|                 y = geom_array[idx, 1] + rect.y() | ||||
|                 w = geom_array[idx, 2] | ||||
|                 h = geom_array[idx, 3] | ||||
|                 item.setGeometry(QRect(QPoint(x, y), QSize(w, h))) | ||||
|  | ||||
|             # Для невидимых — нулевая геометрия | ||||
|             for i in range(N_total): | ||||
|                 if i not in visible_indices: | ||||
|                     self.itemList[i].setGeometry(QRect()) | ||||
|  | ||||
|         return total_height | ||||
|  | ||||
| class ClickableLabel(QLabel): | ||||
|   | ||||
| @@ -12,6 +12,7 @@ from portprotonqt.downloader import Downloader | ||||
| from portprotonqt.animations import GameCardAnimations | ||||
| from typing import cast | ||||
|  | ||||
|  | ||||
| class GameCard(QFrame): | ||||
|     borderWidthChanged = Signal() | ||||
|     gradientAngleChanged = Signal() | ||||
| @@ -447,6 +448,7 @@ class GameCard(QFrame): | ||||
|     gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged)) | ||||
|     scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged)) | ||||
|  | ||||
|  | ||||
|     def paintEvent(self, event): | ||||
|         super().paintEvent(event) | ||||
|         self.animations.paint_border(QPainter(self)) | ||||
|   | ||||
							
								
								
									
										467
									
								
								portprotonqt/game_library_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,467 @@ | ||||
| from typing import Protocol | ||||
| from portprotonqt.game_card import GameCard | ||||
| from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller | ||||
| from PySide6.QtCore import Qt, QTimer | ||||
| from portprotonqt.custom_widgets import FlowLayout | ||||
| from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size | ||||
| from portprotonqt.image_utils import load_pixmap_async | ||||
| from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit | ||||
| from collections import deque | ||||
|  | ||||
| class MainWindowProtocol(Protocol): | ||||
|     """Protocol defining the interface that MainWindow must implement for GameLibraryManager.""" | ||||
|  | ||||
|     def openGameDetailPage( | ||||
|         self, | ||||
|         name: str, | ||||
|         description: str, | ||||
|         cover_path: str | None = None, | ||||
|         appid: str = "", | ||||
|         exec_line: str = "", | ||||
|         controller_support: str = "", | ||||
|         last_launch: str = "", | ||||
|         formatted_playtime: str = "", | ||||
|         protondb_tier: str = "", | ||||
|         game_source: str = "", | ||||
|         anticheat_status: str = "", | ||||
|     ) -> None: ... | ||||
|  | ||||
|     def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ... | ||||
|  | ||||
|     def on_slider_released(self) -> None: ... | ||||
|  | ||||
|     # Required attributes | ||||
|     searchEdit: CustomLineEdit | ||||
|     _last_card_width: int | ||||
|     current_hovered_card: GameCard | None | ||||
|     current_focused_card: GameCard | None | ||||
|     gamesListWidget: QWidget | None | ||||
|  | ||||
| class GameLibraryManager: | ||||
|     def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None): | ||||
|         self.main_window = main_window | ||||
|         self.theme = theme | ||||
|         self.context_menu_manager: ContextMenuManager | None = context_menu_manager | ||||
|         self.games: list[tuple] = [] | ||||
|         self.filtered_games: list[tuple] = [] | ||||
|         self.game_card_cache = {} | ||||
|         self.pending_images = {} | ||||
|         self.card_width = read_card_size() | ||||
|         self.gamesListWidget: QWidget | None = None | ||||
|         self.gamesListLayout: FlowLayout | None = None | ||||
|         self.sizeSlider: QSlider | None = None | ||||
|         self._update_timer: QTimer | None = None | ||||
|         self._pending_update = False | ||||
|         self.pending_deletions = deque() | ||||
|         self.is_filtering = False | ||||
|         self.dirty = False | ||||
|  | ||||
|     def create_games_library_widget(self): | ||||
|         """Creates the games library widget with search, grid, and slider.""" | ||||
|         self.gamesLibraryWidget = QWidget() | ||||
|         self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) | ||||
|         layout = QVBoxLayout(self.gamesLibraryWidget) | ||||
|         layout.setSpacing(15) | ||||
|  | ||||
|         # Search widget | ||||
|         searchWidget, self.searchEdit = self.main_window.createSearchWidget() | ||||
|         layout.addWidget(searchWidget) | ||||
|  | ||||
|         # Scroll area for game grid | ||||
|         scrollArea = QScrollArea() | ||||
|         scrollArea.setWidgetResizable(True) | ||||
|         scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE) | ||||
|         QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture) | ||||
|  | ||||
|         self.gamesListWidget = QWidget() | ||||
|         self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE) | ||||
|         self.gamesListLayout = FlowLayout(self.gamesListWidget) | ||||
|         self.gamesListWidget.setLayout(self.gamesListLayout) | ||||
|  | ||||
|         scrollArea.setWidget(self.gamesListWidget) | ||||
|         layout.addWidget(scrollArea) | ||||
|  | ||||
|         # Slider for card size | ||||
|         sliderLayout = QHBoxLayout() | ||||
|         sliderLayout.addStretch() | ||||
|  | ||||
|         self.sizeSlider = QSlider(Qt.Orientation.Horizontal) | ||||
|         self.sizeSlider.setMinimum(200) | ||||
|         self.sizeSlider.setMaximum(250) | ||||
|         self.sizeSlider.setValue(self.card_width) | ||||
|         self.sizeSlider.setTickInterval(10) | ||||
|         self.sizeSlider.setFixedWidth(150) | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) | ||||
|         self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released) | ||||
|         sliderLayout.addWidget(self.sizeSlider) | ||||
|  | ||||
|         layout.addLayout(sliderLayout) | ||||
|  | ||||
|         # Initialize update timer | ||||
|         self._update_timer = QTimer() | ||||
|         self._update_timer.setSingleShot(True) | ||||
|         self._update_timer.setInterval(100)  # 100ms debounce | ||||
|         self._update_timer.timeout.connect(self._perform_update) | ||||
|  | ||||
|         # Calculate initial card width | ||||
|         def calculate_card_width(): | ||||
|             if self.gamesListLayout is None: | ||||
|                 return | ||||
|             available_width = scrollArea.width() - 20 | ||||
|             spacing = self.gamesListLayout._spacing | ||||
|             target_cards_per_row = 8 | ||||
|             calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row | ||||
|             calculated_width = max(200, min(calculated_width, 250)) | ||||
|  | ||||
|         QTimer.singleShot(0, calculate_card_width) | ||||
|  | ||||
|         # Connect scroll event for lazy loading | ||||
|         scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images) | ||||
|  | ||||
|         return self.gamesLibraryWidget | ||||
|  | ||||
|     def on_slider_released(self): | ||||
|         """Handles slider release to update card size.""" | ||||
|         if self.sizeSlider is None: | ||||
|             return | ||||
|         self.card_width = self.sizeSlider.value() | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         save_card_size(self.card_width) | ||||
|         for card in self.game_card_cache.values(): | ||||
|             card.update_card_size(self.card_width) | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def load_visible_images(self): | ||||
|         """Loads images for visible game cards.""" | ||||
|         if self.gamesListWidget is None: | ||||
|             return | ||||
|         visible_region = self.gamesListWidget.visibleRegion() | ||||
|         max_concurrent_loads = 5 | ||||
|         loaded_count = 0 | ||||
|         for card_key, card in self.game_card_cache.items(): | ||||
|             if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads: | ||||
|                 cover_path, width, height, callback = self.pending_images.pop(card_key) | ||||
|                 load_pixmap_async(cover_path, width, height, callback) | ||||
|                 loaded_count += 1 | ||||
|  | ||||
|     def _on_card_focused(self, game_name: str, is_focused: bool): | ||||
|         """Handles card focus events.""" | ||||
|         card_key = None | ||||
|         for key, card in self.game_card_cache.items(): | ||||
|             if card.name == game_name: | ||||
|                 card_key = key | ||||
|                 break | ||||
|  | ||||
|         if not card_key: | ||||
|             return | ||||
|  | ||||
|         card = self.game_card_cache[card_key] | ||||
|  | ||||
|         if is_focused: | ||||
|             if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: | ||||
|                 self.main_window.current_hovered_card._hovered = False | ||||
|                 self.main_window.current_hovered_card.leaveEvent(None) | ||||
|                 self.main_window.current_hovered_card = None | ||||
|             if self.main_window.current_focused_card and self.main_window.current_focused_card != card: | ||||
|                 self.main_window.current_focused_card._focused = False | ||||
|                 self.main_window.current_focused_card.clearFocus() | ||||
|             self.main_window.current_focused_card = card | ||||
|         else: | ||||
|             if self.main_window.current_focused_card == card: | ||||
|                 self.main_window.current_focused_card = None | ||||
|  | ||||
|     def _on_card_hovered(self, game_name: str, is_hovered: bool): | ||||
|         """Handles card hover events.""" | ||||
|         card_key = None | ||||
|         for key, card in self.game_card_cache.items(): | ||||
|             if card.name == game_name: | ||||
|                 card_key = key | ||||
|                 break | ||||
|  | ||||
|         if not card_key: | ||||
|             return | ||||
|  | ||||
|         card = self.game_card_cache[card_key] | ||||
|  | ||||
|         if is_hovered: | ||||
|             if self.main_window.current_focused_card and self.main_window.current_focused_card != card: | ||||
|                 self.main_window.current_focused_card._focused = False | ||||
|                 self.main_window.current_focused_card.clearFocus() | ||||
|             if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: | ||||
|                 self.main_window.current_hovered_card._hovered = False | ||||
|                 self.main_window.current_hovered_card.leaveEvent(None) | ||||
|             self.main_window.current_hovered_card = card | ||||
|         else: | ||||
|             if self.main_window.current_hovered_card == card: | ||||
|                 self.main_window.current_hovered_card = None | ||||
|  | ||||
|     def _perform_update(self): | ||||
|         """Performs the actual grid update.""" | ||||
|         if not self._pending_update: | ||||
|             return | ||||
|         self._pending_update = False | ||||
|         self._update_game_grid_immediate() | ||||
|  | ||||
|     def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False): | ||||
|         """Schedules a game grid update with debouncing.""" | ||||
|         if not is_filter: | ||||
|             if games_list is not None: | ||||
|                 self.filtered_games = games_list | ||||
|             self.dirty = True  # Full rebuild only for non-filter | ||||
|         self.is_filtering = is_filter | ||||
|         self._pending_update = True | ||||
|  | ||||
|         if self._update_timer is not None: | ||||
|             self._update_timer.start() | ||||
|         else: | ||||
|             self._update_game_grid_immediate() | ||||
|  | ||||
|     def force_update_cards_library(self): | ||||
|         if self.gamesListWidget and self.gamesListLayout: | ||||
|             self.gamesListLayout.invalidate() | ||||
|             self.gamesListWidget.updateGeometry() | ||||
|             widget = self.gamesListWidget | ||||
|             QTimer.singleShot(0, lambda: ( | ||||
|                 widget.adjustSize(), | ||||
|                 widget.updateGeometry() | ||||
|             )) | ||||
|  | ||||
|     def _update_game_grid_immediate(self): | ||||
|         """Updates the game grid with the provided or current game list.""" | ||||
|         if self.gamesListLayout is None or self.gamesListWidget is None: | ||||
|             return | ||||
|  | ||||
|         search_text = self.main_window.searchEdit.text().strip().lower() | ||||
|  | ||||
|         if self.is_filtering: | ||||
|             # Filter mode: do not change layout, only hide/show cards | ||||
|             self._apply_filter_visibility(search_text) | ||||
|         else: | ||||
|             # Full update: sorting, removal/addition, reorganization | ||||
|             games_list = self.filtered_games if self.filtered_games else self.games | ||||
|             favorites = read_favorites() | ||||
|             sort_method = read_sort_method() | ||||
|  | ||||
|             # Batch layout updates (extended scope) | ||||
|             self.gamesListWidget.setUpdatesEnabled(False) | ||||
|             if self.gamesListLayout is not None: | ||||
|                 self.gamesListLayout.setEnabled(False)  # Disable layout during batch | ||||
|  | ||||
|             try: | ||||
|                 # Optimized sorting: Partition favorites first, then sort subgroups | ||||
|                 def partition_sort_key(game): | ||||
|                     name = game[0] | ||||
|                     is_fav = name in favorites | ||||
|                     fav_order = 0 if is_fav else 1 | ||||
|                     if sort_method == "playtime": | ||||
|                         return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0) | ||||
|                     elif sort_method == "alphabetical": | ||||
|                         return (fav_order, name.lower()) | ||||
|                     elif sort_method == "favorites": | ||||
|                         return (fav_order,) | ||||
|                     else: | ||||
|                         return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0) | ||||
|  | ||||
|                 # Quick partition: Sort favorites and non-favorites separately, then merge | ||||
|                 fav_games = [g for g in games_list if g[0] in favorites] | ||||
|                 non_fav_games = [g for g in games_list if g[0] not in favorites] | ||||
|                 sorted_fav = sorted(fav_games, key=partition_sort_key) | ||||
|                 sorted_non_fav = sorted(non_fav_games, key=partition_sort_key) | ||||
|                 sorted_games = sorted_fav + sorted_non_fav | ||||
|  | ||||
|                 # Build set of current game keys for faster lookup | ||||
|                 current_game_keys = {(game[0], game[4]) for game in sorted_games} | ||||
|  | ||||
|                 # Remove cards that no longer exist (batch) | ||||
|                 cards_to_remove = [] | ||||
|                 for card_key in list(self.game_card_cache.keys()): | ||||
|                     if card_key not in current_game_keys: | ||||
|                         cards_to_remove.append(card_key) | ||||
|  | ||||
|                 for card_key in cards_to_remove: | ||||
|                     card = self.game_card_cache.pop(card_key) | ||||
|                     if self.gamesListLayout is not None: | ||||
|                         self.gamesListLayout.removeWidget(card) | ||||
|                     self.pending_deletions.append(card)  # Defer | ||||
|                     if card_key in self.pending_images: | ||||
|                         del self.pending_images[card_key] | ||||
|  | ||||
|                 # Track current layout order (only if dirty/full update needed) | ||||
|                 if self.dirty and self.gamesListLayout is not None: | ||||
|                     current_layout_order = [] | ||||
|                     for i in range(self.gamesListLayout.count()): | ||||
|                         item = self.gamesListLayout.itemAt(i) | ||||
|                         if item is not None: | ||||
|                             widget = item.widget() | ||||
|                             if widget: | ||||
|                                 for key, card in self.game_card_cache.items(): | ||||
|                                     if card == widget: | ||||
|                                         current_layout_order.append(key) | ||||
|                                         break | ||||
|                 else: | ||||
|                     current_layout_order = None  # Skip reorg if not dirty | ||||
|  | ||||
|                 new_card_order = [] | ||||
|                 cards_to_add = [] | ||||
|  | ||||
|                 for game_data in sorted_games: | ||||
|                     game_name = game_data[0] | ||||
|                     exec_line = game_data[4] | ||||
|                     game_key = (game_name, exec_line) | ||||
|                     should_be_visible = not search_text or search_text in game_name.lower() | ||||
|  | ||||
|                     if game_key in self.game_card_cache: | ||||
|                         card = self.game_card_cache[game_key] | ||||
|                         if card.isVisible() != should_be_visible: | ||||
|                             card.setVisible(should_be_visible) | ||||
|                         new_card_order.append(game_key) | ||||
|                     else: | ||||
|                         if self.context_menu_manager is None: | ||||
|                             continue | ||||
|  | ||||
|                         card = self._create_game_card(game_data) | ||||
|                         self.game_card_cache[game_key] = card | ||||
|                         card.setVisible(should_be_visible) | ||||
|                         new_card_order.append(game_key) | ||||
|                         cards_to_add.append((game_key, card)) | ||||
|  | ||||
|                 # Only reorganize if order changed AND dirty | ||||
|                 if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order): | ||||
|                     # Remove all widgets from layout (batch) | ||||
|                     while self.gamesListLayout.count(): | ||||
|                         self.gamesListLayout.takeAt(0) | ||||
|  | ||||
|                     # Add widgets in new order (batch) | ||||
|                     for game_key in new_card_order: | ||||
|                         card = self.game_card_cache[game_key] | ||||
|                         self.gamesListLayout.addWidget(card) | ||||
|  | ||||
|                 self.dirty = False  # Reset flag | ||||
|  | ||||
|                 # Deferred deletions (run in timer to avoid stack overflow) | ||||
|                 if self.pending_deletions: | ||||
|                     QTimer.singleShot(0, lambda: self._flush_deletions()) | ||||
|  | ||||
|                 # Load visible images for new cards only | ||||
|                 if cards_to_add: | ||||
|                     self.load_visible_images() | ||||
|  | ||||
|             finally: | ||||
|                 if self.gamesListLayout is not None: | ||||
|                     self.gamesListLayout.setEnabled(True) | ||||
|                 self.gamesListWidget.setUpdatesEnabled(True) | ||||
|                 if self.gamesListLayout is not None: | ||||
|                     self.gamesListLayout.update() | ||||
|                 self.gamesListWidget.updateGeometry() | ||||
|                 self.main_window._last_card_width = self.card_width | ||||
|  | ||||
|                 self.force_update_cards_library() | ||||
|  | ||||
|         self.is_filtering = False  # Reset flag in any case | ||||
|  | ||||
|     def _apply_filter_visibility(self, search_text: str): | ||||
|         """Applies visibility to cards based on search, without changing the layout.""" | ||||
|         visible_count = 0 | ||||
|         for game_key, card in self.game_card_cache.items(): | ||||
|             game_name = card.name  # Assume GameCard has 'name' attribute | ||||
|             should_be_visible = not search_text or search_text in game_name.lower() | ||||
|             if card.isVisible() != should_be_visible: | ||||
|                 card.setVisible(should_be_visible) | ||||
|             if should_be_visible: | ||||
|                 visible_count += 1 | ||||
|                 # Load image only for newly visible cards | ||||
|                 if game_key in self.pending_images: | ||||
|                     cover_path, width, height, callback = self.pending_images.pop(game_key) | ||||
|                     load_pixmap_async(cover_path, width, height, callback) | ||||
|  | ||||
|         # Force full relayout after visibility changes | ||||
|         if self.gamesListLayout is not None: | ||||
|             self.gamesListLayout.invalidate()  # Принудительно инвалидируем для пересчёта | ||||
|             self.gamesListLayout.update() | ||||
|         if self.gamesListWidget is not None: | ||||
|             self.gamesListWidget.updateGeometry() | ||||
|         self.main_window._last_card_width = self.card_width | ||||
|  | ||||
|         # If search is empty, load images for visible ones | ||||
|         if not search_text: | ||||
|             self.load_visible_images() | ||||
|  | ||||
|     def _create_game_card(self, game_data: tuple) -> GameCard: | ||||
|         """Creates a new game card with all necessary connections.""" | ||||
|         card = GameCard( | ||||
|             *game_data, | ||||
|             select_callback=self.main_window.openGameDetailPage, | ||||
|             theme=self.theme, | ||||
|             card_width=self.card_width, | ||||
|             context_menu_manager=self.context_menu_manager | ||||
|         ) | ||||
|  | ||||
|         card.hoverChanged.connect(self._on_card_hovered) | ||||
|         card.focusChanged.connect(self._on_card_focused) | ||||
|  | ||||
|         if self.context_menu_manager: | ||||
|             card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut) | ||||
|             card.deleteGameRequested.connect(self.context_menu_manager.delete_game) | ||||
|             card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu) | ||||
|             card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu) | ||||
|             card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop) | ||||
|             card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop) | ||||
|             card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam) | ||||
|             card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam) | ||||
|             card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder) | ||||
|  | ||||
|         return card | ||||
|  | ||||
|     def _flush_deletions(self): | ||||
|         """Delete pending widgets off the main update cycle.""" | ||||
|         for card in list(self.pending_deletions): | ||||
|             card.deleteLater() | ||||
|             self.pending_deletions.remove(card) | ||||
|  | ||||
|     def clear_layout(self, layout): | ||||
|         """Clears all widgets from the layout.""" | ||||
|         if layout is None: | ||||
|             return | ||||
|         while layout.count(): | ||||
|             child = layout.takeAt(0) | ||||
|             if child.widget(): | ||||
|                 widget = child.widget() | ||||
|                 for key, card in list(self.game_card_cache.items()): | ||||
|                     if card == widget: | ||||
|                         del self.game_card_cache[key] | ||||
|                         if key in self.pending_images: | ||||
|                             del self.pending_images[key] | ||||
|                 widget.deleteLater() | ||||
|  | ||||
|     def set_games(self, games: list[tuple]): | ||||
|         """Sets the games list and updates the filtered games.""" | ||||
|         self.games = games | ||||
|         self.filtered_games = self.games | ||||
|         self.dirty = True  # Full resort needed | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def add_game_incremental(self, game_data: tuple): | ||||
|         """Add a single game without full reload.""" | ||||
|         self.games.append(game_data) | ||||
|         self.filtered_games.append(game_data)  # Assume no filter active; adjust if needed | ||||
|         self.dirty = True | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def remove_game_incremental(self, game_name: str, exec_line: str): | ||||
|         """Remove a single game without full reload.""" | ||||
|         key = (game_name, exec_line) | ||||
|         self.games = [g for g in self.games if (g[0], g[4]) != key] | ||||
|         self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key] | ||||
|         if key in self.game_card_cache and self.gamesListLayout is not None: | ||||
|             card = self.game_card_cache.pop(key) | ||||
|             self.gamesListLayout.removeWidget(card) | ||||
|             self.pending_deletions.append(card)  # Defer deleteLater | ||||
|             if key in self.pending_images: | ||||
|                 del self.pending_images[key] | ||||
|         self.dirty = True | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def filter_games_delayed(self): | ||||
|         """Filters games based on search text and updates the grid.""" | ||||
|         self.update_game_grid(is_filter=True) | ||||
| @@ -4,16 +4,17 @@ import os | ||||
| from typing import Protocol, cast | ||||
| from evdev import InputDevice, InputEvent, ecodes, list_devices, ff | ||||
| from enum import Enum | ||||
| from pyudev import Context, Monitor, MonitorObserver, Device | ||||
| from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget | ||||
| from pyudev import Context, Monitor, MonitorObserver, Device, Devices | ||||
| from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem | ||||
| from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer | ||||
| from PySide6.QtGui import QKeyEvent, QMouseEvent | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.image_utils import FullscreenDialog | ||||
| from portprotonqt.custom_widgets import NavLabel, AutoSizeButton | ||||
| from portprotonqt.game_card import GameCard | ||||
| from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config | ||||
| from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type | ||||
| from portprotonqt.dialogs import AddGameDialog | ||||
| from portprotonqt.virtual_keyboard import VirtualKeyboard | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| @@ -37,6 +38,7 @@ class MainWindowProtocol(Protocol): | ||||
|     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 | ||||
| @@ -71,7 +73,7 @@ class InputManager(QObject): | ||||
|     for seamless UI interaction. | ||||
|     """ | ||||
|     # Signals for gamepad events | ||||
|     button_pressed = Signal(int)  # Signal for button presses | ||||
|     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) | ||||
|  | ||||
| @@ -85,11 +87,17 @@ class InputManager(QObject): | ||||
|         super().__init__(cast(QObject, main_window)) | ||||
|         self._parent = main_window | ||||
|         self._gamepad_handling_enabled = True | ||||
|         self.gamepad_type = GamepadType.UNKNOWN | ||||
|         # Ensure attributes exist on main_window | ||||
|         type_str = read_gamepad_type() | ||||
|         if type_str == "playstation": | ||||
|             self.gamepad_type = GamepadType.PLAYSTATION | ||||
|         elif type_str == "xbox": | ||||
|             self.gamepad_type = GamepadType.XBOX | ||||
|         else: | ||||
|             self.gamepad_type = GamepadType.UNKNOWN | ||||
|         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 | ||||
| @@ -130,7 +138,7 @@ class InputManager(QObject): | ||||
|         self.current_dpad_value = 0    # Tracks the current D-pad direction value (e.g., -1, 1) | ||||
|  | ||||
|         # Connect signals to slots | ||||
|         self.button_pressed.connect(self.handle_button_slot) | ||||
|         self.button_event.connect(self.handle_button_slot) | ||||
|         self.dpad_moved.connect(self.handle_dpad_slot) | ||||
|         self.toggle_fullscreen.connect(self.handle_fullscreen_slot) | ||||
|  | ||||
| @@ -142,37 +150,131 @@ class InputManager(QObject): | ||||
|         # Initialize evdev + hotplug | ||||
|         self.init_gamepad() | ||||
|  | ||||
|     def detect_gamepad_type(self, device: InputDevice) -> GamepadType: | ||||
|         """ | ||||
|         Определяет тип геймпада по capabilities | ||||
|         """ | ||||
|         caps = device.capabilities() | ||||
|         keys = set(caps.get(ecodes.EV_KEY, [])) | ||||
|     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 | ||||
|         focused = QApplication.focusWidget() | ||||
|         game_cards = container.findChildren(GameCard) | ||||
|         if not game_cards: | ||||
|             return | ||||
|  | ||||
|         # Для EV_ABS вытаскиваем только коды (первый элемент кортежа) | ||||
|         abs_axes = {a if isinstance(a, int) else a[0] for a in caps.get(ecodes.EV_ABS, [])} | ||||
|         scroll_area = container.parentWidget() | ||||
|         while scroll_area and not isinstance(scroll_area, QScrollArea): | ||||
|             scroll_area = scroll_area.parentWidget() | ||||
|  | ||||
|         # Xbox layout | ||||
|         if {ecodes.BTN_SOUTH, ecodes.BTN_EAST, ecodes.BTN_NORTH, ecodes.BTN_WEST}.issubset(keys): | ||||
|             if {ecodes.ABS_X, ecodes.ABS_Y, ecodes.ABS_RX, ecodes.ABS_RY}.issubset(abs_axes): | ||||
|                 self.gamepad_type = GamepadType.XBOX | ||||
|                 return GamepadType.XBOX | ||||
|         # If no focused widget or not a GameCard, focus the first card | ||||
|         if not isinstance(focused, GameCard) or focused not in game_cards: | ||||
|             game_cards[0].setFocus() | ||||
|             if scroll_area: | ||||
|                 scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) | ||||
|             return | ||||
|  | ||||
|         # PlayStation layout | ||||
|         if ecodes.BTN_TOUCH in keys or (ecodes.BTN_DPAD_UP in keys and ecodes.BTN_EAST in keys): | ||||
|             self.gamepad_type = GamepadType.PLAYSTATION | ||||
|             logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}") | ||||
|             return GamepadType.PLAYSTATION | ||||
|         cards = container.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
|         if not cards: | ||||
|             return | ||||
|         # Group cards by rows with tolerance for y-position | ||||
|         rows = {} | ||||
|         y_tolerance = 10  # Allow slight variations in y-position | ||||
|         for card in cards: | ||||
|             y = card.pos().y() | ||||
|             matched = False | ||||
|             for row_y in rows: | ||||
|                 if abs(y - row_y) <= y_tolerance: | ||||
|                     rows[row_y].append(card) | ||||
|                     matched = True | ||||
|                     break | ||||
|             if not matched: | ||||
|                 rows[y] = [card] | ||||
|         sorted_rows = sorted(rows.items(), key=lambda x: x[0]) | ||||
|         if not sorted_rows: | ||||
|             return | ||||
|         current_row_idx = None | ||||
|         current_col_idx = None | ||||
|         for row_idx, (_y, row_cards) in enumerate(sorted_rows): | ||||
|             for idx, card in enumerate(row_cards): | ||||
|                 if card == focused: | ||||
|                     current_row_idx = row_idx | ||||
|                     current_col_idx = idx | ||||
|                     break | ||||
|             if current_row_idx is not None: | ||||
|                 break | ||||
|  | ||||
|         # Steam Controller / Deck (трекпады) | ||||
|         if any(a for a in abs_axes if a >= ecodes.ABS_MT_SLOT): | ||||
|             self.gamepad_type = GamepadType.XBOX | ||||
|             logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}") | ||||
|             return GamepadType.XBOX | ||||
|         # Fallback: if focused card not found, select closest row by y-position | ||||
|         if current_row_idx is None: | ||||
|             if not sorted_rows:  # Additional safety check | ||||
|                 return | ||||
|             focused_y = focused.pos().y() | ||||
|             current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y)) | ||||
|             if current_row_idx >= len(sorted_rows):  # Safety check | ||||
|                 return | ||||
|             current_row = sorted_rows[current_row_idx][1] | ||||
|             focused_x = focused.pos().x() + focused.width() / 2 | ||||
|             current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0)  # type: ignore | ||||
|  | ||||
|         # Fallback | ||||
|         self.gamepad_type = GamepadType.XBOX | ||||
|         return GamepadType.XBOX | ||||
|         # Add null checks before using current_row_idx and current_col_idx | ||||
|         if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): | ||||
|             return | ||||
|  | ||||
|         current_row = sorted_rows[current_row_idx][1] | ||||
|         if code == ecodes.ABS_HAT0X and value != 0: | ||||
|             if value < 0:  # Left | ||||
|                 if current_col_idx > 0: | ||||
|                     next_card = current_row[current_col_idx - 1] | ||||
|                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 else: | ||||
|                     if current_row_idx > 0: | ||||
|                         prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                         next_card = prev_row[-1] if prev_row else None | ||||
|                         if next_card: | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|             elif value > 0:  # Right | ||||
|                 if current_col_idx < len(current_row) - 1: | ||||
|                     next_card = current_row[current_col_idx + 1] | ||||
|                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 else: | ||||
|                     if current_row_idx < len(sorted_rows) - 1: | ||||
|                         next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                         next_card = next_row[0] if next_row else None | ||||
|                         if next_card: | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|         elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|             if value > 0:  # Down | ||||
|                 if current_row_idx < len(sorted_rows) - 1: | ||||
|                     next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                     current_x = focused.pos().x() + focused.width() / 2 | ||||
|                     next_card = min( | ||||
|                         next_row, | ||||
|                         key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                         default=None | ||||
|                     ) | ||||
|                     if next_card: | ||||
|                         next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         if scroll_area: | ||||
|                             scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|             elif value < 0:  # Up | ||||
|                 if current_row_idx > 0: | ||||
|                     prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                     current_x = focused.pos().x() + focused.width() / 2 | ||||
|                     next_card = min( | ||||
|                         prev_row, | ||||
|                         key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                         default=None | ||||
|                     ) | ||||
|                     if next_card: | ||||
|                         next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         if scroll_area: | ||||
|                             scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 elif current_row_idx == 0: | ||||
|                     self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|     def enable_file_explorer_mode(self, file_explorer): | ||||
|         """Настройка обработки геймпада для FileExplorer""" | ||||
| @@ -201,7 +303,9 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error restoring gamepad handlers: {e}") | ||||
|  | ||||
|     def handle_file_explorer_button(self, button_code): | ||||
|     def handle_file_explorer_button(self, button_code, value): | ||||
|         if value == 0:  # Ignore releases | ||||
|                     return | ||||
|         try: | ||||
|             popup = QApplication.activePopupWidget() | ||||
|             if isinstance(popup, QMenu): | ||||
| @@ -351,6 +455,171 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error("Error in FileExplorer dpad handler: %s", e) | ||||
|  | ||||
|     def enable_winetricks_mode(self, winetricks_dialog): | ||||
|         """Setup gamepad handling for WinetricksDialog""" | ||||
|         try: | ||||
|             self.winetricks_dialog = winetricks_dialog | ||||
|             self.original_button_handler = self.handle_button_slot | ||||
|             self.original_dpad_handler = self.handle_dpad_slot | ||||
|             self.original_gamepad_state = self._gamepad_handling_enabled | ||||
|             self.handle_button_slot = self.handle_winetricks_button | ||||
|             self.handle_dpad_slot = self.handle_winetricks_dpad | ||||
|             self._gamepad_handling_enabled = True | ||||
|             # Reset dpad timer for table nav | ||||
|             self.dpad_timer.stop() | ||||
|             self.current_dpad_code = None | ||||
|             self.current_dpad_value = 0 | ||||
|             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.handle_button_slot = self.original_button_handler | ||||
|                 self.handle_dpad_slot = self.original_dpad_handler | ||||
|                 self._gamepad_handling_enabled = self.original_gamepad_state | ||||
|                 self.winetricks_dialog = None | ||||
|                 self.dpad_timer.stop() | ||||
|                 self.current_dpad_code = None | ||||
|                 self.current_dpad_value = 0 | ||||
|                 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: | ||||
|             return | ||||
|         if value == 0:  # Ignore releases | ||||
|             return | ||||
|         try: | ||||
|             # Always check for popups first, including QMessageBox | ||||
|             popup = QApplication.activePopupWidget() | ||||
|             if popup: | ||||
|                 if isinstance(popup, QMessageBox): | ||||
|                     if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']: | ||||
|                         popup.accept()  # Close QMessageBox with A or B | ||||
|                         return | ||||
|                 elif isinstance(popup, QMenu): | ||||
|                     if button_code in BUTTONS['confirm']:  # A: Select menu item | ||||
|                         focused = popup.activeAction() | ||||
|                         if focused: | ||||
|                             focused.trigger() | ||||
|                         return | ||||
|                     elif button_code in BUTTONS['back']:  # B: Close menu | ||||
|                         popup.close() | ||||
|                         return | ||||
|  | ||||
|             # Additional check for top-level QMessageBox (in case not active popup yet) | ||||
|             for widget in QApplication.topLevelWidgets(): | ||||
|                 if isinstance(widget, QMessageBox) and widget.isVisible(): | ||||
|                     if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']: | ||||
|                         widget.accept() | ||||
|                         return | ||||
|  | ||||
|             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_item = focused.item(current_row, 0) | ||||
|                         if checkbox_item and isinstance(checkbox_item, QTableWidgetItem): | ||||
|                             new_state = Qt.CheckState.Checked if checkbox_item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked | ||||
|                             checkbox_item.setCheckState(new_state) | ||||
|                 return | ||||
|             elif button_code in BUTTONS['add_game']:  # X: Install (no force) | ||||
|                 self.winetricks_dialog.install_selected(force=False) | ||||
|                 return | ||||
|             elif button_code in BUTTONS['prev_dir']:  # Y: Force Install | ||||
|                 self.winetricks_dialog.install_selected(force=True) | ||||
|                 return | ||||
|             elif button_code in BUTTONS['back']:  # B: Cancel | ||||
|                 self.winetricks_dialog.reject() | ||||
|                 return | ||||
|             elif button_code in BUTTONS['prev_tab']:  # LB: Prev Tab | ||||
|                 current_index = self.winetricks_dialog.tab_widget.currentIndex() | ||||
|                 new_index = max(0, current_index - 1) | ||||
|                 self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) | ||||
|                 self._focus_first_row_in_current_table() | ||||
|                 return | ||||
|             elif button_code in BUTTONS['next_tab']:  # RB: Next Tab | ||||
|                 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) | ||||
|                 self._focus_first_row_in_current_table() | ||||
|                 return | ||||
|             # Fallback: Activate focused widget (e.g., buttons) | ||||
|             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: Stop repeat | ||||
|                 self.dpad_timer.stop() | ||||
|                 self.current_dpad_code = None | ||||
|                 self.current_dpad_value = 0 | ||||
|                 return | ||||
|  | ||||
|             # Start/update repeat timer for hold navigation | ||||
|             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)  # Initial slower, then faster repeat | ||||
|                 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 == ecodes.ABS_HAT0Y:  # Up/Down: Navigate rows | ||||
|                 if value < 0:  # Up | ||||
|                     new_row = max(0, current_row - 1) | ||||
|                 elif value > 0:  # Down | ||||
|                     new_row = min(table.rowCount() - 1, current_row + 1) | ||||
|                 else: | ||||
|                     return | ||||
|                 if new_row != current_row: | ||||
|                     table.setCurrentCell(new_row, 0)  # Focus checkbox column | ||||
|                     table.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|             elif code == ecodes.ABS_HAT0X:  # Left/Right: Switch tabs | ||||
|                 if value < 0:  # Left: Prev tab | ||||
|                     current_index = self.winetricks_dialog.tab_widget.currentIndex() | ||||
|                     new_index = max(0, current_index - 1) | ||||
|                     self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) | ||||
|                 elif value > 0:  # Right: Next tab | ||||
|                     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) | ||||
|                 self._focus_first_row_in_current_table() | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in handle_winetricks_dpad: {e}") | ||||
|  | ||||
|     def _get_current_table(self): | ||||
|         """Get the current visible table from the tab widget's stacked container.""" | ||||
|         if self.winetricks_dialog is None: | ||||
|             return None | ||||
|         current_container = self.winetricks_dialog.tab_widget.currentWidget() | ||||
|         if current_container and isinstance(current_container, QStackedWidget): | ||||
|             current_table = current_container.widget(1)  # Table is at index 1 (after preloader) | ||||
|             if isinstance(current_table, QTableWidget): | ||||
|                 return current_table | ||||
|         return None | ||||
|  | ||||
|     def _focus_first_row_in_current_table(self): | ||||
|         """Focus the first row in the current table after tab switch.""" | ||||
|         if self.winetricks_dialog is None: | ||||
|             return | ||||
|         table = self._get_current_table() | ||||
|         if table and table.rowCount() > 0: | ||||
|             table.setCurrentCell(0, 0) | ||||
|             table.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|     def handle_navigation_repeat(self): | ||||
|         """Плавное повторение движения с переменной скоростью для FileExplorer""" | ||||
|         try: | ||||
| @@ -441,8 +710,33 @@ class InputManager(QObject): | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error stopping rumble: {e}", exc_info=True) | ||||
|  | ||||
|     @Slot(int) | ||||
|     def handle_button_slot(self, button_code: int) -> None: | ||||
|     @Slot(int, int) | ||||
|     def handle_button_slot(self, button_code: int, value: int) -> None: | ||||
|         active_window = QApplication.activeWindow() | ||||
|  | ||||
|         # Обработка виртуальной клавиатуры в AddGameDialog (handle both press and release) | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             focused = QApplication.focusWidget() | ||||
|             if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit): | ||||
|                 # Показываем клавиатуру при нажатии A на поле ввода (only on press) | ||||
|                 active_window.show_keyboard_for_widget(focused) | ||||
|                 return | ||||
|  | ||||
|             # Если клавиатура видима, обрабатываем её кнопки (including release) | ||||
|             if hasattr(active_window, 'keyboard') and active_window.keyboard.isVisible(): | ||||
|                 self.handle_virtual_keyboard(button_code, value) | ||||
|                 return | ||||
|  | ||||
|         # Main window keyboard handling (including release) | ||||
|         keyboard = getattr(self._parent, 'keyboard', None) | ||||
|         if keyboard and keyboard.isVisible(): | ||||
|             self.handle_virtual_keyboard(button_code, value) | ||||
|             return | ||||
|  | ||||
|         # Ignore releases for all other (non-keyboard) button handling | ||||
|         if value == 0: | ||||
|             return | ||||
|  | ||||
|         if not self._gamepad_handling_enabled: | ||||
|             return | ||||
|         try: | ||||
| @@ -455,6 +749,31 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             current_tab_index = self._parent.stackedWidget.currentIndex() | ||||
|  | ||||
|             if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit): | ||||
|                 search_edit = None | ||||
|                 if current_tab_index == 0: | ||||
|                     search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 elif current_tab_index == 1: | ||||
|                     search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                 if focused == search_edit: | ||||
|                     keyboard = getattr(self._parent, 'keyboard', None) | ||||
|                     if keyboard: | ||||
|                         keyboard.show_for_widget(focused) | ||||
|                         return | ||||
|  | ||||
|             # Handle Y button to focus search | ||||
|             if button_code in BUTTONS['prev_dir']:  # Y button | ||||
|                 search_edit = None | ||||
|                 if current_tab_index == 0: | ||||
|                     search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 elif current_tab_index == 1: | ||||
|                     search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                 if search_edit: | ||||
|                     search_edit.setFocus() | ||||
|                     return | ||||
|  | ||||
|             # Handle Guide button to open system overlay | ||||
|             if button_code in BUTTONS['guide']: | ||||
|                 if not popup and not isinstance(active, QDialog): | ||||
| @@ -595,8 +914,83 @@ class InputManager(QObject): | ||||
|  | ||||
|     @Slot(int, int, float) | ||||
|     def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None: | ||||
|         keyboard = None | ||||
|         active_window = QApplication.activeWindow() | ||||
|  | ||||
|         # Проверяем клавиатуру в активном окне (AddGameDialog или главном окне) | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             keyboard = getattr(active_window, 'keyboard', None) | ||||
|         else: | ||||
|             keyboard = getattr(self._parent, 'keyboard', None) | ||||
|  | ||||
|         # Handle release early | ||||
|         if value == 0: | ||||
|             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() | ||||
|             return | ||||
|  | ||||
|         # Update D-pad state for continuous movement | ||||
|         self.current_dpad_code = code | ||||
|         self.current_dpad_value = value | ||||
|         if not self.axis_moving: | ||||
|             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)) | ||||
|  | ||||
|         if keyboard and keyboard.isVisible(): | ||||
|             # Обработка горизонтального перемещения (LEFT/RIGHT) | ||||
|             if code in (ecodes.ABS_HAT0X, ecodes.ABS_X): | ||||
|                 normalized_value = 0 | ||||
|                 if code == ecodes.ABS_X:  # Левый стик | ||||
|                     # Применяем мертвую зону | ||||
|                     if abs(value) < self.dead_zone: | ||||
|                         self.current_dpad_code = None | ||||
|                         self.current_dpad_value = 0 | ||||
|                         self.axis_moving = False | ||||
|                         self.dpad_timer.stop() | ||||
|                         return | ||||
|                     normalized_value = 1 if value > self.dead_zone else -1 | ||||
|                 else:  # D-pad | ||||
|                     normalized_value = value  # D-pad уже дает -1, 0, 1 | ||||
|  | ||||
|                 if normalized_value != 0: | ||||
|                     if normalized_value > 0:  # Вправо | ||||
|                         keyboard.move_focus_right() | ||||
|                     elif normalized_value < 0:  # Влево | ||||
|                         keyboard.move_focus_left() | ||||
|                 return | ||||
|  | ||||
|             # Обработка вертикального перемещения (UP/DOWN) | ||||
|             elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y): | ||||
|                 normalized_value = 0 | ||||
|                 if code == ecodes.ABS_Y:  # Левый стик | ||||
|                     # Применяем мертвую зону | ||||
|                     if abs(value) < self.dead_zone: | ||||
|                         self.current_dpad_code = None | ||||
|                         self.current_dpad_value = 0 | ||||
|                         self.axis_moving = False | ||||
|                         self.dpad_timer.stop() | ||||
|                         return | ||||
|                     normalized_value = 1 if value > self.dead_zone else -1 | ||||
|                 else:  # D-pad | ||||
|                     normalized_value = value  # D-pad уже дает -1, 0, 1 | ||||
|  | ||||
|                 if normalized_value != 0: | ||||
|                     if normalized_value > 0:  # Вниз | ||||
|                         keyboard.move_focus_down() | ||||
|                     elif normalized_value < 0:  # Вверх | ||||
|                         keyboard.move_focus_up() | ||||
|                 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: | ||||
|  | ||||
|             app = QApplication.instance() | ||||
| @@ -606,23 +1000,6 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             # Update D-pad state | ||||
|             if value != 0: | ||||
|                 self.current_dpad_code = code | ||||
|                 self.current_dpad_value = value | ||||
|                 if not self.axis_moving: | ||||
|                     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))  # Start timer (in milliseconds) | ||||
|             else: | ||||
|                 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()  # Stop timer when D-pad is released | ||||
|                 return | ||||
|  | ||||
|             # Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad | ||||
|             if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0: | ||||
|                 if isinstance(active, QMessageBox):  # Specific handling for QMessageBox | ||||
| @@ -638,7 +1015,7 @@ class InputManager(QObject): | ||||
|                     elif value < 0:  # Left | ||||
|                         active.focusPreviousChild() | ||||
|                     return | ||||
|             elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0:  # Keep up/down for other dialogs | ||||
|             elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget):  # Keep up/down for other dialogs | ||||
|                 if not focused or not active.focusWidget(): | ||||
|                     # If no widget is focused, focus the first focusable widget | ||||
|                     focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
| @@ -691,132 +1068,90 @@ class InputManager(QObject): | ||||
|                     active.show_next() | ||||
|                 return | ||||
|  | ||||
|             # Library tab navigation (index 0) | ||||
|             if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 game_cards = self._parent.gamesListWidget.findChildren(GameCard) | ||||
|                 if not game_cards: | ||||
|  | ||||
|             # Table navigation | ||||
|             if isinstance(focused, QTableWidget): | ||||
|                 row_count = focused.rowCount() | ||||
|                 if row_count <= 0: | ||||
|                     return | ||||
|                 current_row = focused.currentRow() | ||||
|                 if current_row < 0: | ||||
|                     current_row = 0 | ||||
|                     focused.setCurrentCell(0, 0) | ||||
|  | ||||
|                 scroll_area = self._parent.gamesListWidget.parentWidget() | ||||
|                 while scroll_area and not isinstance(scroll_area, QScrollArea): | ||||
|                     scroll_area = scroll_area.parentWidget() | ||||
|  | ||||
|                 # If no focused widget or not a GameCard, focus the first card | ||||
|                 if not isinstance(focused, GameCard) or focused not in game_cards: | ||||
|                     game_cards[0].setFocus() | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) | ||||
|                     return | ||||
|  | ||||
|                 cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
|                 if not cards: | ||||
|                     return | ||||
|                 # Group cards by rows with tolerance for y-position | ||||
|                 rows = {} | ||||
|                 y_tolerance = 10  # Allow slight variations in y-position | ||||
|                 for card in cards: | ||||
|                     y = card.pos().y() | ||||
|                     matched = False | ||||
|                     for row_y in rows: | ||||
|                         if abs(y - row_y) <= y_tolerance: | ||||
|                             rows[row_y].append(card) | ||||
|                             matched = True | ||||
|                             break | ||||
|                     if not matched: | ||||
|                         rows[y] = [card] | ||||
|                 sorted_rows = sorted(rows.items(), key=lambda x: x[0]) | ||||
|                 if not sorted_rows: | ||||
|                     return | ||||
|                 current_row_idx = None | ||||
|                 current_col_idx = None | ||||
|                 for row_idx, (_y, row_cards) in enumerate(sorted_rows): | ||||
|                     for idx, card in enumerate(row_cards): | ||||
|                         if card == focused: | ||||
|                             current_row_idx = row_idx | ||||
|                             current_col_idx = idx | ||||
|                             break | ||||
|                     if current_row_idx is not None: | ||||
|                         break | ||||
|  | ||||
|                 # Fallback: if focused card not found, select closest row by y-position | ||||
|                 if current_row_idx is None: | ||||
|                     if not sorted_rows:  # Additional safety check | ||||
|                         return | ||||
|                     focused_y = focused.pos().y() | ||||
|                     current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y)) | ||||
|                     if current_row_idx >= len(sorted_rows):  # Safety check | ||||
|                         return | ||||
|                     current_row = sorted_rows[current_row_idx][1] | ||||
|                     focused_x = focused.pos().x() + focused.width() / 2 | ||||
|                     current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore | ||||
|  | ||||
|                 # Add null checks before using current_row_idx and current_col_idx | ||||
|                 if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): | ||||
|                     return | ||||
|  | ||||
|                 current_row = sorted_rows[current_row_idx][1] | ||||
|                 if code == ecodes.ABS_HAT0X and value != 0: | ||||
|                     if value < 0:  # Left | ||||
|                         if current_col_idx > 0: | ||||
|                             next_card = current_row[current_col_idx - 1] | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         else: | ||||
|                             if current_row_idx > 0: | ||||
|                                 prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                                 next_card = prev_row[-1] if prev_row else None | ||||
|                                 if next_card: | ||||
|                                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                     if scroll_area: | ||||
|                                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                     elif value > 0:  # Right | ||||
|                         if current_col_idx < len(current_row) - 1: | ||||
|                             next_card = current_row[current_col_idx + 1] | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         else: | ||||
|                             if current_row_idx < len(sorted_rows) - 1: | ||||
|                                 next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                                 next_card = next_row[0] if next_row else None | ||||
|                                 if next_card: | ||||
|                                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                     if scroll_area: | ||||
|                                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|                 if code == ecodes.ABS_HAT0Y and value != 0: | ||||
|                     # Vertical navigation | ||||
|                     if value > 0:  # Down | ||||
|                         if current_row_idx < len(sorted_rows) - 1: | ||||
|                             next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                             current_x = focused.pos().x() + focused.width() / 2 | ||||
|                             next_card = min( | ||||
|                                 next_row, | ||||
|                                 key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                                 default=None | ||||
|                             ) | ||||
|                             if next_card: | ||||
|                                 next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                 if scroll_area: | ||||
|                                     scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         new_row = min(current_row + 1, row_count - 1) | ||||
|                     elif value < 0:  # Up | ||||
|                         if current_row_idx > 0: | ||||
|                             prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                             current_x = focused.pos().x() + focused.width() / 2 | ||||
|                             next_card = min( | ||||
|                                 prev_row, | ||||
|                                 key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                                 default=None | ||||
|                             ) | ||||
|                             if next_card: | ||||
|                                 next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                 if scroll_area: | ||||
|                                     scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         elif current_row_idx == 0: | ||||
|                             self._parent.tabButtons[0].setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         new_row = max(current_row - 1, 0) | ||||
|                     else: | ||||
|                         return | ||||
|  | ||||
|                     focused.setCurrentCell(new_row, focused.currentColumn()) | ||||
|                     item = focused.item(new_row, focused.currentColumn()) | ||||
|                     if item: | ||||
|                         focused.scrollToItem( | ||||
|                             item, | ||||
|                             QAbstractItemView.ScrollHint.PositionAtCenter | ||||
|                         ) | ||||
|                     focused.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     return | ||||
|                 elif code == ecodes.ABS_HAT0X and value != 0: | ||||
|                     # Horizontal navigation | ||||
|                     col_count = focused.columnCount() | ||||
|                     current_col = focused.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 | ||||
|  | ||||
|                     focused.setCurrentCell(focused.currentRow(), new_col) | ||||
|                     focused.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     return | ||||
|  | ||||
|             # Search focus logic for tabs 0 and 1 | ||||
|             if code == ecodes.ABS_HAT0Y and value < 0: | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 current_index = self._parent.stackedWidget.currentIndex() | ||||
|                 if current_index in (0, 1) and isinstance(focused, GameCard): | ||||
|                     if current_index == 0: | ||||
|                         container = self._parent.gamesListWidget | ||||
|                         search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                     else: | ||||
|                         container = self._parent.autoInstallContainer | ||||
|                         search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                     if container and search_edit: | ||||
|                         game_cards = container.findChildren(GameCard) | ||||
|                         if game_cards: | ||||
|                             current_card_pos = focused.pos() | ||||
|                             current_row_y = current_card_pos.y() | ||||
|                             is_first_row = True | ||||
|                             for card in game_cards: | ||||
|                                 if card.pos().y() < current_row_y and card.isVisible(): | ||||
|                                     is_first_row = False | ||||
|                                     break | ||||
|                             if is_first_row: | ||||
|                                 search_edit.setFocus() | ||||
|                                 return | ||||
|  | ||||
|             # Game cards navigation for tabs 0 and 1 | ||||
|             if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): | ||||
|                 current_index = self._parent.stackedWidget.currentIndex() | ||||
|                 if current_index in (0, 1): | ||||
|                     container = self._parent.gamesListWidget if current_index == 0 else self._parent.autoInstallContainer | ||||
|                     if container is None: | ||||
|                         return | ||||
|                     self._navigate_game_cards(container, current_index, code, value) | ||||
|                     return | ||||
|  | ||||
|             # Vertical navigation in other tabs | ||||
|             elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|             if code == ecodes.ABS_HAT0Y and value != 0: | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 page = self._parent.stackedWidget.currentWidget() | ||||
|                 if value > 0:  # Down | ||||
| @@ -836,6 +1171,52 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True) | ||||
|  | ||||
|     def handle_virtual_keyboard(self, button_code: int, value: int) -> None: | ||||
|         # Проверяем клавиатуру в активном окне | ||||
|         active_window = QApplication.activeWindow() | ||||
|         keyboard = None | ||||
|  | ||||
|         # Сначала проверяем AddGameDialog | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             keyboard = getattr(active_window, 'keyboard', None) | ||||
|         else: | ||||
|             # Если это не AddGameDialog, проверяем клавиатуру в главном окне | ||||
|             keyboard = getattr(self._parent, 'keyboard', None) | ||||
|  | ||||
|         if not keyboard or not isinstance(keyboard, VirtualKeyboard) or not keyboard.isVisible(): | ||||
|             return | ||||
|  | ||||
|         # Обработка кнопок геймпада | ||||
|         if button_code in BUTTONS['confirm']:  # Кнопка A/Cross - подтверждение | ||||
|             if value == 1: | ||||
|                 keyboard.activateFocusedKey() | ||||
|         elif button_code in BUTTONS['back']:  # Кнопка B/Circle - скрыть клавиатуру | ||||
|             if value == 1: | ||||
|                 keyboard.hide() | ||||
|                 # Возвращаем фокус на поле ввода | ||||
|                 if keyboard.current_input_widget: | ||||
|                     keyboard.current_input_widget.setFocus() | ||||
|         elif button_code in BUTTONS['prev_tab']:  # LB/L1 - переключение раскладки | ||||
|             if value == 1: | ||||
|                 keyboard.on_lang_click() | ||||
|         elif button_code in BUTTONS['next_tab']:  # RB/R1 - переключение Shift | ||||
|             if value == 1: | ||||
|                 keyboard.on_shift_click(not keyboard.shift_pressed) | ||||
|         elif button_code in BUTTONS['context_menu']:  # Кнопка Start - подтверждение | ||||
|             if value == 1: | ||||
|                 keyboard.activateFocusedKey() | ||||
|         elif button_code in BUTTONS['menu']:  # Кнопка Select - скрыть клавиатуру | ||||
|             if value == 1: | ||||
|                 keyboard.hide() | ||||
|                 # Возвращаем фокус на поле ввода | ||||
|                 if keyboard.current_input_widget: | ||||
|                     keyboard.current_input_widget.setFocus() | ||||
|         elif button_code in BUTTONS['add_game']:  # Кнопка X - 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: | ||||
| @@ -1055,19 +1436,32 @@ class InputManager(QObject): | ||||
|         return super().eventFilter(obj, event) | ||||
|  | ||||
|     def init_gamepad(self) -> None: | ||||
|         self.monitor_observer = None | ||||
|         self.udev_context = Context()  # Создаём context один раз | ||||
|         self.Devices = Devices  # Сохраняем класс для использования в других методах | ||||
|         self.check_gamepad() | ||||
|         threading.Thread(target=self.run_udev_monitor, daemon=True).start() | ||||
|         logger.info("Gamepad support initialized with hotplug (evdev + pyudev)") | ||||
|  | ||||
|     def run_udev_monitor(self) -> None: | ||||
|         try: | ||||
|             context = Context() | ||||
|             monitor = Monitor.from_netlink(context) | ||||
|             logger.info("Starting udev monitor...") | ||||
|             monitor = Monitor.from_netlink(self.udev_context) | ||||
|             monitor.filter_by(subsystem='input') | ||||
|             logger.info("Monitor created and filtered") | ||||
|  | ||||
|             observer = MonitorObserver(monitor, self.handle_udev_event) | ||||
|             self.monitor_observer = observer | ||||
|             logger.info("MonitorObserver created") | ||||
|  | ||||
|             observer.start() | ||||
|             logger.info("MonitorObserver started") | ||||
|  | ||||
|             # Держим поток живым, пока не получим сигнал остановки | ||||
|             while self.running: | ||||
|                 time.sleep(1) | ||||
|  | ||||
|             logger.info("MonitorObserver stopped gracefully") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in udev monitor: {e}", exc_info=True) | ||||
|  | ||||
| @@ -1083,8 +1477,8 @@ class InputManager(QObject): | ||||
|                     self.gamepad = None | ||||
|                     if self.gamepad_thread: | ||||
|                         self.gamepad_thread.join() | ||||
|                     # Signal to exit fullscreen mode | ||||
|                     self.toggle_fullscreen.emit(False) | ||||
|                     if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                         self.toggle_fullscreen.emit(False) | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error handling udev event: {e}", exc_info=True) | ||||
|  | ||||
| @@ -1093,8 +1487,6 @@ class InputManager(QObject): | ||||
|             new_gamepad = self.find_gamepad() | ||||
|             if new_gamepad and new_gamepad != self.gamepad: | ||||
|                 logger.info(f"Gamepad connected: {new_gamepad.name}") | ||||
|                 self.detect_gamepad_type(new_gamepad) | ||||
|                 logger.info(f"Detected gamepad type: {self.gamepad_type.value}") | ||||
|                 self.stop_rumble() | ||||
|                 self.gamepad = new_gamepad | ||||
|                 if self.gamepad_thread: | ||||
| @@ -1112,14 +1504,33 @@ class InputManager(QObject): | ||||
|     def find_gamepad(self) -> InputDevice | None: | ||||
|         try: | ||||
|             devices = [InputDevice(path) for path in list_devices()] | ||||
|             logger.info(f"Checking {len(devices)} devices for gamepad...") | ||||
|  | ||||
|             for device in devices: | ||||
|                 logger.debug(f"Checking device: {device.name} at {device.path}") | ||||
|  | ||||
|                 # Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2) | ||||
|                 if device.info.vendor == 0x26ce and device.info.product == 0x01a2: | ||||
|                     logger.debug(f"Skipping ASRock LED controller: {device.name}") | ||||
|                     continue | ||||
|                 caps = device.capabilities() | ||||
|                 if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps: | ||||
|                     return device | ||||
|  | ||||
|                 # Получаем udev-устройство для проверки ID_INPUT_JOYSTICK | ||||
|                 try: | ||||
|                     udev_device = self.Devices.from_device_file(self.udev_context, device.path) | ||||
|                     is_joystick = udev_device.get('ID_INPUT_JOYSTICK') | ||||
|  | ||||
|                     logger.debug(f"Device {device.name}: ID_INPUT_JOYSTICK = {is_joystick}") | ||||
|  | ||||
|                     if is_joystick == '1': | ||||
|                         logger.info(f"Found gamepad: {device.name}") | ||||
|                         return device | ||||
|                     else: | ||||
|                         logger.debug(f"Skipping non-joystick device: {device.name}") | ||||
|                 except Exception as e: | ||||
|                     logger.warning(f"Could not check udev properties for {device.path}: {e}") | ||||
|                     continue | ||||
|  | ||||
|             logger.warning("No gamepad found") | ||||
|             return None | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error finding gamepad: {e}", exc_info=True) | ||||
| @@ -1142,11 +1553,12 @@ class InputManager(QObject): | ||||
|                 if not app or not active: | ||||
|                     continue | ||||
|  | ||||
|                 if event.type == ecodes.EV_KEY and event.value == 1: | ||||
|                     if event.code in BUTTONS['menu'] and not self._is_gamescope_session: | ||||
|                 if event.type == ecodes.EV_KEY: | ||||
|                     # Emit on both press (1) and release (0) | ||||
|                     self.button_event.emit(event.code, event.value) | ||||
|                     # Special handling for menu on press only | ||||
|                     if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session: | ||||
|                         self.toggle_fullscreen.emit(not self._is_fullscreen) | ||||
|                     else: | ||||
|                         self.button_pressed.emit(event.code) | ||||
|                 elif event.type == ecodes.EV_ABS: | ||||
|                     if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}: | ||||
|                         # Проверяем, достаточно ли времени прошло с последнего срабатывания | ||||
| @@ -1155,17 +1567,19 @@ class InputManager(QObject): | ||||
|                         if event.code == ecodes.ABS_Z:  # LT/L2 | ||||
|                             if event.value > 128 and not self.lt_pressed: | ||||
|                                 self.lt_pressed = True | ||||
|                                 self.button_pressed.emit(event.code) | ||||
|                                 self.button_event.emit(event.code, 1)  # Emit as press | ||||
|                                 self.last_trigger_time = now | ||||
|                             elif event.value <= 128 and self.lt_pressed: | ||||
|                                 self.lt_pressed = False | ||||
|                                 self.button_event.emit(event.code, 0)  # Emit as release | ||||
|                         elif event.code == ecodes.ABS_RZ:  # RT/R2 | ||||
|                             if event.value > 128 and not self.rt_pressed: | ||||
|                                 self.rt_pressed = True | ||||
|                                 self.button_pressed.emit(event.code) | ||||
|                                 self.button_event.emit(event.code, 1)  # Emit as press | ||||
|                                 self.last_trigger_time = now | ||||
|                             elif event.value <= 128 and self.rt_pressed: | ||||
|                                 self.rt_pressed = False | ||||
|                                 self.button_event.emit(event.code, 0)  # Emit as release | ||||
|                     else: | ||||
|                         self.dpad_moved.emit(event.code, event.value, now) | ||||
|         except OSError as e: | ||||
| @@ -1187,11 +1601,21 @@ class InputManager(QObject): | ||||
|     def cleanup(self) -> None: | ||||
|         try: | ||||
|             self.running = False | ||||
|  | ||||
|             # Останавливаем udev monitor | ||||
|             if self.monitor_observer: | ||||
|                 try: | ||||
|                     logger.info("Stopping udev monitor...") | ||||
|                     self.monitor_observer.send_stop() | ||||
|                 except Exception as e: | ||||
|                     logger.warning(f"Error stopping monitor observer: {e}") | ||||
|                 self.monitor_observer = None | ||||
|  | ||||
|             self.dpad_timer.stop() | ||||
|             self.nav_timer.stop() | ||||
|             self.stop_rumble() | ||||
|             if self.gamepad_thread: | ||||
|                 self.gamepad_thread.join() | ||||
|                 self.gamepad_thread.join(timeout=2.0)  # Добавлен таймаут | ||||
|             if self.gamepad: | ||||
|                 self.gamepad.close() | ||||
|             self.gamepad = None | ||||
|   | ||||
							
								
								
									
										73
									
								
								portprotonqt/keyboard_layouts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,73 @@ | ||||
| # keyboard_layouts.py | ||||
| keyboard_layouts = { | ||||
|     'en': { | ||||
|         'normal': [ | ||||
|             ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"], | ||||
|             ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'], | ||||
|             ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?'] | ||||
|         ] | ||||
|     }, | ||||
|     'ru': { | ||||
|         'normal': [ | ||||
|             ['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='], | ||||
|             ['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'], | ||||
|             ['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'], | ||||
|             ['⬆', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['Ё', '!', '"', '№', ';', '%', ':', '?', '*', '(', ')', '_', '+'], | ||||
|             ['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'], | ||||
|             ['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'], | ||||
|             ['⬆', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ','] | ||||
|         ] | ||||
|     }, | ||||
|     'fr': { | ||||
|         'normal': [ | ||||
|             ['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='], | ||||
|             ['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'], | ||||
|             ['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'], | ||||
|             ['⬆', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'], | ||||
|             ['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'], | ||||
|             ['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'], | ||||
|             ['⬆', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§'] | ||||
|         ] | ||||
|     }, | ||||
|     'es': { | ||||
|         'normal': [ | ||||
|             ['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'], | ||||
|             ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'], | ||||
|             ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_'] | ||||
|         ] | ||||
|     }, | ||||
|     'de': { | ||||
|         'normal': [ | ||||
|             ['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'], | ||||
|             ['⬆', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'], | ||||
|             ['⬆', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_'] | ||||
|         ] | ||||
|     } | ||||
| } | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-09-13 11:51+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: de_DE\n" | ||||
| @@ -23,7 +23,7 @@ msgstr "" | ||||
| msgid "Error" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "PortProton is not found" | ||||
| msgid "PortProton directory not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Remove from Favorites" | ||||
| @@ -155,7 +155,7 @@ msgid "Menu" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No executable command in .desktop file for '{game_name}'" | ||||
| msgid "No executable command found in .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to read .desktop file: {error}" | ||||
| msgid "Error reading .desktop file: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -191,6 +191,10 @@ msgstr "" | ||||
| msgid "Failed to delete custom data: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Added '{game_name}' successfully" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Game name and executable path are required" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -248,13 +252,37 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgid "Open" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Toggle" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Tab" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Next Tab" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -264,6 +292,10 @@ msgstr "" | ||||
| msgid "Path: " | ||||
| msgstr "" | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -300,6 +332,39 @@ msgstr "" | ||||
| msgid "No cover selected" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Set" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Libraries" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Information" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fonts" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Warning" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No components selected." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed. Check logs." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Components installed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Epic Games Store games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -348,9 +413,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -366,6 +428,28 @@ msgstr "" | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Processed {} installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -378,13 +462,106 @@ msgstr "" | ||||
| msgid "Find Games ..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| #, python-brace-format | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Various Wine parameters and versions..." | ||||
| msgid "Prefix:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Configuration" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Uninstaller" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Create Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Load Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Compatibility Tool" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start restore process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore failed." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete prefix: {}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete compatibility tool '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Compatibility tool '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete compatibility tool: {}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Main PortProton parameters..." | ||||
| @@ -420,6 +597,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -444,6 +624,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -456,21 +642,6 @@ msgstr "" | ||||
| msgid "Gamepad haptic feedback:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Open Legendary Login" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary Authentication:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Enter Legendary Authorization Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Authorization Code:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Submit Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Save Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -480,28 +651,6 @@ msgstr "" | ||||
| msgid "Clear Cache" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Opened Legendary login page in browser" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to open Legendary login page" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Please enter an authorization code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Successfully authenticated with Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Legendary authentication failed: {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary executable not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Unexpected error during authentication" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Reset" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-09-13 11:51+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: es_ES\n" | ||||
| @@ -23,7 +23,7 @@ msgstr "" | ||||
| msgid "Error" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "PortProton is not found" | ||||
| msgid "PortProton directory not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Remove from Favorites" | ||||
| @@ -155,7 +155,7 @@ msgid "Menu" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No executable command in .desktop file for '{game_name}'" | ||||
| msgid "No executable command found in .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to read .desktop file: {error}" | ||||
| msgid "Error reading .desktop file: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -191,6 +191,10 @@ msgstr "" | ||||
| msgid "Failed to delete custom data: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Added '{game_name}' successfully" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Game name and executable path are required" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -248,13 +252,37 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgid "Open" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Toggle" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Tab" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Next Tab" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -264,6 +292,10 @@ msgstr "" | ||||
| msgid "Path: " | ||||
| msgstr "" | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -300,6 +332,39 @@ msgstr "" | ||||
| msgid "No cover selected" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Set" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Libraries" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Information" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fonts" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Warning" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No components selected." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed. Check logs." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Components installed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Epic Games Store games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -348,9 +413,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -366,6 +428,28 @@ msgstr "" | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Processed {} installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -378,13 +462,106 @@ msgstr "" | ||||
| msgid "Find Games ..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| #, python-brace-format | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Various Wine parameters and versions..." | ||||
| msgid "Prefix:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Configuration" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Uninstaller" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Create Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Load Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Compatibility Tool" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start restore process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore failed." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete prefix: {}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete compatibility tool '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Compatibility tool '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete compatibility tool: {}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Main PortProton parameters..." | ||||
| @@ -420,6 +597,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -444,6 +624,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -456,21 +642,6 @@ msgstr "" | ||||
| msgid "Gamepad haptic feedback:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Open Legendary Login" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary Authentication:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Enter Legendary Authorization Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Authorization Code:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Submit Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Save Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -480,28 +651,6 @@ msgstr "" | ||||
| msgid "Clear Cache" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Opened Legendary login page in browser" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to open Legendary login page" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Please enter an authorization code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Successfully authenticated with Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Legendary authentication failed: {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary executable not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Unexpected error during authentication" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Reset" | ||||
| 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: 2025-09-13 11:51+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+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" | ||||
| @@ -21,7 +21,7 @@ msgstr "" | ||||
| msgid "Error" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "PortProton is not found" | ||||
| msgid "PortProton directory not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Remove from Favorites" | ||||
| @@ -153,7 +153,7 @@ msgid "Menu" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No executable command in .desktop file for '{game_name}'" | ||||
| msgid "No executable command found in .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -161,7 +161,7 @@ msgid "Failed to parse .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to read .desktop file: {error}" | ||||
| msgid "Error reading .desktop file: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -189,6 +189,10 @@ msgstr "" | ||||
| msgid "Failed to delete custom data: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Added '{game_name}' successfully" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Game name and executable path are required" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -246,13 +250,37 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgid "Open" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Toggle" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Tab" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Next Tab" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -262,6 +290,10 @@ msgstr "" | ||||
| msgid "Path: " | ||||
| msgstr "" | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -298,6 +330,39 @@ msgstr "" | ||||
| msgid "No cover selected" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Set" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Libraries" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Information" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fonts" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Warning" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No components selected." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed. Check logs." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Components installed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Epic Games Store games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -346,9 +411,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -364,6 +426,28 @@ msgstr "" | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Processed {} installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -376,13 +460,106 @@ msgstr "" | ||||
| msgid "Find Games ..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| #, python-brace-format | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Various Wine parameters and versions..." | ||||
| msgid "Prefix:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Configuration" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Uninstaller" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Create Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Load Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Compatibility Tool" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start restore process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore failed." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete prefix: {}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete compatibility tool '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Compatibility tool '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete compatibility tool: {}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Main PortProton parameters..." | ||||
| @@ -418,6 +595,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -442,6 +622,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -454,21 +640,6 @@ msgstr "" | ||||
| msgid "Gamepad haptic feedback:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Open Legendary Login" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary Authentication:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Enter Legendary Authorization Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Authorization Code:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Submit Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Save Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -478,28 +649,6 @@ msgstr "" | ||||
| msgid "Clear Cache" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Opened Legendary login page in browser" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to open Legendary login page" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Please enter an authorization code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Successfully authenticated with Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Legendary authentication failed: {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary executable not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Unexpected error during authentication" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Reset" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,8 +9,8 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-09-13 11:51+0500\n" | ||||
| "PO-Revision-Date: 2025-09-13 11:47+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: 2025-10-16 14:54+0500\n" | ||||
| "Last-Translator: \n" | ||||
| "Language: ru_RU\n" | ||||
| "Language-Team: ru_RU <LL@li.org>\n" | ||||
| @@ -24,8 +24,8 @@ msgstr "" | ||||
| msgid "Error" | ||||
| msgstr "Ошибка" | ||||
|  | ||||
| msgid "PortProton is not found" | ||||
| msgstr "PortProton не найден" | ||||
| msgid "PortProton directory not found" | ||||
| msgstr "Не найден каталог PortProton" | ||||
|  | ||||
| msgid "Remove from Favorites" | ||||
| msgstr "Удалить из Избранного" | ||||
| @@ -158,16 +158,16 @@ msgid "Menu" | ||||
| msgstr "Меню" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No executable command in .desktop file for '{game_name}'" | ||||
| msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда" | ||||
| msgid "No executable command found in .desktop file for '{game_name}'" | ||||
| msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to parse .desktop file for '{game_name}'" | ||||
| msgstr "Не удалось разобрать файл .desktop для '{game_name}'" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to read .desktop file: {error}" | ||||
| msgstr "Не удалось прочитать файл .desktop: {error}" | ||||
| msgid "Error reading .desktop file: {error}" | ||||
| msgstr "Ошибка при чтении файла .desktop: {error}" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No .desktop file found for '{game_name}'" | ||||
| @@ -196,6 +196,10 @@ msgstr "'{game_name}' был(а) успешно удалён(а)" | ||||
| msgid "Failed to delete custom data: {error}" | ||||
| msgstr "Не удалось удалить пользовательские данные: {error}" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Added '{game_name}' successfully" | ||||
| msgstr "'{game_name}' успешно добавлен(а)" | ||||
|  | ||||
| msgid "Game name and executable path are required" | ||||
| msgstr "Требуются название игры и путь к исполняемому файлу" | ||||
|  | ||||
| @@ -255,13 +259,37 @@ msgstr "Удалить" | ||||
| msgid "Select All" | ||||
| msgstr "Выбрать всё" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "Идёт запуск {0}" | ||||
| msgid "Open" | ||||
| msgstr "Открыть" | ||||
|  | ||||
| msgid "Select Dir" | ||||
| msgstr "Выбрать папку" | ||||
|  | ||||
| msgid "Prev Dir" | ||||
| msgstr "Предыдущий каталог" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "Отмена" | ||||
|  | ||||
| msgid "Toggle" | ||||
| msgstr "Переключить" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "Установить" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "Принудительно установить" | ||||
|  | ||||
| msgid "Prev Tab" | ||||
| msgstr "Предыдущая вкладка" | ||||
|  | ||||
| msgid "Next Tab" | ||||
| msgstr "Следующая вкладка" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "Идёт запуск {0}" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "Проводник" | ||||
|  | ||||
| @@ -271,6 +299,10 @@ msgstr "Выбрать" | ||||
| msgid "Path: " | ||||
| msgstr "Путь: " | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "Доступ запрещён: %s" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "Редактировать игру" | ||||
|  | ||||
| @@ -307,6 +339,39 @@ msgstr "Скачивание обложки..." | ||||
| msgid "No cover selected" | ||||
| msgstr "Обложка не выбрана" | ||||
|  | ||||
| msgid "Prefix Manager" | ||||
| msgstr "Менеджер префиксов" | ||||
|  | ||||
| msgid "Set" | ||||
| msgstr "Выбор" | ||||
|  | ||||
| msgid "Libraries" | ||||
| msgstr "Библиотеки" | ||||
|  | ||||
| msgid "Information" | ||||
| msgstr "Описание" | ||||
|  | ||||
| msgid "Fonts" | ||||
| msgstr "Шрифты" | ||||
|  | ||||
| msgid "Settings" | ||||
| msgstr "Настройки" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "Winetricks не найден. Повторите попытку." | ||||
|  | ||||
| msgid "Warning" | ||||
| msgstr "Предупреждение" | ||||
|  | ||||
| msgid "No components selected." | ||||
| msgstr "Не выбрано ни одного компонента." | ||||
|  | ||||
| msgid "Installation failed. Check logs." | ||||
| msgstr "Установка не удалась. Проверьте журналы." | ||||
|  | ||||
| msgid "Components installed successfully." | ||||
| msgstr "Компоненты успешно установлены." | ||||
|  | ||||
| msgid "Loading Epic Games Store games..." | ||||
| msgstr "Загрузка игр из Epic Games Store..." | ||||
|  | ||||
| @@ -355,9 +420,6 @@ msgstr "Библиотека" | ||||
| msgid "Auto Install" | ||||
| msgstr "Автоустановка" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "Эмуляторы" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "Настройки wine" | ||||
|  | ||||
| @@ -370,10 +432,31 @@ msgstr "Темы" | ||||
| msgid "Back" | ||||
| msgstr "Назад" | ||||
|  | ||||
| #, fuzzy | ||||
| msgid "Fullscreen" | ||||
| msgstr "Полный экран" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "Поиск" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "Установка уже выполняется." | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "Не удалось запустить установку." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Processed {} installation..." | ||||
| msgstr "В процессе установки {}..." | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "Установка завершена успешно." | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "Установка не удалась." | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "Ошибка установки." | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "Загрузка игр из Steam..." | ||||
|  | ||||
| @@ -386,14 +469,109 @@ msgstr "Игровая библиотека" | ||||
| msgid "Find Games ..." | ||||
| msgstr "Найти игры..." | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "Здесь можно настроить автоматическую установку игр..." | ||||
| #, python-brace-format | ||||
| msgid "Added '{name}'" | ||||
| msgstr "'{name}' добавлен(а)" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "Список доступных эмуляторов и их настройка..." | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "Инструмент совместимости:" | ||||
|  | ||||
| msgid "Various Wine parameters and versions..." | ||||
| msgstr "Различные параметры и версии wine..." | ||||
| msgid "Prefix:" | ||||
| msgstr "Префикс:" | ||||
|  | ||||
| msgid "Wine Configuration" | ||||
| msgstr "Конфигурация Wine" | ||||
|  | ||||
| msgid "Registry Editor" | ||||
| msgstr "Редактор реестра" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "Командная строка" | ||||
|  | ||||
| msgid "Uninstaller" | ||||
| msgstr "Удаление программ" | ||||
|  | ||||
| msgid "Create Prefix Backup" | ||||
| msgstr "Создать резервную копию префикса" | ||||
|  | ||||
| msgid "Load Prefix Backup" | ||||
| msgstr "Загрузить резервную копию префикса" | ||||
|  | ||||
| msgid "Delete Compatibility Tool" | ||||
| msgstr "Удалить Инструмент совместимости" | ||||
|  | ||||
| msgid "Delete Prefix" | ||||
| msgstr "Удалить Префикс" | ||||
|  | ||||
| msgid "Clear Prefix" | ||||
| msgstr "Очистить Префикс" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "Запуск инструмента..." | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "Не удалось запустить процесс." | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "Подтвердите очистку" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "Вы уверены, что хотите очистить префикс «{}»?" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "Префикс '{}' успешно удален." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
| "Префикс '{}' очищен с ошибками:\n" | ||||
| "{}" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "Не удалось запустить процесс резервного копирования." | ||||
|  | ||||
| msgid "Failed to start restore process." | ||||
| msgstr "Не удалось запустить процесс восстановления." | ||||
|  | ||||
| msgid "Prefix backup completed." | ||||
| msgstr "Резервное копирование префикса завершено." | ||||
|  | ||||
| msgid "Prefix backup failed." | ||||
| msgstr "Сбой резервного копирования префикса." | ||||
|  | ||||
| msgid "Prefix restore completed." | ||||
| msgstr "Восстановление префикса завершено." | ||||
|  | ||||
| msgid "Prefix restore failed." | ||||
| msgstr "Восстановление префикса не удалось." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete prefix '{}'?" | ||||
| msgstr "Вы уверены, что хотите удалить префикс «{}»?" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' deleted." | ||||
| msgstr "Префикс «{}» удален." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete prefix: {}" | ||||
| msgstr "Не удалось удалить префикс: {}" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete compatibility tool '{}'?" | ||||
| msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Compatibility tool '{}' deleted." | ||||
| msgstr "Инструмент совместимости «{}» удален." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete compatibility tool: {}" | ||||
| msgstr "Не удалось удалить инструмент совместимости: {}" | ||||
|  | ||||
| msgid "Main PortProton parameters..." | ||||
| msgstr "Основные параметры PortProton..." | ||||
| @@ -428,6 +606,9 @@ msgstr "все" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "Фильтр игр:" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "Тип геймпада:" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "Адрес прокси" | ||||
|  | ||||
| @@ -452,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "Режим полноэкранного отображения приложения:" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "Сворачивать в трей при закрытии" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "Режим закрытия приложения:" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "Режим полноэкранного отображения приложения при подключении геймпада" | ||||
|  | ||||
| @@ -464,21 +651,6 @@ msgstr "Тактильная отдача на геймпаде" | ||||
| msgid "Gamepad haptic feedback:" | ||||
| msgstr "Тактильная отдача на геймпаде:" | ||||
|  | ||||
| msgid "Open Legendary Login" | ||||
| msgstr "Открыть браузер для входа в Legendary" | ||||
|  | ||||
| msgid "Legendary Authentication:" | ||||
| msgstr "Авторизация в Legendary:" | ||||
|  | ||||
| msgid "Enter Legendary Authorization Code" | ||||
| msgstr "Введите код авторизации Legendary" | ||||
|  | ||||
| msgid "Authorization Code:" | ||||
| msgstr "Код авторизации:" | ||||
|  | ||||
| msgid "Submit Code" | ||||
| msgstr "Отправить код" | ||||
|  | ||||
| msgid "Save Settings" | ||||
| msgstr "Сохранить настройки" | ||||
|  | ||||
| @@ -488,28 +660,6 @@ msgstr "Сбросить настройки" | ||||
| msgid "Clear Cache" | ||||
| msgstr "Очистить кэш" | ||||
|  | ||||
| msgid "Opened Legendary login page in browser" | ||||
| msgstr "Открытие страницы входа в Legendary в браузере" | ||||
|  | ||||
| msgid "Failed to open Legendary login page" | ||||
| msgstr "Не удалось открыть страницу входа в Legendary" | ||||
|  | ||||
| msgid "Please enter an authorization code" | ||||
| msgstr "Пожалуйста, введите код авторизации" | ||||
|  | ||||
| msgid "Successfully authenticated with Legendary" | ||||
| msgstr "Успешная аутентификация в Legendary" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Legendary authentication failed: {0}" | ||||
| msgstr "Не удалось выполнить аутентификацию Legendary: {0}" | ||||
|  | ||||
| msgid "Legendary executable not found" | ||||
| msgstr "Не найден исполняемый файл Legendary" | ||||
|  | ||||
| msgid "Unexpected error during authentication" | ||||
| msgstr "Неожиданная ошибка при аутентификации" | ||||
|  | ||||
| msgid "Confirm Reset" | ||||
| msgstr "Подтвердите удаление" | ||||
|  | ||||
|   | ||||
| @@ -4,9 +4,12 @@ import orjson | ||||
| import requests | ||||
| import urllib.parse | ||||
| import time | ||||
| import glob | ||||
| import re | ||||
| from collections.abc import Callable | ||||
| from portprotonqt.downloader import Downloader | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.config_utils import get_portproton_location | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds | ||||
| @@ -52,6 +55,9 @@ class PortProtonAPI: | ||||
|         self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data") | ||||
|         os.makedirs(self.custom_data_dir, exist_ok=True) | ||||
|         self.portproton_location = get_portproton_location() | ||||
|         self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||||
|         self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data") | ||||
|         self._topics_data = None | ||||
|  | ||||
|     def _get_game_dir(self, exe_name: str) -> str: | ||||
| @@ -68,40 +74,6 @@ class PortProtonAPI: | ||||
|             logger.debug(f"Failed to check file at {url}: {e}") | ||||
|             return False | ||||
|  | ||||
|     def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]: | ||||
|         game_dir = self._get_game_dir(exe_name) | ||||
|         results: dict[str, str | None] = {"cover": None, "metadata": None} | ||||
|         cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"] | ||||
|         cover_url_base = f"{self.base_url}/{exe_name}/cover" | ||||
|         metadata_url = f"{self.base_url}/{exe_name}/metadata.txt" | ||||
|  | ||||
|         for ext in cover_extensions: | ||||
|             cover_url = f"{cover_url_base}{ext}" | ||||
|             if self._check_file_exists(cover_url, timeout): | ||||
|                 local_cover_path = os.path.join(game_dir, f"cover{ext}") | ||||
|                 result = self.downloader.download(cover_url, local_cover_path, timeout=timeout) | ||||
|                 if result: | ||||
|                     results["cover"] = result | ||||
|                     logger.info(f"Downloaded cover for {exe_name} to {result}") | ||||
|                     break | ||||
|                 else: | ||||
|                     logger.error(f"Failed to download cover for {exe_name} from {cover_url}") | ||||
|             else: | ||||
|                 logger.debug(f"No cover found for {exe_name} with extension {ext}") | ||||
|  | ||||
|         if self._check_file_exists(metadata_url, timeout): | ||||
|             local_metadata_path = os.path.join(game_dir, "metadata.txt") | ||||
|             result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout) | ||||
|             if result: | ||||
|                 results["metadata"] = result | ||||
|                 logger.info(f"Downloaded metadata for {exe_name} to {result}") | ||||
|             else: | ||||
|                 logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}") | ||||
|         else: | ||||
|             logger.debug(f"No metadata found for {exe_name}") | ||||
|  | ||||
|         return results | ||||
|  | ||||
|     def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None: | ||||
|         game_dir = self._get_game_dir(exe_name) | ||||
|         cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"] | ||||
| @@ -163,6 +135,164 @@ class PortProtonAPI: | ||||
|             if callback: | ||||
|                 callback(results) | ||||
|  | ||||
|     def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None: | ||||
|         """Download only autoinstall cover image (PNG only, no metadata).""" | ||||
|         xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                                 os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") | ||||
|         user_game_folder = os.path.join(autoinstall_root, exe_name) | ||||
|  | ||||
|         if not os.path.isdir(user_game_folder): | ||||
|             try: | ||||
|                 os.mkdir(user_game_folder) | ||||
|             except FileExistsError: | ||||
|                 pass | ||||
|  | ||||
|         cover_url = f"{self.base_url}/{exe_name}/cover.png" | ||||
|         local_cover_path = os.path.join(user_game_folder, "cover.png") | ||||
|  | ||||
|         def on_cover_downloaded(local_path: str | None): | ||||
|             if local_path: | ||||
|                 logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}") | ||||
|             else: | ||||
|                 logger.debug(f"No autoinstall cover downloaded for {exe_name}") | ||||
|             if callback: | ||||
|                 callback(local_path) | ||||
|  | ||||
|         if self._check_file_exists(cover_url, timeout): | ||||
|             self.downloader.download_async( | ||||
|                 cover_url, | ||||
|                 local_cover_path, | ||||
|                 timeout=timeout, | ||||
|                 callback=on_cover_downloaded | ||||
|             ) | ||||
|         else: | ||||
|             logger.debug(f"No autoinstall cover found for {exe_name}") | ||||
|             if callback: | ||||
|                 callback(None) | ||||
|  | ||||
|     def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]: | ||||
|         """Extract display_name from # name comment and exe_name from autoinstall bash script.""" | ||||
|         try: | ||||
|             with open(file_path, encoding='utf-8') as f: | ||||
|                 content = f.read() | ||||
|  | ||||
|             # Skip emulators | ||||
|             if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE): | ||||
|                 return None, None | ||||
|  | ||||
|             display_name = None | ||||
|             exe_name = None | ||||
|  | ||||
|             # Extract display_name from "# name:" comment | ||||
|             name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE) | ||||
|             if name_match: | ||||
|                 display_name = name_match.group(1).strip() | ||||
|  | ||||
|             # --- pw_create_unique_exe --- | ||||
|             pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content) | ||||
|             if pw_match: | ||||
|                 arg = pw_match.group(1) | ||||
|                 if arg: | ||||
|                     exe_name = arg.strip() | ||||
|                     if not exe_name.lower().endswith(".exe"): | ||||
|                         exe_name += ".exe" | ||||
|                 else: | ||||
|                     export_match = re.search( | ||||
|                         r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']', | ||||
|                         content, re.IGNORECASE) | ||||
|                     if export_match: | ||||
|                         exe_name = f"{export_match.group(1).strip()}.exe" | ||||
|  | ||||
|             else: | ||||
|                 portwine_match = None | ||||
|                 for line in content.splitlines(): | ||||
|                     stripped = line.strip() | ||||
|                     if stripped.startswith("#"): | ||||
|                         continue | ||||
|                     if "portwine_exe" in stripped and "=" in stripped: | ||||
|                         portwine_match = stripped | ||||
|                         break | ||||
|  | ||||
|                 if portwine_match: | ||||
|                     exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ") | ||||
|                     exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr) | ||||
|                     if exe_candidates: | ||||
|                         exe_name = os.path.basename(exe_candidates[-1].strip()) | ||||
|  | ||||
|  | ||||
|             # Fallback | ||||
|             if not display_name and exe_name: | ||||
|                 display_name = exe_name | ||||
|  | ||||
|             return display_name, exe_name | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to parse {file_path}: {e}") | ||||
|             return None, None | ||||
|  | ||||
|     def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None: | ||||
|         """Load auto-install games with user/builtin covers (no async download here).""" | ||||
|         games = [] | ||||
|         auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else "" | ||||
|         if not os.path.exists(auto_dir): | ||||
|             callback(games) | ||||
|             return | ||||
|  | ||||
|         scripts = sorted(glob.glob(os.path.join(auto_dir, "*"))) | ||||
|         if not scripts: | ||||
|             callback(games) | ||||
|             return | ||||
|  | ||||
|         xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                                 os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") | ||||
|         os.makedirs(base_autoinstall_dir, exist_ok=True) | ||||
|  | ||||
|         for script_path in scripts: | ||||
|             display_name, exe_name = self.parse_autoinstall_script(script_path) | ||||
|             script_name = os.path.splitext(os.path.basename(script_path))[0] | ||||
|  | ||||
|             if not (display_name and exe_name): | ||||
|                 continue | ||||
|  | ||||
|             exe_name = os.path.splitext(exe_name)[0]  # Без .exe | ||||
|             user_game_folder = os.path.join(base_autoinstall_dir, exe_name) | ||||
|             os.makedirs(user_game_folder, exist_ok=True) | ||||
|  | ||||
|             # Поиск обложки | ||||
|             cover_path = "" | ||||
|             user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set() | ||||
|             for ext in [".jpg", ".png", ".jpeg", ".bmp"]: | ||||
|                 candidate = f"cover{ext}" | ||||
|                 if candidate in user_files: | ||||
|                     cover_path = os.path.join(user_game_folder, candidate) | ||||
|                     break | ||||
|  | ||||
|             if not cover_path: | ||||
|                 logger.debug(f"No local cover found for autoinstall {exe_name}") | ||||
|  | ||||
|             # Формируем кортеж игры (добавлен exe_name в конец) | ||||
|             game_tuple = ( | ||||
|                 display_name,  # name | ||||
|                 "",  # description | ||||
|                 cover_path,  # cover | ||||
|                 "",  # appid | ||||
|                 f"autoinstall:{script_name}",  # exec_line | ||||
|                 "",  # controller_support | ||||
|                 "Never",  # last_launch | ||||
|                 "0h 0m",  # formatted_playtime | ||||
|                 "",  # protondb_tier | ||||
|                 "",  # anticheat_status | ||||
|                 0,  # last_played | ||||
|                 0,  # playtime_seconds | ||||
|                 "autoinstall",  # game_source | ||||
|                 exe_name  # exe_name | ||||
|             ) | ||||
|             games.append(game_tuple) | ||||
|  | ||||
|         callback(games) | ||||
|  | ||||
|     def _load_topics_data(self): | ||||
|         """Load and cache linux_gaming_topics_min.json from the archive.""" | ||||
|         if self._topics_data is not None: | ||||
|   | ||||
							
								
								
									
										49
									
								
								portprotonqt/preloader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,49 @@ | ||||
| import time | ||||
|  | ||||
| from PySide6.QtCore import QRect | ||||
| from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient | ||||
| from PySide6.QtWidgets import QWidget | ||||
|  | ||||
| class Preloader(QWidget): | ||||
|     def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None): | ||||
|         super().__init__(parent) | ||||
|         self.setFixedSize(150, 150) | ||||
|         self._speed = speed | ||||
|         self._line_width = line_line_width | ||||
|         self._color1 = color | ||||
|         self._color2 = QColor(color.red(), color.green(), color.blue(), 0) | ||||
|         self._start_time = time.time() | ||||
|  | ||||
|     def showEvent(self, event): | ||||
|         self._start_time = time.time() | ||||
|  | ||||
|     def paintEvent(self, event): | ||||
|         rect = self._get_preloader_rect() | ||||
|         center = rect.center() | ||||
|         painter = QPainter(self) | ||||
|         painter.setRenderHint(QPainter.RenderHint.Antialiasing) | ||||
|         painter.setPen(self._get_pen()) | ||||
|         painter.translate(center) | ||||
|         painter.rotate(self._get_angle()) | ||||
|         painter.translate(-center) | ||||
|         painter.drawArc(rect, 0, 270 * 16) | ||||
|         self.update() | ||||
|  | ||||
|     def _get_pen(self) -> QPen: | ||||
|         gradient = QConicalGradient() | ||||
|         gradient.setCenter(self.rect().center()) | ||||
|         gradient.setColorAt(0, self._color1) | ||||
|         gradient.setColorAt(1, self._color2) | ||||
|         pen = QPen(QBrush(gradient), self._line_width) | ||||
|         pen.setCapStyle(Qt.PenCapStyle.RoundCap) | ||||
|         return pen | ||||
|  | ||||
|     def _get_angle(self) -> float: | ||||
|         duration = time.time() - self._start_time | ||||
|         return (self._speed * duration) % 360.0 | ||||
|  | ||||
|     def _get_preloader_rect(self) -> QRect: | ||||
|         size = self._line_width // 2 | ||||
|         rect = self.rect() | ||||
|         rect.adjust(size, size, -size, -size) | ||||
|         return rect | ||||
| @@ -45,14 +45,14 @@ def safe_vdf_load(path: str | Path) -> dict: | ||||
|  | ||||
| def decode_text(text: str) -> str: | ||||
|     """ | ||||
|     Декодирует HTML-сущности в строке. | ||||
|     Например, "&quot;" преобразуется в '"'. | ||||
|     Остальные символы и HTML-теги остаются без изменений. | ||||
|     Decodes HTML entities in a string. | ||||
|     For example, "&quot;" is converted to '"'. | ||||
|     Other characters and HTML tags remain unchanged. | ||||
|     """ | ||||
|     return html.unescape(text) | ||||
|  | ||||
| def get_cache_dir(): | ||||
|     """Возвращает путь к каталогу кэша, создаёт его при необходимости.""" | ||||
|     """Returns the path to the cache directory, creating it if necessary.""" | ||||
|     xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) | ||||
|     cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") | ||||
|     os.makedirs(cache_dir, exist_ok=True) | ||||
| @@ -65,7 +65,7 @@ STEAM_DATA_DIRS = ( | ||||
| ) | ||||
|  | ||||
| def get_steam_home(): | ||||
|     """Возвращает путь к директории Steam, используя список возможных директорий.""" | ||||
|     """Returns the path to the Steam directory using a list of possible directories.""" | ||||
|     for dir_path in STEAM_DATA_DIRS: | ||||
|         expanded_path = Path(os.path.expanduser(dir_path)) | ||||
|         if expanded_path.exists(): | ||||
| @@ -73,7 +73,7 @@ def get_steam_home(): | ||||
|     return None | ||||
|  | ||||
| def get_last_steam_user(steam_home: Path) -> dict | None: | ||||
|     """Возвращает данные последнего пользователя Steam из loginusers.vdf.""" | ||||
|     """Returns data for the last Steam user from loginusers.vdf.""" | ||||
|     loginusers_path = steam_home / "config/loginusers.vdf" | ||||
|     data = safe_vdf_load(loginusers_path) | ||||
|     if not data: | ||||
| @@ -84,20 +84,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None: | ||||
|             try: | ||||
|                 return {'SteamID': int(user_id)} | ||||
|             except ValueError: | ||||
|                 logger.error(f"Неверный формат SteamID: {user_id}") | ||||
|                 logger.error(f"Invalid SteamID format: {user_id}") | ||||
|                 return None | ||||
|     logger.info("Не найден пользователь с MostRecent=1") | ||||
|     logger.info("No user found with MostRecent=1") | ||||
|     return None | ||||
|  | ||||
| def convert_steam_id(steam_id: int) -> int: | ||||
|     """ | ||||
|     Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число. | ||||
|     Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения. | ||||
|     Converts a signed 32-bit integer to an unsigned 32-bit integer. | ||||
|     Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values. | ||||
|     """ | ||||
|     return steam_id & 0xFFFFFFFF | ||||
|  | ||||
| def get_steam_libs(steam_dir: Path) -> set[Path]: | ||||
|     """Возвращает набор директорий Steam libraryfolders.""" | ||||
|     """Returns a set of Steam library folders.""" | ||||
|     libs = set() | ||||
|     libs_vdf = steam_dir / "steamapps/libraryfolders.vdf" | ||||
|     data = safe_vdf_load(libs_vdf) | ||||
| @@ -113,7 +113,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]: | ||||
|     return libs | ||||
|  | ||||
| def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]: | ||||
|     """Возвращает данные о времени игры для последнего пользователя.""" | ||||
|     """Returns playtime data for the last user.""" | ||||
|     play_data: dict[int, tuple[int, int]] = {} | ||||
|     if steam_home is None: | ||||
|         steam_home = get_steam_home() | ||||
| @@ -133,14 +133,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in | ||||
|         return play_data | ||||
|  | ||||
|     if not last_user: | ||||
|         logger.info("Не удалось определить последнего пользователя Steam") | ||||
|         logger.info("Could not identify the last Steam user") | ||||
|         return play_data | ||||
|  | ||||
|     user_id = last_user['SteamID'] | ||||
|     unsigned_id = convert_steam_id(user_id) | ||||
|     user_dir = userdata_dir / str(unsigned_id) | ||||
|     if not user_dir.exists(): | ||||
|         logger.info(f"Директория пользователя {unsigned_id} не найдена") | ||||
|         logger.info(f"User directory {unsigned_id} not found") | ||||
|         return play_data | ||||
|  | ||||
|     localconfig = user_dir / "config/localconfig.vdf" | ||||
| @@ -154,11 +154,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in | ||||
|             playtime = int(info.get('Playtime', 0)) | ||||
|             play_data[appid] = (last_played, playtime) | ||||
|         except ValueError: | ||||
|             logger.warning(f"Некорректные данные playtime для app {appid_str}") | ||||
|             logger.warning(f"Invalid playtime data for app {appid_str}") | ||||
|     return play_data | ||||
|  | ||||
| def get_steam_installed_games() -> list[tuple[str, int, int, int]]: | ||||
|     """Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec).""" | ||||
|     """Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec).""" | ||||
|     games: list[tuple[str, int, int, int]] = [] | ||||
|     steam_home = get_steam_home() | ||||
|     if steam_home is None or not steam_home.exists(): | ||||
| @@ -187,13 +187,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]: | ||||
|  | ||||
| def normalize_name(s): | ||||
|     """ | ||||
|     Приведение строки к нормальному виду: | ||||
|       - перевод в нижний регистр, | ||||
|       - удаление символов ™ и ®, | ||||
|       - замена разделителей (-, :, ,) на пробел, | ||||
|       - удаление лишних пробелов, | ||||
|       - удаление суффиксов 'bin' или 'app' в конце строки, | ||||
|       - удаление ключевых слов типа 'ultimate', 'edition' и т.п. | ||||
|     Normalizes a string by: | ||||
|       - converting to lowercase, | ||||
|       - removing ™ and ® symbols, | ||||
|       - replacing separators (-, :, ,) with spaces, | ||||
|       - removing extra spaces, | ||||
|       - removing 'bin' or 'app' suffixes, | ||||
|       - removing keywords like 'ultimate', 'edition', etc. | ||||
|     """ | ||||
|     s = s.lower() | ||||
|     for ch in ["™", "®"]: | ||||
| @@ -211,14 +211,28 @@ def normalize_name(s): | ||||
|  | ||||
| def is_valid_candidate(candidate): | ||||
|     """ | ||||
|     Проверяет, содержит ли кандидат запрещённые подстроки: | ||||
|       - win32 | ||||
|       - win64 | ||||
|       - gamelauncher | ||||
|     Для проверки дополнительно используется строка без пробелов. | ||||
|     Возвращает True, если кандидат допустим, иначе False. | ||||
|     Determines whether a given candidate string is valid for use as a game name. | ||||
|  | ||||
|     The function performs the following checks: | ||||
|       1. Normalizes the candidate using `normalize_name()`. | ||||
|       2. Rejects the candidate if the normalized name is exactly "game" | ||||
|          (to avoid overly generic names). | ||||
|       3. Removes spaces and checks for forbidden substrings: | ||||
|          - "win32" | ||||
|          - "win64" | ||||
|          - "gamelauncher" | ||||
|          These are checked in the space-free version of the string. | ||||
|       4. Returns True only if none of the forbidden conditions are met. | ||||
|  | ||||
|     Args: | ||||
|         candidate (str): The candidate string to validate. | ||||
|  | ||||
|     Returns: | ||||
|         bool: True if the candidate is valid, False otherwise. | ||||
|     """ | ||||
|     normalized_candidate = normalize_name(candidate) | ||||
|     if normalized_candidate == "game": | ||||
|         return False | ||||
|     normalized_no_space = normalized_candidate.replace(" ", "") | ||||
|     forbidden = ["win32", "win64", "gamelauncher"] | ||||
|     for token in forbidden: | ||||
| @@ -228,7 +242,7 @@ def is_valid_candidate(candidate): | ||||
|  | ||||
| def filter_candidates(candidates): | ||||
|     """ | ||||
|     Фильтрует список кандидатов, отбрасывая недопустимые. | ||||
|     Filters a list of candidates, discarding invalid ones. | ||||
|     """ | ||||
|     valid = [] | ||||
|     dropped = [] | ||||
| @@ -238,18 +252,18 @@ def filter_candidates(candidates): | ||||
|         else: | ||||
|             dropped.append(cand) | ||||
|     if dropped: | ||||
|         logger.info("Отбрасываю кандидатов: %s", dropped) | ||||
|         logger.info("Discarding candidates: %s", dropped) | ||||
|     return valid | ||||
|  | ||||
| def remove_duplicates(candidates): | ||||
|     """ | ||||
|     Удаляет дубликаты из списка, сохраняя порядок. | ||||
|     Removes duplicates from a list while preserving order. | ||||
|     """ | ||||
|     return list(dict.fromkeys(candidates)) | ||||
|  | ||||
| @functools.lru_cache(maxsize=256) | ||||
| def get_exiftool_data(game_exe): | ||||
|     """Получает метаданные через exiftool""" | ||||
|     """Retrieves metadata using exiftool.""" | ||||
|     try: | ||||
|         proc = subprocess.run( | ||||
|             ["exiftool", "-j", game_exe], | ||||
| @@ -258,12 +272,12 @@ def get_exiftool_data(game_exe): | ||||
|             check=False | ||||
|         ) | ||||
|         if proc.returncode != 0: | ||||
|             logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}") | ||||
|             logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}") | ||||
|             return {} | ||||
|         meta_data_list = orjson.loads(proc.stdout.encode("utf-8")) | ||||
|         return meta_data_list[0] if meta_data_list else {} | ||||
|     except Exception as e: | ||||
|         logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}") | ||||
|         logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}") | ||||
|         return {} | ||||
|  | ||||
| def delete_cached_app_files(cache_dir: str, pattern: str): | ||||
| @@ -305,14 +319,14 @@ def load_steam_apps_async(callback: Callable[[list], None]): | ||||
|                 f.write(orjson.dumps(data)) | ||||
|             if os.path.exists(cache_tar): | ||||
|                 os.remove(cache_tar) | ||||
|                 logger.info("Archive %s deleted after extraction", cache_tar) | ||||
|                 logger.info("Deleted archive: %s", cache_tar) | ||||
|             # Delete all cached app detail files (steam_app_*.json) | ||||
|             delete_cached_app_files(cache_dir, "steam_app_*.json") | ||||
|             steam_apps = data if isinstance(data, list) else [] | ||||
|             logger.info("Loaded %d apps from archive", len(steam_apps)) | ||||
|             callback(steam_apps) | ||||
|         except Exception as e: | ||||
|             logger.error("Error extracting Steam apps archive: %s", e) | ||||
|             logger.error("Failed to extract Steam apps archive: %s", e) | ||||
|             callback([]) | ||||
|  | ||||
|     if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): | ||||
| @@ -322,18 +336,18 @@ def load_steam_apps_async(callback: Callable[[list], None]): | ||||
|                 data = orjson.loads(f.read()) | ||||
|             # Validate JSON structure | ||||
|             if not isinstance(data, list): | ||||
|                 logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json) | ||||
|                 logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json) | ||||
|                 raise ValueError("Invalid JSON structure") | ||||
|             # Validate each app entry | ||||
|             for app in data: | ||||
|                 if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app: | ||||
|                     logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json) | ||||
|                     logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json) | ||||
|                     raise ValueError("Invalid app entry structure") | ||||
|             steam_apps = data | ||||
|             logger.info("Loaded %d apps from cache", len(steam_apps)) | ||||
|             callback(steam_apps) | ||||
|         except Exception as e: | ||||
|             logger.error("Error reading or validating cached JSON %s: %s", cache_json, e) | ||||
|             logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e) | ||||
|             # Attempt to re-download if cache is invalid or corrupted | ||||
|             app_list_url = ( | ||||
|                 "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" | ||||
| @@ -351,12 +365,12 @@ def load_steam_apps_async(callback: Callable[[list], None]): | ||||
|  | ||||
| def build_index(steam_apps): | ||||
|     """ | ||||
|     Строит индекс приложений по полю normalized_name. | ||||
|     Builds an index of applications by normalized_name field. | ||||
|     """ | ||||
|     steam_apps_index = {} | ||||
|     if not steam_apps: | ||||
|         return steam_apps_index | ||||
|     logger.info("Построение индекса Steam приложений:") | ||||
|     logger.info("Building Steam apps index") | ||||
|     for app in steam_apps: | ||||
|         normalized = app["normalized_name"] | ||||
|         steam_apps_index[normalized] = app | ||||
| @@ -364,25 +378,24 @@ def build_index(steam_apps): | ||||
|  | ||||
| def search_app(candidate, steam_apps_index): | ||||
|     """ | ||||
|     Ищет приложение по кандидату: сначала пытается точное совпадение, затем ищет подстроку. | ||||
|     Searches for an application by candidate: tries exact match first, then substring match. | ||||
|     """ | ||||
|     candidate_norm = normalize_name(candidate) | ||||
|     logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm) | ||||
|     logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm) | ||||
|     if candidate_norm in steam_apps_index: | ||||
|         logger.info("    Найдено точное совпадение: '%s'", candidate_norm) | ||||
|         logger.info("Found exact match: '%s'", candidate_norm) | ||||
|         return steam_apps_index[candidate_norm] | ||||
|     for name_norm, app in steam_apps_index.items(): | ||||
|         if candidate_norm in name_norm: | ||||
|             ratio = len(candidate_norm) / len(name_norm) | ||||
|             if ratio > 0.8: | ||||
|                 logger.info("    Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)", | ||||
|                             candidate_norm, name_norm, ratio) | ||||
|                 logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio) | ||||
|                 return app | ||||
|     logger.info("    Приложение для кандидата '%s' не найдено", candidate_norm) | ||||
|     logger.info("No app found for candidate '%s'", candidate_norm) | ||||
|     return None | ||||
|  | ||||
| def load_app_details(app_id): | ||||
|     """Загружает кэшированные данные для игры по appid, если они не устарели.""" | ||||
|     """Loads cached game data by appid if not outdated.""" | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") | ||||
|     if os.path.exists(cache_file): | ||||
| @@ -392,7 +405,7 @@ def load_app_details(app_id): | ||||
|     return None | ||||
|  | ||||
| def save_app_details(app_id, data): | ||||
|     """Сохраняет данные по appid в файл кэша.""" | ||||
|     """Saves appid data to a cache file.""" | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") | ||||
|     with open(cache_file, "wb") as f: | ||||
| @@ -435,7 +448,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]): | ||||
|             save_app_details(app_id, app_data) | ||||
|             callback(app_data) | ||||
|         except Exception as e: | ||||
|             logger.error("Error processing Steam app info for appid %s: %s", app_id, e) | ||||
|             logger.error("Failed to process Steam app info for appid %s: %s", app_id, e) | ||||
|             callback(None) | ||||
|  | ||||
|     downloader.download_async(url, cache_file, timeout=5, callback=process_response) | ||||
| @@ -470,12 +483,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]): | ||||
|                 f.write(orjson.dumps(data)) | ||||
|             if os.path.exists(cache_tar): | ||||
|                 os.remove(cache_tar) | ||||
|                 logger.info("Archive %s deleted after extraction", cache_tar) | ||||
|                 logger.info("Deleted archive: %s", cache_tar) | ||||
|             anti_cheat_data = data or [] | ||||
|             logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data)) | ||||
|             callback(anti_cheat_data) | ||||
|         except Exception as e: | ||||
|             logger.error("Error extracting WeAntiCheatYet archive: %s", e) | ||||
|             logger.error("Failed to extract WeAntiCheatYet archive: %s", e) | ||||
|             callback([]) | ||||
|  | ||||
|     if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): | ||||
| @@ -485,41 +498,37 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]): | ||||
|                 data = orjson.loads(f.read()) | ||||
|             # Validate JSON structure | ||||
|             if not isinstance(data, list): | ||||
|                 logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json) | ||||
|                 logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json) | ||||
|                 raise ValueError("Invalid JSON structure") | ||||
|             # Validate each anti-cheat entry | ||||
|             for entry in data: | ||||
|                 if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry: | ||||
|                     logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json) | ||||
|                     logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json) | ||||
|                     raise ValueError("Invalid anti-cheat entry structure") | ||||
|             anti_cheat_data = data | ||||
|             logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data)) | ||||
|             callback(anti_cheat_data) | ||||
|         except Exception as e: | ||||
|             logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e) | ||||
|             logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e) | ||||
|             # Attempt to re-download if cache is invalid or corrupted | ||||
|             app_list_url = ( | ||||
|                 "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz" | ||||
|             ) | ||||
|             # Delete cached anti-cheat files before re-downloading | ||||
|             delete_cached_app_files(cache_dir, "anticheat_*.json")  # Adjust pattern if app-specific files are added | ||||
|             downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) | ||||
|     else: | ||||
|         app_list_url = ( | ||||
|             "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz" | ||||
|         ) | ||||
|         # Delete cached anti-cheat files before downloading | ||||
|         delete_cached_app_files(cache_dir, "anticheat_*.json")  # Adjust pattern if app-specific files are added | ||||
|         downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) | ||||
|  | ||||
| def build_weanticheatyet_index(anti_cheat_data): | ||||
|     """ | ||||
|     Строит индекс античит-данных по полю normalized_name. | ||||
|     Builds an index of anti-cheat data by normalized_name field. | ||||
|     """ | ||||
|     anti_cheat_index = {} | ||||
|     if not anti_cheat_data: | ||||
|         return anti_cheat_index | ||||
|     logger.info("Построение индекса WeAntiCheatYet данных:") | ||||
|     logger.info("Building WeAntiCheatYet data index") | ||||
|     for entry in anti_cheat_data: | ||||
|         normalized = entry["normalized_name"] | ||||
|         anti_cheat_index[normalized] = entry | ||||
| @@ -527,20 +536,19 @@ def build_weanticheatyet_index(anti_cheat_data): | ||||
|  | ||||
| def search_anticheat_status(candidate, anti_cheat_index): | ||||
|     candidate_norm = normalize_name(candidate) | ||||
|     logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm) | ||||
|     logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm) | ||||
|     if candidate_norm in anti_cheat_index: | ||||
|         status = anti_cheat_index[candidate_norm]["status"] | ||||
|         logger.info("    Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status) | ||||
|         logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status) | ||||
|         return status | ||||
|     for name_norm, entry in anti_cheat_index.items(): | ||||
|         if candidate_norm in name_norm: | ||||
|             ratio = len(candidate_norm) / len(name_norm) | ||||
|             if ratio > 0.8: | ||||
|                 status = entry["status"] | ||||
|                 logger.info("    Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'", | ||||
|                             candidate_norm, name_norm, ratio, status) | ||||
|                 logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status) | ||||
|                 return status | ||||
|     logger.info("    Античит-статус для кандидата '%s' не найден", candidate_norm) | ||||
|     logger.info("No anti-cheat status found for candidate '%s'", candidate_norm) | ||||
|     return "" | ||||
|  | ||||
| def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]): | ||||
| @@ -556,7 +564,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No | ||||
|     load_weanticheatyet_data_async(on_anticheat_data) | ||||
|  | ||||
| def load_protondb_status(appid): | ||||
|     """Загружает закешированные данные ProtonDB для игры по appid, если они не устарели.""" | ||||
|     """Loads cached ProtonDB data for a game by appid if not outdated.""" | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") | ||||
|     if os.path.exists(cache_file): | ||||
| @@ -565,18 +573,18 @@ def load_protondb_status(appid): | ||||
|                 with open(cache_file, "rb") as f: | ||||
|                     return orjson.loads(f.read()) | ||||
|             except Exception as e: | ||||
|                 logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e) | ||||
|                 logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e) | ||||
|     return None | ||||
|  | ||||
| def save_protondb_status(appid, data): | ||||
|     """Сохраняет данные ProtonDB для игры по appid в файл кэша.""" | ||||
|     """Saves ProtonDB data for a game by appid to a cache file.""" | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") | ||||
|     try: | ||||
|         with open(cache_file, "wb") as f: | ||||
|             f.write(orjson.dumps(data)) | ||||
|     except Exception as e: | ||||
|         logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e) | ||||
|         logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e) | ||||
|  | ||||
| def get_protondb_tier_async(appid: int, callback: Callable[[str], None]): | ||||
|     """ | ||||
| @@ -664,7 +672,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla | ||||
|                         if game_exe.lower().endswith('.exe'): | ||||
|                             break | ||||
|             except Exception as e: | ||||
|                 logger.error("Error processing bat file %s: %s", game_exe, e) | ||||
|                 logger.error("Failed to process bat file %s: %s", game_exe, e) | ||||
|         else: | ||||
|             logger.error("Bat file not found: %s", game_exe) | ||||
|  | ||||
| @@ -799,55 +807,55 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None] | ||||
|  | ||||
| def enable_steam_cef() -> tuple[bool, str]: | ||||
|     """ | ||||
|     Проверяет и при необходимости активирует режим удаленной отладки Steam CEF. | ||||
|     Checks and enables Steam CEF remote debugging if necessary. | ||||
|  | ||||
|     Создает файл .cef-enable-remote-debugging в директории Steam. | ||||
|     Steam необходимо перезапустить после первого создания этого файла. | ||||
|     Creates a .cef-enable-remote-debugging file in the Steam directory. | ||||
|     Steam must be restarted after the file is first created. | ||||
|  | ||||
|     Возвращает кортеж: | ||||
|     - (True, "already_enabled") если уже было активно. | ||||
|     - (True, "restart_needed") если было только что активировано и нужен перезапуск Steam. | ||||
|     - (False, "steam_not_found") если директория Steam не найдена. | ||||
|     Returns a tuple: | ||||
|     - (True, "already_enabled") if already enabled. | ||||
|     - (True, "restart_needed") if just enabled and Steam restart is needed. | ||||
|     - (False, "steam_not_found") if Steam directory is not found. | ||||
|     """ | ||||
|     steam_home = get_steam_home() | ||||
|     if not steam_home: | ||||
|         return (False, "steam_not_found") | ||||
|  | ||||
|     cef_flag_file = steam_home / ".cef-enable-remote-debugging" | ||||
|     logger.info(f"Проверка CEF флага: {cef_flag_file}") | ||||
|     logger.info(f"Checking CEF flag: {cef_flag_file}") | ||||
|  | ||||
|     if cef_flag_file.exists(): | ||||
|         logger.info("CEF Remote Debugging уже активирован.") | ||||
|         logger.info("CEF Remote Debugging is already enabled") | ||||
|         return (True, "already_enabled") | ||||
|     else: | ||||
|         try: | ||||
|             os.makedirs(cef_flag_file.parent, exist_ok=True) | ||||
|             cef_flag_file.touch() | ||||
|             logger.info("CEF Remote Debugging активирован. Steam необходимо перезапустить.") | ||||
|             logger.info("Enabled CEF Remote Debugging. Steam restart required") | ||||
|             return (True, "restart_needed") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Не удалось создать CEF флаг {cef_flag_file}: {e}") | ||||
|             logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}") | ||||
|             return (False, str(e)) | ||||
|  | ||||
| def call_steam_api(js_cmd: str, *args) -> dict | None: | ||||
|     """ | ||||
|     Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging. | ||||
|     Executes a JavaScript function in the Steam context via CEF Remote Debugging. | ||||
|  | ||||
|     Args: | ||||
|         js_cmd: Имя JS функции для вызова (напр. 'createShortcut'). | ||||
|         *args: Аргументы для передачи в JS функцию. | ||||
|         js_cmd: Name of the JS function to call (e.g., 'createShortcut'). | ||||
|         *args: Arguments to pass to the JS function. | ||||
|  | ||||
|     Returns: | ||||
|         Словарь с результатом выполнения или None в случае ошибки. | ||||
|         Dictionary with the result or None if an error occurs. | ||||
|     """ | ||||
|     status, message = enable_steam_cef() | ||||
|     if not (status is True and message == "already_enabled"): | ||||
|         if message == "restart_needed": | ||||
|             logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.") | ||||
|             logger.warning("Steam CEF API is available but requires Steam restart for full activation") | ||||
|         elif message == "steam_not_found": | ||||
|             logger.error("Не удалось найти директорию Steam для проверки CEF API.") | ||||
|             logger.error("Could not find Steam directory to check CEF API") | ||||
|         else: | ||||
|             logger.error(f"Steam CEF API недоступен или не готов: {message}") | ||||
|             logger.error(f"Steam CEF API is unavailable or not ready: {message}") | ||||
|         return None | ||||
|  | ||||
|     steam_debug_url = "http://localhost:8080/json" | ||||
| @@ -858,10 +866,10 @@ def call_steam_api(js_cmd: str, *args) -> dict | None: | ||||
|         contexts = response.json() | ||||
|         ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None) | ||||
|         if not ws_url: | ||||
|             logger.warning("Не удалось найти SharedJSContext. Steam запущен с флагом -cef-enable-remote-debugging?") | ||||
|             logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?") | ||||
|             return None | ||||
|     except Exception as e: | ||||
|         logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}") | ||||
|         logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {e}") | ||||
|         return None | ||||
|  | ||||
|     js_code = """ | ||||
| @@ -906,15 +914,15 @@ def call_steam_api(js_cmd: str, *args) -> dict | None: | ||||
|  | ||||
|         response_data = orjson.loads(response_str) | ||||
|         if "error" in response_data: | ||||
|             logger.error(f"Ошибка выполнения JS в Steam: {response_data['error']['message']}") | ||||
|             logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}") | ||||
|             return None | ||||
|         result = response_data.get('result', {}).get('result', {}) | ||||
|         if result.get('type') == 'object' and result.get('subtype') == 'error': | ||||
|             logger.error(f"Ошибка выполнения JS в Steam: {result.get('description')}") | ||||
|             logger.error(f"JavaScript execution error in Steam: {result.get('description')}") | ||||
|             return None | ||||
|         return result.get('value') | ||||
|     except Exception as e: | ||||
|         logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}") | ||||
|         logger.error(f"WebSocket interaction error with Steam: {e}") | ||||
|         return None | ||||
|  | ||||
| def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]: | ||||
| @@ -991,24 +999,24 @@ export START_FROM_STEAM=1 | ||||
|         else: | ||||
|             success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True) | ||||
|             if not success or not os.path.exists(generated_icon_path): | ||||
|                 logger.warning(f"generate_thumbnail failed to create icon for {exe_path}") | ||||
|                 logger.warning(f"Failed to generate thumbnail for {exe_path}") | ||||
|                 icon_path = "" | ||||
|             else: | ||||
|                 logger.info(f"Generated thumbnail: {generated_icon_path}") | ||||
|         icon_path = generated_icon_path | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error generating thumbnail for {exe_path}: {e}") | ||||
|         logger.error(f"Failed to generate thumbnail for {exe_path}: {e}") | ||||
|         icon_path = "" | ||||
|  | ||||
|     steam_home = get_steam_home() | ||||
|     if not steam_home: | ||||
|         logger.error("Steam home directory not found") | ||||
|         return (False, "Steam directory not found.") | ||||
|         return (False, "Steam directory not found") | ||||
|  | ||||
|     last_user = get_last_steam_user(steam_home) | ||||
|     if not last_user or 'SteamID' not in last_user: | ||||
|         logger.error("Failed to retrieve Steam user ID") | ||||
|         return (False, "Failed to get Steam user ID.") | ||||
|         return (False, "Failed to get Steam user ID") | ||||
|  | ||||
|     userdata_dir = steam_home / "userdata" | ||||
|     user_id = last_user['SteamID'] | ||||
| @@ -1021,7 +1029,7 @@ export START_FROM_STEAM=1 | ||||
|     appid = None | ||||
|     was_api_used = False | ||||
|  | ||||
|     logger.info("Попытка добавления ярлыка через Steam CEF API...") | ||||
|     logger.info("Attempting to add shortcut via Steam CEF API") | ||||
|     api_response = call_steam_api( | ||||
|         "createShortcut", | ||||
|         game_name, | ||||
| @@ -1034,9 +1042,9 @@ export START_FROM_STEAM=1 | ||||
|     if api_response and isinstance(api_response, dict) and 'id' in api_response: | ||||
|         appid = api_response['id'] | ||||
|         was_api_used = True | ||||
|         logger.info(f"Ярлык успешно добавлен через API. AppID: {appid}") | ||||
|         logger.info(f"Shortcut successfully added via API. AppID: {appid}") | ||||
|     else: | ||||
|         logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).") | ||||
|         logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf") | ||||
|         backup_path = f"{steam_shortcuts_path}.backup" | ||||
|         if os.path.exists(steam_shortcuts_path): | ||||
|             try: | ||||
| @@ -1110,7 +1118,7 @@ export START_FROM_STEAM=1 | ||||
|             appid = None | ||||
|  | ||||
|     if not appid: | ||||
|         return (False, "Не удалось создать ярлык ни одним из способов.") | ||||
|         return (False, "Failed to create shortcut using any method") | ||||
|  | ||||
|     steam_appid = None | ||||
|  | ||||
| @@ -1120,7 +1128,7 @@ export START_FROM_STEAM=1 | ||||
|         if not steam_appid or not isinstance(steam_appid, int): | ||||
|             logger.info("No valid Steam appid found, skipping cover download") | ||||
|             return | ||||
|         logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.") | ||||
|         logger.info(f"Found Steam AppID {steam_appid} for cover download") | ||||
|  | ||||
|         cover_types = [ | ||||
|             ("p.jpg", "library_600x900_2x.jpg"), | ||||
| @@ -1137,15 +1145,15 @@ export START_FROM_STEAM=1 | ||||
|                         try: | ||||
|                             with open(result_path, 'rb') as f: | ||||
|                                 img_b64 = base64.b64encode(f.read()).decode('utf-8') | ||||
|                             logger.info(f"Применение обложки типа '{steam_name}' через API для AppID {appid}") | ||||
|                             logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}") | ||||
|                             ext = Path(steam_name).suffix.lstrip('.') | ||||
|                             call_steam_api("setGrid", appid, index, ext, img_b64) | ||||
|                         except Exception as e: | ||||
|                             logger.error(f"Ошибка при применении обложки '{steam_name}' через API: {e}") | ||||
|                             logger.error(f"Failed to apply cover '{steam_name}' via API: {e}") | ||||
|                 else: | ||||
|                     logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}") | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error processing cover {steam_name} for appid {steam_appid}: {e}") | ||||
|                 logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}") | ||||
|  | ||||
|         for i, (suffix, steam_name) in enumerate(cover_types): | ||||
|             cover_file = os.path.join(grid_dir, f"{appid}{suffix}") | ||||
| @@ -1186,13 +1194,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]: | ||||
|     steam_home = get_steam_home() | ||||
|     if not steam_home: | ||||
|         logger.error("Steam home directory not found") | ||||
|         return (False, "Steam directory not found.") | ||||
|         return (False, "Steam directory not found") | ||||
|  | ||||
|     # Get current Steam user ID | ||||
|     last_user = get_last_steam_user(steam_home) | ||||
|     if not last_user or 'SteamID' not in last_user: | ||||
|         logger.error("Failed to retrieve Steam user ID") | ||||
|         return (False, "Failed to get Steam user ID.") | ||||
|         return (False, "Failed to get Steam user ID") | ||||
|     userdata_dir = steam_home / "userdata" | ||||
|     user_id = last_user['SteamID'] | ||||
|     unsigned_id = convert_steam_id(user_id) | ||||
| @@ -1238,10 +1246,10 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]: | ||||
|         return (False, f"Game '{game_name}' not found in Steam") | ||||
|  | ||||
|     api_response = call_steam_api("removeShortcut", appid) | ||||
|     if api_response is not None: # API ответил, даже если ответ пустой | ||||
|         logger.info(f"Ярлык для AppID {appid} успешно удален через API.") | ||||
|     if api_response is not None: # API responded, even if response is empty | ||||
|         logger.info(f"Shortcut for AppID {appid} successfully removed via API") | ||||
|     else: | ||||
|         logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).") | ||||
|         logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf") | ||||
|  | ||||
|         # Create backup of shortcuts.vdf | ||||
|         backup_path = f"{steam_shortcuts_path}.backup" | ||||
| @@ -1320,5 +1328,5 @@ def is_game_in_steam(game_name: str) -> bool: | ||||
|             if entry.get("AppName") == game_name: | ||||
|                 return True | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error checking if game {game_name} is in Steam: {e}") | ||||
|         logger.error(f"Failed to check if game {game_name} is in Steam: {e}") | ||||
|     return False | ||||
|   | ||||
| Before Width: | Height: | Size: 880 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_backspace.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg> | ||||
| After Width: | Height: | Size: 751 B | 
| Before Width: | Height: | Size: 2.0 KiB | 
							
								
								
									
										48
									
								
								portprotonqt/themes/standart/images/key_context.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,48 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    width="48" | ||||
|    height="48" | ||||
|    version="1.1" | ||||
|    viewBox="0 0 48 48" | ||||
|    xml:space="preserve" | ||||
|    id="svg2" | ||||
|    sodipodi:docname="key_context.svg" | ||||
|    inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"><defs | ||||
|      id="defs2" /><sodipodi:namedview | ||||
|      id="namedview2" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#000000" | ||||
|      borderopacity="0.25" | ||||
|      inkscape:showpageshadow="2" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      inkscape:deskcolor="#d1d1d1" | ||||
|      inkscape:zoom="8.6915209" | ||||
|      inkscape:cx="72.311855" | ||||
|      inkscape:cy="22.780823" | ||||
|      inkscape:window-width="2560" | ||||
|      inkscape:window-height="1406" | ||||
|      inkscape:window-x="0" | ||||
|      inkscape:window-y="0" | ||||
|      inkscape:window-maximized="1" | ||||
|      inkscape:current-layer="svg2" /><path | ||||
|      style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000" | ||||
|      d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z" | ||||
|      id="path10" | ||||
|      sodipodi:nodetypes="csccscc" /><path | ||||
|      fill="#000000" | ||||
|      d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z" | ||||
|      id="path2" | ||||
|      style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g | ||||
|      id="g15" | ||||
|      transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path | ||||
|        style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000" | ||||
|        d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z" | ||||
|        id="path14" /><path | ||||
|        style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000" | ||||
|        d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z" | ||||
|        id="path15" /></g></svg> | ||||
| After Width: | Height: | Size: 3.3 KiB | 
| Before Width: | Height: | Size: 874 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_e.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg> | ||||
| After Width: | Height: | Size: 726 B | 
| Before Width: | Height: | Size: 1.3 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_enter.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg> | ||||
| After Width: | Height: | Size: 823 B | 
| Before Width: | Height: | Size: 943 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_f11.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg> | ||||
| After Width: | Height: | Size: 857 B | 
| Before Width: | Height: | Size: 933 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_left.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg> | ||||
| After Width: | Height: | Size: 865 B | 
| Before Width: | Height: | Size: 956 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_right.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg> | ||||
| After Width: | Height: | Size: 864 B | 
| Before Width: | Height: | Size: 1.9 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_circle.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 736 B | 
| Before Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_cross.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 961 B | 
| Before Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_l1.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 1015 B | 
| Before Width: | Height: | Size: 1.3 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_options.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 1.2 KiB | 
| Before Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_r1.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 1.3 KiB | 
| Before Width: | Height: | Size: 1.4 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_share.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 1.5 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_square.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 682 B | 
| Before Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_triangle.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 721 B | 
| Before Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_a.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 600 B | 
| Before Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_b.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 1.0 KiB | 
| Before Width: | Height: | Size: 1.9 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_lb.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 1.3 KiB | 
| Before Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_rb.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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> | ||||
| After Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 2.1 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_start.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <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="m16.169 14.061c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395z" fill="#3f424d" fill-rule="evenodd" stroke-width=".19943"/></svg> | ||||
| After Width: | Height: | Size: 958 B | 
| Before Width: | Height: | Size: 2.2 KiB |