Compare commits
	
		
			88 Commits
		
	
	
		
			5189474631
			...
			renovate/a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 92572bf5a1 | ||
| 438e9737ea | |||
| 2d39a4c740 | |||
| 567203b0b0 | |||
| 502cbc5030 | |||
| 9b61215152 | |||
| 10d3fe8ab4 | |||
| a568ad9ef8 | |||
| f074843fc8 | |||
| 4ab078b93e | |||
| 7df6ad3b80 | |||
| 464ad0fe9c | |||
| 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 | 
| @@ -94,7 +94,7 @@ jobs: | ||||
|     name: Build Arch Package | ||||
|     runs-on: ubuntu-22.04 | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:5d95edcb6e10fd865e827e93749ecd425f5056880a5a1d8971f5f2a96c7b5a9a | ||||
|       image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
|   | ||||
| @@ -8,7 +8,7 @@ on: | ||||
|  | ||||
| env: | ||||
|   # Common version, will be used for tagging the release | ||||
|   VERSION: 0.1.6 | ||||
|   VERSION: 0.1.8 | ||||
|   PKGDEST: "/tmp/portprotonqt" | ||||
|   PACKAGE: "portprotonqt" | ||||
|   GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} | ||||
| @@ -180,10 +180,12 @@ 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 }} | ||||
|           tag_name: v${{ env.VERSION }} | ||||
|           prerelease: true | ||||
|           files: release/**/* | ||||
|           sha256sum: true | ||||
|           sha256sum: false | ||||
|   | ||||
| @@ -138,7 +138,7 @@ jobs: | ||||
|     needs: changes | ||||
|     if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:5d95edcb6e10fd865e827e93749ecd425f5056880a5a1d8971f5f2a96c7b5a9a | ||||
|       image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
|   | ||||
| @@ -8,7 +8,7 @@ on: | ||||
| jobs: | ||||
|   renovate: | ||||
|     runs-on: ubuntu-latest | ||||
|     container: ghcr.io/renovatebot/renovate:latest@sha256:e459af116e0cb6c7d5094c0dd4c999d4335d948324192902125b7aff91601a00 | ||||
|     container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6 | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|   | ||||
| @@ -16,7 +16,7 @@ repos: | ||||
|       - id: uv-lock | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.13.2 | ||||
|     rev: v0.14.0 | ||||
|     hooks: | ||||
|       - id: ruff-check | ||||
|  | ||||
|   | ||||
							
								
								
									
										42
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						| @@ -3,20 +3,59 @@ | ||||
| Все заметные изменения в этом проекте фиксируются в этом файле. | ||||
| Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). | ||||
|  | ||||
| ## [Unreleased] | ||||
| ## [0.1.8] - 2025-10-18 | ||||
|  | ||||
| ### Added | ||||
| - В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению | ||||
| - В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет | ||||
| - К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада | ||||
| - Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы) | ||||
|  | ||||
| ### Changed | ||||
| - При завершении автоустановки приложение больше не перезапускается | ||||
| - Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название | ||||
| - Обновлены и дополнены скриншоты темы | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлено наложение карточек при смене фильтра игр | ||||
| - Исправлена невозможность запуска приложения без подключёного геймпада | ||||
| - Исправлена невозможность установки компонентов Winetricks через геймпад | ||||
| - Ресиверы и виртуальные устройства больше не считаются за геймпад | ||||
|  | ||||
|  | ||||
| ### 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 | ||||
|  | ||||
| --- | ||||
|  | ||||
| @@ -39,6 +78,7 @@ | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
							
								
								
									
										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] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры | ||||
