Compare commits
	
		
			62 Commits
		
	
	
		
			ba9d8b76d8
			...
			v0.1.7
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 96f884904c | |||
| b856a2afae | |||
| 55ef0030e6 | |||
| 8aaeaa4824 | |||
| f55372b480 | |||
| 4d6f32f053 | |||
| a2f5141b20 | |||
| e3cb2857e7 | |||
| efe8a35832 | |||
| 61fae97dad | |||
| 5442100f64 | |||
| 2d6ef84798 | |||
|  | f4aee15b5d | ||
| 87a65108a5 | |||
| bb617708ac | |||
| 1cf332cd87 | |||
| 577ad4d3a3 | |||
| ef3f2d6e96 | |||
| 657d7728a6 | |||
| 9452bfda2e | |||
| 7eb2db0d68 | |||
| 6ef7a03366 | |||
| e5af354b56 | |||
| e6e5f6c8ea | |||
| 84306bb31b | |||
| 60af4d1482 | |||
| 692e11b21d | |||
| b1a804811e | |||
| 9a30cfaea7 | |||
| 5dd2f71f5e | |||
| dba172361b | |||
| a9c70b8818 | |||
| 135ace732f | |||
| 8b727f64e1 | |||
| a8eb591da5 | |||
| fe4ca1ee87 | |||
| ffe3e9d3d6 | |||
| 49d39b5d61 | |||
|  | 03566da704 | ||
|  | 7f996ab6a0 | ||
|  | 9e17978155 | ||
| 5d0185b1b4 | |||
| 5c134be04e | |||
| 8c66695192 | |||
| 7a141d8e46 | |||
| abb2377fb7 | |||
| 75f4f346de | |||
| 87a9f85272 | |||
| 240f685ece | |||
| af4e3e95bb | |||
| 017d9a42cf | |||
| 18b7c4054b | |||
| dd7f71b70a | |||
| 8fd44c575b | |||
| 65b43c1572 | |||
| f35276abfe | |||
| 6fea9a9a7e | |||
| 5189474631 | |||
|  | 416cc6a268 | ||
|  | 3b44ed5252 | ||
| c8c45dda06 | |||
| 3f9f794e6f | 
| @@ -94,7 +94,7 @@ jobs: | ||||
|     name: Build Arch Package | ||||
|     runs-on: ubuntu-22.04 | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64 | ||||
|       image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166 | ||||
|       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.7 | ||||
|   PKGDEST: "/tmp/portprotonqt" | ||||
|   PACKAGE: "portprotonqt" | ||||
|   GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} | ||||
|   | ||||
| @@ -138,7 +138,7 @@ jobs: | ||||
|     needs: changes | ||||
|     if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64 | ||||
|       image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166 | ||||
|       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:dd5721b9a686a40d81687643e4b71b82a0ca31fb653fd727538af69104fd388d | ||||
|     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 | ||||
|  | ||||
|   | ||||
							
								
								
									
										17
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						| @@ -3,20 +3,34 @@ | ||||
| Все заметные изменения в этом проекте фиксируются в этом файле. | ||||
| Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). | ||||
|  | ||||
| ## [Unreleased] | ||||
| ## [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 +53,7 @@ | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -54,8 +54,6 @@ PortProtonQt использует код и зависимости от след | ||||
| - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE). | ||||
| - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE). | ||||
| - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md). | ||||
| - [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/) | ||||
|  | ||||
| Полный текст лицензий см. в файле [LICENSE](LICENSE). | ||||
|  | ||||
| > [!WARNING] | ||||
|   | ||||
| @@ -36,7 +36,7 @@ AppDir: | ||||
|     id: ru.linux_gaming.PortProtonQt | ||||
|     name: PortProtonQt | ||||
|     icon: ru.linux_gaming.PortProtonQt | ||||
|     version: 0.1.6 | ||||
|     version: 0.1.7 | ||||
|     exec: usr/bin/python3 | ||||
|     exec_args: "-m portprotonqt.app $@" | ||||
|   apt: | ||||
| @@ -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.7 | ||||
| pkgrel=1 | ||||
| pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" | ||||
| arch=('any') | ||||
| url="https://git.linux-gaming.ru/Boria138/PortProtonQt" | ||||
| license=('GPL-3.0') | ||||
| depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client') | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') | ||||
| makedepends=('python-'{'build','installer','setuptools','wheel'}) | ||||
| source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver") | ||||
| sha256sums=('SKIP') | ||||
|   | ||||
| @@ -6,7 +6,7 @@ arch=('any') | ||||
| url="https://git.linux-gaming.ru/Boria138/PortProtonQt" | ||||
| license=('GPL-3.0') | ||||
| depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client') | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') | ||||
| makedepends=('python-'{'build','installer','setuptools','wheel'}) | ||||
| source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git") | ||||
| sha256sums=('SKIP') | ||||
|   | ||||
| @@ -46,6 +46,11 @@ Requires:       python3-pillow | ||||
| Requires:       perl-Image-ExifTool | ||||
| Requires:       xdg-utils | ||||
| Requires:       python3-beautifulsoup4 | ||||
| Requires:       cabextract | ||||
| Requires:       gzip | ||||
| Requires:       unzip | ||||
| Requires:       curl | ||||
| Requires:       unrar | ||||
|  | ||||
| %description -n python3-%{pypi_name}-git | ||||
| This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup. | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| %global pypi_name portprotonqt | ||||
| %global pypi_version 0.1.6 | ||||
| %global pypi_version 0.1.7 | ||||
| %global oname PortProtonQt | ||||
| %global _python_no_extras_requires 1 | ||||
|  | ||||
| @@ -43,6 +43,11 @@ Requires:       python3-pillow | ||||
| Requires:       perl-Image-ExifTool | ||||
| Requires:       xdg-utils | ||||
| Requires:       python3-beautifulsoup4 | ||||
| Requires:       cabextract | ||||
| Requires:       gzip | ||||
| Requires:       unzip | ||||
| Requires:       curl | ||||
| Requires:       unrar | ||||
|  | ||||
| %description -n python3-%{pypi_name} | ||||
| This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup. | ||||
|   | ||||
| @@ -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 240 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 240 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 240 of 240 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -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 из 240 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 240 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 240 из 240 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,41 @@ | ||||
| import sys | ||||
| import os | ||||
| import subprocess | ||||
| from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo | ||||
| from PySide6.QtWidgets import QApplication | ||||
| from PySide6.QtGui import QIcon | ||||
| from portprotonqt.main_window import MainWindow | ||||
| from portprotonqt.config_utils import save_fullscreen_config | ||||
| from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location | ||||
| from portprotonqt.logger import get_logger, setup_logger | ||||
| from portprotonqt.cli import parse_args | ||||
|  | ||||
| __app_id__ = "ru.linux_gaming.PortProtonQt" | ||||
| __app_name__ = "PortProtonQt" | ||||
| __app_version__ = "0.1.6" | ||||
| __app_version__ = "0.1.7" | ||||
|  | ||||
| def get_version(): | ||||
|     try: | ||||
|         commit = subprocess.check_output( | ||||
|             ['git', 'rev-parse', '--short', 'HEAD'], | ||||
|             stderr=subprocess.DEVNULL | ||||
|         ).decode('utf-8').strip() | ||||
|         return f"{__app_version__} ({commit})" | ||||
|     except (subprocess.CalledProcessError, FileNotFoundError, OSError): | ||||
|         return __app_version__ | ||||
|  | ||||
| def main(): | ||||
|     os.environ['PW_CLI'] = '1' | ||||
|     os.environ['PROCESS_LOG'] = '1' | ||||
|     os.environ['START_FROM_STEAM'] = '1' | ||||
|  | ||||
|     portproton_path = get_portproton_location() | ||||
|  | ||||
|     if portproton_path is None: | ||||
|         return | ||||
|  | ||||
|     script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh') | ||||
|     subprocess.run([script_path, 'cli', '--initial']) | ||||
|  | ||||
|     app = QApplication(sys.argv) | ||||
|     app.setWindowIcon(QIcon.fromTheme(__app_id__)) | ||||
|     app.setDesktopFileName(__app_id__) | ||||
| @@ -34,7 +58,8 @@ def main(): | ||||
|     else: | ||||
|         logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language") | ||||
|  | ||||
|     window = MainWindow(app_name=__app_name__) | ||||
|     version = get_version() | ||||
|     window = MainWindow(app_name=__app_name__, version=version) | ||||
|  | ||||
|     if args.fullscreen: | ||||
|         logger.info("Launching in fullscreen mode due to --fullscreen flag") | ||||
|   | ||||
| 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: | ||||
| @@ -507,8 +511,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 +601,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 +818,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: | ||||
| @@ -967,3 +1035,465 @@ Icon={icon_path} | ||||
| """ | ||||
|  | ||||
|         return desktop_entry, desktop_path | ||||
|  | ||||
| class WinetricksDialog(QDialog): | ||||
|     """Dialog for managing Winetricks components in a prefix.""" | ||||
|  | ||||
|     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() | ||||
|  | ||||
|     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.""" | ||||
|         main_layout = QVBoxLayout(self) | ||||
|         main_layout.setContentsMargins(10, 10, 10, 10) | ||||
|         main_layout.setSpacing(10) | ||||
|  | ||||
|         # Log output | ||||
|         self.log_output = QTextEdit() | ||||
|         self.log_output.setReadOnly(True) | ||||
|         self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE) | ||||
|         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 | ||||
|         } | ||||
|  | ||||
|         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) | ||||
|         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) | ||||
|   | ||||
| @@ -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)) | ||||
|   | ||||
| @@ -35,6 +35,7 @@ class MainWindowProtocol(Protocol): | ||||
|     _last_card_width: int | ||||
|     current_hovered_card: GameCard | None | ||||
|     current_focused_card: GameCard | None | ||||
|     gamesListWidget: QWidget | None | ||||
|  | ||||
| class GameLibraryManager: | ||||
|     def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None): | ||||
| @@ -362,8 +363,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() | ||||
| @@ -451,3 +453,11 @@ class GameLibraryManager: | ||||
|     def filter_games_delayed(self): | ||||
|         """Filters games based on search text and updates the grid.""" | ||||
|         self.update_game_grid(is_filter=True) | ||||
|  | ||||
|     def calculate_columns(self, card_width: int) -> int: | ||||
|         """Calculate the number of columns based on card width and assumed container width.""" | ||||
|         # Assuming a typical container width; adjust as needed | ||||
|         available_width = 1200  # Example width, can be dynamic if widget access is added | ||||
|         spacing = 15  # Assumed spacing between cards | ||||
|         columns = max(1, (available_width - spacing) // (card_width + spacing)) | ||||
|         return min(columns, 8)  # Cap at reasonable max | ||||
|   | ||||
| @@ -5,7 +5,7 @@ from typing import Protocol, cast | ||||
| from evdev import InputDevice, InputEvent, ecodes, list_devices, ff | ||||
| from enum import Enum | ||||
| from pyudev import Context, Monitor, MonitorObserver, Device | ||||
| from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget | ||||
| from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView | ||||
| from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer | ||||
| from PySide6.QtGui import QKeyEvent, QMouseEvent | ||||
| from portprotonqt.logger import get_logger | ||||
| @@ -13,7 +13,8 @@ from portprotonqt.image_utils import FullscreenDialog | ||||
| from portprotonqt.custom_widgets import NavLabel, AutoSizeButton | ||||
| from portprotonqt.game_card import GameCard | ||||
| from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config | ||||
| from portprotonqt.dialogs import AddGameDialog | ||||
| from portprotonqt.dialogs import AddGameDialog, WinetricksDialog | ||||
| from portprotonqt.virtual_keyboard import VirtualKeyboard | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| @@ -37,6 +38,7 @@ class MainWindowProtocol(Protocol): | ||||
|     stackedWidget: QStackedWidget | ||||
|     tabButtons: dict[int, QWidget] | ||||
|     gamesListWidget: QWidget | ||||
|     autoInstallContainer: QWidget | None | ||||
|     currentDetailPage: QWidget | None | ||||
|     current_exec_line: str | None | ||||
|     current_add_game_dialog: AddGameDialog | None | ||||
| @@ -71,7 +73,7 @@ class InputManager(QObject): | ||||
|     for seamless UI interaction. | ||||
|     """ | ||||
|     # Signals for gamepad events | ||||
|     button_pressed = Signal(int)  # Signal for button presses | ||||
|     button_event = Signal(int, int)  # Signal for button events: (code, value) where value=1 (press), 0 (release) | ||||
|     dpad_moved = Signal(int, int, float)  # Signal for D-pad movements | ||||
|     toggle_fullscreen = Signal(bool)  # Signal for toggling fullscreen mode (True for fullscreen, False for normal) | ||||
|  | ||||
| @@ -90,6 +92,7 @@ class InputManager(QObject): | ||||
|         self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None) | ||||
|         self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None) | ||||
|         self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None) | ||||
|         self._parent.autoInstallContainer = getattr(self._parent, 'autoInstallContainer', None) | ||||
|         self.axis_deadzone = axis_deadzone | ||||
|         self.initial_axis_move_delay = initial_axis_move_delay | ||||
|         self.repeat_axis_move_delay = repeat_axis_move_delay | ||||
| @@ -130,7 +133,7 @@ class InputManager(QObject): | ||||
|         self.current_dpad_value = 0    # Tracks the current D-pad direction value (e.g., -1, 1) | ||||
|  | ||||
|         # Connect signals to slots | ||||
|         self.button_pressed.connect(self.handle_button_slot) | ||||
|         self.button_event.connect(self.handle_button_slot) | ||||
|         self.dpad_moved.connect(self.handle_dpad_slot) | ||||
|         self.toggle_fullscreen.connect(self.handle_fullscreen_slot) | ||||
|  | ||||
| @@ -142,6 +145,132 @@ class InputManager(QObject): | ||||
|         # Initialize evdev + hotplug | ||||
|         self.init_gamepad() | ||||
|  | ||||
|     def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None: | ||||
|         """Common navigation logic for game cards in a container.""" | ||||
|         if container is None: | ||||
|             return | ||||
|         focused = QApplication.focusWidget() | ||||
|         game_cards = container.findChildren(GameCard) | ||||
|         if not game_cards: | ||||
|             return | ||||
|  | ||||
|         scroll_area = container.parentWidget() | ||||
|         while scroll_area and not isinstance(scroll_area, QScrollArea): | ||||
|             scroll_area = scroll_area.parentWidget() | ||||
|  | ||||
|         # If no focused widget or not a GameCard, focus the first card | ||||
|         if not isinstance(focused, GameCard) or focused not in game_cards: | ||||
|             game_cards[0].setFocus() | ||||
|             if scroll_area: | ||||
|                 scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) | ||||
|             return | ||||
|  | ||||
|         cards = container.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
|         if not cards: | ||||
|             return | ||||
|         # Group cards by rows with tolerance for y-position | ||||
|         rows = {} | ||||
|         y_tolerance = 10  # Allow slight variations in y-position | ||||
|         for card in cards: | ||||
|             y = card.pos().y() | ||||
|             matched = False | ||||
|             for row_y in rows: | ||||
|                 if abs(y - row_y) <= y_tolerance: | ||||
|                     rows[row_y].append(card) | ||||
|                     matched = True | ||||
|                     break | ||||
|             if not matched: | ||||
|                 rows[y] = [card] | ||||
|         sorted_rows = sorted(rows.items(), key=lambda x: x[0]) | ||||
|         if not sorted_rows: | ||||
|             return | ||||
|         current_row_idx = None | ||||
|         current_col_idx = None | ||||
|         for row_idx, (_y, row_cards) in enumerate(sorted_rows): | ||||
|             for idx, card in enumerate(row_cards): | ||||
|                 if card == focused: | ||||
|                     current_row_idx = row_idx | ||||
|                     current_col_idx = idx | ||||
|                     break | ||||
|             if current_row_idx is not None: | ||||
|                 break | ||||
|  | ||||
|         # Fallback: if focused card not found, select closest row by y-position | ||||
|         if current_row_idx is None: | ||||
|             if not sorted_rows:  # Additional safety check | ||||
|                 return | ||||
|             focused_y = focused.pos().y() | ||||
|             current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y)) | ||||
|             if current_row_idx >= len(sorted_rows):  # Safety check | ||||
|                 return | ||||
|             current_row = sorted_rows[current_row_idx][1] | ||||
|             focused_x = focused.pos().x() + focused.width() / 2 | ||||
|             current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0)  # type: ignore | ||||
|  | ||||
|         # Add null checks before using current_row_idx and current_col_idx | ||||
|         if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): | ||||
|             return | ||||
|  | ||||
|         current_row = sorted_rows[current_row_idx][1] | ||||
|         if code == ecodes.ABS_HAT0X and value != 0: | ||||
|             if value < 0:  # Left | ||||
|                 if current_col_idx > 0: | ||||
|                     next_card = current_row[current_col_idx - 1] | ||||
|                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 else: | ||||
|                     if current_row_idx > 0: | ||||
|                         prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                         next_card = prev_row[-1] if prev_row else None | ||||
|                         if next_card: | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|             elif value > 0:  # Right | ||||
|                 if current_col_idx < len(current_row) - 1: | ||||
|                     next_card = current_row[current_col_idx + 1] | ||||
|                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 else: | ||||
|                     if current_row_idx < len(sorted_rows) - 1: | ||||
|                         next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                         next_card = next_row[0] if next_row else None | ||||
|                         if next_card: | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|         elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|             if value > 0:  # Down | ||||
|                 if current_row_idx < len(sorted_rows) - 1: | ||||
|                     next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                     current_x = focused.pos().x() + focused.width() / 2 | ||||
|                     next_card = min( | ||||
|                         next_row, | ||||
|                         key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                         default=None | ||||
|                     ) | ||||
|                     if next_card: | ||||
|                         next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         if scroll_area: | ||||
|                             scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|             elif value < 0:  # Up | ||||
|                 if current_row_idx > 0: | ||||
|                     prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                     current_x = focused.pos().x() + focused.width() / 2 | ||||
|                     next_card = min( | ||||
|                         prev_row, | ||||
|                         key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                         default=None | ||||
|                     ) | ||||
|                     if next_card: | ||||
|                         next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         if scroll_area: | ||||
|                             scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 elif current_row_idx == 0: | ||||
|                     self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|     def detect_gamepad_type(self, device: InputDevice) -> GamepadType: | ||||
|         """ | ||||
|         Определяет тип геймпада по capabilities | ||||
| @@ -201,7 +330,9 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error restoring gamepad handlers: {e}") | ||||
|  | ||||
|     def handle_file_explorer_button(self, button_code): | ||||
|     def handle_file_explorer_button(self, button_code, value): | ||||
|         if value == 0:  # Ignore releases | ||||
|                     return | ||||
|         try: | ||||
|             popup = QApplication.activePopupWidget() | ||||
|             if isinstance(popup, QMenu): | ||||
| @@ -441,8 +572,33 @@ class InputManager(QObject): | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error stopping rumble: {e}", exc_info=True) | ||||
|  | ||||
|     @Slot(int) | ||||
|     def handle_button_slot(self, button_code: int) -> None: | ||||
|     @Slot(int, int) | ||||
|     def handle_button_slot(self, button_code: int, value: int) -> None: | ||||
|         active_window = QApplication.activeWindow() | ||||
|  | ||||
|         # Обработка виртуальной клавиатуры в AddGameDialog (handle both press and release) | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             focused = QApplication.focusWidget() | ||||
|             if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit): | ||||
|                 # Показываем клавиатуру при нажатии A на поле ввода (only on press) | ||||
|                 active_window.show_keyboard_for_widget(focused) | ||||
|                 return | ||||
|  | ||||
|             # Если клавиатура видима, обрабатываем её кнопки (including release) | ||||
|             if hasattr(active_window, 'keyboard') and active_window.keyboard.isVisible(): | ||||
|                 self.handle_virtual_keyboard(button_code, value) | ||||
|                 return | ||||
|  | ||||
|         # Main window keyboard handling (including release) | ||||
|         keyboard = getattr(self._parent, 'keyboard', None) | ||||
|         if keyboard and keyboard.isVisible(): | ||||
|             self.handle_virtual_keyboard(button_code, value) | ||||
|             return | ||||
|  | ||||
|         # Ignore releases for all other (non-keyboard) button handling | ||||
|         if value == 0: | ||||
|             return | ||||
|  | ||||
|         if not self._gamepad_handling_enabled: | ||||
|             return | ||||
|         try: | ||||
| @@ -455,6 +611,31 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             current_tab_index = self._parent.stackedWidget.currentIndex() | ||||
|  | ||||
|             if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit): | ||||
|                 search_edit = None | ||||
|                 if current_tab_index == 0: | ||||
|                     search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 elif current_tab_index == 1: | ||||
|                     search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                 if focused == search_edit: | ||||
|                     keyboard = getattr(self._parent, 'keyboard', None) | ||||
|                     if keyboard: | ||||
|                         keyboard.show_for_widget(focused) | ||||
|                         return | ||||
|  | ||||
|             # Handle Y button to focus search | ||||
|             if button_code in BUTTONS['prev_dir']:  # Y button | ||||
|                 search_edit = None | ||||
|                 if current_tab_index == 0: | ||||
|                     search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 elif current_tab_index == 1: | ||||
|                     search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                 if search_edit: | ||||
|                     search_edit.setFocus() | ||||
|                     return | ||||
|  | ||||
|             # Handle Guide button to open system overlay | ||||
|             if button_code in BUTTONS['guide']: | ||||
|                 if not popup and not isinstance(active, QDialog): | ||||
| @@ -551,6 +732,39 @@ class InputManager(QObject): | ||||
|                     self._parent.toggleGame(self._parent.current_exec_line, None) | ||||
|                     return | ||||
|  | ||||
|  | ||||
|             if isinstance(active, WinetricksDialog): | ||||
|                 if button_code in BUTTONS['confirm']:  # A button - toggle checkbox | ||||
|                     current_table = active.tab_widget.currentWidget() | ||||
|                     if isinstance(current_table, QTableWidget): | ||||
|                         current_row = current_table.currentRow() | ||||
|                         if current_row >= 0: | ||||
|                             checkbox = current_table.item(current_row, 0) | ||||
|                             if checkbox: | ||||
|                                 checkbox.setCheckState( | ||||
|                                     Qt.CheckState.Unchecked if checkbox.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked | ||||
|                                 ) | ||||
|                     return | ||||
|                 elif button_code in BUTTONS['add_game']:  # X button - install | ||||
|                     active.install_selected(force=False) | ||||
|                     return | ||||
|                 elif button_code in BUTTONS['prev_dir']:  # Y button - force install | ||||
|                     active.install_selected(force=True) | ||||
|                     return | ||||
|                 elif button_code in BUTTONS['back']:  # B button - close dialog | ||||
|                     active.reject() | ||||
|                     return | ||||
|                 elif button_code in BUTTONS['prev_tab']:  # LB - previous tab | ||||
|                     current_idx = active.tab_widget.currentIndex() | ||||
|                     new_idx = (current_idx - 1) % active.tab_widget.count() | ||||
|                     active.tab_widget.setCurrentIndex(new_idx) | ||||
|                     return | ||||
|                 elif button_code in BUTTONS['next_tab']:  # RB - next tab | ||||
|                     current_idx = active.tab_widget.currentIndex() | ||||
|                     new_idx = (current_idx + 1) % active.tab_widget.count() | ||||
|                     active.tab_widget.setCurrentIndex(new_idx) | ||||
|                     return | ||||
|  | ||||
|             # Standard navigation | ||||
|             if button_code in BUTTONS['confirm']: | ||||
|                 self._parent.activateFocusedWidget() | ||||
| @@ -595,8 +809,83 @@ class InputManager(QObject): | ||||
|  | ||||
|     @Slot(int, int, float) | ||||
|     def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None: | ||||
|         keyboard = None | ||||
|         active_window = QApplication.activeWindow() | ||||
|  | ||||
|         # Проверяем клавиатуру в активном окне (AddGameDialog или главном окне) | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             keyboard = getattr(active_window, 'keyboard', None) | ||||
|         else: | ||||
|             keyboard = getattr(self._parent, 'keyboard', None) | ||||
|  | ||||
|         # Handle release early | ||||
|         if value == 0: | ||||
|             self.current_dpad_code = None | ||||
|             self.current_dpad_value = 0 | ||||
|             self.axis_moving = False | ||||
|             self.current_axis_delay = self.initial_axis_move_delay | ||||
|             self.dpad_timer.stop() | ||||
|             return | ||||
|  | ||||
|         # Update D-pad state for continuous movement | ||||
|         self.current_dpad_code = code | ||||
|         self.current_dpad_value = value | ||||
|         if not self.axis_moving: | ||||
|             self.axis_moving = True | ||||
|             self.last_move_time = current_time | ||||
|             self.current_axis_delay = self.initial_axis_move_delay | ||||
|             self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000)) | ||||
|  | ||||
|         if keyboard and keyboard.isVisible(): | ||||
|             # Обработка горизонтального перемещения (LEFT/RIGHT) | ||||
|             if code in (ecodes.ABS_HAT0X, ecodes.ABS_X): | ||||
|                 normalized_value = 0 | ||||
|                 if code == ecodes.ABS_X:  # Левый стик | ||||
|                     # Применяем мертвую зону | ||||
|                     if abs(value) < self.dead_zone: | ||||
|                         self.current_dpad_code = None | ||||
|                         self.current_dpad_value = 0 | ||||
|                         self.axis_moving = False | ||||
|                         self.dpad_timer.stop() | ||||
|                         return | ||||
|                     normalized_value = 1 if value > self.dead_zone else -1 | ||||
|                 else:  # D-pad | ||||
|                     normalized_value = value  # D-pad уже дает -1, 0, 1 | ||||
|  | ||||
|                 if normalized_value != 0: | ||||
|                     if normalized_value > 0:  # Вправо | ||||
|                         keyboard.move_focus_right() | ||||
|                     elif normalized_value < 0:  # Влево | ||||
|                         keyboard.move_focus_left() | ||||
|                 return | ||||
|  | ||||
|             # Обработка вертикального перемещения (UP/DOWN) | ||||
|             elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y): | ||||
|                 normalized_value = 0 | ||||
|                 if code == ecodes.ABS_Y:  # Левый стик | ||||
|                     # Применяем мертвую зону | ||||
|                     if abs(value) < self.dead_zone: | ||||
|                         self.current_dpad_code = None | ||||
|                         self.current_dpad_value = 0 | ||||
|                         self.axis_moving = False | ||||
|                         self.dpad_timer.stop() | ||||
|                         return | ||||
|                     normalized_value = 1 if value > self.dead_zone else -1 | ||||
|                 else:  # D-pad | ||||
|                     normalized_value = value  # D-pad уже дает -1, 0, 1 | ||||
|  | ||||
|                 if normalized_value != 0: | ||||
|                     if normalized_value > 0:  # Вниз | ||||
|                         keyboard.move_focus_down() | ||||
|                     elif normalized_value < 0:  # Вверх | ||||
|                         keyboard.move_focus_up() | ||||
|                 return | ||||
|  | ||||
|         if not self._gamepad_handling_enabled: | ||||
|             return | ||||
|         if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None: | ||||
|             logger.error("gamesListWidget not available yet, skipping D-pad navigation") | ||||
|             return | ||||
|         try: | ||||
|  | ||||
|             app = QApplication.instance() | ||||
| @@ -606,23 +895,6 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             # Update D-pad state | ||||
|             if value != 0: | ||||
|                 self.current_dpad_code = code | ||||
|                 self.current_dpad_value = value | ||||
|                 if not self.axis_moving: | ||||
|                     self.axis_moving = True | ||||
|                     self.last_move_time = current_time | ||||
|                     self.current_axis_delay = self.initial_axis_move_delay | ||||
|                     self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))  # Start timer (in milliseconds) | ||||
|             else: | ||||
|                 self.current_dpad_code = None | ||||
|                 self.current_dpad_value = 0 | ||||
|                 self.axis_moving = False | ||||
|                 self.current_axis_delay = self.initial_axis_move_delay | ||||
|                 self.dpad_timer.stop()  # Stop timer when D-pad is released | ||||
|                 return | ||||
|  | ||||
|             # Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad | ||||
|             if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0: | ||||
|                 if isinstance(active, QMessageBox):  # Specific handling for QMessageBox | ||||
| @@ -638,7 +910,7 @@ class InputManager(QObject): | ||||
|                     elif value < 0:  # Left | ||||
|                         active.focusPreviousChild() | ||||
|                     return | ||||
|             elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0:  # Keep up/down for other dialogs | ||||
|             elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget):  # Keep up/down for other dialogs | ||||
|                 if not focused or not active.focusWidget(): | ||||
|                     # If no widget is focused, focus the first focusable widget | ||||
|                     focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
| @@ -691,132 +963,90 @@ class InputManager(QObject): | ||||
|                     active.show_next() | ||||
|                 return | ||||
|  | ||||
|             # Library tab navigation (index 0) | ||||
|             if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 game_cards = self._parent.gamesListWidget.findChildren(GameCard) | ||||
|                 if not game_cards: | ||||
|  | ||||
|             # Table navigation | ||||
|             if isinstance(focused, QTableWidget): | ||||
|                 row_count = focused.rowCount() | ||||
|                 if row_count <= 0: | ||||
|                     return | ||||
|                 current_row = focused.currentRow() | ||||
|                 if current_row < 0: | ||||
|                     current_row = 0 | ||||
|                     focused.setCurrentCell(0, 0) | ||||
|  | ||||
|                 scroll_area = self._parent.gamesListWidget.parentWidget() | ||||
|                 while scroll_area and not isinstance(scroll_area, QScrollArea): | ||||
|                     scroll_area = scroll_area.parentWidget() | ||||
|  | ||||
|                 # If no focused widget or not a GameCard, focus the first card | ||||
|                 if not isinstance(focused, GameCard) or focused not in game_cards: | ||||
|                     game_cards[0].setFocus() | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) | ||||
|                     return | ||||
|  | ||||
|                 cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
|                 if not cards: | ||||
|                     return | ||||
|                 # Group cards by rows with tolerance for y-position | ||||
|                 rows = {} | ||||
|                 y_tolerance = 10  # Allow slight variations in y-position | ||||
|                 for card in cards: | ||||
|                     y = card.pos().y() | ||||
|                     matched = False | ||||
|                     for row_y in rows: | ||||
|                         if abs(y - row_y) <= y_tolerance: | ||||
|                             rows[row_y].append(card) | ||||
|                             matched = True | ||||
|                             break | ||||
|                     if not matched: | ||||
|                         rows[y] = [card] | ||||
|                 sorted_rows = sorted(rows.items(), key=lambda x: x[0]) | ||||
|                 if not sorted_rows: | ||||
|                     return | ||||
|                 current_row_idx = None | ||||
|                 current_col_idx = None | ||||
|                 for row_idx, (_y, row_cards) in enumerate(sorted_rows): | ||||
|                     for idx, card in enumerate(row_cards): | ||||
|                         if card == focused: | ||||
|                             current_row_idx = row_idx | ||||
|                             current_col_idx = idx | ||||
|                             break | ||||
|                     if current_row_idx is not None: | ||||
|                         break | ||||
|  | ||||
|                 # Fallback: if focused card not found, select closest row by y-position | ||||
|                 if current_row_idx is None: | ||||
|                     if not sorted_rows:  # Additional safety check | ||||
|                         return | ||||
|                     focused_y = focused.pos().y() | ||||
|                     current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y)) | ||||
|                     if current_row_idx >= len(sorted_rows):  # Safety check | ||||
|                         return | ||||
|                     current_row = sorted_rows[current_row_idx][1] | ||||
|                     focused_x = focused.pos().x() + focused.width() / 2 | ||||
|                     current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore | ||||
|  | ||||
|                 # Add null checks before using current_row_idx and current_col_idx | ||||
|                 if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): | ||||
|                     return | ||||
|  | ||||
|                 current_row = sorted_rows[current_row_idx][1] | ||||
|                 if code == ecodes.ABS_HAT0X and value != 0: | ||||
|                     if value < 0:  # Left | ||||
|                         if current_col_idx > 0: | ||||
|                             next_card = current_row[current_col_idx - 1] | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         else: | ||||
|                             if current_row_idx > 0: | ||||
|                                 prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                                 next_card = prev_row[-1] if prev_row else None | ||||
|                                 if next_card: | ||||
|                                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                     if scroll_area: | ||||
|                                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                     elif value > 0:  # Right | ||||
|                         if current_col_idx < len(current_row) - 1: | ||||
|                             next_card = current_row[current_col_idx + 1] | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         else: | ||||
|                             if current_row_idx < len(sorted_rows) - 1: | ||||
|                                 next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                                 next_card = next_row[0] if next_row else None | ||||
|                                 if next_card: | ||||
|                                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                     if scroll_area: | ||||
|                                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|                 if code == ecodes.ABS_HAT0Y and value != 0: | ||||
|                     # Vertical navigation | ||||
|                     if value > 0:  # Down | ||||
|                         if current_row_idx < len(sorted_rows) - 1: | ||||
|                             next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                             current_x = focused.pos().x() + focused.width() / 2 | ||||
|                             next_card = min( | ||||
|                                 next_row, | ||||
|                                 key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                                 default=None | ||||
|                             ) | ||||
|                             if next_card: | ||||
|                                 next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                 if scroll_area: | ||||
|                                     scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         new_row = min(current_row + 1, row_count - 1) | ||||
|                     elif value < 0:  # Up | ||||
|                         if current_row_idx > 0: | ||||
|                             prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                             current_x = focused.pos().x() + focused.width() / 2 | ||||
|                             next_card = min( | ||||
|                                 prev_row, | ||||
|                                 key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                                 default=None | ||||
|                             ) | ||||
|                             if next_card: | ||||
|                                 next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                                 if scroll_area: | ||||
|                                     scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                         elif current_row_idx == 0: | ||||
|                             self._parent.tabButtons[0].setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         new_row = max(current_row - 1, 0) | ||||
|                     else: | ||||
|                         return | ||||
|  | ||||
|                     focused.setCurrentCell(new_row, focused.currentColumn()) | ||||
|                     item = focused.item(new_row, focused.currentColumn()) | ||||
|                     if item: | ||||
|                         focused.scrollToItem( | ||||
|                             item, | ||||
|                             QAbstractItemView.ScrollHint.PositionAtCenter | ||||
|                         ) | ||||
|                     focused.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     return | ||||
|                 elif code == ecodes.ABS_HAT0X and value != 0: | ||||
|                     # Horizontal navigation | ||||
|                     col_count = focused.columnCount() | ||||
|                     current_col = focused.currentColumn() | ||||
|                     if current_col < 0: | ||||
|                         current_col = 0 | ||||
|  | ||||
|                     if value < 0:  # Left | ||||
|                         new_col = max(current_col - 1, 0) | ||||
|                     elif value > 0:  # Right | ||||
|                         new_col = min(current_col + 1, col_count - 1) | ||||
|                     else: | ||||
|                         return | ||||
|  | ||||
|                     focused.setCurrentCell(focused.currentRow(), new_col) | ||||
|                     focused.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     return | ||||
|  | ||||
|             # Search focus logic for tabs 0 and 1 | ||||
|             if code == ecodes.ABS_HAT0Y and value < 0: | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 current_index = self._parent.stackedWidget.currentIndex() | ||||
|                 if current_index in (0, 1) and isinstance(focused, GameCard): | ||||
|                     if current_index == 0: | ||||
|                         container = self._parent.gamesListWidget | ||||
|                         search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                     else: | ||||
|                         container = self._parent.autoInstallContainer | ||||
|                         search_edit = getattr(self._parent, 'autoInstallSearchLineEdit', None) | ||||
|                     if container and search_edit: | ||||
|                         game_cards = container.findChildren(GameCard) | ||||
|                         if game_cards: | ||||
|                             current_card_pos = focused.pos() | ||||
|                             current_row_y = current_card_pos.y() | ||||
|                             is_first_row = True | ||||
|                             for card in game_cards: | ||||
|                                 if card.pos().y() < current_row_y and card.isVisible(): | ||||
|                                     is_first_row = False | ||||
|                                     break | ||||
|                             if is_first_row: | ||||
|                                 search_edit.setFocus() | ||||
|                                 return | ||||
|  | ||||
|             # Game cards navigation for tabs 0 and 1 | ||||
|             if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): | ||||
|                 current_index = self._parent.stackedWidget.currentIndex() | ||||
|                 if current_index in (0, 1): | ||||
|                     container = self._parent.gamesListWidget if current_index == 0 else self._parent.autoInstallContainer | ||||
|                     if container is None: | ||||
|                         return | ||||
|                     self._navigate_game_cards(container, current_index, code, value) | ||||
|                     return | ||||
|  | ||||
|             # Vertical navigation in other tabs | ||||
|             elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|             if code == ecodes.ABS_HAT0Y and value != 0: | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 page = self._parent.stackedWidget.currentWidget() | ||||
|                 if value > 0:  # Down | ||||
| @@ -836,6 +1066,52 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True) | ||||
|  | ||||
|     def handle_virtual_keyboard(self, button_code: int, value: int) -> None: | ||||
|         # Проверяем клавиатуру в активном окне | ||||
|         active_window = QApplication.activeWindow() | ||||
|         keyboard = None | ||||
|  | ||||
|         # Сначала проверяем AddGameDialog | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             keyboard = getattr(active_window, 'keyboard', None) | ||||
|         else: | ||||
|             # Если это не AddGameDialog, проверяем клавиатуру в главном окне | ||||
|             keyboard = getattr(self._parent, 'keyboard', None) | ||||
|  | ||||
|         if not keyboard or not isinstance(keyboard, VirtualKeyboard) or not keyboard.isVisible(): | ||||
|             return | ||||
|  | ||||
|         # Обработка кнопок геймпада | ||||
|         if button_code in BUTTONS['confirm']:  # Кнопка A/Cross - подтверждение | ||||
|             if value == 1: | ||||
|                 keyboard.activateFocusedKey() | ||||
|         elif button_code in BUTTONS['back']:  # Кнопка B/Circle - скрыть клавиатуру | ||||
|             if value == 1: | ||||
|                 keyboard.hide() | ||||
|                 # Возвращаем фокус на поле ввода | ||||
|                 if keyboard.current_input_widget: | ||||
|                     keyboard.current_input_widget.setFocus() | ||||
|         elif button_code in BUTTONS['prev_tab']:  # LB/L1 - переключение раскладки | ||||
|             if value == 1: | ||||
|                 keyboard.on_lang_click() | ||||
|         elif button_code in BUTTONS['next_tab']:  # RB/R1 - переключение Shift | ||||
|             if value == 1: | ||||
|                 keyboard.on_shift_click(not keyboard.shift_pressed) | ||||
|         elif button_code in BUTTONS['context_menu']:  # Кнопка Start - подтверждение | ||||
|             if value == 1: | ||||
|                 keyboard.activateFocusedKey() | ||||
|         elif button_code in BUTTONS['menu']:  # Кнопка Select - скрыть клавиатуру | ||||
|             if value == 1: | ||||
|                 keyboard.hide() | ||||
|                 # Возвращаем фокус на поле ввода | ||||
|                 if keyboard.current_input_widget: | ||||
|                     keyboard.current_input_widget.setFocus() | ||||
|         elif button_code in BUTTONS['add_game']:  # Кнопка X - Backspace (now holdable) | ||||
|             if value == 1: | ||||
|                 keyboard.on_backspace_pressed() | ||||
|             elif value == 0: | ||||
|                 keyboard.stop_backspace_repeat() | ||||
|  | ||||
|     def eventFilter(self, obj: QObject, event: QEvent) -> bool: | ||||
|         app = QApplication.instance() | ||||
|         if not app: | ||||
| @@ -1083,8 +1359,8 @@ class InputManager(QObject): | ||||
|                     self.gamepad = None | ||||
|                     if self.gamepad_thread: | ||||
|                         self.gamepad_thread.join() | ||||
|                     # Signal to exit fullscreen mode | ||||
|                     self.toggle_fullscreen.emit(False) | ||||
|                     if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                         self.toggle_fullscreen.emit(False) | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error handling udev event: {e}", exc_info=True) | ||||
|  | ||||
| @@ -1142,11 +1418,12 @@ class InputManager(QObject): | ||||
|                 if not app or not active: | ||||
|                     continue | ||||
|  | ||||
|                 if event.type == ecodes.EV_KEY and event.value == 1: | ||||
|                     if event.code in BUTTONS['menu'] and not self._is_gamescope_session: | ||||
|                 if event.type == ecodes.EV_KEY: | ||||
|                     # Emit on both press (1) and release (0) | ||||
|                     self.button_event.emit(event.code, event.value) | ||||
|                     # Special handling for menu on press only | ||||
|                     if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session: | ||||
|                         self.toggle_fullscreen.emit(not self._is_fullscreen) | ||||
|                     else: | ||||
|                         self.button_pressed.emit(event.code) | ||||
|                 elif event.type == ecodes.EV_ABS: | ||||
|                     if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}: | ||||
|                         # Проверяем, достаточно ли времени прошло с последнего срабатывания | ||||
| @@ -1155,17 +1432,19 @@ class InputManager(QObject): | ||||
|                         if event.code == ecodes.ABS_Z:  # LT/L2 | ||||
|                             if event.value > 128 and not self.lt_pressed: | ||||
|                                 self.lt_pressed = True | ||||
|                                 self.button_pressed.emit(event.code) | ||||
|                                 self.button_event.emit(event.code, 1)  # Emit as press | ||||
|                                 self.last_trigger_time = now | ||||
|                             elif event.value <= 128 and self.lt_pressed: | ||||
|                                 self.lt_pressed = False | ||||
|                                 self.button_event.emit(event.code, 0)  # Emit as release | ||||
|                         elif event.code == ecodes.ABS_RZ:  # RT/R2 | ||||
|                             if event.value > 128 and not self.rt_pressed: | ||||
|                                 self.rt_pressed = True | ||||
|                                 self.button_pressed.emit(event.code) | ||||
|                                 self.button_event.emit(event.code, 1)  # Emit as press | ||||
|                                 self.last_trigger_time = now | ||||
|                             elif event.value <= 128 and self.rt_pressed: | ||||
|                                 self.rt_pressed = False | ||||
|                                 self.button_event.emit(event.code, 0)  # Emit as release | ||||
|                     else: | ||||
|                         self.dpad_moved.emit(event.code, event.value, now) | ||||
|         except OSError as e: | ||||
|   | ||||
							
								
								
									
										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-12 17:14+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 "" | ||||
