Compare commits
	
		
			108 Commits
		
	
	
		
			c8c45dda06
			...
			renovate/a
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					e369cb20c9 | ||
| 
						 | 
					a30f6f2e74 | ||
| 
						
						
							
						
						0231073b19
	
				 | 
					
					
						|||
| 
						
						
							
						
						dec24429f5
	
				 | 
					
					
						|||
| 
						
						
							
						
						4a758f3b3c
	
				 | 
					
					
						|||
| 
						
						
							
						
						0853dd1579
	
				 | 
					
					
						|||
| 
						
						
							
						
						bbb87c0455
	
				 | 
					
					
						|||
| 
						
						
							
						
						b32a71a125
	
				 | 
					
					
						|||
| 
						 | 
					bddf9f850a | ||
| 
						 | 
					a9c3cfa167 | ||
| 
						
						
							
						
						7675bc4cdc
	
				 | 
					
					
						|||
| 
						
						
							
						
						ffa203f019
	
				 | 
					
					
						|||
| 
						
						
							
						
						3eed25ecee
	
				 | 
					
					
						|||
| 
						
						
							
						
						3736bb279e
	
				 | 
					
					
						|||
| 
						 | 
					b59ee5ae8e | ||
| 
						
						
							
						
						33176590fd
	
				 | 
					
					
						|||
| 
						
						
							
						
						8046065929
	
				 | 
					
					
						|||
| 
						 | 
					fbad5add6c | ||
| 
						
						
							
						
						438e9737ea
	
				 | 
					
					
						|||
| 
						
						
							
						
						2d39a4c740
	
				 | 
					
					
						|||
| 
						
						
							
						
						567203b0b0
	
				 | 
					
					
						|||
| 
						
						
							
						
						502cbc5030
	
				 | 
					
					
						|||
| 
						
						
							
						
						9b61215152
	
				 | 
					
					
						|||
| 
						
						
							
						
						10d3fe8ab4
	
				 | 
					
					
						|||
| 
						
						
							
						
						a568ad9ef8
	
				 | 
					
					
						|||
| 
						
						
							
						
						f074843fc8
	
				 | 
					
					
						|||
| 
						
						
							
						
						4ab078b93e
	
				 | 
					
					
						|||
| 
						
						
							
						
						7df6ad3b80
	
				 | 
					
					
						|||
| 
						
						
							
						
						464ad0fe9c
	
				 | 
					
					
						|||
| 
						
						
							
						
						cde92885d4
	
				 | 
					
					
						|||
| 
						
						
							
						
						120c7b319c
	
				 | 
					
					
						|||
| 
						
						
							
						
						596aed0077
	
				 | 
					
					
						|||
| 
						
						
							
						
						6fc6cb1e02
	
				 | 
					
					
						|||
| 
						
						
							
						
						186e28a19b
	
				 | 
					
					
						|||
| 
						
						
							
						
						28e4d1e77c
	
				 | 
					
					
						|||
| 
						
						
							
						
						fff1f888c4
	
				 | 
					
					
						|||
| 
						
						
							
						
						fdd5a0a3d5
	
				 | 
					
					
						|||
| 
						
						
							
						
						792e52d981
	
				 | 
					
					
						|||
| 
						
						
							
						
						84d5e46a74
	
				 | 
					
					
						|||
| 
						
						
							
						
						4bc764d568
	
				 | 
					
					
						|||
| 
						
						
							
						
						9a18aa037e
	
				 | 
					
					
						|||
| 
						
						
							
						
						ed62d2d1c4
	
				 | 
					
					
						|||
| 
						
						
							
						
						accc9b18b6
	
				 | 
					
					
						|||
| 
						
						
							
						
						82249d7eab
	
				 | 
					
					
						|||
| 
						
						
							
						
						476c896940
	
				 | 
					
					
						|||
| 
						
						
							
						
						b1047ba18e
	
				 | 
					
					
						|||
| 
						
						
							
						
						987199d8e6
	
				 | 
					
					
						|||
| 
						 | 
					ef1acd4581 | ||
| 
						
						
							
						
						96f884904c
	
				 | 
					
					
						|||
| 
						
						
							
						
						b856a2afae
	
				 | 
					
					
						|||
| 
						
						
							
						
						55ef0030e6
	
				 | 
					
					
						|||
| 
						
						
							
						
						8aaeaa4824
	
				 | 
					
					
						|||
| 
						
						
							
						
						f55372b480
	
				 | 
					
					
						|||
| 
						
						
							
						
						4d6f32f053
	
				 | 
					
					
						|||
| 
						
						
							
						
						a2f5141b20
	
				 | 
					
					
						|||
| 
						
						
							
						
						e3cb2857e7
	
				 | 
					
					
						|||
| 
						
						
							
						
						efe8a35832
	
				 | 
					
					
						|||
| 
						
						
							
						
						61fae97dad
	
				 | 
					
					
						|||
| 
						
						
							
						
						5442100f64
	
				 | 
					
					
						|||
| 
						
						
							
						
						2d6ef84798
	
				 | 
					
					
						|||
| 
						 | 
					f4aee15b5d | ||
| 
						
						
							
						
						87a65108a5
	
				 | 
					
					
						|||
| 
						
						
							
						
						bb617708ac
	
				 | 
					
					
						|||
| 
						
						
							
						
						1cf332cd87
	
				 | 
					
					
						|||
| 
						
						
							
						
						577ad4d3a3
	
				 | 
					
					
						|||
| 
						
						
							
						
						ef3f2d6e96
	
				 | 
					
					
						|||
| 
						
						
							
						
						657d7728a6
	
				 | 
					
					
						|||
| 9452bfda2e | |||
| 7eb2db0d68 | |||
| 6ef7a03366 | |||
| e5af354b56 | |||
| e6e5f6c8ea | |||
| 84306bb31b | |||
| 60af4d1482 | |||
| 692e11b21d | |||
| b1a804811e | |||
| 9a30cfaea7 | |||
| 5dd2f71f5e | |||
| 
						
						
							
						
						dba172361b
	
				 | 
					
					
						|||
| 
						
						
							
						
						a9c70b8818
	
				 | 
					
					
						|||
| 
						
						
							
						
						135ace732f
	
				 | 
					
					
						|||
| 
						
						
							
						
						8b727f64e1
	
				 | 
					
					
						|||
| 
						
						
							
						
						a8eb591da5
	
				 | 
					
					
						|||
| 
						
						
							
						
						fe4ca1ee87
	
				 | 
					
					
						|||
| 
						
						
							
						
						ffe3e9d3d6
	
				 | 
					
					
						|||
| 
						
						
							
						
						49d39b5d61
	
				 | 
					
					
						|||
| 
						 | 
					
						
						
							
						
						03566da704
	
				 | 
					
					
						||
| 
						 | 
					7f996ab6a0 | ||
| 
						 | 
					9e17978155 | ||
| 
						
						
							
						
						5d0185b1b4
	
				 | 
					
					
						|||
| 
						
						
							
						
						5c134be04e
	
				 | 
					
					
						|||
| 
						
						
							
						
						8c66695192
	
				 | 
					
					
						|||
| 
						
						
							
						
						7a141d8e46
	
				 | 
					
					
						|||
| 
						
						
							
						
						abb2377fb7
	
				 | 
					
					
						|||
| 
						
						
							
						
						75f4f346de
	
				 | 
					
					
						|||
| 
						
						
							
						
						87a9f85272
	
				 | 
					
					
						|||
| 
						
						
							
						
						240f685ece
	
				 | 
					
					
						|||
| 
						
						
							
						
						af4e3e95bb
	
				 | 
					
					
						|||
| 
						
						
							
						
						017d9a42cf
	
				 | 
					
					
						|||
| 
						
						
							
						
						18b7c4054b
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd7f71b70a
	
				 | 
					
					
						|||
| 
						
						
							
						
						8fd44c575b
	
				 | 
					
					
						|||
| 
						
						
							
						
						65b43c1572
	
				 | 
					
					
						|||
| 
						
						
							
						
						f35276abfe
	
				 | 
					
					
						|||
| 
						
						
							
						
						6fea9a9a7e
	
				 | 
					
					
						|||
| 
						
						
							
						
						5189474631
	
				 | 
					
					
						|||
| 
						 | 
					416cc6a268 | ||
| 
						 | 
					3b44ed5252 | 
@@ -94,7 +94,7 @@ jobs:
 | 
			
		||||
    name: Build Arch Package
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
 | 
			
		||||
      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /usr:/usr-host
 | 
			
		||||
        - /opt:/opt-host
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ on:
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  # Common version, will be used for tagging the release
 | 
			
		||||
  VERSION: 0.1.6
 | 
			
		||||
  VERSION: 0.1.8
 | 
			
		||||
  PKGDEST: "/tmp/portprotonqt"
 | 
			
		||||
  PACKAGE: "portprotonqt"
 | 
			
		||||
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
 | 
			
		||||
@@ -180,10 +180,12 @@ jobs:
 | 
			
		||||
 | 
			
		||||
      - name: Release
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-release-action@v1
 | 
			
		||||
        env:
 | 
			
		||||
            NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
 | 
			
		||||
        with:
 | 
			
		||||
          body_path: changelog.txt
 | 
			
		||||
          token: ${{ env.GITEA_TOKEN }}
 | 
			
		||||
          tag_name: v${{ env.VERSION }}
 | 
			
		||||
          prerelease: true
 | 
			
		||||
          files: release/**/*
 | 
			
		||||
          sha256sum: true
 | 
			
		||||
          sha256sum: false
 | 
			
		||||
 
 | 
			
		||||
@@ -138,7 +138,7 @@ jobs:
 | 
			
		||||
    needs: changes
 | 
			
		||||
    if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
 | 
			
		||||
      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /usr:/usr-host
 | 
			
		||||
        - /opt:/opt-host
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  renovate:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:latest@sha256:dd5721b9a686a40d81687643e4b71b82a0ca31fb653fd727538af69104fd388d
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -11,12 +11,12 @@ repos:
 | 
			
		||||
      - id: check-yaml
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/uv-pre-commit
 | 
			
		||||
    rev: 0.8.22
 | 
			
		||||
    rev: 0.9.7
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: uv-lock
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    rev: v0.13.2
 | 
			
		||||
    rev: v0.14.2
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff-check
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										42
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@@ -3,20 +3,59 @@
 | 
			
		||||
Все заметные изменения в этом проекте фиксируются в этом файле.
 | 
			
		||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
 | 
			
		||||
 | 
			
		||||
## [Unreleased]
 | 
			
		||||
## [0.1.8] - 2025-10-18
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
 | 
			
		||||
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
 | 
			
		||||
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
 | 
			
		||||
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- При завершении автоустановки приложение больше не перезапускается
 | 
			
		||||
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
 | 
			
		||||
- Обновлены и дополнены скриншоты темы
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Исправлено наложение карточек при смене фильтра игр
 | 
			
		||||
- Исправлена невозможность запуска приложения без подключёного геймпада
 | 
			
		||||
- Исправлена невозможность установки компонентов Winetricks через геймпад
 | 
			
		||||
- Ресиверы и виртуальные устройства больше не считаются за геймпад
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [0.1.7] - 2025-10-12
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Возможность скроллинга библиотеки мышью или пальцем
 | 
			
		||||
- Импорт и экспорт бекапа префикса
 | 
			
		||||
- Диалог для управление Winetricks
 | 
			
		||||
- Кнопки для удаления префикса, wine или proton
 | 
			
		||||
- Все настройки Wine с оригинального PortProton
 | 
			
		||||
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
 | 
			
		||||
- Вкладка автоустановок
 | 
			
		||||
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
 | 
			
		||||
- В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
 | 
			
		||||
- Исправлено зависание при добавлении или удалении игры в Wayland
 | 
			
		||||
- Исправлено зависание при поиске игр
 | 
			
		||||
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
 | 
			
		||||
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
 | 
			
		||||
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
 | 
			
		||||
- При сохранении настроек теперь не меняется размер окна
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @wmigor (Igor Akulov)
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -39,6 +78,7 @@
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @wmigor (Igor Akulov)
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						@@ -1,6 +1,6 @@
 | 
			
		||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
 | 
			
		||||
- [X] Добавить возможность управления с геймпада
 | 
			
		||||
- [ ] Добавить возможность управления с тачскрина
 | 
			
		||||
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
 | 
			
		||||
- [X] Добавить возможность управления с мыши и клавиатуры
 | 
			
		||||
- [X] Добавить систему тем [Документация](documentation/theme_guide)
 | 
			
		||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
 | 
			
		||||
@@ -11,18 +11,18 @@
 | 
			
		||||
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
 | 
			
		||||
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
 | 
			
		||||
- [X] Получать описания и названия игр из базы данных Steam
 | 
			
		||||
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
 | 
			
		||||
- [X] Получать обложки для игр из CDN Steam
 | 
			
		||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
 | 
			
		||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
 | 
			
		||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
			
		||||
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
			
		||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
 | 
			
		||||
- [X] Избавиться от вызовов yad
 | 
			
		||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
 | 
			
		||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
 | 
			
		||||
- [X] Добавить экранную клавиатуру в поиск
 | 
			
		||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
 | 
			
		||||
- [X] Добавить индикацию запуска приложения
 | 
			
		||||
- [X] Достигнуть паритета функциональности с Ingame
 | 
			
		||||
- [ ] Достигнуть паритета функциональности с PortProton
 | 
			
		||||
- [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов)
 | 
			
		||||
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
 | 
			
		||||
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
 | 
			
		||||
- [X] Добавить переводы в переопределения
 | 
			
		||||
@@ -49,7 +49,7 @@
 | 
			
		||||
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
 | 
			
		||||
- [X] Добавить систему избранного для карточек
 | 
			
		||||
- [X] Заменить все `print` на `logging`
 | 
			
		||||
- [ ] Привести все логи к единому языку
 | 
			
		||||
- [X] Привести все логи к единому языку
 | 
			
		||||
- [X] Уменьшить количество подстановок в переводах
 | 
			
		||||
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
 | 
			
		||||
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
 | 
			
		||||
@@ -62,7 +62,6 @@
 | 
			
		||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
 | 
			
		||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
 | 
			
		||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
 | 
			
		||||
- [ ] Доделать светлую тему
 | 
			
		||||
- [ ] Добавить подсказки к управлению с геймпада
 | 
			
		||||
- [X] Добавить подсказки к управлению с геймпада
 | 
			
		||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
 | 
			
		||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
 | 
			
		||||
 
 | 
			
		||||
@@ -36,7 +36,7 @@ AppDir:
 | 
			
		||||
    id: ru.linux_gaming.PortProtonQt
 | 
			
		||||
    name: PortProtonQt
 | 
			
		||||
    icon: ru.linux_gaming.PortProtonQt
 | 
			
		||||
    version: 0.1.6
 | 
			
		||||
    version: 0.1.8
 | 
			
		||||
    exec: usr/bin/python3
 | 
			
		||||
    exec_args: "-m portprotonqt.app $@"
 | 
			
		||||
  apt:
 | 
			
		||||
@@ -54,6 +54,11 @@ AppDir:
 | 
			
		||||
      - libxcb-cursor0
 | 
			
		||||
      - libimage-exiftool-perl
 | 
			
		||||
      - xdg-utils
 | 
			
		||||
      - cabextract
 | 
			
		||||
      - curl
 | 
			
		||||
      - 7zip
 | 
			
		||||
      - unzip
 | 
			
		||||
      - unrar
 | 
			
		||||
    exclude:
 | 
			
		||||
      - "*-doc"
 | 
			
		||||
      - "*-man"
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
pkgname=portprotonqt
 | 
			
		||||
pkgver=0.1.6
 | 
			
		||||
pkgver=0.1.8
 | 
			
		||||
pkgrel=1
 | 
			
		||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
 | 
			
		||||
arch=('any')
 | 
			
		||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
 | 
			
		||||
license=('GPL-3.0')
 | 
			
		||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
 | 
			
		||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
 | 
			
		||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
 | 
			
		||||
sha256sums=('SKIP')
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ arch=('any')
 | 
			
		||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
 | 
			
		||||
license=('GPL-3.0')
 | 
			
		||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
 | 
			
		||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
 | 
			
		||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
 | 
			
		||||
sha256sums=('SKIP')
 | 
			
		||||
 
 | 
			
		||||
@@ -46,6 +46,11 @@ Requires:       python3-pillow
 | 
			
		||||
Requires:       perl-Image-ExifTool
 | 
			
		||||
Requires:       xdg-utils
 | 
			
		||||
Requires:       python3-beautifulsoup4
 | 
			
		||||
Requires:       cabextract
 | 
			
		||||
Requires:       gzip
 | 
			
		||||
Requires:       unzip
 | 
			
		||||
Requires:       curl
 | 
			
		||||
Requires:       unrar
 | 
			
		||||
 | 
			
		||||
%description -n python3-%{pypi_name}-git
 | 
			
		||||
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
%global pypi_name portprotonqt
 | 
			
		||||
%global pypi_version 0.1.6
 | 
			
		||||
%global pypi_version 0.1.8
 | 
			
		||||
%global oname PortProtonQt
 | 
			
		||||
%global _python_no_extras_requires 1
 | 
			
		||||
 | 
			
		||||
@@ -43,6 +43,11 @@ Requires:       python3-pillow
 | 
			
		||||
Requires:       perl-Image-ExifTool
 | 
			
		||||
Requires:       xdg-utils
 | 
			
		||||
Requires:       python3-beautifulsoup4
 | 
			
		||||
Requires:       cabextract
 | 
			
		||||
Requires:       gzip
 | 
			
		||||
Requires:       unzip
 | 
			
		||||
Requires:       curl
 | 
			
		||||