|   | ||||
| @@ -36,7 +36,7 @@ AppDir: | ||||
|     id: ru.linux_gaming.PortProtonQt | ||||
|     name: PortProtonQt | ||||
|     icon: ru.linux_gaming.PortProtonQt | ||||
|     version: 0.1.6 | ||||
|     version: 0.1.8 | ||||
|     exec: usr/bin/python3 | ||||
|     exec_args: "-m portprotonqt.app $@" | ||||
|   apt: | ||||
| @@ -54,6 +54,11 @@ AppDir: | ||||
|       - libxcb-cursor0 | ||||
|       - libimage-exiftool-perl | ||||
|       - xdg-utils | ||||
|       - cabextract | ||||
|       - curl | ||||
|       - 7zip | ||||
|       - unzip | ||||
|       - unrar | ||||
|     exclude: | ||||
|       - "*-doc" | ||||
|       - "*-man" | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| pkgname=portprotonqt | ||||
| pkgver=0.1.6 | ||||
| pkgver=0.1.8 | ||||
| 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.8 | ||||
| %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. | ||||
|   | ||||
| @@ -21,9 +21,9 @@ Current translation status: | ||||
|  | ||||
| | Locale | Progress | Translated | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 193 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 193 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 of 193 | | ||||
| | [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 из 193 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 193 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 из 193 | | ||||
| | [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.8" | ||||
|  | ||||
| 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") | ||||
|   | ||||
| @@ -177,6 +177,26 @@ def save_card_size(card_width): | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_auto_card_size(): | ||||
|     """Reads the card size (width) for Auto Install from the [Cards] section. | ||||
|     Returns 250 if the parameter is not set. | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"): | ||||
|         save_auto_card_size(250) | ||||
|         return 250 | ||||
|     return cp.getint("Cards", "auto_card_width", fallback=250) | ||||
|  | ||||
| def save_auto_card_size(card_width): | ||||
|     """Saves the card size (width) for Auto Install to the [Cards] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Cards" not in cp: | ||||
|         cp["Cards"] = {} | ||||
|     cp["Cards"]["auto_card_width"] = str(card_width) | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
|  | ||||
| def read_sort_method(): | ||||
|     """Reads the sort method from the [Games] section. | ||||
|     Returns 'last_launch' if the parameter is not set. | ||||
| @@ -259,6 +279,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 +447,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) | ||||
|   | ||||
| 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 | 
| @@ -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): | ||||
|   | ||||
| @@ -2,11 +2,13 @@ import os | ||||
| import tempfile | ||||
| import re | ||||
| from typing import cast, TYPE_CHECKING | ||||
| from PySide6.QtGui import QPixmap, QIcon | ||||
| from PySide6.QtGui import QPixmap, QIcon, QTextCursor | ||||
| from PySide6.QtWidgets import ( | ||||
|     QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller | ||||
|     QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller, | ||||
|     QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget | ||||
| ) | ||||
| from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot | ||||
|  | ||||
| from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment | ||||
| from icoextract import IconExtractor, IconExtractorError | ||||
| from PIL import Image | ||||
| from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config | ||||
| @@ -15,6 +17,8 @@ from portprotonqt.logger import get_logger | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
| from portprotonqt.custom_widgets import AutoSizeButton | ||||
| from portprotonqt.downloader import Downloader | ||||
| from portprotonqt.virtual_keyboard import VirtualKeyboard | ||||
| from portprotonqt.preloader import Preloader | ||||
| import psutil | ||||
|  | ||||
| if TYPE_CHECKING: | ||||
| @@ -87,6 +91,130 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True): | ||||
|         logger.error(f"Ошибка при сохранении миниатюры: {e}") | ||||
|         return False | ||||
|  | ||||
| def create_dialog_hints_widget(theme, main_window, input_manager, context='default'): | ||||
|     """ | ||||
|     Common function to create hints widget for all dialogs. | ||||
|     Uses main_window for get_button_icon/get_nav_icon, input_manager for gamepad detection. | ||||
|     """ | ||||
|     theme_manager = ThemeManager() | ||||
|     current_theme_name = read_theme_from_config() | ||||
|  | ||||
|     hintsWidget = QWidget() | ||||
|     hintsWidget.setStyleSheet(theme.STATUS_BAR_STYLE) | ||||
|     hintsLayout = QHBoxLayout(hintsWidget) | ||||
|     hintsLayout.setContentsMargins(10, 0, 10, 0) | ||||
|     hintsLayout.setSpacing(20) | ||||
|  | ||||
|     dialog_actions = [] | ||||
|  | ||||
|     # Context-specific actions (gamepad only, no keyboard) | ||||
|     if context == 'file_explorer': | ||||
|         dialog_actions = [ | ||||
|             ("confirm", _("Open")),        # A / Cross | ||||
|             ("add_game", _("Select Dir")), # X / Triangle | ||||
|             ("prev_dir", _("Prev Dir")),   # Y / Square | ||||
|             ("back", _("Cancel")),         # B / Circle | ||||
|             ("context_menu", _("Menu")),   # Start / Options | ||||
|         ] | ||||
|     elif context == 'winetricks': | ||||
|         dialog_actions = [ | ||||
|             ("confirm", _("Toggle")),         # A / Cross | ||||
|             ("add_game", _("Install")),       # X / Triangle | ||||
|             ("prev_dir", _("Force Install")), # Y / Square | ||||
|             ("back", _("Cancel")),            # B / Circle | ||||
|             ("prev_tab", _("Prev Tab")),      # LB / L1 | ||||
|             ("next_tab", _("Next Tab")),      # RB / R1 | ||||
|         ] | ||||
|  | ||||
|     hints_labels = []  # Store for updates (returned for class storage) | ||||
|  | ||||
|     def make_hint(icon_name, text, action=None): | ||||
|         container = QWidget() | ||||
|         hlayout = QHBoxLayout(container) | ||||
|         hlayout.setContentsMargins(0, 5, 0, 0) | ||||
|         hlayout.setSpacing(6) | ||||
|  | ||||
|         icon_label = QLabel() | ||||
|         icon_label.setFixedSize(26, 26) | ||||
|         icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) | ||||
|  | ||||
|         pixmap = QPixmap() | ||||
|         icon_path = theme_manager.get_theme_image(icon_name, current_theme_name) | ||||
|         if icon_path: | ||||
|             pixmap.load(str(icon_path)) | ||||
|         if not pixmap.isNull(): | ||||
|             icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) | ||||
|  | ||||
|         hlayout.addWidget(icon_label) | ||||
|  | ||||
|         text_label = QLabel(text) | ||||
|         text_label.setStyleSheet(theme.LAST_LAUNCH_VALUE_STYLE) | ||||
|         text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) | ||||
|         hlayout.addWidget(text_label) | ||||
|  | ||||
|         # Initially hidden; show only if gamepad connected | ||||
|         container.setVisible(False) | ||||
|         hints_labels.append((container, icon_label, action)) | ||||
|  | ||||
|         hintsLayout.addWidget(container) | ||||
|  | ||||
|     # Add gamepad hints only | ||||
|     for action, text in dialog_actions: | ||||
|         make_hint("placeholder", text, action) | ||||
|  | ||||
|     hintsLayout.addStretch() | ||||
|  | ||||
|     # Return widget and labels for class storage | ||||
|     return hintsWidget, hints_labels | ||||
|  | ||||
| def update_dialog_hints(hints_labels, main_window, input_manager, theme_manager, current_theme_name): | ||||
|     """ | ||||
|     Common function to update hints for any dialog. | ||||
|     """ | ||||
|     if not input_manager or not main_window: | ||||
|         # Hide all if no input_manager or main_window | ||||
|         for container, _, _ in hints_labels: | ||||
|             container.setVisible(False) | ||||
|         return | ||||
|  | ||||
|     is_gamepad = input_manager.gamepad is not None | ||||
|     if not is_gamepad: | ||||
|         # Hide all hints if no gamepad | ||||
|         for container, _, _ in hints_labels: | ||||
|             container.setVisible(False) | ||||
|         return | ||||
|  | ||||
|     gtype = input_manager.gamepad_type | ||||
|     gamepad_actions = ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir', 'prev_tab', 'next_tab'] | ||||
|  | ||||
|     for container, icon_label, action in hints_labels: | ||||
|         if action and action in gamepad_actions: | ||||
|             container.setVisible(True) | ||||
|             # Update icon using main_window methods | ||||
|             if action in ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir']: | ||||
|                 icon_name = main_window.get_button_icon(action, gtype) | ||||
|             else:  # only prev_tab/next_tab (treat as nav) | ||||
|                 direction = 'left' if action == 'prev_tab' else 'right' | ||||
|                 icon_name = main_window.get_nav_icon(direction, gtype) | ||||
|             icon_path = theme_manager.get_theme_image(icon_name, current_theme_name) | ||||
|             pixmap = QPixmap() | ||||
|             if icon_path: | ||||
|                 pixmap.load(str(icon_path)) | ||||
|             if not pixmap.isNull(): | ||||
|                 icon_label.setPixmap(pixmap.scaled( | ||||
|                     26, 26, | ||||
|                     Qt.AspectRatioMode.KeepAspectRatio, | ||||
|                     Qt.TransformationMode.SmoothTransformation | ||||
|                 )) | ||||
|             else: | ||||
|                 # Fallback to placeholder | ||||
|                 placeholder = theme_manager.get_theme_image("placeholder", current_theme_name) | ||||
|                 if placeholder: | ||||
|                     pixmap.load(str(placeholder)) | ||||
|                     icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) | ||||
|         else: | ||||
|             container.setVisible(False) | ||||
|  | ||||
| class FileSelectedSignal(QObject): | ||||
|     file_selected = Signal(str)  # Сигнал с путем к выбранному файлу | ||||
|  | ||||
| @@ -181,6 +309,7 @@ class FileExplorer(QDialog): | ||||
|         self.initial_path = initial_path  # Store initial path if provided | ||||
|         self.thumbnail_cache = {}  # Cache for loaded thumbnails | ||||
|         self.pending_thumbnails = set()  # Track files pending thumbnail loading | ||||
|         self.main_window = None  # Add reference to MainWindow | ||||
|         self.setup_ui() | ||||
|  | ||||
|         # Window settings | ||||
| @@ -194,6 +323,7 @@ class FileExplorer(QDialog): | ||||
|         while parent: | ||||
|             if hasattr(parent, 'input_manager'): | ||||
|                 self.input_manager = cast("MainWindow", parent).input_manager | ||||
|                 self.main_window = parent | ||||
|             if hasattr(parent, 'context_menu_manager'): | ||||
|                 self.context_menu_manager = cast("MainWindow", parent).context_menu_manager | ||||
|             parent = parent.parent() | ||||
| @@ -210,6 +340,17 @@ class FileExplorer(QDialog): | ||||
|             self.current_path = os.path.expanduser("~")  # Fallback to home if initial path is invalid | ||||
|         self.update_file_list() | ||||
|  | ||||
|         # Create hints widget using common function | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
|         self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='file_explorer') | ||||
|         self.main_layout.addWidget(self.hints_widget) | ||||
|  | ||||
|         # Connect signals | ||||
|         if self.input_manager: | ||||
|             self.input_manager.button_event.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)) | ||||
|             self.input_manager.dpad_moved.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)) | ||||
|             update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) | ||||
|  | ||||
|     class ThumbnailLoader(QRunnable): | ||||
|         """Class for asynchronous thumbnail loading in a separate thread.""" | ||||
|         class Signals(QObject): | ||||
| @@ -507,8 +648,8 @@ class FileExplorer(QDialog): | ||||
|         """Update the list of mounted drives and favorite folders.""" | ||||
|         for i in reversed(range(self.drives_layout.count())): | ||||
|             item = self.drives_layout.itemAt(i) | ||||
|             if item and item.widget(): | ||||
|                 widget = item.widget() | ||||
|             widget = item.widget() if item else None | ||||
|             if widget: | ||||
|                 self.drives_layout.removeWidget(widget) | ||||
|                 widget.deleteLater() | ||||
|  | ||||
| @@ -597,6 +738,16 @@ class FileExplorer(QDialog): | ||||
|         self.thumbnail_cache.clear()  # Clear cache when changing directories | ||||
|         self.pending_thumbnails.clear()  # Clear pending thumbnails | ||||
|         try: | ||||
|             if self.directory_only: | ||||
|                 item = QListWidgetItem("./") | ||||
|                 folder_icon = theme_manager.get_icon("folder") | ||||
|                 # Ensure the icon is a QIcon | ||||
|                 if isinstance(folder_icon, str) and os.path.isfile(folder_icon): | ||||
|                     folder_icon = QIcon(folder_icon) | ||||
|                 elif not isinstance(folder_icon, QIcon): | ||||
|                     folder_icon = QIcon()  # Fallback to empty icon | ||||
|                 item.setIcon(folder_icon) | ||||
|                 self.file_list.addItem(item) | ||||
|             if self.current_path != "/": | ||||
|                 item = QListWidgetItem("../") | ||||
|                 folder_icon = theme_manager.get_icon("folder") | ||||
| @@ -804,6 +955,60 @@ class AddGameDialog(QDialog): | ||||
|         if edit_mode: | ||||
|             self.updatePreview() | ||||
|  | ||||
|         # Инициализация клавиатуры (отдельным методом вроде лучше) | ||||
|         self.init_keyboard() | ||||
|  | ||||
|         # Устанавливаем фокус на первое поле при открытии | ||||
|         QTimer.singleShot(0, self.nameEdit.setFocus) | ||||
|  | ||||
|     def init_keyboard(self): | ||||
|         """Инициализация виртуальной клавиатуры""" | ||||
|         self.keyboard = VirtualKeyboard(self, theme=self.theme, button_width=40) | ||||
|         self.keyboard.hide() | ||||
|  | ||||
|     def show_keyboard_for_widget(self, widget): | ||||
|         """Показывает клавиатуру для указанного виджета""" | ||||
|         if not widget or not widget.isVisible(): | ||||
|             return | ||||
|  | ||||
|         # Устанавливаем текущий виджет ввода | ||||
|         self.keyboard.current_input_widget = widget | ||||
|  | ||||
|         # Позиционирование клавиатуры | ||||
|         keyboard_height = 220 | ||||
|         self.keyboard.setFixedWidth(self.width()) | ||||
|         self.keyboard.setFixedHeight(keyboard_height) | ||||
|         self.keyboard.move(0, self.height() - keyboard_height) | ||||
|  | ||||
|         # Показываем и поднимаем клавиатуру | ||||
|         self.keyboard.setParent(self) | ||||
|         self.keyboard.show() | ||||
|         self.keyboard.raise_() | ||||
|  | ||||
|         # TODO: доработать. | ||||
|         # Устанавливаем фокус на первую кнопку клавиатуры | ||||
|         first_button = self.keyboard.findFirstFocusableButton() | ||||
|         if first_button: | ||||
|             QTimer.singleShot(50, lambda: first_button.setFocus()) | ||||
|  | ||||
|     def closeEvent(self, event): | ||||
|         """Обработчик закрытия окна""" | ||||
|         if hasattr(self, 'keyboard'): | ||||
|             self.keyboard.hide() | ||||
|         super().closeEvent(event) | ||||
|  | ||||
|     def reject(self): | ||||
|         """Обработчик кнопки Cancel""" | ||||
|         if hasattr(self, 'keyboard'): | ||||
|             self.keyboard.hide() | ||||
|         super().reject() | ||||
|  | ||||
|     def accept(self): | ||||
|         """Обработчик кнопки Apply""" | ||||
|         if hasattr(self, 'keyboard'): | ||||
|             self.keyboard.hide() | ||||
|         super().accept() | ||||
|  | ||||
|     def browseExe(self): | ||||
|         """Открывает файловый менеджер для выбора exe-файла""" | ||||
|         try: | ||||
| @@ -829,8 +1034,8 @@ class AddGameDialog(QDialog): | ||||
|         """Обработчик выбора файла в FileExplorer""" | ||||
|         self.exeEdit.setText(file_path) | ||||
|         self.last_exe_path = file_path  # Update last selected exe path | ||||
|         if not self.edit_mode: | ||||
|             # Автоматически заполняем имя игры, если не в режиме редактирования | ||||
|         if not self.edit_mode and not self.nameEdit.text().strip(): | ||||
|             # Автоматически заполняем имя игры, если не в режиме редактирования или если оно не введено вручную | ||||
|             game_name = os.path.splitext(os.path.basename(file_path))[0] | ||||
|             self.nameEdit.setText(game_name) | ||||
|  | ||||
| @@ -967,3 +1172,505 @@ Icon={icon_path} | ||||
| """ | ||||
|  | ||||
|         return desktop_entry, desktop_path | ||||
|  | ||||
| class WinetricksDialog(QDialog): | ||||
|     def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None): | ||||
|         super().__init__(parent) | ||||
|         self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) | ||||
|         self.prefix_path: str | None = prefix_path | ||||
|         self.wine_use: str | None = wine_use | ||||
|         self.portproton_path = get_portproton_location() | ||||
|         if self.portproton_path is None: | ||||
|             logger.error("PortProton location not found") | ||||
|             return | ||||
|         self.tmp_path = os.path.join(self.portproton_path, "data", "tmp") | ||||
|         os.makedirs(self.tmp_path, exist_ok=True) | ||||
|         self.winetricks_path = os.path.join(self.tmp_path, "winetricks") | ||||
|         if self.prefix_path is None: | ||||
|             logger.error("Prefix path not provided") | ||||
|             return | ||||
|         self.log_path = os.path.join(self.prefix_path, "winetricks.log") | ||||
|         os.makedirs(os.path.dirname(self.log_path), exist_ok=True) | ||||
|         if not os.path.exists(self.log_path): | ||||
|             open(self.log_path, 'a').close() | ||||
|  | ||||
|         self.downloader = Downloader(max_workers=4) | ||||
|         self.apply_process: QProcess | None = None | ||||
|  | ||||
|         self.setWindowTitle(_("Prefix Manager")) | ||||
|         self.setModal(True) | ||||
|         self.resize(700, 700) | ||||
|         self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE) | ||||
|  | ||||
|         self.update_winetricks() | ||||
|         self.setup_ui() | ||||
|         self.load_lists() | ||||
|  | ||||
|         # Find input_manager and main_window | ||||
|         self.input_manager = None | ||||
|         self.main_window = None | ||||
|         parent = self.parent() | ||||
|         while parent: | ||||
|             if hasattr(parent, 'input_manager'): | ||||
|                 self.input_manager = cast("MainWindow", parent).input_manager | ||||
|                 self.main_window = parent | ||||
|             parent = parent.parent() | ||||
|  | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
|  | ||||
|         # Enable Winetricks-specific mode | ||||
|         if self.input_manager: | ||||
|             self.input_manager.enable_winetricks_mode(self) | ||||
|  | ||||
|         # Create hints widget using common function | ||||
|         self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='winetricks') | ||||
|         self.main_layout.addWidget(self.hints_widget) | ||||
|  | ||||
|         # Connect signals (use self.theme_manager) | ||||
|         if self.input_manager: | ||||
|             self.input_manager.button_event.connect( | ||||
|                 lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) | ||||
|             ) | ||||
|             self.input_manager.dpad_moved.connect( | ||||
|                 lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) | ||||
|             ) | ||||
|             update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) | ||||
|  | ||||
|     def update_winetricks(self): | ||||
|         """Update the winetricks script.""" | ||||
|         if not self.downloader.has_internet(): | ||||
|             logger.warning("No internet connection, skipping winetricks update") | ||||
|             return | ||||
|  | ||||
|         url = "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks" | ||||
|         temp_path = os.path.join(self.tmp_path, "winetricks_temp") | ||||
|  | ||||
|         try: | ||||
|             self.downloader.download(url, temp_path) | ||||
|             with open(temp_path) as f: | ||||
|                 ext_content = f.read() | ||||
|             ext_ver_match = re.search(r'WINETRICKS_VERSION\s*=\s*[\'"]?([^\'"\s]+)', ext_content) | ||||
|             ext_ver = ext_ver_match.group(1) if ext_ver_match else None | ||||
|             logger.info(f"External winetricks version: {ext_ver}") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to get external version: {e}") | ||||
|             ext_ver = None | ||||
|             if os.path.exists(temp_path): | ||||
|                 os.remove(temp_path) | ||||
|             return | ||||
|  | ||||
|         int_ver = None | ||||
|         if os.path.exists(self.winetricks_path): | ||||
|             try: | ||||
|                 with open(self.winetricks_path) as f: | ||||
|                     int_content = f.read() | ||||
|                 int_ver_match = re.search(r'WINETRICKS_VERSION\s*=\s*[\'"]?([^\'"\s]+)', int_content) | ||||
|                 int_ver = int_ver_match.group(1) if int_ver_match else None | ||||
|                 logger.info(f"Internal winetricks version: {int_ver}") | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to read internal winetricks version: {e}") | ||||
|  | ||||
|         update_needed = not os.path.exists(self.winetricks_path) or (int_ver != ext_ver and ext_ver) | ||||
|  | ||||
|         if update_needed: | ||||
|             try: | ||||
|                 self.downloader.download(url, self.winetricks_path) | ||||
|                 os.chmod(self.winetricks_path, 0o755) | ||||
|                 logger.info(f"Winetricks updated to version {ext_ver}") | ||||
|                 self.apply_modifications(self.winetricks_path) | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to update winetricks: {e}") | ||||
|         elif os.path.exists(self.winetricks_path): | ||||
|             self.apply_modifications(self.winetricks_path) | ||||
|  | ||||
|         if os.path.exists(temp_path): | ||||
|             os.remove(temp_path) | ||||
|  | ||||
|     def apply_modifications(self, file_path): | ||||
|         """Apply custom modifications to the winetricks script.""" | ||||
|         if not os.path.exists(file_path): | ||||
|             return | ||||
|         try: | ||||
|             with open(file_path) as f: | ||||
|                 content = f.read() | ||||
|  | ||||
|             # Apply sed-like replacements | ||||
|             content = re.sub(r'w_metadata vcrun2015 dlls \\', r'w_metadata !dont_use_2015! dlls \\', content) | ||||
|             content = re.sub(r'w_metadata vcrun2017 dlls \\', r'w_metadata !dont_use_2017! dlls \\', content) | ||||
|             content = re.sub(r'w_metadata vcrun2019 dlls \\', r'w_metadata !dont_use_2019! dlls \\', content) | ||||
|             content = re.sub(r'w_set_winver win2k3', r'w_set_winver win7', content) | ||||
|  | ||||
|             with open(file_path, 'w') as f: | ||||
|                 f.write(content) | ||||
|             logger.info("Winetricks modifications applied") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error applying modifications to winetricks: {e}") | ||||
|  | ||||
|     def setup_ui(self): | ||||
|         """Set up the user interface with tabs and tables.""" | ||||
|         self.main_layout = QVBoxLayout(self) | ||||
|         self.main_layout.setContentsMargins(10, 10, 10, 10) | ||||
|         self.main_layout.setSpacing(10) | ||||
|  | ||||
|         # Log output | ||||
|         self.log_output = QTextEdit() | ||||
|         self.log_output.setReadOnly(True) | ||||
|         self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE) | ||||
|         self.main_layout.addWidget(self.log_output) | ||||
|  | ||||
|         # Tab widget | ||||
|         self.tab_widget = QTabWidget() | ||||
|         self.tab_widget.setStyleSheet(self.theme.WINETRICKS_TAB_STYLE) | ||||
|  | ||||
|         table_base_style = self.theme.WINETRICKS_TABBLE_STYLE | ||||
|  | ||||
|         # DLLs tab | ||||
|         self.dll_table = QTableWidget() | ||||
|         self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) | ||||
|         self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) | ||||
|         self.dll_table.setColumnCount(3) | ||||
|         self.dll_table.setHorizontalHeaderLabels([_("Set"), _("Libraries"), _("Information")]) | ||||
|         self.dll_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) | ||||
|         self.dll_table.horizontalHeader().resizeSection(0, 50) | ||||
|         self.dll_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) | ||||
|         self.dll_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) | ||||
|         self.dll_table.setStyleSheet(table_base_style) | ||||
|  | ||||
|         self.dll_preloader = Preloader() | ||||
|         dll_preloader_container = QWidget() | ||||
|         dll_preloader_layout = QVBoxLayout(dll_preloader_container) | ||||
|         dll_preloader_layout.addStretch() | ||||
|         dll_preloader_hlayout = QHBoxLayout() | ||||
|         dll_preloader_hlayout.addStretch() | ||||
|         dll_preloader_hlayout.addWidget(self.dll_preloader) | ||||
|         dll_preloader_hlayout.addStretch() | ||||
|         dll_preloader_layout.addLayout(dll_preloader_hlayout) | ||||
|         dll_preloader_layout.addStretch() | ||||
|         dll_preloader_layout.setContentsMargins(0, 0, 0, 0) | ||||
|         dll_preloader_layout.setSpacing(0) | ||||
|  | ||||
|         self.dll_container = QStackedWidget() | ||||
|         self.dll_container.addWidget(dll_preloader_container) | ||||
|         self.dll_container.addWidget(self.dll_table) | ||||
|         self.tab_widget.addTab(self.dll_container, "DLLs") | ||||
|  | ||||
|         # Fonts tab | ||||
|         self.fonts_table = QTableWidget() | ||||
|         self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) | ||||
|         self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) | ||||
|         self.fonts_table.setColumnCount(3) | ||||
|         self.fonts_table.setHorizontalHeaderLabels([_("Set"), _("Fonts"), _("Information")]) | ||||
|         self.fonts_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) | ||||
|         self.fonts_table.horizontalHeader().resizeSection(0, 50) | ||||
|         self.fonts_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) | ||||
|         self.fonts_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) | ||||
|         self.fonts_table.setStyleSheet(table_base_style) | ||||
|  | ||||
|         self.fonts_preloader = Preloader() | ||||
|         fonts_preloader_container = QWidget() | ||||
|         fonts_preloader_layout = QVBoxLayout(fonts_preloader_container) | ||||
|         fonts_preloader_layout.addStretch() | ||||
|         fonts_preloader_hlayout = QHBoxLayout() | ||||
|         fonts_preloader_hlayout.addStretch() | ||||
|         fonts_preloader_hlayout.addWidget(self.fonts_preloader) | ||||
|         fonts_preloader_hlayout.addStretch() | ||||
|         fonts_preloader_layout.addLayout(fonts_preloader_hlayout) | ||||
|         fonts_preloader_layout.addStretch() | ||||
|         fonts_preloader_layout.setContentsMargins(0, 0, 0, 0) | ||||
|         fonts_preloader_layout.setSpacing(0) | ||||
|  | ||||
|         self.fonts_container = QStackedWidget() | ||||
|         self.fonts_container.addWidget(fonts_preloader_container) | ||||
|         self.fonts_container.addWidget(self.fonts_table) | ||||
|         self.tab_widget.addTab(self.fonts_container, _("Fonts")) | ||||
|  | ||||
|         # Settings tab | ||||
|         self.settings_table = QTableWidget() | ||||
|         self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) | ||||
|         self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) | ||||
|         self.settings_table.setColumnCount(3) | ||||
|         self.settings_table.setHorizontalHeaderLabels([_("Set"), _("Settings"), _("Information")]) | ||||
|         self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed) | ||||
|         self.settings_table.horizontalHeader().resizeSection(0, 50) | ||||
|         self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) | ||||
|         self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) | ||||
|         self.settings_table.setStyleSheet(table_base_style) | ||||
|  | ||||
|         self.settings_preloader = Preloader() | ||||
|         settings_preloader_container = QWidget() | ||||
|         settings_preloader_layout = QVBoxLayout(settings_preloader_container) | ||||
|         settings_preloader_layout.addStretch() | ||||
|         settings_preloader_hlayout = QHBoxLayout() | ||||
|         settings_preloader_hlayout.addStretch() | ||||
|         settings_preloader_hlayout.addWidget(self.settings_preloader) | ||||
|         settings_preloader_hlayout.addStretch() | ||||
|         settings_preloader_layout.addLayout(settings_preloader_hlayout) | ||||
|         settings_preloader_layout.addStretch() | ||||
|         settings_preloader_layout.setContentsMargins(0, 0, 0, 0) | ||||
|         settings_preloader_layout.setSpacing(0) | ||||
|  | ||||
|         self.settings_container = QStackedWidget() | ||||
|         self.settings_container.addWidget(settings_preloader_container) | ||||
|         self.settings_container.addWidget(self.settings_table) | ||||
|         self.tab_widget.addTab(self.settings_container, _("Settings")) | ||||
|  | ||||
|         self.containers = { | ||||
|             "dlls": self.dll_container, | ||||
|             "fonts": self.fonts_container, | ||||
|             "settings": self.settings_container | ||||
|         } | ||||
|  | ||||
|         self.main_layout.addWidget(self.tab_widget) | ||||
|  | ||||
|         # Buttons | ||||
|         button_layout = QHBoxLayout() | ||||
|         button_layout.setSpacing(10) | ||||
|         self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel")) | ||||
|         self.force_button = AutoSizeButton(_("Force Install"), icon=theme_manager.get_icon("apply")) | ||||
|         self.install_button = AutoSizeButton(_("Install"), icon=theme_manager.get_icon("apply")) | ||||
|         self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|         self.force_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|         self.install_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|         button_layout.addWidget(self.cancel_button) | ||||
|         button_layout.addWidget(self.force_button) | ||||
|         button_layout.addWidget(self.install_button) | ||||
|         self.main_layout.addLayout(button_layout) | ||||
|  | ||||
|         self.cancel_button.clicked.connect(self.reject) | ||||
|         self.force_button.clicked.connect(lambda: self.install_selected(force=True)) | ||||
|         self.install_button.clicked.connect(lambda: self.install_selected(force=False)) | ||||
|  | ||||
|     def load_lists(self): | ||||
|         """Load and populate the lists for DLLs, Fonts, and Settings""" | ||||
|         if not os.path.exists(self.winetricks_path): | ||||
|             QMessageBox.warning(self, _("Error"), _("Winetricks not found. Please try again.")) | ||||
|             self.reject() | ||||
|             return | ||||
|  | ||||
|         assert self.prefix_path is not None | ||||
|         env = QProcessEnvironment.systemEnvironment() | ||||
|         env.insert("WINEPREFIX", self.prefix_path) | ||||
|         env.insert("WINETRICKS_DOWNLOADER", "curl") | ||||
|         if self.wine_use is not None: | ||||
|             env.insert("WINE", self.wine_use) | ||||
|  | ||||
|         cwd = os.path.dirname(self.winetricks_path) | ||||
|  | ||||
|         # DLLs | ||||
|         self.containers["dlls"].setCurrentIndex(0) | ||||
|         self._start_list_process("dlls", self.dll_table, self.get_dll_exclusions(), env, cwd) | ||||
|  | ||||
|         # Fonts | ||||
|         self.containers["fonts"].setCurrentIndex(0) | ||||
|         self._start_list_process("fonts", self.fonts_table, self.get_fonts_exclusions(), env, cwd) | ||||
|  | ||||
|         # Settings | ||||
|         self.containers["settings"].setCurrentIndex(0) | ||||
|         self._start_list_process("settings", self.settings_table, self.get_settings_exclusions(), env, cwd) | ||||
|  | ||||
|     def _start_list_process(self, category, table, exclusion_pattern, env, cwd): | ||||
|         """Запускает QProcess для списка.""" | ||||
|         process = QProcess(self) | ||||
|         process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) | ||||
|         process.setProcessEnvironment(env) | ||||
|         process.finished.connect(lambda exit_code, exit_status: self._on_list_finished(category, table, exclusion_pattern, process, exit_code, exit_status)) | ||||
|         process.start(self.winetricks_path, [category, "list"]) | ||||
|  | ||||
|     def _on_list_finished(self, category, table, exclusion_pattern, process: QProcess | None, exit_code, exit_status): | ||||
|         """Обработчик завершения списка.""" | ||||
|         if process is None: | ||||
|             logger.error(f"Process is None for {category}") | ||||
|             self.containers[category].setCurrentIndex(1) | ||||
|             return | ||||
|         output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore') | ||||
|         if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit: | ||||
|             self.populate_table(table, output, exclusion_pattern, self.log_path) | ||||
|             # Restore focus after populating | ||||
|             if table.rowCount() > 0: | ||||
|                 table.setCurrentCell(0, 0) | ||||
|                 table.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|         else: | ||||
|             error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore') | ||||
|             logger.error(f"Failed to list {category}: {error_output}") | ||||
|  | ||||
|         self.containers[category].setCurrentIndex(1) | ||||
|  | ||||
|     def get_dll_exclusions(self): | ||||
|         """Get regex pattern for DLL exclusions.""" | ||||
|         return r'(d3d|directx9|dont_use|dxvk|vkd3d|galliumnine|faudio1|Foundation)' | ||||
|  | ||||
|     def get_fonts_exclusions(self): | ||||
|         """Get regex pattern for Fonts exclusions.""" | ||||
|         return r'dont_use' | ||||
|  | ||||
|     def get_settings_exclusions(self): | ||||
|         """Get regex pattern for Settings exclusions.""" | ||||
|         return r'(vista|alldlls|autostart_|bad|good|win|videomemory|vd=|isolate_home)' | ||||
|  | ||||
|     def populate_table(self, table, output, exclusion_pattern, log_path): | ||||
|         """Populate the table with items from output, checking installation status.""" | ||||
|         table.setRowCount(0) | ||||
|         table.verticalHeader().setVisible(False) | ||||
|         lines = output.strip().split('\n') | ||||
|         installed = set() | ||||
|         if os.path.exists(log_path): | ||||
|             with open(log_path) as f: | ||||
|                 for line in f: | ||||
|                     installed.add(line.strip()) | ||||
|  | ||||
|         # regex-парсинг (имя - первое слово, остальное - описание) | ||||
|         line_re = re.compile(r"^\s*(?:\[(.)]\s+)?([^\s]+)\s*(.*)") | ||||
|  | ||||
|         for line in lines: | ||||
|             line = line.strip() | ||||
|             if not line or re.search(exclusion_pattern, line, re.I): | ||||
|                 continue | ||||
|  | ||||
|             line = line.split('(', 1)[0].strip() | ||||
|  | ||||
|             match = line_re.match(line) | ||||
|             if not match: | ||||
|                 continue | ||||
|  | ||||
|             _status, name, info = match.groups() | ||||
|             # Очищаем info от мусора | ||||
|             info = re.sub(r'\[.*?\]', '', info).strip()  # Удаляем [скачивания] и т.п. | ||||
|  | ||||
|             # To match bash desc extraction: after name, substr(2) to trim leading space | ||||
|             if info.startswith(' '): | ||||
|                 info = info[1:].lstrip() | ||||
|  | ||||
|             # Фильтр служебных строк | ||||
|             if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'): | ||||
|                 continue | ||||
|  | ||||
|             checked = Qt.CheckState.Checked if name in installed else Qt.CheckState.Unchecked | ||||
|  | ||||
|             row = table.rowCount() | ||||
|             table.insertRow(row) | ||||
|  | ||||
|             # Checkbox | ||||
|             checkbox = QTableWidgetItem() | ||||
|             checkbox.setCheckState(checked) | ||||
|             table.setItem(row, 0, checkbox) | ||||
|  | ||||
|             # Name | ||||
|             name_item = QTableWidgetItem(name) | ||||
|             table.setItem(row, 1, name_item) | ||||
|  | ||||
|             # Info | ||||
|             info_item = QTableWidgetItem(info) | ||||
|             table.setItem(row, 2, info_item) | ||||
|  | ||||
|     def install_selected(self, force=False): | ||||
|         """Install selected components.""" | ||||
|         selected = [] | ||||
|         for table in [self.dll_table, self.fonts_table, self.settings_table]: | ||||
|             for row in range(table.rowCount()): | ||||
|                 checkbox = table.item(row, 0) | ||||
|                 if checkbox is not None and checkbox.checkState() == Qt.CheckState.Checked: | ||||
|                     name_item = table.item(row, 1) | ||||
|                     if name_item is not None: | ||||
|                         name = name_item.text() | ||||
|                         if name and name not in selected: | ||||
|                             selected.append(name) | ||||
|  | ||||
|         # Load installed | ||||
|         installed = set() | ||||
|         if os.path.exists(self.log_path): | ||||
|             with open(self.log_path) as f: | ||||
|                 for line in f: | ||||
|                     installed.add(line.strip()) | ||||
|  | ||||
|         # Filter to new selected | ||||
|         new_selected = [name for name in selected if name not in installed] | ||||
|  | ||||
|         if not new_selected: | ||||
|             QMessageBox.information(self, _("Warning"), _("No components selected.")) | ||||
|             return | ||||
|  | ||||
|         self.install_button.setEnabled(False) | ||||
|         self.force_button.setEnabled(False) | ||||
|         self.cancel_button.setEnabled(False) | ||||
|  | ||||
|         self._start_install_process(new_selected, force) | ||||
|  | ||||
|     def _start_install_process(self, selected, force): | ||||
|         """Запускает QProcess для установки.""" | ||||
|         assert self.prefix_path is not None | ||||
|         env = QProcessEnvironment.systemEnvironment() | ||||
|         env.insert("WINEPREFIX", self.prefix_path) | ||||
|         if self.wine_use is not None: | ||||
|             env.insert("WINE", self.wine_use) | ||||
|  | ||||
|         self.apply_process = QProcess(self) | ||||
|         self.apply_process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels) | ||||
|         self.apply_process.setProcessEnvironment(env) | ||||
|         self.apply_process.readyReadStandardOutput.connect(self._on_ready_read) | ||||
|         self.apply_process.finished.connect(lambda exit_code, exit_status: self._on_install_finished(exit_code, exit_status, selected)) | ||||
|         args = ["--unattended"] + (["--force"] if force else []) + selected | ||||
|         self.apply_process.start(self.winetricks_path, args) | ||||
|  | ||||
|     def _on_ready_read(self): | ||||
|         """Handle ready read for install process.""" | ||||
|         if self.apply_process is None: | ||||
|             return | ||||
|         data = self.apply_process.readAllStandardOutput().data() | ||||
|         message = bytes(data).decode('utf-8', 'ignore').strip() | ||||
|         self._log(message) | ||||
|  | ||||
|     def _on_install_finished(self, exit_code, exit_status, selected): | ||||
|         """Обработчик завершения установки.""" | ||||
|         error_message = "" | ||||
|         if self.apply_process is not None: | ||||
|             # Читаем вывод в зависимости от режима каналов | ||||
|             if self.apply_process.processChannelMode() == QProcess.ProcessChannelMode.MergedChannels: | ||||
|                 # Если каналы объединены, читаем из StandardOutput | ||||
|                 output_data = self.apply_process.readAllStandardOutput().data() | ||||
|                 error_message = bytes(output_data).decode('utf-8', 'ignore') | ||||
|             else: | ||||
|                 # Если каналы разделены, читаем из StandardError | ||||
|                 error_data = self.apply_process.readAllStandardError().data() | ||||
|                 error_message = bytes(error_data).decode('utf-8', 'ignore') | ||||
|  | ||||
|         if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit: | ||||
|             logger.error(f"Winetricks install failed: {error_message}") | ||||
|             QMessageBox.warning(self, _("Error"), _("Installation failed. Check logs.")) | ||||
|         else: | ||||
|             if os.path.exists(self.log_path): | ||||
|                 with open(self.log_path) as f: | ||||
|                     existing = {line.strip() for line in f if line.strip()} | ||||
|             else: | ||||
|                 existing = set() | ||||
|             with open(self.log_path, 'a') as f: | ||||
|                 for name in selected: | ||||
|                     if name not in existing: | ||||
|                         f.write(f"{name}\n") | ||||
|             logger.info("Winetricks installation completed successfully.") | ||||
|             QMessageBox.information(self, _("Success"), _("Components installed successfully.")) | ||||
|             self.load_lists() | ||||
|  | ||||
|         # Разблокировка | ||||
|         self.install_button.setEnabled(True) | ||||
|         self.force_button.setEnabled(True) | ||||
|         self.cancel_button.setEnabled(True) | ||||
|  | ||||
|     def _log(self, message): | ||||
|         """Добавляет в лог.""" | ||||
|         self.log_output.append(message) | ||||
|         self.log_output.moveCursor(QTextCursor.MoveOperation.End) | ||||
|  | ||||
|     def closeEvent(self, event): | ||||
|         """Disable mode on close.""" | ||||
|         if self.input_manager: | ||||
|             self.input_manager.disable_winetricks_mode() | ||||
|         super().closeEvent(event) | ||||
|  | ||||
|     def reject(self): | ||||
|         """Disable mode on reject.""" | ||||
|         if self.input_manager: | ||||
|             self.input_manager.disable_winetricks_mode() | ||||
|         super().reject() | ||||
|   | ||||
| @@ -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)) | ||||
|   | ||||
| @@ -33,8 +33,10 @@ class MainWindowProtocol(Protocol): | ||||
|     # Required attributes | ||||
|     searchEdit: CustomLineEdit | ||||
|     _last_card_width: int | ||||
|     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): | ||||
| @@ -127,6 +129,8 @@ class GameLibraryManager: | ||||
|         self.card_width = self.sizeSlider.value() | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         save_card_size(self.card_width) | ||||
|         self.main_window.card_width = self.card_width | ||||
|         self.main_window._last_card_width = self.card_width | ||||
|         for card in self.game_card_cache.values(): | ||||
|             card.update_card_size(self.card_width) | ||||
|         self.update_game_grid() | ||||
| @@ -216,6 +220,16 @@ class GameLibraryManager: | ||||
|         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: | ||||
| @@ -345,6 +359,8 @@ class GameLibraryManager: | ||||
|                 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): | ||||
| @@ -362,8 +378,9 @@ class GameLibraryManager: | ||||
|                     cover_path, width, height, callback = self.pending_images.pop(game_key) | ||||
|                     load_pixmap_async(cover_path, width, height, callback) | ||||
|  | ||||
|         # Force geometry update so FlowLayout accounts for hidden widgets | ||||
|         # 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() | ||||
|   | ||||
							
								
								
									
										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-23 22:23+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" | ||||