|  | ||||
| @@ -304,6 +308,45 @@ 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 "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| 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 +395,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -370,6 +410,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 +444,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..." | ||||
|   | ||||
| @@ -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-12 17:14+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 "" | ||||
|  | ||||
| @@ -304,6 +308,45 @@ 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 "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| 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 +395,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -370,6 +410,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 +444,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..." | ||||
|   | ||||
| @@ -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-12 17:14+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 "" | ||||
|  | ||||
| @@ -302,6 +306,45 @@ 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 "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| 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 +393,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -368,6 +408,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 +442,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..." | ||||
|   | ||||
| @@ -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-12 17:14+0500\n" | ||||
| "PO-Revision-Date: 2025-10-12 17:13+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}" | ||||
| @@ -274,7 +277,7 @@ msgstr "Путь: " | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "Доступ запрещен: %s" | ||||
| msgstr "Доступ запрещён: %s" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "Редактировать игру" | ||||
| @@ -312,6 +315,45 @@ 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 "Force Install" | ||||
| msgstr "Принудительно установить" | ||||
|  | ||||
| msgid "Install" | ||||
| 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 +402,6 @@ msgstr "Библиотека" | ||||
| msgid "Auto Install" | ||||
| msgstr "Автоустановка" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "Эмуляторы" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "Настройки wine" | ||||
|  | ||||
| @@ -378,6 +417,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 +451,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..." | ||||
| @@ -482,7 +638,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 +811,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: | ||||
|   | ||||
| Before Width: | Height: | Size: 880 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_backspace.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg> | ||||
| After Width: | Height: | Size: 751 B | 
| Before Width: | Height: | Size: 2.0 KiB | 
							
								
								
									
										48
									
								
								portprotonqt/themes/standart/images/key_context.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,48 @@ | ||||
