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
 | 
					    name: Build Arch Package
 | 
				
			||||||
    runs-on: ubuntu-22.04
 | 
					    runs-on: ubuntu-22.04
 | 
				
			||||||
    container:
 | 
					    container:
 | 
				
			||||||
      image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166
 | 
					      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
				
			||||||
      volumes:
 | 
					      volumes:
 | 
				
			||||||
        - /usr:/usr-host
 | 
					        - /usr:/usr-host
 | 
				
			||||||
        - /opt:/opt-host
 | 
					        - /opt:/opt-host
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -8,7 +8,7 @@ on:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
env:
 | 
					env:
 | 
				
			||||||
  # Common version, will be used for tagging the release
 | 
					  # Common version, will be used for tagging the release
 | 
				
			||||||
  VERSION: 0.1.6
 | 
					  VERSION: 0.1.8
 | 
				
			||||||
  PKGDEST: "/tmp/portprotonqt"
 | 
					  PKGDEST: "/tmp/portprotonqt"
 | 
				
			||||||
  PACKAGE: "portprotonqt"
 | 
					  PACKAGE: "portprotonqt"
 | 
				
			||||||
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
 | 
					  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
 | 
				
			||||||
@@ -180,10 +180,12 @@ jobs:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
      - name: Release
 | 
					      - name: Release
 | 
				
			||||||
        uses: https://gitea.com/actions/gitea-release-action@v1
 | 
					        uses: https://gitea.com/actions/gitea-release-action@v1
 | 
				
			||||||
 | 
					        env:
 | 
				
			||||||
 | 
					            NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
 | 
				
			||||||
        with:
 | 
					        with:
 | 
				
			||||||
          body_path: changelog.txt
 | 
					          body_path: changelog.txt
 | 
				
			||||||
          token: ${{ env.GITEA_TOKEN }}
 | 
					          token: ${{ env.GITEA_TOKEN }}
 | 
				
			||||||
          tag_name: v${{ env.VERSION }}
 | 
					          tag_name: v${{ env.VERSION }}
 | 
				
			||||||
          prerelease: true
 | 
					          prerelease: true
 | 
				
			||||||
          files: release/**/*
 | 
					          files: release/**/*
 | 
				
			||||||
          sha256sum: true
 | 
					          sha256sum: false
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -138,7 +138,7 @@ jobs:
 | 
				
			|||||||
    needs: changes
 | 
					    needs: changes
 | 
				
			||||||
    if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
 | 
					    if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
 | 
				
			||||||
    container:
 | 
					    container:
 | 
				
			||||||
      image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166
 | 
					      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
				
			||||||
      volumes:
 | 
					      volumes:
 | 
				
			||||||
        - /usr:/usr-host
 | 
					        - /usr:/usr-host
 | 
				
			||||||
        - /opt:/opt-host
 | 
					        - /opt:/opt-host
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -16,7 +16,7 @@ repos:
 | 
				
			|||||||
      - id: uv-lock
 | 
					      - id: uv-lock
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
					  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
				
			||||||
    rev: v0.13.2
 | 
					    rev: v0.14.0
 | 
				
			||||||
    hooks:
 | 
					    hooks:
 | 
				
			||||||
      - id: ruff-check
 | 
					      - id: ruff-check
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										36
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@@ -3,14 +3,42 @@
 | 
				
			|||||||
Все заметные изменения в этом проекте фиксируются в этом файле.
 | 
					Все заметные изменения в этом проекте фиксируются в этом файле.
 | 
				
			||||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
 | 
					Формат основан на [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
 | 
					### Added
 | 
				
			||||||
- Возможность скроллинга библиотеки мышью или пальцем
 | 
					- Возможность скроллинга библиотеки мышью или пальцем
 | 
				
			||||||
- Импорт и экспорт бекапа префикса
 | 
					- Импорт и экспорт бекапа префикса
 | 
				
			||||||
- Диалог для управление Winetricks
 | 
					- Диалог для управление Winetricks
 | 
				
			||||||
- Кнопки для удаления префикса, wine или proton
 | 
					- Кнопки для удаления префикса, wine или proton
 | 
				
			||||||
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке
 | 
					- Все настройки Wine с оригинального PortProton
 | 
				
			||||||
 | 
					- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
 | 
				
			||||||
 | 
					- Вкладка автоустановок
 | 
				
			||||||
 | 
					- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Changed
 | 
					### Changed
 | 
				
			||||||
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
 | 
					- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
 | 
				
			||||||
@@ -22,8 +50,12 @@
 | 
				
			|||||||
- Исправлено зависание при поиске игр
 | 
					- Исправлено зависание при поиске игр
 | 
				
			||||||
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
 | 
					- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
 | 
				
			||||||
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
 | 
					- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
 | 
				
			||||||
 | 
					- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
 | 
				
			||||||
 | 
					- При сохранении настроек теперь не меняется размер окна
 | 
				
			||||||
 | 
					
 | 
				
			||||||
### Contributors
 | 
					### Contributors
 | 
				
			||||||
 | 
					- @wmigor (Igor Akulov)
 | 
				
			||||||
 | 
					- @Vector_null
 | 
				
			||||||
 | 
					
 | 
				
			||||||
---
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										15
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						@@ -1,6 +1,6 @@
 | 
				
			|||||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
 | 
					- [X] Адаптировать структуру проекта для поддержки инструментов сборки
 | 
				
			||||||
- [X] Добавить возможность управления с геймпада
 | 
					- [X] Добавить возможность управления с геймпада
 | 
				
			||||||
- [ ] Добавить возможность управления с тачскрина
 | 
					- [X] Добавить возможность управления с тачскрина (Формально и так есть)
 | 
				
			||||||
- [X] Добавить возможность управления с мыши и клавиатуры
 | 
					- [X] Добавить возможность управления с мыши и клавиатуры
 | 
				
			||||||
- [X] Добавить систему тем [Документация](documentation/theme_guide)
 | 
					- [X] Добавить систему тем [Документация](documentation/theme_guide)
 | 
				
			||||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
 | 
					- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
 | 
				
			||||||
@@ -11,18 +11,18 @@
 | 
				
			|||||||
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
 | 
					- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
 | 
				
			||||||
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
 | 
					- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
 | 
				
			||||||
- [X] Получать описания и названия игр из базы данных Steam
 | 
					- [X] Получать описания и названия игр из базы данных Steam
 | 
				
			||||||
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
 | 
					- [X] Получать обложки для игр из CDN Steam
 | 
				
			||||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
 | 
					- [X] Оптимизировать работу со Steam API для ускорения времени запуска
 | 
				
			||||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
 | 
					- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
 | 
				
			||||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
					- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
				
			||||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
 | 
					- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
 | 
				
			||||||
- [X] Избавиться от вызовов yad
 | 
					- [X] Избавиться от вызовов yad
 | 
				
			||||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
 | 
					- [X] Реализовать собственный системный трей вместо использования трея PortProton
 | 
				
			||||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
 | 
					- [X] Добавить экранную клавиатуру в поиск
 | 
				
			||||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
 | 
					- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
 | 
				
			||||||
- [X] Добавить индикацию запуска приложения
 | 
					- [X] Добавить индикацию запуска приложения
 | 
				
			||||||
- [X] Достигнуть паритета функциональности с Ingame
 | 
					- [X] Достигнуть паритета функциональности с Ingame
 | 
				
			||||||
- [ ] Достигнуть паритета функциональности с PortProton
 | 
					- [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов)
 | 
				
			||||||
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
 | 
					- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
 | 
				
			||||||
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
 | 
					- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
 | 
				
			||||||
- [X] Добавить переводы в переопределения
 | 
					- [X] Добавить переводы в переопределения
 | 
				
			||||||
@@ -49,7 +49,7 @@
 | 
				
			|||||||
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
 | 
					- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
 | 
				
			||||||
- [X] Добавить систему избранного для карточек
 | 
					- [X] Добавить систему избранного для карточек
 | 
				
			||||||
- [X] Заменить все `print` на `logging`
 | 
					- [X] Заменить все `print` на `logging`
 | 
				
			||||||
- [ ] Привести все логи к единому языку
 | 
					- [X] Привести все логи к единому языку
 | 
				
			||||||
- [X] Уменьшить количество подстановок в переводах
 | 
					- [X] Уменьшить количество подстановок в переводах
 | 
				
			||||||
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
 | 
					- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
 | 
				
			||||||
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
 | 
					- [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] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
 | 
					- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
 | 
				
			||||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
 | 
					- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
 | 
				
			||||||
- [ ] Доделать светлую тему
 | 
					- [X] Добавить подсказки к управлению с геймпада
 | 
				
			||||||
- [ ] Добавить подсказки к управлению с геймпада
 | 
					 | 
				
			||||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
 | 
					- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
 | 
				
			||||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
 | 
					- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -36,7 +36,7 @@ AppDir:
 | 
				
			|||||||
    id: ru.linux_gaming.PortProtonQt
 | 
					    id: ru.linux_gaming.PortProtonQt
 | 
				
			||||||
    name: PortProtonQt
 | 
					    name: PortProtonQt
 | 
				
			||||||
    icon: ru.linux_gaming.PortProtonQt
 | 
					    icon: ru.linux_gaming.PortProtonQt
 | 
				
			||||||
    version: 0.1.6
 | 
					    version: 0.1.8
 | 
				
			||||||
    exec: usr/bin/python3
 | 
					    exec: usr/bin/python3
 | 
				
			||||||
    exec_args: "-m portprotonqt.app $@"
 | 
					    exec_args: "-m portprotonqt.app $@"
 | 
				
			||||||
  apt:
 | 
					  apt:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
pkgname=portprotonqt
 | 
					pkgname=portprotonqt
 | 
				
			||||||
pkgver=0.1.6
 | 
					pkgver=0.1.8
 | 
				
			||||||
pkgrel=1
 | 
					pkgrel=1
 | 
				
			||||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
 | 
					pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
 | 
				
			||||||
arch=('any')
 | 
					arch=('any')
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,5 +1,5 @@
 | 
				
			|||||||
%global pypi_name portprotonqt
 | 
					%global pypi_name portprotonqt
 | 
				
			||||||
%global pypi_version 0.1.6
 | 
					%global pypi_version 0.1.8
 | 
				
			||||||
%global oname PortProtonQt
 | 
					%global oname PortProtonQt
 | 
				
			||||||
%global _python_no_extras_requires 1
 | 
					%global _python_no_extras_requires 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -21,9 +21,9 @@ Current translation status:
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
| Locale | Progress | Translated |
 | 
					| Locale | Progress | Translated |
 | 
				
			||||||
| :----- | -------: | ---------: |
 | 
					| :----- | -------: | ---------: |
 | 
				
			||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 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 233 |
 | 
					| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
				
			||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 of 233 |
 | 
					| [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 |
 | 
					| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
				
			||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 233 |
 | 
					| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
				
			||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 из 233 |
 | 
					| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 |
 | 
				
			||||||
 | 
					
 | 
				
			||||||
---
 | 
					---
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,17 +1,41 @@
 | 
				
			|||||||
import sys
 | 
					import sys
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import subprocess
 | 
				
			||||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
 | 
					from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
 | 
				
			||||||
from PySide6.QtWidgets import QApplication
 | 
					from PySide6.QtWidgets import QApplication
 | 
				
			||||||
from PySide6.QtGui import QIcon
 | 
					from PySide6.QtGui import QIcon
 | 
				
			||||||
from portprotonqt.main_window import MainWindow
 | 
					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.logger import get_logger, setup_logger
 | 
				
			||||||
from portprotonqt.cli import parse_args
 | 
					from portprotonqt.cli import parse_args
 | 
				
			||||||
 | 
					
 | 
				
			||||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
 | 
					__app_id__ = "ru.linux_gaming.PortProtonQt"
 | 
				
			||||||
__app_name__ = "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():
 | 
					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 = QApplication(sys.argv)
 | 
				
			||||||
    app.setWindowIcon(QIcon.fromTheme(__app_id__))
 | 
					    app.setWindowIcon(QIcon.fromTheme(__app_id__))
 | 
				
			||||||
    app.setDesktopFileName(__app_id__)
 | 
					    app.setDesktopFileName(__app_id__)
 | 
				
			||||||
@@ -34,7 +58,8 @@ def main():
 | 
				
			|||||||
    else:
 | 
					    else:
 | 
				
			||||||
        logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
 | 
					        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:
 | 
					    if args.fullscreen:
 | 
				
			||||||
        logger.info("Launching in fullscreen mode due to --fullscreen flag")
 | 
					        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:
 | 
					    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
				
			||||||
        cp.write(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():
 | 
					def read_sort_method():
 | 
				
			||||||
    """Reads the sort method from the [Games] section.
 | 
					    """Reads the sort method from the [Games] section.
 | 
				
			||||||
    Returns 'last_launch' if the parameter is not set.
 | 
					    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:
 | 
					    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
				
			||||||
        cp.write(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():
 | 
					def ensure_default_proxy_config():
 | 
				
			||||||
    """Ensures the [Proxy] section exists in the configuration file.
 | 
					    """Ensures the [Proxy] section exists in the configuration file.
 | 
				
			||||||
    Creates it with empty values if missing.
 | 
					    Creates it with empty values if missing.
 | 
				
			||||||
@@ -408,3 +447,22 @@ def save_favorite_folders(folders):
 | 
				
			|||||||
    cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
 | 
					    cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
 | 
				
			||||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
					    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
				
			||||||
        cp.write(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}")
 | 
					        logger.error(f"Ошибка при сохранении миниатюры: {e}")
 | 
				
			||||||
        return False
 | 
					        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):
 | 
					class FileSelectedSignal(QObject):
 | 
				
			||||||
    file_selected = Signal(str)  # Сигнал с путем к выбранному файлу
 | 
					    file_selected = Signal(str)  # Сигнал с путем к выбранному файлу
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -185,6 +309,7 @@ class FileExplorer(QDialog):
 | 
				
			|||||||
        self.initial_path = initial_path  # Store initial path if provided
 | 
					        self.initial_path = initial_path  # Store initial path if provided
 | 
				
			||||||
        self.thumbnail_cache = {}  # Cache for loaded thumbnails
 | 
					        self.thumbnail_cache = {}  # Cache for loaded thumbnails
 | 
				
			||||||
        self.pending_thumbnails = set()  # Track files pending thumbnail loading
 | 
					        self.pending_thumbnails = set()  # Track files pending thumbnail loading
 | 
				
			||||||
 | 
					        self.main_window = None  # Add reference to MainWindow
 | 
				
			||||||
        self.setup_ui()
 | 
					        self.setup_ui()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Window settings
 | 
					        # Window settings
 | 
				
			||||||
@@ -198,6 +323,7 @@ class FileExplorer(QDialog):
 | 
				
			|||||||
        while parent:
 | 
					        while parent:
 | 
				
			||||||
            if hasattr(parent, 'input_manager'):
 | 
					            if hasattr(parent, 'input_manager'):
 | 
				
			||||||
                self.input_manager = cast("MainWindow", parent).input_manager
 | 
					                self.input_manager = cast("MainWindow", parent).input_manager
 | 
				
			||||||
 | 
					                self.main_window = parent
 | 
				
			||||||
            if hasattr(parent, 'context_menu_manager'):
 | 
					            if hasattr(parent, 'context_menu_manager'):
 | 
				
			||||||
                self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
 | 
					                self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
 | 
				
			||||||
            parent = parent.parent()
 | 
					            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.current_path = os.path.expanduser("~")  # Fallback to home if initial path is invalid
 | 
				
			||||||
        self.update_file_list()
 | 
					        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 ThumbnailLoader(QRunnable):
 | 
				
			||||||
        """Class for asynchronous thumbnail loading in a separate thread."""
 | 
					        """Class for asynchronous thumbnail loading in a separate thread."""
 | 
				
			||||||
        class Signals(QObject):
 | 
					        class Signals(QObject):
 | 
				
			||||||
@@ -897,8 +1034,8 @@ class AddGameDialog(QDialog):
 | 
				
			|||||||
        """Обработчик выбора файла в FileExplorer"""
 | 
					        """Обработчик выбора файла в FileExplorer"""
 | 
				
			||||||
        self.exeEdit.setText(file_path)
 | 
					        self.exeEdit.setText(file_path)
 | 
				
			||||||
        self.last_exe_path = file_path  # Update last selected exe 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]
 | 
					            game_name = os.path.splitext(os.path.basename(file_path))[0]
 | 
				
			||||||
            self.nameEdit.setText(game_name)
 | 
					            self.nameEdit.setText(game_name)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1037,8 +1174,6 @@ Icon={icon_path}
 | 
				
			|||||||
        return desktop_entry, desktop_path
 | 
					        return desktop_entry, desktop_path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class WinetricksDialog(QDialog):
 | 
					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):
 | 
					    def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None):
 | 
				
			||||||
        super().__init__(parent)
 | 
					        super().__init__(parent)
 | 
				
			||||||
        self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
 | 
					        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.setup_ui()
 | 
				
			||||||
        self.load_lists()
 | 
					        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):
 | 
					    def update_winetricks(self):
 | 
				
			||||||
        """Update the winetricks script."""
 | 
					        """Update the winetricks script."""
 | 
				
			||||||
        if not self.downloader.has_internet():
 | 
					        if not self.downloader.has_internet():
 | 
				
			||||||
