Compare commits
	
		
			50 Commits
		
	
	
		
			9452bfda2e
			...
			renovate/a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 92572bf5a1 | ||
| 438e9737ea | |||
| 2d39a4c740 | |||
| 567203b0b0 | |||
| 502cbc5030 | |||
| 9b61215152 | |||
| 10d3fe8ab4 | |||
| a568ad9ef8 | |||
| f074843fc8 | |||
| 4ab078b93e | |||
| 7df6ad3b80 | |||
| 464ad0fe9c | |||
| cde92885d4 | |||
| 120c7b319c | |||
| 596aed0077 | |||
| 6fc6cb1e02 | |||
| 186e28a19b | |||
| 28e4d1e77c | |||
| fff1f888c4 | |||
| fdd5a0a3d5 | |||
| 792e52d981 | |||
| 84d5e46a74 | |||
| 4bc764d568 | |||
| 9a18aa037e | |||
| ed62d2d1c4 | |||
| accc9b18b6 | |||
| 82249d7eab | |||
| 476c896940 | |||
| b1047ba18e | |||
| 987199d8e6 | |||
|  | ef1acd4581 | ||
| 96f884904c | |||
| b856a2afae | |||
| 55ef0030e6 | |||
| 8aaeaa4824 | |||
| f55372b480 | |||
| 4d6f32f053 | |||
| a2f5141b20 | |||
| e3cb2857e7 | |||
| efe8a35832 | |||
| 61fae97dad | |||
| 5442100f64 | |||
| 2d6ef84798 | |||
|  | f4aee15b5d | ||
| 87a65108a5 | |||
| bb617708ac | |||
| 1cf332cd87 | |||
| 577ad4d3a3 | |||
| ef3f2d6e96 | |||
| 657d7728a6 | 
| @@ -94,7 +94,7 @@ jobs: | ||||
|     name: Build Arch Package | ||||
|     runs-on: ubuntu-22.04 | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166 | ||||
|       image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
|   | ||||
| @@ -8,7 +8,7 @@ on: | ||||
|  | ||||
| env: | ||||
|   # Common version, will be used for tagging the release | ||||
|   VERSION: 0.1.6 | ||||
|   VERSION: 0.1.8 | ||||
|   PKGDEST: "/tmp/portprotonqt" | ||||
|   PACKAGE: "portprotonqt" | ||||
|   GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} | ||||
| @@ -180,10 +180,12 @@ jobs: | ||||
|  | ||||
|       - name: Release | ||||
|         uses: https://gitea.com/actions/gitea-release-action@v1 | ||||
|         env: | ||||
|             NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18 | ||||
|         with: | ||||
|           body_path: changelog.txt | ||||
|           token: ${{ env.GITEA_TOKEN }} | ||||
|           tag_name: v${{ env.VERSION }} | ||||
|           prerelease: true | ||||
|           files: release/**/* | ||||
|           sha256sum: true | ||||
|           sha256sum: false | ||||
|   | ||||
| @@ -138,7 +138,7 @@ jobs: | ||||
|     needs: changes | ||||
|     if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' | ||||
|     container: | ||||
|       image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166 | ||||
|       image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
|   | ||||
							
								
								
									
										36
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						| @@ -3,14 +3,42 @@ | ||||
| Все заметные изменения в этом проекте фиксируются в этом файле. | ||||
| Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). | ||||
|  | ||||
| ## [Unreleased] | ||||
| ## [0.1.8] - 2025-10-18 | ||||
|  | ||||
| ### Added | ||||
| - В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению | ||||
| - В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет | ||||
| - К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада | ||||
| - Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы) | ||||
|  | ||||
| ### Changed | ||||
| - При завершении автоустановки приложение больше не перезапускается | ||||
| - Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название | ||||
| - Обновлены и дополнены скриншоты темы | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлено наложение карточек при смене фильтра игр | ||||
| - Исправлена невозможность запуска приложения без подключёного геймпада | ||||
| - Исправлена невозможность установки компонентов Winetricks через геймпад | ||||
| - Ресиверы и виртуальные устройства больше не считаются за геймпад | ||||
|  | ||||
|  | ||||
| ### Contributors | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## [0.1.7] - 2025-10-12 | ||||
|  | ||||
| ### Added | ||||
| - Возможность скроллинга библиотеки мышью или пальцем | ||||
| - Импорт и экспорт бекапа префикса | ||||
| - Диалог для управление Winetricks | ||||
| - Кнопки для удаления префикса, wine или proton | ||||
| - Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке | ||||
| - Все настройки Wine с оригинального PortProton | ||||
| - Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках | ||||
| - Вкладка автоустановок | ||||
| - В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита | ||||
|  | ||||
| ### Changed | ||||
| - Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр | ||||
| @@ -22,8 +50,12 @@ | ||||
| - Исправлено зависание при поиске игр | ||||
| - Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity) | ||||
| - Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада | ||||
| - Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена | ||||
| - При сохранении настроек теперь не меняется размер окна | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
							
								
								
									
										15
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,6 @@ | ||||
| - [X] Адаптировать структуру проекта для поддержки инструментов сборки | ||||
| - [X] Добавить возможность управления с геймпада | ||||
| - [ ] Добавить возможность управления с тачскрина | ||||
| - [X] Добавить возможность управления с тачскрина (Формально и так есть) | ||||
| - [X] Добавить возможность управления с мыши и клавиатуры | ||||
| - [X] Добавить систему тем [Документация](documentation/theme_guide) | ||||
| - [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено) | ||||
| @@ -11,18 +11,18 @@ | ||||
| - [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800) | ||||
| - [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots) | ||||
| - [X] Получать описания и названия игр из базы данных Steam | ||||
| - [X] Получать обложки для игр из SteamGridDB или CDN Steam | ||||
| - [X] Получать обложки для игр из CDN Steam | ||||
| - [X] Оптимизировать работу со Steam API для ускорения времени запуска | ||||
| - [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley) | ||||
| - [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода | ||||
| - [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода | ||||
| - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) | ||||
| - [X] Избавиться от вызовов yad | ||||
| - [X] Реализовать собственный системный трей вместо использования трея PortProton | ||||
| - [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.) | ||||
| - [X] Добавить экранную клавиатуру в поиск | ||||
| - [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) | ||||
| - [X] Добавить индикацию запуска приложения | ||||
| - [X] Достигнуть паритета функциональности с Ingame | ||||
| - [ ] Достигнуть паритета функциональности с PortProton | ||||
| - [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов) | ||||
| - [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}` | ||||
| - [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/) | ||||
| - [X] Добавить переводы в переопределения | ||||
| @@ -49,7 +49,7 @@ | ||||
| - [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter) | ||||
| - [X] Добавить систему избранного для карточек | ||||
| - [X] Заменить все `print` на `logging` | ||||
| - [ ] Привести все логи к единому языку | ||||
| - [X] Привести все логи к единому языку | ||||
| - [X] Уменьшить количество подстановок в переводах | ||||
| - [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog) | ||||
| - [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py` | ||||
| @@ -62,7 +62,6 @@ | ||||
| - [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63) | ||||
| - [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)) | ||||
| - [X] Скопировать логику управления с D-pad на стрелки с клавиатуры | ||||
| - [ ] Доделать светлую тему | ||||
| - [ ] Добавить подсказки к управлению с геймпада | ||||
| - [X] Добавить подсказки к управлению с геймпада | ||||
| - [X] Добавить миниатюры к выбору файлов в диалоге добавления игры | ||||
| - [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры | ||||
|   | ||||
| @@ -36,7 +36,7 @@ AppDir: | ||||
|     id: ru.linux_gaming.PortProtonQt | ||||
|     name: PortProtonQt | ||||
|     icon: ru.linux_gaming.PortProtonQt | ||||
|     version: 0.1.6 | ||||
|     version: 0.1.8 | ||||
|     exec: usr/bin/python3 | ||||
|     exec_args: "-m portprotonqt.app $@" | ||||
|   apt: | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| pkgname=portprotonqt | ||||
| pkgver=0.1.6 | ||||
| pkgver=0.1.8 | ||||
| pkgrel=1 | ||||
| pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" | ||||
| arch=('any') | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| %global pypi_name portprotonqt | ||||
| %global pypi_version 0.1.6 | ||||
| %global pypi_version 0.1.8 | ||||
| %global oname PortProtonQt | ||||
| %global _python_no_extras_requires 1 | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ Current translation status: | ||||
|  | ||||
| | Locale | Progress | Translated | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 233 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 233 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 of 233 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ | ||||
|  | ||||
| | Локаль | Прогресс | Переведено | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 233 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 233 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 из 233 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -1,17 +1,41 @@ | ||||
| import sys | ||||
| import os | ||||
| import subprocess | ||||
| from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo | ||||
| from PySide6.QtWidgets import QApplication | ||||
| from PySide6.QtGui import QIcon | ||||
| from portprotonqt.main_window import MainWindow | ||||
| from portprotonqt.config_utils import save_fullscreen_config | ||||
| from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location | ||||
| from portprotonqt.logger import get_logger, setup_logger | ||||
| from portprotonqt.cli import parse_args | ||||
|  | ||||
| __app_id__ = "ru.linux_gaming.PortProtonQt" | ||||
| __app_name__ = "PortProtonQt" | ||||
| __app_version__ = "0.1.6" | ||||
| __app_version__ = "0.1.8" | ||||
|  | ||||
| def get_version(): | ||||
|     try: | ||||
|         commit = subprocess.check_output( | ||||
|             ['git', 'rev-parse', '--short', 'HEAD'], | ||||
|             stderr=subprocess.DEVNULL | ||||
|         ).decode('utf-8').strip() | ||||
|         return f"{__app_version__} ({commit})" | ||||
|     except (subprocess.CalledProcessError, FileNotFoundError, OSError): | ||||
|         return __app_version__ | ||||
|  | ||||
| def main(): | ||||
|     os.environ['PW_CLI'] = '1' | ||||
|     os.environ['PROCESS_LOG'] = '1' | ||||
|     os.environ['START_FROM_STEAM'] = '1' | ||||
|  | ||||
|     portproton_path = get_portproton_location() | ||||
|  | ||||
|     if portproton_path is None: | ||||
|         return | ||||
|  | ||||
|     script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh') | ||||
|     subprocess.run([script_path, 'cli', '--initial']) | ||||
|  | ||||
|     app = QApplication(sys.argv) | ||||
|     app.setWindowIcon(QIcon.fromTheme(__app_id__)) | ||||
|     app.setDesktopFileName(__app_id__) | ||||
| @@ -34,7 +58,8 @@ def main(): | ||||
|     else: | ||||
|         logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language") | ||||
|  | ||||
|     window = MainWindow(app_name=__app_name__) | ||||
|     version = get_version() | ||||
|     window = MainWindow(app_name=__app_name__, version=version) | ||||
|  | ||||
|     if args.fullscreen: | ||||
|         logger.info("Launching in fullscreen mode due to --fullscreen flag") | ||||
|   | ||||
| @@ -177,6 +177,26 @@ def save_card_size(card_width): | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_auto_card_size(): | ||||
|     """Reads the card size (width) for Auto Install from the [Cards] section. | ||||
|     Returns 250 if the parameter is not set. | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"): | ||||
|         save_auto_card_size(250) | ||||
|         return 250 | ||||
|     return cp.getint("Cards", "auto_card_width", fallback=250) | ||||
|  | ||||
| def save_auto_card_size(card_width): | ||||
|     """Saves the card size (width) for Auto Install to the [Cards] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Cards" not in cp: | ||||
|         cp["Cards"] = {} | ||||
|     cp["Cards"]["auto_card_width"] = str(card_width) | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
|  | ||||
| def read_sort_method(): | ||||
|     """Reads the sort method from the [Games] section. | ||||
|     Returns 'last_launch' if the parameter is not set. | ||||
| @@ -259,6 +279,25 @@ def save_rumble_config(rumble_enabled): | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_gamepad_type(): | ||||
|     """Reads the gamepad type from the [Gamepad] section. | ||||
|     Returns 'xbox' if the parameter is missing. | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"): | ||||
|         save_gamepad_type("xbox") | ||||
|         return "xbox" | ||||
|     return cp.get("Gamepad", "type", fallback="xbox").lower() | ||||
|  | ||||
| def save_gamepad_type(gpad_type): | ||||
|     """Saves the gamepad type to the [Gamepad] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Gamepad" not in cp: | ||||
|         cp["Gamepad"] = {} | ||||
|     cp["Gamepad"]["type"] = gpad_type | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def ensure_default_proxy_config(): | ||||
|     """Ensures the [Proxy] section exists in the configuration file. | ||||
|     Creates it with empty values if missing. | ||||
| @@ -408,3 +447,22 @@ def save_favorite_folders(folders): | ||||
|     cp["FavoritesFolders"]["folders"] = f'"{fav_str}"' | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_minimize_to_tray(): | ||||
|     """Reads the minimize-to-tray setting from the [Display] section. | ||||
|     Returns True if the parameter is missing (default: minimize to tray). | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"): | ||||
|         save_minimize_to_tray(True) | ||||
|         return True | ||||
|     return cp.getboolean("Display", "minimize_to_tray", fallback=True) | ||||
|  | ||||
| def save_minimize_to_tray(minimize_to_tray): | ||||
|     """Saves the minimize-to-tray setting to the [Display] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Display" not in cp: | ||||
|         cp["Display"] = {} | ||||
|     cp["Display"]["minimize_to_tray"] = str(minimize_to_tray) | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|   | ||||
| Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB | 
| Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB | 
| Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB | 
| Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB | 
| Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB | 
| Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB | 
| Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB | 
| @@ -91,6 +91,130 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True): | ||||
|         logger.error(f"Ошибка при сохранении миниатюры: {e}") | ||||
|         return False | ||||
|  | ||||
| def create_dialog_hints_widget(theme, main_window, input_manager, context='default'): | ||||
|     """ | ||||
|     Common function to create hints widget for all dialogs. | ||||
|     Uses main_window for get_button_icon/get_nav_icon, input_manager for gamepad detection. | ||||
|     """ | ||||
|     theme_manager = ThemeManager() | ||||
|     current_theme_name = read_theme_from_config() | ||||
|  | ||||
|     hintsWidget = QWidget() | ||||
|     hintsWidget.setStyleSheet(theme.STATUS_BAR_STYLE) | ||||
|     hintsLayout = QHBoxLayout(hintsWidget) | ||||
|     hintsLayout.setContentsMargins(10, 0, 10, 0) | ||||
|     hintsLayout.setSpacing(20) | ||||
|  | ||||
|     dialog_actions = [] | ||||
|  | ||||
|     # Context-specific actions (gamepad only, no keyboard) | ||||
|     if context == 'file_explorer': | ||||
|         dialog_actions = [ | ||||
|             ("confirm", _("Open")),        # A / Cross | ||||
|             ("add_game", _("Select Dir")), # X / Triangle | ||||
|             ("prev_dir", _("Prev Dir")),   # Y / Square | ||||
|             ("back", _("Cancel")),         # B / Circle | ||||
|             ("context_menu", _("Menu")),   # Start / Options | ||||
|         ] | ||||
|     elif context == 'winetricks': | ||||
|         dialog_actions = [ | ||||
|             ("confirm", _("Toggle")),         # A / Cross | ||||
|             ("add_game", _("Install")),       # X / Triangle | ||||
|             ("prev_dir", _("Force Install")), # Y / Square | ||||
|             ("back", _("Cancel")),            # B / Circle | ||||
|             ("prev_tab", _("Prev Tab")),      # LB / L1 | ||||
|             ("next_tab", _("Next Tab")),      # RB / R1 | ||||
|         ] | ||||
|  | ||||
|     hints_labels = []  # Store for updates (returned for class storage) | ||||
|  | ||||
|     def make_hint(icon_name, text, action=None): | ||||
|         container = QWidget() | ||||
|         hlayout = QHBoxLayout(container) | ||||
|         hlayout.setContentsMargins(0, 5, 0, 0) | ||||
|         hlayout.setSpacing(6) | ||||
|  | ||||
|         icon_label = QLabel() | ||||
|         icon_label.setFixedSize(26, 26) | ||||
|         icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) | ||||
|  | ||||
|         pixmap = QPixmap() | ||||
|         icon_path = theme_manager.get_theme_image(icon_name, current_theme_name) | ||||
|         if icon_path: | ||||
|             pixmap.load(str(icon_path)) | ||||
|         if not pixmap.isNull(): | ||||
|             icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) | ||||
|  | ||||
|         hlayout.addWidget(icon_label) | ||||
|  | ||||
|         text_label = QLabel(text) | ||||
|         text_label.setStyleSheet(theme.LAST_LAUNCH_VALUE_STYLE) | ||||
|         text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) | ||||
|         hlayout.addWidget(text_label) | ||||
|  | ||||
|         # Initially hidden; show only if gamepad connected | ||||
|         container.setVisible(False) | ||||
|         hints_labels.append((container, icon_label, action)) | ||||
|  | ||||
|         hintsLayout.addWidget(container) | ||||
|  | ||||
|     # Add gamepad hints only | ||||
|     for action, text in dialog_actions: | ||||
|         make_hint("placeholder", text, action) | ||||
|  | ||||
|     hintsLayout.addStretch() | ||||
|  | ||||
|     # Return widget and labels for class storage | ||||
|     return hintsWidget, hints_labels | ||||
|  | ||||
| def update_dialog_hints(hints_labels, main_window, input_manager, theme_manager, current_theme_name): | ||||
|     """ | ||||
|     Common function to update hints for any dialog. | ||||
|     """ | ||||
|     if not input_manager or not main_window: | ||||
|         # Hide all if no input_manager or main_window | ||||
|         for container, _, _ in hints_labels: | ||||
|             container.setVisible(False) | ||||
|         return | ||||
|  | ||||
|     is_gamepad = input_manager.gamepad is not None | ||||
|     if not is_gamepad: | ||||
|         # Hide all hints if no gamepad | ||||
|         for container, _, _ in hints_labels: | ||||
|             container.setVisible(False) | ||||
|         return | ||||
|  | ||||
|     gtype = input_manager.gamepad_type | ||||
|     gamepad_actions = ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir', 'prev_tab', 'next_tab'] | ||||
|  | ||||
|     for container, icon_label, action in hints_labels: | ||||
|         if action and action in gamepad_actions: | ||||
|             container.setVisible(True) | ||||
|             # Update icon using main_window methods | ||||
|             if action in ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir']: | ||||
|                 icon_name = main_window.get_button_icon(action, gtype) | ||||
|             else:  # only prev_tab/next_tab (treat as nav) | ||||
|                 direction = 'left' if action == 'prev_tab' else 'right' | ||||
|                 icon_name = main_window.get_nav_icon(direction, gtype) | ||||
|             icon_path = theme_manager.get_theme_image(icon_name, current_theme_name) | ||||
|             pixmap = QPixmap() | ||||
|             if icon_path: | ||||
|                 pixmap.load(str(icon_path)) | ||||
|             if not pixmap.isNull(): | ||||
|                 icon_label.setPixmap(pixmap.scaled( | ||||
|                     26, 26, | ||||
|                     Qt.AspectRatioMode.KeepAspectRatio, | ||||
|                     Qt.TransformationMode.SmoothTransformation | ||||
|                 )) | ||||
|             else: | ||||
|                 # Fallback to placeholder | ||||
|                 placeholder = theme_manager.get_theme_image("placeholder", current_theme_name) | ||||
|                 if placeholder: | ||||
|                     pixmap.load(str(placeholder)) | ||||
|                     icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)) | ||||
|         else: | ||||
|             container.setVisible(False) | ||||
|  | ||||
| class FileSelectedSignal(QObject): | ||||
|     file_selected = Signal(str)  # Сигнал с путем к выбранному файлу | ||||
|  | ||||
| @@ -185,6 +309,7 @@ class FileExplorer(QDialog): | ||||
|         self.initial_path = initial_path  # Store initial path if provided | ||||
|         self.thumbnail_cache = {}  # Cache for loaded thumbnails | ||||
|         self.pending_thumbnails = set()  # Track files pending thumbnail loading | ||||
|         self.main_window = None  # Add reference to MainWindow | ||||
|         self.setup_ui() | ||||
|  | ||||
|         # Window settings | ||||
| @@ -198,6 +323,7 @@ class FileExplorer(QDialog): | ||||
|         while parent: | ||||
|             if hasattr(parent, 'input_manager'): | ||||
|                 self.input_manager = cast("MainWindow", parent).input_manager | ||||
|                 self.main_window = parent | ||||
|             if hasattr(parent, 'context_menu_manager'): | ||||
|                 self.context_menu_manager = cast("MainWindow", parent).context_menu_manager | ||||
|             parent = parent.parent() | ||||
| @@ -214,6 +340,17 @@ class FileExplorer(QDialog): | ||||
|             self.current_path = os.path.expanduser("~")  # Fallback to home if initial path is invalid | ||||
|         self.update_file_list() | ||||
|  | ||||
|         # Create hints widget using common function | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
|         self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='file_explorer') | ||||
|         self.main_layout.addWidget(self.hints_widget) | ||||
|  | ||||
|         # Connect signals | ||||
|         if self.input_manager: | ||||
|             self.input_manager.button_event.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)) | ||||
|             self.input_manager.dpad_moved.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)) | ||||
|             update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) | ||||
|  | ||||
|     class ThumbnailLoader(QRunnable): | ||||
|         """Class for asynchronous thumbnail loading in a separate thread.""" | ||||
|         class Signals(QObject): | ||||
| @@ -897,8 +1034,8 @@ class AddGameDialog(QDialog): | ||||
|         """Обработчик выбора файла в FileExplorer""" | ||||
|         self.exeEdit.setText(file_path) | ||||
|         self.last_exe_path = file_path  # Update last selected exe path | ||||
|         if not self.edit_mode: | ||||
|             # Автоматически заполняем имя игры, если не в режиме редактирования | ||||
|         if not self.edit_mode and not self.nameEdit.text().strip(): | ||||
|             # Автоматически заполняем имя игры, если не в режиме редактирования или если оно не введено вручную | ||||
|             game_name = os.path.splitext(os.path.basename(file_path))[0] | ||||
|             self.nameEdit.setText(game_name) | ||||
|  | ||||
| @@ -1037,8 +1174,6 @@ 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()) | ||||
| @@ -1071,6 +1206,36 @@ class WinetricksDialog(QDialog): | ||||
|         self.setup_ui() | ||||
|         self.load_lists() | ||||
|  | ||||
|         # Find input_manager and main_window | ||||
|         self.input_manager = None | ||||
|         self.main_window = None | ||||
|         parent = self.parent() | ||||
|         while parent: | ||||
|             if hasattr(parent, 'input_manager'): | ||||
|                 self.input_manager = cast("MainWindow", parent).input_manager | ||||
|                 self.main_window = parent | ||||
|             parent = parent.parent() | ||||
|  | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
|  | ||||
|         # Enable Winetricks-specific mode | ||||
|         if self.input_manager: | ||||
|             self.input_manager.enable_winetricks_mode(self) | ||||
|  | ||||
|         # Create hints widget using common function | ||||
|         self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='winetricks') | ||||
|         self.main_layout.addWidget(self.hints_widget) | ||||
|  | ||||
|         # Connect signals (use self.theme_manager) | ||||
|         if self.input_manager: | ||||
|             self.input_manager.button_event.connect( | ||||
|                 lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) | ||||
|             ) | ||||
|             self.input_manager.dpad_moved.connect( | ||||
|                 lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) | ||||
|             ) | ||||
|             update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name) | ||||
|  | ||||
|     def update_winetricks(self): | ||||
|         """Update the winetricks script.""" | ||||
|         if not self.downloader.has_internet(): | ||||
| @@ -1143,15 +1308,15 @@ class WinetricksDialog(QDialog): | ||||
|  | ||||
|     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) | ||||
|         self.main_layout = QVBoxLayout(self) | ||||
|         self.main_layout.setContentsMargins(10, 10, 10, 10) | ||||
|         self.main_layout.setSpacing(10) | ||||
|  | ||||
|         # Log output | ||||
|         self.log_output = QTextEdit() | ||||
|         self.log_output.setReadOnly(True) | ||||
|         self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE) | ||||
|         main_layout.addWidget(self.log_output) | ||||
|         self.main_layout.addWidget(self.log_output) | ||||
|  | ||||
|         # Tab widget | ||||
|         self.tab_widget = QTabWidget() | ||||
| @@ -1258,7 +1423,7 @@ class WinetricksDialog(QDialog): | ||||
|             "settings": self.settings_container | ||||
|         } | ||||
|  | ||||
|         main_layout.addWidget(self.tab_widget) | ||||
|         self.main_layout.addWidget(self.tab_widget) | ||||
|  | ||||
|         # Buttons | ||||
|         button_layout = QHBoxLayout() | ||||
| @@ -1272,7 +1437,7 @@ class WinetricksDialog(QDialog): | ||||
|         button_layout.addWidget(self.cancel_button) | ||||
|         button_layout.addWidget(self.force_button) | ||||
|         button_layout.addWidget(self.install_button) | ||||
|         main_layout.addLayout(button_layout) | ||||
|         self.main_layout.addLayout(button_layout) | ||||
|  | ||||
|         self.cancel_button.clicked.connect(self.reject) | ||||
|         self.force_button.clicked.connect(lambda: self.install_selected(force=True)) | ||||
| @@ -1497,3 +1662,15 @@ class WinetricksDialog(QDialog): | ||||
|         """Добавляет в лог.""" | ||||
|         self.log_output.append(message) | ||||
|         self.log_output.moveCursor(QTextCursor.MoveOperation.End) | ||||
|  | ||||
|     def closeEvent(self, event): | ||||
|         """Disable mode on close.""" | ||||
|         if self.input_manager: | ||||
|             self.input_manager.disable_winetricks_mode() | ||||
|         super().closeEvent(event) | ||||
|  | ||||
|     def reject(self): | ||||
|         """Disable mode on reject.""" | ||||
|         if self.input_manager: | ||||
|             self.input_manager.disable_winetricks_mode() | ||||
|         super().reject() | ||||
|   | ||||
| @@ -33,6 +33,7 @@ class MainWindowProtocol(Protocol): | ||||
|     # Required attributes | ||||
|     searchEdit: CustomLineEdit | ||||
|     _last_card_width: int | ||||
|     card_width: int | ||||
|     current_hovered_card: GameCard | None | ||||
|     current_focused_card: GameCard | None | ||||
|     gamesListWidget: QWidget | None | ||||
| @@ -128,6 +129,8 @@ class GameLibraryManager: | ||||
|         self.card_width = self.sizeSlider.value() | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         save_card_size(self.card_width) | ||||
|         self.main_window.card_width = self.card_width | ||||
|         self.main_window._last_card_width = self.card_width | ||||
|         for card in self.game_card_cache.values(): | ||||
|             card.update_card_size(self.card_width) | ||||
|         self.update_game_grid() | ||||
| @@ -217,6 +220,16 @@ class GameLibraryManager: | ||||
|         else: | ||||
|             self._update_game_grid_immediate() | ||||
|  | ||||
|     def force_update_cards_library(self): | ||||
|         if self.gamesListWidget and self.gamesListLayout: | ||||
|             self.gamesListLayout.invalidate() | ||||
|             self.gamesListWidget.updateGeometry() | ||||
|             widget = self.gamesListWidget | ||||
|             QTimer.singleShot(0, lambda: ( | ||||
|                 widget.adjustSize(), | ||||
|                 widget.updateGeometry() | ||||
|             )) | ||||
|  | ||||
|     def _update_game_grid_immediate(self): | ||||
|         """Updates the game grid with the provided or current game list.""" | ||||
|         if self.gamesListLayout is None or self.gamesListWidget is None: | ||||
| @@ -346,6 +359,8 @@ class GameLibraryManager: | ||||
|                 self.gamesListWidget.updateGeometry() | ||||
|                 self.main_window._last_card_width = self.card_width | ||||
|  | ||||
|                 self.force_update_cards_library() | ||||
|  | ||||
|         self.is_filtering = False  # Reset flag in any case | ||||
|  | ||||
|     def _apply_filter_visibility(self, search_text: str): | ||||
|   | ||||
| @@ -4,16 +4,16 @@ import os | ||||
| from typing import Protocol, cast | ||||
| from evdev import InputDevice, InputEvent, ecodes, list_devices, ff | ||||
| from enum import Enum | ||||
| from pyudev import Context, Monitor, MonitorObserver, Device | ||||
| from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView | ||||
| from pyudev import Context, Monitor, Device, Devices | ||||
| from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem | ||||
| from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer | ||||
| from PySide6.QtGui import QKeyEvent, QMouseEvent | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.image_utils import FullscreenDialog | ||||
| from portprotonqt.custom_widgets import NavLabel, AutoSizeButton | ||||
| from portprotonqt.game_card import GameCard | ||||
| from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config | ||||
| from portprotonqt.dialogs import AddGameDialog, WinetricksDialog | ||||
| from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type | ||||
| from portprotonqt.dialogs import AddGameDialog | ||||
| from portprotonqt.virtual_keyboard import VirtualKeyboard | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| @@ -38,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 | ||||
| @@ -75,6 +76,7 @@ class InputManager(QObject): | ||||
|     button_event = Signal(int, int)  # Signal for button events: (code, value) where value=1 (press), 0 (release) | ||||
|     dpad_moved = Signal(int, int, float)  # Signal for D-pad movements | ||||
|     toggle_fullscreen = Signal(bool)  # Signal for toggling fullscreen mode (True for fullscreen, False for normal) | ||||
|     gamepad_hotplug = Signal(str)  # 'add' or 'remove' | ||||
|  | ||||
|     def __init__( | ||||
|         self, | ||||
| @@ -86,11 +88,17 @@ class InputManager(QObject): | ||||
|         super().__init__(cast(QObject, main_window)) | ||||
|         self._parent = main_window | ||||
|         self._gamepad_handling_enabled = True | ||||
|         self.gamepad_type = GamepadType.UNKNOWN | ||||
|         # Ensure attributes exist on main_window | ||||
|         type_str = read_gamepad_type() | ||||
|         if type_str == "playstation": | ||||
|             self.gamepad_type = GamepadType.PLAYSTATION | ||||
|         elif type_str == "xbox": | ||||
|             self.gamepad_type = GamepadType.XBOX | ||||
|         else: | ||||
|             self.gamepad_type = GamepadType.UNKNOWN | ||||
|         self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None) | ||||
|         self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None) | ||||
|         self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None) | ||||
|         self._parent.autoInstallContainer = getattr(self._parent, 'autoInstallContainer', None) | ||||
|         self.axis_deadzone = axis_deadzone | ||||
|         self.initial_axis_move_delay = initial_axis_move_delay | ||||
|         self.repeat_axis_move_delay = repeat_axis_move_delay | ||||
| @@ -143,37 +151,131 @@ class InputManager(QObject): | ||||
|         # Initialize evdev + hotplug | ||||
|         self.init_gamepad() | ||||
|  | ||||
|     def detect_gamepad_type(self, device: InputDevice) -> GamepadType: | ||||
|         """ | ||||
|         Определяет тип геймпада по capabilities | ||||
|         """ | ||||
|         caps = device.capabilities() | ||||
|         keys = set(caps.get(ecodes.EV_KEY, [])) | ||||
|     def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None: | ||||
|         """Common navigation logic for game cards in a container.""" | ||||
|         if container is None: | ||||
|             return | ||||
|         focused = QApplication.focusWidget() | ||||
|         game_cards = container.findChildren(GameCard) | ||||
|         if not game_cards: | ||||
|             return | ||||
|  | ||||
|         # Для EV_ABS вытаскиваем только коды (первый элемент кортежа) | ||||
|         abs_axes = {a if isinstance(a, int) else a[0] for a in caps.get(ecodes.EV_ABS, [])} | ||||
|         scroll_area = container.parentWidget() | ||||
|         while scroll_area and not isinstance(scroll_area, QScrollArea): | ||||
|             scroll_area = scroll_area.parentWidget() | ||||
|  | ||||
|         # Xbox layout | ||||
|         if {ecodes.BTN_SOUTH, ecodes.BTN_EAST, ecodes.BTN_NORTH, ecodes.BTN_WEST}.issubset(keys): | ||||
|             if {ecodes.ABS_X, ecodes.ABS_Y, ecodes.ABS_RX, ecodes.ABS_RY}.issubset(abs_axes): | ||||
|                 self.gamepad_type = GamepadType.XBOX | ||||
|                 return GamepadType.XBOX | ||||
|         # If no focused widget or not a GameCard, focus the first card | ||||
|         if not isinstance(focused, GameCard) or focused not in game_cards: | ||||
|             game_cards[0].setFocus() | ||||
|             if scroll_area: | ||||
|                 scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) | ||||
|             return | ||||
|  | ||||
|         # PlayStation layout | ||||
|         if ecodes.BTN_TOUCH in keys or (ecodes.BTN_DPAD_UP in keys and ecodes.BTN_EAST in keys): | ||||
|             self.gamepad_type = GamepadType.PLAYSTATION | ||||
|             logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}") | ||||
|             return GamepadType.PLAYSTATION | ||||
|         cards = container.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively) | ||||
|         if not cards: | ||||
|             return | ||||
|         # Group cards by rows with tolerance for y-position | ||||
|         rows = {} | ||||
|         y_tolerance = 10  # Allow slight variations in y-position | ||||
|         for card in cards: | ||||
|             y = card.pos().y() | ||||
|             matched = False | ||||
|             for row_y in rows: | ||||
|                 if abs(y - row_y) <= y_tolerance: | ||||
|                     rows[row_y].append(card) | ||||
|                     matched = True | ||||
|                     break | ||||
|             if not matched: | ||||
|                 rows[y] = [card] | ||||
|         sorted_rows = sorted(rows.items(), key=lambda x: x[0]) | ||||
|         if not sorted_rows: | ||||
|             return | ||||
|         current_row_idx = None | ||||
|         current_col_idx = None | ||||
|         for row_idx, (_y, row_cards) in enumerate(sorted_rows): | ||||
|             for idx, card in enumerate(row_cards): | ||||
|                 if card == focused: | ||||
|                     current_row_idx = row_idx | ||||
|                     current_col_idx = idx | ||||
|                     break | ||||
|             if current_row_idx is not None: | ||||
|                 break | ||||
|  | ||||
|         # Steam Controller / Deck (трекпады) | ||||
|         if any(a for a in abs_axes if a >= ecodes.ABS_MT_SLOT): | ||||
|             self.gamepad_type = GamepadType.XBOX | ||||
|             logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}") | ||||
|             return GamepadType.XBOX | ||||
|         # Fallback: if focused card not found, select closest row by y-position | ||||
|         if current_row_idx is None: | ||||
|             if not sorted_rows:  # Additional safety check | ||||
|                 return | ||||
|             focused_y = focused.pos().y() | ||||
|             current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y)) | ||||
|             if current_row_idx >= len(sorted_rows):  # Safety check | ||||
|                 return | ||||
|             current_row = sorted_rows[current_row_idx][1] | ||||
|             focused_x = focused.pos().x() + focused.width() / 2 | ||||
|             current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0)  # type: ignore | ||||
|  | ||||
|         # Fallback | ||||
|         self.gamepad_type = GamepadType.XBOX | ||||
|         return GamepadType.XBOX | ||||
|         # Add null checks before using current_row_idx and current_col_idx | ||||
|         if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows): | ||||
|             return | ||||
|  | ||||
|         current_row = sorted_rows[current_row_idx][1] | ||||
|         if code == ecodes.ABS_HAT0X and value != 0: | ||||
|             if value < 0:  # Left | ||||
|                 if current_col_idx > 0: | ||||
|                     next_card = current_row[current_col_idx - 1] | ||||
|                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 else: | ||||
|                     if current_row_idx > 0: | ||||
|                         prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                         next_card = prev_row[-1] if prev_row else None | ||||
|                         if next_card: | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|             elif value > 0:  # Right | ||||
|                 if current_col_idx < len(current_row) - 1: | ||||
|                     next_card = current_row[current_col_idx + 1] | ||||
|                     next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     if scroll_area: | ||||
|                         scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 else: | ||||
|                     if current_row_idx < len(sorted_rows) - 1: | ||||
|                         next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                         next_card = next_row[0] if next_row else None | ||||
|                         if next_card: | ||||
|                             next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                             if scroll_area: | ||||
|                                 scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|         elif code == ecodes.ABS_HAT0Y and value != 0: | ||||
|             if value > 0:  # Down | ||||
|                 if current_row_idx < len(sorted_rows) - 1: | ||||
|                     next_row = sorted_rows[current_row_idx + 1][1] | ||||
|                     current_x = focused.pos().x() + focused.width() / 2 | ||||
|                     next_card = min( | ||||
|                         next_row, | ||||
|                         key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                         default=None | ||||
|                     ) | ||||
|                     if next_card: | ||||
|                         next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         if scroll_area: | ||||
|                             scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|             elif value < 0:  # Up | ||||
|                 if current_row_idx > 0: | ||||
|                     prev_row = sorted_rows[current_row_idx - 1][1] | ||||
|                     current_x = focused.pos().x() + focused.width() / 2 | ||||
|                     next_card = min( | ||||
|                         prev_row, | ||||
|                         key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x), | ||||
|                         default=None | ||||
|                     ) | ||||
|                     if next_card: | ||||
|                         next_card.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                         if scroll_area: | ||||
|                             scroll_area.ensureWidgetVisible(next_card, 50, 50) | ||||
|                 elif current_row_idx == 0: | ||||
|                     self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|     def enable_file_explorer_mode(self, file_explorer): | ||||
|         """Настройка обработки геймпада для FileExplorer""" | ||||
| @@ -354,6 +456,171 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error("Error in FileExplorer dpad handler: %s", e) | ||||
|  | ||||
|     def enable_winetricks_mode(self, winetricks_dialog): | ||||
|         """Setup gamepad handling for WinetricksDialog""" | ||||
|         try: | ||||
|             self.winetricks_dialog = winetricks_dialog | ||||
|             self.original_button_handler = self.handle_button_slot | ||||
|             self.original_dpad_handler = self.handle_dpad_slot | ||||
|             self.original_gamepad_state = self._gamepad_handling_enabled | ||||
|             self.handle_button_slot = self.handle_winetricks_button | ||||
|             self.handle_dpad_slot = self.handle_winetricks_dpad | ||||
|             self._gamepad_handling_enabled = True | ||||
|             # Reset dpad timer for table nav | ||||
|             self.dpad_timer.stop() | ||||
|             self.current_dpad_code = None | ||||
|             self.current_dpad_value = 0 | ||||
|             logger.debug("Gamepad handling successfully connected for WinetricksDialog") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error connecting gamepad handlers for Winetricks: {e}") | ||||
|  | ||||
|     def disable_winetricks_mode(self): | ||||
|         """Restore original main window handlers""" | ||||
|         try: | ||||
|             if self.winetricks_dialog: | ||||
|                 self.handle_button_slot = self.original_button_handler | ||||
|                 self.handle_dpad_slot = self.original_dpad_handler | ||||
|                 self._gamepad_handling_enabled = self.original_gamepad_state | ||||
|                 self.winetricks_dialog = None | ||||
|                 self.dpad_timer.stop() | ||||
|                 self.current_dpad_code = None | ||||
|                 self.current_dpad_value = 0 | ||||
|                 logger.debug("Gamepad handling successfully restored from Winetricks") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error restoring gamepad handlers from Winetricks: {e}") | ||||
|  | ||||
|     def handle_winetricks_button(self, button_code, value): | ||||
|         if self.winetricks_dialog is None: | ||||
|             return | ||||
|         if value == 0:  # Ignore releases | ||||
|             return | ||||
|         try: | ||||
|             # Always check for popups first, including QMessageBox | ||||
|             popup = QApplication.activePopupWidget() | ||||
|             if popup: | ||||
|                 if isinstance(popup, QMessageBox): | ||||
|                     if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']: | ||||
|                         popup.accept()  # Close QMessageBox with A or B | ||||
|                         return | ||||
|                 elif isinstance(popup, QMenu): | ||||
|                     if button_code in BUTTONS['confirm']:  # A: Select menu item | ||||
|                         focused = popup.activeAction() | ||||
|                         if focused: | ||||
|                             focused.trigger() | ||||
|                         return | ||||
|                     elif button_code in BUTTONS['back']:  # B: Close menu | ||||
|                         popup.close() | ||||
|                         return | ||||
|  | ||||
|             # Additional check for top-level QMessageBox (in case not active popup yet) | ||||
|             for widget in QApplication.topLevelWidgets(): | ||||
|                 if isinstance(widget, QMessageBox) and widget.isVisible(): | ||||
|                     if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']: | ||||
|                         widget.accept() | ||||
|                         return | ||||
|  | ||||
|             focused = QApplication.focusWidget() | ||||
|             if button_code in BUTTONS['confirm']:  # A: Toggle checkbox | ||||
|                 if isinstance(focused, QTableWidget): | ||||
|                     current_row = focused.currentRow() | ||||
|                     if current_row >= 0: | ||||
|                         checkbox_item = focused.item(current_row, 0) | ||||
|                         if checkbox_item and isinstance(checkbox_item, QTableWidgetItem): | ||||
|                             new_state = Qt.CheckState.Checked if checkbox_item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked | ||||
|                             checkbox_item.setCheckState(new_state) | ||||
|                 return | ||||
|             elif button_code in BUTTONS['add_game']:  # X: Install (no force) | ||||
|                 self.winetricks_dialog.install_selected(force=False) | ||||
|                 return | ||||
|             elif button_code in BUTTONS['prev_dir']:  # Y: Force Install | ||||
|                 self.winetricks_dialog.install_selected(force=True) | ||||
|                 return | ||||
|             elif button_code in BUTTONS['back']:  # B: Cancel | ||||
|                 self.winetricks_dialog.reject() | ||||
|                 return | ||||
|             elif button_code in BUTTONS['prev_tab']:  # LB: Prev Tab | ||||
|                 current_index = self.winetricks_dialog.tab_widget.currentIndex() | ||||
|                 new_index = max(0, current_index - 1) | ||||
|                 self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) | ||||
|                 self._focus_first_row_in_current_table() | ||||
|                 return | ||||
|             elif button_code in BUTTONS['next_tab']:  # RB: Next Tab | ||||
|                 current_index = self.winetricks_dialog.tab_widget.currentIndex() | ||||
|                 new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1) | ||||
|                 self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) | ||||
|                 self._focus_first_row_in_current_table() | ||||
|                 return | ||||
|             # Fallback: Activate focused widget (e.g., buttons) | ||||
|             self._parent.activateFocusedWidget() | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in handle_winetricks_button: {e}") | ||||
|  | ||||
|     def handle_winetricks_dpad(self, code, value, now): | ||||
|         if self.winetricks_dialog is None: | ||||
|             return | ||||
|         try: | ||||
|             if value == 0:  # Release: Stop repeat | ||||
|                 self.dpad_timer.stop() | ||||
|                 self.current_dpad_code = None | ||||
|                 self.current_dpad_value = 0 | ||||
|                 return | ||||
|  | ||||
|             # Start/update repeat timer for hold navigation | ||||
|             if self.current_dpad_code != code or self.current_dpad_value != value: | ||||
|                 self.dpad_timer.stop() | ||||
|                 self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300)  # Initial slower, then faster repeat | ||||
|                 self.dpad_timer.start() | ||||
|                 self.current_dpad_code = code | ||||
|                 self.current_dpad_value = value | ||||
|  | ||||
|             table = self._get_current_table() | ||||
|             if not table or table.rowCount() == 0: | ||||
|                 return | ||||
|  | ||||
|             current_row = table.currentRow() | ||||
|             if code == ecodes.ABS_HAT0Y:  # Up/Down: Navigate rows | ||||
|                 if value < 0:  # Up | ||||
|                     new_row = max(0, current_row - 1) | ||||
|                 elif value > 0:  # Down | ||||
|                     new_row = min(table.rowCount() - 1, current_row + 1) | ||||
|                 else: | ||||
|                     return | ||||
|                 if new_row != current_row: | ||||
|                     table.setCurrentCell(new_row, 0)  # Focus checkbox column | ||||
|                     table.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|             elif code == ecodes.ABS_HAT0X:  # Left/Right: Switch tabs | ||||
|                 if value < 0:  # Left: Prev tab | ||||
|                     current_index = self.winetricks_dialog.tab_widget.currentIndex() | ||||
|                     new_index = max(0, current_index - 1) | ||||
|                     self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) | ||||
|                 elif value > 0:  # Right: Next tab | ||||
|                     current_index = self.winetricks_dialog.tab_widget.currentIndex() | ||||
|                     new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1) | ||||
|                     self.winetricks_dialog.tab_widget.setCurrentIndex(new_index) | ||||
|                 self._focus_first_row_in_current_table() | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in handle_winetricks_dpad: {e}") | ||||
|  | ||||
|     def _get_current_table(self): | ||||
|         """Get the current visible table from the tab widget's stacked container.""" | ||||
|         if self.winetricks_dialog is None: | ||||
|             return None | ||||
|         current_container = self.winetricks_dialog.tab_widget.currentWidget() | ||||
|         if current_container and isinstance(current_container, QStackedWidget): | ||||
|             current_table = current_container.widget(1)  # Table is at index 1 (after preloader) | ||||
|             if isinstance(current_table, QTableWidget): | ||||
|                 return current_table | ||||
|         return None | ||||
|  | ||||
|     def _focus_first_row_in_current_table(self): | ||||
|         """Focus the first row in the current table after tab switch.""" | ||||
|         if self.winetricks_dialog is None: | ||||
|             return | ||||
|         table = self._get_current_table() | ||||
|         if table and table.rowCount() > 0: | ||||
|             table.setCurrentCell(0, 0) | ||||
|             table.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|     def handle_navigation_repeat(self): | ||||
|         """Плавное повторение движения с переменной скоростью для FileExplorer""" | ||||
|         try: | ||||
| @@ -483,17 +750,27 @@ 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 = getattr(self._parent, 'searchEdit', None) | ||||
|                 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 | ||||
|             # Handle Y button to focus search | ||||
|             if button_code in BUTTONS['prev_dir']:  # Y button | ||||
|                 search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 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 | ||||
| @@ -594,39 +871,6 @@ 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() | ||||
| @@ -757,32 +1001,6 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             # Новый код: обработка перехода на поле поиска | ||||
|             if code == ecodes.ABS_HAT0Y and value < 0:  # Only D-pad up | ||||
|                 if isinstance(focused, GameCard): | ||||
|                     # Get all visible game cards | ||||
|                     game_cards = self._parent.gamesListWidget.findChildren(GameCard) | ||||
|                     if not game_cards: | ||||
|                         return | ||||
|  | ||||
|                     # Find the current card's position | ||||
|                     current_card_pos = focused.pos() | ||||
|                     current_row_y = current_card_pos.y() | ||||
|  | ||||
|                     # Check if this is the first row (no cards above) | ||||
|                     is_first_row = True | ||||
|                     for card in game_cards: | ||||
|                         if card.pos().y() < current_row_y and card.isVisible(): | ||||
|                             is_first_row = False | ||||
|                             break | ||||
|  | ||||
|                     # Only move to search if on first row | ||||
|                     if is_first_row: | ||||
|                         search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                         if search_edit: | ||||
|                             search_edit.setFocus() | ||||
|                             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 | ||||
| @@ -898,132 +1116,43 @@ class InputManager(QObject): | ||||
|                     focused.setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|                     return | ||||
|  | ||||
|             # Library tab navigation (index 0) | ||||
|             if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): | ||||
|             # Search focus logic for tabs 0 and 1 | ||||
|             if code == ecodes.ABS_HAT0Y and value < 0: | ||||
|                 focused = QApplication.focusWidget() | ||||
|                 game_cards = self._parent.gamesListWidget.findChildren(GameCard) | ||||
|                 if not game_cards: | ||||
|                     return | ||||
|                 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 | ||||
|  | ||||
|                 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 | ||||
|             # 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 | ||||
|                     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): | ||||
|                     self._navigate_game_cards(container, current_index, code, value) | ||||
|                     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[0].setFocus(Qt.FocusReason.OtherFocusReason) | ||||
|  | ||||
|             # 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 | ||||
| @@ -1308,76 +1437,258 @@ class InputManager(QObject): | ||||
|         return super().eventFilter(obj, event) | ||||
|  | ||||
|     def init_gamepad(self) -> None: | ||||
|         self.udev_context = Context() | ||||
|         self.Devices = Devices | ||||
|         self.monitor_ready = False | ||||
|  | ||||
|         # Подключаем сигнал hotplug к обработчику в главном потоке | ||||
|         self.gamepad_hotplug.connect(self._on_gamepad_hotplug) | ||||
|  | ||||
|         # Debounce timer для отложенной проверки геймпада (в главном потоке Qt) | ||||
|         self.gamepad_check_timer = QTimer() | ||||
|         self.gamepad_check_timer.setSingleShot(True) | ||||
|         self.gamepad_check_timer.timeout.connect(self.check_gamepad) | ||||
|  | ||||
|         # Первоначальная проверка | ||||
|         self.check_gamepad() | ||||
|  | ||||
|         # Запускаем udev monitor в отдельном потоке | ||||
|         threading.Thread(target=self.run_udev_monitor, daemon=True).start() | ||||
|         logger.info("Gamepad support initialized with hotplug (evdev + pyudev)") | ||||
|  | ||||
|     def run_udev_monitor(self) -> None: | ||||
|         """ | ||||
|         Безопасный неблокирующий udev monitor для геймпадов. | ||||
|         Использует select.poll() вместо блокирующего monitor.poll(). | ||||
|         """ | ||||
|         try: | ||||
|             context = Context() | ||||
|             monitor = Monitor.from_netlink(context) | ||||
|             logger.info("Starting udev monitor...") | ||||
|             monitor = Monitor.from_netlink(self.udev_context) | ||||
|             monitor.filter_by(subsystem='input') | ||||
|             observer = MonitorObserver(monitor, self.handle_udev_event) | ||||
|             observer.start() | ||||
|  | ||||
|             try: | ||||
|                 monitor.start() | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to start udev monitor: {e}") | ||||
|                 return | ||||
|  | ||||
|             import select | ||||
|             fd = monitor.fileno() | ||||
|             poller = select.poll() | ||||
|             poller.register(fd, select.POLLIN) | ||||
|  | ||||
|             # Короткий дренаж событий при запуске (0.5 сек) | ||||
|             drain_start = time.time() | ||||
|             drained_count = 0 | ||||
|             while time.time() - drain_start < 0.5: | ||||
|                 events = poller.poll(100) | ||||
|                 if not events: | ||||
|                     continue | ||||
|                 try: | ||||
|                     _ = monitor.poll(timeout=0)  # просто читаем, не обрабатываем | ||||
|                     drained_count += 1 | ||||
|                 except Exception: | ||||
|                     break | ||||
|  | ||||
|             self.monitor_ready = True | ||||
|             logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...") | ||||
|  | ||||
|             # Основной цикл | ||||
|             while self.running: | ||||
|                 time.sleep(1) | ||||
|                 events = poller.poll(1000)  # 1 сек таймаут | ||||
|                 if not events: | ||||
|                     continue  # просто ждём, не блокируем | ||||
|  | ||||
|                 try: | ||||
|                     device = monitor.poll(timeout=0) | ||||
|                 except Exception as e: | ||||
|                     logger.debug(f"Monitor poll failed: {e}") | ||||
|                     continue | ||||
|  | ||||
|                 if not device: | ||||
|                     continue | ||||
|  | ||||
|                 action = device.action | ||||
|                 if action and self._is_joystick_device(device): | ||||
|                     logger.info(f"Joystick hotplug event: {action} for {device.sys_name}") | ||||
|                     # отправляем сигнал в Qt-поток | ||||
|                     self.handle_udev_event(action, device) | ||||
|  | ||||
|             logger.info("udev monitor stopped gracefully") | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in udev monitor: {e}", exc_info=True) | ||||
|  | ||||
|     def _is_joystick_device(self, device: Device) -> bool: | ||||
|         """ | ||||
|         Быстрая проверка: является ли устройство джойстиком. | ||||
|         Проверяет ID_INPUT_JOYSTICK из udev базы данных. | ||||
|         """ | ||||
|         try: | ||||
|             # Проверяем свойство ID_INPUT_JOYSTICK | ||||
|             if device.get('ID_INPUT_JOYSTICK') == '1': | ||||
|                 return True | ||||
|  | ||||
|             # Дополнительно: проверяем родительские устройства | ||||
|             # (некоторые контроллеры имеют свойство только у родителя) | ||||
|             parent = device.parent | ||||
|             if parent and parent.get('ID_INPUT_JOYSTICK') == '1': | ||||
|                 return True | ||||
|  | ||||
|             return False | ||||
|         except Exception as e: | ||||
|             logger.debug(f"Error checking joystick device: {e}") | ||||
|             return False | ||||
|  | ||||
|  | ||||
|     def handle_udev_event(self, action: str, device: Device) -> None: | ||||
|         """ | ||||
|         Обработчик udev событий для джойстиков. | ||||
|         Отправляет сигнал в главный поток Qt вместо прямого вызова QTimer. | ||||
|         """ | ||||
|         try: | ||||
|             if action == 'add': | ||||
|                 time.sleep(0.1) | ||||
|                 self.check_gamepad() | ||||
|                 # Отправляем сигнал в главный поток Qt | ||||
|                 # QTimer будет запущен там безопасно | ||||
|                 logger.debug("Emitting gamepad add signal") | ||||
|                 self.gamepad_hotplug.emit('add') | ||||
|  | ||||
|             elif action == 'remove' and self.gamepad: | ||||
|                 if not any(self.gamepad.path == path for path in list_devices()): | ||||
|                     logger.info("Gamepad disconnected") | ||||
|                     self.stop_rumble() | ||||
|                     self.gamepad = None | ||||
|                     if self.gamepad_thread: | ||||
|                         self.gamepad_thread.join() | ||||
|                     # Signal to exit fullscreen mode | ||||
|                     self.toggle_fullscreen.emit(False) | ||||
|                 # Проверяем конкретно наш геймпад по пути устройства | ||||
|                 device_node = device.device_node  # например, /dev/input/event3 | ||||
|  | ||||
|                 if device_node and self.gamepad.path == device_node: | ||||
|                     logger.info(f"Connected gamepad disconnected: {device_node}") | ||||
|                     # Отправляем сигнал в главный поток | ||||
|                     self.gamepad_hotplug.emit('remove') | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error handling udev event: {e}", exc_info=True) | ||||
|  | ||||
|  | ||||
|     def _on_gamepad_hotplug(self, action: str) -> None: | ||||
|         """ | ||||
|         Обработчик сигнала hotplug, выполняется в главном потоке Qt. | ||||
|         Безопасно работает с QTimer. | ||||
|         """ | ||||
|         try: | ||||
|             if action == 'add': | ||||
|                 # Debounce: откладываем проверку на 200ms | ||||
|                 # Множественные события за короткое время объединяются в один вызов | ||||
|                 logger.debug("Scheduling gamepad check (debounced)") | ||||
|                 self.gamepad_check_timer.start(200) | ||||
|  | ||||
|             elif action == 'remove': | ||||
|                 # Немедленная обработка отключения | ||||
|                 self.stop_rumble() | ||||
|                 self.gamepad = None | ||||
|  | ||||
|                 if self.gamepad_thread: | ||||
|                     self.gamepad_thread.join(timeout=2.0) | ||||
|  | ||||
|                 if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                     self.toggle_fullscreen.emit(False) | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in hotplug handler: {e}", exc_info=True) | ||||
|  | ||||
|  | ||||
|     def check_gamepad(self) -> None: | ||||
|         """ | ||||
|         Проверка и подключение геймпада. | ||||
|         Вызывается из главного потока Qt через QTimer (debounced). | ||||
|         """ | ||||
|         try: | ||||
|             new_gamepad = self.find_gamepad() | ||||
|             if new_gamepad and new_gamepad != self.gamepad: | ||||
|                 logger.info(f"Gamepad connected: {new_gamepad.name}") | ||||
|                 self.detect_gamepad_type(new_gamepad) | ||||
|                 logger.info(f"Detected gamepad type: {self.gamepad_type.value}") | ||||
|  | ||||
|             # Проверяем, действительно ли это новый геймпад | ||||
|             if new_gamepad: | ||||
|                 if not self.gamepad or new_gamepad.path != self.gamepad.path: | ||||
|                     logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}") | ||||
|                     self.stop_rumble() | ||||
|                     self.gamepad = new_gamepad | ||||
|  | ||||
|                     if self.gamepad_thread: | ||||
|                         self.gamepad_thread.join(timeout=2.0) | ||||
|  | ||||
|                     self.gamepad_thread = threading.Thread( | ||||
|                         target=self.monitor_gamepad, | ||||
|                         daemon=True | ||||
|                     ) | ||||
|                     self.gamepad_thread.start() | ||||
|  | ||||
|                     # Автоматический фуллскрин при подключении геймпада | ||||
|                     if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                         self.toggle_fullscreen.emit(True) | ||||
|  | ||||
|             elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()): | ||||
|                 # Геймпад был подключён, но теперь его нет в системе | ||||
|                 logger.info("Gamepad no longer detected") | ||||
|                 self.stop_rumble() | ||||
|                 self.gamepad = new_gamepad | ||||
|                 self.gamepad = None | ||||
|  | ||||
|                 if self.gamepad_thread: | ||||
|                     self.gamepad_thread.join() | ||||
|                 self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True) | ||||
|                 self.gamepad_thread.start() | ||||
|                 # Send signal for fullscreen mode only if: | ||||
|                 # 1. auto_fullscreen_gamepad is enabled | ||||
|                 # 2. fullscreen is not already enabled (to avoid conflict) | ||||
|                     self.gamepad_thread.join(timeout=2.0) | ||||
|  | ||||
|                 if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): | ||||
|                     self.toggle_fullscreen.emit(True) | ||||
|                     self.toggle_fullscreen.emit(False) | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error checking gamepad: {e}", exc_info=True) | ||||
|  | ||||
|  | ||||
|     def find_gamepad(self) -> InputDevice | None: | ||||
|         """ | ||||
|         Находит первый доступный геймпад. | ||||
|         Оптимизирован: предварительная фильтрация по capabilities перед udev-запросами. | ||||
|         """ | ||||
|         try: | ||||
|             devices = [InputDevice(path) for path in list_devices()] | ||||
|  | ||||
|             if not devices: | ||||
|                 return None | ||||
|  | ||||
|             logger.debug(f"Checking {len(devices)} devices for gamepad...") | ||||
|  | ||||
|             for device in devices: | ||||
|                 # Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2) | ||||
|                 # Skip ASRock LED controller (известная проблема) | ||||
|                 if device.info.vendor == 0x26ce and device.info.product == 0x01a2: | ||||
|                     logger.debug(f"Skipping ASRock LED controller: {device.name}") | ||||
|                     continue | ||||
|                 caps = device.capabilities() | ||||
|                 if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps: | ||||
|                     return device | ||||
|  | ||||
|                 # Предварительная фильтрация: проверяем capabilities | ||||
|                 # Джойстик должен иметь хотя бы оси (ABS) или кнопки (KEY) | ||||
|                 # Это избегает udev-запросов для явно не-джойстиков | ||||
|                 caps = device.capabilities(verbose=False) | ||||
|                 has_abs_axes = ecodes.EV_ABS in caps | ||||
|                 has_buttons = ecodes.EV_KEY in caps | ||||
|  | ||||
|                 if not (has_abs_axes or has_buttons): | ||||
|                     continue | ||||
|  | ||||
|                 # Только для потенциальных джойстиков делаем udev-запрос | ||||
|                 try: | ||||
|                     udev_device = self.Devices.from_device_file( | ||||
|                         self.udev_context, | ||||
|                         device.path | ||||
|                     ) | ||||
|                     is_joystick = udev_device.get('ID_INPUT_JOYSTICK') | ||||
|  | ||||
|                     if is_joystick == '1': | ||||
|                         logger.info(f"Found gamepad: {device.name}") | ||||
|                         return device | ||||
|  | ||||
|                 except Exception as e: | ||||
|                     logger.debug(f"Could not check udev properties for {device.path}: {e}") | ||||
|                     continue | ||||
|  | ||||
|             logger.debug("No gamepad found") | ||||
|             return None | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error finding gamepad: {e}", exc_info=True) | ||||
|             return None | ||||
|  | ||||
|  | ||||
|     def monitor_gamepad(self) -> None: | ||||
|         try: | ||||
|             if not self.gamepad: | ||||
| @@ -1441,16 +1752,32 @@ class InputManager(QObject): | ||||
|             self.gamepad = None | ||||
|  | ||||
|     def cleanup(self) -> None: | ||||
|         """ | ||||
|         Корректное завершение работы с геймпадом и udev монитором. | ||||
|         """ | ||||
|         try: | ||||
|             # Флаг для остановки udev monitor loop | ||||
|             self.running = False | ||||
|  | ||||
|             # Останавливаем все таймеры | ||||
|             if hasattr(self, 'gamepad_check_timer'): | ||||
|                 self.gamepad_check_timer.stop() | ||||
|             self.dpad_timer.stop() | ||||
|             self.nav_timer.stop() | ||||
|  | ||||
|             # Очистка геймпада | ||||
|             self.stop_rumble() | ||||
|  | ||||
|             if self.gamepad_thread: | ||||
|                 self.gamepad_thread.join() | ||||
|                 self.gamepad_thread.join(timeout=2.0) | ||||
|  | ||||
|             if self.gamepad: | ||||
|                 self.gamepad.close() | ||||
|  | ||||
|             self.gamepad = None | ||||
|             self.gamepad_type = GamepadType.UNKNOWN | ||||
|  | ||||
|             logger.info("Gamepad cleanup completed") | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error during cleanup: {e}", exc_info=True) | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: de_DE\n" | ||||
| @@ -252,13 +252,37 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgid "Open" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Toggle" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Tab" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Next Tab" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -326,12 +350,6 @@ msgstr "" | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -395,9 +413,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -416,6 +431,25 @@ 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 "" | ||||
|  | ||||
| @@ -432,12 +466,6 @@ msgstr "" | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -450,12 +478,6 @@ msgstr "" | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Control Panel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Task Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -477,6 +499,29 @@ 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 "" | ||||
|  | ||||
| @@ -552,6 +597,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -576,6 +624,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: es_ES\n" | ||||
| @@ -252,13 +252,37 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgid "Open" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Toggle" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Tab" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Next Tab" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -326,12 +350,6 @@ msgstr "" | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -395,9 +413,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -416,6 +431,25 @@ 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 "" | ||||
|  | ||||
| @@ -432,12 +466,6 @@ msgstr "" | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -450,12 +478,6 @@ msgstr "" | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Control Panel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Task Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -477,6 +499,29 @@ 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 "" | ||||
|  | ||||
| @@ -552,6 +597,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -576,6 +624,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PortProtonQt 0.1.1\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | ||||
| @@ -250,13 +250,37 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgid "Open" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Dir" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Toggle" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prev Tab" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Next Tab" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -324,12 +348,6 @@ msgstr "" | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -393,9 +411,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -414,6 +429,25 @@ 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 "" | ||||
|  | ||||
| @@ -430,12 +464,6 @@ msgstr "" | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -448,12 +476,6 @@ msgstr "" | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Control Panel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Task Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -475,6 +497,29 @@ 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 "" | ||||
|  | ||||
| @@ -550,6 +595,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -574,6 +622,12 @@ msgstr "" | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,8 +9,8 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "PO-Revision-Date: 2025-10-09 16:37+0500\n" | ||||
| "POT-Creation-Date: 2025-10-16 14:54+0500\n" | ||||
| "PO-Revision-Date: 2025-10-16 14:54+0500\n" | ||||
| "Last-Translator: \n" | ||||
| "Language: ru_RU\n" | ||||
| "Language-Team: ru_RU <LL@li.org>\n" | ||||
| @@ -259,13 +259,37 @@ msgstr "Удалить" | ||||
| msgid "Select All" | ||||
| msgstr "Выбрать всё" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "Идёт запуск {0}" | ||||
| msgid "Open" | ||||
| msgstr "Открыть" | ||||
|  | ||||
| msgid "Select Dir" | ||||
| msgstr "Выбрать папку" | ||||
|  | ||||
| msgid "Prev Dir" | ||||
| msgstr "Предыдущий каталог" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "Отмена" | ||||
|  | ||||
| msgid "Toggle" | ||||
| msgstr "Переключить" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "Установить" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "Принудительно установить" | ||||
|  | ||||
| msgid "Prev Tab" | ||||
| msgstr "Предыдущая вкладка" | ||||
|  | ||||
| msgid "Next Tab" | ||||
| msgstr "Следующая вкладка" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "Идёт запуск {0}" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "Проводник" | ||||
|  | ||||
| @@ -333,12 +357,6 @@ msgstr "Шрифты" | ||||
| msgid "Settings" | ||||
| msgstr "Настройки" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "Принудительно установить" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "Установить" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "Winetricks не найден. Повторите попытку." | ||||
|  | ||||
| @@ -402,9 +420,6 @@ msgstr "Библиотека" | ||||
| msgid "Auto Install" | ||||
| msgstr "Автоустановка" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "Эмуляторы" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "Настройки wine" | ||||
|  | ||||
| @@ -423,6 +438,25 @@ 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..." | ||||
|  | ||||
| @@ -439,12 +473,6 @@ msgstr "Найти игры..." | ||||
| msgid "Added '{name}'" | ||||
| msgstr "'{name}' добавлен(а)" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "Здесь можно настроить автоматическую установку игр..." | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "Список доступных эмуляторов и их настройка..." | ||||
|  | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "Инструмент совместимости:" | ||||
|  | ||||
| @@ -457,12 +485,6 @@ msgstr "Конфигурация Wine" | ||||
| msgid "Registry Editor" | ||||
| msgstr "Редактор реестра" | ||||
|  | ||||
| msgid "Control Panel" | ||||
| msgstr "Панель управления" | ||||
|  | ||||
| msgid "Task Manager" | ||||
| msgstr "Диспетчер задач" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "Командная строка" | ||||
|  | ||||
| @@ -484,6 +506,31 @@ 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 "Не удалось запустить процесс резервного копирования." | ||||
|  | ||||
| @@ -559,6 +606,9 @@ msgstr "все" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "Фильтр игр:" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "Тип геймпада:" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "Адрес прокси" | ||||
|  | ||||
| @@ -583,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи | ||||
| msgid "Application Fullscreen Mode:" | ||||
| msgstr "Режим полноэкранного отображения приложения:" | ||||
|  | ||||
| msgid "Minimize to tray on close" | ||||
| msgstr "Сворачивать в трей при закрытии" | ||||
|  | ||||
| msgid "Application Close Mode:" | ||||
| msgstr "Режим закрытия приложения:" | ||||
|  | ||||
| msgid "Auto Fullscreen on Gamepad connected" | ||||
| msgstr "Режим полноэкранного отображения приложения при подключении геймпада" | ||||
|  | ||||
|   | ||||
| @@ -5,12 +5,13 @@ import signal | ||||
| import subprocess | ||||
| import sys | ||||
| import psutil | ||||
| import re | ||||
|  | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog | ||||
| from portprotonqt.game_card import GameCard | ||||
| from portprotonqt.animations import DetailPageAnimations | ||||
| from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel | ||||
| from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout | ||||
| from portprotonqt.portproton_api import PortProtonAPI | ||||
| from portprotonqt.input_manager import InputManager | ||||
| from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit | ||||
| @@ -28,7 +29,8 @@ from portprotonqt.config_utils import ( | ||||
|     read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method, | ||||
|     save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config, | ||||
|     save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config, | ||||
|     clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config | ||||
|     clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray, | ||||
|     read_auto_card_size, save_auto_card_size | ||||
| ) | ||||
| from portprotonqt.localization import _, get_egs_language, read_metadata_translations | ||||
| from portprotonqt.howlongtobeat_api import HowLongToBeat | ||||
| @@ -38,7 +40,7 @@ from portprotonqt.game_library_manager import GameLibraryManager | ||||
| from portprotonqt.virtual_keyboard import VirtualKeyboard | ||||
|  | ||||
| from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, | ||||
|                                QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout) | ||||
|                                QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider) | ||||
| from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess | ||||
| from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices | ||||
| from typing import cast | ||||
| @@ -53,7 +55,7 @@ class MainWindow(QMainWindow): | ||||
|     update_progress = Signal(int) | ||||
|     update_status_message = Signal(str, int) | ||||
|  | ||||
|     def __init__(self, app_name: str): | ||||
|     def __init__(self, app_name: str, version: str): | ||||
|         super().__init__() | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.is_exiting = False | ||||
| @@ -62,8 +64,9 @@ class MainWindow(QMainWindow): | ||||
|         self.theme = self.theme_manager.apply_theme(selected_theme) | ||||
|         self.tray_manager = TrayManager(self, app_name, self.current_theme_name) | ||||
|         self.card_width = read_card_size() | ||||
|         self.auto_card_width = read_auto_card_size() | ||||
|         self._last_card_width = self.card_width | ||||
|         self.setWindowTitle(app_name) | ||||
|         self.setWindowTitle(f"{app_name} {version}") | ||||
|         self.setMinimumSize(800, 600) | ||||
|  | ||||
|         self.games = [] | ||||
| @@ -129,6 +132,11 @@ class MainWindow(QMainWindow): | ||||
|         self.update_progress.connect(self.progress_bar.setValue) | ||||
|         self.update_status_message.connect(self.statusBar().showMessage) | ||||
|  | ||||
|         self.installing = False | ||||
|         self.current_install_script = None | ||||
|         self.install_process = None | ||||
|         self.install_monitor_timer = None | ||||
|  | ||||
|         # Центральный виджет и основной layout | ||||
|         centralWidget = QWidget() | ||||
|         self.setCentralWidget(centralWidget) | ||||
| @@ -166,7 +174,6 @@ class MainWindow(QMainWindow): | ||||
|         tabs = [ | ||||
|             _("Library"), | ||||
|             _("Auto Install"), | ||||
|             _("Emulators"), | ||||
|             _("Wine Settings"), | ||||
|             _("PortProton Settings"), | ||||
|             _("Themes") | ||||
| @@ -198,7 +205,6 @@ class MainWindow(QMainWindow): | ||||
|  | ||||
|         self.createInstalledTab() | ||||
|         self.createAutoInstallTab() | ||||
|         self.createEmulatorsTab() | ||||
|         self.createWineTab() | ||||
|         self.createPortProtonTab() | ||||
|         self.createThemeTab() | ||||
| @@ -256,6 +262,10 @@ class MainWindow(QMainWindow): | ||||
|                 GamepadType.XBOX: "xbox_y", | ||||
|                 GamepadType.PLAYSTATION: "ps_square", | ||||
|             }, | ||||
|             'prev_dir': { | ||||
|                 GamepadType.XBOX: "xbox_y", | ||||
|                 GamepadType.PLAYSTATION: "ps_square", | ||||
|             }, | ||||
|         } | ||||
|         return mappings.get(action, {}).get(gtype, "placeholder") | ||||
|  | ||||
| @@ -439,6 +449,116 @@ class MainWindow(QMainWindow): | ||||
|         # Update navigation buttons | ||||
|         self.updateNavButtons() | ||||
|  | ||||
|     def launch_autoinstall(self, script_name: str): | ||||
|         """Launch auto-install script.""" | ||||
|         if self.installing: | ||||
|             QMessageBox.warning(self, _("Warning"), _("Installation already in progress.")) | ||||
|             return | ||||
|         self.installing = True | ||||
|         self.current_install_script = script_name | ||||
|         self.seen_progress = False | ||||
|         self.current_percent = 0.0 | ||||
|         start_sh = os.path.join(self.portproton_location or "", "data", "scripts", "start.sh") if self.portproton_location else "" | ||||
|         if not os.path.exists(start_sh): | ||||
|             self.installing = False | ||||
|             return | ||||
|         cmd = [start_sh, "cli", "--autoinstall", script_name] | ||||
|         self.install_process = QProcess(self) | ||||
|         self.install_process.finished.connect(self.on_install_finished) | ||||
|         self.install_process.errorOccurred.connect(self.on_install_error) | ||||
|         self.install_process.start(cmd[0], cmd[1:]) | ||||
|         if not self.install_process.waitForStarted(5000): | ||||
|             self.installing = False | ||||
|             QMessageBox.warning(self, _("Error"), _("Failed to start installation.")) | ||||
|             return | ||||
|         self.progress_bar.setVisible(True) | ||||
|         self.progress_bar.setRange(0, 0)  # Indeterminate | ||||
|         self.update_status_message.emit(_("Processed {} installation...").format(script_name), 0) | ||||
|         self.install_monitor_timer = QTimer(self) | ||||
|         self.install_monitor_timer.timeout.connect(self.monitor_install_progress) | ||||
|         self.install_monitor_timer.start(2000)  # Start monitoring after 2s | ||||
|  | ||||
|     def monitor_install_progress(self): | ||||
|         """Monitor /tmp/PortProton_$USER/process.log for progress.""" | ||||
|         user = os.getenv('USER', 'unknown') | ||||
|         log_file = f"/tmp/PortProton_{user}/process.log" | ||||
|         if not os.path.exists(log_file): | ||||
|             return | ||||
|         try: | ||||
|             with open(log_file, encoding='utf-8') as f: | ||||
|                 content = f.read() | ||||
|             # Extract all percentage matches, including .0% as 0.0 | ||||
|             matches = re.findall(r'([0-9]*\.?[0-9]+)%', content) | ||||
|             if matches: | ||||
|                 try: | ||||
|                     percent = float(matches[-1]) | ||||
|                     if percent > 0: | ||||
|                         self.seen_progress = True | ||||
|                         self.current_percent = percent | ||||
|                     elif self.seen_progress and percent == 0: | ||||
|                         self.current_percent = 100.0 | ||||
|                         if self.install_monitor_timer is not None: | ||||
|                             self.install_monitor_timer.stop() | ||||
|                     # Update progress bar to determinate if not already | ||||
|                     if self.progress_bar.maximum() == 0: | ||||
|                         self.progress_bar.setRange(0, 100) | ||||
|                         self.progress_bar.setFormat("%p")  # Show percentage | ||||
|                     self.progress_bar.setValue(int(self.current_percent)) | ||||
|                     if self.current_percent >= 100: | ||||
|                         if self.install_monitor_timer is not None: | ||||
|                             self.install_monitor_timer.stop() | ||||
|                 except ValueError: | ||||
|                     pass  # Ignore invalid floats | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error monitoring log: {e}") | ||||
|  | ||||
|     @Slot(int, int) | ||||
|     def on_install_finished(self, exit_code: int, exit_status: int): | ||||
|         """Handle installation finish.""" | ||||
|         self.installing = False | ||||
|         if self.install_monitor_timer is not None: | ||||
|             self.install_monitor_timer.stop() | ||||
|             self.install_monitor_timer.deleteLater() | ||||
|             self.install_monitor_timer = None | ||||
|         self.progress_bar.setRange(0, 100) | ||||
|         self.progress_bar.setValue(100) | ||||
|  | ||||
|         if exit_code == 0: | ||||
|             self.update_status_message.emit(_("Installation completed successfully."), 5000) | ||||
|  | ||||
|             desktop_dir = self.portproton_location or "" | ||||
|             new_desktops = [e.path for e in os.scandir(desktop_dir) if e.name.endswith(".desktop")] | ||||
|             if new_desktops: | ||||
|                 latest = max(new_desktops, key=os.path.getmtime) | ||||
|                 self._process_desktop_file_async( | ||||
|                     latest, | ||||
|                     lambda result: ( | ||||
|                         self.game_library_manager.add_game_incremental(result) | ||||
|                         if result else None | ||||
|                     ) | ||||
|                 ) | ||||
|  | ||||
|         else: | ||||
|             self.update_status_message.emit(_("Installation failed."), 5000) | ||||
|             QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).") | ||||
|  | ||||
|         self.progress_bar.setVisible(False) | ||||
|         self.current_install_script = None | ||||
|         if self.install_process: | ||||
|             self.install_process.deleteLater() | ||||
|             self.install_process = None | ||||
|  | ||||
|     def on_install_error(self, error: QProcess.ProcessError): | ||||
|         """Handle installation error.""" | ||||
|         self.installing = False | ||||
|         if self.install_monitor_timer is not None: | ||||
|             self.install_monitor_timer.stop() | ||||
|             self.install_monitor_timer.deleteLater() | ||||
|             self.install_monitor_timer = None | ||||
|         self.update_status_message.emit(_("Installation error."), 5000) | ||||
|         QMessageBox.warning(self, _("Error"), f"Process error: {error}") | ||||
|         self.progress_bar.setVisible(False) | ||||
|  | ||||
|     @Slot(list) | ||||
|     def on_games_loaded(self, games: list[tuple]): | ||||
|         self.game_library_manager.set_games(games) | ||||
| @@ -720,6 +840,25 @@ class MainWindow(QMainWindow): | ||||
|         for i, btn in self.tabButtons.items(): | ||||
|             btn.setChecked(i == index) | ||||
|         self.stackedWidget.setCurrentIndex(index) | ||||
|         if hasattr(self, "game_library_manager"): | ||||
|             mgr = self.game_library_manager | ||||
|             if mgr.gamesListWidget and mgr.gamesListLayout: | ||||
|                 games_layout = mgr.gamesListLayout | ||||
|                 games_widget = mgr.gamesListWidget | ||||
|                 QTimer.singleShot(0, lambda: ( | ||||
|                     games_layout.invalidate(), | ||||
|                     games_widget.adjustSize(), | ||||
|                     games_widget.updateGeometry() | ||||
|                 )) | ||||
|         if hasattr(self, "autoInstallContainer") and hasattr(self, "autoInstallContainerLayout"): | ||||
|             auto_layout = self.autoInstallContainerLayout | ||||
|             auto_widget = self.autoInstallContainer | ||||
|             QTimer.singleShot(0, lambda: ( | ||||
|                 auto_layout.invalidate(), | ||||
|                 auto_widget.adjustSize(), | ||||
|                 auto_widget.updateGeometry() | ||||
|             )) | ||||
|  | ||||
|  | ||||
|     def openSystemOverlay(self): | ||||
|         """Opens the system overlay dialog.""" | ||||
| @@ -960,52 +1099,197 @@ class MainWindow(QMainWindow): | ||||
|                 get_steam_game_info_async(final_name, exec_line, on_steam_info) | ||||
|  | ||||
|     def createAutoInstallTab(self): | ||||
|         """Вкладка 'Auto Install'.""" | ||||
|         self.autoInstallWidget = QWidget() | ||||
|         self.autoInstallWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) | ||||
|         self.autoInstallWidget.setObjectName("otherPage") | ||||
|         layout = QVBoxLayout(self.autoInstallWidget) | ||||
|         layout.setContentsMargins(10, 18, 10, 10) | ||||
|         autoInstallPage = QWidget() | ||||
|         autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) | ||||
|         autoInstallLayout = QVBoxLayout(autoInstallPage) | ||||
|         autoInstallLayout.setSpacing(15) | ||||
|  | ||||
|         self.autoInstallTitle = QLabel(_("Auto Install")) | ||||
|         self.autoInstallTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE) | ||||
|         self.autoInstallTitle.setObjectName("tabTitle") | ||||
|         layout.addWidget(self.autoInstallTitle) | ||||
|         # Верхняя панель с заголовком и поиском | ||||
|         headerWidget = QWidget() | ||||
|         headerLayout = QHBoxLayout(headerWidget) | ||||
|         headerLayout.setContentsMargins(0, 10, 0, 10) | ||||
|         headerLayout.setSpacing(10) | ||||
|  | ||||
|         self.autoInstallContent = QLabel(_("Here you can configure automatic game installation...")) | ||||
|         self.autoInstallContent.setStyleSheet(self.theme.CONTENT_STYLE) | ||||
|         self.autoInstallContent.setObjectName("tabContent") | ||||
|         layout.addWidget(self.autoInstallContent) | ||||
|         layout.addStretch(1) | ||||
|         # Заголовок | ||||
|         titleLabel = QLabel(_("Auto Install")) | ||||
|         titleLabel.setStyleSheet(self.theme.TAB_TITLE_STYLE) | ||||
|         titleLabel.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft) | ||||
|         headerLayout.addWidget(titleLabel) | ||||
|  | ||||
|         self.stackedWidget.addWidget(self.autoInstallWidget) | ||||
|         headerLayout.addStretch() | ||||
|  | ||||
|     def createEmulatorsTab(self): | ||||
|         """Вкладка 'Emulators'.""" | ||||
|         self.emulatorsWidget = QWidget() | ||||
|         self.emulatorsWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) | ||||
|         self.emulatorsWidget.setObjectName("otherPage") | ||||
|         layout = QVBoxLayout(self.emulatorsWidget) | ||||
|         layout.setContentsMargins(10, 18, 10, 10) | ||||
|         # Поисковая строка | ||||
|         self.autoInstallSearchLineEdit = CustomLineEdit(self, theme=self.theme) | ||||
|         icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search")) | ||||
|         action_pos = QLineEdit.ActionPosition.LeadingPosition | ||||
|         self.search_action = self.autoInstallSearchLineEdit.addAction(icon, action_pos) | ||||
|         self.autoInstallSearchLineEdit.setMaximumWidth(200) | ||||
|         self.autoInstallSearchLineEdit.setPlaceholderText(_("Find Games ...")) | ||||
|         self.autoInstallSearchLineEdit.setClearButtonEnabled(True) | ||||
|         self.autoInstallSearchLineEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE) | ||||
|         self.autoInstallSearchLineEdit.textChanged.connect(self.filterAutoInstallGames) | ||||
|         headerLayout.addWidget(self.autoInstallSearchLineEdit) | ||||
|  | ||||
|         self.emulatorsTitle = QLabel(_("Emulators")) | ||||
|         self.emulatorsTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE) | ||||
|         self.emulatorsTitle.setObjectName("tabTitle") | ||||
|         layout.addWidget(self.emulatorsTitle) | ||||
|         autoInstallLayout.addWidget(headerWidget) | ||||
|  | ||||
|         self.emulatorsContent = QLabel(_("List of available emulators and their configuration...")) | ||||
|         self.emulatorsContent.setStyleSheet(self.theme.CONTENT_STYLE) | ||||
|         self.emulatorsContent.setObjectName("tabContent") | ||||
|         layout.addWidget(self.emulatorsContent) | ||||
|         layout.addStretch(1) | ||||
|         # Прогресс-бар | ||||
|         self.autoInstallProgress = QProgressBar() | ||||
|         self.autoInstallProgress.setStyleSheet(self.theme.PROGRESS_BAR_STYLE) | ||||
|         self.autoInstallProgress.setVisible(False) | ||||
|         autoInstallLayout.addWidget(self.autoInstallProgress) | ||||
|  | ||||
|         self.stackedWidget.addWidget(self.emulatorsWidget) | ||||
|         # Скролл | ||||
|         self.autoInstallScrollArea = QScrollArea() | ||||
|         self.autoInstallScrollArea.setWidgetResizable(True) | ||||
|         self.autoInstallScrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE) | ||||
|         QScroller.grabGesture(self.autoInstallScrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture) | ||||
|  | ||||
|         self.autoInstallContainer = QWidget() | ||||
|         self.autoInstallContainerLayout = FlowLayout(self.autoInstallContainer) | ||||
|         self.autoInstallContainer.setLayout(self.autoInstallContainerLayout) | ||||
|         self.autoInstallScrollArea.setWidget(self.autoInstallContainer) | ||||
|  | ||||
|         autoInstallLayout.addWidget(self.autoInstallScrollArea) | ||||
|  | ||||
|         # Slider for card size | ||||
|         sliderLayout = QHBoxLayout() | ||||
|         sliderLayout.setSpacing(0) | ||||
|         sliderLayout.setContentsMargins(0, 0, 0, 0) | ||||
|         sliderLayout.addStretch() | ||||
|  | ||||
|         self.auto_size_slider = QSlider(Qt.Orientation.Horizontal) | ||||
|         self.auto_size_slider.setMinimum(200) | ||||
|         self.auto_size_slider.setMaximum(250) | ||||
|         self.auto_size_slider.setValue(self.auto_card_width) | ||||
|         self.auto_size_slider.setTickInterval(10) | ||||
|         self.auto_size_slider.setFixedWidth(150) | ||||
|         self.auto_size_slider.setToolTip(f"{self.auto_card_width} px") | ||||
|         self.auto_size_slider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) | ||||
|         self.auto_size_slider.sliderReleased.connect(self.on_auto_slider_released) | ||||
|         sliderLayout.addWidget(self.auto_size_slider) | ||||
|  | ||||
|         autoInstallLayout.addLayout(sliderLayout) | ||||
|  | ||||
|         # Хранение карточек | ||||
|         self.autoInstallGameCards = {} | ||||
|         self.allAutoInstallCards = [] | ||||
|  | ||||
|         # Обновление обложки | ||||
|         def on_autoinstall_cover_updated(exe_name, local_path): | ||||
|             if exe_name in self.autoInstallGameCards and local_path: | ||||
|                 card = self.autoInstallGameCards[exe_name] | ||||
|                 card.cover_path = local_path | ||||
|                 load_pixmap_async(local_path, self.auto_card_width, int(self.auto_card_width * 1.5), card.on_cover_loaded) | ||||
|  | ||||
|         # Загрузка игр | ||||
|         def on_autoinstall_games_loaded(games: list[tuple]): | ||||
|             self.autoInstallProgress.setVisible(False) | ||||
|  | ||||
|             # Очистка | ||||
|             while self.autoInstallContainerLayout.count(): | ||||
|                 child = self.autoInstallContainerLayout.takeAt(0) | ||||
|                 if child: | ||||
|                     child.widget().deleteLater() | ||||
|  | ||||
|             self.autoInstallGameCards.clear() | ||||
|             self.allAutoInstallCards.clear() | ||||
|  | ||||
|             if not games: | ||||
|                 return | ||||
|  | ||||
|             # Callback для запуска установки | ||||
|             def select_callback(name, description, cover_path, appid, exec_line, controller_support, *_): | ||||
|                 if not exec_line or not exec_line.startswith("autoinstall:"): | ||||
|                     logger.warning(f"Invalid exec_line for autoinstall: {exec_line}") | ||||
|                     return | ||||
|                 script_name = exec_line[11:].lstrip(':').strip() | ||||
|                 self.launch_autoinstall(script_name) | ||||
|  | ||||
|             # Создаём карточки | ||||
|             for game_tuple in games: | ||||
|                 name, description, cover_path, appid, controller_support, exec_line, *_ , game_source, exe_name = game_tuple | ||||
|  | ||||
|                 card = GameCard( | ||||
|                     name, description, cover_path, appid, controller_support, | ||||
|                     exec_line, None, None, None, | ||||
|                     None, None, None, game_source, | ||||
|                     select_callback=select_callback, | ||||
|                     theme=self.theme, | ||||
|                     card_width=self.auto_card_width, | ||||
|                     parent=self.autoInstallContainer, | ||||
|                 ) | ||||
|  | ||||
|                 # Hide badges and favorite button | ||||
|                 if hasattr(card, 'steamLabel'): | ||||
|                     card.steamLabel.setVisible(False) | ||||
|                 if hasattr(card, 'egsLabel'): | ||||
|                     card.egsLabel.setVisible(False) | ||||
|                 if hasattr(card, 'portprotonLabel'): | ||||
|                     card.portprotonLabel.setVisible(False) | ||||
|                 if hasattr(card, 'protondbLabel'): | ||||
|                     card.protondbLabel.setVisible(False) | ||||
|                 if hasattr(card, 'anticheatLabel'): | ||||
|                     card.anticheatLabel.setVisible(False) | ||||
|                 if hasattr(card, 'favoriteLabel'): | ||||
|                     card.favoriteLabel.setVisible(False) | ||||
|  | ||||
|                 self.autoInstallGameCards[exe_name] = card | ||||
|                 self.allAutoInstallCards.append(card) | ||||
|                 self.autoInstallContainerLayout.addWidget(card) | ||||
|  | ||||
|             # Загружаем недостающие обложки | ||||
|             for game_tuple in games: | ||||
|                 name, _, cover_path, *_ , game_source, exe_name = game_tuple | ||||
|                 if not cover_path: | ||||
|                     self.portproton_api.download_autoinstall_cover_async( | ||||
|                         exe_name, timeout=5, | ||||
|                         callback=lambda path, ex=exe_name: on_autoinstall_cover_updated(ex, path) | ||||
|                     ) | ||||
|  | ||||
|             self.autoInstallContainer.updateGeometry() | ||||
|             self.autoInstallScrollArea.updateGeometry() | ||||
|             self.filterAutoInstallGames() | ||||
|  | ||||
|         # Показываем прогресс | ||||
|         self.autoInstallProgress.setVisible(True) | ||||
|         self.autoInstallProgress.setRange(0, 0) | ||||
|         self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded) | ||||
|  | ||||
|         self.stackedWidget.addWidget(autoInstallPage) | ||||
|  | ||||
|     def on_auto_slider_released(self): | ||||
|         """Handles auto-install slider release to update card size.""" | ||||
|         if hasattr(self, 'auto_size_slider') and self.auto_size_slider: | ||||
|             self.auto_card_width = self.auto_size_slider.value() | ||||
|             self.auto_size_slider.setToolTip(f"{self.auto_card_width} px") | ||||
|             save_auto_card_size(self.auto_card_width) | ||||
|             for card in self.allAutoInstallCards: | ||||
|                 card.update_card_size(self.auto_card_width) | ||||
|             self.autoInstallContainerLayout.invalidate() | ||||
|             self.autoInstallContainer.updateGeometry() | ||||
|             self.autoInstallScrollArea.updateGeometry() | ||||
|  | ||||
|     def filterAutoInstallGames(self): | ||||
|         """Filter auto install game cards based on search text.""" | ||||
|         search_text = self.autoInstallSearchLineEdit.text().lower().strip() | ||||
|         visible_count = 0 | ||||
|  | ||||
|         for card in self.allAutoInstallCards: | ||||
|             if search_text in card.name.lower(): | ||||
|                 card.setVisible(True) | ||||
|                 visible_count += 1 | ||||
|             else: | ||||
|                 card.setVisible(False) | ||||
|  | ||||
|         # Re-layout the container | ||||
|         self.autoInstallContainerLayout.invalidate() | ||||
|         self.autoInstallContainer.updateGeometry() | ||||
|         self.autoInstallScrollArea.updateGeometry() | ||||
|  | ||||
|     def createWineTab(self): | ||||
|         """Вкладка 'Wine Settings'.""" | ||||
|         self.wineWidget = QWidget() | ||||
|         self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE) | ||||
|         self.wineWidget.setObjectName("otherPage") | ||||
|         layout = QVBoxLayout(self.wineWidget) | ||||
|         layout.setContentsMargins(10, 18, 10, 10) | ||||
|  | ||||
| @@ -1061,21 +1345,20 @@ class MainWindow(QMainWindow): | ||||
|         tools_grid.setSpacing(6) | ||||
|  | ||||
|         tools = [ | ||||
|             ("winecfg", _("Wine Configuration")), | ||||
|             ("regedit", _("Registry Editor")), | ||||
|             ("control", _("Control Panel")), | ||||
|             ("taskmgr", _("Task Manager")), | ||||
|             ("explorer", _("File Explorer")), | ||||
|             ("cmd", _("Command Prompt")), | ||||
|             ("uninstaller", _("Uninstaller")), | ||||
|             ("--winecfg", _("Wine Configuration")), | ||||
|             ("--winereg", _("Registry Editor")), | ||||
|             ("--winefile", _("File Explorer")), | ||||
|             ("--winecmd", _("Command Prompt")), | ||||
|             ("--wine_uninstaller", _("Uninstaller")), | ||||
|         ] | ||||
|  | ||||
|         for i, (_tool_cmd, tool_name) in enumerate(tools): | ||||
|         for i, (tool_cmd, tool_name) in enumerate(tools): | ||||
|             row = i // 3 | ||||
|             col = i % 3 | ||||
|             btn = AutoSizeButton(tool_name, update_size=False) | ||||
|             btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|             btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|             btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t)) | ||||
|             tools_grid.addWidget(btn, row, col) | ||||
|  | ||||
|         for col in range(3): | ||||
| @@ -1093,7 +1376,7 @@ class MainWindow(QMainWindow): | ||||
|             (_("Load Prefix Backup"), self.load_prefix_backup), | ||||
|             (_("Delete Compatibility Tool"), self.delete_compat_tool), | ||||
|             (_("Delete Prefix"), self.delete_prefix), | ||||
|             (_("Clear Prefix"), None), | ||||
|             (_("Clear Prefix"), self.clear_prefix), | ||||
|         ] | ||||
|  | ||||
|         for i, (text, callback) in enumerate(additional_buttons): | ||||
| @@ -1114,8 +1397,220 @@ class MainWindow(QMainWindow): | ||||
|         additional_grid.setContentsMargins(10, 6, 10, 0) | ||||
|         layout.addStretch(1) | ||||
|  | ||||
|         self.wine_progress_bar = QProgressBar(self.wineWidget) | ||||
|         self.wine_progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE) | ||||
|         self.wine_progress_bar.setMaximumWidth(200) | ||||
|         self.wine_progress_bar.setTextVisible(True) | ||||
|         self.wine_progress_bar.setVisible(False) | ||||
|         self.wine_progress_bar.setRange(0, 0) | ||||
|  | ||||
|         wine_progress_layout = QHBoxLayout() | ||||
|         wine_progress_layout.addStretch(1) | ||||
|         wine_progress_layout.addWidget(self.wine_progress_bar) | ||||
|         layout.addLayout(wine_progress_layout) | ||||
|  | ||||
|         self.stackedWidget.addWidget(self.wineWidget) | ||||
|  | ||||
|     def launch_generic_tool(self, cli_arg): | ||||
|         wine = self.wineCombo.currentText() | ||||
|         prefix = self.prefixCombo.currentText() | ||||
|         if not wine or not prefix: | ||||
|             return | ||||
|         if not self.portproton_location: | ||||
|             return | ||||
|         start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh") | ||||
|         if not os.path.exists(start_sh): | ||||
|             return | ||||
|         cmd = [start_sh, "cli", cli_arg, wine, prefix] | ||||
|  | ||||
|         # Показываем прогресс-бар перед запуском | ||||
|         self.wine_progress_bar.setVisible(True) | ||||
|         self.update_status_message.emit(_("Launching tool..."), 0) | ||||
|  | ||||
|         proc = QProcess(self) | ||||
|         proc.finished.connect(lambda exitCode, exitStatus: self._on_wine_tool_finished(exitCode, cli_arg)) | ||||
|         proc.errorOccurred.connect(lambda error: self._on_wine_tool_error(error, cli_arg)) | ||||
|         proc.start(cmd[0], cmd[1:]) | ||||
|  | ||||
|         if not proc.waitForStarted(5000): | ||||
|             self.wine_progress_bar.setVisible(False) | ||||
|             self.update_status_message.emit("", 0) | ||||
|             QMessageBox.warning(self, _("Error"), _("Failed to start process.")) | ||||
|             return | ||||
|  | ||||
|         self._start_wine_process_monitor(cli_arg) | ||||
|  | ||||
|     def _start_wine_process_monitor(self, cli_arg): | ||||
|         """Запускает таймер для мониторинга запуска Wine утилиты.""" | ||||
|         self.wine_monitor_timer = QTimer(self) | ||||
|         self.wine_monitor_timer.setInterval(500) | ||||
|         self.wine_monitor_timer.timeout.connect(lambda: self._check_wine_process(cli_arg)) | ||||
|         self.wine_monitor_timer.start() | ||||
|  | ||||
|     def _check_wine_process(self, cli_arg): | ||||
|         """Проверяет, запустился ли целевой .exe процесс.""" | ||||
|         exe_map = { | ||||
|             "--winecfg": "winecfg.exe", | ||||
|             "--winereg": "regedit.exe", | ||||
|             "--winefile": "winefile.exe", | ||||
|             "--winecmd": "cmd.exe", | ||||
|             "--wine_uninstaller": "uninstaller.exe", | ||||
|         } | ||||
|         target_exe = exe_map.get(cli_arg, "") | ||||
|         if not target_exe: | ||||
|             return | ||||
|  | ||||
|         # Проверяем процессы через psutil | ||||
|         for proc in psutil.process_iter(attrs=["name"]): | ||||
|             if proc.info["name"].lower() == target_exe.lower(): | ||||
|                 # Процесс запустился — скрываем прогресс-бар и останавливаем мониторинг | ||||
|                 self.wine_progress_bar.setVisible(False) | ||||
|                 self.update_status_message.emit("", 0) | ||||
|                 if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None: | ||||
|                     self.wine_monitor_timer.stop() | ||||
|                     self.wine_monitor_timer.deleteLater() | ||||
|                     self.wine_monitor_timer = None | ||||
|                 logger.info(f"Wine tool {target_exe} started successfully") | ||||
|                 return | ||||
|  | ||||
|     def _on_wine_tool_finished(self, exitCode, cli_arg): | ||||
|         """Обработчик завершения Wine утилиты.""" | ||||
|         self.wine_progress_bar.setVisible(False) | ||||
|         self.update_status_message.emit("", 0) | ||||
|         # Останавливаем мониторинг, если он активен | ||||
|         if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None: | ||||
|             self.wine_monitor_timer.stop() | ||||
|             self.wine_monitor_timer.deleteLater() | ||||
|             self.wine_monitor_timer = None | ||||
|         if exitCode == 0: | ||||
|             logger.info(f"Wine tool {cli_arg} finished successfully") | ||||
|         else: | ||||
|             logger.warning(f"Wine tool {cli_arg} finished with exit code {exitCode}") | ||||
|  | ||||
|     def _on_wine_tool_error(self, error, cli_arg): | ||||
|         """Обработчик ошибки запуска Wine утилиты.""" | ||||
|         self.wine_progress_bar.setVisible(False) | ||||
|         self.update_status_message.emit("", 0) | ||||
|         # Останавливаем мониторинг, если он активен | ||||
|         if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None: | ||||
|             self.wine_monitor_timer.stop() | ||||
|             self.wine_monitor_timer.deleteLater() | ||||
|             self.wine_monitor_timer = None | ||||
|         logger.error(f"Wine tool {cli_arg} error: {error}") | ||||
|         QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}") | ||||
|  | ||||
|     def clear_prefix(self): | ||||
|         """Очистка префикса (позже удалить).""" | ||||
|         selected_prefix = self.prefixCombo.currentText() | ||||
|         selected_wine = self.wineCombo.currentText() | ||||
|         if not selected_prefix or not selected_wine: | ||||
|             return | ||||
|         if not self.portproton_location: | ||||
|             return | ||||
|  | ||||
|         reply = QMessageBox.question( | ||||
|             self, | ||||
|             _("Confirm Clear"), | ||||
|             _("Are you sure you want to clear prefix '{}'?").format(selected_prefix), | ||||
|             QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, | ||||
|             QMessageBox.StandardButton.No | ||||
|         ) | ||||
|         if reply != QMessageBox.StandardButton.Yes: | ||||
|             return | ||||
|  | ||||
|         prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix) | ||||
|         if not os.path.exists(prefix_dir): | ||||
|             return | ||||
|  | ||||
|         success = True | ||||
|         errors = [] | ||||
|  | ||||
|         # Удаление файлов | ||||
|         files_to_remove = [ | ||||
|             os.path.join(prefix_dir, "*.dot*"), | ||||
|             os.path.join(prefix_dir, "*.prog*"), | ||||
|             os.path.join(prefix_dir, ".wine_ver"), | ||||
|             os.path.join(prefix_dir, "system.reg"), | ||||
|             os.path.join(prefix_dir, "user.reg"), | ||||
|             os.path.join(prefix_dir, "userdef.reg"), | ||||
|             os.path.join(prefix_dir, "winetricks.log"), | ||||
|             os.path.join(prefix_dir, ".update-timestamp"), | ||||
|             os.path.join(prefix_dir, "drive_c", ".windows-serial"), | ||||
|         ] | ||||
|  | ||||
|         import glob | ||||
|         for pattern in files_to_remove: | ||||
|             if "*" in pattern:  # Глобальный паттерн | ||||
|                 matches = glob.glob(pattern) | ||||
|                 for file_path in matches: | ||||
|                     try: | ||||
|                         if os.path.exists(file_path): | ||||
|                             os.remove(file_path) | ||||
|                     except Exception as e: | ||||
|                         success = False | ||||
|                         errors.append(str(e)) | ||||
|             else:  # Конкретный файл | ||||
|                 try: | ||||
|                     if os.path.exists(pattern): | ||||
|                         os.remove(pattern) | ||||
|                 except Exception as e: | ||||
|                     success = False | ||||
|                     errors.append(str(e)) | ||||
|  | ||||
|         # Удаление директорий | ||||
|         dirs_to_remove = [ | ||||
|             os.path.join(prefix_dir, "drive_c", "windows"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Setup"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Windows"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "WindowsTask"), | ||||
|             os.path.join(prefix_dir, "drive_c", "ProgramData", "Package Cache"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Temporary Internet Files"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "wine_gecko"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "Public", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Temporary Internet Files"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "Microsoft"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "wine_gecko"), | ||||
|             os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Temp"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Internet Explorer"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Windows Media Player"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files", "Windows NT"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Internet Explorer"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows Media Player"), | ||||
|             os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows NT"), | ||||
|         ] | ||||
|  | ||||
|         import shutil | ||||
|         for dir_path in dirs_to_remove: | ||||
|             try: | ||||
|                 if os.path.exists(dir_path): | ||||
|                     shutil.rmtree(dir_path) | ||||
|             except Exception as e: | ||||
|                 success = False | ||||
|                 errors.append(str(e)) | ||||
|  | ||||
|         tmp_path = os.path.join(self.portproton_location, "data", "tmp") | ||||
|         if os.path.exists(tmp_path): | ||||
|             import glob | ||||
|             bin_files = glob.glob(os.path.join(tmp_path, "*.bin")) | ||||
|             foz_files = glob.glob(os.path.join(tmp_path, "*.foz")) | ||||
|             for file_path in bin_files + foz_files: | ||||
|                 try: | ||||
|                     os.remove(file_path) | ||||
|                 except Exception as e: | ||||
|                     success = False | ||||
|                     errors.append(str(e)) | ||||
|  | ||||
|         if success: | ||||
|             QMessageBox.information(self, _("Success"), _("Prefix '{}' cleared successfully.").format(selected_prefix)) | ||||
|         else: | ||||
|             error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5])) | ||||
|             QMessageBox.warning(self, _("Warning"), error_msg) | ||||
|  | ||||
|     def create_prefix_backup(self): | ||||
|         selected_prefix = self.prefixCombo.currentText() | ||||
|         if not selected_prefix: | ||||
| @@ -1196,8 +1691,9 @@ class MainWindow(QMainWindow): | ||||
|                 QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix)) | ||||
|                 # обновляем список | ||||
|                 self.prefixCombo.clear() | ||||
|                 self.prefixes = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "prefixes")) | ||||
|                                  if os.path.isdir(os.path.join(self.portproton_location, "data", "prefixes", d))] | ||||
|                 prefixes_path = os.path.join(self.portproton_location, "data", "prefixes") | ||||
|                 self.prefixes = [d for d in os.listdir(prefixes_path) | ||||
|                                 if os.path.isdir(os.path.join(prefixes_path, d))] | ||||
|                 self.prefixCombo.addItems(self.prefixes) | ||||
|             except Exception as e: | ||||
|                 QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e))) | ||||
| @@ -1338,7 +1834,22 @@ class MainWindow(QMainWindow): | ||||
|         self.gamesDisplayCombo.setCurrentIndex(idx) | ||||
|         formLayout.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo) | ||||
|  | ||||
|         # 4. Proxy settings | ||||
|         # 4 Gamepad Type | ||||
|         self.gamepadTypeCombo = QComboBox() | ||||
|         self.gamepadTypeCombo.addItems(["Xbox", "PlayStation"]) | ||||
|         self.gamepadTypeCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.gamepadTypeCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE) | ||||
|         self.gamepadTypeTitle = QLabel(_("Gamepad Type:")) | ||||
|         self.gamepadTypeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) | ||||
|         self.gamepadTypeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) | ||||
|         current_type_str = read_gamepad_type() | ||||
|         if current_type_str == "playstation": | ||||
|             self.gamepadTypeCombo.setCurrentText("PlayStation") | ||||
|         else: | ||||
|             self.gamepadTypeCombo.setCurrentText("Xbox") | ||||
|         formLayout.addRow(self.gamepadTypeTitle, self.gamepadTypeCombo) | ||||
|  | ||||
|         # 5. Proxy settings | ||||
|         self.proxyUrlEdit = CustomLineEdit(self, theme=self.theme) | ||||
|         self.proxyUrlEdit.setPlaceholderText(_("Proxy URL")) | ||||
|         self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE) | ||||
| @@ -1370,7 +1881,7 @@ class MainWindow(QMainWindow): | ||||
|         self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) | ||||
|         formLayout.addRow(self.proxyPasswordTitle, self.proxyPasswordEdit) | ||||
|  | ||||
|         # 5. Fullscreen setting for application | ||||
|         # 6. Fullscreen setting for application | ||||
|         self.fullscreenCheckBox = QCheckBox(_("Launch Application in Fullscreen")) | ||||
|         self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) | ||||
|         self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
| @@ -1381,7 +1892,19 @@ class MainWindow(QMainWindow): | ||||
|         self.fullscreenCheckBox.setChecked(current_fullscreen) | ||||
|         formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox) | ||||
|  | ||||
|         # 6. Automatic fullscreen on gamepad connection | ||||
|         # 7. Minimize to tray setting | ||||
|         self.minimizeToTrayCheckBox = QCheckBox(_("Minimize to tray on close")) | ||||
|         self.minimizeToTrayCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) | ||||
|         self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.minimizeToTrayTitle = QLabel(_("Application Close Mode:")) | ||||
|         self.minimizeToTrayTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) | ||||
|         self.minimizeToTrayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) | ||||
|         current_minimize_to_tray = read_minimize_to_tray() | ||||
|         self.minimizeToTrayCheckBox.setChecked(current_minimize_to_tray) | ||||
|         self.minimizeToTrayCheckBox.toggled.connect(lambda checked: save_minimize_to_tray(checked)) | ||||
|         formLayout.addRow(self.minimizeToTrayTitle, self.minimizeToTrayCheckBox) | ||||
|  | ||||
|         # 8. Automatic fullscreen on gamepad connection | ||||
|         self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected")) | ||||
|         self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) | ||||
|         self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
| @@ -1393,7 +1916,7 @@ class MainWindow(QMainWindow): | ||||
|         self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen) | ||||
|         formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox) | ||||
|  | ||||
|         # 7. Gamepad haptic feedback config | ||||
|         # 9. Gamepad haptic feedback config | ||||
|         self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback")) | ||||
|         self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) | ||||
| @@ -1404,10 +1927,10 @@ class MainWindow(QMainWindow): | ||||
|         self.gamepadRumbleCheckBox.setChecked(current_rumble_state) | ||||
|         formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox) | ||||
|  | ||||
|         # # 8. Legendary Authentication | ||||
|         # # 9. Legendary Authentication | ||||
|         # self.legendaryAuthButton = AutoSizeButton( | ||||
|         #     _("Open Legendary Login"), | ||||
|         #     icon=self.theme_manager.get_icon("login") | ||||
|         #     icon=self.theme_manager.get_icon("login")self.theme_manager.get_icon("login") | ||||
|         # ) | ||||
|         # self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) | ||||
|         # self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
| @@ -1584,6 +2107,19 @@ class MainWindow(QMainWindow): | ||||
|         rumble_enabled = self.gamepadRumbleCheckBox.isChecked() | ||||
|         save_rumble_config(rumble_enabled) | ||||
|  | ||||
|         gamepad_type_text = self.gamepadTypeCombo.currentText() | ||||
|         gpad_type = "playstation" if gamepad_type_text == "PlayStation" else "xbox" | ||||
|         save_gamepad_type(gpad_type) | ||||
|  | ||||
|         if hasattr(self, 'input_manager'): | ||||
|             if gpad_type == "playstation": | ||||
|                 self.input_manager.gamepad_type = GamepadType.PLAYSTATION | ||||
|             elif gpad_type == "xbox": | ||||
|                 self.input_manager.gamepad_type = GamepadType.XBOX | ||||
|             else: | ||||
|                 self.input_manager.gamepad_type = GamepadType.UNKNOWN | ||||
|             self.updateControlHints() | ||||
|  | ||||
|         for card in self.game_library_manager.game_card_cache.values(): | ||||
|             card.update_badge_visibility(filter_key) | ||||
|  | ||||
| @@ -1600,9 +2136,6 @@ class MainWindow(QMainWindow): | ||||
|         gamepad_connected = self.input_manager.find_gamepad() is not None | ||||
|         if fullscreen or (auto_fullscreen_gamepad and gamepad_connected): | ||||
|             self.showFullScreen() | ||||
|         else: | ||||
|             self.showNormal() | ||||
|             self.resize(*read_window_geometry()) | ||||
|  | ||||
|         self.statusBar().showMessage(_("Settings saved"), 3000) | ||||
|  | ||||
| @@ -2392,9 +2925,7 @@ class MainWindow(QMainWindow): | ||||
|             else: | ||||
|                 # Запускаем игру через PortProton | ||||
|                 env_vars = os.environ.copy() | ||||
|                 env_vars['START_FROM_STEAM'] = '1' | ||||
|                 env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path | ||||
|                 env_vars['PROCESS_LOG'] = '1' | ||||
|  | ||||
|                 wrapper = "flatpak run ru.linux_gaming.PortProton" | ||||
|                 if self.portproton_location is not None and ".var" not in self.portproton_location: | ||||
| @@ -2521,53 +3052,77 @@ class MainWindow(QMainWindow): | ||||
|                 logger.error(f"Failed to launch game {exe_name}: {e}") | ||||
|                 QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e))) | ||||
|  | ||||
|  | ||||
|     def closeEvent(self, event): | ||||
|         """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход.""" | ||||
|         if hasattr(self, 'is_exiting') and self.is_exiting: | ||||
|             # Принудительное закрытие: завершаем процессы и приложение | ||||
|             for proc in self.game_processes: | ||||
|                 try: | ||||
|                     parent = psutil.Process(proc.pid) | ||||
|                     children = parent.children(recursive=True) | ||||
|                     for child in children: | ||||
|                         try: | ||||
|                             logger.debug(f"Terminating child process {child.pid}") | ||||
|                             child.terminate() | ||||
|                         except psutil.NoSuchProcess: | ||||
|                             logger.debug(f"Child process {child.pid} already terminated") | ||||
|                     psutil.wait_procs(children, timeout=5) | ||||
|                     for child in children: | ||||
|                         if child.is_running(): | ||||
|                             logger.debug(f"Killing child process {child.pid}") | ||||
|                             child.kill() | ||||
|                     logger.debug(f"Terminating process group {proc.pid}") | ||||
|                     os.killpg(os.getpgid(proc.pid), signal.SIGTERM) | ||||
|                 except (psutil.NoSuchProcess, ProcessLookupError) as e: | ||||
|                     logger.debug(f"Process {proc.pid} already terminated: {e}") | ||||
|         """Обработчик закрытия окна: проверяет настройку minimize_to_tray. | ||||
|         Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем. | ||||
|         """ | ||||
|         minimize_to_tray = read_minimize_to_tray() | ||||
|  | ||||
|             self.game_processes = []  # Очищаем список процессов | ||||
|  | ||||
|             # Очищаем таймеры | ||||
|             if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive(): | ||||
|                 self.games_load_timer.stop() | ||||
|             if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive(): | ||||
|                 self.settingsDebounceTimer.stop() | ||||
|             if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive(): | ||||
|                 self.searchDebounceTimer.stop() | ||||
|             if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive(): | ||||
|                 self.checkProcessTimer.stop() | ||||
|                 self.checkProcessTimer.deleteLater() | ||||
|                 self.checkProcessTimer = None | ||||
|  | ||||
|             # Сохраняем настройки окна | ||||
|             if not read_fullscreen_config(): | ||||
|                 logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") | ||||
|                 save_window_geometry(self.width(), self.height()) | ||||
|             save_card_size(self.card_width) | ||||
|  | ||||
|             event.accept() | ||||
|         else: | ||||
|             # Сворачиваем в трей вместо закрытия | ||||
|             self.hide() | ||||
|         if minimize_to_tray: | ||||
|             # Просто сворачиваем в трей | ||||
|             event.ignore() | ||||
|             self.hide() | ||||
|             return | ||||
|  | ||||
|         # Полное закрытие приложения | ||||
|         self.is_exiting = True | ||||
|         event.accept() | ||||
|  | ||||
|         # Скрываем и удаляем иконку трея | ||||
|         if hasattr(self, "tray_manager") and self.tray_manager.tray_icon: | ||||
|             self.tray_manager.tray_icon.hide() | ||||
|             self.tray_manager.tray_icon.deleteLater() | ||||
|  | ||||
|         # Сохраняем размеры карточек | ||||
|         save_card_size(self.card_width) | ||||
|         save_auto_card_size(self.auto_card_width) | ||||
|  | ||||
|         # Сохраняем размеры окна (если не в полноэкранном режиме) | ||||
|         if not read_fullscreen_config(): | ||||
|             logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") | ||||
|             save_window_geometry(self.width(), self.height()) | ||||
|  | ||||
|         # Завершаем все игровые процессы | ||||
|         for proc in getattr(self, "game_processes", []): | ||||
|             try: | ||||
|                 parent = psutil.Process(proc.pid) | ||||
|                 children = parent.children(recursive=True) | ||||
|                 for child in children: | ||||
|                     try: | ||||
|                         logger.debug(f"Terminating child process {child.pid}") | ||||
|                         child.terminate() | ||||
|                     except psutil.NoSuchProcess: | ||||
|                         logger.debug(f"Child process {child.pid} already terminated") | ||||
|  | ||||
|                 psutil.wait_procs(children, timeout=5) | ||||
|                 for child in children: | ||||
|                     if child.is_running(): | ||||
|                         logger.debug(f"Killing child process {child.pid}") | ||||
|                         child.kill() | ||||
|  | ||||
|                 logger.debug(f"Terminating process group {proc.pid}") | ||||
|                 os.killpg(os.getpgid(proc.pid), signal.SIGTERM) | ||||
|  | ||||
|             except (psutil.NoSuchProcess, ProcessLookupError) as e: | ||||
|                 logger.debug(f"Process {getattr(proc, 'pid', '?')} already terminated: {e}") | ||||
|             except Exception as e: | ||||
|                 logger.warning(f"Failed to terminate process {getattr(proc, 'pid', '?')}: {e}") | ||||
|  | ||||
|         self.game_processes = [] | ||||
|  | ||||
|         # Универсальная остановка и удаление таймеров | ||||
|         timers = [ | ||||
|             "games_load_timer", | ||||
|             "settingsDebounceTimer", | ||||
|             "searchDebounceTimer", | ||||
|             "checkProcessTimer", | ||||
|             "wine_monitor_timer", | ||||
|         ] | ||||
|  | ||||
|         for tname in timers: | ||||
|             timer = getattr(self, tname, None) | ||||
|             if timer and timer.isActive(): | ||||
|                 timer.stop() | ||||
|             if timer: | ||||
|                 timer.deleteLater() | ||||
|                 setattr(self, tname, None) | ||||
|   | ||||
| @@ -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: | ||||
|   | ||||
| After Width: | Height: | Size: 232 KiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Библиотека.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 225 KiB | 
| Before Width: | Height: | Size: 1.3 MiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Карточка.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 70 KiB | 
| Before Width: | Height: | Size: 364 KiB | 
| Before Width: | Height: | Size: 430 KiB | 
| After Width: | Height: | Size: 238 KiB | 
| Before Width: | Height: | Size: 1.3 MiB | 
| After Width: | Height: | Size: 61 KiB | 
| After Width: | Height: | Size: 38 KiB | 
| Before Width: | Height: | Size: 104 KiB | 
| Before Width: | Height: | Size: 1.0 MiB | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Темы.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 93 KiB | 
| @@ -1,7 +1,8 @@ | ||||
| from typing import cast | ||||
| from typing import cast, Any | ||||
| from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout, | ||||
|                                QSizePolicy, QWidget, QLineEdit) | ||||
| from PySide6.QtCore import Qt, Signal, QProcess | ||||
| from PySide6.QtCore import Qt, Signal, QProcess, QSize | ||||
| from PySide6.QtGui import QPixmap, QIcon | ||||
| from portprotonqt.keyboard_layouts import keyboard_layouts | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
| from portprotonqt.config_utils import read_theme_from_config | ||||
| @@ -43,6 +44,18 @@ class VirtualKeyboard(QFrame): | ||||
|         self.margins = 10 | ||||
|         self.num_cols = 14 | ||||
|  | ||||
|         # Find input_manager and main_window | ||||
|         self.input_manager: Any = None | ||||
|         self.main_window: Any = None | ||||
|         parent_widget: QWidget | None = self._parent | ||||
|         while parent_widget: | ||||
|             if hasattr(parent_widget, 'input_manager'): | ||||
|                 self.input_manager = cast(Any, parent_widget).input_manager | ||||
|                 self.main_window = cast(Any, parent_widget) | ||||
|             parent_widget = cast(QWidget | None, parent_widget.parent()) | ||||
|  | ||||
|  | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
|         self.initUI() | ||||
|         self.hide() | ||||
|  | ||||
| @@ -119,6 +132,34 @@ class VirtualKeyboard(QFrame): | ||||
|         self.buttons: dict[str, QPushButton] = {} | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     def set_gamepad_icon(self, button, icon_type, gtype=''): | ||||
|         """Set gamepad icon on button based on type""" | ||||
|         if icon_type in ['back', 'add_game']: | ||||
|             icon_name = self.main_window.get_button_icon(icon_type, gtype) | ||||
|         else:  # nav left/right | ||||
|             if icon_type in ['left', 'right']: | ||||
|                 direction = icon_type | ||||
|                 icon_name = self.main_window.get_nav_icon(direction, gtype) | ||||
|             else: | ||||
|                 direction = 'left' if icon_type == 'left' else 'right' | ||||
|                 icon_name = self.main_window.get_nav_icon(direction, gtype) | ||||
|  | ||||
|         icon_path = theme_manager.get_theme_image(icon_name, self.current_theme_name) | ||||
|         pixmap = QPixmap() | ||||
|         if icon_path: | ||||
|             pixmap.load(str(icon_path)) | ||||
|         if not pixmap.isNull(): | ||||
|             button.setIcon(QIcon(pixmap)) | ||||
|             button.setIconSize(QSize(20, 20)) | ||||
|             return | ||||
|         else: | ||||
|             # Fallback to placeholder | ||||
|             placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name) | ||||
|             if placeholder: | ||||
|                 button.setIcon(QIcon(placeholder)) | ||||
|                 button.setIconSize(QSize(20, 20)) | ||||
|                 return | ||||
|  | ||||
|     def update_keyboard(self): | ||||
|         coords = self._save_focused_coords() | ||||
|  | ||||
| @@ -151,6 +192,9 @@ class VirtualKeyboard(QFrame): | ||||
|                     button.setCheckable(True) | ||||
|                     button.setChecked(self.shift_pressed) | ||||
|                     button.clicked.connect(lambda checked: self.on_shift_click(checked)) | ||||
|                     # Add gamepad icon for Shift (RB/R) | ||||
|                     gtype = self.input_manager.gamepad_type | ||||
|                     self.set_gamepad_icon(button, 'right', gtype) | ||||
|                 else: | ||||
|                     button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k)) | ||||
|  | ||||
| @@ -163,6 +207,9 @@ class VirtualKeyboard(QFrame): | ||||
|         shift.setCheckable(True) | ||||
|         shift.setChecked(self.shift_pressed) | ||||
|         shift.clicked.connect(lambda checked: self.on_shift_click(checked)) | ||||
|         # Add gamepad icon for Shift (RB/R) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(shift, 'right', gtype) | ||||
|         self.keyboard_layout.addWidget(shift, 3, 11, 1, 3) | ||||
|  | ||||
|         button = QPushButton('CAPS') | ||||
| @@ -179,6 +226,9 @@ class VirtualKeyboard(QFrame): | ||||
|         backspace.setFixedSize(fixed_w, fixed_h) | ||||
|         backspace.pressed.connect(self.on_backspace_pressed) | ||||
|         backspace.released.connect(self.stop_backspace_repeat) | ||||
|         # Add gamepad icon for Backspace (X/Triangle) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(backspace, 'add_game', gtype) | ||||
|         self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1) | ||||
|  | ||||
|         enter = QPushButton('Enter') | ||||
| @@ -189,6 +239,9 @@ class VirtualKeyboard(QFrame): | ||||
|         lang = QPushButton('🌐') | ||||
|         lang.setFixedSize(fixed_w, fixed_h) | ||||
|         lang.clicked.connect(self.on_lang_click) | ||||
|         # Add gamepad icon for Lang (LB/L) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(lang, 'left', gtype) | ||||
|         self.keyboard_layout.addWidget(lang, 4, 0, 1, 1) | ||||
|  | ||||
|         clear = QPushButton('Clear') | ||||
| @@ -219,6 +272,9 @@ class VirtualKeyboard(QFrame): | ||||
|         hide_button = QPushButton('Hide') | ||||
|         hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) | ||||
|         hide_button.clicked.connect(self.hide) | ||||
|         # Add gamepad icon for Hide (B/Circle) | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         self.set_gamepad_icon(hide_button, 'back', gtype) | ||||
|         self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2) | ||||
|  | ||||
|         if coords: | ||||
|   | ||||
| @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" | ||||
|  | ||||
| [project] | ||||
| name = "portprotonqt" | ||||
| version = "0.1.6" | ||||
| version = "0.1.8" | ||||
| description = "A project to rewrite PortProton (PortWINE) using PySide" | ||||
| readme = "README.md" | ||||
| license = { text = "GPL-3.0" } | ||||
|   | ||||