| <?xml version="1.0" encoding="UTF-8" standalone="no"?> | ||||
| <svg | ||||
|    width="48" | ||||
|    height="48" | ||||
|    version="1.1" | ||||
|    viewBox="0 0 48 48" | ||||
|    xml:space="preserve" | ||||
|    id="svg2" | ||||
|    sodipodi:docname="key_context.svg" | ||||
|    inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)" | ||||
|    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" | ||||
|    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" | ||||
|    xmlns="http://www.w3.org/2000/svg" | ||||
|    xmlns:svg="http://www.w3.org/2000/svg"><defs | ||||
|      id="defs2" /><sodipodi:namedview | ||||
|      id="namedview2" | ||||
|      pagecolor="#ffffff" | ||||
|      bordercolor="#000000" | ||||
|      borderopacity="0.25" | ||||
|      inkscape:showpageshadow="2" | ||||
|      inkscape:pageopacity="0.0" | ||||
|      inkscape:pagecheckerboard="0" | ||||
|      inkscape:deskcolor="#d1d1d1" | ||||
|      inkscape:zoom="8.6915209" | ||||
|      inkscape:cx="72.311855" | ||||
|      inkscape:cy="22.780823" | ||||
|      inkscape:window-width="2560" | ||||
|      inkscape:window-height="1406" | ||||
|      inkscape:window-x="0" | ||||
|      inkscape:window-y="0" | ||||
|      inkscape:window-maximized="1" | ||||
|      inkscape:current-layer="svg2" /><path | ||||
|      style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000" | ||||
|      d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z" | ||||
|      id="path10" | ||||
|      sodipodi:nodetypes="csccscc" /><path | ||||
|      fill="#000000" | ||||
|      d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z" | ||||
|      id="path2" | ||||
|      style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g | ||||
|      id="g15" | ||||
|      transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path | ||||
|        style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000" | ||||
|        d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z" | ||||
|        id="path14" /><path | ||||
|        style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000" | ||||
|        d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z" | ||||
|        id="path15" /></g></svg> | ||||
| After Width: | Height: | Size: 3.3 KiB | 
| Before Width: | Height: | Size: 874 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_e.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg> | ||||
| After Width: | Height: | Size: 726 B | 
| Before Width: | Height: | Size: 1.3 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_enter.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg> | ||||
| After Width: | Height: | Size: 823 B | 
| Before Width: | Height: | Size: 943 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_f11.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg> | ||||
| After Width: | Height: | Size: 857 B | 
| Before Width: | Height: | Size: 933 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_left.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg> | ||||
| After Width: | Height: | Size: 865 B | 
| Before Width: | Height: | Size: 956 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/key_right.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg> | ||||
| After Width: | Height: | Size: 864 B | 
| Before Width: | Height: | Size: 1.9 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_circle.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg> | ||||
| After Width: | Height: | Size: 736 B | 
| Before Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_cross.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></svg> | ||||
| After Width: | Height: | Size: 961 B | 
| Before Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_l1.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></svg> | ||||
| After Width: | Height: | Size: 1015 B | 
| Before Width: | Height: | Size: 1.3 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_options.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></svg> | ||||
| After Width: | Height: | Size: 1.2 KiB | 
| Before Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_r1.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></svg> | ||||
| After Width: | Height: | Size: 1.3 KiB | 
| Before Width: | Height: | Size: 1.4 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_share.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></svg> | ||||
| After Width: | Height: | Size: 1.5 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_square.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg> | ||||
| After Width: | Height: | Size: 682 B | 
| Before Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_triangle.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></svg> | ||||
| After Width: | Height: | Size: 721 B | 
| Before Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_a.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></svg> | ||||
| After Width: | Height: | Size: 600 B | 
| Before Width: | Height: | Size: 1.7 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_b.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m15.973 13.476h8.5299q3.0163 0 4.6379 0.45406 1.6541 0.42163 2.3352 1.3946 0.68109 0.94056 0.68109 2.6595v2.5946q0 0.87569-0.71353 1.6541-0.68109 0.77839-1.6216 1.0703v0.16216q1.2325 0.12973 2.2379 1.0703 1.0379 0.90812 1.0379 2.0433v3.2433q0 2.5622-2.0433 3.6325t-6.3244 1.0703h-8.7569zm8.5299 8.5623q1.2 0 1.7838-0.1946t0.77839-0.61623q0.22703-0.45406 0.22703-1.2973v-1.0379q0-0.74596-0.1946-1.1027-0.1946-0.3892-0.81082-0.55136-0.58379-0.16216-1.8811-0.16216h-3.373v4.9622zm0.12973 8.8866q1.8487 0 2.6271-0.42163t0.77839-1.3622v-1.6865q0-1.1676-0.61623-1.6541-0.58379-0.4865-2.1081-0.4865h-4.2812v5.6109z" fill="#3f424d" stroke-width=".67675" aria-label="B"/></svg> | ||||
| After Width: | Height: | Size: 1.0 KiB | 
| Before Width: | Height: | Size: 1.9 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_lb.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m13.757 18h2.8844v9.9476h5.8983v2.0524h-8.7827zm10.724 0h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.0169-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="LB"/></svg> | ||||
| After Width: | Height: | Size: 1.3 KiB | 
| Before Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_rb.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m12.943 18h5.3991q2.4037 0 3.4761 0.59168 1.0724 0.57319 1.0724 1.9414v2.1079q0 1.0539-0.75809 1.6641-0.7396 0.59168-1.9784 0.75809l3.4761 4.9368h-3.3282l-3.1433-4.7149h-1.3313v4.7149h-2.8844zm5.3621 5.3806q1.0169 0 1.3867-0.25886 0.3698-0.27735 0.3698-1.0539v-0.85054q0-0.66564-0.40678-0.90601-0.38829-0.25886-1.3498-0.25886h-2.4777v3.3282zm6.9892-5.3806h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.017-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="RB"/></svg> | ||||
| After Width: | Height: | Size: 1.6 KiB | 
| Before Width: | Height: | Size: 2.1 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_start.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m16.169 14.061c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395z" fill="#3f424d" fill-rule="evenodd" stroke-width=".19943"/></svg> | ||||
| After Width: | Height: | Size: 958 B | 
| Before Width: | Height: | Size: 2.2 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_view.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="m12.75 14.492c-0.62128 0-1.1257 0.38721-1.1257 0.86456v12.1c0 0.47737 0.50442 0.86456 1.1257 0.86456h3.3753v-1.7274h-2.2496v-10.373h13.498v1.7291h2.2496v-2.5937c0-0.47735-0.50268-0.86456-1.1239-0.86456zm6.7489 5.1874c-0.62128 0-1.1239 0.38721-1.1239 0.86456v12.1c0 0.47737 0.50266 0.86456 1.1239 0.86456h15.749c0.62125 0 1.1239-0.3872 1.1239-0.86456v-12.1c0-0.47735-0.50268-0.86456-1.1239-0.86456zm1.1257 1.7291h13.498v10.371h-13.498z" clip-rule="evenodd" fill="#3f424d" fill-rule="evenodd" stroke-width=".98604"/></svg> | ||||
| After Width: | Height: | Size: 919 B | 
| Before Width: | Height: | Size: 1.8 KiB | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_x.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.097 23.789-7.4272-10.314h5.8379l4.5082 7.0055 4.4758-7.0055h5.8379l-7.4272 10.314 7.7839 10.735h-5.8704l-4.8001-7.4596-4.8325 7.4596h-5.8379z" fill="#3f424d" stroke-width=".67675" aria-label="X"/></svg> | ||||
| After Width: | Height: | Size: 605 B | 
							
								
								
									
										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}; | ||||
|   | ||||
							
								
								
									
										586
									
								
								portprotonqt/virtual_keyboard.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,586 @@ | ||||
| from typing import cast | ||||
| from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout, | ||||
|                                QSizePolicy, QWidget, QLineEdit) | ||||
| from PySide6.QtCore import Qt, Signal, QProcess | ||||
| 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 | ||||
|  | ||||
|         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 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)) | ||||
|                 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)) | ||||
|         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) | ||||
|         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) | ||||
|         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) | ||||
|         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.7" | ||||
| 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)" | ||||
|     }, | ||||
|   | ||||