@@ -1143,15 +1308,15 @@ class WinetricksDialog(QDialog):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
    def setup_ui(self):
 | 
					    def setup_ui(self):
 | 
				
			||||||
        """Set up the user interface with tabs and tables."""
 | 
					        """Set up the user interface with tabs and tables."""
 | 
				
			||||||
        main_layout = QVBoxLayout(self)
 | 
					        self.main_layout = QVBoxLayout(self)
 | 
				
			||||||
        main_layout.setContentsMargins(10, 10, 10, 10)
 | 
					        self.main_layout.setContentsMargins(10, 10, 10, 10)
 | 
				
			||||||
        main_layout.setSpacing(10)
 | 
					        self.main_layout.setSpacing(10)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Log output
 | 
					        # Log output
 | 
				
			||||||
        self.log_output = QTextEdit()
 | 
					        self.log_output = QTextEdit()
 | 
				
			||||||
        self.log_output.setReadOnly(True)
 | 
					        self.log_output.setReadOnly(True)
 | 
				
			||||||
        self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
 | 
					        self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
 | 
				
			||||||
        main_layout.addWidget(self.log_output)
 | 
					        self.main_layout.addWidget(self.log_output)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Tab widget
 | 
					        # Tab widget
 | 
				
			||||||
        self.tab_widget = QTabWidget()
 | 
					        self.tab_widget = QTabWidget()
 | 
				
			||||||
@@ -1258,7 +1423,7 @@ class WinetricksDialog(QDialog):
 | 
				
			|||||||
            "settings": self.settings_container
 | 
					            "settings": self.settings_container
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        main_layout.addWidget(self.tab_widget)
 | 
					        self.main_layout.addWidget(self.tab_widget)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Buttons
 | 
					        # Buttons
 | 
				
			||||||
        button_layout = QHBoxLayout()
 | 
					        button_layout = QHBoxLayout()
 | 
				
			||||||
@@ -1272,7 +1437,7 @@ class WinetricksDialog(QDialog):
 | 
				
			|||||||
        button_layout.addWidget(self.cancel_button)
 | 
					        button_layout.addWidget(self.cancel_button)
 | 
				
			||||||
        button_layout.addWidget(self.force_button)
 | 
					        button_layout.addWidget(self.force_button)
 | 
				
			||||||
        button_layout.addWidget(self.install_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.cancel_button.clicked.connect(self.reject)
 | 
				
			||||||
        self.force_button.clicked.connect(lambda: self.install_selected(force=True))
 | 
					        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.append(message)
 | 
				
			||||||
        self.log_output.moveCursor(QTextCursor.MoveOperation.End)
 | 
					        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
 | 
					    # Required attributes
 | 
				
			||||||
    searchEdit: CustomLineEdit
 | 
					    searchEdit: CustomLineEdit
 | 
				
			||||||
    _last_card_width: int
 | 
					    _last_card_width: int
 | 
				
			||||||
 | 
					    card_width: int
 | 
				
			||||||
    current_hovered_card: GameCard | None
 | 
					    current_hovered_card: GameCard | None
 | 
				
			||||||
    current_focused_card: GameCard | None
 | 
					    current_focused_card: GameCard | None
 | 
				
			||||||
    gamesListWidget: QWidget | None
 | 
					    gamesListWidget: QWidget | None
 | 
				
			||||||
@@ -128,6 +129,8 @@ class GameLibraryManager:
 | 
				
			|||||||
        self.card_width = self.sizeSlider.value()
 | 
					        self.card_width = self.sizeSlider.value()
 | 
				
			||||||
        self.sizeSlider.setToolTip(f"{self.card_width} px")
 | 
					        self.sizeSlider.setToolTip(f"{self.card_width} px")
 | 
				
			||||||
        save_card_size(self.card_width)
 | 
					        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():
 | 
					        for card in self.game_card_cache.values():
 | 
				
			||||||
            card.update_card_size(self.card_width)
 | 
					            card.update_card_size(self.card_width)
 | 
				
			||||||
        self.update_game_grid()
 | 
					        self.update_game_grid()
 | 
				
			||||||
@@ -217,6 +220,16 @@ class GameLibraryManager:
 | 
				
			|||||||
        else:
 | 
					        else:
 | 
				
			||||||
            self._update_game_grid_immediate()
 | 
					            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):
 | 
					    def _update_game_grid_immediate(self):
 | 
				
			||||||
        """Updates the game grid with the provided or current game list."""
 | 
					        """Updates the game grid with the provided or current game list."""
 | 
				
			||||||
        if self.gamesListLayout is None or self.gamesListWidget is None:
 | 
					        if self.gamesListLayout is None or self.gamesListWidget is None:
 | 
				
			||||||