Requires:       unrar
 | 
			
		||||
 | 
			
		||||
%description -n python3-%{pypi_name}
 | 
			
		||||
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
 | 
			
		||||
 
 | 
			
		||||
@@ -1021,7 +1021,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "farlight 84",
 | 
			
		||||
    "status": "Supported"
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "riders republic",
 | 
			
		||||
@@ -1436,8 +1436,8 @@
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blue protocol",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
    "normalized_name": "blue protocol star resonance",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dark and darker",
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										12972
									
								
								data/games_appid.json
									
									
									
									
									
								
							
							
						
						@@ -1,4 +1,108 @@
 | 
			
		||||
[
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "split/second",
 | 
			
		||||
    "slug": "split-second"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "warzone 2100",
 | 
			
		||||
    "slug": "warzone-2100"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "foundation",
 | 
			
		||||
    "slug": "foundation"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "земский собор [демо]",
 | 
			
		||||
    "slug": "zemskij-sobor-demo"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "crusader kings 3",
 | 
			
		||||
    "slug": "crusader-kings-3"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "nadir a grimdark deck builder",
 | 
			
		||||
    "slug": "nadir-a-grimdark-deck-builder"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "oriental empires",
 | 
			
		||||
    "slug": "oriental-empires"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "vampire the masquerade bloodlines 2",
 | 
			
		||||
    "slug": "vampire-the-masquerade-bloodlines-2"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "escape from duckov",
 | 
			
		||||
    "slug": "escape-from-duckov"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "xiii",
 | 
			
		||||
    "slug": "xiii"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "saints row 2",
 | 
			
		||||
    "slug": "saints-row-2"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "frozenheim",
 | 
			
		||||
    "slug": "frozenheim"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "saints row (2022)",
 | 
			
		||||
    "slug": "saints-row-2022"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "iron harvest",
 | 
			
		||||
    "slug": "iron-harvest"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "tom clancy's splinter cell blacklist",
 | 
			
		||||
    "slug": "tom-clancys-splinter-cell-blacklist"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "painkiller overdose",
 | 
			
		||||
    "slug": "painkiller-overdose"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "ancestors legacy",
 | 
			
		||||
    "slug": "ancestors-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "bye sweet carole",
 | 
			
		||||
    "slug": "bye-sweet-carole"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "painkiller black",
 | 
			
		||||
    "slug": "painkiller-black-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "hogwarts legacy",
 | 
			
		||||
    "slug": "hogwarts-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "active matter",
 | 
			
		||||
    "slug": "active-matter"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "tom clancy's splinter cell",
 | 
			
		||||
    "slug": "tom-clancys-splinter-cell"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "sniper ghost warrior",
 | 
			
		||||
    "slug": "sniper-ghost-warrior"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "fate undiscovered realms",
 | 
			
		||||
    "slug": "fate-undiscovered-realms"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dying light the beast deluxe",
 | 
			
		||||
    "slug": "dying-light-the-beast-deluxe-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "spellforce platinum",
 | 
			
		||||
    "slug": "spellforce-platinum-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dirt rally 2.0 game of the year",
 | 
			
		||||
    "slug": "dirt-rally-2-0-game-of-the-year-edition"
 | 
			
		||||
@@ -271,10 +375,6 @@
 | 
			
		||||
    "normalized_title": "steins;gate the distant valhalla",
 | 
			
		||||
    "slug": "steins-gate-the-distant-valhalla"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "hogwarts legacy",
 | 
			
		||||
    "slug": "hogwarts-legacy"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "osu!",
 | 
			
		||||
    "slug": "osu"
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@ Current translation status:
 | 
			
		||||
 | 
			
		||||
| Locale | Progress | Translated |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 of 193 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@
 | 
			
		||||
 | 
			
		||||
| Локаль | Прогресс | Переведено |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 из 193 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,17 +1,46 @@
 | 
			
		||||
import sys
 | 
			
		||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
 | 
			
		||||
import os
 | 
			
		||||
import subprocess
 | 
			
		||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
 | 
			
		||||
from PySide6.QtWidgets import QApplication
 | 
			
		||||
from PySide6.QtGui import QIcon
 | 
			
		||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
 | 
			
		||||
 | 
			
		||||
from portprotonqt.main_window import MainWindow
 | 
			
		||||
from portprotonqt.config_utils import save_fullscreen_config
 | 
			
		||||
from portprotonqt.config_utils import (
 | 
			
		||||
    save_fullscreen_config,
 | 
			
		||||
    read_fullscreen_config,
 | 
			
		||||
    get_portproton_start_command
 | 
			
		||||
)
 | 
			
		||||
from portprotonqt.logger import get_logger, setup_logger
 | 
			
		||||
from portprotonqt.cli import parse_args
 | 
			
		||||
 | 
			
		||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
 | 
			
		||||
__app_name__ = "PortProtonQt"
 | 
			
		||||
__app_version__ = "0.1.6"
 | 
			
		||||
__app_version__ = "0.1.8"
 | 
			
		||||
 | 
			
		||||
def get_version():
 | 
			
		||||
    try:
 | 
			
		||||
        commit = subprocess.check_output(
 | 
			
		||||
            ["git", "rev-parse", "--short", "HEAD"],
 | 
			
		||||
            stderr=subprocess.DEVNULL,
 | 
			
		||||
        ).decode("utf-8").strip()
 | 
			
		||||
        return f"{__app_version__} ({commit})"
 | 
			
		||||
    except (subprocess.CalledProcessError, FileNotFoundError, OSError):
 | 
			
		||||
        return __app_version__
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    os.environ["PW_CLI"] = "1"
 | 
			
		||||
    os.environ["PROCESS_LOG"] = "1"
 | 
			
		||||
    os.environ["START_FROM_STEAM"] = "1"
 | 
			
		||||
 | 
			
		||||
    start_sh = get_portproton_start_command()
 | 
			
		||||
 | 
			
		||||
    if start_sh is None:
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    subprocess.run(start_sh + ["cli", "--initial"])
 | 
			
		||||
 | 
			
		||||
    app = QApplication(sys.argv)
 | 
			
		||||
    app.setWindowIcon(QIcon.fromTheme(__app_id__))
 | 
			
		||||
    app.setDesktopFileName(__app_id__)
 | 
			
		||||
@@ -19,40 +48,116 @@ def main():
 | 
			
		||||
    app.setApplicationVersion(__app_version__)
 | 
			
		||||
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
 | 
			
		||||
    # Setup logger with specified debug level
 | 
			
		||||
    setup_logger(args.debug_level)
 | 
			
		||||
 | 
			
		||||
    # Reinitialize logger after setup to ensure it uses the new configuration
 | 
			
		||||
    logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
    # --- Single-instance logic ---
 | 
			
		||||
    server_name = __app_id__
 | 
			
		||||
    socket = QLocalSocket()
 | 
			
		||||
    socket.connectToServer(server_name)
 | 
			
		||||
 | 
			
		||||
    if socket.waitForConnected(200):
 | 
			
		||||
        # Второй экземпляр — передаём команду первому
 | 
			
		||||
        fullscreen = args.fullscreen or read_fullscreen_config()
 | 
			
		||||
        msg = b"show:fullscreen" if fullscreen else b"show"
 | 
			
		||||
        socket.write(msg)
 | 
			
		||||
        socket.flush()
 | 
			
		||||
        socket.waitForBytesWritten(500)
 | 
			
		||||
        socket.disconnectFromServer()
 | 
			
		||||
        logger.info("Restored existing instance from tray")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Если старый сокет остался — удалить
 | 
			
		||||
    QLocalServer.removeServer(server_name)
 | 
			
		||||
 | 
			
		||||
    local_server = QLocalServer()
 | 
			
		||||
    if not local_server.listen(server_name):
 | 
			
		||||
        logger.warning(f"Failed to start local server: {local_server.errorString()}")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # --- Qt translations ---
 | 
			
		||||
    system_locale = QLocale.system()
 | 
			
		||||
    qt_translator = QTranslator()
 | 
			
		||||
    translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
 | 
			
		||||
    if qt_translator.load(system_locale, "qtbase", "_", translations_path):
 | 
			
		||||
        app.installTranslator(qt_translator)
 | 
			
		||||
    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"
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    window = MainWindow(app_name=__app_name__)
 | 
			
		||||
    # --- Main Window ---
 | 
			
		||||
    version = get_version()
 | 
			
		||||
    window = MainWindow(app_name=__app_name__, version=version)
 | 
			
		||||
 | 
			
		||||
    if args.fullscreen:
 | 
			
		||||
        logger.info("Launching in fullscreen mode due to --fullscreen flag")
 | 
			
		||||
    # --- Handle incoming connections ---
 | 
			
		||||
    def handle_new_connection():
 | 
			
		||||
        conn = local_server.nextPendingConnection()
 | 
			
		||||
        if not conn:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        if conn.waitForReadyRead(1000):
 | 
			
		||||
            data = conn.readAll().data()
 | 
			
		||||
            msg = bytes(data).decode("utf-8", errors="ignore")
 | 
			
		||||
            logger.info(f"IPC message received: {msg}")
 | 
			
		||||
 | 
			
		||||
            def restore_window():
 | 
			
		||||
                try:
 | 
			
		||||
                    if msg.startswith("show"):
 | 
			
		||||
                        if hasattr(window, "restore_from_tray"):
 | 
			
		||||
                            window.restore_from_tray()  # type: ignore[attr-defined]
 | 
			
		||||
                        else:
 | 
			
		||||
                            window.showNormal()
 | 
			
		||||
                            window.raise_()
 | 
			
		||||
                            window.activateWindow()
 | 
			
		||||
                            window.setWindowState(
 | 
			
		||||
                                window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
 | 
			
		||||
                            )
 | 
			
		||||
 | 
			
		||||
                        if ":fullscreen" in msg:
 | 
			
		||||
                            logger.info("Switching to fullscreen via IPC")
 | 
			
		||||
                            save_fullscreen_config(True)
 | 
			
		||||
                            window.showFullScreen()
 | 
			
		||||
                        else:
 | 
			
		||||
                            logger.info("Switching to normal window via IPC")
 | 
			
		||||
                            save_fullscreen_config(False)
 | 
			
		||||
                            window.showNormal()
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.warning(f"Failed to restore window: {e}")
 | 
			
		||||
 | 
			
		||||
            # Выполняем в основном потоке
 | 
			
		||||
            QTimer.singleShot(0, restore_window)
 | 
			
		||||
 | 
			
		||||
        conn.disconnectFromServer()
 | 
			
		||||
 | 
			
		||||
    local_server.newConnection.connect(handle_new_connection)
 | 
			
		||||
 | 
			
		||||
    # --- Initial fullscreen state ---
 | 
			
		||||
    launch_fullscreen = args.fullscreen or read_fullscreen_config()
 | 
			
		||||
    if launch_fullscreen:
 | 
			
		||||
        logger.info(
 | 
			
		||||
            f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
 | 
			
		||||
        )
 | 
			
		||||
        save_fullscreen_config(True)
 | 
			
		||||
        window.showFullScreen()
 | 
			
		||||
    else:
 | 
			
		||||
        logger.info("Launching in normal mode")
 | 
			
		||||
        save_fullscreen_config(False)
 | 
			
		||||
        window.showNormal()
 | 
			
		||||
 | 
			
		||||
    # --- Cleanup ---
 | 
			
		||||
    def cleanup_on_exit():
 | 
			
		||||
        nonlocal window
 | 
			
		||||
        app.aboutToQuit.disconnect()
 | 
			
		||||
        if window:
 | 
			
		||||
            window.close()
 | 
			
		||||
        app.quit()
 | 
			
		||||
        try:
 | 
			
		||||
            local_server.close()
 | 
			
		||||
            QLocalServer.removeServer(server_name)
 | 
			
		||||
            if window:
 | 
			
		||||
                window.close()
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.warning(f"Cleanup error: {e}")
 | 
			
		||||
 | 
			
		||||
    app.aboutToQuit.connect(cleanup_on_exit)
 | 
			
		||||
 | 
			
		||||
    window.show()
 | 
			
		||||
 | 
			
		||||
    sys.exit(app.exec())
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    main()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,13 @@
 | 
			
		||||
import os
 | 
			
		||||
import configparser
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
_portproton_location = None
 | 
			
		||||
_portproton_start_sh = None
 | 
			
		||||
 | 
			
		||||
# Paths to configuration files
 | 
			
		||||
CONFIG_FILE = os.path.join(
 | 
			
		||||
@@ -101,14 +103,14 @@ def read_file_content(file_path):
 | 
			
		||||
        return f.read().strip()
 | 
			
		||||
 | 
			
		||||
def get_portproton_location():
 | 
			
		||||
    """Returns the path to the PortProton directory.
 | 
			
		||||
    Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
 | 
			
		||||
    If the path is invalid, uses the default directory.
 | 
			
		||||
    """
 | 
			
		||||
    """Возвращает путь к PortProton каталогу (строку) или None."""
 | 
			
		||||
    global _portproton_location
 | 
			
		||||
 | 
			
		||||
    if _portproton_location is not None:
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    location = None
 | 
			
		||||
 | 
			
		||||
    if os.path.isfile(PORTPROTON_CONFIG_FILE):
 | 
			
		||||
        try:
 | 
			
		||||
            location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
 | 
			
		||||
@@ -116,19 +118,46 @@ def get_portproton_location():
 | 
			
		||||
                _portproton_location = location
 | 
			
		||||
                logger.info(f"PortProton path from configuration: {location}")
 | 
			
		||||
                return _portproton_location
 | 
			
		||||
            logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
 | 
			
		||||
            logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
 | 
			
		||||
        except (OSError, PermissionError) as e:
 | 
			
		||||
            logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
 | 
			
		||||
            logger.warning(f"Failed to read PortProton configuration file: {e}")
 | 
			
		||||
 | 
			
		||||
    default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
			
		||||
    if os.path.isdir(default_dir):
 | 
			
		||||
        _portproton_location = default_dir
 | 
			
		||||
        logger.info(f"Using flatpak PortProton directory: {default_dir}")
 | 
			
		||||
    default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
 | 
			
		||||
    if os.path.isdir(default_flatpak_dir):
 | 
			
		||||
        _portproton_location = default_flatpak_dir
 | 
			
		||||
        logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    logger.warning("PortProton configuration and flatpak directory not found")
 | 
			
		||||
    logger.warning("PortProton configuration and Flatpak directory not found")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def get_portproton_start_command():
 | 
			
		||||
    """Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
 | 
			
		||||
    portproton_path = get_portproton_location()
 | 
			
		||||
    if not portproton_path:
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        result = subprocess.run(
 | 
			
		||||
            ["flatpak", "list"],
 | 
			
		||||
            capture_output=True,
 | 
			
		||||
            text=True,
 | 
			
		||||
            check=False
 | 
			
		||||
        )
 | 
			
		||||
        if "ru.linux_gaming.PortProton" in result.stdout:
 | 
			
		||||
            logger.info("Detected Flatpak installation")
 | 
			
		||||
            return ["flatpak", "run", "ru.linux_gaming.PortProton"]
 | 
			
		||||
    except Exception:
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
 | 
			
		||||
    if os.path.exists(start_sh_path):
 | 
			
		||||
        return [start_sh_path]
 | 
			
		||||
 | 
			
		||||
    logger.warning("Neither flatpak nor start.sh found for PortProton")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parse_desktop_entry(file_path):
 | 
			
		||||
    """Reads and parses a .desktop file using configparser.
 | 
			
		||||
    Returns None if the [Desktop Entry] section is missing.
 | 
			
		||||
@@ -177,6 +206,26 @@ def save_card_size(card_width):
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_auto_card_size():
 | 
			
		||||
    """Reads the card size (width) for Auto Install from the [Cards] section.
 | 
			
		||||
    Returns 250 if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
 | 
			
		||||
        save_auto_card_size(250)
 | 
			
		||||
        return 250
 | 
			
		||||
    return cp.getint("Cards", "auto_card_width", fallback=250)
 | 
			
		||||
 | 
			
		||||
def save_auto_card_size(card_width):
 | 
			
		||||
    """Saves the card size (width) for Auto Install to the [Cards] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Cards" not in cp:
 | 
			
		||||
        cp["Cards"] = {}
 | 
			
		||||
    cp["Cards"]["auto_card_width"] = str(card_width)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def read_sort_method():
 | 
			
		||||
    """Reads the sort method from the [Games] section.
 | 
			
		||||
    Returns 'last_launch' if the parameter is not set.
 | 
			
		||||
@@ -259,6 +308,25 @@ def save_rumble_config(rumble_enabled):
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_gamepad_type():
 | 
			
		||||
    """Reads the gamepad type from the [Gamepad] section.
 | 
			
		||||
    Returns 'xbox' if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"):
 | 
			
		||||
        save_gamepad_type("xbox")
 | 
			
		||||
        return "xbox"
 | 
			
		||||
    return cp.get("Gamepad", "type", fallback="xbox").lower()
 | 
			
		||||
 | 
			
		||||
def save_gamepad_type(gpad_type):
 | 
			
		||||
    """Saves the gamepad type to the [Gamepad] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Gamepad" not in cp:
 | 
			
		||||
        cp["Gamepad"] = {}
 | 
			
		||||
    cp["Gamepad"]["type"] = gpad_type
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def ensure_default_proxy_config():
 | 
			
		||||
    """Ensures the [Proxy] section exists in the configuration file.
 | 
			
		||||
    Creates it with empty values if missing.
 | 
			
		||||
@@ -408,3 +476,22 @@ def save_favorite_folders(folders):
 | 
			
		||||
    cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_minimize_to_tray():
 | 
			
		||||
    """Reads the minimize-to-tray setting from the [Display] section.
 | 
			
		||||
    Returns True if the parameter is missing (default: minimize to tray).
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
 | 
			
		||||
        save_minimize_to_tray(True)
 | 
			
		||||
        return True
 | 
			
		||||
    return cp.getboolean("Display", "minimize_to_tray", fallback=True)
 | 
			
		||||
 | 
			
		||||
def save_minimize_to_tray(minimize_to_tray):
 | 
			
		||||
    """Saves the minimize-to-tray setting to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 
 | 
			
		||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
 | 
			
		||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
 | 
			
		||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
 | 
			
		||||
from portprotonqt.localization import _
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command
 | 
			
		||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
 | 
			
		||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
 | 
			
		||||
@@ -406,16 +406,7 @@ class ContextMenuManager:
 | 
			
		||||
                )
 | 
			
		||||
                return
 | 
			
		||||
            # Construct EGS launch command
 | 
			
		||||
            wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
			
		||||
            start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
 | 
			
		||||
            if self.portproton_location and ".var" not in self.portproton_location:
 | 
			
		||||
                wrapper = start_sh_path
 | 
			
		||||
                if not os.path.exists(start_sh_path):
 | 
			
		||||
                    self.signals.show_warning_dialog.emit(
 | 
			
		||||
                        _("Error"),
 | 
			
		||||
                        _("start.sh not found at {path}").format(path=start_sh_path)
 | 
			
		||||
                    )
 | 
			
		||||
                    return
 | 
			
		||||
            wrapper = get_portproton_start_command()
 | 
			
		||||
            exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
 | 
			
		||||
        else:
 | 
			
		||||
            exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB  | 
| 
		 Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB  | 
| 
		 Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB  | 
| 
		 Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB  | 
| 
		 Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB  | 
| 
		 Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB  | 
| 
		 Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB  | 
@@ -126,7 +126,21 @@ class FlowLayout(QLayout):
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def heightForWidth(self, width):
 | 
			
		||||
        return self.doLayout(QRect(0, 0, width, 0), True)
 | 
			
		||||
        # Аналогично фильтруем видимые для тестового расчёта высоты
 | 
			
		||||
        visible_items = []
 | 
			
		||||
        nat_sizes = np.empty((0, 2), dtype=np.int32)
 | 
			
		||||
        for item in self.itemList:
 | 
			
		||||
            if item.widget() and item.widget().isVisible():
 | 
			
		||||
                visible_items.append(item)
 | 
			
		||||
                s = item.sizeHint()
 | 
			
		||||
                new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
 | 
			
		||||
                nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
 | 
			
		||||
 | 
			
		||||
        if len(visible_items) == 0:
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        _, total_height = compute_layout(nat_sizes, width, self._spacing, self._max_scale)
 | 
			
		||||
        return total_height
 | 
			
		||||
 | 
			
		||||
    def setGeometry(self, rect):
 | 
			
		||||
        super().setGeometry(rect)
 | 
			
		||||
@@ -145,26 +159,46 @@ class FlowLayout(QLayout):
 | 
			
		||||
        return size
 | 
			
		||||
 | 
			
		||||
    def doLayout(self, rect, testOnly):
 | 
			
		||||
        N = len(self.itemList)
 | 
			
		||||
        if N == 0:
 | 
			
		||||
        N_total = len(self.itemList)
 | 
			
		||||
        if N_total == 0:
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        nat_sizes = np.empty((N, 2), dtype=np.int32)
 | 
			
		||||
        # Фильтруем только видимые элементы
 | 
			
		||||
        visible_items = []
 | 
			
		||||
        visible_indices = []  # Индексы в оригинальном itemList для установки геометрии
 | 
			
		||||
        nat_sizes = np.empty((0, 2), dtype=np.int32)
 | 
			
		||||
        for i, item in enumerate(self.itemList):
 | 
			
		||||
            s = item.sizeHint()
 | 
			
		||||
            nat_sizes[i, 0] = s.width()
 | 
			
		||||
            nat_sizes[i, 1] = s.height()
 | 
			
		||||
            if item.widget() and item.widget().isVisible():
 | 
			
		||||
                visible_items.append(item)
 | 
			
		||||
                visible_indices.append(i)
 | 
			
		||||
                s = item.sizeHint()
 | 
			
		||||
                new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
 | 
			
		||||
                nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
 | 
			
		||||
 | 
			
		||||
        N = len(visible_items)
 | 
			
		||||
        if N == 0:
 | 
			
		||||
            # Если все скрыты, устанавливаем нулевые геометрии для всех
 | 
			
		||||
            if not testOnly:
 | 
			
		||||
                for item in self.itemList:
 | 
			
		||||
                    item.setGeometry(QRect())
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
 | 
			
		||||
 | 
			
		||||
        if not testOnly:
 | 
			
		||||
            for i, item in enumerate(self.itemList):
 | 
			
		||||
                x = geom_array[i, 0] + rect.x()
 | 
			
		||||
                y = geom_array[i, 1] + rect.y()
 | 
			
		||||
                w = geom_array[i, 2]
 | 
			
		||||
                h = geom_array[i, 3]
 | 
			
		||||
            # Устанавливаем геометрии только для видимых
 | 
			
		||||
            for idx, (_vis_idx, item) in enumerate(zip(visible_indices, visible_items, strict=True)):
 | 
			
		||||
                x = geom_array[idx, 0] + rect.x()
 | 
			
		||||
                y = geom_array[idx, 1] + rect.y()
 | 
			
		||||
                w = geom_array[idx, 2]
 | 
			
		||||
                h = geom_array[idx, 3]
 | 
			
		||||
                item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
 | 
			
		||||
 | 
			
		||||
            # Для невидимых — нулевая геометрия
 | 
			
		||||
            for i in range(N_total):
 | 
			
		||||
                if i not in visible_indices:
 | 
			
		||||
                    self.itemList[i].setGeometry(QRect())
 | 
			
		||||
 | 
			
		||||
        return total_height
 | 
			
		||||
 | 
			
		||||
class ClickableLabel(QLabel):
 | 
			
		||||
 
 | 
			
		||||
@@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async
 | 
			
		||||
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
 | 
			
		||||
from portprotonqt.steam_api import (
 | 
			
		||||
    get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
 | 
			
		||||
    search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
 | 
			
		||||
@@ -254,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Determine wrapper
 | 
			
		||||
    wrapper = "flatpak run ru.linux_gaming.PortProton"
 | 
			
		||||
    start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
 | 
			
		||||
    if portproton_dir is not None and ".var" not in portproton_dir:
 | 
			
		||||
        wrapper = start_sh_path
 | 
			
		||||
        if not os.path.exists(start_sh_path):
 | 
			
		||||
            logger.error(f"start.sh not found at {start_sh_path}")
 | 
			
		||||
            callback((False, f"start.sh not found at {start_sh_path}"))
 | 
			
		||||
            return
 | 
			
		||||
    wrapper = get_portproton_start_command()
 | 
			
		||||
 | 
			
		||||
    # Create launch script
 | 
			
		||||
    steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
 | 
			
		||||
from PySide6.QtCore import Signal, Property, Qt, QUrl
 | 
			
		||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
 | 
			
		||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
 | 
			
		||||
@@ -12,6 +12,7 @@ from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.animations import GameCardAnimations
 | 
			
		||||
from typing import cast
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameCard(QFrame):
 | 
			
		||||
    borderWidthChanged = Signal()
 | 
			
		||||
    gradientAngleChanged = Signal()
 | 
			
		||||
@@ -403,6 +404,13 @@ class GameCard(QFrame):
 | 
			
		||||
            self.favoriteLabel.setText("☆")
 | 
			
		||||
        self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
 | 
			
		||||
 | 
			
		||||
        parent = self.parent()
 | 
			
		||||
        while parent:
 | 
			
		||||
            if hasattr(parent, 'game_library_manager'):
 | 
			
		||||
                QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
 | 
			
		||||
                break
 | 
			
		||||
            parent = parent.parent()
 | 
			
		||||
 | 
			
		||||
    def toggle_favorite(self):
 | 
			
		||||
        favorites = read_favorites()
 | 
			
		||||
        if self.is_favorite:
 | 
			
		||||
@@ -447,6 +455,7 @@ class GameCard(QFrame):
 | 
			
		||||
    gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
 | 
			
		||||
    scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    def paintEvent(self, event):
 | 
			
		||||
        super().paintEvent(event)
 | 
			
		||||
        self.animations.paint_border(QPainter(self))
 | 
			
		||||
 
 | 
			
		||||
@@ -33,8 +33,10 @@ class MainWindowProtocol(Protocol):
 | 
			
		||||
    # Required attributes
 | 
			
		||||
    searchEdit: CustomLineEdit
 | 
			
		||||
    _last_card_width: int
 | 
			
		||||
    card_width: int
 | 
			
		||||
    current_hovered_card: GameCard | None
 | 
			
		||||
    current_focused_card: GameCard | None
 | 
			
		||||
    gamesListWidget: QWidget | None
 | 
			
		||||
 | 
			
		||||
class GameLibraryManager:
 | 
			
		||||
    def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
 | 
			
		||||
@@ -127,6 +129,8 @@ class GameLibraryManager:
 | 
			
		||||
        self.card_width = self.sizeSlider.value()
 | 
			
		||||
        self.sizeSlider.setToolTip(f"{self.card_width} px")
 | 
			
		||||
        save_card_size(self.card_width)
 | 
			
		||||
        self.main_window.card_width = self.card_width
 | 
			
		||||
        self.main_window._last_card_width = self.card_width
 | 
			
		||||
        for card in self.game_card_cache.values():
 | 
			
		||||
            card.update_card_size(self.card_width)
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
@@ -216,6 +220,16 @@ class GameLibraryManager:
 | 
			
		||||
        else:
 | 
			
		||||
            self._update_game_grid_immediate()
 | 
			
		||||
 | 
			
		||||
    def force_update_cards_library(self):
 | 
			
		||||
        if self.gamesListWidget and self.gamesListLayout:
 | 
			
		||||
            self.gamesListLayout.invalidate()
 | 
			
		||||
            self.gamesListWidget.updateGeometry()
 | 
			
		||||
            widget = self.gamesListWidget
 | 
			
		||||
            QTimer.singleShot(0, lambda: (
 | 
			
		||||
                widget.adjustSize(),
 | 
			
		||||
                widget.updateGeometry()
 | 
			
		||||
            ))
 | 
			
		||||
 | 
			
		||||
    def _update_game_grid_immediate(self):
 | 
			
		||||
        """Updates the game grid with the provided or current game list."""
 | 
			
		||||
        if self.gamesListLayout is None or self.gamesListWidget is None:
 | 
			
		||||
@@ -345,6 +359,8 @@ class GameLibraryManager:
 | 
			
		||||
                self.gamesListWidget.updateGeometry()
 | 
			
		||||
                self.main_window._last_card_width = self.card_width
 | 
			
		||||
 | 
			
		||||
                self.force_update_cards_library()
 | 
			
		||||
 | 
			
		||||
        self.is_filtering = False  # Reset flag in any case
 | 
			
		||||
 | 
			
		||||
    def _apply_filter_visibility(self, search_text: str):
 | 
			
		||||
@@ -362,8 +378,9 @@ class GameLibraryManager:
 | 
			
		||||
                    cover_path, width, height, callback = self.pending_images.pop(game_key)
 | 
			
		||||
                    load_pixmap_async(cover_path, width, height, callback)
 | 
			
		||||
 | 
			
		||||
        # Force geometry update so FlowLayout accounts for hidden widgets
 | 
			
		||||
        # Force full relayout after visibility changes
 | 
			
		||||
        if self.gamesListLayout is not None:
 | 
			
		||||
            self.gamesListLayout.invalidate()  # Принудительно инвалидируем для пересчёта
 | 
			
		||||
            self.gamesListLayout.update()
 | 
			
		||||
        if self.gamesListWidget is not None:
 | 
			
		||||
            self.gamesListWidget.updateGeometry()
 | 
			
		||||
 
 | 
			
		||||
@@ -83,6 +83,43 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Ошибка обработки URL {cover}: {e}")
 | 
			
		||||
 | 
			
		||||
        # SteamGridDB (SGDB)
 | 
			
		||||
        if cover and cover.startswith("https://cdn2.steamgriddb.com"):
 | 
			
		||||
            try:
 | 
			
		||||
                parts = cover.split("/")
 | 
			
		||||
                filename = parts[-1] if parts else "sgdb_cover.png"
 | 
			
		||||
                # SGDB ссылки содержат уникальный хеш в названии — используем как имя
 | 
			
		||||
                local_path = os.path.join(image_folder, filename)
 | 
			
		||||
 | 
			
		||||
                if os.path.exists(local_path):
 | 
			
		||||
                    pixmap = QPixmap(local_path)
 | 
			
		||||
                    finish_with(pixmap)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                def on_downloaded(result: str | None):
 | 
			
		||||
                    pixmap = QPixmap()
 | 
			
		||||
                    if result and os.path.exists(result):
 | 
			
		||||
                        pixmap.load(result)
 | 
			
		||||
                    if pixmap.isNull():
 | 
			
		||||
                        placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
 | 
			
		||||
                        if placeholder_path and QFile.exists(placeholder_path):
 | 
			
		||||
                            pixmap.load(placeholder_path)
 | 
			
		||||
                        else:
 | 
			
		||||
                            pixmap = QPixmap(width, height)
 | 
			
		||||
                            pixmap.fill(QColor("#333333"))
 | 
			
		||||
                            painter = QPainter(pixmap)
 | 
			
		||||
                            painter.setPen(QPen(QColor("white")))
 | 
			
		||||
                            painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
 | 
			
		||||
                            painter.end()
 | 
			
		||||
                    finish_with(pixmap)
 | 
			
		||||
 | 
			
		||||
                logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
 | 
			
		||||
                downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Ошибка обработки SGDB URL {cover}: {e}")
 | 
			
		||||
 | 
			
		||||
        if cover and cover.startswith(("http://", "https://")):
 | 
			
		||||
            try:
 | 
			
		||||
                local_path = os.path.join(image_folder, f"{app_name}.jpg")
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										73
									
								
								portprotonqt/keyboard_layouts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,73 @@
 | 
			
		||||
# keyboard_layouts.py
 | 
			
		||||
keyboard_layouts = {
 | 
			
		||||
    'en': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
 | 
			
		||||
            ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
 | 
			
		||||
            ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"],
 | 
			
		||||
            ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'],
 | 
			
		||||
            ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'],
 | 
			
		||||
            ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'],
 | 
			
		||||
            ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?']
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    'ru': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
 | 
			
		||||
            ['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'],
 | 
			
		||||
            ['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'],
 | 
			
		||||
            ['⬆', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['Ё', '!', '"', '№', ';', '%', ':', '?', '*', '(', ')', '_', '+'],
 | 
			
		||||
            ['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'],
 | 
			
		||||
            ['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'],
 | 
			
		||||
            ['⬆', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ',']
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    'fr': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='],
 | 
			
		||||
            ['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'],
 | 
			
		||||
            ['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'],
 | 
			
		||||
            ['⬆', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'],
 | 
			
		||||
            ['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'],
 | 
			
		||||
            ['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'],
 | 
			
		||||
            ['⬆', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§']
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    'es': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'],
 | 
			
		||||
            ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'],
 | 
			
		||||
            ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'],
 | 
			
		||||
            ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'],
 | 
			
		||||
            ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'],
 | 
			
		||||
            ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'],
 | 
			
		||||
            ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    'de': {
 | 
			
		||||
        'normal': [
 | 
			
		||||
            ['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'],
 | 
			
		||||
            ['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'],
 | 
			
		||||
            ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'],
 | 
			
		||||
            ['⬆', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
 | 
			
		||||
        ],
 | 
			
		||||
        'shift': [
 | 
			
		||||
            ['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'],
 | 
			
		||||
            ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''],
 | 
			
		||||
            ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'],
 | 
			
		||||
            ['⬆', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: de_DE\n"
 | 
			
		||||
@@ -191,6 +191,10 @@ msgstr ""
 | 
			
		||||
msgid "Failed to delete custom data: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{game_name}' successfully"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Game name and executable path are required"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -248,13 +252,37 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -304,6 +332,39 @@ msgstr ""
 | 
			
		||||
msgid "No cover selected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Set"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Libraries"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Information"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fonts"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Warning"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No components selected."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed. Check logs."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Components installed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Epic Games Store games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -352,9 +413,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -370,6 +428,28 @@ msgstr ""
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -382,13 +462,106 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgid "Prefix:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Configuration"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Uninstaller"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Create Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Load Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Compatibility Tool"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start restore process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete prefix: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Compatibility tool '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete compatibility tool: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -424,6 +597,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -448,6 +624,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language: es_ES\n"
 | 
			
		||||
@@ -191,6 +191,10 @@ msgstr ""
 | 
			
		||||
msgid "Failed to delete custom data: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{game_name}' successfully"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Game name and executable path are required"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -248,13 +252,37 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -304,6 +332,39 @@ msgstr ""
 | 
			
		||||
msgid "No cover selected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Set"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Libraries"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Information"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fonts"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Warning"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No components selected."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed. Check logs."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Components installed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Epic Games Store games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -352,9 +413,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -370,6 +428,28 @@ msgstr ""
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -382,13 +462,106 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgid "Prefix:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Configuration"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Uninstaller"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Create Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Load Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Compatibility Tool"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start restore process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete prefix: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Compatibility tool '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete compatibility tool: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -424,6 +597,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -448,6 +624,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
 | 
			
		||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
 | 
			
		||||
"Language-Team: LANGUAGE <LL@li.org>\n"
 | 
			
		||||
@@ -189,6 +189,10 @@ msgstr ""
 | 
			
		||||
msgid "Failed to delete custom data: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{game_name}' successfully"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Game name and executable path are required"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -246,13 +250,37 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -302,6 +330,39 @@ msgstr ""
 | 
			
		||||
msgid "No cover selected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix Manager"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Set"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Libraries"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Information"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Fonts"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Warning"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "No components selected."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed. Check logs."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Components installed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Epic Games Store games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -350,9 +411,6 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -368,6 +426,28 @@ msgstr ""
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -380,13 +460,106 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgid "Prefix:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Configuration"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Uninstaller"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Create Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Load Prefix Backup"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Compatibility Tool"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start restore process."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore completed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore failed."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete prefix '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete prefix: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Compatibility tool '{}' deleted."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete compatibility tool: {}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -422,6 +595,9 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -446,6 +622,12 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,18 +9,17 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-09-23 22:23+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"Last-Translator: \n"
 | 
			
		||||
"Language-Team: ru_RU <LL@li.org>\n"
 | 
			
		||||
"Language: ru_RU\n"
 | 
			
		||||
"Language-Team: ru_RU <LL@li.org>\n"
 | 
			
		||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
 | 
			
		||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
 | 
			
		||||
"MIME-Version: 1.0\n"
 | 
			
		||||
"Content-Type: text/plain; charset=utf-8\n"
 | 
			
		||||
"Content-Transfer-Encoding: 8bit\n"
 | 
			
		||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 "
 | 
			
		||||
"&& (n%100<10 || n%100>=20) ? 1 : 2);\n"
 | 
			
		||||
"Generated-By: Babel 2.17.0\n"
 | 
			
		||||
"X-Generator: Poedit 3.6\n"
 | 
			
		||||
 | 
			
		||||
msgid "Error"
 | 
			
		||||
msgstr "Ошибка"
 | 
			
		||||
@@ -87,11 +86,11 @@ msgstr "Успешно"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"'{game_name}' was added to Steam. Please restart Steam for changes to take "
 | 
			
		||||
"effect."
 | 
			
		||||
"'{game_name}' was added to Steam. Please restart Steam for changes to "
 | 
			
		||||
"take effect."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите Steam, "
 | 
			
		||||
"чтобы изменения вступили в силу."
 | 
			
		||||
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите "
 | 
			
		||||
"Steam, чтобы изменения вступили в силу."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found for game: {game_name}"
 | 
			
		||||
@@ -179,11 +178,11 @@ msgstr "Подтвердите удаление"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Are you sure you want to delete '{game_name}'? This will remove the .desktop "
 | 
			
		||||
"file and custom data."
 | 
			
		||||
"Are you sure you want to delete '{game_name}'? This will remove the "
 | 
			
		||||
".desktop file and custom data."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению файла ."
 | 
			
		||||
"desktop и пользовательских данных."
 | 
			
		||||
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению "
 | 
			
		||||
"файла .desktop и пользовательских данных."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete .desktop file: {error}"
 | 
			
		||||
@@ -197,6 +196,10 @@ msgstr "'{game_name}' был(а) успешно удалён(а)"
 | 
			
		||||
msgid "Failed to delete custom data: {error}"
 | 
			
		||||
msgstr "Не удалось удалить пользовательские данные: {error}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{game_name}' successfully"
 | 
			
		||||
msgstr "'{game_name}' успешно добавлен(а)"
 | 
			
		||||
 | 
			
		||||
msgid "Game name and executable path are required"
 | 
			
		||||
msgstr "Требуются название игры и путь к исполняемому файлу"
 | 
			
		||||
 | 
			
		||||
@@ -225,11 +228,11 @@ msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"'{game_name}' was removed from Steam. Please restart Steam for changes to take "
 | 
			
		||||
"effect."
 | 
			
		||||
"'{game_name}' was removed from Steam. Please restart Steam for changes to"
 | 
			
		||||
" take effect."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam, чтобы "
 | 
			
		||||
"изменения вступили в силу."
 | 
			
		||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
 | 
			
		||||
" чтобы изменения вступили в силу."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
 | 
			
		||||
@@ -256,13 +259,37 @@ msgstr "Удалить"
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr "Выбрать всё"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr "Идёт запуск {0}"
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr "Открыть"
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr "Выбрать папку"
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr "Предыдущий каталог"
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "Отмена"
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr "Переключить"
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr "Установить"
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr "Принудительно установить"
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr "Предыдущая вкладка"
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr "Следующая вкладка"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr "Идёт запуск {0}"
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr "Проводник"
 | 
			
		||||
 | 
			
		||||
@@ -274,7 +301,7 @@ msgstr "Путь: "
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgstr "Доступ запрещен: %s"
 | 
			
		||||
msgstr "Доступ запрещён: %s"
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
msgstr "Редактировать игру"
 | 
			
		||||
@@ -312,6 +339,39 @@ msgstr "Скачивание обложки..."
 | 
			
		||||
msgid "No cover selected"
 | 
			
		||||
msgstr "Обложка не выбрана"
 | 
			
		||||
 | 
			
		||||
msgid "Prefix Manager"
 | 
			
		||||
msgstr "Менеджер префиксов"
 | 
			
		||||
 | 
			
		||||
msgid "Set"
 | 
			
		||||
msgstr "Выбор"
 | 
			
		||||
 | 
			
		||||
msgid "Libraries"
 | 
			
		||||
msgstr "Библиотеки"
 | 
			
		||||
 | 
			
		||||
msgid "Information"
 | 
			
		||||
msgstr "Описание"
 | 
			
		||||
 | 
			
		||||
msgid "Fonts"
 | 
			
		||||
msgstr "Шрифты"
 | 
			
		||||
 | 
			
		||||
msgid "Settings"
 | 
			
		||||
msgstr "Настройки"
 | 
			
		||||
 | 
			
		||||
msgid "Winetricks not found. Please try again."
 | 
			
		||||
msgstr "Winetricks не найден. Повторите попытку."
 | 
			
		||||
 | 
			
		||||
msgid "Warning"
 | 
			
		||||
msgstr "Предупреждение"
 | 
			
		||||
 | 
			
		||||
msgid "No components selected."
 | 
			
		||||
msgstr "Не выбрано ни одного компонента."
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed. Check logs."
 | 
			
		||||
msgstr "Установка не удалась. Проверьте журналы."
 | 
			
		||||
 | 
			
		||||
msgid "Components installed successfully."
 | 
			
		||||
msgstr "Компоненты успешно установлены."
 | 
			
		||||
 | 
			
		||||
msgid "Loading Epic Games Store games..."
 | 
			
		||||
msgstr "Загрузка игр из Epic Games Store..."
 | 
			
		||||
 | 
			
		||||
@@ -360,9 +420,6 @@ msgstr "Библиотека"
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr "Автоустановка"
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr "Эмуляторы"
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr "Настройки wine"
 | 
			
		||||
 | 
			
		||||
@@ -378,6 +435,28 @@ msgstr "Назад"
 | 
			
		||||
msgid "Fullscreen"
 | 
			
		||||
msgstr "Полный экран"
 | 
			
		||||
 | 
			
		||||
msgid "Search"
 | 
			
		||||
msgstr "Поиск"
 | 
			
		||||
 | 
			
		||||
msgid "Installation already in progress."
 | 
			
		||||
msgstr "Установка уже выполняется."
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start installation."
 | 
			
		||||
msgstr "Не удалось запустить установку."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Processed {} installation..."
 | 
			
		||||
msgstr "В процессе установки {}..."
 | 
			
		||||
 | 
			
		||||
msgid "Installation completed successfully."
 | 
			
		||||
msgstr "Установка завершена успешно."
 | 
			
		||||
 | 
			
		||||
msgid "Installation failed."
 | 
			
		||||
msgstr "Установка не удалась."
 | 
			
		||||
 | 
			
		||||
msgid "Installation error."
 | 
			
		||||
msgstr "Ошибка установки."
 | 
			
		||||
 | 
			
		||||
msgid "Loading Steam games..."
 | 
			
		||||
msgstr "Загрузка игр из Steam..."
 | 
			
		||||
 | 
			
		||||
@@ -390,14 +469,109 @@ msgstr "Игровая библиотека"
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr "Найти игры..."
 | 
			
		||||
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr "Здесь можно настроить автоматическую установку игр..."
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr "'{name}' добавлен(а)"
 | 
			
		||||
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr "Список доступных эмуляторов и их настройка..."
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr "Инструмент совместимости:"
 | 
			
		||||
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgstr "Различные параметры и версии wine..."
 | 
			
		||||
msgid "Prefix:"
 | 
			
		||||
msgstr "Префикс:"
 | 
			
		||||
 | 
			
		||||
msgid "Wine Configuration"
 | 
			
		||||
msgstr "Конфигурация Wine"
 | 
			
		||||
 | 
			
		||||
msgid "Registry Editor"
 | 
			
		||||
msgstr "Редактор реестра"
 | 
			
		||||
 | 
			
		||||
msgid "Command Prompt"
 | 
			
		||||
msgstr "Командная строка"
 | 
			
		||||
 | 
			
		||||
msgid "Uninstaller"
 | 
			
		||||
msgstr "Удаление программ"
 | 
			
		||||
 | 
			
		||||
msgid "Create Prefix Backup"
 | 
			
		||||
msgstr "Создать резервную копию префикса"
 | 
			
		||||
 | 
			
		||||
msgid "Load Prefix Backup"
 | 
			
		||||
msgstr "Загрузить резервную копию префикса"
 | 
			
		||||
 | 
			
		||||
msgid "Delete Compatibility Tool"
 | 
			
		||||
msgstr "Удалить Инструмент совместимости"
 | 
			
		||||
 | 
			
		||||
msgid "Delete Prefix"
 | 
			
		||||
msgstr "Удалить Префикс"
 | 
			
		||||
 | 
			
		||||
msgid "Clear Prefix"
 | 
			
		||||
msgstr "Очистить Префикс"
 | 
			
		||||
 | 
			
		||||
msgid "Launching tool..."
 | 
			
		||||
msgstr "Запуск инструмента..."
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start process."
 | 
			
		||||
msgstr "Не удалось запустить процесс."
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Clear"
 | 
			
		||||
msgstr "Подтвердите очистку"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to clear prefix '{}'?"
 | 
			
		||||
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' cleared successfully."
 | 
			
		||||
msgstr "Префикс '{}' успешно удален."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid ""
 | 
			
		||||
"Prefix '{}' cleared with errors:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Префикс '{}' очищен с ошибками:\n"
 | 
			
		||||
"{}"
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start backup process."
 | 
			
		||||
msgstr "Не удалось запустить процесс резервного копирования."
 | 
			
		||||
 | 
			
		||||
msgid "Failed to start restore process."
 | 
			
		||||
msgstr "Не удалось запустить процесс восстановления."
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup completed."
 | 
			
		||||
msgstr "Резервное копирование префикса завершено."
 | 
			
		||||
 | 
			
		||||
msgid "Prefix backup failed."
 | 
			
		||||
msgstr "Сбой резервного копирования префикса."
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore completed."
 | 
			
		||||
msgstr "Восстановление префикса завершено."
 | 
			
		||||
 | 
			
		||||
msgid "Prefix restore failed."
 | 
			
		||||
msgstr "Восстановление префикса не удалось."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete prefix '{}'?"
 | 
			
		||||
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Prefix '{}' deleted."
 | 
			
		||||
msgstr "Префикс «{}» удален."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete prefix: {}"
 | 
			
		||||
msgstr "Не удалось удалить префикс: {}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
 | 
			
		||||
msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Compatibility tool '{}' deleted."
 | 
			
		||||
msgstr "Инструмент совместимости «{}» удален."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to delete compatibility tool: {}"
 | 
			
		||||
msgstr "Не удалось удалить инструмент совместимости: {}"
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
msgstr "Основные параметры PortProton..."
 | 
			
		||||
@@ -432,6 +606,9 @@ msgstr "все"
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr "Фильтр игр:"
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr "Тип геймпада:"
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr "Адрес прокси"
 | 
			
		||||
 | 
			
		||||
@@ -456,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения:"
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr "Сворачивать в трей при закрытии"
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr "Режим закрытия приложения:"
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
 | 
			
		||||
 | 
			
		||||
@@ -482,7 +665,8 @@ msgstr "Подтвердите удаление"
 | 
			
		||||
 | 
			
		||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
 | 
			
		||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
 | 
			
		||||
"отменить."
 | 
			
		||||
 | 
			
		||||
msgid "Settings reset. Restarting..."
 | 
			
		||||
msgstr "Настройки сброшены. Перезапуск..."
 | 
			
		||||
@@ -654,3 +838,4 @@ msgstr "Нет избранных"
 | 
			
		||||
 | 
			
		||||
msgid "No recent games"
 | 
			
		||||
msgstr "Нет недавних игр"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -4,12 +4,18 @@ import orjson
 | 
			
		||||
import requests
 | 
			
		||||
import urllib.parse
 | 
			
		||||
import time
 | 
			
		||||
import glob
 | 
			
		||||
import re
 | 
			
		||||
import hashlib
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from PySide6.QtCore import QThread, Signal
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds
 | 
			
		||||
AUTOINSTALL_CACHE_DURATION = 3600  # 1 hour for autoinstall cache
 | 
			
		||||
 | 
			
		||||
def normalize_name(s):
 | 
			
		||||
    """
 | 
			
		||||
@@ -52,7 +58,11 @@ class PortProtonAPI:
 | 
			
		||||
        self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
        self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
 | 
			
		||||
        os.makedirs(self.custom_data_dir, exist_ok=True)
 | 
			
		||||
        self.portproton_location = get_portproton_location()
 | 
			
		||||
        self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
 | 
			
		||||
        self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
 | 
			
		||||
        self._topics_data = None
 | 
			
		||||
        self._autoinstall_cache = None  # New: In-memory cache
 | 
			
		||||
 | 
			
		||||
    def _get_game_dir(self, exe_name: str) -> str:
 | 
			
		||||
        game_dir = os.path.join(self.custom_data_dir, exe_name)
 | 
			
		||||
@@ -68,40 +78,6 @@ class PortProtonAPI:
 | 
			
		||||
            logger.debug(f"Failed to check file at {url}: {e}")
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]:
 | 
			
		||||
        game_dir = self._get_game_dir(exe_name)
 | 
			
		||||
        results: dict[str, str | None] = {"cover": None, "metadata": None}
 | 
			
		||||
        cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
 | 
			
		||||
        cover_url_base = f"{self.base_url}/{exe_name}/cover"
 | 
			
		||||
        metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
 | 
			
		||||
 | 
			
		||||
        for ext in cover_extensions:
 | 
			
		||||
            cover_url = f"{cover_url_base}{ext}"
 | 
			
		||||
            if self._check_file_exists(cover_url, timeout):
 | 
			
		||||
                local_cover_path = os.path.join(game_dir, f"cover{ext}")
 | 
			
		||||
                result = self.downloader.download(cover_url, local_cover_path, timeout=timeout)
 | 
			
		||||
                if result:
 | 
			
		||||
                    results["cover"] = result
 | 
			
		||||
                    logger.info(f"Downloaded cover for {exe_name} to {result}")
 | 
			
		||||
                    break
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.error(f"Failed to download cover for {exe_name} from {cover_url}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.debug(f"No cover found for {exe_name} with extension {ext}")
 | 
			
		||||
 | 
			
		||||
        if self._check_file_exists(metadata_url, timeout):
 | 
			
		||||
            local_metadata_path = os.path.join(game_dir, "metadata.txt")
 | 
			
		||||
            result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout)
 | 
			
		||||
            if result:
 | 
			
		||||
                results["metadata"] = result
 | 
			
		||||
                logger.info(f"Downloaded metadata for {exe_name} to {result}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.debug(f"No metadata found for {exe_name}")
 | 
			
		||||
 | 
			
		||||
        return results
 | 
			
		||||
 | 
			
		||||
    def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None:
 | 
			
		||||
        game_dir = self._get_game_dir(exe_name)
 | 
			
		||||
        cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
 | 
			
		||||
@@ -163,6 +139,236 @@ class PortProtonAPI:
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(results)
 | 
			
		||||
 | 
			
		||||
    def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
 | 
			
		||||
        """Download only autoinstall cover image (PNG only, no metadata)."""
 | 
			
		||||
        xdg_data_home = os.getenv("XDG_DATA_HOME",
 | 
			
		||||
                                os.path.join(os.path.expanduser("~"), ".local", "share"))
 | 
			
		||||
        autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
 | 
			
		||||
        user_game_folder = os.path.join(autoinstall_root, exe_name)
 | 
			
		||||
 | 
			
		||||
        if not os.path.isdir(user_game_folder):
 | 
			
		||||
            try:
 | 
			
		||||
                os.mkdir(user_game_folder)
 | 
			
		||||
            except FileExistsError:
 | 
			
		||||
                pass
 | 
			
		||||
 | 
			
		||||
        cover_url = f"{self.base_url}/{exe_name}/cover.png"
 | 
			
		||||
        local_cover_path = os.path.join(user_game_folder, "cover.png")
 | 
			
		||||
 | 
			
		||||
        def on_cover_downloaded(local_path: str | None):
 | 
			
		||||
            if local_path:
 | 
			
		||||
                logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.debug(f"No autoinstall cover downloaded for {exe_name}")
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(local_path)
 | 
			
		||||
 | 
			
		||||
        if self._check_file_exists(cover_url, timeout):
 | 
			
		||||
            self.downloader.download_async(
 | 
			
		||||
                cover_url,
 | 
			
		||||
                local_cover_path,
 | 
			
		||||
                timeout=timeout,
 | 
			
		||||
                callback=on_cover_downloaded
 | 
			
		||||
            )
 | 
			
		||||
        else:
 | 
			
		||||
            logger.debug(f"No autoinstall cover found for {exe_name}")
 | 
			
		||||
            if callback:
 | 
			
		||||
                callback(None)
 | 
			
		||||
 | 
			
		||||
    def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]:
 | 
			
		||||
        """Extract display_name from # name comment and exe_name from autoinstall bash script."""
 | 
			
		||||
        try:
 | 
			
		||||
            with open(file_path, encoding='utf-8') as f:
 | 
			
		||||
                content = f.read()
 | 
			
		||||
 | 
			
		||||
            # Skip emulators
 | 
			
		||||
            if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE):
 | 
			
		||||
                return None, None
 | 
			
		||||
 | 
			
		||||
            display_name = None
 | 
			
		||||
            exe_name = None
 | 
			
		||||
 | 
			
		||||
            # Extract display_name from "# name:" comment
 | 
			
		||||
            name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE)
 | 
			
		||||
            if name_match:
 | 
			
		||||
                display_name = name_match.group(1).strip()
 | 
			
		||||
 | 
			
		||||
            # --- pw_create_unique_exe ---
 | 
			
		||||
            pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content)
 | 
			
		||||
            if pw_match:
 | 
			
		||||
                arg = pw_match.group(1)
 | 
			
		||||
                if arg:
 | 
			
		||||
                    exe_name = arg.strip()
 | 
			
		||||
                    if not exe_name.lower().endswith(".exe"):
 | 
			
		||||
                        exe_name += ".exe"
 | 
			
		||||
                else:
 | 
			
		||||
                    export_match = re.search(
 | 
			
		||||
                        r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']',
 | 
			
		||||
                        content, re.IGNORECASE)
 | 
			
		||||
                    if export_match:
 | 
			
		||||
                        exe_name = f"{export_match.group(1).strip()}.exe"
 | 
			
		||||
 | 
			
		||||
            else:
 | 
			
		||||
                portwine_match = None
 | 
			
		||||
                for line in content.splitlines():
 | 
			
		||||
                    stripped = line.strip()
 | 
			
		||||
                    if stripped.startswith("#"):
 | 
			
		||||
                        continue
 | 
			
		||||
                    if "portwine_exe" in stripped and "=" in stripped:
 | 
			
		||||
                        portwine_match = stripped
 | 
			
		||||
                        break
 | 
			
		||||
 | 
			
		||||
                if portwine_match:
 | 
			
		||||
                    exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ")
 | 
			
		||||
                    exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr)
 | 
			
		||||
                    if exe_candidates:
 | 
			
		||||
                        exe_name = os.path.basename(exe_candidates[-1].strip())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            # Fallback
 | 
			
		||||
            if not display_name and exe_name:
 | 
			
		||||
                display_name = exe_name
 | 
			
		||||
 | 
			
		||||
            return display_name, exe_name
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to parse {file_path}: {e}")
 | 
			
		||||
            return None, None
 | 
			
		||||
 | 
			
		||||
    def _compute_scripts_signature(self, auto_dir: str) -> str:
 | 
			
		||||
        """Compute a hash-based signature of the autoinstall scripts to detect changes."""
 | 
			
		||||
        if not os.path.exists(auto_dir):
 | 
			
		||||
            return ""
 | 
			
		||||
        scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
			
		||||
        # Simple hash: concatenate sorted filenames and hash
 | 
			
		||||
        filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
 | 
			
		||||
        return hashlib.md5(filenames_str.encode()).hexdigest()
 | 
			
		||||
 | 
			
		||||
    def _load_autoinstall_cache(self):
 | 
			
		||||
        """Load cached autoinstall games if fresh and scripts unchanged."""
 | 
			
		||||
        if self._autoinstall_cache is not None:
 | 
			
		||||
            return self._autoinstall_cache
 | 
			
		||||
        cache_dir = get_cache_dir()
 | 
			
		||||
        cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
 | 
			
		||||
        if os.path.exists(cache_file):
 | 
			
		||||
            try:
 | 
			
		||||
                mod_time = os.path.getmtime(cache_file)
 | 
			
		||||
                if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
 | 
			
		||||
                    with open(cache_file, "rb") as f:
 | 
			
		||||
                        data = orjson.loads(f.read())
 | 
			
		||||
                        # Check signature
 | 
			
		||||
                        cached_signature = data.get("scripts_signature", "")
 | 
			
		||||
                        current_signature = self._compute_scripts_signature(
 | 
			
		||||
                            os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
 | 
			
		||||
                        )
 | 
			
		||||
                        if cached_signature != current_signature:
 | 
			
		||||
                            logger.info("Scripts signature mismatch; invalidating cache")
 | 
			
		||||
                            return None
 | 
			
		||||
                        self._autoinstall_cache = data["games"]
 | 
			
		||||
                        logger.info(f"Loaded {len(self._autoinstall_cache)} cached autoinstall games")
 | 
			
		||||
                        return self._autoinstall_cache
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to load autoinstall cache: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    def _save_autoinstall_cache(self, games):
 | 
			
		||||
        """Save parsed autoinstall games to cache with scripts signature."""
 | 
			
		||||
        try:
 | 
			
		||||
            cache_dir = get_cache_dir()
 | 
			
		||||
            cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
 | 
			
		||||
            auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
 | 
			
		||||
            scripts_signature = self._compute_scripts_signature(auto_dir)
 | 
			
		||||
            data = {"games": games, "scripts_signature": scripts_signature, "timestamp": time.time()}
 | 
			
		||||
            with open(cache_file, "wb") as f:
 | 
			
		||||
                f.write(orjson.dumps(data))
 | 
			
		||||
            logger.debug(f"Saved {len(games)} autoinstall games to cache with signature {scripts_signature}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to save autoinstall cache: {e}")
 | 
			
		||||
 | 
			
		||||
    def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
 | 
			
		||||
        """Start loading auto-install games in a background thread. Returns the thread for management."""
 | 
			
		||||
        # Check cache first (sync, fast)
 | 
			
		||||
        cached_games = self._load_autoinstall_cache()
 | 
			
		||||
        if cached_games is not None:
 | 
			
		||||
            # Emit via callback immediately if cached
 | 
			
		||||
            QThread.msleep(0)  # Yield to Qt event loop
 | 
			
		||||
            callback(cached_games)
 | 
			
		||||
            return None  # No thread needed
 | 
			
		||||
 | 
			
		||||
        # No cache: Start background thread
 | 
			
		||||
        class AutoinstallWorker(QThread):
 | 
			
		||||
            finished = Signal(list)
 | 
			
		||||
            api: "PortProtonAPI"
 | 
			
		||||
            portproton_location: str | None
 | 
			
		||||
 | 
			
		||||
            def run(self):
 | 
			
		||||
                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):
 | 
			
		||||
                    self.finished.emit(games)
 | 
			
		||||
                    return
 | 
			
		||||
 | 
			
		||||
                scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
 | 
			
		||||
                if not scripts:
 | 
			
		||||
                    self.finished.emit(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.api.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]
 | 
			
		||||
                    user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
 | 
			
		||||
                    os.makedirs(user_game_folder, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
                    # Find cover
 | 
			
		||||
                    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}")
 | 
			
		||||
 | 
			
		||||
                    game_tuple = (
 | 
			
		||||
                        display_name, "", cover_path, "", f"autoinstall:{script_name}",
 | 
			
		||||
                        "", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
 | 
			
		||||
                    )
 | 
			
		||||
                    games.append(game_tuple)
 | 
			
		||||
 | 
			
		||||
                self.api._save_autoinstall_cache(games)
 | 
			
		||||
                self.api._autoinstall_cache = games
 | 
			
		||||
                self.finished.emit(games)
 | 
			
		||||
 | 
			
		||||
        worker = AutoinstallWorker()
 | 
			
		||||
        worker.api = self
 | 
			
		||||
        worker.portproton_location = self.portproton_location
 | 
			
		||||
        worker.finished.connect(lambda games: callback(games))
 | 
			
		||||
        worker.start()
 | 
			
		||||
        logger.info("Started background load of autoinstall games")
 | 
			
		||||
        return worker
 | 
			
		||||
 | 
			
		||||
    def _load_topics_data(self):
 | 
			
		||||
        """Load and cache linux_gaming_topics_min.json from the archive."""
 | 
			
		||||
        if self._topics_data is not None:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										49
									
								
								portprotonqt/preloader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,49 @@
 | 
			
		||||
import time
 | 
			
		||||
 | 
			
		||||
from PySide6.QtCore import QRect
 | 
			
		||||
from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient
 | 
			
		||||
from PySide6.QtWidgets import QWidget
 | 
			
		||||
 | 
			
		||||
class Preloader(QWidget):
 | 
			
		||||
    def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.setFixedSize(150, 150)
 | 
			
		||||
        self._speed = speed
 | 
			
		||||
        self._line_width = line_line_width
 | 
			
		||||
        self._color1 = color
 | 
			
		||||
        self._color2 = QColor(color.red(), color.green(), color.blue(), 0)
 | 
			
		||||
        self._start_time = time.time()
 | 
			
		||||
 | 
			
		||||
    def showEvent(self, event):
 | 
			
		||||
        self._start_time = time.time()
 | 
			
		||||
 | 
			
		||||
    def paintEvent(self, event):
 | 
			
		||||
        rect = self._get_preloader_rect()
 | 
			
		||||
        center = rect.center()
 | 
			
		||||
        painter = QPainter(self)
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
        painter.setPen(self._get_pen())
 | 
			
		||||
        painter.translate(center)
 | 
			
		||||
        painter.rotate(self._get_angle())
 | 
			
		||||
        painter.translate(-center)
 | 
			
		||||
        painter.drawArc(rect, 0, 270 * 16)
 | 
			
		||||
        self.update()
 | 
			
		||||
 | 
			
		||||
    def _get_pen(self) -> QPen:
 | 
			
		||||
        gradient = QConicalGradient()
 | 
			
		||||
        gradient.setCenter(self.rect().center())
 | 
			
		||||
        gradient.setColorAt(0, self._color1)
 | 
			
		||||
        gradient.setColorAt(1, self._color2)
 | 
			
		||||
        pen = QPen(QBrush(gradient), self._line_width)
 | 
			
		||||
        pen.setCapStyle(Qt.PenCapStyle.RoundCap)
 | 
			
		||||
        return pen
 | 
			
		||||
 | 
			
		||||
    def _get_angle(self) -> float:
 | 
			
		||||
        duration = time.time() - self._start_time
 | 
			
		||||
        return (self._speed * duration) % 360.0
 | 
			
		||||
 | 
			
		||||
    def _get_preloader_rect(self) -> QRect:
 | 
			
		||||
        size = self._line_width // 2
 | 
			
		||||
        rect = self.rect()
 | 
			
		||||
        rect.adjust(size, size, -size, -size)
 | 
			
		||||
        return rect
 | 
			
		||||
@@ -13,7 +13,7 @@ from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.localization import get_steam_language
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.dialogs import generate_thumbnail
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import re
 | 
			
		||||
import shutil
 | 
			
		||||
@@ -23,6 +23,7 @@ import requests
 | 
			
		||||
import random
 | 
			
		||||
import base64
 | 
			
		||||
import glob
 | 
			
		||||
import urllib.parse
 | 
			
		||||
 | 
			
		||||
downloader = Downloader()
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -211,14 +212,28 @@ def normalize_name(s):
 | 
			
		||||
 | 
			
		||||
def is_valid_candidate(candidate):
 | 
			
		||||
    """
 | 
			
		||||
    Checks if a candidate contains forbidden substrings:
 | 
			
		||||
      - win32
 | 
			
		||||
      - win64
 | 
			
		||||
      - gamelauncher
 | 
			
		||||
    Additionally checks the string without spaces.
 | 
			
		||||
    Returns True if the candidate is valid, otherwise False.
 | 
			
		||||
    Determines whether a given candidate string is valid for use as a game name.
 | 
			
		||||
 | 
			
		||||
    The function performs the following checks:
 | 
			
		||||
      1. Normalizes the candidate using `normalize_name()`.
 | 
			
		||||
      2. Rejects the candidate if the normalized name is exactly "game"
 | 
			
		||||
         (to avoid overly generic names).
 | 
			
		||||
      3. Removes spaces and checks for forbidden substrings:
 | 
			
		||||
         - "win32"
 | 
			
		||||
         - "win64"
 | 
			
		||||
         - "gamelauncher"
 | 
			
		||||
         These are checked in the space-free version of the string.
 | 
			
		||||
      4. Returns True only if none of the forbidden conditions are met.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        candidate (str): The candidate string to validate.
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        bool: True if the candidate is valid, False otherwise.
 | 
			
		||||
    """
 | 
			
		||||
    normalized_candidate = normalize_name(candidate)
 | 
			
		||||
    if normalized_candidate == "game":
 | 
			
		||||
        return False
 | 
			
		||||
    normalized_no_space = normalized_candidate.replace(" ", "")
 | 
			
		||||
    forbidden = ["win32", "win64", "gamelauncher"]
 | 
			
		||||
    for token in forbidden:
 | 
			
		||||
@@ -397,6 +412,39 @@ def save_app_details(app_id, data):
 | 
			
		||||
    with open(cache_file, "wb") as f:
 | 
			
		||||
        f.write(orjson.dumps(data))
 | 
			
		||||
 | 
			
		||||
def fetch_sgdb_cover(game_name: str) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    Fetch a cover image URL from steamgrid.usebottles.com for the given game.
 | 
			
		||||
    The API returns a single string (quoted URL).
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        encoded = urllib.parse.quote(game_name)
 | 
			
		||||
        url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
 | 
			
		||||
        resp = requests.get(url, timeout=5)
 | 
			
		||||
        if resp.status_code != 200:
 | 
			
		||||
            logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
 | 
			
		||||
            return ""
 | 
			
		||||
        text = resp.text.strip()
 | 
			
		||||
        # Убираем возможные кавычки вокруг строки
 | 
			
		||||
        if text.startswith('"') and text.endswith('"'):
 | 
			
		||||
            text = text[1:-1]
 | 
			
		||||
        if text:
 | 
			
		||||
            logger.info("Fetched SGDB cover for %s: %s", game_name, text)
 | 
			
		||||
        return text
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def check_url_exists(url: str) -> bool:
 | 
			
		||||
    """Check whether a URL returns HTTP 200."""
 | 
			
		||||
    try:
 | 
			
		||||
        r = requests.head(url, timeout=3)
 | 
			
		||||
        return r.status_code == 200
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously fetches detailed app info from Steam API.
 | 
			
		||||
@@ -615,6 +663,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None])
 | 
			
		||||
        title = decode_text(app_info.get("name", ""))
 | 
			
		||||
        description = decode_text(app_info.get("short_description", ""))
 | 
			
		||||
        cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
			
		||||
        if not check_url_exists(cover):
 | 
			
		||||
            logger.info("Steam cover not found for %s, trying SGDB", title)
 | 
			
		||||
            alt_cover = fetch_sgdb_cover(title)
 | 
			
		||||
            if alt_cover:
 | 
			
		||||
                cover = alt_cover
 | 
			
		||||
 | 
			
		||||
        def on_protondb_tier(tier: str):
 | 
			
		||||
            def on_anticheat_status(anticheat_status: str):
 | 
			
		||||
@@ -708,12 +761,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
			
		||||
        game_name = desktop_name or exe_name.capitalize()
 | 
			
		||||
 | 
			
		||||
        if not matching_app:
 | 
			
		||||
            cover = fetch_sgdb_cover(game_name) or ""
 | 
			
		||||
            logger.info("Using SGDB cover for non-Steam game '%s': %s", game_name, cover)
 | 
			
		||||
 | 
			
		||||
            def on_anticheat_status(anticheat_status: str):
 | 
			
		||||
                callback({
 | 
			
		||||
                    "appid": "",
 | 
			
		||||
                    "name": decode_text(game_name),
 | 
			
		||||
                    "description": "",
 | 
			
		||||
                    "cover": "",
 | 
			
		||||
                    "cover": cover,
 | 
			
		||||
                    "controller_support": "",
 | 
			
		||||
                    "protondb_tier": "",
 | 
			
		||||
                    "steam_game": "false",
 | 
			
		||||
@@ -744,6 +800,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
 | 
			
		||||
            title = decode_text(app_info.get("name", game_name))
 | 
			
		||||
            description = decode_text(app_info.get("short_description", ""))
 | 
			
		||||
            cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
 | 
			
		||||
            if not check_url_exists(cover):
 | 
			
		||||
                logger.info("Steam cover not found for %s, trying SGDB", title)
 | 
			
		||||
                alt_cover = fetch_sgdb_cover(title)
 | 
			
		||||
                if alt_cover:
 | 
			
		||||
                    cover = alt_cover
 | 
			
		||||
            controller_support = app_info.get("controller_support", "")
 | 
			
		||||
 | 
			
		||||
            def on_protondb_tier(tier: str):
 | 
			
		||||
@@ -943,7 +1004,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
 | 
			
		||||
        return (False, f"Executable file not found: {exe_path}")
 | 
			
		||||
 | 
			
		||||
    portproton_dir = get_portproton_location()
 | 
			
		||||
    if not portproton_dir:
 | 
			
		||||
    start_sh = get_portproton_start_command()
 | 
			
		||||
    if not portproton_dir or not start_sh:
 | 
			
		||||
        logger.error("PortProton directory not found")
 | 
			
		||||
        return (False, "PortProton directory not found")
 | 
			
		||||
 | 
			
		||||
@@ -952,17 +1014,12 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
 | 
			
		||||
 | 
			
		||||
    safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
 | 
			
		||||
    script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
 | 
			
		||||
    start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
 | 
			
		||||
 | 
			
		||||
    if not os.path.exists(start_sh_path):
 | 
			
		||||
        logger.error(f"start.sh not found at {start_sh_path}")
 | 
			
		||||
        return (False, f"start.sh not found at {start_sh_path}")
 | 
			
		||||
 | 
			
		||||
    if not os.path.exists(script_path):
 | 
			
		||||
        script_content = f"""#!/usr/bin/env bash
 | 
			
		||||
export LD_PRELOAD=
 | 
			
		||||
export START_FROM_STEAM=1
 | 
			
		||||
"{start_sh_path}" "{exe_path}" "$@"
 | 
			
		||||
"{start_sh}" "{exe_path}" "$@"
 | 
			
		||||
"""
 | 
			
		||||
        try:
 | 
			
		||||
            with open(script_path, "w", encoding="utf-8") as f:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/icons/settings.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0005 1c-0.38761 0-0.77522 0.0327-1.1588 0.0979-0.16351 0.0281-0.30273 0.13627-0.37209 0.28935l-0.39088 0.86264c-0.49378 0.16682-0.96454 0.39759-1.4007 0.68616 2.5e-4 0-0.90672-0.2272-0.90672-0.2272-0.161-0.0403-0.33098 3e-3 -0.45442 0.11569-0.57867 0.5285-1.0672 1.1514-1.4451 1.8432-0.0804 0.14721-0.0841 0.32549-0.01 0.47628l0.41938 0.84865c-0.17954 0.49666-0.29567 1.0147-0.346 1.5417l-0.73995 0.57946c-0.13121 0.10289-0.20407 0.26514-0.19431 0.4335 0.0453 0.78981 0.21961 1.5666 0.51558 2.2983 0.0631 0.15587 0.1978 0.27003 0.36005 0.30467l0.91397 0.19559c0.26993 0.45234 0.59572 0.86802 0.96931 1.2363l-0.0161 0.94973c-3e-3 0.16861 0.0766 0.32755 0.21183 0.42484 0.63551 0.45642 1.3414 0.80207 2.0884 1.0229 0.15926 0.0471 0.33077 0.0109 0.45872-0.0963l0.72016-0.60485c0.51582 0.0674 1.0384 0.0674 1.5544 0l0.72016 0.60485c0.12796 0.10722 0.29946 0.14343 0.45872 0.0963 0.74693-0.22083 1.4528-0.56648 2.0883-1.0229 0.13521-0.0973 0.21465-0.25623 0.21189-0.42484l-0.0161-0.94973c0.37359-0.36829 0.69939-0.78372 0.96932-1.2363l0.91396-0.19559c0.16226-0.0347 0.29695-0.1488 0.36005-0.30467 0.29597-0.73174 0.47026-1.5085 0.51558-2.2983 0.01-0.16836-0.0631-0.33061-0.1943-0.4335l-0.73996-0.57946c-0.0501-0.52671-0.16652-1.045-0.34606-1.5417l0.41944-0.84865c0.0746-0.15079 0.0709-0.32907-0.01-0.47628-0.37785-0.69176-0.86638-1.3147-1.445-1.8432-0.12345-0.11258-0.29343-0.15594-0.45443-0.11569l-0.90697 0.2272c-0.43594-0.28857-0.9067-0.51908-1.4005-0.68616l-0.39088-0.86264c-0.0694-0.15308-0.20858-0.26132-0.37209-0.28935-0.38361-0.0653-0.77121-0.0979-1.1588-0.0979zm0 4.1365a2.8152 2.8635 0 0 1 2.8152 2.8636 2.8152 2.8635 0 0 1-2.8152 2.8635 2.8152 2.8635 0 0 1-2.8152-2.8635 2.8152 2.8635 0 0 1 2.8152-2.8636z" fill="#fff" stroke-width=".25254"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 1.8 KiB  | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_square.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 682 B  | 
| 
		 After Width: | Height: | Size: 232 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Библиотека.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 225 KiB  | 
| 
		 Before Width: | Height: | Size: 1.3 MiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Карточка.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 70 KiB  | 
| 
		 Before Width: | Height: | Size: 364 KiB  | 
| 
		 Before Width: | Height: | Size: 430 KiB  | 
| 
		 After Width: | Height: | Size: 238 KiB  | 
| 
		 Before Width: | Height: | Size: 1.3 MiB  | 
| 
		 After Width: | Height: | Size: 61 KiB  | 
| 
		 After Width: | Height: | Size: 38 KiB  | 
| 
		 Before Width: | Height: | Size: 104 KiB  | 
| 
		 Before Width: | Height: | Size: 1.0 MiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Темы.jpg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 93 KiB  | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_y.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.438 26.092-7.6218-12.616h5.7406l4.4433 8.238 4.4109-8.238h5.7731l-7.6866 12.552v8.4974h-5.0595z" fill="#3f424d" stroke-width="1.0811" aria-label="Y"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 559 B  | 
@@ -217,6 +217,56 @@ CONTEXT_MENU_STYLE = f"""
 | 
			
		||||
    }}
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
VIRTUAL_KEYBOARD_STYLE = """
 | 
			
		||||
VirtualKeyboard {
 | 
			
		||||
    background-color: rgba(30, 30, 30, 200);
 | 
			
		||||
    border-radius: 0px;
 | 
			
		||||
    border: none;
 | 
			
		||||
}
 | 
			
		||||
QPushButton {
 | 
			
		||||
    font-size: 14px;
 | 
			
		||||
    border: 1px solid #555;
 | 
			
		||||
    border-top-color: #666;
 | 
			
		||||
    border-left-color: #666;
 | 
			
		||||
    border-radius: 3px;
 | 
			
		||||
    min-width: 30px;
 | 
			
		||||
    min-height: 30px;
 | 
			
		||||
    padding: 4px;
 | 
			
		||||
    background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #505050, stop:1 #404040);
 | 
			
		||||
    color: #e0e0e0;
 | 
			
		||||
}
 | 
			
		||||
QPushButton:hover {
 | 
			
		||||
    background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #606060, stop:1 #505050);
 | 
			
		||||
    border: 1px solid #666;
 | 
			
		||||
    border-top-color: #777;
 | 
			
		||||
    border-left-color: #777;
 | 
			
		||||
}
 | 
			
		||||
QPushButton:focus {
 | 
			
		||||
    border: 2px solid #4a90e2;
 | 
			
		||||
    background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5a5a5a, stop:1 #454545);
 | 
			
		||||
}
 | 
			
		||||
QPushButton:pressed {
 | 
			
		||||
    background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a3a3a, stop:1 #303030);
 | 
			
		||||
    border: 1px solid #444;
 | 
			
		||||
    border-bottom-color: #555;
 | 
			
		||||
    border-right-color: #555;
 | 
			
		||||
    padding-top: 5px;
 | 
			
		||||
    padding-bottom: 3px;
 | 
			
		||||
    padding-left: 5px;
 | 
			
		||||
    padding-right: 3px;
 | 
			
		||||
}
 | 
			
		||||
QPushButton[checked="true"] {
 | 
			
		||||
    background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a90e2, stop:1 #3a7ad2);
 | 
			
		||||
    color: white;
 | 
			
		||||
    border: 1px solid #2a6ac2;
 | 
			
		||||
    border-top-color: #5aa0f2;
 | 
			
		||||
    border-left-color: #5aa0f2;
 | 
			
		||||
}
 | 
			
		||||
QPushButton[checked="true"]:focus {
 | 
			
		||||
    border: 2px solid #6aa3f5;
 | 
			
		||||
}
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
 | 
			
		||||
MAIN_WINDOW_STYLE = f"""
 | 
			
		||||
    QWidget {{
 | 
			
		||||
@@ -916,6 +966,96 @@ SETTINGS_CHECKBOX_STYLE = f"""
 | 
			
		||||
    }}
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
WINETRICKS_TAB_STYLE = f"""
 | 
			
		||||
QTabWidget::pane {{
 | 
			
		||||
    border: 1px solid {color_d};
 | 
			
		||||
    background: {color_b};
 | 
			
		||||
    border-radius: {border_radius_a};
 | 
			
		||||
}}
 | 
			
		||||
QTabBar::tab {{
 | 
			
		||||
    background: {color_c};
 | 
			
		||||
    color: {color_f};
 | 
			
		||||
    padding: 8px 16px;
 | 
			
		||||
    border-top-left-radius: {border_radius_a};
 | 
			
		||||
    border-top-right-radius: {border_radius_a};
 | 
			
		||||
    margin-right: 2px;
 | 
			
		||||
}}
 | 
			
		||||
QTabBar::tab:selected {{
 | 
			
		||||
    background: {color_a};
 | 
			
		||||
    color: {color_f};
 | 
			
		||||
}}
 | 
			
		||||
QTabBar::tab:hover {{
 | 
			
		||||
    background: {color_e};
 | 
			
		||||
}}
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
WINETRICKS_TABBLE_STYLE = f"""
 | 
			
		||||
QTableWidget {{
 | 
			
		||||
    background: {color_c};
 | 
			
		||||
    color: {color_f};
 | 
			
		||||
    gridline-color: {color_d};
 | 
			
		||||
    alternate-background-color: {color_d};
 | 
			
		||||
    border: {border_a};
 | 
			
		||||
    border-radius: {border_radius_a};
 | 
			
		||||
    font-family: '{font_family}';
 | 
			
		||||
    font-size: {font_size_a};
 | 
			
		||||
}}
 | 
			
		||||
QHeaderView::section {{
 | 
			
		||||
    background: {color_d};
 | 
			
		||||
    color: {color_f};
 | 
			
		||||
    padding: 5px;
 | 
			
		||||
    border: {border_a};
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
}}
 | 
			
		||||
QTableWidget::item {{
 | 
			
		||||
    padding: 8px;
 | 
			
		||||
    border-bottom: 1px solid {color_d};
 | 
			
		||||
}}
 | 
			
		||||
QTableWidget::item:selected {{
 | 
			
		||||
    background: {color_a};
 | 
			
		||||
    color: {color_f};
 | 
			
		||||
}}
 | 
			
		||||
QTableWidget::item:hover {{
 | 
			
		||||
    background: {color_e};
 | 
			
		||||
}}
 | 
			
		||||
QTableWidget::indicator {{
 | 
			
		||||
    width: 24px;
 | 
			
		||||
    height: 24px;
 | 
			
		||||
    border: {border_b} {color_a};
 | 
			
		||||
    border-radius: {border_radius_a};
 | 
			
		||||
    background: rgba(255, 255, 255, 0.1);
 | 
			
		||||
}}
 | 
			
		||||
QTableWidget::indicator:unchecked {{
 | 
			
		||||
    background: rgba(255, 255, 255, 0.1);
 | 
			
		||||
    image: none;
 | 
			
		||||
}}
 | 
			
		||||
QTableWidget::indicator:checked {{
 | 
			
		||||
    background: {color_a};
 | 
			
		||||
    image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
 | 
			
		||||
    border: {border_b} {color_f};
 | 
			
		||||
}}
 | 
			
		||||
QTableWidget::indicator:hover {{
 | 
			
		||||
    background: rgba(255, 255, 255, 0.2);
 | 
			
		||||
    border: {border_b} {color_a};
 | 
			
		||||
}}
 | 
			
		||||
QTableWidget::indicator:focus {{
 | 
			
		||||
    border: {border_c} {color_a};
 | 
			
		||||
}}
 | 
			
		||||
{SCROLL_AREA_STYLE}
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
WINETRICKS_LOG_STYLE = f"""
 | 
			
		||||
QTextEdit {{
 | 
			
		||||
    background: {color_c};
 | 
			
		||||
    border: {border_a};
 | 
			
		||||
    border-radius: {border_radius_a};
 | 
			
		||||
    color: {color_f};
 | 
			
		||||
    font-family: '{font_family}';
 | 
			
		||||
    font-size: {font_size_a};
 | 
			
		||||
    padding: 5px;
 | 
			
		||||
}}
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
FILE_EXPLORER_STYLE = f"""
 | 
			
		||||
    QListView {{
 | 
			
		||||
        font-size: {font_size_a};
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										642
									
								
								portprotonqt/virtual_keyboard.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,642 @@
 | 
			
		||||
from typing import cast, Any
 | 
			
		||||
from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
 | 
			
		||||
                               QSizePolicy, QWidget, QLineEdit)
 | 
			
		||||
from PySide6.QtCore import Qt, Signal, QProcess, QSize
 | 
			
		||||
from PySide6.QtGui import QPixmap, QIcon
 | 
			
		||||
from portprotonqt.keyboard_layouts import keyboard_layouts
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
 | 
			
		||||
theme_manager = ThemeManager()
 | 
			
		||||
 | 
			
		||||
class VirtualKeyboard(QFrame):
 | 
			
		||||
    keyPressed = Signal(str)
 | 
			
		||||
 | 
			
		||||
    def __init__(self, parent: QWidget | None = None, theme=None, button_width: int = 80):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self._parent: QWidget | None = parent
 | 
			
		||||
        self.available_layouts: list[str] = self.get_layouts_setxkbmap()
 | 
			
		||||
        self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        if not self.available_layouts:
 | 
			
		||||
            self.available_layouts.append('en')
 | 
			
		||||
        self.current_layout: str = self.available_layouts[0]
 | 
			
		||||
 | 
			
		||||
        self.focus_timer = None
 | 
			
		||||
        self.focus_delay = 150  # Задержка между перемещениями в мс
 | 
			
		||||
        self.last_focus_time = 0
 | 
			
		||||
 | 
			
		||||
        self.backspace_pressed = False
 | 
			
		||||
        self.backspace_timer = None
 | 
			
		||||
        self.backspace_initial_delay = 500
 | 
			
		||||
        self.backspace_repeat_delay = 50
 | 
			
		||||
        self.gamepad_x_pressed = False
 | 
			
		||||
        self.caps_lock = False
 | 
			
		||||
        self.shift_pressed = False
 | 
			
		||||
        self.current_input_widget = None
 | 
			
		||||
        self.cursor_visible = True
 | 
			
		||||
        self.last_focused_button = None
 | 
			
		||||
 | 
			
		||||
        self.base_button_width = 40
 | 
			
		||||
        self.base_min_width = 574
 | 
			
		||||
        self.button_width = button_width
 | 
			
		||||
        self.button_height = 40
 | 
			
		||||
        self.spacing = 4
 | 
			
		||||
        self.margins = 10
 | 
			
		||||
        self.num_cols = 14
 | 
			
		||||
 | 
			
		||||
        # Find input_manager and main_window
 | 
			
		||||
        self.input_manager: Any = None
 | 
			
		||||
        self.main_window: Any = None
 | 
			
		||||
        parent_widget: QWidget | None = self._parent
 | 
			
		||||
        while parent_widget:
 | 
			
		||||
            if hasattr(parent_widget, 'input_manager'):
 | 
			
		||||
                self.input_manager = cast(Any, parent_widget).input_manager
 | 
			
		||||
                self.main_window = cast(Any, parent_widget)
 | 
			
		||||
            parent_widget = cast(QWidget | None, parent_widget.parent())
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
        self.initUI()
 | 
			
		||||
        self.hide()
 | 
			
		||||
 | 
			
		||||
        self.setStyleSheet(self.theme.VIRTUAL_KEYBOARD_STYLE)
 | 
			
		||||
 | 
			
		||||
    def highlight_cursor_position(self):
 | 
			
		||||
        """Подсвечиваем текущую позицию курсора"""
 | 
			
		||||
        if not self.current_input_widget or not isinstance(self.current_input_widget, QLineEdit):
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Просто устанавливаем курсор на нужную позицию без выделения
 | 
			
		||||
        self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition())
 | 
			
		||||
 | 
			
		||||
    def initUI(self):
 | 
			
		||||
        layout = QVBoxLayout()
 | 
			
		||||
        layout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        layout.setSpacing(0)
 | 
			
		||||
 | 
			
		||||
        self.keyboard_layout = QGridLayout()
 | 
			
		||||
        self.keyboard_layout.setSpacing(self.spacing)
 | 
			
		||||
        self.keyboard_layout.setContentsMargins(self.margins // 2, self.margins // 2, self.margins // 2, self.margins // 2)
 | 
			
		||||
        self.create_keyboard()
 | 
			
		||||
 | 
			
		||||
        self.keyboard_container = QWidget()
 | 
			
		||||
        self.keyboard_container.setLayout(self.keyboard_layout)
 | 
			
		||||
        ratio = self.button_width / self.base_button_width
 | 
			
		||||
        self.keyboard_container.setMinimumWidth(int(self.base_min_width * ratio))
 | 
			
		||||
        self.keyboard_container.setMinimumHeight(220)
 | 
			
		||||
        self.keyboard_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
 | 
			
		||||
 | 
			
		||||
        layout.addWidget(self.keyboard_container, 0, Qt.AlignmentFlag.AlignHCenter)
 | 
			
		||||
        self.setLayout(layout)
 | 
			
		||||
        self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
 | 
			
		||||
 | 
			
		||||
    def run_shell_command(self, cmd: str) -> str | None:
 | 
			
		||||
        process = QProcess(self)
 | 
			
		||||
        process.start("sh", ["-c", cmd])
 | 
			
		||||
        process.waitForFinished(-1)
 | 
			
		||||
        if process.exitCode() == 0:
 | 
			
		||||
            output_bytes = process.readAllStandardOutput().data()
 | 
			
		||||
            if isinstance(output_bytes, memoryview):
 | 
			
		||||
                output_str = output_bytes.tobytes().decode('utf-8').strip()
 | 
			
		||||
            else:
 | 
			
		||||
                output_str = output_bytes.decode('utf-8').strip()
 | 
			
		||||
            return output_str
 | 
			
		||||
        else:
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def get_layouts_setxkbmap(self) -> list[str]:
 | 
			
		||||
        """Получаем раскладки, которые используются в системе, возвращаем список вида ['us', 'ru'] и т.п."""
 | 
			
		||||
        cmd = r'''localectl status | awk -F: '/X11 Layout/ {gsub(/^[ \t]+/, "", $2); print $2}' '''
 | 
			
		||||
        output = self.run_shell_command(cmd)
 | 
			
		||||
        if output:
 | 
			
		||||
            layouts = [lang.strip() for lang in output.split(',') if lang.strip()]
 | 
			
		||||
            return layouts if layouts else ['en']
 | 
			
		||||
        else:
 | 
			
		||||
            return ['en']
 | 
			
		||||
 | 
			
		||||
    def create_keyboard(self):
 | 
			
		||||
        # TODO: сделать нормальное описание (сейчас лень)
 | 
			
		||||
        # Основные раскладки с учетом Shift
 | 
			
		||||
        # Фильтруем доступные раскладки
 | 
			
		||||
 | 
			
		||||
        LAYOUT_MAP = {'us': 'en'}
 | 
			
		||||
 | 
			
		||||
        # Assume keyboard_layouts is dict[str, dict[str, list[list[str]]]]
 | 
			
		||||
        self.layouts: dict[str, dict[str, list[list[str]]]] = {
 | 
			
		||||
            lang: keyboard_layouts.get(LAYOUT_MAP.get(lang, lang), keyboard_layouts['en'])
 | 
			
		||||
            for lang in self.available_layouts
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.current_layout = (self.current_layout if self.current_layout in self.layouts else next(iter(self.layouts.keys()), None) or 'en')
 | 
			
		||||
 | 
			
		||||
        self.buttons: dict[str, QPushButton] = {}
 | 
			
		||||
        self.update_keyboard()
 | 
			
		||||
 | 
			
		||||
    def set_gamepad_icon(self, button, icon_type, gtype=''):
 | 
			
		||||
        """Set gamepad icon on button based on type"""
 | 
			
		||||
        if icon_type in ['back', 'add_game']:
 | 
			
		||||
            icon_name = self.main_window.get_button_icon(icon_type, gtype)
 | 
			
		||||
        else:  # nav left/right
 | 
			
		||||
            if icon_type in ['left', 'right']:
 | 
			
		||||
                direction = icon_type
 | 
			
		||||
                icon_name = self.main_window.get_nav_icon(direction, gtype)
 | 
			
		||||
            else:
 | 
			
		||||
                direction = 'left' if icon_type == 'left' else 'right'
 | 
			
		||||
                icon_name = self.main_window.get_nav_icon(direction, gtype)
 | 
			
		||||
 | 
			
		||||
        icon_path = theme_manager.get_theme_image(icon_name, self.current_theme_name)
 | 
			
		||||
        pixmap = QPixmap()
 | 
			
		||||
        if icon_path:
 | 
			
		||||
            pixmap.load(str(icon_path))
 | 
			
		||||
        if not pixmap.isNull():
 | 
			
		||||
            button.setIcon(QIcon(pixmap))
 | 
			
		||||
            button.setIconSize(QSize(20, 20))
 | 
			
		||||
            return
 | 
			
		||||
        else:
 | 
			
		||||
            # Fallback to placeholder
 | 
			
		||||
            placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name)
 | 
			
		||||
            if placeholder:
 | 
			
		||||
                button.setIcon(QIcon(placeholder))
 | 
			
		||||
                button.setIconSize(QSize(20, 20))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
    def update_keyboard(self):
 | 
			
		||||
        coords = self._save_focused_coords()
 | 
			
		||||
 | 
			
		||||
        # Очищаем предыдущие кнопки
 | 
			
		||||
        while self.keyboard_layout.count():
 | 
			
		||||
            item = self.keyboard_layout.takeAt(0)
 | 
			
		||||
            if item.widget():
 | 
			
		||||
                item.widget().deleteLater()
 | 
			
		||||
 | 
			
		||||
        fixed_w = self.button_width
 | 
			
		||||
        fixed_h = self.button_height
 | 
			
		||||
 | 
			
		||||
        # Выбираем текущую раскладку (обычная или с shift)
 | 
			
		||||
        layout_mode = 'shift' if self.shift_pressed else 'normal'
 | 
			
		||||
        layout_data = self.layouts.get(self.current_layout, {})
 | 
			
		||||
        buttons: list[list[str]] = layout_data.get(layout_mode, [])
 | 
			
		||||
 | 
			
		||||
        # Добавляем основные кнопки
 | 
			
		||||
        for row_idx, row in enumerate(buttons):
 | 
			
		||||
            for col_idx, key in enumerate(row):
 | 
			
		||||
                button = QPushButton(key)
 | 
			
		||||
                button.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
 | 
			
		||||
                # Обработчики для CAPS и левого Shift
 | 
			
		||||
                if key == 'CAPS':
 | 
			
		||||
                    button.setCheckable(True)
 | 
			
		||||
                    button.setChecked(self.caps_lock)
 | 
			
		||||
                    button.clicked.connect(self.on_caps_click)
 | 
			
		||||
                elif key == '⬆':  # Левый Shift
 | 
			
		||||
                    button.setCheckable(True)
 | 
			
		||||
                    button.setChecked(self.shift_pressed)
 | 
			
		||||
                    button.clicked.connect(lambda checked: self.on_shift_click(checked))
 | 
			
		||||
                    # Add gamepad icon for Shift (RB/R)
 | 
			
		||||
                    gtype = self.input_manager.gamepad_type
 | 
			
		||||
                    self.set_gamepad_icon(button, 'right', gtype)
 | 
			
		||||
                else:
 | 
			
		||||
                    button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
 | 
			
		||||
 | 
			
		||||
                self.keyboard_layout.addWidget(button, row_idx, col_idx)
 | 
			
		||||
                self.buttons[key] = button
 | 
			
		||||
 | 
			
		||||
        # Нижний ряд (специальные кнопки)
 | 
			
		||||
        shift = QPushButton('⬆')
 | 
			
		||||
        shift.setFixedSize(fixed_w * 3 + 2 * self.spacing, fixed_h)
 | 
			
		||||
        shift.setCheckable(True)
 | 
			
		||||
        shift.setChecked(self.shift_pressed)
 | 
			
		||||
        shift.clicked.connect(lambda checked: self.on_shift_click(checked))
 | 
			
		||||
        # Add gamepad icon for Shift (RB/R)
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        self.set_gamepad_icon(shift, 'right', gtype)
 | 
			
		||||
        self.keyboard_layout.addWidget(shift, 3, 11, 1, 3)
 | 
			
		||||
 | 
			
		||||
        button = QPushButton('CAPS')
 | 
			
		||||
        button.setCheckable(True)
 | 
			
		||||
        button.setChecked(self.caps_lock)
 | 
			
		||||
        button.clicked.connect(self.on_caps_click)
 | 
			
		||||
 | 
			
		||||
        space = QPushButton('Space')
 | 
			
		||||
        space.setFixedSize(fixed_w * 5 + 4 * self.spacing, fixed_h)
 | 
			
		||||
        space.clicked.connect(lambda: self.on_button_click(' '))
 | 
			
		||||
        self.keyboard_layout.addWidget(space, 4, 1, 1, 5)
 | 
			
		||||
 | 
			
		||||
        backspace = QPushButton('⌫')
 | 
			
		||||
        backspace.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
        backspace.pressed.connect(self.on_backspace_pressed)
 | 
			
		||||
        backspace.released.connect(self.stop_backspace_repeat)
 | 
			
		||||
        # Add gamepad icon for Backspace (X/Triangle)
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        self.set_gamepad_icon(backspace, 'add_game', gtype)
 | 
			
		||||
        self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1)
 | 
			
		||||
 | 
			
		||||
        enter = QPushButton('Enter')
 | 
			
		||||
        enter.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
 | 
			
		||||
        enter.clicked.connect(self.on_enter_click)
 | 
			
		||||
        self.keyboard_layout.addWidget(enter, 2, 12, 1, 2)
 | 
			
		||||
 | 
			
		||||
        lang = QPushButton('🌐')
 | 
			
		||||
        lang.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
        lang.clicked.connect(self.on_lang_click)
 | 
			
		||||
        # Add gamepad icon for Lang (LB/L)
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        self.set_gamepad_icon(lang, 'left', gtype)
 | 
			
		||||
        self.keyboard_layout.addWidget(lang, 4, 0, 1, 1)
 | 
			
		||||
 | 
			
		||||
        clear = QPushButton('Clear')
 | 
			
		||||
        clear.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
 | 
			
		||||
        clear.clicked.connect(self.on_clear_click)
 | 
			
		||||
        self.keyboard_layout.addWidget(clear, 4, 10, 1, 2)
 | 
			
		||||
 | 
			
		||||
        up = QPushButton('▲')
 | 
			
		||||
        up.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
        up.clicked.connect(self.up_key)  # Обработка клика мышью - управление курсором
 | 
			
		||||
        self.keyboard_layout.addWidget(up, 4, 6, 1, 1)
 | 
			
		||||
 | 
			
		||||
        down = QPushButton('▼')
 | 
			
		||||
        down.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
        down.clicked.connect(self.down_key)
 | 
			
		||||
        self.keyboard_layout.addWidget(down, 4, 7, 1, 1)
 | 
			
		||||
 | 
			
		||||
        left = QPushButton('◄')
 | 
			
		||||
        left.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
        left.clicked.connect(self.left_key)
 | 
			
		||||
        self.keyboard_layout.addWidget(left, 4, 8, 1, 1)
 | 
			
		||||
 | 
			
		||||
        right = QPushButton('►')
 | 
			
		||||
        right.setFixedSize(fixed_w, fixed_h)
 | 
			
		||||
        right.clicked.connect(self.right_key)
 | 
			
		||||
        self.keyboard_layout.addWidget(right, 4, 9, 1, 1)
 | 
			
		||||
 | 
			
		||||
        hide_button = QPushButton('Hide')
 | 
			
		||||
        hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
 | 
			
		||||
        hide_button.clicked.connect(self.hide)
 | 
			
		||||
        # Add gamepad icon for Hide (B/Circle)
 | 
			
		||||
        gtype = self.input_manager.gamepad_type
 | 
			
		||||
        self.set_gamepad_icon(hide_button, 'back', gtype)
 | 
			
		||||
        self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2)
 | 
			
		||||
 | 
			
		||||
        if coords:
 | 
			
		||||
            row, col = coords
 | 
			
		||||
            item = self.keyboard_layout.itemAtPosition(row, col)
 | 
			
		||||
            if item and item.widget():
 | 
			
		||||
                item.widget().setFocus()
 | 
			
		||||
 | 
			
		||||
    def up_key(self):
 | 
			
		||||
        """Перемещает курсор в QLineEdit вверх/в начало, если клавиатура видима"""
 | 
			
		||||
        if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
 | 
			
		||||
            self.current_input_widget.setCursorPosition(0)
 | 
			
		||||
            self.current_input_widget.setFocus()
 | 
			
		||||
 | 
			
		||||
    def down_key(self):
 | 
			
		||||
        """Перемещает курсор в QLineEdit вниз/в конец, если клавиатура видима"""
 | 
			
		||||
        if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
 | 
			
		||||
            self.current_input_widget.setCursorPosition(len(self.current_input_widget.text()))
 | 
			
		||||
            self.current_input_widget.setFocus()
 | 
			
		||||
 | 
			
		||||
    def left_key(self):
 | 
			
		||||
        """Перемещает курсор в QLineEdit влево, если клавиатура видима"""
 | 
			
		||||
        if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
 | 
			
		||||
            pos = self.current_input_widget.cursorPosition()
 | 
			
		||||
            if pos > 0:
 | 
			
		||||
                self.current_input_widget.setCursorPosition(pos - 1)
 | 
			
		||||
            self.current_input_widget.setFocus()
 | 
			
		||||
 | 
			
		||||
    def right_key(self):
 | 
			
		||||
        """Перемещает курсор в QLineEdit вправо, если клавиатура видима"""
 | 
			
		||||
        if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
 | 
			
		||||
            pos = self.current_input_widget.cursorPosition()
 | 
			
		||||
            text_len = len(self.current_input_widget.text())
 | 
			
		||||
            if pos < text_len:
 | 
			
		||||
                self.current_input_widget.setCursorPosition(pos + 1)
 | 
			
		||||
            self.current_input_widget.setFocus()
 | 
			
		||||
 | 
			
		||||
    def move_focus_up(self):
 | 
			
		||||
        """Перемещает фокус по кнопкам клавиатуры вверх с фиксированной скоростью"""
 | 
			
		||||
        current_time = self.get_current_time()
 | 
			
		||||
        if current_time - self.last_focus_time >= self.focus_delay:
 | 
			
		||||
            self.focusNextKey("up")
 | 
			
		||||
            self.last_focus_time = current_time
 | 
			
		||||
 | 
			
		||||
    def move_focus_down(self):
 | 
			
		||||
        """Перемещает фокус по кнопкам клавиатуры вниз с фиксированной скоростью"""
 | 
			
		||||
        current_time = self.get_current_time()
 | 
			
		||||
        if current_time - self.last_focus_time >= self.focus_delay:
 | 
			
		||||
            self.focusNextKey("down")
 | 
			
		||||
            self.last_focus_time = current_time
 | 
			
		||||
 | 
			
		||||
    def move_focus_left(self):
 | 
			
		||||
        """Перемещает фокус по кнопкам клавиатуры влево с фиксированной скоростью"""
 | 
			
		||||
        current_time = self.get_current_time()
 | 
			
		||||
        if current_time - self.last_focus_time >= self.focus_delay:
 | 
			
		||||
            self.focusNextKey("left")
 | 
			
		||||
            self.last_focus_time = current_time
 | 
			
		||||
 | 
			
		||||
    def move_focus_right(self):
 | 
			
		||||
        """Перемещает фокус по кнопкам клавиатуры вправо с фиксированной скоростью"""
 | 
			
		||||
        current_time = self.get_current_time()
 | 
			
		||||
        if current_time - self.last_focus_time >= self.focus_delay:
 | 
			
		||||
            self.focusNextKey("right")
 | 
			
		||||
            self.last_focus_time = current_time
 | 
			
		||||
 | 
			
		||||
    def get_current_time(self):
 | 
			
		||||
        """Возвращает текущее время в миллисекундах"""
 | 
			
		||||
        from time import time
 | 
			
		||||
        return int(time() * 1000)
 | 
			
		||||
 | 
			
		||||
    def _save_focused_coords(self) -> tuple[int, int] | None:
 | 
			
		||||
        """Возвращает (row, col) кнопки с фокусом или None"""
 | 
			
		||||
        current = self.focusWidget()
 | 
			
		||||
        if not current:
 | 
			
		||||
            return None
 | 
			
		||||
        idx = self.keyboard_layout.indexOf(current)
 | 
			
		||||
        if idx == -1:
 | 
			
		||||
            return None
 | 
			
		||||
        position = cast(tuple[int, int, int, int], self.keyboard_layout.getItemPosition(idx))
 | 
			
		||||
        return position[:2]  # row, col
 | 
			
		||||
 | 
			
		||||
    def on_button_click(self, key):
 | 
			
		||||
        if key in ['TAB', 'CAPS', '⬆']:
 | 
			
		||||
            if key == 'TAB':
 | 
			
		||||
                self.on_tab_click()
 | 
			
		||||
            elif key == 'CAPS':
 | 
			
		||||
                self.on_caps_click()
 | 
			
		||||
            elif key == '⬆':
 | 
			
		||||
                self.on_shift_click(not self.shift_pressed)
 | 
			
		||||
            self.highlight_cursor_position()
 | 
			
		||||
        elif self.current_input_widget is not None:
 | 
			
		||||
            # Сохраняем текущую кнопку с фокусом
 | 
			
		||||
            focused_button = self.focusWidget()
 | 
			
		||||
            key_to_restore = None
 | 
			
		||||
            if isinstance(focused_button, QPushButton) and focused_button in self.buttons.values():
 | 
			
		||||
                key_to_restore = next((k for k, btn in self.buttons.items() if btn == focused_button), None)
 | 
			
		||||
 | 
			
		||||
            key = "&" if key == "&&" else key
 | 
			
		||||
            cursor_pos = self.current_input_widget.cursorPosition()
 | 
			
		||||
            text = self.current_input_widget.text()
 | 
			
		||||
            new_text = text[:cursor_pos] + key + text[cursor_pos:]
 | 
			
		||||
            self.current_input_widget.setText(new_text)
 | 
			
		||||
            self.current_input_widget.setCursorPosition(cursor_pos + len(key))
 | 
			
		||||
            self.keyPressed.emit(key)
 | 
			
		||||
            self.highlight_cursor_position()
 | 
			
		||||
 | 
			
		||||
            # Если был нажат SHIFT, но не CapsLock, отключаем его после ввода символа
 | 
			
		||||
            if self.shift_pressed and not self.caps_lock:
 | 
			
		||||
                self.shift_pressed = False
 | 
			
		||||
                self.update_keyboard()
 | 
			
		||||
                if key_to_restore and key_to_restore in self.buttons:
 | 
			
		||||
                    self.buttons[key_to_restore].setFocus()
 | 
			
		||||
 | 
			
		||||
    def on_tab_click(self):
 | 
			
		||||
        if self.current_input_widget is not None:
 | 
			
		||||
            self.current_input_widget.insert('\t')
 | 
			
		||||
            self.keyPressed.emit('Tab')
 | 
			
		||||
            self.current_input_widget.setFocus()
 | 
			
		||||
            self.highlight_cursor_position()
 | 
			
		||||
 | 
			
		||||
    def on_caps_click(self):
 | 
			
		||||
        """Включаем/выключаем CapsLock"""
 | 
			
		||||
        self.caps_lock = not self.caps_lock
 | 
			
		||||
        self.shift_pressed = self.caps_lock
 | 
			
		||||
        self.update_keyboard()
 | 
			
		||||
 | 
			
		||||
    # ---------- таймерное событие ----------
 | 
			
		||||
    def timerEvent(self, event):
 | 
			
		||||
        if event.timerId() == self.backspace_timer:
 | 
			
		||||
            self.on_backspace_click()  # стираем ещё один символ
 | 
			
		||||
            # первое срабатывание прошло – ускоряем
 | 
			
		||||
            if self.backspace_timer:
 | 
			
		||||
                self.killTimer(self.backspace_timer)
 | 
			
		||||
                self.backspace_timer = self.startTimer(self.backspace_repeat_delay)
 | 
			
		||||
    def on_backspace_click(self):
 | 
			
		||||
        """Обработка одного нажатия Backspace"""
 | 
			
		||||
        if self.current_input_widget is not None:
 | 
			
		||||
            cursor_pos = self.current_input_widget.cursorPosition()
 | 
			
		||||
            text = self.current_input_widget.text()
 | 
			
		||||
 | 
			
		||||
            if cursor_pos > 0:
 | 
			
		||||
                new_text = text[:cursor_pos - 1] + text[cursor_pos:]
 | 
			
		||||
                self.current_input_widget.setText(new_text)
 | 
			
		||||
                self.current_input_widget.setCursorPosition(cursor_pos - 1)
 | 
			
		||||
                self.keyPressed.emit('Backspace')
 | 
			
		||||
                self.highlight_cursor_position()
 | 
			
		||||
 | 
			
		||||
    def on_backspace_pressed(self):
 | 
			
		||||
        """Обработка зажатого Backspace"""
 | 
			
		||||
        self.backspace_pressed = True
 | 
			
		||||
        self.start_backspace_repeat()
 | 
			
		||||
 | 
			
		||||
    def start_backspace_repeat(self):
 | 
			
		||||
        """Запуск автоповтора нажатия Backspace"""
 | 
			
		||||
        self.on_backspace_click()  # Первое нажатие
 | 
			
		||||
        self.backspace_timer = self.startTimer(self.backspace_initial_delay)
 | 
			
		||||
 | 
			
		||||
    def stop_backspace_repeat(self):
 | 
			
		||||
        """Остановка автоповтора нажатия Backspace"""
 | 
			
		||||
        if self.backspace_timer:
 | 
			
		||||
            self.killTimer(self.backspace_timer)
 | 
			
		||||
            self.backspace_timer = None
 | 
			
		||||
        self.backspace_pressed = False
 | 
			
		||||
 | 
			
		||||
    def on_enter_click(self):
 | 
			
		||||
        """Обработка действия кнопки Enter"""
 | 
			
		||||
        # TODO: тут подумать, как обрабатывать нажатие.
 | 
			
		||||
        #  Пока болванка перехода на новую строку, в QlineEdit работает как нажатие пробела
 | 
			
		||||
        if self.current_input_widget is not None:
 | 
			
		||||
            self.current_input_widget.insert('\n')
 | 
			
		||||
            self.keyPressed.emit('Enter')
 | 
			
		||||
 | 
			
		||||
    def on_clear_click(self):
 | 
			
		||||
        """Чистим строку от введённого текста"""
 | 
			
		||||
        if self.current_input_widget is not None:
 | 
			
		||||
            self.current_input_widget.clear()
 | 
			
		||||
            self.keyPressed.emit('Clear')
 | 
			
		||||
            self.highlight_cursor_position()
 | 
			
		||||
 | 
			
		||||
    def on_lang_click(self):
 | 
			
		||||
        """Переключение раскладки"""
 | 
			
		||||
        if not self.available_layouts:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            current_index = self.available_layouts.index(self.current_layout)
 | 
			
		||||
            next_index = (current_index + 1) % len(self.available_layouts)
 | 
			
		||||
            self.current_layout = self.available_layouts[next_index]
 | 
			
		||||
        except ValueError:
 | 
			
		||||
            # Если текущей раскладки нет в available_layouts
 | 
			
		||||
            self.current_layout = self.available_layouts[0] if self.available_layouts else 'en'
 | 
			
		||||
 | 
			
		||||
        self.update_keyboard()
 | 
			
		||||
 | 
			
		||||
    def on_shift_click(self, checked):
 | 
			
		||||
        self.shift_pressed = checked
 | 
			
		||||
        if not checked and self.caps_lock:
 | 
			
		||||
            self.caps_lock = False
 | 
			
		||||
        self.update_keyboard()
 | 
			
		||||
 | 
			
		||||
    def show_for_widget(self, widget):
 | 
			
		||||
        self.current_input_widget = widget
 | 
			
		||||
        if widget:
 | 
			
		||||
            widget.setFocus()
 | 
			
		||||
            self.highlight_cursor_position()
 | 
			
		||||
 | 
			
		||||
        # Позиционирование клавиатуры внизу родительского виджета
 | 
			
		||||
        if self._parent and isinstance(self._parent, QWidget):
 | 
			
		||||
            keyboard_height = 220
 | 
			
		||||
            self.setFixedWidth(self._parent.width())
 | 
			
		||||
            self.setFixedHeight(keyboard_height)
 | 
			
		||||
            self.move(0, self._parent.height() - keyboard_height)
 | 
			
		||||
 | 
			
		||||
        self.show()
 | 
			
		||||
        self.raise_()
 | 
			
		||||
 | 
			
		||||
        # Установить фокус на первую кнопку, если нет фокуса на виджете ввода
 | 
			
		||||
        if not widget:
 | 
			
		||||
            first_button: QPushButton | None = next((cast(QPushButton, btn) for btn in self.buttons.values()), None)
 | 
			
		||||
            if first_button:
 | 
			
		||||
                first_button.setFocus()
 | 
			
		||||
 | 
			
		||||
    def activateFocusedKey(self):
 | 
			
		||||
        """Активирует текущую выделенную кнопку на клавиатуре"""
 | 
			
		||||
        focused = self.focusWidget()
 | 
			
		||||
        if isinstance(focused, QPushButton):
 | 
			
		||||
            focused.animateClick()
 | 
			
		||||
 | 
			
		||||
    def focusNextKey(self, direction: str):
 | 
			
		||||
        """Перемещает фокус на следующую кнопку в указанном направлении с обертыванием"""
 | 
			
		||||
        current = self.focusWidget()
 | 
			
		||||
        if not current:
 | 
			
		||||
            first_button = self.findFirstFocusableButton()
 | 
			
		||||
            if first_button:
 | 
			
		||||
                first_button.setFocus()
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        current_idx = self.keyboard_layout.indexOf(current)
 | 
			
		||||
        if current_idx == -1:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        position = cast(tuple[int, int, int, int], self.keyboard_layout.getItemPosition(current_idx))
 | 
			
		||||
        current_row, current_col, row_span, col_span = position
 | 
			
		||||
 | 
			
		||||
        num_rows = self.keyboard_layout.rowCount()
 | 
			
		||||
        num_cols = self.keyboard_layout.columnCount()
 | 
			
		||||
 | 
			
		||||
        found = False
 | 
			
		||||
 | 
			
		||||
        if direction == "right":
 | 
			
		||||
            # Сначала ищем в той же строке вправо
 | 
			
		||||
            search_row = current_row
 | 
			
		||||
            search_col = current_col + col_span
 | 
			
		||||
            while search_col < num_cols:
 | 
			
		||||
                item = self.keyboard_layout.itemAtPosition(search_row, search_col)
 | 
			
		||||
                if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                    next_button = cast(QPushButton, item.widget())
 | 
			
		||||
                    next_button.setFocus()
 | 
			
		||||
                    found = True
 | 
			
		||||
                    break
 | 
			
		||||
                search_col += 1
 | 
			
		||||
 | 
			
		||||
            if not found:
 | 
			
		||||
                # Переходим к следующей строке, начиная с col 0
 | 
			
		||||
                search_row = (current_row + 1) % num_rows
 | 
			
		||||
                search_col = 0
 | 
			
		||||
                # Ищем первую кнопку в этой строке
 | 
			
		||||
                while search_col < num_cols:
 | 
			
		||||
                    item = self.keyboard_layout.itemAtPosition(search_row, search_col)
 | 
			
		||||
                    if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                        next_button = cast(QPushButton, item.widget())
 | 
			
		||||
                        next_button.setFocus()
 | 
			
		||||
                        found = True
 | 
			
		||||
                        break
 | 
			
		||||
                    search_col += 1
 | 
			
		||||
 | 
			
		||||
        elif direction == "left":
 | 
			
		||||
            # Сначала ищем в той же строке влево
 | 
			
		||||
            search_row = current_row
 | 
			
		||||
            search_col = current_col - 1
 | 
			
		||||
            while search_col >= 0:
 | 
			
		||||
                item = self.keyboard_layout.itemAtPosition(search_row, search_col)
 | 
			
		||||
                if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                    next_button = cast(QPushButton, item.widget())
 | 
			
		||||
                    next_button.setFocus()
 | 
			
		||||
                    found = True
 | 
			
		||||
                    break
 | 
			
		||||
                search_col -= 1
 | 
			
		||||
 | 
			
		||||
            if not found:
 | 
			
		||||
                # Переходим к предыдущей строке, начиная с последнего столбца
 | 
			
		||||
                search_row = (current_row - 1) % num_rows
 | 
			
		||||
                search_col = num_cols - 1
 | 
			
		||||
                # Ищем последнюю кнопку в этой строке
 | 
			
		||||
                while search_col >= 0:
 | 
			
		||||
                    item = self.keyboard_layout.itemAtPosition(search_row, search_col)
 | 
			
		||||
                    if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                        next_button = cast(QPushButton, item.widget())
 | 
			
		||||
                        next_button.setFocus()
 | 
			
		||||
                        found = True
 | 
			
		||||
                        break
 | 
			
		||||
                    search_col -= 1
 | 
			
		||||
 | 
			
		||||
        elif direction == "down":
 | 
			
		||||
            # Сначала ищем в том же столбце вниз
 | 
			
		||||
            search_col = current_col
 | 
			
		||||
            search_row = current_row + row_span
 | 
			
		||||
            while search_row < num_rows:
 | 
			
		||||
                item = self.keyboard_layout.itemAtPosition(search_row, search_col)
 | 
			
		||||
                if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                    next_button = cast(QPushButton, item.widget())
 | 
			
		||||
                    next_button.setFocus()
 | 
			
		||||
                    found = True
 | 
			
		||||
                    break
 | 
			
		||||
                search_row += 1
 | 
			
		||||
 | 
			
		||||
            if not found:
 | 
			
		||||
                # Переходим к следующему столбцу, начиная с row 0
 | 
			
		||||
                search_col = (current_col + col_span) % num_cols
 | 
			
		||||
                search_row = 0
 | 
			
		||||
                # Ищем первую кнопку в этом столбце
 | 
			
		||||
                while search_row < num_rows:
 | 
			
		||||
                    item = self.keyboard_layout.itemAtPosition(search_row, search_col)
 | 
			
		||||
                    if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                        next_button = cast(QPushButton, item.widget())
 | 
			
		||||
                        next_button.setFocus()
 | 
			
		||||
                        found = True
 | 
			
		||||
                        break
 | 
			
		||||
                    search_row += 1
 | 
			
		||||
 | 
			
		||||
        elif direction == "up":
 | 
			
		||||
            # Сначала ищем в том же столбце вверх
 | 
			
		||||
            search_col = current_col
 | 
			
		||||
            search_row = current_row - 1
 | 
			
		||||
            while search_row >= 0:
 | 
			
		||||
                item = self.keyboard_layout.itemAtPosition(search_row, search_col)
 | 
			
		||||
                if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                    next_button = cast(QPushButton, item.widget())
 | 
			
		||||
                    next_button.setFocus()
 | 
			
		||||
                    found = True
 | 
			
		||||
                    break
 | 
			
		||||
                search_row -= 1
 | 
			
		||||
 | 
			
		||||
            if not found:
 | 
			
		||||
                # Переходим к предыдущему столбцу, начиная с последней строки
 | 
			
		||||
                search_col = (current_col - 1) % num_cols
 | 
			
		||||
                search_row = num_rows - 1
 | 
			
		||||
                # Ищем последнюю кнопку в этом столбце
 | 
			
		||||
                while search_row >= 0:
 | 
			
		||||
                    item = self.keyboard_layout.itemAtPosition(search_row, search_col)
 | 
			
		||||
                    if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                        next_button = cast(QPushButton, item.widget())
 | 
			
		||||
                        next_button.setFocus()
 | 
			
		||||
                        found = True
 | 
			
		||||
                        break
 | 
			
		||||
                    search_row -= 1
 | 
			
		||||
 | 
			
		||||
    def findFirstFocusableButton(self) -> QPushButton | None:
 | 
			
		||||
        """Находит первую фокусируемую кнопку на клавиатуре"""
 | 
			
		||||
        for row in range(self.keyboard_layout.rowCount()):
 | 
			
		||||
            for col in range(self.keyboard_layout.columnCount()):
 | 
			
		||||
                item = self.keyboard_layout.itemAtPosition(row, col)
 | 
			
		||||
                if item and item.widget() and item.widget().isEnabled():
 | 
			
		||||
                    return cast(QPushButton, item.widget())
 | 
			
		||||
        return None
 | 
			
		||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
 | 
			
		||||
 | 
			
		||||
[project]
 | 
			
		||||
name = "portprotonqt"
 | 
			
		||||
version = "0.1.6"
 | 
			
		||||
version = "0.1.8"
 | 
			
		||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
 | 
			
		||||
readme = "README.md"
 | 
			
		||||
license = { text = "GPL-3.0" }
 | 
			
		||||
@@ -27,19 +27,19 @@ classifiers = [
 | 
			
		||||
requires-python = ">=3.10"
 | 
			
		||||
dependencies = [
 | 
			
		||||
    "babel>=2.17.0",
 | 
			
		||||
    "beautifulsoup4>=4.13.5",
 | 
			
		||||
    "beautifulsoup4>=4.14.2",
 | 
			
		||||
    "evdev>=1.9.2",
 | 
			
		||||
    "icoextract>=0.2.0",
 | 
			
		||||
    "numpy>=2.2.4",
 | 
			
		||||
    "orjson>=3.11.2",
 | 
			
		||||
    "orjson>=3.11.3",
 | 
			
		||||
    "pillow>=11.3.0",
 | 
			
		||||
    "psutil>=7.0.0",
 | 
			
		||||
    "pyside6>=6.9.1",
 | 
			
		||||
    "psutil>=7.1.0",
 | 
			
		||||
    "pyside6==6.9.1",
 | 
			
		||||
    "pyudev>=0.24.3",
 | 
			
		||||
    "requests>=2.32.5",
 | 
			
		||||
    "tqdm>=4.67.1",
 | 
			
		||||
    "vdf>=3.4",
 | 
			
		||||
    "websocket-client>=1.8.0",
 | 
			
		||||
    "websocket-client>=1.9.0",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[project.scripts]
 | 
			
		||||
@@ -105,5 +105,5 @@ ignore = [
 | 
			
		||||
dev = [
 | 
			
		||||
    "pre-commit>=4.3.0",
 | 
			
		||||
    "pyaspeller>=2.0.2",
 | 
			
		||||
    "pyright>=1.1.404",
 | 
			
		||||
    "pyright>=1.1.406",
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -1,6 +1,8 @@
 | 
			
		||||
{
 | 
			
		||||
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
 | 
			
		||||
  "extends": ["config:best-practices"],
 | 
			
		||||
  "extends": [
 | 
			
		||||
    "config:best-practices"
 | 
			
		||||
  ],
 | 
			
		||||
  "rebaseWhen": "never",
 | 
			
		||||
  "lockFileMaintenance": {
 | 
			
		||||
    "enabled": true
 | 
			
		||||
@@ -9,6 +11,23 @@
 | 
			
		||||
    "enabled": true
 | 
			
		||||
  },
 | 
			
		||||
  "packageRules": [
 | 
			
		||||
    {
 | 
			
		||||
      "description": "Update renovate only weekly",
 | 
			
		||||
      "matchDepNames": ["ghcr.io/renovatebot/renovate"],
 | 
			
		||||
      "extends": ["schedule:weekly"]
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "description": "Automerge renovate updates",
 | 
			
		||||
      "matchPackageNames": [
 | 
			
		||||
        "ghcr.io/renovatebot/renovate"
 | 
			
		||||
      ],
 | 
			
		||||
      "matchUpdateTypes": [
 | 
			
		||||
        "minor",
 | 
			
		||||
        "patch",
 | 
			
		||||
        "digest"
 | 
			
		||||
      ],
 | 
			
		||||
      "automerge": true
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchUpdateTypes": ["minor", "patch"],
 | 
			
		||||
      "automerge": true
 | 
			
		||||
@@ -33,7 +52,7 @@
 | 
			
		||||
      "groupName": "Python dependencies"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchPackageNames": ["numpy", "setuptools", "python"],
 | 
			
		||||
      "matchPackageNames": ["numpy", "setuptools", "python", "pyside6"],
 | 
			
		||||
      "enabled": false,
 | 
			
		||||
      "description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
 | 
			
		||||
    },
 | 
			
		||||
 
 | 
			
		||||