| @@ -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 "" | ||||
|  | ||||
| @@ -304,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 "" | ||||
|  | ||||
| @@ -352,9 +413,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -370,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 "" | ||||
|  | ||||
| @@ -382,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..." | ||||
| @@ -424,6 +597,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -448,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 "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-09-23 22:23+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" | ||||
| @@ -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 "" | ||||
|  | ||||
| @@ -304,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 "" | ||||
|  | ||||
| @@ -352,9 +413,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -370,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 "" | ||||
|  | ||||
| @@ -382,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..." | ||||
| @@ -424,6 +597,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -448,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 "" | ||||
|  | ||||
|   | ||||
| @@ -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-23 22:23+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" | ||||
| @@ -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 "" | ||||
|  | ||||
| @@ -302,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 "" | ||||
|  | ||||
| @@ -350,9 +411,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -368,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 "" | ||||
|  | ||||
| @@ -380,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..." | ||||
| @@ -422,6 +595,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -446,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 "" | ||||
|  | ||||
|   | ||||
| @@ -9,18 +9,17 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-09-23 22:23+0500\n" | ||||
| "PO-Revision-Date: 2025-09-23 22:23+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-Team: ru_RU <LL@li.org>\n" | ||||
| "Language: ru_RU\n" | ||||
| "Language-Team: ru_RU <LL@li.org>\n" | ||||
| "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && " | ||||
| "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n" | ||||
| "MIME-Version: 1.0\n" | ||||
| "Content-Type: text/plain; charset=utf-8\n" | ||||
| "Content-Transfer-Encoding: 8bit\n" | ||||
| "Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 " | ||||
| "&& (n%100<10 || n%100>=20) ? 1 : 2);\n" | ||||
| "Generated-By: Babel 2.17.0\n" | ||||
| "X-Generator: Poedit 3.6\n" | ||||
|  | ||||
| msgid "Error" | ||||
| msgstr "Ошибка" | ||||
| @@ -87,11 +86,11 @@ msgstr "Успешно" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "'{game_name}' was added to Steam. Please restart Steam for changes to take " | ||||
| "effect." | ||||
| "'{game_name}' was added to Steam. Please restart Steam for changes to " | ||||
| "take effect." | ||||
| msgstr "" | ||||
| "'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите Steam, " | ||||
| "чтобы изменения вступили в силу." | ||||
| "'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите " | ||||
| "Steam, чтобы изменения вступили в силу." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Executable not found for game: {game_name}" | ||||
| @@ -179,11 +178,11 @@ msgstr "Подтвердите удаление" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Are you sure you want to delete '{game_name}'? This will remove the .desktop " | ||||
| "file and custom data." | ||||
| "Are you sure you want to delete '{game_name}'? This will remove the " | ||||
| ".desktop file and custom data." | ||||
| msgstr "" | ||||
| "Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению файла ." | ||||
| "desktop и пользовательских данных." | ||||
| "Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению " | ||||
| "файла .desktop и пользовательских данных." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete .desktop file: {error}" | ||||
| @@ -197,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 "Требуются название игры и путь к исполняемому файлу" | ||||
|  | ||||
| @@ -225,11 +228,11 @@ msgstr "Не удалось добавить '{game_name}' в Steam: {error}" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "'{game_name}' was removed from Steam. Please restart Steam for changes to take " | ||||
| "effect." | ||||
| "'{game_name}' was removed from Steam. Please restart Steam for changes to" | ||||
| " take effect." | ||||
| msgstr "" | ||||
| "'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam, чтобы " | ||||
| "изменения вступили в силу." | ||||
| "'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam," | ||||
| " чтобы изменения вступили в силу." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to remove game '{game_name}' from Steam: {error}" | ||||
| @@ -256,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 "Проводник" | ||||
|  | ||||
| @@ -274,7 +301,7 @@ msgstr "Путь: " | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "Доступ запрещен: %s" | ||||
| msgstr "Доступ запрещён: %s" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "Редактировать игру" | ||||
| @@ -312,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..." | ||||
|  | ||||
| @@ -360,9 +420,6 @@ msgstr "Библиотека" | ||||
| msgid "Auto Install" | ||||
| msgstr "Автоустановка" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "Эмуляторы" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "Настройки wine" | ||||
|  | ||||
| @@ -378,6 +435,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 "Загрузка игр из Steam..." | ||||
|  | ||||
| @@ -390,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..." | ||||
| @@ -432,6 +606,9 @@ msgstr "все" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "Фильтр игр:" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "Тип геймпада:" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "Адрес прокси" | ||||
|  | ||||
| @@ -456,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 "Режим полноэкранного отображения приложения при подключении геймпада" | ||||
|  | ||||
| @@ -482,7 +665,8 @@ msgstr "Подтвердите удаление" | ||||
|  | ||||
| msgid "Are you sure you want to reset all settings? This action cannot be undone." | ||||
| msgstr "" | ||||
| "Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить." | ||||
| "Вы уверены, что хотите сбросить все настройки? Это действие нельзя " | ||||
| "отменить." | ||||
|  | ||||
| msgid "Settings reset. Restarting..." | ||||
| msgstr "Настройки сброшены. Перезапуск..." | ||||
| @@ -654,3 +838,4 @@ msgstr "Нет избранных" | ||||
|  | ||||
| msgid "No recent games" | ||||
| 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 | ||||
| @@ -211,14 +211,28 @@ def normalize_name(s): | ||||
|  | ||||
| def is_valid_candidate(candidate): | ||||
|     """ | ||||
|     Checks if a candidate contains forbidden substrings: | ||||
|       - win32 | ||||
|       - win64 | ||||
|       - gamelauncher | ||||
|     Additionally checks the string without spaces. | ||||
|     Returns True if the candidate is valid, otherwise 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: | ||||
|   | ||||
							
								
								
									
										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 | 
| After Width: | Height: | Size: 232 KiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Библиотека.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 225 KiB | 
| Before Width: | Height: | Size: 1.3 MiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Карточка.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 70 KiB | 
| Before Width: | Height: | Size: 364 KiB | 
| Before Width: | Height: | Size: 430 KiB | 
| After Width: | Height: | Size: 238 KiB | 
| Before Width: | Height: | Size: 1.3 MiB | 
| After Width: | Height: | Size: 61 KiB | 
| After Width: | Height: | Size: 38 KiB | 
| Before Width: | Height: | Size: 104 KiB | 
| Before Width: | Height: | Size: 1.0 MiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Темы.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 93 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_y.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.438 26.092-7.6218-12.616h5.7406l4.4433 8.238 4.4109-8.238h5.7731l-7.6866 12.552v8.4974h-5.0595z" fill="#3f424d" stroke-width="1.0811" aria-label="Y"/></svg> | ||||
| After Width: | Height: | Size: 559 B | 
| @@ -217,6 +217,56 @@ CONTEXT_MENU_STYLE = f""" | ||||
|     }} | ||||
| """ | ||||
|  | ||||
| VIRTUAL_KEYBOARD_STYLE = """ | ||||
| VirtualKeyboard { | ||||
|     background-color: rgba(30, 30, 30, 200); | ||||
|     border-radius: 0px; | ||||
|     border: none; | ||||
| } | ||||
| QPushButton { | ||||
|     font-size: 14px; | ||||
|     border: 1px solid #555; | ||||
|     border-top-color: #666; | ||||
|     border-left-color: #666; | ||||
|     border-radius: 3px; | ||||
|     min-width: 30px; | ||||
|     min-height: 30px; | ||||
|     padding: 4px; | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #505050, stop:1 #404040); | ||||
|     color: #e0e0e0; | ||||
| } | ||||
| QPushButton:hover { | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #606060, stop:1 #505050); | ||||
|     border: 1px solid #666; | ||||
|     border-top-color: #777; | ||||
|     border-left-color: #777; | ||||
| } | ||||
| QPushButton:focus { | ||||
|     border: 2px solid #4a90e2; | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5a5a5a, stop:1 #454545); | ||||
| } | ||||
| QPushButton:pressed { | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a3a3a, stop:1 #303030); | ||||
|     border: 1px solid #444; | ||||
|     border-bottom-color: #555; | ||||
|     border-right-color: #555; | ||||
|     padding-top: 5px; | ||||
|     padding-bottom: 3px; | ||||
|     padding-left: 5px; | ||||
|     padding-right: 3px; | ||||
| } | ||||
| QPushButton[checked="true"] { | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a90e2, stop:1 #3a7ad2); | ||||
|     color: white; | ||||
|     border: 1px solid #2a6ac2; | ||||
|     border-top-color: #5aa0f2; | ||||
|     border-left-color: #5aa0f2; | ||||
| } | ||||
| QPushButton[checked="true"]:focus { | ||||
|     border: 2px solid #6aa3f5; | ||||
| } | ||||
| """ | ||||
|  | ||||
| # ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК | ||||
| MAIN_WINDOW_STYLE = f""" | ||||
|     QWidget {{ | ||||
| @@ -916,6 +966,96 @@ SETTINGS_CHECKBOX_STYLE = f""" | ||||
|     }} | ||||
| """ | ||||
|  | ||||
| WINETRICKS_TAB_STYLE = f""" | ||||
| QTabWidget::pane {{ | ||||
|     border: 1px solid {color_d}; | ||||
|     background: {color_b}; | ||||
|     border-radius: {border_radius_a}; | ||||
| }} | ||||
| QTabBar::tab {{ | ||||
|     background: {color_c}; | ||||
|     color: {color_f}; | ||||
|     padding: 8px 16px; | ||||
|     border-top-left-radius: {border_radius_a}; | ||||
|     border-top-right-radius: {border_radius_a}; | ||||
|     margin-right: 2px; | ||||
| }} | ||||
| QTabBar::tab:selected {{ | ||||
|     background: {color_a}; | ||||
|     color: {color_f}; | ||||
| }} | ||||
| QTabBar::tab:hover {{ | ||||
|     background: {color_e}; | ||||
| }} | ||||
| """ | ||||
|  | ||||
| WINETRICKS_TABBLE_STYLE = f""" | ||||
| QTableWidget {{ | ||||
|     background: {color_c}; | ||||
|     color: {color_f}; | ||||
|     gridline-color: {color_d}; | ||||
|     alternate-background-color: {color_d}; | ||||
|     border: {border_a}; | ||||
|     border-radius: {border_radius_a}; | ||||
|     font-family: '{font_family}'; | ||||
|     font-size: {font_size_a}; | ||||
| }} | ||||
| QHeaderView::section {{ | ||||
|     background: {color_d}; | ||||
|     color: {color_f}; | ||||
|     padding: 5px; | ||||
|     border: {border_a}; | ||||
|     font-weight: bold; | ||||
| }} | ||||
| QTableWidget::item {{ | ||||
|     padding: 8px; | ||||
|     border-bottom: 1px solid {color_d}; | ||||
| }} | ||||
| QTableWidget::item:selected {{ | ||||
|     background: {color_a}; | ||||
|     color: {color_f}; | ||||
| }} | ||||
| QTableWidget::item:hover {{ | ||||
|     background: {color_e}; | ||||
| }} | ||||
| QTableWidget::indicator {{ | ||||
|     width: 24px; | ||||
|     height: 24px; | ||||
|     border: {border_b} {color_a}; | ||||
|     border-radius: {border_radius_a}; | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
| }} | ||||
| QTableWidget::indicator:unchecked {{ | ||||
|     background: rgba(255, 255, 255, 0.1); | ||||
|     image: none; | ||||
| }} | ||||
| QTableWidget::indicator:checked {{ | ||||
|     background: {color_a}; | ||||
|     image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)}); | ||||
|     border: {border_b} {color_f}; | ||||
| }} | ||||
| QTableWidget::indicator:hover {{ | ||||
|     background: rgba(255, 255, 255, 0.2); | ||||
|     border: {border_b} {color_a}; | ||||
| }} | ||||
| QTableWidget::indicator:focus {{ | ||||
|     border: {border_c} {color_a}; | ||||
| }} | ||||
| {SCROLL_AREA_STYLE} | ||||
| """ | ||||
|  | ||||
| WINETRICKS_LOG_STYLE = f""" | ||||
| QTextEdit {{ | ||||
|     background: {color_c}; | ||||
|     border: {border_a}; | ||||
|     border-radius: {border_radius_a}; | ||||
|     color: {color_f}; | ||||
|     font-family: '{font_family}'; | ||||
|     font-size: {font_size_a}; | ||||
|     padding: 5px; | ||||
| }} | ||||
| """ | ||||
|  | ||||
| FILE_EXPLORER_STYLE = f""" | ||||
|     QListView {{ | ||||
|         font-size: {font_size_a}; | ||||
|   | ||||
							
								
								
									
										642
									
								
								portprotonqt/virtual_keyboard.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,642 @@ | ||||
| from typing import cast, Any | ||||
| from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout, | ||||
|                                QSizePolicy, QWidget, QLineEdit) | ||||
| from PySide6.QtCore import Qt, Signal, QProcess, QSize | ||||
| from PySide6.QtGui import QPixmap, QIcon | ||||
| from portprotonqt.keyboard_layouts import keyboard_layouts | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
| from portprotonqt.config_utils import read_theme_from_config | ||||
|  | ||||
| theme_manager = ThemeManager() | ||||
|  | ||||
| class VirtualKeyboard(QFrame): | ||||
|     keyPressed = Signal(str) | ||||
|  | ||||
|     def __init__(self, parent: QWidget | None = None, theme=None, button_width: int = 80): | ||||
|         super().__init__(parent) | ||||
|         self._parent: QWidget | None = parent | ||||
|         self.available_layouts: list[str] = self.get_layouts_setxkbmap() | ||||
|         self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) | ||||
|         if not self.available_layouts: | ||||
|             self.available_layouts.append('en') | ||||
|         self.current_layout: str = self.available_layouts[0] | ||||
|  | ||||
|         self.focus_timer = None | ||||
|         self.focus_delay = 150  # Задержка между перемещениями в мс | ||||
|         self.last_focus_time = 0 | ||||
|  | ||||
|         self.backspace_pressed = False | ||||
|         self.backspace_timer = None | ||||
|         self.backspace_initial_delay = 500 | ||||
|         self.backspace_repeat_delay = 50 | ||||
|         self.gamepad_x_pressed = False | ||||
|         self.caps_lock = False | ||||
|         self.shift_pressed = False | ||||
|         self.current_input_widget = None | ||||
|         self.cursor_visible = True | ||||
|         self.last_focused_button = None | ||||
|  | ||||
|         self.base_button_width = 40 | ||||
|         self.base_min_width = 574 | ||||
|         self.button_width = button_width | ||||
|         self.button_height = 40 | ||||
|         self.spacing = 4 | ||||
|         self.margins = 10 | ||||
|         self.num_cols = 14 | ||||
|  | ||||
|         # Find input_manager and main_window | ||||
|         self.input_manager: Any = None | ||||
|         self.main_window: Any = None | ||||
|         parent_widget: QWidget | None = self._parent | ||||
|         while parent_widget: | ||||
|             if hasattr(parent_widget, 'input_manager'): | ||||
|                 self.input_manager = cast(Any, parent_widget).input_manager | ||||
|                 self.main_window = cast(Any, parent_widget) | ||||
|             parent_widget = cast(QWidget | None, parent_widget.parent()) | ||||
|  | ||||
|  | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
|         self.initUI() | ||||
|         self.hide() | ||||
|  | ||||
|         self.setStyleSheet(self.theme.VIRTUAL_KEYBOARD_STYLE) | ||||
|  | ||||
|     def highlight_cursor_position(self): | ||||
|         """Подсвечиваем текущую позицию курсора""" | ||||
|         if not self.current_input_widget or not isinstance(self.current_input_widget, QLineEdit): | ||||
|             return | ||||
|  | ||||
|         # Просто устанавливаем курсор на нужную позицию без выделения | ||||
|         self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition()) | ||||
|  | ||||
|     def initUI(self): | ||||
|         layout = QVBoxLayout() | ||||
|         layout.setContentsMargins(0, 0, 0, 0) | ||||
|         layout.setSpacing(0) | ||||
|  | ||||
|         self.keyboard_layout = QGridLayout() | ||||
|         self.keyboard_layout.setSpacing(self.spacing) | ||||
|         self.keyboard_layout.setContentsMargins(self.margins // 2, self.margins // 2, self.margins // 2, self.margins // 2) | ||||
|         self.create_keyboard() | ||||
|  | ||||
|         self.keyboard_container = QWidget() | ||||
|         self.keyboard_container.setLayout(self.keyboard_layout) | ||||
|         ratio = self.button_width / self.base_button_width | ||||
|         self.keyboard_container.setMinimumWidth(int(self.base_min_width * ratio)) | ||||
|         self.keyboard_container.setMinimumHeight(220) | ||||
|         self.keyboard_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) | ||||
|  | ||||
|         layout.addWidget(self.keyboard_container, 0, Qt.AlignmentFlag.AlignHCenter) | ||||
|         self.setLayout(layout) | ||||
|         self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) | ||||
|  | ||||
|     def run_shell_command(self, cmd: str) -> str | None: | ||||
|         process = QProcess(self) | ||||
|         process.start("sh", ["-c", cmd]) | ||||
|         process.waitForFinished(-1) | ||||
|         if process.exitCode() == 0: | ||||
|             output_bytes = process.readAllStandardOutput().data() | ||||
|             if isinstance(output_bytes, memoryview): | ||||
|                 output_str = output_bytes.tobytes().decode('utf-8').strip() | ||||
|             else: | ||||
|                 output_str = output_bytes.decode('utf-8').strip() | ||||
|             return output_str | ||||
|         else: | ||||
|             return None | ||||
|  | ||||
|     def get_layouts_setxkbmap(self) -> list[str]: | ||||
|         """Получаем раскладки, которые используются в системе, возвращаем список вида ['us', 'ru'] и т.п.""" | ||||
|         cmd = r'''localectl status | awk -F: '/X11 Layout/ {gsub(/^[ \t]+/, "", $2); print $2}' ''' | ||||
|         output = self.run_shell_command(cmd) | ||||
|         if output: | ||||
|             layouts = [lang.strip() for lang in output.split(',') if lang.strip()] | ||||
|             return layouts if layouts else ['en'] | ||||
|         else: | ||||
|             return ['en'] | ||||
|  | ||||
|     def create_keyboard(self): | ||||
|         # TODO: сделать нормальное описание (сейчас лень) | ||||
|         # Основные раскладки с учетом Shift | ||||
|         # Фильтруем доступные раскладки | ||||
|  | ||||
|         LAYOUT_MAP = {'us': 'en'} | ||||
|  | ||||
|         # Assume keyboard_layouts is dict[str, dict[str, list[list[str]]]] | ||||
|         self.layouts: dict[str, dict[str, list[list[str]]]] = { | ||||
|             lang: keyboard_layouts.get(LAYOUT_MAP.get(lang, lang), keyboard_layouts['en']) | ||||
|             for lang in self.available_layouts | ||||
|         } | ||||
|  | ||||
|         self.current_layout = (self.current_layout if self.current_layout in self.layouts else next(iter(self.layouts.keys()), None) or 'en') | ||||
|  | ||||
|         self.buttons: dict[str, QPushButton] = {} | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     def set_gamepad_icon(self, button, icon_type, gtype=''): | ||||
|         """Set gamepad icon on button based on type""" | ||||
|         if icon_type in ['back', 'add_game']: | ||||
|             icon_name = self.main_window.get_button_icon(icon_type, gtype) | ||||
|         else:  # nav left/right | ||||
|             if icon_type in ['left', 'right']: | ||||
|                 direction = icon_type | ||||
|                 icon_name = self.main_window.get_nav_icon(direction, gtype) | ||||
|             else: | ||||
|                 direction = 'left' if icon_type == 'left' else 'right' | ||||
|                 icon_name = self.main_window.get_nav_icon(direction, gtype) | ||||
|  | ||||
|         icon_path = theme_manager.get_theme_image(icon_name, self.current_theme_name) | ||||
|         pixmap = QPixmap() | ||||
|         if icon_path: | ||||
|             pixmap.load(str(icon_path)) | ||||
|         if not pixmap.isNull(): | ||||
|             button.setIcon(QIcon(pixmap)) | ||||
|             button.setIconSize(QSize(20, 20)) | ||||
|             return | ||||
|         else: | ||||
|             # Fallback to placeholder | ||||
|             placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name) | ||||
|             if placeholder: | ||||
|                 button.setIcon(QIcon(placeholder)) | ||||
|                 button.setIconSize(QSize(20, 20)) | ||||
|                 return | ||||
|  | ||||
|     def update_keyboard(self): | ||||
|         coords = self._save_focused_coords() | ||||
|  | ||||
|         # Очищаем предыдущие кнопки | ||||
|         while self.keyboard_layout.count(): | ||||
|             item = self.keyboard_layout.takeAt(0) | ||||
|             if item.widget(): | ||||
|                 item.widget().deleteLater() | ||||
|  | ||||
|         fixed_w = self.button_width | ||||
|         fixed_h = self.button_height | ||||
|  | ||||
|         # Выбираем текущую раскладку (обычная или с shift) | ||||
|         layout_mode = 'shift' if self.shift_pressed else 'normal' | ||||
|         layout_data = self.layouts.get(self.current_layout, {}) | ||||
|         buttons: list[list[str]] = layout_data.get(layout_mode, []) | ||||
|  | ||||
|         # Добавляем основные кнопки | ||||
|         for row_idx, row in enumerate(buttons): | ||||
|             for col_idx, key in enumerate(row): | ||||
|                 button = QPushButton(key) | ||||
|                 button.setFixedSize(fixed_w, fixed_h) | ||||
|  | ||||
|                 # Обработчики для CAPS и левого Shift | ||||
|                 if key == 'CAPS': | ||||
|                     button.setCheckable(True) | ||||
|                     button.setChecked(self.caps_lock) | ||||
|                     button.clicked.connect(self.on_caps_click) | ||||
|                 elif key == '⬆':  # Левый Shift | ||||
|                     button.setCheckable(True) | ||||
|                     button.setChecked(self.shift_pressed) | ||||
|                     button.clicked.connect(lambda checked: self.on_shift_click(checked)) | ||||
|                     # Add gamepad icon for Shift (RB/R) | ||||
|                     gtype = self.input_manager.gamepad_type | ||||
|                     self.set_gamepad_icon(button, 'right', gtype) | ||||
|                 else: | ||||
|                     button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k)) | ||||
|  | ||||
|                 self.keyboard_layout.addWidget(button, row_idx, col_idx) | ||||
|                 self.buttons[key] = button | ||||
|  | ||||
|         # Нижний ряд (специальные кнопки) | ||||
|         shift = QPushButton('⬆') | ||||
|         shift.setFixedSize(fixed_w * 3 + 2 * self.spacing, fixed_h) | ||||
|         shift.setCheckable(True) | ||||
|         shift.setChecked(self.shift_pressed) | ||||
|         shift.clicked.connect(lambda checked: self.on_shift_click(checked)) | ||||
|         # Add gamepad icon for Shift (RB/R) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(shift, 'right', gtype) | ||||
|         self.keyboard_layout.addWidget(shift, 3, 11, 1, 3) | ||||
|  | ||||
|         button = QPushButton('CAPS') | ||||
|         button.setCheckable(True) | ||||
|         button.setChecked(self.caps_lock) | ||||
|         button.clicked.connect(self.on_caps_click) | ||||
|  | ||||
|         space = QPushButton('Space') | ||||
|         space.setFixedSize(fixed_w * 5 + 4 * self.spacing, fixed_h) | ||||
|         space.clicked.connect(lambda: self.on_button_click(' ')) | ||||
|         self.keyboard_layout.addWidget(space, 4, 1, 1, 5) | ||||
|  | ||||
|         backspace = QPushButton('⌫') | ||||
|         backspace.setFixedSize(fixed_w, fixed_h) | ||||
|         backspace.pressed.connect(self.on_backspace_pressed) | ||||
|         backspace.released.connect(self.stop_backspace_repeat) | ||||
|         # Add gamepad icon for Backspace (X/Triangle) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(backspace, 'add_game', gtype) | ||||
|         self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1) | ||||
|  | ||||
|         enter = QPushButton('Enter') | ||||
|         enter.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) | ||||
|         enter.clicked.connect(self.on_enter_click) | ||||
|         self.keyboard_layout.addWidget(enter, 2, 12, 1, 2) | ||||
|  | ||||
|         lang = QPushButton('🌐') | ||||
|         lang.setFixedSize(fixed_w, fixed_h) | ||||
|         lang.clicked.connect(self.on_lang_click) | ||||
|         # Add gamepad icon for Lang (LB/L) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(lang, 'left', gtype) | ||||
|         self.keyboard_layout.addWidget(lang, 4, 0, 1, 1) | ||||
|  | ||||
|         clear = QPushButton('Clear') | ||||
|         clear.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) | ||||
|         clear.clicked.connect(self.on_clear_click) | ||||
|         self.keyboard_layout.addWidget(clear, 4, 10, 1, 2) | ||||
|  | ||||
|         up = QPushButton('▲') | ||||
|         up.setFixedSize(fixed_w, fixed_h) | ||||
|         up.clicked.connect(self.up_key)  # Обработка клика мышью - управление курсором | ||||
|         self.keyboard_layout.addWidget(up, 4, 6, 1, 1) | ||||
|  | ||||
|         down = QPushButton('▼') | ||||
|         down.setFixedSize(fixed_w, fixed_h) | ||||
|         down.clicked.connect(self.down_key) | ||||
|         self.keyboard_layout.addWidget(down, 4, 7, 1, 1) | ||||
|  | ||||
|         left = QPushButton('◄') | ||||
|         left.setFixedSize(fixed_w, fixed_h) | ||||
|         left.clicked.connect(self.left_key) | ||||
|         self.keyboard_layout.addWidget(left, 4, 8, 1, 1) | ||||
|  | ||||
|         right = QPushButton('►') | ||||
|         right.setFixedSize(fixed_w, fixed_h) | ||||
|         right.clicked.connect(self.right_key) | ||||
|         self.keyboard_layout.addWidget(right, 4, 9, 1, 1) | ||||
|  | ||||
|         hide_button = QPushButton('Hide') | ||||
|         hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) | ||||
|         hide_button.clicked.connect(self.hide) | ||||
|         # Add gamepad icon for Hide (B/Circle) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(hide_button, 'back', gtype) | ||||
|         self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2) | ||||
|  | ||||
|         if coords: | ||||
|             row, col = coords | ||||
|             item = self.keyboard_layout.itemAtPosition(row, col) | ||||
|             if item and item.widget(): | ||||
|                 item.widget().setFocus() | ||||
|  | ||||
|     def up_key(self): | ||||
|         """Перемещает курсор в QLineEdit вверх/в начало, если клавиатура видима""" | ||||
|         if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): | ||||
|             self.current_input_widget.setCursorPosition(0) | ||||
|             self.current_input_widget.setFocus() | ||||
|  | ||||
|     def down_key(self): | ||||
|         """Перемещает курсор в QLineEdit вниз/в конец, если клавиатура видима""" | ||||
|         if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): | ||||
|             self.current_input_widget.setCursorPosition(len(self.current_input_widget.text())) | ||||
|             self.current_input_widget.setFocus() | ||||
|  | ||||
|     def left_key(self): | ||||
|         """Перемещает курсор в QLineEdit влево, если клавиатура видима""" | ||||
|         if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): | ||||
|             pos = self.current_input_widget.cursorPosition() | ||||
|             if pos > 0: | ||||
|                 self.current_input_widget.setCursorPosition(pos - 1) | ||||
|             self.current_input_widget.setFocus() | ||||
|  | ||||
|     def right_key(self): | ||||
|         """Перемещает курсор в QLineEdit вправо, если клавиатура видима""" | ||||
|         if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): | ||||
|             pos = self.current_input_widget.cursorPosition() | ||||
|             text_len = len(self.current_input_widget.text()) | ||||
|             if pos < text_len: | ||||
|                 self.current_input_widget.setCursorPosition(pos + 1) | ||||
|             self.current_input_widget.setFocus() | ||||
|  | ||||
|     def move_focus_up(self): | ||||
|         """Перемещает фокус по кнопкам клавиатуры вверх с фиксированной скоростью""" | ||||
|         current_time = self.get_current_time() | ||||
|         if current_time - self.last_focus_time >= self.focus_delay: | ||||
|             self.focusNextKey("up") | ||||
|             self.last_focus_time = current_time | ||||
|  | ||||
|     def move_focus_down(self): | ||||
|         """Перемещает фокус по кнопкам клавиатуры вниз с фиксированной скоростью""" | ||||
|         current_time = self.get_current_time() | ||||
|         if current_time - self.last_focus_time >= self.focus_delay: | ||||
|             self.focusNextKey("down") | ||||
|             self.last_focus_time = current_time | ||||
|  | ||||
|     def move_focus_left(self): | ||||
|         """Перемещает фокус по кнопкам клавиатуры влево с фиксированной скоростью""" | ||||
|         current_time = self.get_current_time() | ||||
|         if current_time - self.last_focus_time >= self.focus_delay: | ||||
|             self.focusNextKey("left") | ||||
|             self.last_focus_time = current_time | ||||
|  | ||||
|     def move_focus_right(self): | ||||
|         """Перемещает фокус по кнопкам клавиатуры вправо с фиксированной скоростью""" | ||||
|         current_time = self.get_current_time() | ||||
|         if current_time - self.last_focus_time >= self.focus_delay: | ||||
|             self.focusNextKey("right") | ||||
|             self.last_focus_time = current_time | ||||
|  | ||||
|     def get_current_time(self): | ||||
|         """Возвращает текущее время в миллисекундах""" | ||||
|         from time import time | ||||
|         return int(time() * 1000) | ||||
|  | ||||
|     def _save_focused_coords(self) -> tuple[int, int] | None: | ||||
|         """Возвращает (row, col) кнопки с фокусом или None""" | ||||
|         current = self.focusWidget() | ||||
|         if not current: | ||||
|             return None | ||||
|         idx = self.keyboard_layout.indexOf(current) | ||||
|         if idx == -1: | ||||
|             return None | ||||
|         position = cast(tuple[int, int, int, int], self.keyboard_layout.getItemPosition(idx)) | ||||
|         return position[:2]  # row, col | ||||
|  | ||||
|     def on_button_click(self, key): | ||||
|         if key in ['TAB', 'CAPS', '⬆']: | ||||
|             if key == 'TAB': | ||||
|                 self.on_tab_click() | ||||
|             elif key == 'CAPS': | ||||
|                 self.on_caps_click() | ||||
|             elif key == '⬆': | ||||
|                 self.on_shift_click(not self.shift_pressed) | ||||
|             self.highlight_cursor_position() | ||||
|         elif self.current_input_widget is not None: | ||||
|             # Сохраняем текущую кнопку с фокусом | ||||
|             focused_button = self.focusWidget() | ||||
|             key_to_restore = None | ||||
|             if isinstance(focused_button, QPushButton) and focused_button in self.buttons.values(): | ||||
|                 key_to_restore = next((k for k, btn in self.buttons.items() if btn == focused_button), None) | ||||
|  | ||||
|             key = "&" if key == "&&" else key | ||||
|             cursor_pos = self.current_input_widget.cursorPosition() | ||||
|             text = self.current_input_widget.text() | ||||
|             new_text = text[:cursor_pos] + key + text[cursor_pos:] | ||||
|             self.current_input_widget.setText(new_text) | ||||
|             self.current_input_widget.setCursorPosition(cursor_pos + len(key)) | ||||
|             self.keyPressed.emit(key) | ||||
|             self.highlight_cursor_position() | ||||
|  | ||||
|             # Если был нажат SHIFT, но не CapsLock, отключаем его после ввода символа | ||||
|             if self.shift_pressed and not self.caps_lock: | ||||
|                 self.shift_pressed = False | ||||
|                 self.update_keyboard() | ||||
|                 if key_to_restore and key_to_restore in self.buttons: | ||||
|                     self.buttons[key_to_restore].setFocus() | ||||
|  | ||||
|     def on_tab_click(self): | ||||
|         if self.current_input_widget is not None: | ||||
|             self.current_input_widget.insert('\t') | ||||
|             self.keyPressed.emit('Tab') | ||||
|             self.current_input_widget.setFocus() | ||||
|             self.highlight_cursor_position() | ||||
|  | ||||
|     def on_caps_click(self): | ||||
|         """Включаем/выключаем CapsLock""" | ||||
|         self.caps_lock = not self.caps_lock | ||||
|         self.shift_pressed = self.caps_lock | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     # ---------- таймерное событие ---------- | ||||
|     def timerEvent(self, event): | ||||
|         if event.timerId() == self.backspace_timer: | ||||
|             self.on_backspace_click()  # стираем ещё один символ | ||||
|             # первое срабатывание прошло – ускоряем | ||||
|             if self.backspace_timer: | ||||
|                 self.killTimer(self.backspace_timer) | ||||
|                 self.backspace_timer = self.startTimer(self.backspace_repeat_delay) | ||||
|     def on_backspace_click(self): | ||||
|         """Обработка одного нажатия Backspace""" | ||||
|         if self.current_input_widget is not None: | ||||
|             cursor_pos = self.current_input_widget.cursorPosition() | ||||
|             text = self.current_input_widget.text() | ||||
|  | ||||
|             if cursor_pos > 0: | ||||
|                 new_text = text[:cursor_pos - 1] + text[cursor_pos:] | ||||
|                 self.current_input_widget.setText(new_text) | ||||
|                 self.current_input_widget.setCursorPosition(cursor_pos - 1) | ||||
|                 self.keyPressed.emit('Backspace') | ||||
|                 self.highlight_cursor_position() | ||||
|  | ||||
|     def on_backspace_pressed(self): | ||||
|         """Обработка зажатого Backspace""" | ||||
|         self.backspace_pressed = True | ||||
|         self.start_backspace_repeat() | ||||
|  | ||||
|     def start_backspace_repeat(self): | ||||
|         """Запуск автоповтора нажатия Backspace""" | ||||
|         self.on_backspace_click()  # Первое нажатие | ||||
|         self.backspace_timer = self.startTimer(self.backspace_initial_delay) | ||||
|  | ||||
|     def stop_backspace_repeat(self): | ||||
|         """Остановка автоповтора нажатия Backspace""" | ||||
|         if self.backspace_timer: | ||||
|             self.killTimer(self.backspace_timer) | ||||
|             self.backspace_timer = None | ||||
|         self.backspace_pressed = False | ||||
|  | ||||
|     def on_enter_click(self): | ||||
|         """Обработка действия кнопки Enter""" | ||||
|         # TODO: тут подумать, как обрабатывать нажатие. | ||||
|         #  Пока болванка перехода на новую строку, в QlineEdit работает как нажатие пробела | ||||
|         if self.current_input_widget is not None: | ||||
|             self.current_input_widget.insert('\n') | ||||
|             self.keyPressed.emit('Enter') | ||||
|  | ||||
|     def on_clear_click(self): | ||||
|         """Чистим строку от введённого текста""" | ||||
|         if self.current_input_widget is not None: | ||||
|             self.current_input_widget.clear() | ||||
|             self.keyPressed.emit('Clear') | ||||
|             self.highlight_cursor_position() | ||||
|  | ||||
|     def on_lang_click(self): | ||||
|         """Переключение раскладки""" | ||||
|         if not self.available_layouts: | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             current_index = self.available_layouts.index(self.current_layout) | ||||
|             next_index = (current_index + 1) % len(self.available_layouts) | ||||
|             self.current_layout = self.available_layouts[next_index] | ||||
|         except ValueError: | ||||
|             # Если текущей раскладки нет в available_layouts | ||||
|             self.current_layout = self.available_layouts[0] if self.available_layouts else 'en' | ||||
|  | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     def on_shift_click(self, checked): | ||||
|         self.shift_pressed = checked | ||||
|         if not checked and self.caps_lock: | ||||
|             self.caps_lock = False | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     def show_for_widget(self, widget): | ||||
|         self.current_input_widget = widget | ||||
|         if widget: | ||||
|             widget.setFocus() | ||||
|             self.highlight_cursor_position() | ||||
|  | ||||
|         # Позиционирование клавиатуры внизу родительского виджета | ||||
|         if self._parent and isinstance(self._parent, QWidget): | ||||
|             keyboard_height = 220 | ||||
|             self.setFixedWidth(self._parent.width()) | ||||
|             self.setFixedHeight(keyboard_height) | ||||
|             self.move(0, self._parent.height() - keyboard_height) | ||||
|  | ||||
|         self.show() | ||||
|         self.raise_() | ||||
|  | ||||
|         # Установить фокус на первую кнопку, если нет фокуса на виджете ввода | ||||
|         if not widget: | ||||
|             first_button: QPushButton | None = next((cast(QPushButton, btn) for btn in self.buttons.values()), None) | ||||
|             if first_button: | ||||
|                 first_button.setFocus() | ||||
|  | ||||
|     def activateFocusedKey(self): | ||||
|         """Активирует текущую выделенную кнопку на клавиатуре""" | ||||
|         focused = self.focusWidget() | ||||
|         if isinstance(focused, QPushButton): | ||||
|             focused.animateClick() | ||||
|  | ||||
|     def focusNextKey(self, direction: str): | ||||
|         """Перемещает фокус на следующую кнопку в указанном направлении с обертыванием""" | ||||
|         current = self.focusWidget() | ||||
|         if not current: | ||||
|             first_button = self.findFirstFocusableButton() | ||||
|             if first_button: | ||||
|                 first_button.setFocus() | ||||
|             return | ||||
|  | ||||
|         current_idx = self.keyboard_layout.indexOf(current) | ||||
|         if current_idx == -1: | ||||
|             return | ||||
|  | ||||
|         position = cast(tuple[int, int, int, int], self.keyboard_layout.getItemPosition(current_idx)) | ||||
|         current_row, current_col, row_span, col_span = position | ||||
|  | ||||
|         num_rows = self.keyboard_layout.rowCount() | ||||
|         num_cols = self.keyboard_layout.columnCount() | ||||
|  | ||||
|         found = False | ||||
|  | ||||
|         if direction == "right": | ||||
|             # Сначала ищем в той же строке вправо | ||||
|             search_row = current_row | ||||
|             search_col = current_col + col_span | ||||
|             while search_col < num_cols: | ||||
|                 item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     next_button = cast(QPushButton, item.widget()) | ||||
|                     next_button.setFocus() | ||||
|                     found = True | ||||
|                     break | ||||
|                 search_col += 1 | ||||
|  | ||||
|             if not found: | ||||
|                 # Переходим к следующей строке, начиная с col 0 | ||||
|                 search_row = (current_row + 1) % num_rows | ||||
|                 search_col = 0 | ||||
|                 # Ищем первую кнопку в этой строке | ||||
|                 while search_col < num_cols: | ||||
|                     item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                     if item and item.widget() and item.widget().isEnabled(): | ||||
|                         next_button = cast(QPushButton, item.widget()) | ||||
|                         next_button.setFocus() | ||||
|                         found = True | ||||
|                         break | ||||
|                     search_col += 1 | ||||
|  | ||||
|         elif direction == "left": | ||||
|             # Сначала ищем в той же строке влево | ||||
|             search_row = current_row | ||||
|             search_col = current_col - 1 | ||||
|             while search_col >= 0: | ||||
|                 item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     next_button = cast(QPushButton, item.widget()) | ||||
|                     next_button.setFocus() | ||||
|                     found = True | ||||
|                     break | ||||
|                 search_col -= 1 | ||||
|  | ||||
|             if not found: | ||||
|                 # Переходим к предыдущей строке, начиная с последнего столбца | ||||
|                 search_row = (current_row - 1) % num_rows | ||||
|                 search_col = num_cols - 1 | ||||
|                 # Ищем последнюю кнопку в этой строке | ||||
|                 while search_col >= 0: | ||||
|                     item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                     if item and item.widget() and item.widget().isEnabled(): | ||||
|                         next_button = cast(QPushButton, item.widget()) | ||||
|                         next_button.setFocus() | ||||
|                         found = True | ||||
|                         break | ||||
|                     search_col -= 1 | ||||
|  | ||||
|         elif direction == "down": | ||||
|             # Сначала ищем в том же столбце вниз | ||||
|             search_col = current_col | ||||
|             search_row = current_row + row_span | ||||
|             while search_row < num_rows: | ||||
|                 item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     next_button = cast(QPushButton, item.widget()) | ||||
|                     next_button.setFocus() | ||||
|                     found = True | ||||
|                     break | ||||
|                 search_row += 1 | ||||
|  | ||||
|             if not found: | ||||
|                 # Переходим к следующему столбцу, начиная с row 0 | ||||
|                 search_col = (current_col + col_span) % num_cols | ||||
|                 search_row = 0 | ||||
|                 # Ищем первую кнопку в этом столбце | ||||
|                 while search_row < num_rows: | ||||
|                     item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                     if item and item.widget() and item.widget().isEnabled(): | ||||
|                         next_button = cast(QPushButton, item.widget()) | ||||
|                         next_button.setFocus() | ||||
|                         found = True | ||||
|                         break | ||||
|                     search_row += 1 | ||||
|  | ||||
|         elif direction == "up": | ||||
|             # Сначала ищем в том же столбце вверх | ||||
|             search_col = current_col | ||||
|             search_row = current_row - 1 | ||||
|             while search_row >= 0: | ||||
|                 item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     next_button = cast(QPushButton, item.widget()) | ||||
|                     next_button.setFocus() | ||||
|                     found = True | ||||
|                     break | ||||
|                 search_row -= 1 | ||||
|  | ||||
|             if not found: | ||||
|                 # Переходим к предыдущему столбцу, начиная с последней строки | ||||
|                 search_col = (current_col - 1) % num_cols | ||||
|                 search_row = num_rows - 1 | ||||
|                 # Ищем последнюю кнопку в этом столбце | ||||
|                 while search_row >= 0: | ||||
|                     item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                     if item and item.widget() and item.widget().isEnabled(): | ||||
|                         next_button = cast(QPushButton, item.widget()) | ||||
|                         next_button.setFocus() | ||||
|                         found = True | ||||
|                         break | ||||
|                     search_row -= 1 | ||||
|  | ||||
|     def findFirstFocusableButton(self) -> QPushButton | None: | ||||
|         """Находит первую фокусируемую кнопку на клавиатуре""" | ||||
|         for row in range(self.keyboard_layout.rowCount()): | ||||
|             for col in range(self.keyboard_layout.columnCount()): | ||||
|                 item = self.keyboard_layout.itemAtPosition(row, col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     return cast(QPushButton, item.widget()) | ||||
|         return None | ||||
| @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" | ||||
|  | ||||
| [project] | ||||
| name = "portprotonqt" | ||||
| version = "0.1.6" | ||||
| version = "0.1.8" | ||||
| description = "A project to rewrite PortProton (PortWINE) using PySide" | ||||
| readme = "README.md" | ||||
| license = { text = "GPL-3.0" } | ||||
| @@ -27,19 +27,19 @@ classifiers = [ | ||||
| requires-python = ">=3.10" | ||||
| dependencies = [ | ||||
|     "babel>=2.17.0", | ||||
|     "beautifulsoup4>=4.13.5", | ||||
|     "beautifulsoup4>=4.14.2", | ||||
|     "evdev>=1.9.2", | ||||
|     "icoextract>=0.2.0", | ||||
|     "numpy>=2.2.4", | ||||
|     "orjson>=3.11.2", | ||||
|     "orjson>=3.11.3", | ||||
|     "pillow>=11.3.0", | ||||
|     "psutil>=7.0.0", | ||||
|     "pyside6>=6.9.1", | ||||
|     "psutil>=7.1.0", | ||||
|     "pyside6==6.9.1", | ||||
|     "pyudev>=0.24.3", | ||||
|     "requests>=2.32.5", | ||||
|     "tqdm>=4.67.1", | ||||
|     "vdf>=3.4", | ||||
|     "websocket-client>=1.8.0", | ||||
|     "websocket-client>=1.9.0", | ||||
| ] | ||||
|  | ||||
| [project.scripts] | ||||
| @@ -105,5 +105,5 @@ ignore = [ | ||||
| dev = [ | ||||
|     "pre-commit>=4.3.0", | ||||
|     "pyaspeller>=2.0.2", | ||||
|     "pyright>=1.1.404", | ||||
|     "pyright>=1.1.406", | ||||
| ] | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| { | ||||
|   "$schema": "https://docs.renovatebot.com/renovate-schema.json", | ||||
|   "extends": ["config:best-practices"], | ||||
|   "extends": [ | ||||
|     "config:best-practices" | ||||
|   ], | ||||
|   "rebaseWhen": "never", | ||||
|   "lockFileMaintenance": { | ||||
|     "enabled": true | ||||
| @@ -9,6 +11,23 @@ | ||||
|     "enabled": true | ||||
|   }, | ||||
|   "packageRules": [ | ||||
|     { | ||||
|       "description": "Update renovate only weekly", | ||||
|       "matchDepNames": ["ghcr.io/renovatebot/renovate"], | ||||
|       "extends": ["schedule:weekly"] | ||||
|     }, | ||||
|     { | ||||
|       "description": "Automerge renovate updates", | ||||
|       "matchPackageNames": [ | ||||
|         "ghcr.io/renovatebot/renovate" | ||||
|       ], | ||||
|       "matchUpdateTypes": [ | ||||
|         "minor", | ||||
|         "patch", | ||||
|         "digest" | ||||
|       ], | ||||
|       "automerge": true | ||||
|     }, | ||||
|     { | ||||
|       "matchUpdateTypes": ["minor", "patch"], | ||||
|       "automerge": true | ||||
| @@ -33,7 +52,7 @@ | ||||
|       "groupName": "Python dependencies" | ||||
|     }, | ||||
|     { | ||||
|       "matchPackageNames": ["numpy", "setuptools", "python"], | ||||
|       "matchPackageNames": ["numpy", "setuptools", "python", "pyside6"], | ||||
|       "enabled": false, | ||||
|       "description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)" | ||||
|     }, | ||||
|   | ||||