@@ -346,6 +359,8 @@ class GameLibraryManager:
 | 
				
			|||||||
                self.gamesListWidget.updateGeometry()
 | 
					                self.gamesListWidget.updateGeometry()
 | 
				
			||||||
                self.main_window._last_card_width = self.card_width
 | 
					                self.main_window._last_card_width = self.card_width
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					                self.force_update_cards_library()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.is_filtering = False  # Reset flag in any case
 | 
					        self.is_filtering = False  # Reset flag in any case
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _apply_filter_visibility(self, search_text: str):
 | 
					    def _apply_filter_visibility(self, search_text: str):
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,16 +4,16 @@ import os
 | 
				
			|||||||
from typing import Protocol, cast
 | 
					from typing import Protocol, cast
 | 
				
			||||||
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
 | 
					from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
 | 
				
			||||||
from enum import Enum
 | 
					from enum import Enum
 | 
				
			||||||
from pyudev import Context, Monitor, MonitorObserver, Device
 | 
					from pyudev import Context, Monitor, Device, Devices
 | 
				
			||||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView
 | 
					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.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
 | 
				
			||||||
from PySide6.QtGui import QKeyEvent, QMouseEvent
 | 
					from PySide6.QtGui import QKeyEvent, QMouseEvent
 | 
				
			||||||
from portprotonqt.logger import get_logger
 | 
					from portprotonqt.logger import get_logger
 | 
				
			||||||
from portprotonqt.image_utils import FullscreenDialog
 | 
					from portprotonqt.image_utils import FullscreenDialog
 | 
				
			||||||
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
 | 
					from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
 | 
				
			||||||
from portprotonqt.game_card import GameCard
 | 
					from portprotonqt.game_card import GameCard
 | 
				
			||||||
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config
 | 
					from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type
 | 
				
			||||||
from portprotonqt.dialogs import AddGameDialog, WinetricksDialog
 | 
					from portprotonqt.dialogs import AddGameDialog
 | 
				
			||||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
 | 
					from portprotonqt.virtual_keyboard import VirtualKeyboard
 | 
				
			||||||
 | 
					
 | 
				
			||||||
logger = get_logger(__name__)
 | 
					logger = get_logger(__name__)
 | 
				
			||||||
@@ -38,6 +38,7 @@ class MainWindowProtocol(Protocol):
 | 
				
			|||||||
    stackedWidget: QStackedWidget
 | 
					    stackedWidget: QStackedWidget
 | 
				
			||||||
    tabButtons: dict[int, QWidget]
 | 
					    tabButtons: dict[int, QWidget]
 | 
				
			||||||
    gamesListWidget: QWidget
 | 
					    gamesListWidget: QWidget
 | 
				
			||||||
 | 
					    autoInstallContainer: QWidget | None
 | 
				
			||||||
    currentDetailPage: QWidget | None
 | 
					    currentDetailPage: QWidget | None
 | 
				
			||||||
    current_exec_line: str | None
 | 
					    current_exec_line: str | None
 | 
				
			||||||
    current_add_game_dialog: AddGameDialog | 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)
 | 
					    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
 | 
					    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)
 | 
					    toggle_fullscreen = Signal(bool)  # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
 | 
				
			||||||
 | 
					    gamepad_hotplug = Signal(str)  # 'add' or 'remove'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(
 | 
					    def __init__(
 | 
				
			||||||
        self,
 | 
					        self,
 | 
				
			||||||
@@ -86,11 +88,17 @@ class InputManager(QObject):
 | 
				
			|||||||
        super().__init__(cast(QObject, main_window))
 | 
					        super().__init__(cast(QObject, main_window))
 | 
				
			||||||
        self._parent = main_window
 | 
					        self._parent = main_window
 | 
				
			||||||
        self._gamepad_handling_enabled = True
 | 
					        self._gamepad_handling_enabled = True
 | 
				
			||||||
        self.gamepad_type = GamepadType.UNKNOWN
 | 
					        type_str = read_gamepad_type()
 | 
				
			||||||
        # Ensure attributes exist on main_window
 | 
					        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.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
 | 
				
			||||||
        self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', 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.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.axis_deadzone = axis_deadzone
 | 
				
			||||||
        self.initial_axis_move_delay = initial_axis_move_delay
 | 
					        self.initial_axis_move_delay = initial_axis_move_delay
 | 
				
			||||||
        self.repeat_axis_move_delay = repeat_axis_move_delay
 | 
					        self.repeat_axis_move_delay = repeat_axis_move_delay
 | 
				
			||||||
@@ -143,37 +151,131 @@ class InputManager(QObject):
 | 
				
			|||||||
        # Initialize evdev + hotplug
 | 
					        # Initialize evdev + hotplug
 | 
				
			||||||
        self.init_gamepad()
 | 
					        self.init_gamepad()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def detect_gamepad_type(self, device: InputDevice) -> GamepadType:
 | 
					    def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
 | 
				
			||||||
        """
 | 
					        """Common navigation logic for game cards in a container."""
 | 
				
			||||||
        Определяет тип геймпада по capabilities
 | 
					        if container is None:
 | 
				
			||||||
        """
 | 
					            return
 | 
				
			||||||
        caps = device.capabilities()
 | 
					        focused = QApplication.focusWidget()
 | 
				
			||||||
        keys = set(caps.get(ecodes.EV_KEY, []))
 | 
					        game_cards = container.findChildren(GameCard)
 | 
				
			||||||
 | 
					        if not game_cards:
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Для EV_ABS вытаскиваем только коды (первый элемент кортежа)
 | 
					        scroll_area = container.parentWidget()
 | 
				
			||||||
        abs_axes = {a if isinstance(a, int) else a[0] for a in caps.get(ecodes.EV_ABS, [])}
 | 
					        while scroll_area and not isinstance(scroll_area, QScrollArea):
 | 
				
			||||||
 | 
					            scroll_area = scroll_area.parentWidget()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # Xbox layout
 | 
					        # If no focused widget or not a GameCard, focus the first card
 | 
				
			||||||
        if {ecodes.BTN_SOUTH, ecodes.BTN_EAST, ecodes.BTN_NORTH, ecodes.BTN_WEST}.issubset(keys):
 | 
					        if not isinstance(focused, GameCard) or focused not in game_cards:
 | 
				
			||||||
            if {ecodes.ABS_X, ecodes.ABS_Y, ecodes.ABS_RX, ecodes.ABS_RY}.issubset(abs_axes):
 | 
					            game_cards[0].setFocus()
 | 
				
			||||||
                self.gamepad_type = GamepadType.XBOX
 | 
					            if scroll_area:
 | 
				
			||||||
                return GamepadType.XBOX
 | 
					                scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # PlayStation layout
 | 
					        cards = container.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively)
 | 
				
			||||||
        if ecodes.BTN_TOUCH in keys or (ecodes.BTN_DPAD_UP in keys and ecodes.BTN_EAST in keys):
 | 
					        if not cards:
 | 
				
			||||||
            self.gamepad_type = GamepadType.PLAYSTATION
 | 
					            return
 | 
				
			||||||
            logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
 | 
					        # Group cards by rows with tolerance for y-position
 | 
				
			||||||
            return GamepadType.PLAYSTATION
 | 
					        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 (трекпады)
 | 
					        # Fallback: if focused card not found, select closest row by y-position
 | 
				
			||||||
        if any(a for a in abs_axes if a >= ecodes.ABS_MT_SLOT):
 | 
					        if current_row_idx is None:
 | 
				
			||||||
            self.gamepad_type = GamepadType.XBOX
 | 
					            if not sorted_rows:  # Additional safety check
 | 
				
			||||||
            logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
 | 
					                return
 | 
				
			||||||
            return GamepadType.XBOX
 | 
					            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
 | 
					        # Add null checks before using current_row_idx and current_col_idx
 | 
				
			||||||
        self.gamepad_type = GamepadType.XBOX
 | 
					        if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
 | 
				
			||||||
        return GamepadType.XBOX
 | 
					            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):
 | 
					    def enable_file_explorer_mode(self, file_explorer):
 | 
				
			||||||
        """Настройка обработки геймпада для FileExplorer"""
 | 
					        """Настройка обработки геймпада для FileExplorer"""
 | 
				
			||||||
@@ -354,6 +456,171 @@ class InputManager(QObject):
 | 
				
			|||||||
        except Exception as e:
 | 
					        except Exception as e:
 | 
				
			||||||
            logger.error("Error in FileExplorer dpad handler: %s", 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):
 | 
					    def handle_navigation_repeat(self):
 | 
				
			||||||
        """Плавное повторение движения с переменной скоростью для FileExplorer"""
 | 
					        """Плавное повторение движения с переменной скоростью для FileExplorer"""
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
@@ -483,17 +750,27 @@ class InputManager(QObject):
 | 
				
			|||||||
            if not app or not active:
 | 
					            if not app or not active:
 | 
				
			||||||
                return
 | 
					                return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            current_tab_index = self._parent.stackedWidget.currentIndex()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
 | 
					            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:
 | 
					                if focused == search_edit:
 | 
				
			||||||
                    keyboard = getattr(self._parent, 'keyboard', None)
 | 
					                    keyboard = getattr(self._parent, 'keyboard', None)
 | 
				
			||||||
                    if keyboard:
 | 
					                    if keyboard:
 | 
				
			||||||
                        keyboard.show_for_widget(focused)
 | 
					                        keyboard.show_for_widget(focused)
 | 
				
			||||||
                        return
 | 
					                        return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                # Handle Y button to focus search
 | 
					            # Handle Y button to focus search
 | 
				
			||||||
            if button_code in BUTTONS['prev_dir']:  # Y button
 | 
					            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:
 | 
					                if search_edit:
 | 
				
			||||||
                    search_edit.setFocus()
 | 
					                    search_edit.setFocus()
 | 
				
			||||||
                    return
 | 
					                    return
 | 
				
			||||||
@@ -594,39 +871,6 @@ class InputManager(QObject):
 | 
				
			|||||||
                    self._parent.toggleGame(self._parent.current_exec_line, None)
 | 
					                    self._parent.toggleGame(self._parent.current_exec_line, None)
 | 
				
			||||||
                    return
 | 
					                    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
 | 
					            # Standard navigation
 | 
				
			||||||
            if button_code in BUTTONS['confirm']:
 | 
					            if button_code in BUTTONS['confirm']:
 | 
				
			||||||
                self._parent.activateFocusedWidget()
 | 
					                self._parent.activateFocusedWidget()
 | 
				
			||||||
@@ -757,32 +1001,6 @@ class InputManager(QObject):
 | 
				
			|||||||
            if not app or not active:
 | 
					            if not app or not active:
 | 
				
			||||||
                return
 | 
					                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
 | 
					            # Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad
 | 
				
			||||||
            if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0:
 | 
					            if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0:
 | 
				
			||||||
                if isinstance(active, QMessageBox):  # Specific handling for QMessageBox
 | 
					                if isinstance(active, QMessageBox):  # Specific handling for QMessageBox
 | 
				
			||||||
@@ -898,132 +1116,43 @@ class InputManager(QObject):
 | 
				
			|||||||
                    focused.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
					                    focused.setFocus(Qt.FocusReason.OtherFocusReason)
 | 
				
			||||||
                    return
 | 
					                    return
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            # Library tab navigation (index 0)
 | 
					            # Search focus logic for tabs 0 and 1
 | 
				
			||||||
            if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
 | 
					            if code == ecodes.ABS_HAT0Y and value < 0:
 | 
				
			||||||
                focused = QApplication.focusWidget()
 | 
					                focused = QApplication.focusWidget()
 | 
				
			||||||
                game_cards = self._parent.gamesListWidget.findChildren(GameCard)
 | 
					                current_index = self._parent.stackedWidget.currentIndex()
 | 
				
			||||||
                if not game_cards:
 | 
					                if current_index in (0, 1) and isinstance(focused, GameCard):
 | 
				
			||||||
                    return
 | 
					                    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()
 | 
					            # Game cards navigation for tabs 0 and 1
 | 
				
			||||||
                while scroll_area and not isinstance(scroll_area, QScrollArea):
 | 
					            if code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
 | 
				
			||||||
                    scroll_area = scroll_area.parentWidget()
 | 
					                current_index = self._parent.stackedWidget.currentIndex()
 | 
				
			||||||
 | 
					                if current_index in (0, 1):
 | 
				
			||||||
                # If no focused widget or not a GameCard, focus the first card
 | 
					                    container = self._parent.gamesListWidget if current_index == 0 else self._parent.autoInstallContainer
 | 
				
			||||||
                if not isinstance(focused, GameCard) or focused not in game_cards:
 | 
					                    if container is None:
 | 
				
			||||||
                    game_cards[0].setFocus()
 | 
					 | 
				
			||||||
                    if scroll_area:
 | 
					 | 
				
			||||||
                        scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
 | 
					 | 
				
			||||||
                    return
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively)
 | 
					 | 
				
			||||||
                if not cards:
 | 
					 | 
				
			||||||
                    return
 | 
					 | 
				
			||||||
                # Group cards by rows with tolerance for y-position
 | 
					 | 
				
			||||||
                rows = {}
 | 
					 | 
				
			||||||
                y_tolerance = 10  # Allow slight variations in y-position
 | 
					 | 
				
			||||||
                for card in cards:
 | 
					 | 
				
			||||||
                    y = card.pos().y()
 | 
					 | 
				
			||||||
                    matched = False
 | 
					 | 
				
			||||||
                    for row_y in rows:
 | 
					 | 
				
			||||||
                        if abs(y - row_y) <= y_tolerance:
 | 
					 | 
				
			||||||
                            rows[row_y].append(card)
 | 
					 | 
				
			||||||
                            matched = True
 | 
					 | 
				
			||||||
                            break
 | 
					 | 
				
			||||||
                    if not matched:
 | 
					 | 
				
			||||||
                        rows[y] = [card]
 | 
					 | 
				
			||||||
                sorted_rows = sorted(rows.items(), key=lambda x: x[0])
 | 
					 | 
				
			||||||
                if not sorted_rows:
 | 
					 | 
				
			||||||
                    return
 | 
					 | 
				
			||||||
                current_row_idx = None
 | 
					 | 
				
			||||||
                current_col_idx = None
 | 
					 | 
				
			||||||
                for row_idx, (_y, row_cards) in enumerate(sorted_rows):
 | 
					 | 
				
			||||||
                    for idx, card in enumerate(row_cards):
 | 
					 | 
				
			||||||
                        if card == focused:
 | 
					 | 
				
			||||||
                            current_row_idx = row_idx
 | 
					 | 
				
			||||||
                            current_col_idx = idx
 | 
					 | 
				
			||||||
                            break
 | 
					 | 
				
			||||||
                    if current_row_idx is not None:
 | 
					 | 
				
			||||||
                        break
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                # Fallback: if focused card not found, select closest row by y-position
 | 
					 | 
				
			||||||
                if current_row_idx is None:
 | 
					 | 
				
			||||||
                    if not sorted_rows:  # Additional safety check
 | 
					 | 
				
			||||||
                        return
 | 
					                        return
 | 
				
			||||||
                    focused_y = focused.pos().y()
 | 
					                    self._navigate_game_cards(container, current_index, code, value)
 | 
				
			||||||
                    current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y))
 | 
					 | 
				
			||||||
                    if current_row_idx >= len(sorted_rows):  # Safety check
 | 
					 | 
				
			||||||
                        return
 | 
					 | 
				
			||||||
                    current_row = sorted_rows[current_row_idx][1]
 | 
					 | 
				
			||||||
                    focused_x = focused.pos().x() + focused.width() / 2
 | 
					 | 
				
			||||||
                    current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                # Add null checks before using current_row_idx and current_col_idx
 | 
					 | 
				
			||||||
                if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
 | 
					 | 
				
			||||||
                    return
 | 
					                    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
 | 
					            # Vertical navigation in other tabs
 | 
				
			||||||
            elif code == ecodes.ABS_HAT0Y and value != 0:
 | 
					            if code == ecodes.ABS_HAT0Y and value != 0:
 | 
				
			||||||
                focused = QApplication.focusWidget()
 | 
					                focused = QApplication.focusWidget()
 | 
				
			||||||
                page = self._parent.stackedWidget.currentWidget()
 | 
					                page = self._parent.stackedWidget.currentWidget()
 | 
				
			||||||
                if value > 0:  # Down
 | 
					                if value > 0:  # Down
 | 
				
			||||||
@@ -1308,76 +1437,258 @@ class InputManager(QObject):
 | 
				
			|||||||
        return super().eventFilter(obj, event)
 | 
					        return super().eventFilter(obj, event)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def init_gamepad(self) -> None:
 | 
					    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()
 | 
					        self.check_gamepad()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        # Запускаем udev monitor в отдельном потоке
 | 
				
			||||||
        threading.Thread(target=self.run_udev_monitor, daemon=True).start()
 | 
					        threading.Thread(target=self.run_udev_monitor, daemon=True).start()
 | 
				
			||||||
        logger.info("Gamepad support initialized with hotplug (evdev + pyudev)")
 | 
					        logger.info("Gamepad support initialized with hotplug (evdev + pyudev)")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def run_udev_monitor(self) -> None:
 | 
					    def run_udev_monitor(self) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Безопасный неблокирующий udev monitor для геймпадов.
 | 
				
			||||||
 | 
					        Использует select.poll() вместо блокирующего monitor.poll().
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            context = Context()
 | 
					            logger.info("Starting udev monitor...")
 | 
				
			||||||
            monitor = Monitor.from_netlink(context)
 | 
					            monitor = Monitor.from_netlink(self.udev_context)
 | 
				
			||||||
            monitor.filter_by(subsystem='input')
 | 
					            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:
 | 
					            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:
 | 
					        except Exception as e:
 | 
				
			||||||
            logger.error(f"Error in udev monitor: {e}", exc_info=True)
 | 
					            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:
 | 
					    def handle_udev_event(self, action: str, device: Device) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Обработчик udev событий для джойстиков.
 | 
				
			||||||
 | 
					        Отправляет сигнал в главный поток Qt вместо прямого вызова QTimer.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            if action == 'add':
 | 
					            if action == 'add':
 | 
				
			||||||
                time.sleep(0.1)
 | 
					                # Отправляем сигнал в главный поток Qt
 | 
				
			||||||
                self.check_gamepad()
 | 
					                # QTimer будет запущен там безопасно
 | 
				
			||||||
 | 
					                logger.debug("Emitting gamepad add signal")
 | 
				
			||||||
 | 
					                self.gamepad_hotplug.emit('add')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            elif action == 'remove' and self.gamepad:
 | 
					            elif action == 'remove' and self.gamepad:
 | 
				
			||||||
                if not any(self.gamepad.path == path for path in list_devices()):
 | 
					                # Проверяем конкретно наш геймпад по пути устройства
 | 
				
			||||||
                    logger.info("Gamepad disconnected")
 | 
					                device_node = device.device_node  # например, /dev/input/event3
 | 
				
			||||||
                    self.stop_rumble()
 | 
					
 | 
				
			||||||
                    self.gamepad = None
 | 
					                if device_node and self.gamepad.path == device_node:
 | 
				
			||||||
                    if self.gamepad_thread:
 | 
					                    logger.info(f"Connected gamepad disconnected: {device_node}")
 | 
				
			||||||
                        self.gamepad_thread.join()
 | 
					                    # Отправляем сигнал в главный поток
 | 
				
			||||||
                    # Signal to exit fullscreen mode
 | 
					                    self.gamepad_hotplug.emit('remove')
 | 
				
			||||||
                    self.toggle_fullscreen.emit(False)
 | 
					
 | 
				
			||||||
        except Exception as e:
 | 
					        except Exception as e:
 | 
				
			||||||
            logger.error(f"Error handling udev event: {e}", exc_info=True)
 | 
					            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:
 | 
					    def check_gamepad(self) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Проверка и подключение геймпада.
 | 
				
			||||||
 | 
					        Вызывается из главного потока Qt через QTimer (debounced).
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            new_gamepad = self.find_gamepad()
 | 
					            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)
 | 
					            if new_gamepad:
 | 
				
			||||||
                logger.info(f"Detected gamepad type: {self.gamepad_type.value}")
 | 
					                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.stop_rumble()
 | 
				
			||||||
                self.gamepad = new_gamepad
 | 
					                self.gamepad = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if self.gamepad_thread:
 | 
					                if self.gamepad_thread:
 | 
				
			||||||
                    self.gamepad_thread.join()
 | 
					                    self.gamepad_thread.join(timeout=2.0)
 | 
				
			||||||
                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)
 | 
					 | 
				
			||||||
                if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
 | 
					                if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
 | 
				
			||||||
                    self.toggle_fullscreen.emit(True)
 | 
					                    self.toggle_fullscreen.emit(False)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        except Exception as e:
 | 
					        except Exception as e:
 | 
				
			||||||
            logger.error(f"Error checking gamepad: {e}", exc_info=True)
 | 
					            logger.error(f"Error checking gamepad: {e}", exc_info=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def find_gamepad(self) -> InputDevice | None:
 | 
					    def find_gamepad(self) -> InputDevice | None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Находит первый доступный геймпад.
 | 
				
			||||||
 | 
					        Оптимизирован: предварительная фильтрация по capabilities перед udev-запросами.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            devices = [InputDevice(path) for path in list_devices()]
 | 
					            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:
 | 
					            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:
 | 
					                if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
 | 
				
			||||||
                    logger.debug(f"Skipping ASRock LED controller: {device.name}")
 | 
					 | 
				
			||||||
                    continue
 | 
					                    continue
 | 
				
			||||||
                caps = device.capabilities()
 | 
					
 | 
				
			||||||
                if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
 | 
					                # Предварительная фильтрация: проверяем capabilities
 | 
				
			||||||
                    return device
 | 
					                # Джойстик должен иметь хотя бы оси (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
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        except Exception as e:
 | 
					        except Exception as e:
 | 
				
			||||||
            logger.error(f"Error finding gamepad: {e}", exc_info=True)
 | 
					            logger.error(f"Error finding gamepad: {e}", exc_info=True)
 | 
				
			||||||
            return None
 | 
					            return None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def monitor_gamepad(self) -> None:
 | 
					    def monitor_gamepad(self) -> None:
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
            if not self.gamepad:
 | 
					            if not self.gamepad:
 | 
				
			||||||
@@ -1441,16 +1752,32 @@ class InputManager(QObject):
 | 
				
			|||||||
            self.gamepad = None
 | 
					            self.gamepad = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def cleanup(self) -> None:
 | 
					    def cleanup(self) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Корректное завершение работы с геймпадом и udev монитором.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
        try:
 | 
					        try:
 | 
				
			||||||
 | 
					            # Флаг для остановки udev monitor loop
 | 
				
			||||||
            self.running = False
 | 
					            self.running = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Останавливаем все таймеры
 | 
				
			||||||
 | 
					            if hasattr(self, 'gamepad_check_timer'):
 | 
				
			||||||
 | 
					                self.gamepad_check_timer.stop()
 | 
				
			||||||
            self.dpad_timer.stop()
 | 
					            self.dpad_timer.stop()
 | 
				
			||||||
            self.nav_timer.stop()
 | 
					            self.nav_timer.stop()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            # Очистка геймпада
 | 
				
			||||||
            self.stop_rumble()
 | 
					            self.stop_rumble()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if self.gamepad_thread:
 | 
					            if self.gamepad_thread:
 | 
				
			||||||
                self.gamepad_thread.join()
 | 
					                self.gamepad_thread.join(timeout=2.0)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            if self.gamepad:
 | 
					            if self.gamepad:
 | 
				
			||||||
                self.gamepad.close()
 | 
					                self.gamepad.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            self.gamepad = None
 | 
					            self.gamepad = None
 | 
				
			||||||
            self.gamepad_type = GamepadType.UNKNOWN
 | 
					            self.gamepad_type = GamepadType.UNKNOWN
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					            logger.info("Gamepad cleanup completed")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        except Exception as e:
 | 
					        except Exception as e:
 | 
				
			||||||
            logger.error(f"Error during cleanup: {e}", exc_info=True)
 | 
					            logger.error(f"Error during cleanup: {e}", exc_info=True)
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,7 +9,7 @@ msgid ""
 | 
				
			|||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
					"Project-Id-Version: PROJECT VERSION\n"
 | 
				
			||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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"
 | 
					"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
				
			||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
					"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
				
			||||||
"Language: de_DE\n"
 | 
					"Language: de_DE\n"
 | 
				
			||||||
@@ -252,13 +252,37 @@ msgstr ""
 | 
				
			|||||||
msgid "Select All"
 | 
					msgid "Select All"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#, python-brace-format
 | 
					msgid "Open"
 | 
				
			||||||
msgid "Launching {0}"
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Select Dir"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Prev Dir"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Cancel"
 | 
					msgid "Cancel"
 | 
				
			||||||
msgstr ""
 | 
					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"
 | 
					msgid "File Explorer"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -326,12 +350,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Settings"
 | 
					msgid "Settings"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Force Install"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Install"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Winetricks not found. Please try again."
 | 
					msgid "Winetricks not found. Please try again."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -395,9 +413,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Auto Install"
 | 
					msgid "Auto Install"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Emulators"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Wine Settings"
 | 
					msgid "Wine Settings"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -416,6 +431,25 @@ msgstr ""
 | 
				
			|||||||
msgid "Search"
 | 
					msgid "Search"
 | 
				
			||||||
msgstr ""
 | 
					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..."
 | 
					msgid "Loading Steam games..."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -432,12 +466,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Added '{name}'"
 | 
					msgid "Added '{name}'"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Here you can configure automatic game installation..."
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "List of available emulators and their configuration..."
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Compatibility tool:"
 | 
					msgid "Compatibility tool:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -450,12 +478,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Registry Editor"
 | 
					msgid "Registry Editor"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Control Panel"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Task Manager"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Command Prompt"
 | 
					msgid "Command Prompt"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -477,6 +499,29 @@ msgstr ""
 | 
				
			|||||||
msgid "Clear Prefix"
 | 
					msgid "Clear Prefix"
 | 
				
			||||||
msgstr ""
 | 
					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."
 | 
					msgid "Failed to start backup process."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -552,6 +597,9 @@ msgstr ""
 | 
				
			|||||||
msgid "Games Display Filter:"
 | 
					msgid "Games Display Filter:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Gamepad Type:"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Proxy URL"
 | 
					msgid "Proxy URL"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -576,6 +624,12 @@ msgstr ""
 | 
				
			|||||||
msgid "Application Fullscreen Mode:"
 | 
					msgid "Application Fullscreen Mode:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Minimize to tray on close"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Application Close Mode:"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
					msgid "Auto Fullscreen on Gamepad connected"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,7 +9,7 @@ msgid ""
 | 
				
			|||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
					"Project-Id-Version: PROJECT VERSION\n"
 | 
				
			||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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"
 | 
					"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
				
			||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
					"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
				
			||||||
"Language: es_ES\n"
 | 
					"Language: es_ES\n"
 | 
				
			||||||
@@ -252,13 +252,37 @@ msgstr ""
 | 
				
			|||||||
msgid "Select All"
 | 
					msgid "Select All"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#, python-brace-format
 | 
					msgid "Open"
 | 
				
			||||||
msgid "Launching {0}"
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Select Dir"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Prev Dir"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Cancel"
 | 
					msgid "Cancel"
 | 
				
			||||||
msgstr ""
 | 
					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"
 | 
					msgid "File Explorer"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -326,12 +350,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Settings"
 | 
					msgid "Settings"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Force Install"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Install"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Winetricks not found. Please try again."
 | 
					msgid "Winetricks not found. Please try again."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -395,9 +413,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Auto Install"
 | 
					msgid "Auto Install"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Emulators"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Wine Settings"
 | 
					msgid "Wine Settings"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -416,6 +431,25 @@ msgstr ""
 | 
				
			|||||||
msgid "Search"
 | 
					msgid "Search"
 | 
				
			||||||
msgstr ""
 | 
					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..."
 | 
					msgid "Loading Steam games..."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -432,12 +466,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Added '{name}'"
 | 
					msgid "Added '{name}'"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Here you can configure automatic game installation..."
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "List of available emulators and their configuration..."
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Compatibility tool:"
 | 
					msgid "Compatibility tool:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -450,12 +478,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Registry Editor"
 | 
					msgid "Registry Editor"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Control Panel"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Task Manager"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Command Prompt"
 | 
					msgid "Command Prompt"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -477,6 +499,29 @@ msgstr ""
 | 
				
			|||||||
msgid "Clear Prefix"
 | 
					msgid "Clear Prefix"
 | 
				
			||||||
msgstr ""
 | 
					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."
 | 
					msgid "Failed to start backup process."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -552,6 +597,9 @@ msgstr ""
 | 
				
			|||||||
msgid "Games Display Filter:"
 | 
					msgid "Games Display Filter:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Gamepad Type:"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Proxy URL"
 | 
					msgid "Proxy URL"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -576,6 +624,12 @@ msgstr ""
 | 
				
			|||||||
msgid "Application Fullscreen Mode:"
 | 
					msgid "Application Fullscreen Mode:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Minimize to tray on close"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Application Close Mode:"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
					msgid "Auto Fullscreen on Gamepad connected"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,7 +9,7 @@ msgid ""
 | 
				
			|||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
 | 
					"Project-Id-Version: PortProtonQt 0.1.1\n"
 | 
				
			||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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"
 | 
					"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
				
			||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
					"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
				
			||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
 | 
					"Language-Team: LANGUAGE <LL@li.org>\n"
 | 
				
			||||||
@@ -250,13 +250,37 @@ msgstr ""
 | 
				
			|||||||
msgid "Select All"
 | 
					msgid "Select All"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#, python-brace-format
 | 
					msgid "Open"
 | 
				
			||||||
msgid "Launching {0}"
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Select Dir"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Prev Dir"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Cancel"
 | 
					msgid "Cancel"
 | 
				
			||||||
msgstr ""
 | 
					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"
 | 
					msgid "File Explorer"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -324,12 +348,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Settings"
 | 
					msgid "Settings"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Force Install"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Install"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Winetricks not found. Please try again."
 | 
					msgid "Winetricks not found. Please try again."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -393,9 +411,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Auto Install"
 | 
					msgid "Auto Install"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Emulators"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Wine Settings"
 | 
					msgid "Wine Settings"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -414,6 +429,25 @@ msgstr ""
 | 
				
			|||||||
msgid "Search"
 | 
					msgid "Search"
 | 
				
			||||||
msgstr ""
 | 
					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..."
 | 
					msgid "Loading Steam games..."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -430,12 +464,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Added '{name}'"
 | 
					msgid "Added '{name}'"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Here you can configure automatic game installation..."
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "List of available emulators and their configuration..."
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Compatibility tool:"
 | 
					msgid "Compatibility tool:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -448,12 +476,6 @@ msgstr ""
 | 
				
			|||||||
msgid "Registry Editor"
 | 
					msgid "Registry Editor"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Control Panel"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Task Manager"
 | 
					 | 
				
			||||||
msgstr ""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Command Prompt"
 | 
					msgid "Command Prompt"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -475,6 +497,29 @@ msgstr ""
 | 
				
			|||||||
msgid "Clear Prefix"
 | 
					msgid "Clear Prefix"
 | 
				
			||||||
msgstr ""
 | 
					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."
 | 
					msgid "Failed to start backup process."
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -550,6 +595,9 @@ msgstr ""
 | 
				
			|||||||
msgid "Games Display Filter:"
 | 
					msgid "Games Display Filter:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Gamepad Type:"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Proxy URL"
 | 
					msgid "Proxy URL"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -574,6 +622,12 @@ msgstr ""
 | 
				
			|||||||
msgid "Application Fullscreen Mode:"
 | 
					msgid "Application Fullscreen Mode:"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Minimize to tray on close"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Application Close Mode:"
 | 
				
			||||||
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
					msgid "Auto Fullscreen on Gamepad connected"
 | 
				
			||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -9,8 +9,8 @@ msgid ""
 | 
				
			|||||||
msgstr ""
 | 
					msgstr ""
 | 
				
			||||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
					"Project-Id-Version: PROJECT VERSION\n"
 | 
				
			||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\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: 2025-10-09 16:37+0500\n"
 | 
					"PO-Revision-Date: 2025-10-16 14:54+0500\n"
 | 
				
			||||||
"Last-Translator: \n"
 | 
					"Last-Translator: \n"
 | 
				
			||||||
"Language: ru_RU\n"
 | 
					"Language: ru_RU\n"
 | 
				
			||||||
"Language-Team: ru_RU <LL@li.org>\n"
 | 
					"Language-Team: ru_RU <LL@li.org>\n"
 | 
				
			||||||
@@ -259,13 +259,37 @@ msgstr "Удалить"
 | 
				
			|||||||
msgid "Select All"
 | 
					msgid "Select All"
 | 
				
			||||||
msgstr "Выбрать всё"
 | 
					msgstr "Выбрать всё"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#, python-brace-format
 | 
					msgid "Open"
 | 
				
			||||||
msgid "Launching {0}"
 | 
					msgstr "Открыть"
 | 
				
			||||||
msgstr "Идёт запуск {0}"
 | 
					
 | 
				
			||||||
 | 
					msgid "Select Dir"
 | 
				
			||||||
 | 
					msgstr "Выбрать папку"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Prev Dir"
 | 
				
			||||||
 | 
					msgstr "Предыдущий каталог"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Cancel"
 | 
					msgid "Cancel"
 | 
				
			||||||
msgstr "Отмена"
 | 
					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"
 | 
					msgid "File Explorer"
 | 
				
			||||||
msgstr "Проводник"
 | 
					msgstr "Проводник"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -333,12 +357,6 @@ msgstr "Шрифты"
 | 
				
			|||||||
msgid "Settings"
 | 
					msgid "Settings"
 | 
				
			||||||
msgstr "Настройки"
 | 
					msgstr "Настройки"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Force Install"
 | 
					 | 
				
			||||||
msgstr "Принудительно установить"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Install"
 | 
					 | 
				
			||||||
msgstr "Установить"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Winetricks not found. Please try again."
 | 
					msgid "Winetricks not found. Please try again."
 | 
				
			||||||
msgstr "Winetricks не найден. Повторите попытку."
 | 
					msgstr "Winetricks не найден. Повторите попытку."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -402,9 +420,6 @@ msgstr "Библиотека"
 | 
				
			|||||||
msgid "Auto Install"
 | 
					msgid "Auto Install"
 | 
				
			||||||
msgstr "Автоустановка"
 | 
					msgstr "Автоустановка"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Emulators"
 | 
					 | 
				
			||||||
msgstr "Эмуляторы"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Wine Settings"
 | 
					msgid "Wine Settings"
 | 
				
			||||||
msgstr "Настройки wine"
 | 
					msgstr "Настройки wine"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -423,6 +438,25 @@ msgstr "Полный экран"
 | 
				
			|||||||
msgid "Search"
 | 
					msgid "Search"
 | 
				
			||||||
msgstr "Поиск"
 | 
					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..."
 | 
					msgid "Loading Steam games..."
 | 
				
			||||||
msgstr "Загрузка игр из Steam..."
 | 
					msgstr "Загрузка игр из Steam..."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -439,12 +473,6 @@ msgstr "Найти игры..."
 | 
				
			|||||||
msgid "Added '{name}'"
 | 
					msgid "Added '{name}'"
 | 
				
			||||||
msgstr "'{name}' добавлен(а)"
 | 
					msgstr "'{name}' добавлен(а)"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Here you can configure automatic game installation..."
 | 
					 | 
				
			||||||
msgstr "Здесь можно настроить автоматическую установку игр..."
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "List of available emulators and their configuration..."
 | 
					 | 
				
			||||||
msgstr "Список доступных эмуляторов и их настройка..."
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Compatibility tool:"
 | 
					msgid "Compatibility tool:"
 | 
				
			||||||
msgstr "Инструмент совместимости:"
 | 
					msgstr "Инструмент совместимости:"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -457,12 +485,6 @@ msgstr "Конфигурация Wine"
 | 
				
			|||||||
msgid "Registry Editor"
 | 
					msgid "Registry Editor"
 | 
				
			||||||
msgstr "Редактор реестра"
 | 
					msgstr "Редактор реестра"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Control Panel"
 | 
					 | 
				
			||||||
msgstr "Панель управления"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Task Manager"
 | 
					 | 
				
			||||||
msgstr "Диспетчер задач"
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
msgid "Command Prompt"
 | 
					msgid "Command Prompt"
 | 
				
			||||||
msgstr "Командная строка"
 | 
					msgstr "Командная строка"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -484,6 +506,31 @@ msgstr "Удалить Префикс"
 | 
				
			|||||||
msgid "Clear Prefix"
 | 
					msgid "Clear Prefix"
 | 
				
			||||||
msgstr "Очистить Префикс"
 | 
					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."
 | 
					msgid "Failed to start backup process."
 | 
				
			||||||
msgstr "Не удалось запустить процесс резервного копирования."
 | 
					msgstr "Не удалось запустить процесс резервного копирования."
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -559,6 +606,9 @@ msgstr "все"
 | 
				
			|||||||
msgid "Games Display Filter:"
 | 
					msgid "Games Display Filter:"
 | 
				
			||||||
msgstr "Фильтр игр:"
 | 
					msgstr "Фильтр игр:"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Gamepad Type:"
 | 
				
			||||||
 | 
					msgstr "Тип геймпада:"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Proxy URL"
 | 
					msgid "Proxy URL"
 | 
				
			||||||
msgstr "Адрес прокси"
 | 
					msgstr "Адрес прокси"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -583,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи
 | 
				
			|||||||
msgid "Application Fullscreen Mode:"
 | 
					msgid "Application Fullscreen Mode:"
 | 
				
			||||||
msgstr "Режим полноэкранного отображения приложения:"
 | 
					msgstr "Режим полноэкранного отображения приложения:"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Minimize to tray on close"
 | 
				
			||||||
 | 
					msgstr "Сворачивать в трей при закрытии"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					msgid "Application Close Mode:"
 | 
				
			||||||
 | 
					msgstr "Режим закрытия приложения:"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
					msgid "Auto Fullscreen on Gamepad connected"
 | 
				
			||||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
 | 
					msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -5,12 +5,13 @@ import signal
 | 
				
			|||||||
import subprocess
 | 
					import subprocess
 | 
				
			||||||
import sys
 | 
					import sys
 | 
				
			||||||
import psutil
 | 
					import psutil
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from portprotonqt.logger import get_logger
 | 
					from portprotonqt.logger import get_logger
 | 
				
			||||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
 | 
					from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
 | 
				
			||||||
from portprotonqt.game_card import GameCard
 | 
					from portprotonqt.game_card import GameCard
 | 
				
			||||||
from portprotonqt.animations import DetailPageAnimations
 | 
					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.portproton_api import PortProtonAPI
 | 
				
			||||||
from portprotonqt.input_manager import InputManager
 | 
					from portprotonqt.input_manager import InputManager
 | 
				
			||||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
 | 
					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,
 | 
					    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_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
 | 
				
			||||||
    save_fullscreen_config, read_window_geometry, save_window_geometry, reset_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.localization import _, get_egs_language, read_metadata_translations
 | 
				
			||||||
from portprotonqt.howlongtobeat_api import HowLongToBeat
 | 
					from portprotonqt.howlongtobeat_api import HowLongToBeat
 | 
				
			||||||
@@ -38,7 +40,7 @@ from portprotonqt.game_library_manager import GameLibraryManager
 | 
				
			|||||||
from portprotonqt.virtual_keyboard import VirtualKeyboard
 | 
					from portprotonqt.virtual_keyboard import VirtualKeyboard
 | 
				
			||||||
 | 
					
 | 
				
			||||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
 | 
					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.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
 | 
				
			||||||
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
 | 
					from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
 | 
				
			||||||
from typing import cast
 | 
					from typing import cast
 | 
				
			||||||
@@ -53,7 +55,7 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
    update_progress = Signal(int)
 | 
					    update_progress = Signal(int)
 | 
				
			||||||
    update_status_message = Signal(str, int)
 | 
					    update_status_message = Signal(str, int)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def __init__(self, app_name: str):
 | 
					    def __init__(self, app_name: str, version: str):
 | 
				
			||||||
        super().__init__()
 | 
					        super().__init__()
 | 
				
			||||||
        self.theme_manager = ThemeManager()
 | 
					        self.theme_manager = ThemeManager()
 | 
				
			||||||
        self.is_exiting = False
 | 
					        self.is_exiting = False
 | 
				
			||||||
@@ -62,8 +64,9 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.theme = self.theme_manager.apply_theme(selected_theme)
 | 
					        self.theme = self.theme_manager.apply_theme(selected_theme)
 | 
				
			||||||
        self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
 | 
					        self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
 | 
				
			||||||
        self.card_width = read_card_size()
 | 
					        self.card_width = read_card_size()
 | 
				
			||||||
 | 
					        self.auto_card_width = read_auto_card_size()
 | 
				
			||||||
        self._last_card_width = self.card_width
 | 
					        self._last_card_width = self.card_width
 | 
				
			||||||
        self.setWindowTitle(app_name)
 | 
					        self.setWindowTitle(f"{app_name} {version}")
 | 
				
			||||||
        self.setMinimumSize(800, 600)
 | 
					        self.setMinimumSize(800, 600)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.games = []
 | 
					        self.games = []
 | 
				
			||||||
@@ -129,6 +132,11 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.update_progress.connect(self.progress_bar.setValue)
 | 
					        self.update_progress.connect(self.progress_bar.setValue)
 | 
				
			||||||
        self.update_status_message.connect(self.statusBar().showMessage)
 | 
					        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
 | 
					        # Центральный виджет и основной layout
 | 
				
			||||||
        centralWidget = QWidget()
 | 
					        centralWidget = QWidget()
 | 
				
			||||||
        self.setCentralWidget(centralWidget)
 | 
					        self.setCentralWidget(centralWidget)
 | 
				
			||||||
@@ -166,7 +174,6 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        tabs = [
 | 
					        tabs = [
 | 
				
			||||||
            _("Library"),
 | 
					            _("Library"),
 | 
				
			||||||
            _("Auto Install"),
 | 
					            _("Auto Install"),
 | 
				
			||||||
            _("Emulators"),
 | 
					 | 
				
			||||||
            _("Wine Settings"),
 | 
					            _("Wine Settings"),
 | 
				
			||||||
            _("PortProton Settings"),
 | 
					            _("PortProton Settings"),
 | 
				
			||||||
            _("Themes")
 | 
					            _("Themes")
 | 
				
			||||||
@@ -198,7 +205,6 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
        self.createInstalledTab()
 | 
					        self.createInstalledTab()
 | 
				
			||||||
        self.createAutoInstallTab()
 | 
					        self.createAutoInstallTab()
 | 
				
			||||||
        self.createEmulatorsTab()
 | 
					 | 
				
			||||||
        self.createWineTab()
 | 
					        self.createWineTab()
 | 
				
			||||||
        self.createPortProtonTab()
 | 
					        self.createPortProtonTab()
 | 
				
			||||||
        self.createThemeTab()
 | 
					        self.createThemeTab()
 | 
				
			||||||
@@ -256,6 +262,10 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
                GamepadType.XBOX: "xbox_y",
 | 
					                GamepadType.XBOX: "xbox_y",
 | 
				
			||||||
                GamepadType.PLAYSTATION: "ps_square",
 | 
					                GamepadType.PLAYSTATION: "ps_square",
 | 
				
			||||||
            },
 | 
					            },
 | 
				
			||||||
 | 
					            'prev_dir': {
 | 
				
			||||||
 | 
					                GamepadType.XBOX: "xbox_y",
 | 
				
			||||||
 | 
					                GamepadType.PLAYSTATION: "ps_square",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
        return mappings.get(action, {}).get(gtype, "placeholder")
 | 
					        return mappings.get(action, {}).get(gtype, "placeholder")
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -439,6 +449,116 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        # Update navigation buttons
 | 
					        # Update navigation buttons
 | 
				
			||||||
        self.updateNavButtons()
 | 
					        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)
 | 
					    @Slot(list)
 | 
				
			||||||
    def on_games_loaded(self, games: list[tuple]):
 | 
					    def on_games_loaded(self, games: list[tuple]):
 | 
				
			||||||
        self.game_library_manager.set_games(games)
 | 
					        self.game_library_manager.set_games(games)
 | 
				
			||||||
@@ -720,6 +840,25 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        for i, btn in self.tabButtons.items():
 | 
					        for i, btn in self.tabButtons.items():
 | 
				
			||||||
            btn.setChecked(i == index)
 | 
					            btn.setChecked(i == index)
 | 
				
			||||||
        self.stackedWidget.setCurrentIndex(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):
 | 
					    def openSystemOverlay(self):
 | 
				
			||||||
        """Opens the system overlay dialog."""
 | 
					        """Opens the system overlay dialog."""
 | 
				
			||||||
@@ -960,52 +1099,197 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
                get_steam_game_info_async(final_name, exec_line, on_steam_info)
 | 
					                get_steam_game_info_async(final_name, exec_line, on_steam_info)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def createAutoInstallTab(self):
 | 
					    def createAutoInstallTab(self):
 | 
				
			||||||
        """Вкладка 'Auto Install'."""
 | 
					        autoInstallPage = QWidget()
 | 
				
			||||||
        self.autoInstallWidget = QWidget()
 | 
					        autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
 | 
				
			||||||
        self.autoInstallWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
 | 
					        autoInstallLayout = QVBoxLayout(autoInstallPage)
 | 
				
			||||||
        self.autoInstallWidget.setObjectName("otherPage")
 | 
					        autoInstallLayout.setSpacing(15)
 | 
				
			||||||
        layout = QVBoxLayout(self.autoInstallWidget)
 | 
					 | 
				
			||||||
        layout.setContentsMargins(10, 18, 10, 10)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.autoInstallTitle = QLabel(_("Auto Install"))
 | 
					        # Верхняя панель с заголовком и поиском
 | 
				
			||||||
        self.autoInstallTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE)
 | 
					        headerWidget = QWidget()
 | 
				
			||||||
        self.autoInstallTitle.setObjectName("tabTitle")
 | 
					        headerLayout = QHBoxLayout(headerWidget)
 | 
				
			||||||
        layout.addWidget(self.autoInstallTitle)
 | 
					        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)
 | 
					        titleLabel = QLabel(_("Auto Install"))
 | 
				
			||||||
        self.autoInstallContent.setObjectName("tabContent")
 | 
					        titleLabel.setStyleSheet(self.theme.TAB_TITLE_STYLE)
 | 
				
			||||||
        layout.addWidget(self.autoInstallContent)
 | 
					        titleLabel.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
 | 
				
			||||||
        layout.addStretch(1)
 | 
					        headerLayout.addWidget(titleLabel)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.stackedWidget.addWidget(self.autoInstallWidget)
 | 
					        headerLayout.addStretch()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def createEmulatorsTab(self):
 | 
					        # Поисковая строка
 | 
				
			||||||
        """Вкладка 'Emulators'."""
 | 
					        self.autoInstallSearchLineEdit = CustomLineEdit(self, theme=self.theme)
 | 
				
			||||||
        self.emulatorsWidget = QWidget()
 | 
					        icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search"))
 | 
				
			||||||
        self.emulatorsWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
 | 
					        action_pos = QLineEdit.ActionPosition.LeadingPosition
 | 
				
			||||||
        self.emulatorsWidget.setObjectName("otherPage")
 | 
					        self.search_action = self.autoInstallSearchLineEdit.addAction(icon, action_pos)
 | 
				
			||||||
        layout = QVBoxLayout(self.emulatorsWidget)
 | 
					        self.autoInstallSearchLineEdit.setMaximumWidth(200)
 | 
				
			||||||
        layout.setContentsMargins(10, 18, 10, 10)
 | 
					        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"))
 | 
					        autoInstallLayout.addWidget(headerWidget)
 | 
				
			||||||
        self.emulatorsTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE)
 | 
					 | 
				
			||||||
        self.emulatorsTitle.setObjectName("tabTitle")
 | 
					 | 
				
			||||||
        layout.addWidget(self.emulatorsTitle)
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.emulatorsContent = QLabel(_("List of available emulators and their configuration..."))
 | 
					        # Прогресс-бар
 | 
				
			||||||
        self.emulatorsContent.setStyleSheet(self.theme.CONTENT_STYLE)
 | 
					        self.autoInstallProgress = QProgressBar()
 | 
				
			||||||
        self.emulatorsContent.setObjectName("tabContent")
 | 
					        self.autoInstallProgress.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
 | 
				
			||||||
        layout.addWidget(self.emulatorsContent)
 | 
					        self.autoInstallProgress.setVisible(False)
 | 
				
			||||||
        layout.addStretch(1)
 | 
					        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):
 | 
					    def createWineTab(self):
 | 
				
			||||||
        """Вкладка 'Wine Settings'."""
 | 
					        """Вкладка 'Wine Settings'."""
 | 
				
			||||||
        self.wineWidget = QWidget()
 | 
					        self.wineWidget = QWidget()
 | 
				
			||||||
        self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
 | 
					        self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
 | 
				
			||||||
        self.wineWidget.setObjectName("otherPage")
 | 
					 | 
				
			||||||
        layout = QVBoxLayout(self.wineWidget)
 | 
					        layout = QVBoxLayout(self.wineWidget)
 | 
				
			||||||
        layout.setContentsMargins(10, 18, 10, 10)
 | 
					        layout.setContentsMargins(10, 18, 10, 10)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1061,21 +1345,20 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        tools_grid.setSpacing(6)
 | 
					        tools_grid.setSpacing(6)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        tools = [
 | 
					        tools = [
 | 
				
			||||||
            ("winecfg", _("Wine Configuration")),
 | 
					            ("--winecfg", _("Wine Configuration")),
 | 
				
			||||||
            ("regedit", _("Registry Editor")),
 | 
					            ("--winereg", _("Registry Editor")),
 | 
				
			||||||
            ("control", _("Control Panel")),
 | 
					            ("--winefile", _("File Explorer")),
 | 
				
			||||||
            ("taskmgr", _("Task Manager")),
 | 
					            ("--winecmd", _("Command Prompt")),
 | 
				
			||||||
            ("explorer", _("File Explorer")),
 | 
					            ("--wine_uninstaller", _("Uninstaller")),
 | 
				
			||||||
            ("cmd", _("Command Prompt")),
 | 
					 | 
				
			||||||
            ("uninstaller", _("Uninstaller")),
 | 
					 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for i, (_tool_cmd, tool_name) in enumerate(tools):
 | 
					        for i, (tool_cmd, tool_name) in enumerate(tools):
 | 
				
			||||||
            row = i // 3
 | 
					            row = i // 3
 | 
				
			||||||
            col = i % 3
 | 
					            col = i % 3
 | 
				
			||||||
            btn = AutoSizeButton(tool_name, update_size=False)
 | 
					            btn = AutoSizeButton(tool_name, update_size=False)
 | 
				
			||||||
            btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
					            btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
				
			||||||
            btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
					            btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
				
			||||||
 | 
					            btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t))
 | 
				
			||||||
            tools_grid.addWidget(btn, row, col)
 | 
					            tools_grid.addWidget(btn, row, col)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for col in range(3):
 | 
					        for col in range(3):
 | 
				
			||||||
@@ -1093,7 +1376,7 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
            (_("Load Prefix Backup"), self.load_prefix_backup),
 | 
					            (_("Load Prefix Backup"), self.load_prefix_backup),
 | 
				
			||||||
            (_("Delete Compatibility Tool"), self.delete_compat_tool),
 | 
					            (_("Delete Compatibility Tool"), self.delete_compat_tool),
 | 
				
			||||||
            (_("Delete Prefix"), self.delete_prefix),
 | 
					            (_("Delete Prefix"), self.delete_prefix),
 | 
				
			||||||
            (_("Clear Prefix"), None),
 | 
					            (_("Clear Prefix"), self.clear_prefix),
 | 
				
			||||||
        ]
 | 
					        ]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        for i, (text, callback) in enumerate(additional_buttons):
 | 
					        for i, (text, callback) in enumerate(additional_buttons):
 | 
				
			||||||
@@ -1114,8 +1397,220 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        additional_grid.setContentsMargins(10, 6, 10, 0)
 | 
					        additional_grid.setContentsMargins(10, 6, 10, 0)
 | 
				
			||||||
        layout.addStretch(1)
 | 
					        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)
 | 
					        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):
 | 
					    def create_prefix_backup(self):
 | 
				
			||||||
        selected_prefix = self.prefixCombo.currentText()
 | 
					        selected_prefix = self.prefixCombo.currentText()
 | 
				
			||||||
        if not selected_prefix:
 | 
					        if not selected_prefix:
 | 
				
			||||||
@@ -1196,8 +1691,9 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
                QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix))
 | 
					                QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix))
 | 
				
			||||||
                # обновляем список
 | 
					                # обновляем список
 | 
				
			||||||
                self.prefixCombo.clear()
 | 
					                self.prefixCombo.clear()
 | 
				
			||||||
                self.prefixes = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "prefixes"))
 | 
					                prefixes_path = os.path.join(self.portproton_location, "data", "prefixes")
 | 
				
			||||||
                                 if os.path.isdir(os.path.join(self.portproton_location, "data", "prefixes", d))]
 | 
					                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)
 | 
					                self.prefixCombo.addItems(self.prefixes)
 | 
				
			||||||
            except Exception as e:
 | 
					            except Exception as e:
 | 
				
			||||||
                QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e)))
 | 
					                QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e)))
 | 
				
			||||||
@@ -1338,7 +1834,22 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.gamesDisplayCombo.setCurrentIndex(idx)
 | 
					        self.gamesDisplayCombo.setCurrentIndex(idx)
 | 
				
			||||||
        formLayout.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo)
 | 
					        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 = CustomLineEdit(self, theme=self.theme)
 | 
				
			||||||
        self.proxyUrlEdit.setPlaceholderText(_("Proxy URL"))
 | 
					        self.proxyUrlEdit.setPlaceholderText(_("Proxy URL"))
 | 
				
			||||||
        self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
 | 
					        self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
 | 
				
			||||||
@@ -1370,7 +1881,7 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
 | 
					        self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
 | 
				
			||||||
        formLayout.addRow(self.proxyPasswordTitle, self.proxyPasswordEdit)
 | 
					        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 = QCheckBox(_("Launch Application in Fullscreen"))
 | 
				
			||||||
        self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
					        self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
				
			||||||
        self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
					        self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
				
			||||||
@@ -1381,7 +1892,19 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.fullscreenCheckBox.setChecked(current_fullscreen)
 | 
					        self.fullscreenCheckBox.setChecked(current_fullscreen)
 | 
				
			||||||
        formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox)
 | 
					        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 = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
 | 
				
			||||||
        self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
					        self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
				
			||||||
        self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
					        self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
				
			||||||
@@ -1393,7 +1916,7 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
 | 
					        self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
 | 
				
			||||||
        formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
 | 
					        formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # 7. Gamepad haptic feedback config
 | 
					        # 9. Gamepad haptic feedback config
 | 
				
			||||||
        self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
 | 
					        self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
 | 
				
			||||||
        self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
					        self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
				
			||||||
        self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
					        self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
 | 
				
			||||||
@@ -1404,10 +1927,10 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
 | 
					        self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
 | 
				
			||||||
        formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
 | 
					        formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        # # 8. Legendary Authentication
 | 
					        # # 9. Legendary Authentication
 | 
				
			||||||
        # self.legendaryAuthButton = AutoSizeButton(
 | 
					        # self.legendaryAuthButton = AutoSizeButton(
 | 
				
			||||||
        #     _("Open Legendary Login"),
 | 
					        #     _("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.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
 | 
				
			||||||
        # self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
					        # self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
				
			||||||
@@ -1584,6 +2107,19 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
 | 
					        rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
 | 
				
			||||||
        save_rumble_config(rumble_enabled)
 | 
					        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():
 | 
					        for card in self.game_library_manager.game_card_cache.values():
 | 
				
			||||||
            card.update_badge_visibility(filter_key)
 | 
					            card.update_badge_visibility(filter_key)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -1600,9 +2136,6 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
        gamepad_connected = self.input_manager.find_gamepad() is not None
 | 
					        gamepad_connected = self.input_manager.find_gamepad() is not None
 | 
				
			||||||
        if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
 | 
					        if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
 | 
				
			||||||
            self.showFullScreen()
 | 
					            self.showFullScreen()
 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            self.showNormal()
 | 
					 | 
				
			||||||
            self.resize(*read_window_geometry())
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
        self.statusBar().showMessage(_("Settings saved"), 3000)
 | 
					        self.statusBar().showMessage(_("Settings saved"), 3000)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -2392,9 +2925,7 @@ class MainWindow(QMainWindow):
 | 
				
			|||||||
            else:
 | 
					            else:
 | 
				
			||||||
                # Запускаем игру через PortProton
 | 
					                # Запускаем игру через PortProton
 | 
				
			||||||
                env_vars = os.environ.copy()
 | 
					                env_vars = os.environ.copy()
 | 
				
			||||||
                env_vars['START_FROM_STEAM'] = '1'
 | 
					 | 
				
			||||||
                env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
 | 
					                env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
 | 
				
			||||||
                env_vars['PROCESS_LOG'] = '1'
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
					                wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
				
			||||||
                if self.portproton_location is not None and ".var" not in self.portproton_location:
 | 
					                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}")
 | 
					                logger.error(f"Failed to launch game {exe_name}: {e}")
 | 
				
			||||||
                QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
 | 
					                QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					 | 
				
			||||||
    def closeEvent(self, event):
 | 
					    def closeEvent(self, event):
 | 
				
			||||||
        """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
 | 
					        """Обработчик закрытия окна: проверяет настройку minimize_to_tray.
 | 
				
			||||||
        if hasattr(self, 'is_exiting') and self.is_exiting:
 | 
					        Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
 | 
				
			||||||
            # Принудительное закрытие: завершаем процессы и приложение
 | 
					        """
 | 
				
			||||||
            for proc in self.game_processes:
 | 
					        minimize_to_tray = read_minimize_to_tray()
 | 
				
			||||||
                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}")
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
            self.game_processes = []  # Очищаем список процессов
 | 
					        if minimize_to_tray:
 | 
				
			||||||
 | 
					            # Просто сворачиваем в трей
 | 
				
			||||||
            # Очищаем таймеры
 | 
					 | 
				
			||||||
            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()
 | 
					 | 
				
			||||||
            event.ignore()
 | 
					            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 requests
 | 
				
			||||||
import urllib.parse
 | 
					import urllib.parse
 | 
				
			||||||
import time
 | 
					import time
 | 
				
			||||||
 | 
					import glob
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
from collections.abc import Callable
 | 
					from collections.abc import Callable
 | 
				
			||||||
from portprotonqt.downloader import Downloader
 | 
					from portprotonqt.downloader import Downloader
 | 
				
			||||||
from portprotonqt.logger import get_logger
 | 
					from portprotonqt.logger import get_logger
 | 
				
			||||||
 | 
					from portprotonqt.config_utils import get_portproton_location
 | 
				
			||||||
 | 
					
 | 
				
			||||||
logger = get_logger(__name__)
 | 
					logger = get_logger(__name__)
 | 
				
			||||||
CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds
 | 
					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.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")
 | 
					        self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
 | 
				
			||||||
        os.makedirs(self.custom_data_dir, exist_ok=True)
 | 
					        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
 | 
					        self._topics_data = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    def _get_game_dir(self, exe_name: str) -> str:
 | 
					    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}")
 | 
					            logger.debug(f"Failed to check file at {url}: {e}")
 | 
				
			||||||
            return False
 | 
					            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:
 | 
					    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)
 | 
					        game_dir = self._get_game_dir(exe_name)
 | 
				
			||||||
        cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
 | 
					        cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
 | 
				
			||||||
@@ -163,6 +135,164 @@ class PortProtonAPI:
 | 
				
			|||||||
            if callback:
 | 
					            if callback:
 | 
				
			||||||
                callback(results)
 | 
					                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):
 | 
					    def _load_topics_data(self):
 | 
				
			||||||
        """Load and cache linux_gaming_topics_min.json from the archive."""
 | 
					        """Load and cache linux_gaming_topics_min.json from the archive."""
 | 
				
			||||||
        if self._topics_data is not None:
 | 
					        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,
 | 
					from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
 | 
				
			||||||
                               QSizePolicy, QWidget, QLineEdit)
 | 
					                               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.keyboard_layouts import keyboard_layouts
 | 
				
			||||||
from portprotonqt.theme_manager import ThemeManager
 | 
					from portprotonqt.theme_manager import ThemeManager
 | 
				
			||||||
from portprotonqt.config_utils import read_theme_from_config
 | 
					from portprotonqt.config_utils import read_theme_from_config
 | 
				
			||||||
@@ -43,6 +44,18 @@ class VirtualKeyboard(QFrame):
 | 
				
			|||||||
        self.margins = 10
 | 
					        self.margins = 10
 | 
				
			||||||
        self.num_cols = 14
 | 
					        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.initUI()
 | 
				
			||||||
        self.hide()
 | 
					        self.hide()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -119,6 +132,34 @@ class VirtualKeyboard(QFrame):
 | 
				
			|||||||
        self.buttons: dict[str, QPushButton] = {}
 | 
					        self.buttons: dict[str, QPushButton] = {}
 | 
				
			||||||
        self.update_keyboard()
 | 
					        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):
 | 
					    def update_keyboard(self):
 | 
				
			||||||
        coords = self._save_focused_coords()
 | 
					        coords = self._save_focused_coords()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -151,6 +192,9 @@ class VirtualKeyboard(QFrame):
 | 
				
			|||||||
                    button.setCheckable(True)
 | 
					                    button.setCheckable(True)
 | 
				
			||||||
                    button.setChecked(self.shift_pressed)
 | 
					                    button.setChecked(self.shift_pressed)
 | 
				
			||||||
                    button.clicked.connect(lambda checked: self.on_shift_click(checked))
 | 
					                    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:
 | 
					                else:
 | 
				
			||||||
                    button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
 | 
					                    button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -163,6 +207,9 @@ class VirtualKeyboard(QFrame):
 | 
				
			|||||||
        shift.setCheckable(True)
 | 
					        shift.setCheckable(True)
 | 
				
			||||||
        shift.setChecked(self.shift_pressed)
 | 
					        shift.setChecked(self.shift_pressed)
 | 
				
			||||||
        shift.clicked.connect(lambda checked: self.on_shift_click(checked))
 | 
					        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)
 | 
					        self.keyboard_layout.addWidget(shift, 3, 11, 1, 3)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        button = QPushButton('CAPS')
 | 
					        button = QPushButton('CAPS')
 | 
				
			||||||
@@ -179,6 +226,9 @@ class VirtualKeyboard(QFrame):
 | 
				
			|||||||
        backspace.setFixedSize(fixed_w, fixed_h)
 | 
					        backspace.setFixedSize(fixed_w, fixed_h)
 | 
				
			||||||
        backspace.pressed.connect(self.on_backspace_pressed)
 | 
					        backspace.pressed.connect(self.on_backspace_pressed)
 | 
				
			||||||
        backspace.released.connect(self.stop_backspace_repeat)
 | 
					        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)
 | 
					        self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        enter = QPushButton('Enter')
 | 
					        enter = QPushButton('Enter')
 | 
				
			||||||
@@ -189,6 +239,9 @@ class VirtualKeyboard(QFrame):
 | 
				
			|||||||
        lang = QPushButton('🌐')
 | 
					        lang = QPushButton('🌐')
 | 
				
			||||||
        lang.setFixedSize(fixed_w, fixed_h)
 | 
					        lang.setFixedSize(fixed_w, fixed_h)
 | 
				
			||||||
        lang.clicked.connect(self.on_lang_click)
 | 
					        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)
 | 
					        self.keyboard_layout.addWidget(lang, 4, 0, 1, 1)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        clear = QPushButton('Clear')
 | 
					        clear = QPushButton('Clear')
 | 
				
			||||||
@@ -219,6 +272,9 @@ class VirtualKeyboard(QFrame):
 | 
				
			|||||||
        hide_button = QPushButton('Hide')
 | 
					        hide_button = QPushButton('Hide')
 | 
				
			||||||
        hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
 | 
					        hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
 | 
				
			||||||
        hide_button.clicked.connect(self.hide)
 | 
					        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)
 | 
					        self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
        if coords:
 | 
					        if coords:
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
[project]
 | 
					[project]
 | 
				
			||||||
name = "portprotonqt"
 | 
					name = "portprotonqt"
 | 
				
			||||||
version = "0.1.6"
 | 
					version = "0.1.8"
 | 
				
			||||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
 | 
					description = "A project to rewrite PortProton (PortWINE) using PySide"
 | 
				
			||||||
readme = "README.md"
 | 
					readme = "README.md"
 | 
				
			||||||
license = { text = "GPL-3.0" }
 | 
					license = { text = "GPL-3.0" }
 | 
				
			||||||
 
 | 
				
			|||||||