Compare commits
108 Commits
Author | SHA1 | Date | |
---|---|---|---|
7185019a3f
|
|||
1f4f4093bd
|
|||
2d72fdb4c7
|
|||
d9729ebbea
|
|||
43e7d5b65b
|
|||
70dca2b704
|
|||
2875efb050
|
|||
ce097e489b
|
|||
f8de5ec589
|
|||
fea07e19fe
|
|||
37b108f689
|
|||
78f5118709 | |||
1f14dd7fdf | |||
3d3bdd8f98 | |||
9d7c674544 | |||
e6c90508ab | |||
d0eea92139
|
|||
04726491c0
|
|||
bd1b7c07ae
|
|||
e6161d2e3f
|
|||
b82080600f
|
|||
05693514aa
|
|||
1c2835a933
|
|||
d229914fb6
|
|||
ce69a18249
|
|||
4d58830910 | |||
016ba537be
|
|||
6eeb93f6ba
|
|||
3f5d058740
|
|||
1a9228b76d
|
|||
e9e0bea854
|
|||
f7d9f5c150
|
|||
bcb5987d31
|
|||
b1aa987e4e
|
|||
f4c8b70bd0
|
|||
ff960df77c
|
|||
a57f509295
|
|||
32bbe89911
|
|||
593db00166
|
|||
79a78c785b
|
|||
0b92d058a9
|
|||
9df22edfc9
|
|||
4559231712
|
|||
18dbd42369
|
|||
76c0e607c5
|
|||
a91c9dacd8
|
|||
62b8da2dc4
|
|||
b77609cb5f
|
|||
56b105d7b4
|
|||
14687d12ca
|
|||
6a648a2a8d
|
|||
c0b2006338
|
|||
2c2fc082a7
|
|||
66e1871304
|
|||
6daa28b393
|
|||
a3445898e5
|
|||
076d06a9c0
|
|||
d85e7f058f
|
|||
dd05ef8a1f
|
|||
326b2d7411 | |||
d280cf2531
|
|||
3cc40154b0
|
|||
f765b5e840
|
|||
c54c3273a0
|
|||
502b5b5256
|
|||
0b45ba963a
|
|||
7becbf5de2
|
|||
66b4b82d49
|
|||
dbf3a30119
|
|||
4c2e2a9c8d
|
|||
802d5a2ba1
|
|||
1d47caf4aa
|
|||
502664438c
|
|||
f4e155dade
|
|||
74400d1389
|
|||
2a46cf7a2f
|
|||
f105af01ef
|
|||
e9ecb466b2 | |||
2ce41697ef | |||
997e66afa6 | |||
bad91fed4e | |||
a1bdff73fe | |||
0c7cb0092b | |||
120f2a5590 | |||
fbe8d87b3d | |||
568120fb0e | |||
bff5e456cf | |||
de3b95d06c | |||
db95120b87 | |||
337db17467 | |||
dbf1340f88
|
|||
09066521e8 | |||
186ee048f7 | |||
79e2ad1997 | |||
a4a3271df9 | |||
213709e88b | |||
9f86eae5ef | |||
748f9c886b | |||
84708ed260
|
|||
9fe5a8315a
|
|||
c1b8eac127
|
|||
953e4fa715
|
|||
24ca66a1af
|
|||
30a4fc6ed7
|
|||
2d7369d46c
|
|||
0587cf58ed
|
|||
58c7541fa3
|
|||
b9d7fc2326
|
@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
fedora_version: [40, 41, 42, rawhide]
|
fedora_version: [41, 42, rawhide]
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: fedora:${{ matrix.fedora_version }}
|
image: fedora:${{ matrix.fedora_version }}
|
||||||
|
@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Common version, will be used for tagging the release
|
# Common version, will be used for tagging the release
|
||||||
VERSION: v0.1.1
|
VERSION: 0.1.2
|
||||||
PKGDEST: "/tmp/portprotonqt"
|
PKGDEST: "/tmp/portprotonqt"
|
||||||
PACKAGE: "portprotonqt"
|
PACKAGE: "portprotonqt"
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
@ -97,7 +97,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
fedora_version: [40, 41, 42, rawhide]
|
fedora_version: [41, 42, rawhide]
|
||||||
|
|
||||||
container:
|
container:
|
||||||
image: fedora:${{ matrix.fedora_version }}
|
image: fedora:${{ matrix.fedora_version }}
|
||||||
@ -140,16 +140,29 @@ jobs:
|
|||||||
needs: [build-appimage, build-arch, build-fedora]
|
needs: [build-appimage, build-arch, build-fedora]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install required dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y original-awk unzip
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: https://gitea.com/actions/download-artifact@v4
|
uses: https://gitea.com/actions/download-artifact@v3
|
||||||
with:
|
with:
|
||||||
path: release/
|
path: release/
|
||||||
|
|
||||||
|
- name: Extract downloaded artifacts
|
||||||
|
run: |
|
||||||
|
mkdir -p extracted
|
||||||
|
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
|
||||||
|
find extracted/ -type f -exec mv {} release/ \;
|
||||||
|
rm -rf extracted/
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- name: Extract changelog for version
|
||||||
id: changelog
|
id: changelog
|
||||||
run: |
|
run: |
|
||||||
VERSION="${{ env.VERSION }}"
|
VERSION="${{ env.VERSION }}"
|
||||||
VERSION=${VERSION#v} # Remove 'v' prefix if present
|
|
||||||
awk "/^## \\[$VERSION\\]/ {flag=1; next} /^## \\[/ || /^---/ {flag=0} flag" CHANGELOG.md > changelog.txt
|
awk "/^## \\[$VERSION\\]/ {flag=1; next} /^## \\[/ || /^---/ {flag=0} flag" CHANGELOG.md > changelog.txt
|
||||||
|
|
||||||
- name: Release
|
- name: Release
|
||||||
@ -157,7 +170,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
body_path: changelog.txt
|
body_path: changelog.txt
|
||||||
token: ${{ env.GITEA_TOKEN }}
|
token: ${{ env.GITEA_TOKEN }}
|
||||||
tag_name: ${{ env.VERSION }}
|
tag_name: v${{ env.VERSION }}
|
||||||
prerelease: true
|
prerelease: true
|
||||||
files: release/**/*
|
files: release/**/*
|
||||||
sha256sum: true
|
sha256sum: true
|
||||||
|
@ -30,6 +30,8 @@ jobs:
|
|||||||
run: python dev-scripts/get_id.py
|
run: python dev-scripts/get_id.py
|
||||||
env:
|
env:
|
||||||
STEAM_KEY: ${{ secrets.STEAM_KEY }}
|
STEAM_KEY: ${{ secrets.STEAM_KEY }}
|
||||||
|
LINUX_GAMING_API_KEY: ${{ secrets.LINUX_GAMING_API_KEY }}
|
||||||
|
LINUX_GAMING_API_USERNAME: ${{ secrets.LINUX_GAMING_API_USERNAME }}
|
||||||
|
|
||||||
- name: Commit and push changes
|
- name: Commit and push changes
|
||||||
env:
|
env:
|
||||||
|
76
CHANGELOG.md
@ -5,27 +5,50 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Аргумент `--session` для запуска приложения в gamescope с GAMESCOPE_CMD
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Удалены сборки для Fedora 40
|
||||||
|
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем.
|
||||||
|
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Дублирование обводки выделения карточек при быстром перемешении мыши
|
||||||
|
- Завершение приложения при закритие окна
|
||||||
|
- Использование системной палитры в темах
|
||||||
|
- Ошибки темы в нативном пакете
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
- @Dervart
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.2] - 2025-06-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Кнопки сброса настроек и очистки кэша
|
- Кнопки сброса настроек и очистки кэша
|
||||||
- Бейдж PortProton
|
- Бейдж PortProton
|
||||||
- Зависимость от `xdg-utils`
|
- Зависимость от `xdg-utils`
|
||||||
- Интеграция статуса WeAntiCheatYet в карточку
|
- Интеграция статуса WeAntiCheatYet в карточку
|
||||||
- Стили в AddGameDialog
|
|
||||||
- Переключение полноэкранного режима через F11 или кнопку Select на геймпаде
|
- Переключение полноэкранного режима через F11 или кнопку Select на геймпаде
|
||||||
- Выбор QCheckBox через Enter или кнопку A на геймпаде
|
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде
|
||||||
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде
|
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде
|
||||||
- Закрытие окна приложения по комбинации клавиш Ctrl+Q
|
- Закрытие окна приложения по комбинации клавиш Ctrl+Q
|
||||||
- Сохранение и восстановление размера окна при перезапуске
|
- Сохранение и восстановление размера окна при перезапуске
|
||||||
- Переключатель полноэкранного режима приложения
|
- Переключатель полноэкранного режима приложения
|
||||||
- Пункт в контекстном меню «Открыть папку игры»
|
- Пункт в контекстном меню «Открыть папку игры»
|
||||||
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam»
|
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam»
|
||||||
- Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного» для переключения статуса избранного через геймпад
|
- Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного»
|
||||||
- Метод сортировки «Сначала избранное»
|
- Метод сортировки «Сначала избранное»
|
||||||
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена)
|
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена)
|
||||||
- Обработчики для QMenu и QComboBox при управлении геймпадом
|
- Поддержка управления геймпадом в `QMenu` и `QComboBox`
|
||||||
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме
|
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме
|
||||||
- Оверлей на кнопку Xbox/PS для закрытия приложения, выключения, перезагрузки и перехода в спящий режим
|
- Оверлей на кнопку Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или переключения между сессиями
|
||||||
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
||||||
|
- Пресеты управления для DualShock 4 и DualSense
|
||||||
|
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию выключена)
|
||||||
|
- Переводы пунктов настроек
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Обновлены все иконки
|
- Обновлены все иконки
|
||||||
@ -34,29 +57,37 @@
|
|||||||
- Логика контекстного меню вынесена в `ContextMenuManager`
|
- Логика контекстного меню вынесена в `ContextMenuManager`
|
||||||
- Бейдж Steam теперь открывает Steam Community
|
- Бейдж Steam теперь открывает Steam Community
|
||||||
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
|
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
|
||||||
- Оптимизирована генерация карточек для предотвращения задержек при поиске и изменении размера окна
|
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна
|
||||||
- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке
|
- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке
|
||||||
- Установлена ширина бейджа в две трети ширины карточки
|
- Установлена ширина бейджа в две трети ширины карточки
|
||||||
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites`
|
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites`
|
||||||
- Карточки теперь фокусируются в направлении движения стрелок или D-pad: например, при нажатии D-pad вниз фокус переходит на карточку в следующей колонке, а не по порядку
|
- Карточки теперь фокусируются в направлении движения стрелок или D-pad:
|
||||||
- Теперь D-pad можно зажимать для переключения карточек
|
- Поддерживается удержание D-pad для непрерывного переключения карточек
|
||||||
- D-pad больше не переключает вкладки, только RB и LB
|
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности
|
||||||
|
- D-pad больше не переключает вкладки (только кнопки RB/LB)
|
||||||
- Кнопка добавления игры больше не фокусируется
|
- Кнопка добавления игры больше не фокусируется
|
||||||
- Диалог добавления игры теперь открывается только в библиотеке
|
- Диалог добавления игры теперь открывается только в библиотеке
|
||||||
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt
|
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt
|
||||||
|
- Размер карточек теперь меняется только при отпускании слайдера
|
||||||
|
- Слайдер теперь управляется через тригеры на геймпаде
|
||||||
|
- Диалог добавления игры теперь открывается на X, а не на Y
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Обработка несуществующей темы с возвратом к «standard»
|
- Возврат к теме «standard» при выборе несуществующей темы
|
||||||
- Открытие контекстного меню
|
- Корректное открытие контекстного меню
|
||||||
- Запуск при отсутствии exiftool
|
- Запуск приложения при отсутствии `exiftool`
|
||||||
- Переводы пунктов настроек
|
- Предотвращено бесконечное обращение к `get_portproton_location`
|
||||||
- Бесконечное обращение к `get_portproton_location`
|
- Обновлены ссылки на документацию в README
|
||||||
- Ссылки на документацию в README
|
- Устранён traceback при отсутствии обложек (placeholder)
|
||||||
- Traceback при загрузке placeholder при отсутствии обложек
|
- Устранены утечки памяти при загрузке обложек
|
||||||
- Утечки памяти при загрузке обложек
|
- Исправлены ошибки при подключении геймпада
|
||||||
- Ошибки при подключении геймпада из-за работы в разных потоках
|
- Предотвращено многократное открытие диалога добавления игры через геймпад
|
||||||
- Многократное открытие диалога добавления игры при использовании геймпада
|
- Корректная обработка событий геймпада во время игры
|
||||||
- Перехват событий геймпада во время работы игры
|
- Убийсво всех процессов "зомби" при закрытии программы
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
- @Vector_null
|
||||||
|
- @Dervart
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -78,6 +109,11 @@
|
|||||||
- Зависание GUI
|
- Зависание GUI
|
||||||
- Сбой при повреждённом Steam
|
- Сбой при повреждённом Steam
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
- @Vector_null
|
||||||
|
- @Dervart
|
||||||
|
- @alex2844
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
> См. подробности по каждому коммиту в истории репозитория.
|
> См. подробности по каждому коммиту в истории репозитория.
|
||||||
|
19
README.md
@ -4,7 +4,6 @@
|
|||||||
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
|
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
## В планах
|
## В планах
|
||||||
|
|
||||||
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
|
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
|
||||||
@ -15,7 +14,8 @@
|
|||||||
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
|
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
|
||||||
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
|
- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
|
||||||
- [ ] Продумать систему вкладок вместо текущей
|
- [ ] Продумать систему вкладок вместо текущей
|
||||||
- [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
- [ ] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
||||||
|
- [ ] Разобраться почему теряется часть стилей в Gamescope
|
||||||
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
|
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
|
||||||
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
|
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
|
||||||
- [X] Получать описания и названия игр из базы данных Steam
|
- [X] Получать описания и названия игр из базы данных Steam
|
||||||
@ -41,7 +41,10 @@
|
|||||||
- [X] Добавить парсинг ярлыков из Steam
|
- [X] Добавить парсинг ярлыков из Steam
|
||||||
- [X] Добавить парсинг ярлыков из EGS (скрыто для переработки)
|
- [X] Добавить парсинг ярлыков из EGS (скрыто для переработки)
|
||||||
- [ ] Избавиться от бинарника legendary
|
- [ ] Избавиться от бинарника legendary
|
||||||
- [ ] Добавить запуск и скачивание игр из EGS
|
- [X] Добавить запуск игр из EGS
|
||||||
|
- [ ] Добавить скачивание игр из EGS
|
||||||
|
- [ ] Добавить поддержку запуска сторонних игр из EGS
|
||||||
|
- [ ] Добавить поддержку запуска игр с EOS
|
||||||
- [ ] Добавить авторизацию в EGS через WebView вместо ручного ввода
|
- [ ] Добавить авторизацию в EGS через WebView вместо ручного ввода
|
||||||
- [X] Получать описания для игр из EGS через их [API](https://store-content.ak.epicgames.com/api)
|
- [X] Получать описания для игр из EGS через их [API](https://store-content.ak.epicgames.com/api)
|
||||||
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
||||||
@ -62,10 +65,13 @@
|
|||||||
- [ ] Добавить поддержку GOG (?)
|
- [ ] Добавить поддержку GOG (?)
|
||||||
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
||||||
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?)
|
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?)
|
||||||
- [ ] Добавить виброотдачу на геймпаде при запуске игры (?)
|
- [X] Добавить виброотдачу на геймпаде при запуске игры
|
||||||
- [ ] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
||||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
||||||
- [ ] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||||
|
- [ ] Доделать светлую тему
|
||||||
|
- [ ] Добавить подсказки к управлению с геймпада
|
||||||
|
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
|
||||||
|
|
||||||
### Установка (devel)
|
### Установка (devel)
|
||||||
|
|
||||||
@ -109,6 +115,5 @@ pre-commit run --all-files
|
|||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
|
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
|
||||||
|
|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> **Будьте осторожны!** Если вы берёте тему не из официального репозитория или надёжного источника, убедитесь, что в её файле `styles.py` нет вредоносного или нежелательного кода. Поскольку `styles.py` — это обычный Python-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.
|
> **Будьте осторожны!** Если вы берёте тему не из официального репозитория или надёжного источника, убедитесь, что в её файле `styles.py` нет вредоносного или нежелательного кода. Поскольку `styles.py` — это обычный Python-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.
|
||||||
|
@ -25,7 +25,7 @@ AppDir:
|
|||||||
id: ru.linux_gaming.PortProtonQt
|
id: ru.linux_gaming.PortProtonQt
|
||||||
name: PortProtonQt
|
name: PortProtonQt
|
||||||
icon: ru.linux_gaming.PortProtonQt
|
icon: ru.linux_gaming.PortProtonQt
|
||||||
version: 0.1.1
|
version: 0.1.2
|
||||||
exec: usr/bin/python3
|
exec: usr/bin/python3
|
||||||
exec_args: "-m portprotonqt.app $@"
|
exec_args: "-m portprotonqt.app $@"
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
pkgname=portprotonqt
|
pkgname=portprotonqt
|
||||||
pkgver=0.1.1
|
pkgver=0.1.2
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
|
@ -28,19 +28,19 @@ BuildRequires: git
|
|||||||
%package -n python3-%{pypi_name}-git
|
%package -n python3-%{pypi_name}-git
|
||||||
Summary: %{summary}
|
Summary: %{summary}
|
||||||
%{?python_provide:%python_provide python3-%{pypi_name}}
|
%{?python_provide:%python_provide python3-%{pypi_name}}
|
||||||
Requires: python3dist(babel)
|
Requires: python3-babel
|
||||||
Requires: python3dist(evdev)
|
Requires: python3-evdev
|
||||||
Requires: python3dist(icoextract)
|
Requires: python3-icoextract
|
||||||
Requires: python3dist(numpy)
|
Requires: python3-numpy
|
||||||
Requires: python3dist(orjson)
|
Requires: python3-orjson
|
||||||
Requires: python3dist(psutil)
|
Requires: python3-psutil
|
||||||
Requires: python3dist(pyside6)
|
Requires: python3-pyside6
|
||||||
Requires: python3dist(pyudev)
|
Requires: python3-pyudev
|
||||||
Requires: python3dist(requests)
|
Requires: python3-requests
|
||||||
Requires: python3dist(tqdm)
|
Requires: python3-tqdm
|
||||||
Requires: python3dist(vdf)
|
Requires: python3-vdf
|
||||||
Requires: python3dist(pefile)
|
Requires: python3-pefile
|
||||||
Requires: python3dist(pillow)
|
Requires: python3-pillow
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
%global pypi_name portprotonqt
|
%global pypi_name portprotonqt
|
||||||
%global pypi_version 0.1.1
|
%global pypi_version 0.1.2
|
||||||
%global oname PortProtonQt
|
%global oname PortProtonQt
|
||||||
|
|
||||||
Name: python-%{pypi_name}
|
Name: python-%{pypi_name}
|
||||||
@ -25,19 +25,19 @@ BuildRequires: git
|
|||||||
%package -n python3-%{pypi_name}
|
%package -n python3-%{pypi_name}
|
||||||
Summary: %{summary}
|
Summary: %{summary}
|
||||||
%{?python_provide:%python_provide python3-%{pypi_name}}
|
%{?python_provide:%python_provide python3-%{pypi_name}}
|
||||||
Requires: python3dist(babel)
|
Requires: python3-babel
|
||||||
Requires: python3dist(evdev)
|
Requires: python3-evdev
|
||||||
Requires: python3dist(icoextract)
|
Requires: python3-icoextract
|
||||||
Requires: python3dist(numpy)
|
Requires: python3-numpy
|
||||||
Requires: python3dist(orjson)
|
Requires: python3-orjson
|
||||||
Requires: python3dist(psutil)
|
Requires: python3-psutil
|
||||||
Requires: python3dist(pyside6)
|
Requires: python3-pyside6
|
||||||
Requires: python3dist(pyudev)
|
Requires: python3-pyudev
|
||||||
Requires: python3dist(requests)
|
Requires: python3-requests
|
||||||
Requires: python3dist(tqdm)
|
Requires: python3-tqdm
|
||||||
Requires: python3dist(vdf)
|
Requires: python3-vdf
|
||||||
Requires: python3dist(pefile)
|
Requires: python3-pefile
|
||||||
Requires: python3dist(pillow)
|
Requires: python3-pillow
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
|
||||||
|
@ -49,6 +49,16 @@
|
|||||||
<caption>Settings</caption>
|
<caption>Settings</caption>
|
||||||
<caption xml:lang="ru">Настройки</caption>
|
<caption xml:lang="ru">Настройки</caption>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/screenshots/%D0%9A%D0%BE%D0%BD%D1%82%D0%B5%D0%BA%D1%81%D1%82%D0%BD%D0%BE%D0%B5%20%D0%BC%D0%B5%D0%BD%D1%8E.png</image>
|
||||||
|
<caption>Context Menu</caption>
|
||||||
|
<caption xml:lang="ru">Контекстное меню</caption>
|
||||||
|
</screenshot>
|
||||||
|
<screenshot>
|
||||||
|
<image>https://git.linux-gaming.ru/Boria138/PortProtonQt/src/branch/main/portprotonqt/themes/standart/images/screenshots/%D0%9E%D0%B2%D0%B5%D1%80%D0%BB%D0%B5%D0%B9.png</image>
|
||||||
|
<caption>Overlay</caption>
|
||||||
|
<caption xml:lang="ru">Оверлей</caption>
|
||||||
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
<keywords>
|
<keywords>
|
||||||
<keyword translate="no">wine</keyword>
|
<keyword translate="no">wine</keyword>
|
||||||
|
8
config.js
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
module.exports = {
|
||||||
|
"endpoint": "https://git.linux-gaming.ru/api/v1",
|
||||||
|
"gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
|
||||||
|
"platform": "gitea",
|
||||||
|
"onboardingConfigFileName": "renovate.json",
|
||||||
|
"autodiscover": true,
|
||||||
|
"optimizeForDisabled": true,
|
||||||
|
};
|
@ -1573,7 +1573,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "dune awakening",
|
"normalized_name": "dune awakening",
|
||||||
"status": "Broken"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "warcraft iii reforged",
|
"normalized_name": "warcraft iii reforged",
|
||||||
@ -2337,7 +2337,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "punishing gray raven",
|
"normalized_name": "punishing gray raven",
|
||||||
"status": "Broken"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "brainbread 2",
|
"normalized_name": "brainbread 2",
|
||||||
@ -3951,10 +3951,6 @@
|
|||||||
"normalized_name": "outpost infinity siege",
|
"normalized_name": "outpost infinity siege",
|
||||||
"status": "Running"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_name": "avatar frontiers of pandora",
|
|
||||||
"status": "Broken"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_name": "v rising",
|
"normalized_name": "v rising",
|
||||||
"status": "Running"
|
"status": "Running"
|
||||||
@ -4406,5 +4402,17 @@
|
|||||||
{
|
{
|
||||||
"normalized_name": "elden ring nightreign",
|
"normalized_name": "elden ring nightreign",
|
||||||
"status": "Running"
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "steel hunters",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "reverse 1999",
|
||||||
|
"status": "Running"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_name": "ragnarok origin roo",
|
||||||
|
"status": "Running"
|
||||||
}
|
}
|
||||||
]
|
]
|
1802
data/linux_gaming_topics.json
Normal file
BIN
data/linux_gaming_topics.tar.xz
Normal file
@ -6,11 +6,20 @@ import asyncio
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import tarfile
|
import tarfile
|
||||||
|
|
||||||
|
# Получаем ключи и данные из переменных окружения
|
||||||
|
STEAM_KEY = os.environ.get('STEAM_KEY')
|
||||||
|
LINUX_GAMING_API_KEY = os.environ.get('LINUX_GAMING_API_KEY')
|
||||||
|
LINUX_GAMING_API_USERNAME = os.environ.get('LINUX_GAMING_API_USERNAME')
|
||||||
|
|
||||||
# Получаем ключ Steam из переменной окружения.
|
# Конфигурация API
|
||||||
key = os.environ.get('STEAM_KEY')
|
STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
|
||||||
base_url = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
|
LINUX_GAMING_BASE_URL = "https://linux-gaming.ru"
|
||||||
category = "games"
|
CATEGORY_STEAM = "games"
|
||||||
|
CATEGORY_LINUX_GAMING = "ppdb"
|
||||||
|
LINUX_GAMING_HEADERS = {
|
||||||
|
"Api-Key": LINUX_GAMING_API_KEY,
|
||||||
|
"Api-Username": LINUX_GAMING_API_USERNAME
|
||||||
|
}
|
||||||
|
|
||||||
def normalize_name(s):
|
def normalize_name(s):
|
||||||
"""
|
"""
|
||||||
@ -32,13 +41,11 @@ def normalize_name(s):
|
|||||||
if s.endswith(suffix):
|
if s.endswith(suffix):
|
||||||
s = s[:-len(suffix)].strip()
|
s = s[:-len(suffix)].strip()
|
||||||
|
|
||||||
# Удаляем служебные слова, которые не должны влиять на сопоставление
|
|
||||||
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
|
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
|
||||||
words = s.split()
|
words = s.split()
|
||||||
filtered_words = [word for word in words if word not in keywords_to_remove]
|
filtered_words = [word for word in words if word not in keywords_to_remove]
|
||||||
return " ".join(filtered_words)
|
return " ".join(filtered_words)
|
||||||
|
|
||||||
|
|
||||||
def process_steam_apps(steam_apps):
|
def process_steam_apps(steam_apps):
|
||||||
"""
|
"""
|
||||||
Для каждого приложения из Steam добавляет ключ "normalized_name",
|
Для каждого приложения из Steam добавляет ключ "normalized_name",
|
||||||
@ -49,16 +56,14 @@ def process_steam_apps(steam_apps):
|
|||||||
original = app.get("name", "")
|
original = app.get("name", "")
|
||||||
if not app.get("normalized_name"):
|
if not app.get("normalized_name"):
|
||||||
app["normalized_name"] = normalize_name(original)
|
app["normalized_name"] = normalize_name(original)
|
||||||
# Удаляем ненужные поля
|
|
||||||
app.pop("name", None)
|
app.pop("name", None)
|
||||||
app.pop("last_modified", None)
|
app.pop("last_modified", None)
|
||||||
app.pop("price_change_number", None)
|
app.pop("price_change_number", None)
|
||||||
return steam_apps
|
return steam_apps
|
||||||
|
|
||||||
|
|
||||||
async def get_app_list(session, last_appid, endpoint):
|
async def get_app_list(session, last_appid, endpoint):
|
||||||
"""
|
"""
|
||||||
Получает часть списка приложений из API.
|
Получает часть списка приложений из API Steam.
|
||||||
Если last_appid передан, добавляет его к URL для постраничной загрузки.
|
Если last_appid передан, добавляет его к URL для постраничной загрузки.
|
||||||
"""
|
"""
|
||||||
url = endpoint
|
url = endpoint
|
||||||
@ -68,7 +73,6 @@ async def get_app_list(session, last_appid, endpoint):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return await response.json()
|
return await response.json()
|
||||||
|
|
||||||
|
|
||||||
async def fetch_games_json(session):
|
async def fetch_games_json(session):
|
||||||
"""
|
"""
|
||||||
Загружает JSON с данными из AreWeAntiCheatYet и извлекает поля normalized_name и status.
|
Загружает JSON с данными из AreWeAntiCheatYet и извлекает поля normalized_name и status.
|
||||||
@ -79,21 +83,46 @@ async def fetch_games_json(session):
|
|||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
data = json.loads(text)
|
data = json.loads(text)
|
||||||
# Извлекаем только поля normalized_name и status
|
|
||||||
return [{"normalized_name": normalize_name(game["name"]), "status": game["status"]} for game in data]
|
return [{"normalized_name": normalize_name(game["name"]), "status": game["status"]} for game in data]
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(f"Ошибка загрузки games.json: {error}")
|
print(f"Ошибка загрузки games.json: {error}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
async def get_linux_gaming_topics(session, category_slug):
|
||||||
|
"""
|
||||||
|
Получает все темы из указанной категории linux-gaming.ru.
|
||||||
|
Сохраняет только нормализованное название (normalized_title) и slug.
|
||||||
|
"""
|
||||||
|
page = 0
|
||||||
|
all_topics = []
|
||||||
|
|
||||||
|
while True:
|
||||||
|
page += 1
|
||||||
|
url = f"{LINUX_GAMING_BASE_URL}/c/{category_slug}/l/latest.json?page={page}"
|
||||||
|
try:
|
||||||
|
async with session.get(url, headers=LINUX_GAMING_HEADERS) as response:
|
||||||
|
response.raise_for_status()
|
||||||
|
data = await response.json()
|
||||||
|
topics = data.get("topic_list", {}).get("topics", [])
|
||||||
|
if not topics:
|
||||||
|
break
|
||||||
|
for topic in topics:
|
||||||
|
all_topics.append({
|
||||||
|
"normalized_title": normalize_name(topic["title"]),
|
||||||
|
"slug": topic["slug"]
|
||||||
|
})
|
||||||
|
print(f"Обработано {len(topics)} тем на странице {page}, всего: {len(all_topics)}.")
|
||||||
|
except Exception as error:
|
||||||
|
print(f"Ошибка получения тем для страницы {page}: {error}")
|
||||||
|
break
|
||||||
|
return all_topics
|
||||||
|
|
||||||
async def request_data():
|
async def request_data():
|
||||||
"""
|
"""
|
||||||
Получает данные списка приложений для категории "games" до тех пор,
|
Получает данные из Steam, AreWeAntiCheatYet и linux-gaming.ru,
|
||||||
пока не закончатся результаты, обрабатывает данные для добавления
|
обрабатывает их и сохраняет в JSON-файлы и tar.xz архивы.
|
||||||
нормализованных имён и записывает итоговый результат в JSON-файл.
|
|
||||||
Отдельно загружает games.json и сохраняет его в отдельный JSON-файл.
|
|
||||||
"""
|
"""
|
||||||
# Параметры запроса для игр.
|
# Параметры запроса для Steam
|
||||||
game_param = "&include_games=true"
|
game_param = "&include_games=true"
|
||||||
dlc_param = "&include_dlc=false"
|
dlc_param = "&include_dlc=false"
|
||||||
software_param = "&include_software=false"
|
software_param = "&include_software=false"
|
||||||
@ -101,13 +130,15 @@ async def request_data():
|
|||||||
hardware_param = "&include_hardware=false"
|
hardware_param = "&include_hardware=false"
|
||||||
|
|
||||||
endpoint = (
|
endpoint = (
|
||||||
f"{base_url}key={key}"
|
f"{STEAM_BASE_URL}key={STEAM_KEY}"
|
||||||
f"{game_param}{dlc_param}{software_param}{videos_param}{hardware_param}"
|
f"{game_param}{dlc_param}{software_param}{videos_param}{hardware_param}"
|
||||||
f"&max_results=50000"
|
f"&max_results=50000"
|
||||||
)
|
)
|
||||||
|
|
||||||
output_json = []
|
output_json = []
|
||||||
total_parsed = 0
|
total_parsed = 0
|
||||||
|
linux_gaming_topics = []
|
||||||
|
anticheat_games = []
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
@ -117,58 +148,62 @@ async def request_data():
|
|||||||
while have_more_results:
|
while have_more_results:
|
||||||
app_list = await get_app_list(session, last_appid_val, endpoint)
|
app_list = await get_app_list(session, last_appid_val, endpoint)
|
||||||
apps = app_list['response']['apps']
|
apps = app_list['response']['apps']
|
||||||
# Обрабатываем приложения для добавления нормализованных имён
|
|
||||||
apps = process_steam_apps(apps)
|
apps = process_steam_apps(apps)
|
||||||
output_json.extend(apps)
|
output_json.extend(apps)
|
||||||
total_parsed += len(apps)
|
total_parsed += len(apps)
|
||||||
have_more_results = app_list['response'].get('have_more_results', False)
|
have_more_results = app_list['response'].get('have_more_results', False)
|
||||||
last_appid_val = app_list['response'].get('last_appid')
|
last_appid_val = app_list['response'].get('last_appid')
|
||||||
|
print(f"Обработано {len(apps)} игр Steam, всего: {total_parsed}.")
|
||||||
|
|
||||||
print(f"Обработано {len(apps)} игр, всего: {total_parsed}.")
|
# Загружаем данные AreWeAntiCheatYet
|
||||||
|
|
||||||
# Загружаем и сохраняем games.json отдельно
|
|
||||||
anticheat_games = await fetch_games_json(session)
|
anticheat_games = await fetch_games_json(session)
|
||||||
|
|
||||||
|
# Загружаем данные linux-gaming.ru
|
||||||
|
if LINUX_GAMING_API_KEY and LINUX_GAMING_API_USERNAME:
|
||||||
|
linux_gaming_topics = await get_linux_gaming_topics(session, CATEGORY_LINUX_GAMING)
|
||||||
|
else:
|
||||||
|
print("Предупреждение: LINUX_GAMING_API_KEY или LINUX_GAMING_API_USERNAME не установлены.")
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(f"Ошибка получения данных для {category}: {error}")
|
print(f"Ошибка получения данных: {error}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
data_dir = os.path.join(repo_root, "data")
|
data_dir = os.path.join(repo_root, "data")
|
||||||
os.makedirs(data_dir, exist_ok=True)
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
|
||||||
# Путь к JSON-файлам для Steam
|
# Сохранение данных Steam
|
||||||
output_json_full = os.path.join(data_dir, f"{category}_appid.json")
|
output_json_full = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.json")
|
||||||
output_json_min = os.path.join(data_dir, f"{category}_appid_min.json")
|
output_json_min = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid_min.json")
|
||||||
|
|
||||||
# Записываем полные данные Steam с отступами
|
|
||||||
with open(output_json_full, "w", encoding="utf-8") as f:
|
with open(output_json_full, "w", encoding="utf-8") as f:
|
||||||
json.dump(output_json, f, ensure_ascii=False, indent=2)
|
json.dump(output_json, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
# Записываем минимизированные данные Steam
|
|
||||||
with open(output_json_min, "w", encoding="utf-8") as f:
|
with open(output_json_min, "w", encoding="utf-8") as f:
|
||||||
json.dump(output_json, f, ensure_ascii=False, separators=(',',':'))
|
json.dump(output_json, f, ensure_ascii=False, separators=(',',':'))
|
||||||
|
|
||||||
# Путь к JSON-файлам для AreWeAntiCheatYet
|
# Сохранение данных AreWeAntiCheatYet
|
||||||
anticheat_json_full = os.path.join(data_dir, "anticheat_games.json")
|
anticheat_json_full = os.path.join(data_dir, "anticheat_games.json")
|
||||||
anticheat_json_min = os.path.join(data_dir, "anticheat_games_min.json")
|
anticheat_json_min = os.path.join(data_dir, "anticheat_games_min.json")
|
||||||
|
|
||||||
# Записываем полные данные AreWeAntiCheatYet с отступами
|
|
||||||
with open(anticheat_json_full, "w", encoding="utf-8") as f:
|
with open(anticheat_json_full, "w", encoding="utf-8") as f:
|
||||||
json.dump(anticheat_games, f, ensure_ascii=False, indent=2)
|
json.dump(anticheat_games, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
# Записываем минимизированные данные AreWeAntiCheatYet
|
|
||||||
with open(anticheat_json_min, "w", encoding="utf-8") as f:
|
with open(anticheat_json_min, "w", encoding="utf-8") as f:
|
||||||
json.dump(anticheat_games, f, ensure_ascii=False, separators=(',',':'))
|
json.dump(anticheat_games, f, ensure_ascii=False, separators=(',',':'))
|
||||||
|
|
||||||
# Упаковка только минифицированных JSON в tar.xz архивы с максимальным сжатием
|
# Сохранение данных linux-gaming.ru
|
||||||
|
linux_gaming_json_full = os.path.join(data_dir, "linux_gaming_topics.json")
|
||||||
|
linux_gaming_json_min = os.path.join(data_dir, "linux_gaming_topics_min.json")
|
||||||
|
if linux_gaming_topics:
|
||||||
|
with open(linux_gaming_json_full, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(linux_gaming_topics, f, ensure_ascii=False, indent=2)
|
||||||
|
with open(linux_gaming_json_min, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(linux_gaming_topics, f, ensure_ascii=False, separators=(',',':'))
|
||||||
|
|
||||||
|
# Упаковка минифицированных JSON в tar.xz архивы
|
||||||
# Архив для Steam
|
# Архив для Steam
|
||||||
steam_archive_path = os.path.join(data_dir, f"{category}_appid.tar.xz")
|
steam_archive_path = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.tar.xz")
|
||||||
try:
|
try:
|
||||||
with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar:
|
with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar:
|
||||||
tar.add(output_json_min, arcname=os.path.basename(output_json_min))
|
tar.add(output_json_min, arcname=os.path.basename(output_json_min))
|
||||||
print(f"Упаковано минифицированное JSON Steam в архив: {steam_archive_path}")
|
print(f"Упаковано минифицированное JSON Steam в архив: {steam_archive_path}")
|
||||||
# Удаляем исходный минифицированный файл после упаковки
|
|
||||||
os.remove(output_json_min)
|
os.remove(output_json_min)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при упаковке архива Steam: {e}")
|
print(f"Ошибка при упаковке архива Steam: {e}")
|
||||||
@ -180,20 +215,29 @@ async def request_data():
|
|||||||
with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar:
|
with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar:
|
||||||
tar.add(anticheat_json_min, arcname=os.path.basename(anticheat_json_min))
|
tar.add(anticheat_json_min, arcname=os.path.basename(anticheat_json_min))
|
||||||
print(f"Упаковано минифицированное JSON AreWeAntiCheatYet в архив: {anticheat_archive_path}")
|
print(f"Упаковано минифицированное JSON AreWeAntiCheatYet в архив: {anticheat_archive_path}")
|
||||||
# Удаляем исходный минифицированный файл после упаковки
|
|
||||||
os.remove(anticheat_json_min)
|
os.remove(anticheat_json_min)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}")
|
print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
return True
|
# Архив для linux-gaming.ru
|
||||||
|
if linux_gaming_topics:
|
||||||
|
linux_gaming_archive_path = os.path.join(data_dir, "linux_gaming_topics.tar.xz")
|
||||||
|
try:
|
||||||
|
with tarfile.open(linux_gaming_archive_path, "w:xz", preset=9) as tar:
|
||||||
|
tar.add(linux_gaming_json_min, arcname=os.path.basename(linux_gaming_json_min))
|
||||||
|
print(f"Упаковано минифицированное JSON linux-gaming.ru в архив: {linux_gaming_archive_path}")
|
||||||
|
os.remove(linux_gaming_json_min)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при упаковке архива linux-gaming.ru: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
async def run():
|
async def run():
|
||||||
success = await request_data()
|
success = await request_data()
|
||||||
if not success:
|
if not success:
|
||||||
exit(1)
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
asyncio.run(run())
|
asyncio.run(run())
|
||||||
|
@ -20,9 +20,9 @@ Current translation status:
|
|||||||
|
|
||||||
| Locale | Progress | Translated |
|
| Locale | Progress | Translated |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 160 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 161 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 160 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 161 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 160 of 160 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 161 of 161 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -20,9 +20,9 @@
|
|||||||
|
|
||||||
| Локаль | Прогресс | Переведено |
|
| Локаль | Прогресс | Переведено |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 160 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 161 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 160 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 161 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 160 из 160 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 161 из 161 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
from PySide6.QtGui import QIcon
|
from PySide6.QtGui import QIcon
|
||||||
@ -12,7 +14,7 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||||
__app_name__ = "PortProtonQt"
|
__app_name__ = "PortProtonQt"
|
||||||
__app_version__ = "0.1.1"
|
__app_version__ = "0.1.2"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
@ -29,14 +31,19 @@ def main():
|
|||||||
else:
|
else:
|
||||||
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
|
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
|
||||||
|
|
||||||
# Парсинг аргументов командной строки
|
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
|
|
||||||
window = MainWindow()
|
window = MainWindow()
|
||||||
|
|
||||||
# Обработка флага --fullscreen
|
if args.session:
|
||||||
|
gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2")
|
||||||
|
cmd = f"{gamescope_cmd} -- portprotonqt"
|
||||||
|
logger.info(f"Executing: {cmd}")
|
||||||
|
subprocess.Popen(cmd, shell=True)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
if args.fullscreen:
|
if args.fullscreen:
|
||||||
logger.info("Запуск в полноэкранном режиме по флагу --fullscreen")
|
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||||
save_fullscreen_config(True)
|
save_fullscreen_config(True)
|
||||||
window.showFullScreen()
|
window.showFullScreen()
|
||||||
|
|
||||||
@ -47,13 +54,29 @@ def main():
|
|||||||
|
|
||||||
def recreate_tray():
|
def recreate_tray():
|
||||||
nonlocal tray
|
nonlocal tray
|
||||||
tray.hide_tray()
|
if tray:
|
||||||
|
logger.debug("Recreating system tray")
|
||||||
|
tray.cleanup()
|
||||||
|
tray = None
|
||||||
current_theme = read_theme_from_config()
|
current_theme = read_theme_from_config()
|
||||||
tray = SystemTray(app, current_theme)
|
tray = SystemTray(app, current_theme)
|
||||||
|
# Ensure window is not None before connecting signals
|
||||||
|
if window:
|
||||||
tray.show_action.triggered.connect(window.show)
|
tray.show_action.triggered.connect(window.show)
|
||||||
tray.hide_action.triggered.connect(window.hide)
|
tray.hide_action.triggered.connect(window.hide)
|
||||||
|
|
||||||
|
def cleanup_on_exit():
|
||||||
|
nonlocal tray, window
|
||||||
|
app.aboutToQuit.disconnect()
|
||||||
|
if tray:
|
||||||
|
tray.cleanup()
|
||||||
|
tray = None
|
||||||
|
if window:
|
||||||
|
window.close()
|
||||||
|
app.quit()
|
||||||
|
|
||||||
window.settings_saved.connect(recreate_tray)
|
window.settings_saved.connect(recreate_tray)
|
||||||
|
app.aboutToQuit.connect(cleanup_on_exit)
|
||||||
|
|
||||||
window.show()
|
window.show()
|
||||||
|
|
||||||
|
@ -13,4 +13,9 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--session",
|
||||||
|
action="store_true",
|
||||||
|
help="Запустить приложение с использованием gamescope"
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
@ -322,6 +322,41 @@ def save_favorites(favorites):
|
|||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
|
||||||
|
def read_rumble_config():
|
||||||
|
"""
|
||||||
|
Читает настройку виброотдачи геймпада из секции [Gamepad].
|
||||||
|
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
|
||||||
|
"""
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
try:
|
||||||
|
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||||
|
save_rumble_config(False)
|
||||||
|
return False
|
||||||
|
if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
|
||||||
|
save_rumble_config(False)
|
||||||
|
return False
|
||||||
|
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def save_rumble_config(rumble_enabled):
|
||||||
|
"""
|
||||||
|
Сохраняет настройку виброотдачи геймпада в секцию [Gamepad].
|
||||||
|
"""
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
try:
|
||||||
|
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||||
|
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
|
||||||
|
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||||
|
if "Gamepad" not in cp:
|
||||||
|
cp["Gamepad"] = {}
|
||||||
|
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
|
||||||
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
|
cp.write(configfile)
|
||||||
|
|
||||||
def ensure_default_proxy_config():
|
def ensure_default_proxy_config():
|
||||||
"""
|
"""
|
||||||
Проверяет наличие секции [Proxy] в конфигурационном файле.
|
Проверяет наличие секции [Proxy] в конфигурационном файле.
|
||||||
@ -342,7 +377,6 @@ def ensure_default_proxy_config():
|
|||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
|
||||||
|
|
||||||
def read_proxy_config():
|
def read_proxy_config():
|
||||||
"""
|
"""
|
||||||
Читает настройки прокси из секции [Proxy] конфигурационного файла.
|
Читает настройки прокси из секции [Proxy] конфигурационного файла.
|
||||||
@ -421,8 +455,6 @@ def save_fullscreen_config(fullscreen):
|
|||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def read_window_geometry() -> tuple[int, int]:
|
def read_window_geometry() -> tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
|
Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
|
||||||
|
@ -3,13 +3,26 @@ import shlex
|
|||||||
import glob
|
import glob
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu
|
import threading
|
||||||
from PySide6.QtCore import QUrl, QPoint
|
import logging
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QFileDialog
|
||||||
|
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
||||||
from PySide6.QtGui import QDesktopServices
|
from PySide6.QtGui import QDesktopServices
|
||||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
|
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
|
||||||
from portprotonqt.localization import _
|
from portprotonqt.localization import _
|
||||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
||||||
from portprotonqt.dialogs import AddGameDialog
|
from portprotonqt.dialogs import AddGameDialog
|
||||||
|
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable
|
||||||
|
import vdf
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class ContextMenuSignals(QObject):
|
||||||
|
"""Signals for thread-safe UI updates from worker threads."""
|
||||||
|
show_status_message = Signal(str, int)
|
||||||
|
show_warning_dialog = Signal(str, str)
|
||||||
|
|
||||||
class ContextMenuManager:
|
class ContextMenuManager:
|
||||||
"""Manages context menu actions for game management in PortProtonQt."""
|
"""Manages context menu actions for game management in PortProtonQt."""
|
||||||
@ -30,6 +43,56 @@ class ContextMenuManager:
|
|||||||
self.theme = theme
|
self.theme = theme
|
||||||
self.load_games = load_games_callback
|
self.load_games = load_games_callback
|
||||||
self.update_game_grid = update_game_grid_callback
|
self.update_game_grid = update_game_grid_callback
|
||||||
|
self.legendary_path = os.path.join(
|
||||||
|
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||||
|
"PortProtonQt", "legendary_cache", "legendary"
|
||||||
|
)
|
||||||
|
self.legendary_config_path = os.path.join(
|
||||||
|
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||||
|
"PortProtonQt", "legendary_cache"
|
||||||
|
)
|
||||||
|
# Initialize signals for thread-safe UI updates
|
||||||
|
self.signals = ContextMenuSignals()
|
||||||
|
if self.parent.statusBar() is None:
|
||||||
|
logger.warning("Status bar is not initialized in MainWindow")
|
||||||
|
else:
|
||||||
|
self.signals.show_status_message.connect(
|
||||||
|
self.parent.statusBar().showMessage,
|
||||||
|
Qt.ConnectionType.QueuedConnection
|
||||||
|
)
|
||||||
|
logger.debug("Connected show_status_message signal to statusBar")
|
||||||
|
self.signals.show_warning_dialog.connect(
|
||||||
|
self._show_warning_dialog,
|
||||||
|
Qt.ConnectionType.QueuedConnection
|
||||||
|
)
|
||||||
|
|
||||||
|
def _show_warning_dialog(self, title: str, message: str):
|
||||||
|
"""Show a warning dialog in the main thread."""
|
||||||
|
logger.debug("Showing warning dialog: %s - %s", title, message)
|
||||||
|
QMessageBox.warning(self.parent, title, message)
|
||||||
|
|
||||||
|
def _is_egs_game_installed(self, app_name: str) -> bool:
|
||||||
|
"""
|
||||||
|
Check if an EGS game is installed by reading installed.json.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_name: The Legendary app_name (unique identifier for the game).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if the game is installed, False otherwise.
|
||||||
|
"""
|
||||||
|
installed_json_path = os.path.join(self.legendary_config_path, "installed.json")
|
||||||
|
if not os.path.exists(installed_json_path):
|
||||||
|
logger.debug("installed.json not found at %s", installed_json_path)
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(installed_json_path, encoding="utf-8") as f:
|
||||||
|
installed_games = json.load(f)
|
||||||
|
return app_name in installed_games
|
||||||
|
except (OSError, json.JSONDecodeError) as e:
|
||||||
|
logger.error("Failed to read installed.json: %s", e)
|
||||||
|
return False
|
||||||
|
|
||||||
def show_context_menu(self, game_card, pos: QPoint):
|
def show_context_menu(self, game_card, pos: QPoint):
|
||||||
"""
|
"""
|
||||||
@ -39,7 +102,6 @@ class ContextMenuManager:
|
|||||||
game_card: The GameCard instance requesting the context menu.
|
game_card: The GameCard instance requesting the context menu.
|
||||||
pos: The position (in widget coordinates) where the menu should appear.
|
pos: The position (in widget coordinates) where the menu should appear.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
menu = QMenu(self.parent)
|
menu = QMenu(self.parent)
|
||||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||||
|
|
||||||
@ -53,6 +115,34 @@ class ContextMenuManager:
|
|||||||
favorite_action = menu.addAction(_("Add to Favorites"))
|
favorite_action = menu.addAction(_("Add to Favorites"))
|
||||||
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, True))
|
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, True))
|
||||||
|
|
||||||
|
if game_card.game_source == "epic":
|
||||||
|
# Always show Import to Legendary
|
||||||
|
import_action = menu.addAction(_("Import to Legendary"))
|
||||||
|
import_action.triggered.connect(
|
||||||
|
lambda: self.import_to_legendary(game_card.name, game_card.appid)
|
||||||
|
)
|
||||||
|
# Show other actions only if the game is installed
|
||||||
|
if self._is_egs_game_installed(game_card.appid):
|
||||||
|
uninstall_action = menu.addAction(_("Uninstall Game"))
|
||||||
|
uninstall_action.triggered.connect(
|
||||||
|
lambda: self.uninstall_egs_game(game_card.name, game_card.appid)
|
||||||
|
)
|
||||||
|
is_in_steam = is_game_in_steam(game_card.name)
|
||||||
|
if is_in_steam:
|
||||||
|
remove_steam_action = menu.addAction(_("Remove from Steam"))
|
||||||
|
remove_steam_action.triggered.connect(
|
||||||
|
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
add_steam_action = menu.addAction(_("Add to Steam"))
|
||||||
|
add_steam_action.triggered.connect(
|
||||||
|
lambda: self.add_egs_to_steam(game_card.name, game_card.appid)
|
||||||
|
)
|
||||||
|
open_folder_action = menu.addAction(_("Open Game Folder"))
|
||||||
|
open_folder_action.triggered.connect(
|
||||||
|
lambda: self.open_egs_game_folder(game_card.appid)
|
||||||
|
)
|
||||||
|
|
||||||
if game_card.game_source not in ("steam", "epic"):
|
if game_card.game_source not in ("steam", "epic"):
|
||||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
||||||
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||||
@ -64,13 +154,17 @@ class ContextMenuManager:
|
|||||||
add_action.triggered.connect(lambda: self.add_to_desktop(game_card.name, game_card.exec_line))
|
add_action.triggered.connect(lambda: self.add_to_desktop(game_card.name, game_card.exec_line))
|
||||||
|
|
||||||
edit_action = menu.addAction(_("Edit Shortcut"))
|
edit_action = menu.addAction(_("Edit Shortcut"))
|
||||||
edit_action.triggered.connect(lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path))
|
edit_action.triggered.connect(
|
||||||
|
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||||
|
)
|
||||||
|
|
||||||
delete_action = menu.addAction(_("Delete from PortProton"))
|
delete_action = menu.addAction(_("Delete from PortProton"))
|
||||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||||
|
|
||||||
open_folder_action = menu.addAction(_("Open Game Folder"))
|
open_folder_action = menu.addAction(_("Open Game Folder"))
|
||||||
open_folder_action.triggered.connect(lambda: self.open_game_folder(game_card.name, game_card.exec_line))
|
open_folder_action.triggered.connect(
|
||||||
|
lambda: self.open_game_folder(game_card.name, game_card.exec_line)
|
||||||
|
)
|
||||||
|
|
||||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||||
desktop_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
desktop_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||||
@ -81,17 +175,209 @@ class ContextMenuManager:
|
|||||||
add_action = menu.addAction(_("Add to Menu"))
|
add_action = menu.addAction(_("Add to Menu"))
|
||||||
add_action.triggered.connect(lambda: self.add_to_menu(game_card.name, game_card.exec_line))
|
add_action.triggered.connect(lambda: self.add_to_menu(game_card.name, game_card.exec_line))
|
||||||
|
|
||||||
# Add Steam-related actions
|
|
||||||
is_in_steam = is_game_in_steam(game_card.name)
|
is_in_steam = is_game_in_steam(game_card.name)
|
||||||
if is_in_steam:
|
if is_in_steam:
|
||||||
remove_steam_action = menu.addAction(_("Remove from Steam"))
|
remove_steam_action = menu.addAction(_("Remove from Steam"))
|
||||||
remove_steam_action.triggered.connect(lambda: self.remove_from_steam(game_card.name, game_card.exec_line))
|
remove_steam_action.triggered.connect(
|
||||||
|
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
add_steam_action = menu.addAction(_("Add to Steam"))
|
add_steam_action = menu.addAction(_("Add to Steam"))
|
||||||
add_steam_action.triggered.connect(lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path))
|
add_steam_action.triggered.connect(
|
||||||
|
lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||||
|
)
|
||||||
|
|
||||||
menu.exec(game_card.mapToGlobal(pos))
|
menu.exec(game_card.mapToGlobal(pos))
|
||||||
|
|
||||||
|
def add_egs_to_steam(self, game_name: str, app_name: str):
|
||||||
|
"""
|
||||||
|
Adds an EGS game to Steam using the egs_api.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_name: The display name of the game.
|
||||||
|
app_name: The Legendary app_name (unique identifier for the game).
|
||||||
|
"""
|
||||||
|
if not self._check_portproton():
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(self.legendary_path):
|
||||||
|
self._show_warning_dialog(_("Error"), _("Legendary executable not found at {0}").format(self.legendary_path))
|
||||||
|
return
|
||||||
|
|
||||||
|
def on_add_to_steam_result(result: tuple[bool, str]):
|
||||||
|
success, message = result
|
||||||
|
if success:
|
||||||
|
self.signals.show_status_message.emit(
|
||||||
|
_("The game was added successfully. Please restart Steam for changes to take effect."), 5000
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.signals.show_warning_dialog.emit(_("Error"), message)
|
||||||
|
|
||||||
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(_("Adding '{0}' to Steam...").format(game_name), 0)
|
||||||
|
logger.debug("Direct status message: Adding '%s' to Steam", game_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when adding '%s' to Steam", game_name)
|
||||||
|
add_egs_to_steam(app_name, game_name, self.legendary_path, on_add_to_steam_result)
|
||||||
|
|
||||||
|
def open_egs_game_folder(self, app_name: str):
|
||||||
|
"""
|
||||||
|
Opens the folder containing the EGS game's executable.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
app_name: The Legendary app_name (unique identifier for the game).
|
||||||
|
"""
|
||||||
|
if not self._check_portproton():
|
||||||
|
return
|
||||||
|
|
||||||
|
exe_path = get_egs_executable(app_name, self.legendary_config_path)
|
||||||
|
if not exe_path or not os.path.exists(exe_path):
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Executable file not found for game: {0}").format(app_name)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||||
|
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||||
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Opened folder for EGS game '{0}'").format(app_name), 3000
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Opened folder for '%s'", app_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when opening folder for '%s'", app_name)
|
||||||
|
except Exception as e:
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to open game folder: {0}").format(str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
def import_to_legendary(self, game_name, app_name):
|
||||||
|
"""
|
||||||
|
Imports an installed Epic Games Store game to Legendary asynchronously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_name: The display name of the game.
|
||||||
|
app_name: The Legendary app_name (unique identifier for the game).
|
||||||
|
"""
|
||||||
|
if not self._check_portproton():
|
||||||
|
return
|
||||||
|
|
||||||
|
folder_path = QFileDialog.getExistingDirectory(
|
||||||
|
self.parent,
|
||||||
|
_("Select Game Installation Folder"),
|
||||||
|
os.path.expanduser("~")
|
||||||
|
)
|
||||||
|
if not folder_path:
|
||||||
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(_("No folder selected"), 3000)
|
||||||
|
logger.debug("Direct status message: No folder selected for '%s'", game_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when no folder selected for '%s'", game_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(self.legendary_path):
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Legendary executable not found at {0}").format(self.legendary_path)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
def run_import():
|
||||||
|
cmd = [self.legendary_path, "import", app_name, folder_path]
|
||||||
|
subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Importing '{0}' to Legendary...").format(game_name), 0
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Importing '%s' to Legendary", game_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when importing '%s'", game_name)
|
||||||
|
threading.Thread(target=run_import, daemon=True).start()
|
||||||
|
|
||||||
|
def uninstall_egs_game(self, game_name: str, app_name: str):
|
||||||
|
"""
|
||||||
|
Uninstalls an Epic Games Store game using Legendary asynchronously.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
game_name: The display name of the game.
|
||||||
|
app_name: The Legendary app_name (unique identifier for the game).
|
||||||
|
"""
|
||||||
|
if not self._check_portproton():
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(self.legendary_path):
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Legendary executable not found at {0}").format(self.legendary_path)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self.parent,
|
||||||
|
_("Confirm Uninstallation"),
|
||||||
|
_("Are you sure you want to uninstall '{0}'? This will remove the game files.").format(game_name),
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
def run_uninstall():
|
||||||
|
cmd = [self.legendary_path, "uninstall", app_name, "--skip-uninstaller"]
|
||||||
|
try:
|
||||||
|
subprocess.run(
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True,
|
||||||
|
env={"LEGENDARY_CONFIG_PATH": self.legendary_config_path}
|
||||||
|
)
|
||||||
|
self.signals.show_status_message.emit(
|
||||||
|
_("Successfully uninstalled '{0}'").format(game_name), 3000
|
||||||
|
)
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
self.signals.show_status_message.emit(
|
||||||
|
_("Failed to uninstall '{0}'").format(game_name), 3000
|
||||||
|
)
|
||||||
|
self.signals.show_warning_dialog.emit(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to uninstall '{0}': {1}").format(game_name, e.stderr)
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.signals.show_status_message.emit(
|
||||||
|
_("Legendary executable not found"), 3000
|
||||||
|
)
|
||||||
|
self.signals.show_warning_dialog.emit(
|
||||||
|
_("Error"),
|
||||||
|
_("Legendary executable not found")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.signals.show_status_message.emit(
|
||||||
|
_("Unexpected error during uninstall"), 3000
|
||||||
|
)
|
||||||
|
self.signals.show_warning_dialog.emit(
|
||||||
|
_("Error"),
|
||||||
|
_("Unexpected error during uninstall: {0}").format(str(e))
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Uninstalling '{0}'...").format(game_name), 0
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Uninstalling '%s'", game_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when uninstalling '%s'", game_name)
|
||||||
|
threading.Thread(target=run_uninstall, daemon=True).start()
|
||||||
|
|
||||||
def toggle_favorite(self, game_card, add: bool):
|
def toggle_favorite(self, game_card, add: bool):
|
||||||
"""
|
"""
|
||||||
Toggle the favorite status of a game and update its icon.
|
Toggle the favorite status of a game and update its icon.
|
||||||
@ -104,18 +390,33 @@ class ContextMenuManager:
|
|||||||
if add and game_card.name not in favorites:
|
if add and game_card.name not in favorites:
|
||||||
favorites.append(game_card.name)
|
favorites.append(game_card.name)
|
||||||
game_card.is_favorite = True
|
game_card.is_favorite = True
|
||||||
self.parent.statusBar().showMessage(_("Added '{0}' to favorites").format(game_card.name), 3000)
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Added '{0}' to favorites").format(game_card.name), 3000
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Added '%s' to favorites", game_card.name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when adding '%s' to favorites", game_card.name)
|
||||||
elif not add and game_card.name in favorites:
|
elif not add and game_card.name in favorites:
|
||||||
favorites.remove(game_card.name)
|
favorites.remove(game_card.name)
|
||||||
game_card.is_favorite = False
|
game_card.is_favorite = False
|
||||||
self.parent.statusBar().showMessage(_("Removed '{0}' from favorites").format(game_card.name), 3000)
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Removed '{0}' from favorites").format(game_card.name), 3000
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Removed '%s' from favorites", game_card.name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when removing '%s' from favorites", game_card.name)
|
||||||
save_favorites(favorites)
|
save_favorites(favorites)
|
||||||
game_card.update_favorite_icon()
|
game_card.update_favorite_icon()
|
||||||
|
|
||||||
def _check_portproton(self):
|
def _check_portproton(self):
|
||||||
"""Check if PortProton is available."""
|
"""Check if PortProton is available."""
|
||||||
if self.portproton_location is None:
|
if self.portproton_location is None:
|
||||||
QMessageBox.warning(self.parent, _("Error"), _("PortProton is not found."))
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("PortProton is not found.")
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@ -139,33 +440,32 @@ class ContextMenuManager:
|
|||||||
if entry:
|
if entry:
|
||||||
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
|
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
|
||||||
if not exec_line:
|
if not exec_line:
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("No executable command found in .desktop for game: {0}").format(game_name)
|
_("No executable command found in .desktop for game: {0}").format(game_name)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Failed to parse .desktop file for game: {0}").format(game_name)
|
_("Failed to parse .desktop file for game: {0}").format(game_name)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Error reading .desktop file: {0}").format(e)
|
_("Error reading .desktop file: {0}").format(e)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
# Fallback: Search all .desktop files
|
|
||||||
for file in glob.glob(os.path.join(self.portproton_location, "*.desktop")):
|
for file in glob.glob(os.path.join(self.portproton_location, "*.desktop")):
|
||||||
entry = parse_desktop_entry(file)
|
entry = parse_desktop_entry(file)
|
||||||
if entry:
|
if entry:
|
||||||
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
|
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
|
||||||
if exec_line:
|
if exec_line:
|
||||||
return exec_line
|
return exec_line
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_(".desktop file not found for game: {0}").format(game_name)
|
_(".desktop file not found for game: {0}").format(game_name)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@ -176,8 +476,8 @@ class ContextMenuManager:
|
|||||||
try:
|
try:
|
||||||
entry_exec_split = shlex.split(exec_line)
|
entry_exec_split = shlex.split(exec_line)
|
||||||
if not entry_exec_split:
|
if not entry_exec_split:
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Invalid executable command: {0}").format(exec_line)
|
_("Invalid executable command: {0}").format(exec_line)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@ -188,15 +488,15 @@ class ContextMenuManager:
|
|||||||
else:
|
else:
|
||||||
exe_path = entry_exec_split[-1]
|
exe_path = entry_exec_split[-1]
|
||||||
if not exe_path or not os.path.exists(exe_path):
|
if not exe_path or not os.path.exists(exe_path):
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Executable file not found: {0}").format(exe_path or "None")
|
_("Executable file not found: {0}").format(exe_path or "None")
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return exe_path
|
return exe_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Failed to parse executable command: {0}").format(e)
|
_("Failed to parse executable command: {0}").format(e)
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@ -205,10 +505,17 @@ class ContextMenuManager:
|
|||||||
"""Remove a file and handle errors."""
|
"""Remove a file and handle errors."""
|
||||||
try:
|
try:
|
||||||
os.remove(file_path)
|
os.remove(file_path)
|
||||||
|
if self.parent.statusBar():
|
||||||
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
|
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
|
||||||
|
logger.debug("Direct status message: %s", success_message.format(game_name))
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when removing file for '%s'", game_name)
|
||||||
return True
|
return True
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
QMessageBox.warning(self.parent, _("Error"), error_message.format(e))
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
error_message.format(e)
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def delete_game(self, game_name, exec_line):
|
def delete_game(self, game_name, exec_line):
|
||||||
@ -229,13 +536,12 @@ class ContextMenuManager:
|
|||||||
|
|
||||||
desktop_path = self._get_desktop_path(game_name)
|
desktop_path = self._get_desktop_path(game_name)
|
||||||
if not os.path.exists(desktop_path):
|
if not os.path.exists(desktop_path):
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get exec_line and parse exe_path
|
|
||||||
exec_line = self._get_exec_line(game_name, exec_line)
|
exec_line = self._get_exec_line(game_name, exec_line)
|
||||||
if not exec_line:
|
if not exec_line:
|
||||||
return
|
return
|
||||||
@ -243,7 +549,6 @@ class ContextMenuManager:
|
|||||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||||
exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None
|
exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None
|
||||||
|
|
||||||
# Remove .desktop file
|
|
||||||
if not self._remove_file(
|
if not self._remove_file(
|
||||||
desktop_path,
|
desktop_path,
|
||||||
_("Failed to delete .desktop file: {0}"),
|
_("Failed to delete .desktop file: {0}"),
|
||||||
@ -252,7 +557,6 @@ class ContextMenuManager:
|
|||||||
):
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Remove custom data if we got an exe_name
|
|
||||||
if exe_name:
|
if exe_name:
|
||||||
xdg_data_home = os.getenv(
|
xdg_data_home = os.getenv(
|
||||||
"XDG_DATA_HOME",
|
"XDG_DATA_HOME",
|
||||||
@ -263,15 +567,11 @@ class ContextMenuManager:
|
|||||||
try:
|
try:
|
||||||
shutil.rmtree(custom_folder)
|
shutil.rmtree(custom_folder)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Failed to delete custom data: {0}").format(e)
|
_("Failed to delete custom data: {0}").format(e)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Refresh UI
|
|
||||||
self.parent.games = self.load_games()
|
|
||||||
self.update_game_grid()
|
|
||||||
|
|
||||||
def add_to_menu(self, game_name, exec_line):
|
def add_to_menu(self, game_name, exec_line):
|
||||||
"""Copy the .desktop file to ~/.local/share/applications."""
|
"""Copy the .desktop file to ~/.local/share/applications."""
|
||||||
if not self._check_portproton():
|
if not self._check_portproton():
|
||||||
@ -279,25 +579,29 @@ class ContextMenuManager:
|
|||||||
|
|
||||||
desktop_path = self._get_desktop_path(game_name)
|
desktop_path = self._get_desktop_path(game_name)
|
||||||
if not os.path.exists(desktop_path):
|
if not os.path.exists(desktop_path):
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Destination path
|
|
||||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||||
os.makedirs(applications_dir, exist_ok=True)
|
os.makedirs(applications_dir, exist_ok=True)
|
||||||
dest_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
dest_path = os.path.join(applications_dir, f"{game_name}.desktop")
|
||||||
|
|
||||||
# Copy .desktop file
|
|
||||||
try:
|
try:
|
||||||
shutil.copyfile(desktop_path, dest_path)
|
shutil.copyfile(desktop_path, dest_path)
|
||||||
os.chmod(dest_path, 0o755) # Ensure executable permissions
|
os.chmod(dest_path, 0o755)
|
||||||
self.parent.statusBar().showMessage(_("Game '{0}' added to menu").format(game_name), 3000)
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Game '{0}' added to menu").format(game_name), 3000
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Game '%s' added to menu", game_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when adding '%s' to menu", game_name)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Failed to add game to menu: {0}").format(str(e))
|
_("Failed to add game to menu: {0}").format(str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -319,25 +623,29 @@ class ContextMenuManager:
|
|||||||
|
|
||||||
desktop_path = self._get_desktop_path(game_name)
|
desktop_path = self._get_desktop_path(game_name)
|
||||||
if not os.path.exists(desktop_path):
|
if not os.path.exists(desktop_path):
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Could not locate .desktop file for '{0}'").format(game_name)
|
_("Could not locate .desktop file for '{0}'").format(game_name)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Destination path
|
|
||||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
||||||
os.makedirs(desktop_dir, exist_ok=True)
|
os.makedirs(desktop_dir, exist_ok=True)
|
||||||
dest_path = os.path.join(desktop_dir, f"{game_name}.desktop")
|
dest_path = os.path.join(desktop_dir, f"{game_name}.desktop")
|
||||||
|
|
||||||
# Copy .desktop file
|
|
||||||
try:
|
try:
|
||||||
shutil.copyfile(desktop_path, dest_path)
|
shutil.copyfile(desktop_path, dest_path)
|
||||||
os.chmod(dest_path, 0o755) # Ensure executable permissions
|
os.chmod(dest_path, 0o755)
|
||||||
self.parent.statusBar().showMessage(_("Game '{0}' added to desktop").format(game_name), 3000)
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Game '{0}' added to desktop").format(game_name), 3000
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Game '%s' added to desktop", game_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when adding '%s' to desktop", game_name)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
QMessageBox.warning(
|
self._show_warning_dialog(
|
||||||
self.parent, _("Error"),
|
_("Error"),
|
||||||
_("Failed to add game to desktop: {0}").format(str(e))
|
_("Failed to add game to desktop: {0}").format(str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -354,7 +662,6 @@ class ContextMenuManager:
|
|||||||
|
|
||||||
def edit_game_shortcut(self, game_name, exec_line, cover_path):
|
def edit_game_shortcut(self, game_name, exec_line, cover_path):
|
||||||
"""Opens the AddGameDialog in edit mode to modify an existing .desktop file."""
|
"""Opens the AddGameDialog in edit mode to modify an existing .desktop file."""
|
||||||
|
|
||||||
if not self._check_portproton():
|
if not self._check_portproton():
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -366,7 +673,6 @@ class ContextMenuManager:
|
|||||||
if not exe_path:
|
if not exe_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Open dialog in edit mode
|
|
||||||
dialog = AddGameDialog(
|
dialog = AddGameDialog(
|
||||||
parent=self.parent,
|
parent=self.parent,
|
||||||
theme=self.theme,
|
theme=self.theme,
|
||||||
@ -382,16 +688,20 @@ class ContextMenuManager:
|
|||||||
new_cover_path = dialog.coverEdit.text().strip()
|
new_cover_path = dialog.coverEdit.text().strip()
|
||||||
|
|
||||||
if not new_name or not new_exe_path:
|
if not new_name or not new_exe_path:
|
||||||
QMessageBox.warning(self.parent, _("Error"), _("Game name and executable path are required."))
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Game name and executable path are required.")
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Generate new .desktop file content
|
|
||||||
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
|
||||||
if not desktop_entry or not new_desktop_path:
|
if not desktop_entry or not new_desktop_path:
|
||||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to generate .desktop file data."))
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to generate .desktop file data.")
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# If the name has changed, remove the old .desktop file
|
|
||||||
old_desktop_path = self._get_desktop_path(game_name)
|
old_desktop_path = self._get_desktop_path(game_name)
|
||||||
if game_name != new_name and os.path.exists(old_desktop_path):
|
if game_name != new_name and os.path.exists(old_desktop_path):
|
||||||
self._remove_file(
|
self._remove_file(
|
||||||
@ -401,16 +711,17 @@ class ContextMenuManager:
|
|||||||
game_name
|
game_name
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save the updated .desktop file
|
|
||||||
try:
|
try:
|
||||||
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
with open(new_desktop_path, "w", encoding="utf-8") as f:
|
||||||
f.write(desktop_entry)
|
f.write(desktop_entry)
|
||||||
os.chmod(new_desktop_path, 0o755)
|
os.chmod(new_desktop_path, 0o755)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to save .desktop file: {0}").format(e))
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to save .desktop file: {0}").format(e)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update custom cover if provided
|
|
||||||
if os.path.isfile(new_cover_path):
|
if os.path.isfile(new_cover_path):
|
||||||
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
|
||||||
xdg_data_home = os.getenv(
|
xdg_data_home = os.getenv(
|
||||||
@ -425,16 +736,14 @@ class ContextMenuManager:
|
|||||||
try:
|
try:
|
||||||
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
|
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to copy cover image: {0}").format(e))
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to copy cover image: {0}").format(e)
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Refresh the game list
|
|
||||||
self.parent.games = self.load_games()
|
|
||||||
self.update_game_grid()
|
|
||||||
|
|
||||||
def add_to_steam(self, game_name, exec_line, cover_path):
|
def add_to_steam(self, game_name, exec_line, cover_path):
|
||||||
"""Handle adding a non-Steam game to Steam via steam_api."""
|
"""Handle adding a non-Steam game to Steam via steam_api."""
|
||||||
|
|
||||||
if not self._check_portproton():
|
if not self._check_portproton():
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -446,21 +755,158 @@ class ContextMenuManager:
|
|||||||
if not exe_path:
|
if not exe_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
def on_add_to_steam_result(result: tuple[bool, str]):
|
||||||
|
success, message = result
|
||||||
if success:
|
if success:
|
||||||
QMessageBox.information(
|
self.signals.show_status_message.emit(
|
||||||
self.parent, _("Restart Steam"),
|
_("The game was added successfully. Please restart Steam for changes to take effect."), 5000
|
||||||
_("The game was added successfully.\nPlease restart Steam for changes to take effect.")
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
QMessageBox.warning(self.parent, _("Error"), message)
|
self.signals.show_warning_dialog.emit(_("Error"), message)
|
||||||
|
|
||||||
def remove_from_steam(self, game_name, exec_line):
|
if self.parent.statusBar():
|
||||||
"""Handle removing a non-Steam game from Steam via steam_api."""
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Adding '{0}' to Steam...").format(game_name), 0
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Adding '%s' to Steam", game_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when adding '%s' to Steam", game_name)
|
||||||
|
add_to_steam(game_name, exec_line, cover_path)
|
||||||
|
|
||||||
|
def remove_from_steam(self, game_name, exec_line, game_source):
|
||||||
|
"""Handle removing a game from Steam via steam_api, supporting both EGS and non-EGS games."""
|
||||||
if not self._check_portproton():
|
if not self._check_portproton():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
def on_remove_from_steam_result(result: tuple[bool, str]):
|
||||||
|
success, message = result
|
||||||
|
if success:
|
||||||
|
self.signals.show_status_message.emit(
|
||||||
|
_("The game was removed successfully. Please restart Steam for changes to take effect."), 5000
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.signals.show_warning_dialog.emit(_("Error"), message)
|
||||||
|
|
||||||
|
if game_source == "epic":
|
||||||
|
# For EGS games, construct the script path used in Steam shortcuts.vdf
|
||||||
|
if not self.portproton_location:
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("PortProton directory not found")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
steam_scripts_dir = os.path.join(self.portproton_location, "steam_scripts")
|
||||||
|
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
|
||||||
|
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}_egs.sh")
|
||||||
|
quoted_script_path = f'"{script_path}"'
|
||||||
|
|
||||||
|
# Directly remove the shortcut by matching AppName and Exe
|
||||||
|
try:
|
||||||
|
from portprotonqt.steam_api import get_steam_home, get_last_steam_user, convert_steam_id
|
||||||
|
steam_home = get_steam_home()
|
||||||
|
if not steam_home:
|
||||||
|
self._show_warning_dialog(_("Error"), _("Steam directory not found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
last_user = get_last_steam_user(steam_home)
|
||||||
|
if not last_user or 'SteamID' not in last_user:
|
||||||
|
self._show_warning_dialog(_("Error"), _("Failed to get Steam user ID"))
|
||||||
|
return
|
||||||
|
|
||||||
|
userdata_dir = os.path.join(steam_home, "userdata")
|
||||||
|
user_id = last_user['SteamID']
|
||||||
|
unsigned_id = convert_steam_id(user_id)
|
||||||
|
user_dir = os.path.join(userdata_dir, str(unsigned_id))
|
||||||
|
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
|
||||||
|
backup_path = f"{steam_shortcuts_path}.backup"
|
||||||
|
|
||||||
|
if not os.path.exists(steam_shortcuts_path):
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Steam shortcuts file not found")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Backup shortcuts.vdf
|
||||||
|
try:
|
||||||
|
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||||
|
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||||
|
except Exception as e:
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to create backup of shortcuts.vdf: {0}").format(e)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load shortcuts.vdf
|
||||||
|
try:
|
||||||
|
with open(steam_shortcuts_path, 'rb') as f:
|
||||||
|
shortcuts_data = vdf.binary_load(f)
|
||||||
|
except Exception as e:
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to load shortcuts.vdf: {0}").format(e)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||||
|
modified = False
|
||||||
|
new_shortcuts = {}
|
||||||
|
index = 0
|
||||||
|
|
||||||
|
for _key, entry in shortcuts.items():
|
||||||
|
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||||
|
modified = True
|
||||||
|
logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
|
||||||
|
continue
|
||||||
|
new_shortcuts[str(index)] = entry
|
||||||
|
index += 1
|
||||||
|
|
||||||
|
if not modified:
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Game '{0}' not found in Steam shortcuts").format(game_name)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Save updated shortcuts.vdf
|
||||||
|
try:
|
||||||
|
with open(steam_shortcuts_path, 'wb') as f:
|
||||||
|
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
||||||
|
logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
|
||||||
|
on_remove_from_steam_result((True, f"Game '{game_name}' removed from Steam"))
|
||||||
|
except Exception as e:
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to update shortcuts.vdf: {0}").format(e)
|
||||||
|
)
|
||||||
|
if os.path.exists(backup_path):
|
||||||
|
try:
|
||||||
|
shutil.copy2(backup_path, steam_shortcuts_path)
|
||||||
|
logger.info("Restored shortcuts.vdf from backup")
|
||||||
|
except Exception as restore_err:
|
||||||
|
logger.error(f"Failed to restore shortcuts.vdf: {restore_err}")
|
||||||
|
on_remove_from_steam_result((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Optionally, remove the script file
|
||||||
|
if os.path.exists(script_path):
|
||||||
|
try:
|
||||||
|
os.remove(script_path)
|
||||||
|
logger.info(f"Removed EGS script file: {script_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to remove EGS script file {script_path}: {e}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to remove EGS game from Steam: {0}").format(e)
|
||||||
|
)
|
||||||
|
on_remove_from_steam_result((False, f"Failed to remove EGS game from Steam: {e}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
# For non-EGS games, use the existing logic
|
||||||
exec_line = self._get_exec_line(game_name, exec_line)
|
exec_line = self._get_exec_line(game_name, exec_line)
|
||||||
if not exec_line:
|
if not exec_line:
|
||||||
return
|
return
|
||||||
@ -469,14 +915,14 @@ class ContextMenuManager:
|
|||||||
if not exe_path:
|
if not exe_path:
|
||||||
return
|
return
|
||||||
|
|
||||||
success, message = remove_from_steam(game_name, exec_line)
|
if self.parent.statusBar():
|
||||||
if success:
|
self.parent.statusBar().showMessage(
|
||||||
QMessageBox.information(
|
_("Removing '{0}' from Steam...").format(game_name), 0
|
||||||
self.parent, _("Restart Steam"),
|
|
||||||
_("The game was removed successfully.\nPlease restart Steam for changes to take effect.")
|
|
||||||
)
|
)
|
||||||
|
logger.debug("Direct status message: Removing '%s' from Steam", game_name)
|
||||||
else:
|
else:
|
||||||
QMessageBox.warning(self.parent, _("Error"), message)
|
logger.warning("Status bar not available when removing '%s' from Steam", game_name)
|
||||||
|
remove_from_steam(game_name, exec_line)
|
||||||
|
|
||||||
def open_game_folder(self, game_name, exec_line):
|
def open_game_folder(self, game_name, exec_line):
|
||||||
"""Open the folder containing the game's executable."""
|
"""Open the folder containing the game's executable."""
|
||||||
@ -494,6 +940,15 @@ class ContextMenuManager:
|
|||||||
try:
|
try:
|
||||||
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
folder_path = os.path.dirname(os.path.abspath(exe_path))
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
|
||||||
self.parent.statusBar().showMessage(_("Opened folder for '{0}'").format(game_name), 3000)
|
if self.parent.statusBar():
|
||||||
|
self.parent.statusBar().showMessage(
|
||||||
|
_("Opened folder for '{0}'").format(game_name), 3000
|
||||||
|
)
|
||||||
|
logger.debug("Direct status message: Opened folder for '%s'", game_name)
|
||||||
|
else:
|
||||||
|
logger.warning("Status bar not available when opening folder for '%s'", game_name)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(self.parent, _("Error"), _("Failed to open game folder: {0}").format(str(e)))
|
self._show_warning_dialog(
|
||||||
|
_("Error"),
|
||||||
|
_("Failed to open game folder: {0}").format(str(e))
|
||||||
|
)
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PySide6.QtWidgets import QLabel, QPushButton, QWidget, QLayout, QStyleOption, QLayoutItem
|
from PySide6.QtWidgets import QLabel, QPushButton, QWidget, QLayout, QLayoutItem
|
||||||
from PySide6.QtCore import Qt, Signal, QRect, QPoint, QSize
|
from PySide6.QtCore import Qt, Signal, QRect, QPoint, QSize
|
||||||
from PySide6.QtGui import QFont, QFontMetrics, QPainter
|
from PySide6.QtGui import QFont, QFontMetrics, QPainter
|
||||||
|
|
||||||
@ -133,18 +133,7 @@ class FlowLayout(QLayout):
|
|||||||
class ClickableLabel(QLabel):
|
class ClickableLabel(QLabel):
|
||||||
clicked = Signal()
|
clicked = Signal()
|
||||||
|
|
||||||
def __init__(self, *args, icon=None, icon_size=16, icon_space=5, change_cursor=True, **kwargs):
|
def __init__(self, *args, icon=None, icon_size=16, icon_space=5, change_cursor=True, font_scale_factor=0.06, **kwargs):
|
||||||
"""
|
|
||||||
Поддерживаются вызовы:
|
|
||||||
- ClickableLabel("текст", parent=...) – первый аргумент строка,
|
|
||||||
- ClickableLabel(parent, text="...") – если первым аргументом передается родитель.
|
|
||||||
|
|
||||||
Аргументы:
|
|
||||||
icon: QIcon или None – иконка, которая будет отрисована вместе с текстом.
|
|
||||||
icon_size: int – размер иконки (ширина и высота).
|
|
||||||
icon_space: int – отступ между иконкой и текстом.
|
|
||||||
change_cursor: bool – изменять ли курсор на PointingHandCursor при наведении (по умолчанию True).
|
|
||||||
"""
|
|
||||||
if args and isinstance(args[0], str):
|
if args and isinstance(args[0], str):
|
||||||
text = args[0]
|
text = args[0]
|
||||||
parent = kwargs.get("parent", None)
|
parent = kwargs.get("parent", None)
|
||||||
@ -162,20 +151,38 @@ class ClickableLabel(QLabel):
|
|||||||
self._icon = icon
|
self._icon = icon
|
||||||
self._icon_size = icon_size
|
self._icon_size = icon_size
|
||||||
self._icon_space = icon_space
|
self._icon_space = icon_space
|
||||||
|
self._font_scale_factor = font_scale_factor
|
||||||
|
self._card_width = 250 # Значение по умолчанию
|
||||||
if change_cursor:
|
if change_cursor:
|
||||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
|
self.updateFontSize()
|
||||||
|
|
||||||
def setIcon(self, icon):
|
def setIcon(self, icon):
|
||||||
"""Устанавливает иконку и перерисовывает виджет."""
|
|
||||||
self._icon = icon
|
self._icon = icon
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def icon(self):
|
def icon(self):
|
||||||
"""Возвращает текущую иконку."""
|
|
||||||
return self._icon
|
return self._icon
|
||||||
|
|
||||||
|
def setIconSize(self, icon_size: int, icon_space: int):
|
||||||
|
self._icon_size = icon_size
|
||||||
|
self._icon_space = icon_space
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def setCardWidth(self, card_width: int):
|
||||||
|
"""Обновляет ширину карточки и пересчитывает размер шрифта."""
|
||||||
|
self._card_width = card_width
|
||||||
|
self.updateFontSize()
|
||||||
|
|
||||||
|
def updateFontSize(self):
|
||||||
|
"""Обновляет размер шрифта на основе card_width и font_scale_factor."""
|
||||||
|
font = self.font()
|
||||||
|
font_size = int(self._card_width * self._font_scale_factor)
|
||||||
|
font.setPointSize(max(8, font_size)) # Минимальный размер шрифта 8
|
||||||
|
self.setFont(font)
|
||||||
|
self.update()
|
||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
"""Переопределяем отрисовку: рисуем иконку и текст в одном лейбле."""
|
|
||||||
painter = QPainter(self)
|
painter = QPainter(self)
|
||||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||||
|
|
||||||
@ -190,7 +197,6 @@ class ClickableLabel(QLabel):
|
|||||||
text = self.text()
|
text = self.text()
|
||||||
|
|
||||||
if self._icon:
|
if self._icon:
|
||||||
# Получаем QPixmap нужного размера
|
|
||||||
pixmap = self._icon.pixmap(icon_size, icon_size)
|
pixmap = self._icon.pixmap(icon_size, icon_size)
|
||||||
icon_rect = QRect(0, 0, icon_size, icon_size)
|
icon_rect = QRect(0, 0, icon_size, icon_size)
|
||||||
icon_rect.moveTop(rect.top() + (rect.height() - icon_size) // 2)
|
icon_rect.moveTop(rect.top() + (rect.height() - icon_size) // 2)
|
||||||
@ -214,13 +220,11 @@ class ClickableLabel(QLabel):
|
|||||||
if pixmap:
|
if pixmap:
|
||||||
icon_rect.moveLeft(x)
|
icon_rect.moveLeft(x)
|
||||||
text_rect = QRect(x + icon_size + spacing, y, text_width, text_height)
|
text_rect = QRect(x + icon_size + spacing, y, text_width, text_height)
|
||||||
|
painter.drawPixmap(icon_rect, pixmap)
|
||||||
else:
|
else:
|
||||||
|
# Устанавливаем text_rect для меток без иконки (например, favoriteLabel)
|
||||||
text_rect = QRect(x, y, text_width, text_height)
|
text_rect = QRect(x, y, text_width, text_height)
|
||||||
|
|
||||||
option = QStyleOption()
|
|
||||||
option.initFrom(self)
|
|
||||||
if pixmap:
|
|
||||||
painter.drawPixmap(icon_rect, pixmap)
|
|
||||||
self.style().drawItemText(
|
self.style().drawItemText(
|
||||||
painter,
|
painter,
|
||||||
text_rect,
|
text_rect,
|
||||||
|
@ -95,10 +95,11 @@ class AddGameDialog(QDialog):
|
|||||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
|
||||||
|
|
||||||
layout = QFormLayout(self)
|
layout = QFormLayout(self)
|
||||||
|
layout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||||
|
|
||||||
# Game name
|
# Game name
|
||||||
self.nameEdit = QLineEdit(self)
|
self.nameEdit = QLineEdit(self)
|
||||||
self.nameEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
|
self.nameEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||||
if game_name:
|
if game_name:
|
||||||
self.nameEdit.setText(game_name)
|
self.nameEdit.setText(game_name)
|
||||||
name_label = QLabel(_("Game Name:"))
|
name_label = QLabel(_("Game Name:"))
|
||||||
@ -107,7 +108,7 @@ class AddGameDialog(QDialog):
|
|||||||
|
|
||||||
# Exe path
|
# Exe path
|
||||||
self.exeEdit = QLineEdit(self)
|
self.exeEdit = QLineEdit(self)
|
||||||
self.exeEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
|
self.exeEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||||
if exe_path:
|
if exe_path:
|
||||||
self.exeEdit.setText(exe_path)
|
self.exeEdit.setText(exe_path)
|
||||||
exeBrowseButton = QPushButton(_("Browse..."), self)
|
exeBrowseButton = QPushButton(_("Browse..."), self)
|
||||||
@ -123,7 +124,7 @@ class AddGameDialog(QDialog):
|
|||||||
|
|
||||||
# Cover path
|
# Cover path
|
||||||
self.coverEdit = QLineEdit(self)
|
self.coverEdit = QLineEdit(self)
|
||||||
self.coverEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
|
self.coverEdit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
|
||||||
if cover_path:
|
if cover_path:
|
||||||
self.coverEdit.setText(cover_path)
|
self.coverEdit.setText(cover_path)
|
||||||
coverBrowseButton = QPushButton(_("Browse..."), self)
|
coverBrowseButton = QPushButton(_("Browse..."), self)
|
||||||
|
@ -12,9 +12,42 @@ from collections.abc import Callable
|
|||||||
from portprotonqt.localization import get_egs_language, _
|
from portprotonqt.localization import get_egs_language, _
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
from portprotonqt.image_utils import load_pixmap_async
|
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.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
|
||||||
|
)
|
||||||
|
import vdf
|
||||||
|
import shutil
|
||||||
|
import zlib
|
||||||
|
from portprotonqt.downloader import Downloader
|
||||||
from PySide6.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
downloader = Downloader()
|
||||||
|
|
||||||
|
def get_egs_executable(app_name: str, legendary_config_path: str) -> str | None:
|
||||||
|
"""Получает путь к исполняемому файлу EGS-игры из installed.json с использованием orjson."""
|
||||||
|
installed_json_path = os.path.join(legendary_config_path, "installed.json")
|
||||||
|
try:
|
||||||
|
with open(installed_json_path, "rb") as f:
|
||||||
|
installed_data = orjson.loads(f.read())
|
||||||
|
if app_name in installed_data:
|
||||||
|
install_path = installed_data[app_name].get("install_path", "").decode('utf-8') if isinstance(installed_data[app_name].get("install_path"), bytes) else installed_data[app_name].get("install_path", "")
|
||||||
|
executable = installed_data[app_name].get("executable", "").decode('utf-8') if isinstance(installed_data[app_name].get("executable"), bytes) else installed_data[app_name].get("executable", "")
|
||||||
|
if install_path and executable:
|
||||||
|
return os.path.join(install_path, executable)
|
||||||
|
return None
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error(f"installed.json not found at {installed_json_path}")
|
||||||
|
return None
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
logger.error(f"Invalid JSON in {installed_json_path}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading installed.json: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
def get_cache_dir() -> Path:
|
def get_cache_dir() -> Path:
|
||||||
"""Returns the path to the cache directory, creating it if necessary."""
|
"""Returns the path to the cache directory, creating it if necessary."""
|
||||||
@ -26,6 +59,237 @@ def get_cache_dir() -> Path:
|
|||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return cache_dir
|
return cache_dir
|
||||||
|
|
||||||
|
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
||||||
|
"""
|
||||||
|
Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag.
|
||||||
|
Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
|
||||||
|
Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
|
||||||
|
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
|
||||||
|
Calls the callback with (success, message).
|
||||||
|
"""
|
||||||
|
if not app_name or not app_name.strip() or not game_title or not game_title.strip():
|
||||||
|
logger.error("Invalid app_name or game_title: empty or whitespace")
|
||||||
|
callback((False, "Game name or app name is empty or invalid"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if not os.path.exists(legendary_path):
|
||||||
|
logger.error(f"Legendary executable not found: {legendary_path}")
|
||||||
|
callback((False, f"Legendary executable not found: {legendary_path}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
portproton_dir = get_portproton_location()
|
||||||
|
if not portproton_dir:
|
||||||
|
logger.error("PortProton directory not found")
|
||||||
|
callback((False, "PortProton directory not found"))
|
||||||
|
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
|
||||||
|
|
||||||
|
# Create launch script
|
||||||
|
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
|
||||||
|
os.makedirs(steam_scripts_dir, exist_ok=True)
|
||||||
|
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_title.strip())
|
||||||
|
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}_egs.sh")
|
||||||
|
legendary_config_path = os.path.dirname(legendary_path)
|
||||||
|
|
||||||
|
script_content = f"""#!/usr/bin/env bash
|
||||||
|
export LD_PRELOAD=
|
||||||
|
export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||||
|
"{legendary_path}" launch {app_name} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}" "$@"
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(script_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(script_content)
|
||||||
|
os.chmod(script_path, 0o755)
|
||||||
|
logger.info(f"Created launch script for EGS game: {script_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create launch script {script_path}: {e}")
|
||||||
|
callback((False, f"Failed to create launch script: {e}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate thumbnail
|
||||||
|
generated_icon_path = os.path.join(portproton_dir, "data", "img", f"{safe_game_name}_egs.png")
|
||||||
|
try:
|
||||||
|
img_dir = os.path.join(portproton_dir, "data", "img")
|
||||||
|
os.makedirs(img_dir, exist_ok=True)
|
||||||
|
game_exe = get_egs_executable(app_name, legendary_config_path)
|
||||||
|
if not game_exe or not os.path.exists(game_exe):
|
||||||
|
logger.warning(f"Executable not found for {app_name}, skipping thumbnail generation")
|
||||||
|
icon_path = ""
|
||||||
|
elif os.path.exists(generated_icon_path):
|
||||||
|
logger.info(f"Reusing existing thumbnail: {generated_icon_path}")
|
||||||
|
icon_path = generated_icon_path
|
||||||
|
else:
|
||||||
|
success = generate_thumbnail(game_exe, generated_icon_path, size=128, force_resize=True)
|
||||||
|
if not success or not os.path.exists(generated_icon_path):
|
||||||
|
logger.warning(f"generate_thumbnail failed for {game_exe}")
|
||||||
|
icon_path = ""
|
||||||
|
else:
|
||||||
|
logger.info(f"Generated thumbnail: {generated_icon_path}")
|
||||||
|
icon_path = generated_icon_path
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating thumbnail for {app_name}: {e}")
|
||||||
|
icon_path = ""
|
||||||
|
|
||||||
|
# Get Steam directories
|
||||||
|
steam_home = get_steam_home()
|
||||||
|
if not steam_home:
|
||||||
|
logger.error("Steam home directory not found")
|
||||||
|
callback((False, "Steam directory not found"))
|
||||||
|
return
|
||||||
|
|
||||||
|
last_user = get_last_steam_user(steam_home)
|
||||||
|
if not last_user or 'SteamID' not in last_user:
|
||||||
|
logger.error("Failed to retrieve Steam user ID")
|
||||||
|
callback((False, "Failed to get Steam user ID"))
|
||||||
|
return
|
||||||
|
|
||||||
|
userdata_dir = steam_home / "userdata"
|
||||||
|
user_id = last_user['SteamID']
|
||||||
|
unsigned_id = convert_steam_id(user_id)
|
||||||
|
user_dir = userdata_dir / str(unsigned_id)
|
||||||
|
steam_shortcuts_path = user_dir / "config" / "shortcuts.vdf"
|
||||||
|
grid_dir = user_dir / "config" / "grid"
|
||||||
|
os.makedirs(grid_dir, exist_ok=True)
|
||||||
|
|
||||||
|
# Backup shortcuts.vdf
|
||||||
|
backup_path = f"{steam_shortcuts_path}.backup"
|
||||||
|
if os.path.exists(steam_shortcuts_path):
|
||||||
|
try:
|
||||||
|
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||||
|
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||||
|
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate unique appid
|
||||||
|
unique_string = f"{script_path}{game_title}"
|
||||||
|
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||||
|
appid = baseid | 0x80000000
|
||||||
|
if appid > 0x7FFFFFFF:
|
||||||
|
aidvdf = appid - 0x100000000
|
||||||
|
else:
|
||||||
|
aidvdf = appid
|
||||||
|
|
||||||
|
steam_appid = None
|
||||||
|
downloaded_count = 0
|
||||||
|
total_covers = 4
|
||||||
|
download_lock = threading.Lock()
|
||||||
|
|
||||||
|
def on_cover_download(cover_file: str, cover_type: str):
|
||||||
|
nonlocal downloaded_count
|
||||||
|
try:
|
||||||
|
if cover_file and os.path.exists(cover_file):
|
||||||
|
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
|
||||||
|
with download_lock:
|
||||||
|
downloaded_count += 1
|
||||||
|
if downloaded_count == total_covers:
|
||||||
|
finalize_shortcut()
|
||||||
|
|
||||||
|
def finalize_shortcut():
|
||||||
|
tags_dict = {'0': 'PortProton'}
|
||||||
|
shortcut = {
|
||||||
|
"appid": aidvdf,
|
||||||
|
"AppName": game_title,
|
||||||
|
"Exe": f'"{script_path}"',
|
||||||
|
"StartDir": f'"{os.path.dirname(script_path)}"',
|
||||||
|
"icon": icon_path,
|
||||||
|
"LaunchOptions": "",
|
||||||
|
"IsHidden": 0,
|
||||||
|
"AllowDesktopConfig": 1,
|
||||||
|
"AllowOverlay": 1,
|
||||||
|
"openvr": 0,
|
||||||
|
"Devkit": 0,
|
||||||
|
"DevkitGameID": "",
|
||||||
|
"LastPlayTime": 0,
|
||||||
|
"tags": tags_dict
|
||||||
|
}
|
||||||
|
logger.info(f"Shortcut entry for EGS game: {shortcut}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if not os.path.exists(steam_shortcuts_path):
|
||||||
|
os.makedirs(os.path.dirname(steam_shortcuts_path), exist_ok=True)
|
||||||
|
open(steam_shortcuts_path, 'wb').close()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os.path.getsize(steam_shortcuts_path) > 0:
|
||||||
|
with open(steam_shortcuts_path, 'rb') as f:
|
||||||
|
shortcuts_data = vdf.binary_load(f)
|
||||||
|
else:
|
||||||
|
shortcuts_data = {"shortcuts": {}}
|
||||||
|
except Exception as load_err:
|
||||||
|
logger.warning(f"Failed to load shortcuts.vdf, starting fresh: {load_err}")
|
||||||
|
shortcuts_data = {"shortcuts": {}}
|
||||||
|
|
||||||
|
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||||
|
for _key, entry in shortcuts.items():
|
||||||
|
if entry.get("AppName") == game_title and entry.get("Exe") == f'"{script_path}"':
|
||||||
|
logger.info(f"EGS game '{game_title}' already exists in Steam shortcuts")
|
||||||
|
callback((False, f"Game '{game_title}' already exists in Steam"))
|
||||||
|
return
|
||||||
|
|
||||||
|
new_index = str(len(shortcuts))
|
||||||
|
shortcuts[new_index] = shortcut
|
||||||
|
|
||||||
|
with open(steam_shortcuts_path, 'wb') as f:
|
||||||
|
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||||
|
if os.path.exists(backup_path):
|
||||||
|
try:
|
||||||
|
shutil.copy2(backup_path, steam_shortcuts_path)
|
||||||
|
logger.info("Restored shortcuts.vdf from backup")
|
||||||
|
except Exception as restore_err:
|
||||||
|
logger.error(f"Failed to restore shortcuts.vdf: {restore_err}")
|
||||||
|
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"EGS game '{game_title}' added to Steam")
|
||||||
|
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||||
|
|
||||||
|
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||||
|
nonlocal steam_appid
|
||||||
|
steam_apps, steam_apps_index = steam_data
|
||||||
|
matching_app = search_app(game_title, steam_apps_index)
|
||||||
|
steam_appid = matching_app.get("appid") if matching_app else None
|
||||||
|
|
||||||
|
if not steam_appid:
|
||||||
|
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
|
||||||
|
finalize_shortcut()
|
||||||
|
return
|
||||||
|
|
||||||
|
cover_types = [
|
||||||
|
(".jpg", "header.jpg"),
|
||||||
|
("p.jpg", "library_600x900_2x.jpg"),
|
||||||
|
("_hero.jpg", "library_hero.jpg"),
|
||||||
|
("_logo.png", "logo.png")
|
||||||
|
]
|
||||||
|
|
||||||
|
for suffix, cover_type in cover_types:
|
||||||
|
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||||
|
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
|
||||||
|
downloader.download_async(
|
||||||
|
cover_url,
|
||||||
|
cover_file,
|
||||||
|
timeout=5,
|
||||||
|
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
|
||||||
|
)
|
||||||
|
|
||||||
|
get_steam_apps_and_index_async(on_steam_apps)
|
||||||
|
|
||||||
def get_egs_game_description_async(
|
def get_egs_game_description_async(
|
||||||
app_name: str,
|
app_name: str,
|
||||||
callback: Callable[[str], None],
|
callback: Callable[[str], None],
|
||||||
@ -281,6 +545,7 @@ def get_egs_game_description_async(
|
|||||||
|
|
||||||
thread = threading.Thread(target=fetch_description, daemon=True)
|
thread = threading.Thread(target=fetch_description, daemon=True)
|
||||||
thread.start()
|
thread.start()
|
||||||
|
|
||||||
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
|
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
|
||||||
"""
|
"""
|
||||||
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
|
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
|
||||||
@ -326,6 +591,8 @@ def run_legendary_list_async(legendary_path: str, callback: Callable[[list | Non
|
|||||||
def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]], None], downloader, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
|
def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]], None], downloader, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
|
||||||
"""
|
"""
|
||||||
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
|
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
|
||||||
|
Читает статистику времени игры и последнего запуска из файла statistics.
|
||||||
|
Проверяет наличие игры в Steam для получения ProtonDB статуса.
|
||||||
"""
|
"""
|
||||||
logger.debug("Starting to load Epic Games Store games")
|
logger.debug("Starting to load Epic Games Store games")
|
||||||
games: list[tuple] = []
|
games: list[tuple] = []
|
||||||
@ -334,6 +601,14 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
|
|||||||
cache_file = cache_dir / "legendary_games.json"
|
cache_file = cache_dir / "legendary_games.json"
|
||||||
cache_ttl = 3600 # Cache TTL in seconds (1 hour)
|
cache_ttl = 3600 # Cache TTL in seconds (1 hour)
|
||||||
|
|
||||||
|
# Путь к файлу statistics
|
||||||
|
portproton_location = get_portproton_location()
|
||||||
|
if portproton_location is None:
|
||||||
|
logger.error("PortProton location is not set, cannot locate statistics file")
|
||||||
|
statistics_file = ""
|
||||||
|
else:
|
||||||
|
statistics_file = os.path.join(portproton_location, "data", "tmp", "statistics")
|
||||||
|
|
||||||
if not os.path.exists(legendary_path):
|
if not os.path.exists(legendary_path):
|
||||||
logger.info("Legendary binary not found, downloading...")
|
logger.info("Legendary binary not found, downloading...")
|
||||||
def on_legendary_downloaded(result):
|
def on_legendary_downloaded(result):
|
||||||
@ -345,7 +620,7 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
|
|||||||
logger.error(f"Failed to make legendary binary executable: {e}")
|
logger.error(f"Failed to make legendary binary executable: {e}")
|
||||||
callback(games) # Return empty games list on failure
|
callback(games) # Return empty games list on failure
|
||||||
return
|
return
|
||||||
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
|
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message, statistics_file)
|
||||||
else:
|
else:
|
||||||
logger.error("Failed to download legendary binary")
|
logger.error("Failed to download legendary binary")
|
||||||
callback(games) # Return empty games list on failure
|
callback(games) # Return empty games list on failure
|
||||||
@ -356,9 +631,9 @@ def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]],
|
|||||||
callback(games)
|
callback(games)
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
|
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message, statistics_file)
|
||||||
|
|
||||||
def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tuple]], None], metadata_dir: Path, cache_dir: Path, cache_file: Path, cache_ttl: int, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
|
def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tuple]], None], metadata_dir: Path, cache_dir: Path, cache_file: Path, cache_ttl: int, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None], statistics_file: str):
|
||||||
"""
|
"""
|
||||||
Продолжает процесс загрузки EGS игр, либо из кэша, либо через legendary CLI.
|
Продолжает процесс загрузки EGS игр, либо из кэша, либо через legendary CLI.
|
||||||
"""
|
"""
|
||||||
@ -410,6 +685,33 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
|||||||
callback(final_games)
|
callback(final_games)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Получаем путь к .exe для извлечения имени
|
||||||
|
game_exe = get_egs_executable(app_name, os.path.dirname(legendary_path))
|
||||||
|
exe_name = ""
|
||||||
|
if game_exe:
|
||||||
|
exe_name = os.path.splitext(os.path.basename(game_exe))[0]
|
||||||
|
|
||||||
|
# Читаем статистику из файла statistics
|
||||||
|
playtime_seconds = 0
|
||||||
|
formatted_playtime = ""
|
||||||
|
last_launch = _("Never")
|
||||||
|
last_launch_timestamp = 0
|
||||||
|
if exe_name and os.path.exists(statistics_file):
|
||||||
|
try:
|
||||||
|
playtime_data = parse_playtime_file(statistics_file)
|
||||||
|
matching_key = next(
|
||||||
|
(key for key in playtime_data if os.path.basename(key).split('.')[0] == exe_name),
|
||||||
|
None
|
||||||
|
)
|
||||||
|
if matching_key:
|
||||||
|
playtime_seconds = playtime_data[matching_key]
|
||||||
|
formatted_playtime = format_playtime(playtime_seconds)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to parse playtime data for {app_name}: {e}")
|
||||||
|
if exe_name:
|
||||||
|
last_launch = get_last_launch(exe_name) or _("Never")
|
||||||
|
last_launch_timestamp = get_last_launch_timestamp(exe_name)
|
||||||
|
|
||||||
metadata_file = metadata_dir / f"{app_name}.json"
|
metadata_file = metadata_dir / f"{app_name}.json"
|
||||||
cover_url = ""
|
cover_url = ""
|
||||||
try:
|
try:
|
||||||
@ -426,11 +728,16 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
|||||||
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
|
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQt", "images")
|
||||||
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
|
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
|
||||||
|
|
||||||
|
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||||
|
steam_apps, steam_apps_index = steam_data
|
||||||
|
matching_app = search_app(title, steam_apps_index)
|
||||||
|
steam_appid = matching_app.get("appid") if matching_app else None
|
||||||
|
|
||||||
|
def on_protondb_tier(protondb_tier: str):
|
||||||
def on_description_fetched(api_description: str):
|
def on_description_fetched(api_description: str):
|
||||||
final_description = api_description or _("No description available")
|
final_description = api_description or _("No description available")
|
||||||
|
|
||||||
def on_cover_loaded(pixmap: QPixmap):
|
def on_cover_loaded(pixmap: QPixmap):
|
||||||
from portprotonqt.steam_api import get_weanticheatyet_status_async
|
|
||||||
def on_anticheat_status(status: str):
|
def on_anticheat_status(status: str):
|
||||||
nonlocal pending_images
|
nonlocal pending_images
|
||||||
with results_lock:
|
with results_lock:
|
||||||
@ -441,12 +748,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
|||||||
app_name,
|
app_name,
|
||||||
f"legendary:launch:{app_name}",
|
f"legendary:launch:{app_name}",
|
||||||
"",
|
"",
|
||||||
_("Never"),
|
last_launch, # Время последнего запуска
|
||||||
"",
|
formatted_playtime, # Форматированное время игры
|
||||||
"",
|
protondb_tier, # ProtonDB tier
|
||||||
status or "",
|
status or "",
|
||||||
0,
|
last_launch_timestamp, # Временная метка последнего запуска
|
||||||
0,
|
playtime_seconds, # Время игры в секундах
|
||||||
"epic"
|
"epic"
|
||||||
)
|
)
|
||||||
pending_images -= 1
|
pending_images -= 1
|
||||||
@ -461,6 +768,15 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
|||||||
|
|
||||||
get_egs_game_description_async(title, on_description_fetched)
|
get_egs_game_description_async(title, on_description_fetched)
|
||||||
|
|
||||||
|
if steam_appid:
|
||||||
|
logger.info(f"Found Steam appid {steam_appid} for EGS game {title}")
|
||||||
|
get_protondb_tier_async(steam_appid, on_protondb_tier)
|
||||||
|
else:
|
||||||
|
logger.debug(f"No Steam app found for EGS game {title}")
|
||||||
|
on_protondb_tier("") # Proceed with empty ProtonDB tier
|
||||||
|
|
||||||
|
get_steam_apps_and_index_async(on_steam_apps)
|
||||||
|
|
||||||
max_workers = min(4, len(valid_games))
|
max_workers = min(4, len(valid_games))
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
for i, game in enumerate(valid_games):
|
for i, game in enumerate(valid_games):
|
||||||
|
@ -25,6 +25,8 @@ class GameCard(QFrame):
|
|||||||
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path
|
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path
|
||||||
removeFromSteamRequested = Signal(str, str) # name, exec_line
|
removeFromSteamRequested = Signal(str, str) # name, exec_line
|
||||||
openGameFolderRequested = Signal(str, str) # name, exec_line
|
openGameFolderRequested = Signal(str, str) # name, exec_line
|
||||||
|
hoverChanged = Signal(str, bool)
|
||||||
|
focusChanged = Signal(str, bool)
|
||||||
|
|
||||||
def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
|
def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
|
||||||
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
|
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
|
||||||
@ -43,6 +45,7 @@ class GameCard(QFrame):
|
|||||||
self.game_source = game_source
|
self.game_source = game_source
|
||||||
self.last_launch_ts = last_launch_ts
|
self.last_launch_ts = last_launch_ts
|
||||||
self.playtime_seconds = playtime_seconds
|
self.playtime_seconds = playtime_seconds
|
||||||
|
self.card_width = card_width
|
||||||
|
|
||||||
self.select_callback = select_callback
|
self.select_callback = select_callback
|
||||||
self.context_menu_manager = context_menu_manager
|
self.context_menu_manager = context_menu_manager
|
||||||
@ -54,6 +57,10 @@ class GameCard(QFrame):
|
|||||||
self.display_filter = read_display_filter()
|
self.display_filter = read_display_filter()
|
||||||
self.current_theme_name = read_theme_from_config()
|
self.current_theme_name = read_theme_from_config()
|
||||||
|
|
||||||
|
self.steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
||||||
|
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
||||||
|
self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
|
||||||
|
|
||||||
# Дополнительное пространство для анимации
|
# Дополнительное пространство для анимации
|
||||||
extra_margin = 20
|
extra_margin = 20
|
||||||
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
|
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
|
||||||
@ -61,14 +68,14 @@ class GameCard(QFrame):
|
|||||||
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
||||||
|
|
||||||
# Параметры анимации обводки
|
# Параметры анимации обводки
|
||||||
self._borderWidth = 2
|
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||||
self._gradientAngle = 0.0
|
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||||
self._hovered = False
|
self._hovered = False
|
||||||
self._focused = False
|
self._focused = False
|
||||||
|
|
||||||
# Анимации
|
# Анимации
|
||||||
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||||
self.thickness_anim.setDuration(300)
|
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||||
self.gradient_anim = None
|
self.gradient_anim = None
|
||||||
self.pulse_anim = None
|
self.pulse_anim = None
|
||||||
|
|
||||||
@ -121,9 +128,11 @@ class GameCard(QFrame):
|
|||||||
self.update_favorite_icon()
|
self.update_favorite_icon()
|
||||||
self.favoriteLabel.raise_()
|
self.favoriteLabel.raise_()
|
||||||
|
|
||||||
steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
# Определяем общие параметры для бейджей
|
||||||
egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
badge_width = int(card_width * 2/3)
|
||||||
portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
|
icon_size = int(card_width * 0.06) # 6% от ширины карточки
|
||||||
|
icon_space = int(card_width * 0.012) # 1.2% от ширины карточки
|
||||||
|
font_scale_factor = 0.06 # Шрифт будет 6% от card_width
|
||||||
|
|
||||||
# ProtonDB бейдж
|
# ProtonDB бейдж
|
||||||
tier_text = self.getProtonDBText(protondb_tier)
|
tier_text = self.getProtonDBText(protondb_tier)
|
||||||
@ -134,17 +143,17 @@ class GameCard(QFrame):
|
|||||||
tier_text,
|
tier_text,
|
||||||
icon=icon,
|
icon=icon,
|
||||||
parent=coverWidget,
|
parent=coverWidget,
|
||||||
icon_size=16,
|
icon_size=icon_size,
|
||||||
icon_space=3,
|
icon_space=icon_space,
|
||||||
|
font_scale_factor=font_scale_factor
|
||||||
)
|
)
|
||||||
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
|
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
|
||||||
self.protondbLabel.setFixedWidth(int(card_width * 2/3))
|
self.protondbLabel.setFixedWidth(badge_width)
|
||||||
protondb_visible = True
|
self.protondbLabel.setCardWidth(card_width)
|
||||||
else:
|
else:
|
||||||
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
|
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
|
||||||
self.protondbLabel.setFixedWidth(int(card_width * 2/3))
|
self.protondbLabel.setFixedWidth(badge_width)
|
||||||
self.protondbLabel.setVisible(False)
|
self.protondbLabel.setVisible(False)
|
||||||
protondb_visible = False
|
|
||||||
|
|
||||||
# Steam бейдж
|
# Steam бейдж
|
||||||
steam_icon = self.theme_manager.get_icon("steam")
|
steam_icon = self.theme_manager.get_icon("steam")
|
||||||
@ -152,40 +161,46 @@ class GameCard(QFrame):
|
|||||||
"Steam",
|
"Steam",
|
||||||
icon=steam_icon,
|
icon=steam_icon,
|
||||||
parent=coverWidget,
|
parent=coverWidget,
|
||||||
icon_size=16,
|
icon_size=icon_size,
|
||||||
icon_space=5,
|
icon_space=icon_space,
|
||||||
|
font_scale_factor=font_scale_factor
|
||||||
)
|
)
|
||||||
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
self.steamLabel.setFixedWidth(int(card_width * 2/3))
|
self.steamLabel.setFixedWidth(badge_width)
|
||||||
self.steamLabel.setVisible(steam_visible)
|
self.steamLabel.setCardWidth(card_width)
|
||||||
|
self.steamLabel.setVisible(self.steam_visible)
|
||||||
|
|
||||||
# Epic Games Store бейдж
|
# Epic Games Store бейдж
|
||||||
egs_icon = self.theme_manager.get_icon("steam")
|
egs_icon = self.theme_manager.get_icon("epic_games")
|
||||||
self.egsLabel = ClickableLabel(
|
self.egsLabel = ClickableLabel(
|
||||||
"Epic Games",
|
"Epic Games",
|
||||||
icon=egs_icon,
|
icon=egs_icon,
|
||||||
parent=coverWidget,
|
parent=coverWidget,
|
||||||
icon_size=16,
|
icon_size=icon_size,
|
||||||
icon_space=5,
|
icon_space=icon_space,
|
||||||
|
font_scale_factor=font_scale_factor,
|
||||||
change_cursor=False
|
change_cursor=False
|
||||||
)
|
)
|
||||||
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
self.egsLabel.setFixedWidth(int(card_width * 2/3))
|
self.egsLabel.setFixedWidth(badge_width)
|
||||||
self.egsLabel.setVisible(egs_visible)
|
self.egsLabel.setCardWidth(card_width)
|
||||||
|
self.egsLabel.setVisible(self.egs_visible)
|
||||||
|
|
||||||
# PortProton badge
|
# PortProton бейдж
|
||||||
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
|
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
|
||||||
self.portprotonLabel = ClickableLabel(
|
self.portprotonLabel = ClickableLabel(
|
||||||
"PortProton",
|
"PortProton",
|
||||||
icon=portproton_icon,
|
icon=portproton_icon,
|
||||||
parent=coverWidget,
|
parent=coverWidget,
|
||||||
icon_size=16,
|
icon_size=icon_size,
|
||||||
icon_space=5,
|
icon_space=icon_space,
|
||||||
|
font_scale_factor=font_scale_factor,
|
||||||
change_cursor=False
|
change_cursor=False
|
||||||
)
|
)
|
||||||
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
self.portprotonLabel.setFixedWidth(int(card_width * 2/3))
|
self.portprotonLabel.setFixedWidth(badge_width)
|
||||||
self.portprotonLabel.setVisible(portproton_visible)
|
self.portprotonLabel.setCardWidth(card_width)
|
||||||
|
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||||
|
|
||||||
# WeAntiCheatYet бейдж
|
# WeAntiCheatYet бейдж
|
||||||
anticheat_text = self.getAntiCheatText(anticheat_status)
|
anticheat_text = self.getAntiCheatText(anticheat_status)
|
||||||
@ -196,53 +211,20 @@ class GameCard(QFrame):
|
|||||||
anticheat_text,
|
anticheat_text,
|
||||||
icon=icon,
|
icon=icon,
|
||||||
parent=coverWidget,
|
parent=coverWidget,
|
||||||
icon_size=16,
|
icon_size=icon_size,
|
||||||
icon_space=3,
|
icon_space=icon_space,
|
||||||
|
font_scale_factor=font_scale_factor
|
||||||
)
|
)
|
||||||
self.anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
|
||||||
self.anticheatLabel.setFixedWidth(int(card_width * 2/3))
|
self.anticheatLabel.setFixedWidth(badge_width)
|
||||||
anticheat_visible = True
|
self.anticheatLabel.setCardWidth(card_width)
|
||||||
else:
|
else:
|
||||||
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
|
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
|
||||||
self.anticheatLabel.setFixedWidth(int(card_width * 2/3))
|
self.anticheatLabel.setFixedWidth(badge_width)
|
||||||
self.anticheatLabel.setVisible(False)
|
self.anticheatLabel.setVisible(False)
|
||||||
anticheat_visible = False
|
|
||||||
|
|
||||||
# Расположение бейджей
|
# Расположение бейджей
|
||||||
right_margin = 8
|
self._position_badges(card_width)
|
||||||
badge_spacing = 5
|
|
||||||
top_y = 10
|
|
||||||
badge_y_positions = []
|
|
||||||
badge_width = int(card_width * 2/3)
|
|
||||||
if steam_visible:
|
|
||||||
steam_x = card_width - badge_width - right_margin
|
|
||||||
self.steamLabel.move(steam_x, top_y)
|
|
||||||
badge_y_positions.append(top_y + self.steamLabel.height())
|
|
||||||
if egs_visible:
|
|
||||||
egs_x = card_width - badge_width - right_margin
|
|
||||||
egs_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
|
||||||
self.egsLabel.move(egs_x, egs_y)
|
|
||||||
badge_y_positions.append(egs_y + self.egsLabel.height())
|
|
||||||
if portproton_visible:
|
|
||||||
portproton_x = card_width - badge_width - right_margin
|
|
||||||
portproton_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
|
||||||
self.portprotonLabel.move(portproton_x, portproton_y)
|
|
||||||
badge_y_positions.append(portproton_y + self.portprotonLabel.height())
|
|
||||||
if protondb_visible:
|
|
||||||
protondb_x = card_width - badge_width - right_margin
|
|
||||||
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
|
||||||
self.protondbLabel.move(protondb_x, protondb_y)
|
|
||||||
badge_y_positions.append(protondb_y + self.protondbLabel.height())
|
|
||||||
if anticheat_visible:
|
|
||||||
anticheat_x = card_width - badge_width - right_margin
|
|
||||||
anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
|
||||||
self.anticheatLabel.move(anticheat_x, anticheat_y)
|
|
||||||
|
|
||||||
self.anticheatLabel.raise_()
|
|
||||||
self.protondbLabel.raise_()
|
|
||||||
self.portprotonLabel.raise_()
|
|
||||||
self.egsLabel.raise_()
|
|
||||||
self.steamLabel.raise_()
|
|
||||||
self.protondbLabel.clicked.connect(self.open_protondb_report)
|
self.protondbLabel.clicked.connect(self.open_protondb_report)
|
||||||
self.steamLabel.clicked.connect(self.open_steam_page)
|
self.steamLabel.clicked.connect(self.open_steam_page)
|
||||||
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
|
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
|
||||||
@ -255,8 +237,79 @@ class GameCard(QFrame):
|
|||||||
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||||
layout.addWidget(nameLabel)
|
layout.addWidget(nameLabel)
|
||||||
|
|
||||||
|
def _position_badges(self, card_width):
|
||||||
|
"""Позиционирует бейджи на основе ширины карточки."""
|
||||||
|
right_margin = 8
|
||||||
|
badge_spacing = int(card_width * 0.02) # 2% от ширины карточки
|
||||||
|
top_y = 10
|
||||||
|
badge_y_positions = []
|
||||||
|
badge_width = int(card_width * 2/3)
|
||||||
|
|
||||||
|
badges = [
|
||||||
|
(self.steam_visible, self.steamLabel),
|
||||||
|
(self.egs_visible, self.egsLabel),
|
||||||
|
(self.portproton_visible, self.portprotonLabel),
|
||||||
|
(bool(self.getProtonDBText(self.protondb_tier)), self.protondbLabel),
|
||||||
|
(bool(self.getAntiCheatText(self.anticheat_status)), self.anticheatLabel),
|
||||||
|
]
|
||||||
|
|
||||||
|
for is_visible, badge in badges:
|
||||||
|
if is_visible:
|
||||||
|
badge_x = card_width - badge_width - right_margin
|
||||||
|
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||||
|
badge.move(badge_x, badge_y)
|
||||||
|
badge_y_positions.append(badge_y + badge.height())
|
||||||
|
|
||||||
|
# Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
|
||||||
|
self.anticheatLabel.raise_()
|
||||||
|
self.protondbLabel.raise_()
|
||||||
|
self.portprotonLabel.raise_()
|
||||||
|
self.egsLabel.raise_()
|
||||||
|
self.steamLabel.raise_()
|
||||||
|
|
||||||
|
def update_card_size(self, new_width: int):
|
||||||
|
"""Обновляет размер карточки, обложки и бейджей."""
|
||||||
|
self.card_width = new_width
|
||||||
|
extra_margin = 20
|
||||||
|
self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin)
|
||||||
|
|
||||||
|
if self.coverLabel is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
coverWidget = self.coverLabel.parentWidget()
|
||||||
|
if coverWidget is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
coverWidget.setFixedSize(new_width, int(new_width * 1.2))
|
||||||
|
self.coverLabel.setFixedSize(new_width, int(new_width * 1.2))
|
||||||
|
|
||||||
|
label_ref = weakref.ref(self.coverLabel)
|
||||||
|
def on_cover_loaded(pixmap):
|
||||||
|
label = label_ref()
|
||||||
|
if label:
|
||||||
|
scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
|
||||||
|
rounded_pixmap = round_corners(scaled_pixmap, 15)
|
||||||
|
label.setPixmap(rounded_pixmap)
|
||||||
|
|
||||||
|
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded)
|
||||||
|
|
||||||
|
# Обновляем размеры и шрифты бейджей
|
||||||
|
badge_width = int(new_width * 2/3)
|
||||||
|
icon_size = int(new_width * 0.06)
|
||||||
|
icon_space = int(new_width * 0.012)
|
||||||
|
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
|
||||||
|
if label is not None:
|
||||||
|
label.setFixedWidth(badge_width)
|
||||||
|
label.setIconSize(icon_size, icon_space)
|
||||||
|
label.setCardWidth(new_width) # Пересчитываем размер шрифта
|
||||||
|
|
||||||
|
# Перепозиционируем бейджи
|
||||||
|
self._position_badges(new_width)
|
||||||
|
|
||||||
|
self.update()
|
||||||
|
|
||||||
def update_badge_visibility(self, display_filter: str):
|
def update_badge_visibility(self, display_filter: str):
|
||||||
"""Update badge visibility based on the provided display_filter."""
|
"""Обновляет видимость бейджей на основе display_filter."""
|
||||||
self.display_filter = display_filter
|
self.display_filter = display_filter
|
||||||
self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites"))
|
self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites"))
|
||||||
self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites"))
|
self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites"))
|
||||||
@ -271,35 +324,8 @@ class GameCard(QFrame):
|
|||||||
self.protondbLabel.setVisible(protondb_visible)
|
self.protondbLabel.setVisible(protondb_visible)
|
||||||
self.anticheatLabel.setVisible(anticheat_visible)
|
self.anticheatLabel.setVisible(anticheat_visible)
|
||||||
|
|
||||||
# Подготавливаем список всех бейджей с их текущей видимостью
|
# Перепозиционируем бейджи
|
||||||
badges = [
|
self._position_badges(self.card_width)
|
||||||
(self.steam_visible, self.steamLabel),
|
|
||||||
(self.egs_visible, self.egsLabel),
|
|
||||||
(self.portproton_visible, self.portprotonLabel),
|
|
||||||
(protondb_visible, self.protondbLabel),
|
|
||||||
(anticheat_visible, self.anticheatLabel),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Пересчитываем позиции бейджей
|
|
||||||
right_margin = 8
|
|
||||||
badge_spacing = 5
|
|
||||||
top_y = 10
|
|
||||||
badge_y_positions = []
|
|
||||||
badge_width = int(self.coverLabel.width() * 2/3)
|
|
||||||
|
|
||||||
for is_visible, badge in badges:
|
|
||||||
if is_visible:
|
|
||||||
badge_x = self.coverLabel.width() - badge_width - right_margin
|
|
||||||
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
|
||||||
badge.move(badge_x, badge_y)
|
|
||||||
badge_y_positions.append(badge_y + badge.height())
|
|
||||||
|
|
||||||
# Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
|
|
||||||
self.anticheatLabel.raise_()
|
|
||||||
self.protondbLabel.raise_()
|
|
||||||
self.portprotonLabel.raise_()
|
|
||||||
self.egsLabel.raise_()
|
|
||||||
self.steamLabel.raise_()
|
|
||||||
|
|
||||||
def _show_context_menu(self, pos):
|
def _show_context_menu(self, pos):
|
||||||
"""Delegate context menu display to ContextMenuManager."""
|
"""Delegate context menu display to ContextMenuManager."""
|
||||||
@ -322,10 +348,16 @@ class GameCard(QFrame):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def getAntiCheatIconFilename(status: str) -> str:
|
def getAntiCheatIconFilename(status: str) -> str:
|
||||||
status = status.lower()
|
status = status.lower()
|
||||||
if status in ("supported", "running"):
|
if status in ("supported"):
|
||||||
return "platinum-gold"
|
return "ac_supported"
|
||||||
elif status in ("denied", "planned", "broken"):
|
elif status in ("running"):
|
||||||
return "broken"
|
return "ac_running"
|
||||||
|
elif status in ("planned"):
|
||||||
|
return "ac_planned"
|
||||||
|
elif status in ("denied"):
|
||||||
|
return "ac_denied"
|
||||||
|
elif status in ("broken"):
|
||||||
|
return "ac_broken"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@ -417,10 +449,8 @@ class GameCard(QFrame):
|
|||||||
if self._hovered or self._focused:
|
if self._hovered or self._focused:
|
||||||
center = self.rect().center()
|
center = self.rect().center()
|
||||||
gradient = QConicalGradient(center, self._gradientAngle)
|
gradient = QConicalGradient(center, self._gradientAngle)
|
||||||
gradient.setColorAt(0, QColor("#00fff5"))
|
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||||
gradient.setColorAt(0.33, QColor("#FF5733"))
|
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||||
gradient.setColorAt(0.66, QColor("#9B59B6"))
|
|
||||||
gradient.setColorAt(1, QColor("#00fff5"))
|
|
||||||
pen.setBrush(QBrush(gradient))
|
pen.setBrush(QBrush(gradient))
|
||||||
else:
|
else:
|
||||||
pen.setColor(QColor(0, 0, 0, 0))
|
pen.setColor(QColor(0, 0, 0, 0))
|
||||||
@ -437,22 +467,25 @@ class GameCard(QFrame):
|
|||||||
if self.pulse_anim:
|
if self.pulse_anim:
|
||||||
self.pulse_anim.stop()
|
self.pulse_anim.stop()
|
||||||
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||||
self.pulse_anim.setDuration(800)
|
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||||
self.pulse_anim.setLoopCount(0)
|
self.pulse_anim.setLoopCount(0)
|
||||||
self.pulse_anim.setKeyValueAt(0, 8)
|
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||||
self.pulse_anim.setKeyValueAt(0.5, 10)
|
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||||
self.pulse_anim.setKeyValueAt(1, 8)
|
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||||
self.pulse_anim.start()
|
self.pulse_anim.start()
|
||||||
|
|
||||||
def enterEvent(self, event):
|
def enterEvent(self, event):
|
||||||
self._hovered = True
|
self._hovered = True
|
||||||
|
self.hoverChanged.emit(self.name, True)
|
||||||
|
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||||
|
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
|
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||||
self.thickness_anim.setStartValue(self._borderWidth)
|
self.thickness_anim.setStartValue(self._borderWidth)
|
||||||
self.thickness_anim.setEndValue(8)
|
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
|
||||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||||
self._isPulseAnimationConnected = True
|
self._isPulseAnimationConnected = True
|
||||||
self.thickness_anim.start()
|
self.thickness_anim.start()
|
||||||
@ -460,9 +493,9 @@ class GameCard(QFrame):
|
|||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||||
self.gradient_anim.setDuration(3000)
|
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||||
self.gradient_anim.setStartValue(360)
|
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||||
self.gradient_anim.setEndValue(0)
|
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||||
self.gradient_anim.setLoopCount(-1)
|
self.gradient_anim.setLoopCount(-1)
|
||||||
self.gradient_anim.start()
|
self.gradient_anim.start()
|
||||||
|
|
||||||
@ -470,33 +503,37 @@ class GameCard(QFrame):
|
|||||||
|
|
||||||
def leaveEvent(self, event):
|
def leaveEvent(self, event):
|
||||||
self._hovered = False
|
self._hovered = False
|
||||||
if not self._focused: # Сохраняем анимацию, если есть фокус
|
self.hoverChanged.emit(self.name, False)
|
||||||
|
if not self._focused:
|
||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
self.gradient_anim = None
|
self.gradient_anim = None
|
||||||
self.thickness_anim.stop()
|
|
||||||
if self._isPulseAnimationConnected:
|
|
||||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
|
||||||
self._isPulseAnimationConnected = False
|
|
||||||
if self.pulse_anim:
|
if self.pulse_anim:
|
||||||
self.pulse_anim.stop()
|
self.pulse_anim.stop()
|
||||||
self.pulse_anim = None
|
self.pulse_anim = None
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
|
if self.thickness_anim:
|
||||||
self.thickness_anim.setStartValue(self._borderWidth)
|
|
||||||
self.thickness_anim.setEndValue(2)
|
|
||||||
self.thickness_anim.start()
|
|
||||||
|
|
||||||
super().leaveEvent(event)
|
|
||||||
|
|
||||||
def focusInEvent(self, event):
|
|
||||||
self._focused = True
|
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
|
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||||
self.thickness_anim.setStartValue(self._borderWidth)
|
self.thickness_anim.setStartValue(self._borderWidth)
|
||||||
self.thickness_anim.setEndValue(12)
|
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||||
|
self.thickness_anim.start()
|
||||||
|
super().leaveEvent(event)
|
||||||
|
|
||||||
|
def focusInEvent(self, event):
|
||||||
|
if not self._hovered:
|
||||||
|
self._focused = True
|
||||||
|
self.focusChanged.emit(self.name, True)
|
||||||
|
|
||||||
|
self.thickness_anim.stop()
|
||||||
|
if self._isPulseAnimationConnected:
|
||||||
|
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||||
|
self._isPulseAnimationConnected = False
|
||||||
|
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||||
|
self.thickness_anim.setStartValue(self._borderWidth)
|
||||||
|
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
|
||||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||||
self._isPulseAnimationConnected = True
|
self._isPulseAnimationConnected = True
|
||||||
self.thickness_anim.start()
|
self.thickness_anim.start()
|
||||||
@ -504,9 +541,9 @@ class GameCard(QFrame):
|
|||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||||
self.gradient_anim.setDuration(3000)
|
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||||
self.gradient_anim.setStartValue(360)
|
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||||
self.gradient_anim.setEndValue(0)
|
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||||
self.gradient_anim.setLoopCount(-1)
|
self.gradient_anim.setLoopCount(-1)
|
||||||
self.gradient_anim.start()
|
self.gradient_anim.start()
|
||||||
|
|
||||||
@ -514,22 +551,23 @@ class GameCard(QFrame):
|
|||||||
|
|
||||||
def focusOutEvent(self, event):
|
def focusOutEvent(self, event):
|
||||||
self._focused = False
|
self._focused = False
|
||||||
if not self._hovered: # Сохраняем анимацию, если есть наведение
|
self.focusChanged.emit(self.name, False)
|
||||||
|
if not self._hovered:
|
||||||
if self.gradient_anim:
|
if self.gradient_anim:
|
||||||
self.gradient_anim.stop()
|
self.gradient_anim.stop()
|
||||||
self.gradient_anim = None
|
self.gradient_anim = None
|
||||||
|
if self.pulse_anim:
|
||||||
|
self.pulse_anim.stop()
|
||||||
|
self.pulse_anim = None
|
||||||
|
if self.thickness_anim:
|
||||||
self.thickness_anim.stop()
|
self.thickness_anim.stop()
|
||||||
if self._isPulseAnimationConnected:
|
if self._isPulseAnimationConnected:
|
||||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||||
self._isPulseAnimationConnected = False
|
self._isPulseAnimationConnected = False
|
||||||
if self.pulse_anim:
|
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||||
self.pulse_anim.stop()
|
|
||||||
self.pulse_anim = None
|
|
||||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
|
|
||||||
self.thickness_anim.setStartValue(self._borderWidth)
|
self.thickness_anim.setStartValue(self._borderWidth)
|
||||||
self.thickness_anim.setEndValue(2)
|
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||||
self.thickness_anim.start()
|
self.thickness_anim.start()
|
||||||
|
|
||||||
super().focusOutEvent(event)
|
super().focusOutEvent(event)
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
import time
|
import time
|
||||||
import threading
|
import threading
|
||||||
from typing import Protocol, cast
|
from typing import Protocol, cast
|
||||||
from evdev import InputDevice, ecodes, list_devices
|
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
|
||||||
import pyudev
|
from pyudev import Context, Monitor, MonitorObserver, Device
|
||||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox
|
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox
|
||||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
||||||
from PySide6.QtGui import QKeyEvent
|
from PySide6.QtGui import QKeyEvent
|
||||||
@ -10,7 +10,7 @@ from portprotonqt.logger import get_logger
|
|||||||
from portprotonqt.image_utils import FullscreenDialog
|
from portprotonqt.image_utils import FullscreenDialog
|
||||||
from portprotonqt.custom_widgets import NavLabel
|
from portprotonqt.custom_widgets import NavLabel
|
||||||
from portprotonqt.game_card import GameCard
|
from portprotonqt.game_card import GameCard
|
||||||
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad
|
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -27,6 +27,8 @@ class MainWindowProtocol(Protocol):
|
|||||||
...
|
...
|
||||||
def openSystemOverlay(self) -> None:
|
def openSystemOverlay(self) -> None:
|
||||||
...
|
...
|
||||||
|
def on_slider_released(self) -> None:
|
||||||
|
...
|
||||||
stackedWidget: QStackedWidget
|
stackedWidget: QStackedWidget
|
||||||
tabButtons: dict[int, QWidget]
|
tabButtons: dict[int, QWidget]
|
||||||
gamesListWidget: QWidget
|
gamesListWidget: QWidget
|
||||||
@ -34,25 +36,27 @@ class MainWindowProtocol(Protocol):
|
|||||||
current_exec_line: str | None
|
current_exec_line: str | None
|
||||||
current_add_game_dialog: QDialog | None
|
current_add_game_dialog: QDialog | None
|
||||||
|
|
||||||
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers
|
# Mapping of actions to evdev button codes, includes Xbox and PlayStation controllers
|
||||||
|
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
|
||||||
|
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
|
||||||
BUTTONS = {
|
BUTTONS = {
|
||||||
'confirm': {ecodes.BTN_A},
|
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS)
|
||||||
'back': {ecodes.BTN_B},
|
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS)
|
||||||
'add_game': {ecodes.BTN_Y},
|
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS)
|
||||||
'prev_tab': {ecodes.BTN_TL},
|
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS)
|
||||||
'next_tab': {ecodes.BTN_TR},
|
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS)
|
||||||
'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR},
|
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS)
|
||||||
'context_menu': {ecodes.BTN_START},
|
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS)
|
||||||
'menu': {ecodes.BTN_SELECT},
|
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button
|
||||||
'guide': {ecodes.BTN_MODE, ecodes.KEY_HOMEPAGE},
|
'increase_size': {ecodes.ABS_RZ}, # RT (Xbox) / R2 (PS)
|
||||||
|
'decrease_size': {ecodes.ABS_Z}, # LT (Xbox) / L2 (PS)
|
||||||
}
|
}
|
||||||
|
|
||||||
class InputManager(QObject):
|
class InputManager(QObject):
|
||||||
"""
|
"""
|
||||||
Manages input from gamepads and keyboards for navigating the application interface.
|
Manages input from gamepads and keyboards for navigating the application interface.
|
||||||
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
|
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
|
||||||
for seamless UI interaction. Enables fullscreen mode when a gamepad is connected
|
for seamless UI interaction.
|
||||||
and restores normal mode when disconnected.
|
|
||||||
"""
|
"""
|
||||||
# Signals for gamepad events
|
# Signals for gamepad events
|
||||||
button_pressed = Signal(int) # Signal for button presses
|
button_pressed = Signal(int) # Signal for button presses
|
||||||
@ -68,6 +72,7 @@ class InputManager(QObject):
|
|||||||
):
|
):
|
||||||
super().__init__(cast(QObject, main_window))
|
super().__init__(cast(QObject, main_window))
|
||||||
self._parent = main_window
|
self._parent = main_window
|
||||||
|
self._gamepad_handling_enabled = True
|
||||||
# Ensure attributes exist on main_window
|
# Ensure attributes exist on main_window
|
||||||
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
|
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
|
||||||
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
|
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
|
||||||
@ -82,6 +87,11 @@ class InputManager(QObject):
|
|||||||
self.gamepad_thread: threading.Thread | None = None
|
self.gamepad_thread: threading.Thread | None = None
|
||||||
self.running = True
|
self.running = True
|
||||||
self._is_fullscreen = read_fullscreen_config()
|
self._is_fullscreen = read_fullscreen_config()
|
||||||
|
self.rumble_effect_id: int | None = None # Store the rumble effect ID
|
||||||
|
self.lt_pressed = False
|
||||||
|
self.rt_pressed = False
|
||||||
|
self.last_trigger_time = 0.0
|
||||||
|
self.trigger_cooldown = 0.2
|
||||||
|
|
||||||
# Add variables for continuous D-pad movement
|
# Add variables for continuous D-pad movement
|
||||||
self.dpad_timer = QTimer(self)
|
self.dpad_timer = QTimer(self)
|
||||||
@ -105,8 +115,6 @@ class InputManager(QObject):
|
|||||||
@Slot(bool)
|
@Slot(bool)
|
||||||
def handle_fullscreen_slot(self, enable: bool) -> None:
|
def handle_fullscreen_slot(self, enable: bool) -> None:
|
||||||
try:
|
try:
|
||||||
if read_fullscreen_config():
|
|
||||||
return
|
|
||||||
window = self._parent
|
window = self._parent
|
||||||
if not isinstance(window, QWidget):
|
if not isinstance(window, QWidget):
|
||||||
return
|
return
|
||||||
@ -125,10 +133,64 @@ class InputManager(QObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
|
logger.error(f"Error in handle_fullscreen_slot: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def disable_gamepad_handling(self) -> None:
|
||||||
|
"""Отключает обработку событий геймпада."""
|
||||||
|
self._gamepad_handling_enabled = False
|
||||||
|
self.stop_rumble()
|
||||||
|
self.dpad_timer.stop()
|
||||||
|
|
||||||
|
def enable_gamepad_handling(self) -> None:
|
||||||
|
"""Включает обработку событий геймпада."""
|
||||||
|
self._gamepad_handling_enabled = True
|
||||||
|
|
||||||
|
def trigger_rumble(self, duration_ms: int = 200, strong_magnitude: int = 0x8000, weak_magnitude: int = 0x8000) -> None:
|
||||||
|
"""Trigger a rumble effect on the gamepad if supported."""
|
||||||
|
if not read_rumble_config():
|
||||||
|
return
|
||||||
|
if not self.gamepad:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# Check if the gamepad supports force feedback
|
||||||
|
caps = self.gamepad.capabilities()
|
||||||
|
if ecodes.EV_FF not in caps or ecodes.FF_RUMBLE not in caps.get(ecodes.EV_FF, []):
|
||||||
|
logger.debug("Gamepad does not support force feedback or rumble")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create a rumble effect
|
||||||
|
rumble = ff.Rumble(strong_magnitude=strong_magnitude, weak_magnitude=weak_magnitude)
|
||||||
|
effect = ff.Effect(
|
||||||
|
id=-1, # Let evdev assign an ID
|
||||||
|
type=ecodes.FF_RUMBLE,
|
||||||
|
direction=0, # Direction (not used for rumble)
|
||||||
|
replay=ff.Replay(length=duration_ms, delay=0),
|
||||||
|
u=ff.EffectType(ff_rumble_effect=rumble)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Upload the effect
|
||||||
|
self.rumble_effect_id = self.gamepad.upload_effect(effect)
|
||||||
|
# Play the effect
|
||||||
|
event = InputEvent(0, 0, ecodes.EV_FF, self.rumble_effect_id, 1)
|
||||||
|
self.gamepad.write_event(event)
|
||||||
|
# Schedule effect erasure after duration
|
||||||
|
QTimer.singleShot(duration_ms, self.stop_rumble)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error triggering rumble: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def stop_rumble(self) -> None:
|
||||||
|
"""Stop the rumble effect and clean up."""
|
||||||
|
if self.gamepad and self.rumble_effect_id is not None:
|
||||||
|
try:
|
||||||
|
self.gamepad.erase_effect(self.rumble_effect_id)
|
||||||
|
self.rumble_effect_id = None
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error stopping rumble: {e}", exc_info=True)
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
def handle_button_slot(self, button_code: int) -> None:
|
def handle_button_slot(self, button_code: int) -> None:
|
||||||
|
if not self._gamepad_handling_enabled:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
# Игнорировать события геймпада, если игра запущена
|
# Ignore gamepad events if a game is launched
|
||||||
if getattr(self._parent, '_gameLaunched', False):
|
if getattr(self._parent, '_gameLaunched', False):
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -147,19 +209,29 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
# Handle QMenu (context menu)
|
# Handle QMenu (context menu)
|
||||||
if isinstance(popup, QMenu):
|
if isinstance(popup, QMenu):
|
||||||
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
|
if button_code in BUTTONS['confirm']:
|
||||||
if popup.activeAction():
|
if popup.activeAction():
|
||||||
popup.activeAction().trigger()
|
popup.activeAction().trigger()
|
||||||
popup.close()
|
popup.close()
|
||||||
return
|
return
|
||||||
elif button_code in BUTTONS['back'] or button_code in BUTTONS['menu']:
|
elif button_code in BUTTONS['back']:
|
||||||
popup.close()
|
popup.close()
|
||||||
return
|
return
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Handle QMessageBox
|
||||||
|
if isinstance(active, QMessageBox):
|
||||||
|
if button_code in BUTTONS['confirm']:
|
||||||
|
active.accept() # Close QMessageBox with the default button
|
||||||
|
return
|
||||||
|
elif button_code in BUTTONS['back']:
|
||||||
|
active.reject() # Close QMessageBox on back button
|
||||||
|
return
|
||||||
|
return
|
||||||
|
|
||||||
# Handle QComboBox
|
# Handle QComboBox
|
||||||
if isinstance(focused, QComboBox):
|
if isinstance(focused, QComboBox):
|
||||||
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
|
if button_code in BUTTONS['confirm']:
|
||||||
focused.showPopup()
|
focused.showPopup()
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -173,7 +245,7 @@ class InputManager(QObject):
|
|||||||
break
|
break
|
||||||
parent = parent.parentWidget()
|
parent = parent.parentWidget()
|
||||||
|
|
||||||
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
|
if button_code in BUTTONS['confirm']:
|
||||||
idx = focused.currentIndex()
|
idx = focused.currentIndex()
|
||||||
if idx.isValid():
|
if idx.isValid():
|
||||||
if combo:
|
if combo:
|
||||||
@ -194,7 +266,7 @@ class InputManager(QObject):
|
|||||||
focused.clearSelection()
|
focused.clearSelection()
|
||||||
focused.hide()
|
focused.hide()
|
||||||
|
|
||||||
# Закрытие AddGameDialog на кнопку B
|
# Close AddGameDialog on B button
|
||||||
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
|
if button_code in BUTTONS['back'] and isinstance(active, QDialog):
|
||||||
active.reject()
|
active.reject()
|
||||||
return
|
return
|
||||||
@ -219,18 +291,18 @@ class InputManager(QObject):
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Game launch on detail page
|
# Game launch on detail page
|
||||||
if (button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']) and self._parent.currentDetailPage is not None and self._parent.current_add_game_dialog is None:
|
if (button_code in BUTTONS['confirm']) and self._parent.currentDetailPage is not None and self._parent.current_add_game_dialog is None:
|
||||||
if self._parent.current_exec_line:
|
if self._parent.current_exec_line:
|
||||||
|
self.trigger_rumble()
|
||||||
self._parent.toggleGame(self._parent.current_exec_line, None)
|
self._parent.toggleGame(self._parent.current_exec_line, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Standard navigation
|
# Standard navigation
|
||||||
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
|
if button_code in BUTTONS['confirm']:
|
||||||
self._parent.activateFocusedWidget()
|
self._parent.activateFocusedWidget()
|
||||||
elif button_code in BUTTONS['back'] or button_code in BUTTONS['menu']:
|
elif button_code in BUTTONS['back']:
|
||||||
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
|
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
|
||||||
elif button_code in BUTTONS['add_game']:
|
elif button_code in BUTTONS['add_game']:
|
||||||
# Only open AddGameDialog if in library tab (index 0)
|
|
||||||
if self._parent.stackedWidget.currentIndex() == 0:
|
if self._parent.stackedWidget.currentIndex() == 0:
|
||||||
self._parent.openAddGameDialog()
|
self._parent.openAddGameDialog()
|
||||||
elif button_code in BUTTONS['prev_tab']:
|
elif button_code in BUTTONS['prev_tab']:
|
||||||
@ -241,6 +313,20 @@ class InputManager(QObject):
|
|||||||
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
|
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
|
||||||
self._parent.switchTab(idx)
|
self._parent.switchTab(idx)
|
||||||
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
elif button_code in BUTTONS['increase_size'] and self._parent.stackedWidget.currentIndex() == 0:
|
||||||
|
# Increase card size with RT (Xbox) / R2 (PS)
|
||||||
|
size_slider = getattr(self._parent, 'sizeSlider', None)
|
||||||
|
if size_slider:
|
||||||
|
new_value = min(size_slider.value() + 10, size_slider.maximum())
|
||||||
|
size_slider.setValue(new_value)
|
||||||
|
self._parent.on_slider_released()
|
||||||
|
elif button_code in BUTTONS['decrease_size'] and self._parent.stackedWidget.currentIndex() == 0:
|
||||||
|
# Decrease card size with LT (Xbox) / L2 (PS)
|
||||||
|
size_slider = getattr(self._parent, 'sizeSlider', None)
|
||||||
|
if size_slider:
|
||||||
|
new_value = max(size_slider.value() - 10, size_slider.minimum())
|
||||||
|
size_slider.setValue(new_value)
|
||||||
|
self._parent.on_slider_released()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
||||||
|
|
||||||
@ -255,8 +341,10 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
@Slot(int, int, float)
|
@Slot(int, int, float)
|
||||||
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
|
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
|
||||||
|
if not self._gamepad_handling_enabled:
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
# Игнорировать события геймпада, если игра запущена
|
# Ignore gamepad events if a game is launched
|
||||||
if getattr(self._parent, '_gameLaunched', False):
|
if getattr(self._parent, '_gameLaunched', False):
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -482,41 +570,83 @@ class InputManager(QObject):
|
|||||||
if not app:
|
if not app:
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
# Handle only key press events
|
# Handle key press and release events
|
||||||
if not (isinstance(event, QKeyEvent) and event.type() == QEvent.Type.KeyPress):
|
if not isinstance(event, QKeyEvent):
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
key = event.key()
|
key = event.key()
|
||||||
modifiers = event.modifiers()
|
modifiers = event.modifiers()
|
||||||
focused = QApplication.focusWidget()
|
focused = QApplication.focusWidget()
|
||||||
popup = QApplication.activePopupWidget()
|
popup = QApplication.activePopupWidget()
|
||||||
|
active_win = QApplication.activeWindow()
|
||||||
|
|
||||||
|
# Handle key press events
|
||||||
|
if event.type() == QEvent.Type.KeyPress:
|
||||||
|
# Open system overlay with Insert
|
||||||
|
if key == Qt.Key.Key_Insert:
|
||||||
|
if not popup and not isinstance(active_win, QDialog):
|
||||||
|
self._parent.openSystemOverlay()
|
||||||
|
return True
|
||||||
|
|
||||||
# Close application with Ctrl+Q
|
# Close application with Ctrl+Q
|
||||||
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
|
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
|
||||||
app.quit()
|
app.quit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Закрытие AddGameDialog на Esc
|
# Close AddGameDialog with Escape
|
||||||
if key == Qt.Key.Key_Escape and isinstance(popup, QDialog):
|
if key == Qt.Key.Key_Escape and isinstance(popup, QDialog):
|
||||||
popup.reject() # Закрываем диалог
|
popup.reject()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Skip navigation keys if a popup is open
|
|
||||||
if popup:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# FullscreenDialog navigation
|
# FullscreenDialog navigation
|
||||||
active_win = QApplication.activeWindow()
|
|
||||||
if isinstance(active_win, FullscreenDialog):
|
if isinstance(active_win, FullscreenDialog):
|
||||||
if key == Qt.Key.Key_Right:
|
|
||||||
active_win.show_next()
|
|
||||||
return True
|
|
||||||
if key == Qt.Key.Key_Left:
|
|
||||||
active_win.show_prev()
|
|
||||||
return True
|
|
||||||
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace):
|
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace):
|
||||||
active_win.close()
|
active_win.close()
|
||||||
return True
|
return True
|
||||||
|
elif key in (Qt.Key.Key_Left, Qt.Key.Key_Right):
|
||||||
|
# Navigate screenshots in FullscreenDialog
|
||||||
|
if key == Qt.Key.Key_Left:
|
||||||
|
active_win.show_prev()
|
||||||
|
elif key == Qt.Key.Key_Right:
|
||||||
|
active_win.show_next()
|
||||||
|
return True # Consume event to prevent tab switching
|
||||||
|
|
||||||
|
# Handle tab switching with Left/Right arrow keys when not in GameCard focus
|
||||||
|
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and (not isinstance(focused, GameCard) or focused is None):
|
||||||
|
idx = self._parent.stackedWidget.currentIndex()
|
||||||
|
total = len(self._parent.tabButtons)
|
||||||
|
if key == Qt.Key.Key_Left:
|
||||||
|
new_idx = (idx - 1) % total
|
||||||
|
self._parent.switchTab(new_idx)
|
||||||
|
self._parent.tabButtons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
return True
|
||||||
|
elif key == Qt.Key.Key_Right:
|
||||||
|
new_idx = (idx + 1) % total
|
||||||
|
self._parent.switchTab(new_idx)
|
||||||
|
self._parent.tabButtons[new_idx].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Map arrow keys to D-pad press events for other contexts
|
||||||
|
if key in (Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Left, Qt.Key.Key_Right):
|
||||||
|
now = time.time()
|
||||||
|
dpad_code = None
|
||||||
|
dpad_value = 0
|
||||||
|
if key == Qt.Key.Key_Up:
|
||||||
|
dpad_code = ecodes.ABS_HAT0Y
|
||||||
|
dpad_value = -1
|
||||||
|
elif key == Qt.Key.Key_Down:
|
||||||
|
dpad_code = ecodes.ABS_HAT0Y
|
||||||
|
dpad_value = 1
|
||||||
|
elif key == Qt.Key.Key_Left:
|
||||||
|
dpad_code = ecodes.ABS_HAT0X
|
||||||
|
dpad_value = -1
|
||||||
|
elif key == Qt.Key.Key_Right:
|
||||||
|
dpad_code = ecodes.ABS_HAT0X
|
||||||
|
dpad_value = 1
|
||||||
|
|
||||||
|
if dpad_code is not None:
|
||||||
|
self.dpad_moved.emit(dpad_code, dpad_value, now)
|
||||||
|
return True
|
||||||
|
|
||||||
# Launch/stop game on detail page
|
# Launch/stop game on detail page
|
||||||
if self._parent.currentDetailPage and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
if self._parent.currentDetailPage and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||||
@ -526,169 +656,11 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
# Context menu for GameCard
|
# Context menu for GameCard
|
||||||
if isinstance(focused, GameCard):
|
if isinstance(focused, GameCard):
|
||||||
if key == Qt.Key.Key_F10 and Qt.KeyboardModifier.ShiftModifier:
|
if key == Qt.Key.Key_F10 and modifiers & Qt.KeyboardModifier.ShiftModifier:
|
||||||
pos = QPoint(focused.width() // 2, focused.height() // 2)
|
pos = QPoint(focused.width() // 2, focused.height() // 2)
|
||||||
focused._show_context_menu(pos)
|
focused._show_context_menu(pos)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Handle Up/Down keys for non-GameCard tabs
|
|
||||||
if key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and not isinstance(focused, GameCard):
|
|
||||||
page = self._parent.stackedWidget.currentWidget()
|
|
||||||
if key == Qt.Key.Key_Down:
|
|
||||||
if isinstance(focused, NavLabel):
|
|
||||||
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
|
|
||||||
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
|
|
||||||
if focusables:
|
|
||||||
focusables[0].setFocus()
|
|
||||||
return True
|
|
||||||
elif focused:
|
|
||||||
focused.focusNextChild()
|
|
||||||
return True
|
|
||||||
elif key == Qt.Key.Key_Up and focused:
|
|
||||||
focused.focusPreviousChild()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Tab switching with Left/Right keys (non-GameCard focus or no focus)
|
|
||||||
idx = self._parent.stackedWidget.currentIndex()
|
|
||||||
total = len(self._parent.tabButtons)
|
|
||||||
if key == Qt.Key.Key_Left and (not isinstance(focused, GameCard) or focused is None):
|
|
||||||
new = (idx - 1) % total
|
|
||||||
self._parent.switchTab(new)
|
|
||||||
self._parent.tabButtons[new].setFocus()
|
|
||||||
return True
|
|
||||||
if key == Qt.Key.Key_Right and (not isinstance(focused, GameCard) or focused is None):
|
|
||||||
new = (idx + 1) % total
|
|
||||||
self._parent.switchTab(new)
|
|
||||||
self._parent.tabButtons[new].setFocus()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Library tab navigation
|
|
||||||
if self._parent.stackedWidget.currentIndex() == 0:
|
|
||||||
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
|
|
||||||
scroll_area = self._parent.gamesListWidget.parentWidget()
|
|
||||||
while scroll_area and not isinstance(scroll_area, QScrollArea):
|
|
||||||
scroll_area = scroll_area.parentWidget()
|
|
||||||
|
|
||||||
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right, Qt.Key.Key_Up, Qt.Key.Key_Down):
|
|
||||||
if not game_cards:
|
|
||||||
return True
|
|
||||||
|
|
||||||
# If no focused widget or not a GameCard, focus the first card
|
|
||||||
if not isinstance(focused, GameCard) or focused not in game_cards:
|
|
||||||
game_cards[0].setFocus()
|
|
||||||
if scroll_area:
|
|
||||||
scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Group cards by rows based on y-coordinate
|
|
||||||
rows = {}
|
|
||||||
for card in game_cards:
|
|
||||||
y = card.pos().y()
|
|
||||||
if y not in rows:
|
|
||||||
rows[y] = []
|
|
||||||
rows[y].append(card)
|
|
||||||
# Sort cards in each row by x-coordinate
|
|
||||||
for y in rows:
|
|
||||||
rows[y].sort(key=lambda c: c.pos().x())
|
|
||||||
# Sort rows by y-coordinate
|
|
||||||
sorted_rows = sorted(rows.items(), key=lambda x: x[0])
|
|
||||||
|
|
||||||
# Find current row and column
|
|
||||||
current_y = focused.pos().y()
|
|
||||||
current_row_idx = next(i for i, (y, _) in enumerate(sorted_rows) if y == current_y)
|
|
||||||
current_row = sorted_rows[current_row_idx][1]
|
|
||||||
current_col_idx = current_row.index(focused)
|
|
||||||
|
|
||||||
if key == Qt.Key.Key_Right:
|
|
||||||
next_col_idx = current_col_idx + 1
|
|
||||||
if next_col_idx < len(current_row):
|
|
||||||
next_card = current_row[next_col_idx]
|
|
||||||
next_card.setFocus()
|
|
||||||
if scroll_area:
|
|
||||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# Move to the first card of the next row if available
|
|
||||||
if current_row_idx < len(sorted_rows) - 1:
|
|
||||||
next_row = sorted_rows[current_row_idx + 1][1]
|
|
||||||
next_card = next_row[0] if next_row else None
|
|
||||||
if next_card:
|
|
||||||
next_card.setFocus()
|
|
||||||
if scroll_area:
|
|
||||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
|
||||||
return True
|
|
||||||
elif key == Qt.Key.Key_Left:
|
|
||||||
next_col_idx = current_col_idx - 1
|
|
||||||
if next_col_idx >= 0:
|
|
||||||
next_card = current_row[next_col_idx]
|
|
||||||
next_card.setFocus()
|
|
||||||
if scroll_area:
|
|
||||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
# Move to the last card of the previous row if available
|
|
||||||
if current_row_idx > 0:
|
|
||||||
prev_row = sorted_rows[current_row_idx - 1][1]
|
|
||||||
next_card = prev_row[-1] if prev_row else None
|
|
||||||
if next_card:
|
|
||||||
next_card.setFocus()
|
|
||||||
if scroll_area:
|
|
||||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
|
||||||
return True
|
|
||||||
elif key == Qt.Key.Key_Down:
|
|
||||||
next_row_idx = current_row_idx + 1
|
|
||||||
if next_row_idx < len(sorted_rows):
|
|
||||||
next_row = sorted_rows[next_row_idx][1]
|
|
||||||
target_x = focused.pos().x()
|
|
||||||
next_card = min(
|
|
||||||
next_row,
|
|
||||||
key=lambda c: abs(c.pos().x() - target_x),
|
|
||||||
default=None
|
|
||||||
)
|
|
||||||
if next_card:
|
|
||||||
next_card.setFocus()
|
|
||||||
if scroll_area:
|
|
||||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
|
||||||
return True
|
|
||||||
elif key == Qt.Key.Key_Up:
|
|
||||||
next_row_idx = current_row_idx - 1
|
|
||||||
if next_row_idx >= 0:
|
|
||||||
next_row = sorted_rows[next_row_idx][1]
|
|
||||||
target_x = focused.pos().x()
|
|
||||||
next_card = min(
|
|
||||||
next_row,
|
|
||||||
key=lambda c: abs(c.pos().x() - target_x),
|
|
||||||
default=None
|
|
||||||
)
|
|
||||||
if next_card:
|
|
||||||
next_card.setFocus()
|
|
||||||
if scroll_area:
|
|
||||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
|
||||||
return True
|
|
||||||
elif current_row_idx == 0:
|
|
||||||
self._parent.tabButtons[0].setFocus()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Navigate down into tab content
|
|
||||||
if key == Qt.Key.Key_Down:
|
|
||||||
if isinstance(focused, NavLabel):
|
|
||||||
page = self._parent.stackedWidget.currentWidget()
|
|
||||||
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
|
|
||||||
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
|
|
||||||
if focusables:
|
|
||||||
focusables[0].setFocus()
|
|
||||||
return True
|
|
||||||
elif focused:
|
|
||||||
focused.focusNextChild()
|
|
||||||
return True
|
|
||||||
# Navigate up through tab content
|
|
||||||
if key == Qt.Key.Key_Up:
|
|
||||||
if isinstance(focused, NavLabel):
|
|
||||||
return True
|
|
||||||
if focused is not None:
|
|
||||||
focused.focusPreviousChild()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# General actions: Activate, Back, Add
|
# General actions: Activate, Back, Add
|
||||||
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||||
self._parent.activateFocusedWidget()
|
self._parent.activateFocusedWidget()
|
||||||
@ -708,11 +680,24 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
# Toggle fullscreen with F11
|
# Toggle fullscreen with F11
|
||||||
if key == Qt.Key.Key_F11:
|
if key == Qt.Key.Key_F11:
|
||||||
if read_fullscreen_config():
|
|
||||||
return True
|
|
||||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Handle key release events for arrow keys
|
||||||
|
elif event.type() == QEvent.Type.KeyRelease:
|
||||||
|
if key in (Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Left, Qt.Key.Key_Right):
|
||||||
|
now = time.time()
|
||||||
|
dpad_code = None
|
||||||
|
if key in (Qt.Key.Key_Up, Qt.Key.Key_Down):
|
||||||
|
dpad_code = ecodes.ABS_HAT0Y
|
||||||
|
elif key in (Qt.Key.Key_Left, Qt.Key.Key_Right):
|
||||||
|
dpad_code = ecodes.ABS_HAT0X
|
||||||
|
|
||||||
|
if dpad_code is not None:
|
||||||
|
# Emit release event with value 0 to stop continuous movement
|
||||||
|
self.dpad_moved.emit(dpad_code, 0, now)
|
||||||
|
return True
|
||||||
|
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
def init_gamepad(self) -> None:
|
def init_gamepad(self) -> None:
|
||||||
@ -722,17 +707,17 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
def run_udev_monitor(self) -> None:
|
def run_udev_monitor(self) -> None:
|
||||||
try:
|
try:
|
||||||
context = pyudev.Context()
|
context = Context()
|
||||||
monitor = pyudev.Monitor.from_netlink(context)
|
monitor = Monitor.from_netlink(context)
|
||||||
monitor.filter_by(subsystem='input')
|
monitor.filter_by(subsystem='input')
|
||||||
observer = pyudev.MonitorObserver(monitor, self.handle_udev_event)
|
observer = MonitorObserver(monitor, self.handle_udev_event)
|
||||||
observer.start()
|
observer.start()
|
||||||
while self.running:
|
while self.running:
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in udev monitor: {e}", exc_info=True)
|
logger.error(f"Error in udev monitor: {e}", exc_info=True)
|
||||||
|
|
||||||
def handle_udev_event(self, action: str, device: pyudev.Device) -> None:
|
def handle_udev_event(self, action: str, device: Device) -> None:
|
||||||
try:
|
try:
|
||||||
if action == 'add':
|
if action == 'add':
|
||||||
time.sleep(0.1)
|
time.sleep(0.1)
|
||||||
@ -740,6 +725,7 @@ class InputManager(QObject):
|
|||||||
elif action == 'remove' and self.gamepad:
|
elif action == 'remove' and self.gamepad:
|
||||||
if not any(self.gamepad.path == path for path in list_devices()):
|
if not any(self.gamepad.path == path for path in list_devices()):
|
||||||
logger.info("Gamepad disconnected")
|
logger.info("Gamepad disconnected")
|
||||||
|
self.stop_rumble()
|
||||||
self.gamepad = None
|
self.gamepad = None
|
||||||
if self.gamepad_thread:
|
if self.gamepad_thread:
|
||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
@ -753,14 +739,15 @@ class InputManager(QObject):
|
|||||||
new_gamepad = self.find_gamepad()
|
new_gamepad = self.find_gamepad()
|
||||||
if new_gamepad and new_gamepad != self.gamepad:
|
if new_gamepad and new_gamepad != self.gamepad:
|
||||||
logger.info(f"Gamepad connected: {new_gamepad.name}")
|
logger.info(f"Gamepad connected: {new_gamepad.name}")
|
||||||
|
self.stop_rumble()
|
||||||
self.gamepad = new_gamepad
|
self.gamepad = new_gamepad
|
||||||
if self.gamepad_thread:
|
if self.gamepad_thread:
|
||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
|
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
|
||||||
self.gamepad_thread.start()
|
self.gamepad_thread.start()
|
||||||
# Отправляем сигнал для полноэкранного режима только если:
|
# Send signal for fullscreen mode only if:
|
||||||
# 1. auto_fullscreen_gamepad включено
|
# 1. auto_fullscreen_gamepad is enabled
|
||||||
# 2. fullscreen выключено (чтобы не конфликтовать с основной настройкой)
|
# 2. fullscreen is not already enabled (to avoid conflict)
|
||||||
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
|
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
|
||||||
self.toggle_fullscreen.emit(True)
|
self.toggle_fullscreen.emit(True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -789,13 +776,30 @@ class InputManager(QObject):
|
|||||||
continue
|
continue
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if event.type == ecodes.EV_KEY and event.value == 1:
|
if event.type == ecodes.EV_KEY and event.value == 1:
|
||||||
# Обработка кнопки Select для переключения полноэкранного режима
|
|
||||||
if event.code in BUTTONS['menu']:
|
if event.code in BUTTONS['menu']:
|
||||||
# Переключаем полноэкранный режим
|
|
||||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||||
else:
|
else:
|
||||||
self.button_pressed.emit(event.code)
|
self.button_pressed.emit(event.code)
|
||||||
elif event.type == ecodes.EV_ABS:
|
elif event.type == ecodes.EV_ABS:
|
||||||
|
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
|
||||||
|
# Проверяем, достаточно ли времени прошло с последнего срабатывания
|
||||||
|
if now - self.last_trigger_time < self.trigger_cooldown:
|
||||||
|
continue
|
||||||
|
if event.code == ecodes.ABS_Z: # LT/L2
|
||||||
|
if event.value > 128 and not self.lt_pressed:
|
||||||
|
self.lt_pressed = True
|
||||||
|
self.button_pressed.emit(event.code)
|
||||||
|
self.last_trigger_time = now
|
||||||
|
elif event.value <= 128 and self.lt_pressed:
|
||||||
|
self.lt_pressed = False
|
||||||
|
elif event.code == ecodes.ABS_RZ: # RT/R2
|
||||||
|
if event.value > 128 and not self.rt_pressed:
|
||||||
|
self.rt_pressed = True
|
||||||
|
self.button_pressed.emit(event.code)
|
||||||
|
self.last_trigger_time = now
|
||||||
|
elif event.value <= 128 and self.rt_pressed:
|
||||||
|
self.rt_pressed = False
|
||||||
|
else:
|
||||||
self.dpad_moved.emit(event.code, event.value, now)
|
self.dpad_moved.emit(event.code, event.value, now)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
if e.errno == 19: # ENODEV: No such device
|
if e.errno == 19: # ENODEV: No such device
|
||||||
@ -807,6 +811,7 @@ class InputManager(QObject):
|
|||||||
finally:
|
finally:
|
||||||
if self.gamepad:
|
if self.gamepad:
|
||||||
try:
|
try:
|
||||||
|
self.stop_rumble()
|
||||||
self.gamepad.close()
|
self.gamepad.close()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@ -816,6 +821,7 @@ class InputManager(QObject):
|
|||||||
try:
|
try:
|
||||||
self.running = False
|
self.running = False
|
||||||
self.dpad_timer.stop()
|
self.dpad_timer.stop()
|
||||||
|
self.stop_rumble()
|
||||||
if self.gamepad_thread:
|
if self.gamepad_thread:
|
||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
if self.gamepad:
|
if self.gamepad:
|
||||||
|
@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-06-10 10:25+0500\n"
|
"POT-Creation-Date: 2025-06-14 10:37+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: de_DE\n"
|
"Language: de_DE\n"
|
||||||
@ -376,6 +376,12 @@ msgstr ""
|
|||||||
msgid "Auto Fullscreen on Gamepad connected:"
|
msgid "Auto Fullscreen on Gamepad connected:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad haptic feedback"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad haptic feedback:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -488,9 +494,6 @@ msgstr ""
|
|||||||
msgid "Launching"
|
msgid "Launching"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "System Overlay"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Reboot"
|
msgid "Reboot"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-06-10 10:25+0500\n"
|
"POT-Creation-Date: 2025-06-14 10:37+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: es_ES\n"
|
"Language: es_ES\n"
|
||||||
@ -376,6 +376,12 @@ msgstr ""
|
|||||||
msgid "Auto Fullscreen on Gamepad connected:"
|
msgid "Auto Fullscreen on Gamepad connected:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad haptic feedback"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad haptic feedback:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -488,9 +494,6 @@ msgstr ""
|
|||||||
msgid "Launching"
|
msgid "Launching"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "System Overlay"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Reboot"
|
msgid "Reboot"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-06-10 10:25+0500\n"
|
"POT-Creation-Date: 2025-06-14 10:37+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@ -374,6 +374,12 @@ msgstr ""
|
|||||||
msgid "Auto Fullscreen on Gamepad connected:"
|
msgid "Auto Fullscreen on Gamepad connected:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad haptic feedback"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Gamepad haptic feedback:"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@ -486,9 +492,6 @@ msgstr ""
|
|||||||
msgid "Launching"
|
msgid "Launching"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "System Overlay"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Reboot"
|
msgid "Reboot"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@ -9,8 +9,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-06-10 10:25+0500\n"
|
"POT-Creation-Date: 2025-06-14 10:37+0500\n"
|
||||||
"PO-Revision-Date: 2025-06-10 10:24+0500\n"
|
"PO-Revision-Date: 2025-06-14 10:37+0500\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language: ru_RU\n"
|
"Language: ru_RU\n"
|
||||||
"Language-Team: ru_RU <LL@li.org>\n"
|
"Language-Team: ru_RU <LL@li.org>\n"
|
||||||
@ -383,6 +383,12 @@ msgstr "Режим полноэкранного отображения прил
|
|||||||
msgid "Auto Fullscreen on Gamepad connected:"
|
msgid "Auto Fullscreen on Gamepad connected:"
|
||||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада:"
|
msgstr "Режим полноэкранного отображения приложения при подключении геймпада:"
|
||||||
|
|
||||||
|
msgid "Gamepad haptic feedback"
|
||||||
|
msgstr "Тактильная отдача на геймпаде"
|
||||||
|
|
||||||
|
msgid "Gamepad haptic feedback:"
|
||||||
|
msgstr "Тактильная отдача на геймпаде:"
|
||||||
|
|
||||||
msgid "Save Settings"
|
msgid "Save Settings"
|
||||||
msgstr "Сохранить настройки"
|
msgstr "Сохранить настройки"
|
||||||
|
|
||||||
@ -497,9 +503,6 @@ msgstr "Невозможно запустить игру пока запущен
|
|||||||
msgid "Launching"
|
msgid "Launching"
|
||||||
msgstr "Идёт запуск"
|
msgstr "Идёт запуск"
|
||||||
|
|
||||||
msgid "System Overlay"
|
|
||||||
msgstr "Системный оверлей"
|
|
||||||
|
|
||||||
msgid "Reboot"
|
msgid "Reboot"
|
||||||
msgstr "Перезагрузить"
|
msgstr "Перезагрузить"
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ from portprotonqt.system_overlay import SystemOverlay
|
|||||||
|
|
||||||
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
|
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
|
||||||
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
|
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
|
||||||
from portprotonqt.egs_api import load_egs_games_async
|
from portprotonqt.egs_api import load_egs_games_async, get_egs_executable
|
||||||
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo
|
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo
|
||||||
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
|
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
|
||||||
from portprotonqt.config_utils import (
|
from portprotonqt.config_utils import (
|
||||||
@ -26,7 +26,7 @@ from portprotonqt.config_utils import (
|
|||||||
read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method,
|
read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method,
|
||||||
save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
|
save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
|
||||||
save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
|
save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
|
||||||
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad
|
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
|
||||||
)
|
)
|
||||||
from portprotonqt.localization import _
|
from portprotonqt.localization import _
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
@ -40,6 +40,7 @@ from typing import cast
|
|||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from PySide6.QtWidgets import QSizePolicy
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -52,38 +53,6 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setAcceptDrops(True)
|
|
||||||
self.current_exec_line = None
|
|
||||||
self.currentDetailPage = None
|
|
||||||
self.current_play_button = None
|
|
||||||
self.pending_games = []
|
|
||||||
self.game_card_cache = {}
|
|
||||||
self.pending_images = {}
|
|
||||||
self.total_games = 0
|
|
||||||
self.games_load_timer = QTimer(self)
|
|
||||||
self.games_load_timer.setSingleShot(True)
|
|
||||||
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
|
||||||
self.games_loaded.connect(self.on_games_loaded)
|
|
||||||
self.current_add_game_dialog = None
|
|
||||||
|
|
||||||
# Добавляем таймер для дебаунсинга сохранения настроек
|
|
||||||
self.settingsDebounceTimer = QTimer(self)
|
|
||||||
self.settingsDebounceTimer.setSingleShot(True)
|
|
||||||
self.settingsDebounceTimer.setInterval(300) # 300 мс задержка
|
|
||||||
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
|
|
||||||
|
|
||||||
read_time_config()
|
|
||||||
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
|
|
||||||
self.legendary_config_path = os.path.join(
|
|
||||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
|
||||||
"PortProtonQt", "legendary_cache"
|
|
||||||
)
|
|
||||||
os.makedirs(self.legendary_config_path, exist_ok=True)
|
|
||||||
os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path
|
|
||||||
|
|
||||||
self.legendary_path = os.path.join(self.legendary_config_path, "legendary")
|
|
||||||
self.downloader = Downloader(max_workers=4)
|
|
||||||
|
|
||||||
# Создаём менеджер тем и читаем, какая тема выбрана
|
# Создаём менеджер тем и читаем, какая тема выбрана
|
||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
selected_theme = read_theme_from_config()
|
selected_theme = read_theme_from_config()
|
||||||
@ -116,9 +85,47 @@ class MainWindow(QMainWindow):
|
|||||||
self.updateGameGrid
|
self.updateGameGrid
|
||||||
)
|
)
|
||||||
|
|
||||||
|
QApplication.setStyle("Fusion")
|
||||||
|
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
|
||||||
|
self.setAcceptDrops(True)
|
||||||
|
self.current_exec_line = None
|
||||||
|
self.currentDetailPage = None
|
||||||
|
self.current_play_button = None
|
||||||
|
self.current_focused_card = None
|
||||||
|
self.pending_games = []
|
||||||
|
self.game_card_cache = {}
|
||||||
|
self.pending_images = {}
|
||||||
|
self.total_games = 0
|
||||||
|
self.games_load_timer = QTimer(self)
|
||||||
|
self.games_load_timer.setSingleShot(True)
|
||||||
|
self.games_load_timer.timeout.connect(self.finalize_game_loading)
|
||||||
|
self.games_loaded.connect(self.on_games_loaded)
|
||||||
|
self.current_add_game_dialog = None
|
||||||
|
self.current_hovered_card = None
|
||||||
|
|
||||||
|
# Добавляем таймер для дебаунсинга сохранения настроек
|
||||||
|
self.settingsDebounceTimer = QTimer(self)
|
||||||
|
self.settingsDebounceTimer.setSingleShot(True)
|
||||||
|
self.settingsDebounceTimer.setInterval(300) # 300 мс задержка
|
||||||
|
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
|
||||||
|
|
||||||
|
read_time_config()
|
||||||
|
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
|
||||||
|
self.legendary_config_path = os.path.join(
|
||||||
|
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||||
|
"PortProtonQt", "legendary_cache"
|
||||||
|
)
|
||||||
|
os.makedirs(self.legendary_config_path, exist_ok=True)
|
||||||
|
os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path
|
||||||
|
|
||||||
|
self.legendary_path = os.path.join(self.legendary_config_path, "legendary")
|
||||||
|
self.downloader = Downloader(max_workers=4)
|
||||||
|
|
||||||
# Статус-бар
|
# Статус-бар
|
||||||
self.setStatusBar(QStatusBar(self))
|
self.setStatusBar(QStatusBar(self))
|
||||||
|
self.statusBar().setStyleSheet(self.theme.STATUS_BAR_STYLE)
|
||||||
self.progress_bar = QProgressBar()
|
self.progress_bar = QProgressBar()
|
||||||
|
self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
|
||||||
self.progress_bar.setMaximumWidth(200)
|
self.progress_bar.setMaximumWidth(200)
|
||||||
self.progress_bar.setTextVisible(True)
|
self.progress_bar.setTextVisible(True)
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
@ -199,8 +206,6 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.restore_state()
|
self.restore_state()
|
||||||
|
|
||||||
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
|
|
||||||
self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE)
|
|
||||||
self.input_manager = InputManager(self)
|
self.input_manager = InputManager(self)
|
||||||
QTimer.singleShot(0, self.loadGames)
|
QTimer.singleShot(0, self.loadGames)
|
||||||
|
|
||||||
@ -241,6 +246,65 @@ class MainWindow(QMainWindow):
|
|||||||
self.updateGameGrid()
|
self.updateGameGrid()
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
|
|
||||||
|
def _on_card_focused(self, game_name: str, is_focused: bool):
|
||||||
|
"""Обработчик сигнала focusChanged от GameCard."""
|
||||||
|
card_key = None
|
||||||
|
for key, card in self.game_card_cache.items():
|
||||||
|
if card.name == game_name:
|
||||||
|
card_key = key
|
||||||
|
break
|
||||||
|
|
||||||
|
if not card_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
card = self.game_card_cache[card_key]
|
||||||
|
|
||||||
|
if is_focused:
|
||||||
|
# Если карточка получила фокус
|
||||||
|
if self.current_hovered_card and self.current_hovered_card != card:
|
||||||
|
# Сбрасываем текущую hovered карточку
|
||||||
|
self.current_hovered_card._hovered = False
|
||||||
|
self.current_hovered_card.leaveEvent(None)
|
||||||
|
self.current_hovered_card = None
|
||||||
|
if self.current_focused_card and self.current_focused_card != card:
|
||||||
|
# Сбрасываем текущую focused карточку
|
||||||
|
self.current_focused_card._focused = False
|
||||||
|
self.current_focused_card.clearFocus()
|
||||||
|
self.current_focused_card = card
|
||||||
|
else:
|
||||||
|
# Если карточка потеряла фокус
|
||||||
|
if self.current_focused_card == card:
|
||||||
|
self.current_focused_card = None
|
||||||
|
|
||||||
|
def _on_card_hovered(self, game_name: str, is_hovered: bool):
|
||||||
|
"""Обработчик сигнала hoverChanged от GameCard."""
|
||||||
|
card_key = None
|
||||||
|
for key, card in self.game_card_cache.items():
|
||||||
|
if card.name == game_name:
|
||||||
|
card_key = key
|
||||||
|
break
|
||||||
|
|
||||||
|
if not card_key:
|
||||||
|
return
|
||||||
|
|
||||||
|
card = self.game_card_cache[card_key]
|
||||||
|
|
||||||
|
if is_hovered:
|
||||||
|
# Если мышь наведена на карточку
|
||||||
|
if self.current_focused_card and self.current_focused_card != card:
|
||||||
|
# Сбрасываем текущую focused карточку
|
||||||
|
self.current_focused_card._focused = False
|
||||||
|
self.current_focused_card.clearFocus()
|
||||||
|
if self.current_hovered_card and self.current_hovered_card != card:
|
||||||
|
# Сбрасываем предыдущую hovered карточку
|
||||||
|
self.current_hovered_card._hovered = False
|
||||||
|
self.current_hovered_card.leaveEvent(None)
|
||||||
|
self.current_hovered_card = card
|
||||||
|
else:
|
||||||
|
# Если мышь покинула карточку
|
||||||
|
if self.current_hovered_card == card:
|
||||||
|
self.current_hovered_card = None
|
||||||
|
|
||||||
def loadGames(self):
|
def loadGames(self):
|
||||||
display_filter = read_display_filter()
|
display_filter = read_display_filter()
|
||||||
favorites = read_favorites()
|
favorites = read_favorites()
|
||||||
@ -261,19 +325,25 @@ class MainWindow(QMainWindow):
|
|||||||
self.update_status_message.emit
|
self.update_status_message.emit
|
||||||
)
|
)
|
||||||
elif display_filter == "favorites":
|
elif display_filter == "favorites":
|
||||||
def on_all_games(portproton_games, steam_games):
|
def on_all_games(portproton_games, steam_games, epic_games):
|
||||||
games = [game for game in portproton_games + steam_games if game[0] in favorites]
|
games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites]
|
||||||
self.games_loaded.emit(games)
|
self.games_loaded.emit(games)
|
||||||
self._load_portproton_games_async(
|
self._load_portproton_games_async(
|
||||||
lambda pg: self._load_steam_games_async(
|
lambda pg: self._load_steam_games_async(
|
||||||
lambda sg: on_all_games(pg, sg)
|
lambda sg: load_egs_games_async(
|
||||||
|
self.legendary_path,
|
||||||
|
lambda eg: on_all_games(pg, sg, eg),
|
||||||
|
self.downloader,
|
||||||
|
self.update_progress.emit,
|
||||||
|
self.update_status_message.emit
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
def on_all_games(portproton_games, steam_games):
|
def on_all_games(portproton_games, steam_games, epic_games):
|
||||||
seen = set()
|
seen = set()
|
||||||
games = []
|
games = []
|
||||||
for game in portproton_games + steam_games:
|
for game in portproton_games + steam_games + epic_games:
|
||||||
# Уникальный ключ: имя + exec_line
|
# Уникальный ключ: имя + exec_line
|
||||||
key = (game[0], game[4])
|
key = (game[0], game[4])
|
||||||
if key not in seen:
|
if key not in seen:
|
||||||
@ -282,7 +352,13 @@ class MainWindow(QMainWindow):
|
|||||||
self.games_loaded.emit(games)
|
self.games_loaded.emit(games)
|
||||||
self._load_portproton_games_async(
|
self._load_portproton_games_async(
|
||||||
lambda pg: self._load_steam_games_async(
|
lambda pg: self._load_steam_games_async(
|
||||||
lambda sg: on_all_games(pg, sg)
|
lambda sg: load_egs_games_async(
|
||||||
|
self.legendary_path,
|
||||||
|
lambda eg: on_all_games(pg, sg, eg),
|
||||||
|
self.downloader,
|
||||||
|
self.update_progress.emit,
|
||||||
|
self.update_status_message.emit
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return []
|
return []
|
||||||
@ -535,10 +611,12 @@ class MainWindow(QMainWindow):
|
|||||||
def startSearchDebounce(self, text):
|
def startSearchDebounce(self, text):
|
||||||
self.searchDebounceTimer.start()
|
self.searchDebounceTimer.start()
|
||||||
|
|
||||||
def on_slider_value_changed(self, value: int):
|
def on_slider_released(self):
|
||||||
self.card_width = value
|
self.card_width = self.sizeSlider.value()
|
||||||
self.sizeSlider.setToolTip(f"{value} px")
|
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
||||||
save_card_size(value)
|
save_card_size(self.card_width)
|
||||||
|
for card in self.game_card_cache.values():
|
||||||
|
card.update_card_size(self.card_width)
|
||||||
self.updateGameGrid()
|
self.updateGameGrid()
|
||||||
|
|
||||||
def filterGamesDelayed(self):
|
def filterGamesDelayed(self):
|
||||||
@ -581,7 +659,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.sizeSlider.setFixedWidth(150)
|
self.sizeSlider.setFixedWidth(150)
|
||||||
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
||||||
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
|
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
|
||||||
self.sizeSlider.valueChanged.connect(self.on_slider_value_changed)
|
self.sizeSlider.sliderReleased.connect(self.on_slider_released)
|
||||||
sliderLayout.addWidget(self.sizeSlider)
|
sliderLayout.addWidget(self.sizeSlider)
|
||||||
layout.addLayout(sliderLayout)
|
layout.addLayout(sliderLayout)
|
||||||
|
|
||||||
@ -679,6 +757,8 @@ class MainWindow(QMainWindow):
|
|||||||
card_width=self.card_width,
|
card_width=self.card_width,
|
||||||
context_menu_manager=self.context_menu_manager
|
context_menu_manager=self.context_menu_manager
|
||||||
)
|
)
|
||||||
|
card.hoverChanged.connect(self._on_card_hovered)
|
||||||
|
card.focusChanged.connect(self._on_card_focused)
|
||||||
# Подключаем сигналы контекстного меню
|
# Подключаем сигналы контекстного меню
|
||||||
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
|
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
|
||||||
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
|
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
|
||||||
@ -888,9 +968,11 @@ class MainWindow(QMainWindow):
|
|||||||
formLayout = QFormLayout()
|
formLayout = QFormLayout()
|
||||||
formLayout.setContentsMargins(0, 10, 0, 0)
|
formLayout.setContentsMargins(0, 10, 0, 0)
|
||||||
formLayout.setSpacing(10)
|
formLayout.setSpacing(10)
|
||||||
|
formLayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
|
||||||
|
|
||||||
# 1. Time detail_level
|
# 1. Time detail_level
|
||||||
self.timeDetailCombo = QComboBox()
|
self.timeDetailCombo = QComboBox()
|
||||||
|
self.timeDetailCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
self.time_keys = ["detailed", "brief"]
|
self.time_keys = ["detailed", "brief"]
|
||||||
self.time_labels = [_("detailed"), _("brief")]
|
self.time_labels = [_("detailed"), _("brief")]
|
||||||
self.timeDetailCombo.addItems(self.time_labels)
|
self.timeDetailCombo.addItems(self.time_labels)
|
||||||
@ -909,6 +991,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# 2. Games sort_method
|
# 2. Games sort_method
|
||||||
self.gamesSortCombo = QComboBox()
|
self.gamesSortCombo = QComboBox()
|
||||||
|
self.gamesSortCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
self.sort_keys = ["last_launch", "playtime", "alphabetical", "favorites"]
|
self.sort_keys = ["last_launch", "playtime", "alphabetical", "favorites"]
|
||||||
self.sort_labels = [_("last launch"), _("playtime"), _("alphabetical"), _("favorites")]
|
self.sort_labels = [_("last launch"), _("playtime"), _("alphabetical"), _("favorites")]
|
||||||
self.gamesSortCombo.addItems(self.sort_labels)
|
self.gamesSortCombo.addItems(self.sort_labels)
|
||||||
@ -927,8 +1010,9 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# 3. Games display_filter
|
# 3. Games display_filter
|
||||||
self.filter_keys = ["all", "steam", "portproton", "favorites", "epic"]
|
self.filter_keys = ["all", "steam", "portproton", "favorites", "epic"]
|
||||||
self.filter_labels = [_("all"), "steam", "portproton", _("favorites")]
|
self.filter_labels = [_("all"), "steam", "portproton", _("favorites"), "epic games store"]
|
||||||
self.gamesDisplayCombo = QComboBox()
|
self.gamesDisplayCombo = QComboBox()
|
||||||
|
self.gamesDisplayCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||||
self.gamesDisplayCombo.addItems(self.filter_labels)
|
self.gamesDisplayCombo.addItems(self.filter_labels)
|
||||||
self.gamesDisplayCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
|
self.gamesDisplayCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
|
||||||
self.gamesDisplayCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
self.gamesDisplayCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
@ -988,6 +1072,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
# 6. Automatic fullscreen on gamepad connection
|
# 6. Automatic fullscreen on gamepad connection
|
||||||
self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
|
self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
|
||||||
|
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
|
||||||
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
|
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
|
||||||
self.autoFullscreenGamepadTitle = QLabel(_("Auto Fullscreen on Gamepad connected:"))
|
self.autoFullscreenGamepadTitle = QLabel(_("Auto Fullscreen on Gamepad connected:"))
|
||||||
@ -997,6 +1082,48 @@ class MainWindow(QMainWindow):
|
|||||||
self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
|
self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
|
||||||
formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
|
formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
|
||||||
|
|
||||||
|
# 7. Gamepad haptic feedback config
|
||||||
|
self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
|
||||||
|
self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
|
||||||
|
self.gamepadRumbleTitle = QLabel(_("Gamepad haptic feedback:"))
|
||||||
|
self.gamepadRumbleTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||||
|
self.gamepadRumbleTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||||
|
current_rumble_state = read_rumble_config()
|
||||||
|
self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
|
||||||
|
formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
|
||||||
|
|
||||||
|
# 8. Legendary Authentication
|
||||||
|
self.legendaryAuthButton = AutoSizeButton(
|
||||||
|
_("Open Legendary Login"),
|
||||||
|
icon=self.theme_manager.get_icon("login")
|
||||||
|
)
|
||||||
|
self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||||
|
self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin)
|
||||||
|
self.legendaryAuthTitle = QLabel(_("Legendary Authentication:"))
|
||||||
|
self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||||
|
self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||||
|
formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton)
|
||||||
|
|
||||||
|
self.legendaryCodeEdit = QLineEdit()
|
||||||
|
self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code"))
|
||||||
|
self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
|
||||||
|
self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.legendaryCodeTitle = QLabel(_("Authorization Code:"))
|
||||||
|
self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
|
||||||
|
self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||||
|
formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit)
|
||||||
|
|
||||||
|
self.submitCodeButton = AutoSizeButton(
|
||||||
|
_("Submit Code"),
|
||||||
|
icon=self.theme_manager.get_icon("save")
|
||||||
|
)
|
||||||
|
self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||||
|
self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
self.submitCodeButton.clicked.connect(self.submitLegendaryCode)
|
||||||
|
formLayout.addRow(QLabel(""), self.submitCodeButton)
|
||||||
|
|
||||||
layout.addLayout(formLayout)
|
layout.addLayout(formLayout)
|
||||||
|
|
||||||
# Кнопки
|
# Кнопки
|
||||||
@ -1047,6 +1174,37 @@ class MainWindow(QMainWindow):
|
|||||||
logger.error(f"Failed to open Legendary login page: {e}")
|
logger.error(f"Failed to open Legendary login page: {e}")
|
||||||
self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
|
self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
|
||||||
|
|
||||||
|
def submitLegendaryCode(self):
|
||||||
|
"""Submits the Legendary authorization code using the legendary CLI."""
|
||||||
|
auth_code = self.legendaryCodeEdit.text().strip()
|
||||||
|
if not auth_code:
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Execute legendary auth command
|
||||||
|
result = subprocess.run(
|
||||||
|
[self.legendary_path, "auth", "--code", auth_code],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=True
|
||||||
|
)
|
||||||
|
logger.info("Legendary authentication successful: %s", result.stdout)
|
||||||
|
self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
|
||||||
|
self.legendaryCodeEdit.clear()
|
||||||
|
# Reload Epic Games Store games after successful authentication
|
||||||
|
self.games = self.loadGames()
|
||||||
|
self.updateGameGrid()
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
logger.error("Legendary authentication failed: %s", e.stderr)
|
||||||
|
self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.error("Legendary executable not found at %s", self.legendary_path)
|
||||||
|
self.statusBar().showMessage(_("Legendary executable not found"), 5000)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Unexpected error during Legendary authentication: %s", str(e))
|
||||||
|
self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
|
||||||
|
|
||||||
def resetSettings(self):
|
def resetSettings(self):
|
||||||
"""Сбрасывает настройки и перезапускает приложение."""
|
"""Сбрасывает настройки и перезапускает приложение."""
|
||||||
reply = QMessageBox.question(
|
reply = QMessageBox.question(
|
||||||
@ -1117,6 +1275,10 @@ class MainWindow(QMainWindow):
|
|||||||
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
|
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
|
||||||
save_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
|
save_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
|
||||||
|
|
||||||
|
# Сохранение настройки виброотдачи геймпада
|
||||||
|
rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
|
||||||
|
save_rumble_config(rumble_enabled)
|
||||||
|
|
||||||
for card in self.game_card_cache.values():
|
for card in self.game_card_cache.values():
|
||||||
card.update_badge_visibility(filter_key)
|
card.update_badge_visibility(filter_key)
|
||||||
|
|
||||||
@ -1438,7 +1600,7 @@ class MainWindow(QMainWindow):
|
|||||||
steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}")))
|
steamLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://steamcommunity.com/app/{appid}")))
|
||||||
|
|
||||||
# Epic Games Store бейдж
|
# Epic Games Store бейдж
|
||||||
egs_icon = self.theme_manager.get_icon("steam")
|
egs_icon = self.theme_manager.get_icon("epic_games")
|
||||||
egsLabel = ClickableLabel(
|
egsLabel = ClickableLabel(
|
||||||
"Epic Games",
|
"Epic Games",
|
||||||
icon=egs_icon,
|
icon=egs_icon,
|
||||||
@ -1477,7 +1639,7 @@ class MainWindow(QMainWindow):
|
|||||||
icon_size=16,
|
icon_size=16,
|
||||||
icon_space=3,
|
icon_space=3,
|
||||||
)
|
)
|
||||||
anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
|
||||||
anticheatLabel.setFixedWidth(badge_width)
|
anticheatLabel.setFixedWidth(badge_width)
|
||||||
anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}")))
|
anticheatLabel.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}")))
|
||||||
anticheat_visible = True
|
anticheat_visible = True
|
||||||
@ -1708,6 +1870,8 @@ class MainWindow(QMainWindow):
|
|||||||
elif not child_running:
|
elif not child_running:
|
||||||
# Игра завершилась – сбрасываем флаг, сбрасываем кнопку и останавливаем таймер
|
# Игра завершилась – сбрасываем флаг, сбрасываем кнопку и останавливаем таймер
|
||||||
self._gameLaunched = False
|
self._gameLaunched = False
|
||||||
|
if hasattr(self, 'input_manager'):
|
||||||
|
self.input_manager.enable_gamepad_handling()
|
||||||
self.resetPlayButton()
|
self.resetPlayButton()
|
||||||
#self._uninhibit_screensaver()
|
#self._uninhibit_screensaver()
|
||||||
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
|
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
|
||||||
@ -1733,11 +1897,110 @@ class MainWindow(QMainWindow):
|
|||||||
self.target_exe = None
|
self.target_exe = None
|
||||||
|
|
||||||
def toggleGame(self, exec_line, button=None):
|
def toggleGame(self, exec_line, button=None):
|
||||||
|
# Обработка Steam-игр
|
||||||
if exec_line.startswith("steam://"):
|
if exec_line.startswith("steam://"):
|
||||||
url = QUrl(exec_line)
|
url = QUrl(exec_line)
|
||||||
QDesktopServices.openUrl(url)
|
QDesktopServices.openUrl(url)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Обработка EGS-игр
|
||||||
|
if exec_line.startswith("legendary:launch:"):
|
||||||
|
app_name = exec_line.split("legendary:launch:")[1]
|
||||||
|
|
||||||
|
# Получаем путь к .exe из installed.json
|
||||||
|
game_exe = get_egs_executable(app_name, self.legendary_config_path)
|
||||||
|
if not game_exe or not os.path.exists(game_exe):
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Executable not found for EGS game: {0}").format(app_name))
|
||||||
|
return
|
||||||
|
|
||||||
|
current_exe = os.path.basename(game_exe)
|
||||||
|
if self.game_processes and self.target_exe is not None and self.target_exe != current_exe:
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Обновляем кнопку
|
||||||
|
update_button = button if button is not None else self.current_play_button
|
||||||
|
self.current_running_button = update_button
|
||||||
|
self.target_exe = current_exe
|
||||||
|
exe_name = os.path.splitext(current_exe)[0]
|
||||||
|
|
||||||
|
# Проверяем, запущена ли игра
|
||||||
|
if self.game_processes and self.target_exe == current_exe:
|
||||||
|
# Останавливаем игру
|
||||||
|
if hasattr(self, 'input_manager'):
|
||||||
|
self.input_manager.enable_gamepad_handling()
|
||||||
|
|
||||||
|
for proc in self.game_processes:
|
||||||
|
try:
|
||||||
|
parent = psutil.Process(proc.pid)
|
||||||
|
children = parent.children(recursive=True)
|
||||||
|
for child in children:
|
||||||
|
try:
|
||||||
|
child.terminate()
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
psutil.wait_procs(children, timeout=5)
|
||||||
|
for child in children:
|
||||||
|
if child.is_running():
|
||||||
|
child.kill()
|
||||||
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
pass
|
||||||
|
self.game_processes = []
|
||||||
|
if update_button:
|
||||||
|
update_button.setText(_("Play"))
|
||||||
|
icon = self.theme_manager.get_icon("play")
|
||||||
|
if isinstance(icon, str):
|
||||||
|
icon = QIcon(icon)
|
||||||
|
elif icon is None:
|
||||||
|
icon = QIcon()
|
||||||
|
update_button.setIcon(icon)
|
||||||
|
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
|
||||||
|
self.checkProcessTimer.stop()
|
||||||
|
self.checkProcessTimer.deleteLater()
|
||||||
|
self.checkProcessTimer = None
|
||||||
|
self.current_running_button = None
|
||||||
|
self.target_exe = None
|
||||||
|
self._gameLaunched = False
|
||||||
|
else:
|
||||||
|
# Запускаем игру через PortProton
|
||||||
|
env_vars = os.environ.copy()
|
||||||
|
env_vars['START_FROM_STEAM'] = '1'
|
||||||
|
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
|
||||||
|
|
||||||
|
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||||
|
if self.portproton_location is not None and ".var" not in self.portproton_location:
|
||||||
|
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||||
|
wrapper = start_sh
|
||||||
|
|
||||||
|
cmd = [wrapper, game_exe]
|
||||||
|
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(cmd, env=env_vars, shell=False, preexec_fn=os.setsid)
|
||||||
|
self.game_processes.append(process)
|
||||||
|
save_last_launch(exe_name, datetime.now())
|
||||||
|
if update_button:
|
||||||
|
update_button.setText(_("Launching"))
|
||||||
|
icon = self.theme_manager.get_icon("stop")
|
||||||
|
if isinstance(icon, str):
|
||||||
|
icon = QIcon(icon)
|
||||||
|
elif icon is None:
|
||||||
|
icon = QIcon()
|
||||||
|
update_button.setIcon(icon)
|
||||||
|
|
||||||
|
# Delay disabling gamepad handling
|
||||||
|
if hasattr(self, 'input_manager'):
|
||||||
|
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
|
||||||
|
|
||||||
|
self.checkProcessTimer = QTimer(self)
|
||||||
|
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||||
|
self.checkProcessTimer.start(500)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to launch EGS game {app_name}: {e}")
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Обработка PortProton-игр
|
||||||
entry_exec_split = shlex.split(exec_line)
|
entry_exec_split = shlex.split(exec_line)
|
||||||
if entry_exec_split[0] == "env":
|
if entry_exec_split[0] == "env":
|
||||||
if len(entry_exec_split) < 3:
|
if len(entry_exec_split) < 3:
|
||||||
@ -1751,19 +2014,24 @@ class MainWindow(QMainWindow):
|
|||||||
file_to_check = entry_exec_split[3]
|
file_to_check = entry_exec_split[3]
|
||||||
else:
|
else:
|
||||||
file_to_check = entry_exec_split[0]
|
file_to_check = entry_exec_split[0]
|
||||||
|
|
||||||
if not os.path.exists(file_to_check):
|
if not os.path.exists(file_to_check):
|
||||||
QMessageBox.warning(self, _("Error"), _("File not found: {0}").format(file_to_check))
|
QMessageBox.warning(self, _("Error"), _("File not found: {0}").format(file_to_check))
|
||||||
return
|
return
|
||||||
current_exe = os.path.basename(file_to_check)
|
|
||||||
|
|
||||||
|
current_exe = os.path.basename(file_to_check)
|
||||||
if self.game_processes and self.target_exe is not None and self.target_exe != current_exe:
|
if self.game_processes and self.target_exe is not None and self.target_exe != current_exe:
|
||||||
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
QMessageBox.warning(self, _("Error"), _("Cannot launch game while another game is running"))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Обновляем кнопку
|
||||||
update_button = button if button is not None else self.current_play_button
|
update_button = button if button is not None else self.current_play_button
|
||||||
|
|
||||||
# Если игра уже запущена для этого exe – останавливаем её по нажатию кнопки
|
# Если игра уже запущена для этого exe – останавливаем её
|
||||||
if self.game_processes and self.target_exe == current_exe:
|
if self.game_processes and self.target_exe == current_exe:
|
||||||
|
if hasattr(self, 'input_manager'):
|
||||||
|
self.input_manager.enable_gamepad_handling()
|
||||||
|
|
||||||
for proc in self.game_processes:
|
for proc in self.game_processes:
|
||||||
try:
|
try:
|
||||||
parent = psutil.Process(proc.pid)
|
parent = psutil.Process(proc.pid)
|
||||||
@ -1803,10 +2071,24 @@ class MainWindow(QMainWindow):
|
|||||||
self.target_exe = current_exe
|
self.target_exe = current_exe
|
||||||
exe_name = os.path.splitext(current_exe)[0]
|
exe_name = os.path.splitext(current_exe)[0]
|
||||||
env_vars = os.environ.copy()
|
env_vars = os.environ.copy()
|
||||||
|
|
||||||
|
# Delay disabling gamepad handling to allow rumble to complete
|
||||||
|
if hasattr(self, 'input_manager'):
|
||||||
|
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
|
||||||
|
|
||||||
if entry_exec_split[0] == "env" and len(entry_exec_split) > 1 and 'data/scripts/start.sh' in entry_exec_split[1]:
|
if entry_exec_split[0] == "env" and len(entry_exec_split) > 1 and 'data/scripts/start.sh' in entry_exec_split[1]:
|
||||||
env_vars['START_FROM_STEAM'] = '1'
|
env_vars['START_FROM_STEAM'] = '1'
|
||||||
elif entry_exec_split[0] == "flatpak":
|
elif entry_exec_split[0] == "flatpak":
|
||||||
env_vars['START_FROM_STEAM'] = '1'
|
env_vars['START_FROM_STEAM'] = '1'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Запускаем игру
|
||||||
|
self.current_running_button = update_button
|
||||||
|
self.target_exe = current_exe
|
||||||
|
exe_name = os.path.splitext(current_exe)[0]
|
||||||
|
env_vars = os.environ.copy()
|
||||||
|
env_vars['START_FROM_STEAM'] = '1'
|
||||||
|
try:
|
||||||
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
|
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
|
||||||
self.game_processes.append(process)
|
self.game_processes.append(process)
|
||||||
save_last_launch(exe_name, datetime.now())
|
save_last_launch(exe_name, datetime.now())
|
||||||
@ -1822,16 +2104,51 @@ class MainWindow(QMainWindow):
|
|||||||
self.checkProcessTimer = QTimer(self)
|
self.checkProcessTimer = QTimer(self)
|
||||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||||
self.checkProcessTimer.start(500)
|
self.checkProcessTimer.start(500)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to launch game {exe_name}: {e}")
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
|
||||||
|
|
||||||
def closeEvent(self, event):
|
def closeEvent(self, event):
|
||||||
|
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна."""
|
||||||
for proc in self.game_processes:
|
for proc in self.game_processes:
|
||||||
try:
|
try:
|
||||||
|
parent = psutil.Process(proc.pid)
|
||||||
|
children = parent.children(recursive=True)
|
||||||
|
for child in children:
|
||||||
|
try:
|
||||||
|
logger.debug(f"Terminating child process {child.pid}")
|
||||||
|
child.terminate()
|
||||||
|
except psutil.NoSuchProcess:
|
||||||
|
logger.debug(f"Child process {child.pid} already terminated")
|
||||||
|
psutil.wait_procs(children, timeout=5)
|
||||||
|
for child in children:
|
||||||
|
if child.is_running():
|
||||||
|
logger.debug(f"Killing child process {child.pid}")
|
||||||
|
child.kill()
|
||||||
|
logger.debug(f"Terminating process group {proc.pid}")
|
||||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||||
except ProcessLookupError:
|
except (psutil.NoSuchProcess, ProcessLookupError) as e:
|
||||||
pass # процесс уже завершился
|
logger.debug(f"Process {proc.pid} already terminated: {e}")
|
||||||
|
|
||||||
|
self.game_processes = [] # Очищаем список процессов
|
||||||
|
|
||||||
|
# Сохраняем настройки окна
|
||||||
if not read_fullscreen_config():
|
if not read_fullscreen_config():
|
||||||
|
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
|
||||||
save_window_geometry(self.width(), self.height())
|
save_window_geometry(self.width(), self.height())
|
||||||
|
|
||||||
save_card_size(self.card_width)
|
save_card_size(self.card_width)
|
||||||
|
|
||||||
|
# Очищаем таймеры и другие ресурсы
|
||||||
|
if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
|
||||||
|
self.games_load_timer.stop()
|
||||||
|
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
|
||||||
|
self.settingsDebounceTimer.stop()
|
||||||
|
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
|
||||||
|
self.searchDebounceTimer.stop()
|
||||||
|
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
|
||||||
|
self.checkProcessTimer.stop()
|
||||||
|
self.checkProcessTimer.deleteLater()
|
||||||
|
self.checkProcessTimer = None
|
||||||
|
|
||||||
|
QApplication.quit()
|
||||||
event.accept()
|
event.accept()
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import subprocess
|
import subprocess
|
||||||
from PySide6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QMessageBox
|
from PySide6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QMessageBox, QApplication, QWidget
|
||||||
from PySide6.QtWidgets import QApplication
|
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
import os
|
import os
|
||||||
from portprotonqt.localization import _
|
from portprotonqt.localization import _
|
||||||
|
from portprotonqt.custom_widgets import AutoSizeButton
|
||||||
|
from portprotonqt.theme_manager import ThemeManager
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@ -13,44 +14,68 @@ class SystemOverlay(QDialog):
|
|||||||
def __init__(self, parent, theme):
|
def __init__(self, parent, theme):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.theme = theme
|
self.theme = theme
|
||||||
self.setWindowTitle(_("System Overlay"))
|
self.setWindowTitle("System Overlay")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setFixedSize(400, 300)
|
self.setFixedSize(400, 300)
|
||||||
|
self.theme_manager = ThemeManager()
|
||||||
|
self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE)
|
||||||
|
|
||||||
|
# Make window stay on top and frameless
|
||||||
|
self.setWindowFlags(
|
||||||
|
Qt.WindowType.FramelessWindowHint |
|
||||||
|
Qt.WindowType.Dialog |
|
||||||
|
Qt.WindowType.WindowStaysOnTopHint
|
||||||
|
)
|
||||||
|
|
||||||
layout = QVBoxLayout(self)
|
layout = QVBoxLayout(self)
|
||||||
layout.setContentsMargins(20, 20, 20, 20)
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
layout.setSpacing(10)
|
layout.setSpacing(10)
|
||||||
|
|
||||||
# Reboot button
|
# Reboot button
|
||||||
reboot_button = QPushButton(_("Reboot"))
|
reboot_button = AutoSizeButton(
|
||||||
|
_("Reboot"),
|
||||||
|
icon=self.theme_manager.get_icon("reboot")
|
||||||
|
)
|
||||||
reboot_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
reboot_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||||
reboot_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
reboot_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
reboot_button.clicked.connect(self.reboot)
|
reboot_button.clicked.connect(self.reboot)
|
||||||
layout.addWidget(reboot_button)
|
layout.addWidget(reboot_button)
|
||||||
|
|
||||||
# Shutdown button
|
# Shutdown button
|
||||||
shutdown_button = QPushButton(_("Shutdown"))
|
shutdown_button = AutoSizeButton(
|
||||||
|
_("Shutdown"),
|
||||||
|
icon=self.theme_manager.get_icon("shutdown")
|
||||||
|
)
|
||||||
shutdown_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
shutdown_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||||
shutdown_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
shutdown_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
shutdown_button.clicked.connect(self.shutdown)
|
shutdown_button.clicked.connect(self.shutdown)
|
||||||
layout.addWidget(shutdown_button)
|
layout.addWidget(shutdown_button)
|
||||||
|
|
||||||
# Suspend button
|
# Suspend button
|
||||||
suspend_button = QPushButton(_("Suspend"))
|
suspend_button = AutoSizeButton(
|
||||||
|
_("Suspend"),
|
||||||
|
icon=self.theme_manager.get_icon("suspend")
|
||||||
|
)
|
||||||
suspend_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
suspend_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||||
suspend_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
suspend_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
suspend_button.clicked.connect(self.suspend)
|
suspend_button.clicked.connect(self.suspend)
|
||||||
layout.addWidget(suspend_button)
|
layout.addWidget(suspend_button)
|
||||||
|
|
||||||
# Exit application button
|
# Exit application button
|
||||||
exit_button = QPushButton(_("Exit Application"))
|
exit_button = AutoSizeButton(
|
||||||
|
_("Exit Application"),
|
||||||
|
icon=self.theme_manager.get_icon("exit")
|
||||||
|
)
|
||||||
exit_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
exit_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||||
exit_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
exit_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
exit_button.clicked.connect(self.exit_application)
|
exit_button.clicked.connect(self.exit_application)
|
||||||
layout.addWidget(exit_button)
|
layout.addWidget(exit_button)
|
||||||
|
|
||||||
# Return to Desktop button
|
# Return to Desktop button
|
||||||
desktop_button = QPushButton(_("Return to Desktop"))
|
desktop_button = AutoSizeButton(
|
||||||
|
_("Return to Desktop"),
|
||||||
|
icon=self.theme_manager.get_icon("desktop")
|
||||||
|
)
|
||||||
desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||||
desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
desktop_button.clicked.connect(self.return_to_desktop)
|
desktop_button.clicked.connect(self.return_to_desktop)
|
||||||
@ -62,14 +87,31 @@ class SystemOverlay(QDialog):
|
|||||||
layout.addWidget(desktop_button)
|
layout.addWidget(desktop_button)
|
||||||
|
|
||||||
# Cancel button
|
# Cancel button
|
||||||
cancel_button = QPushButton(_("Cancel"))
|
cancel_button = AutoSizeButton(
|
||||||
|
_("Cancel"),
|
||||||
|
icon=self.theme_manager.get_icon("cancel")
|
||||||
|
)
|
||||||
cancel_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
cancel_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||||
cancel_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
cancel_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
cancel_button.clicked.connect(self.reject)
|
cancel_button.clicked.connect(self.reject)
|
||||||
layout.addWidget(cancel_button)
|
layout.addWidget(cancel_button)
|
||||||
|
|
||||||
# Set focus to the first button
|
def showEvent(self, event):
|
||||||
reboot_button.setFocus()
|
"""Override showEvent to center window and set focus."""
|
||||||
|
super().showEvent(event)
|
||||||
|
|
||||||
|
# Center window relative to parent or screen
|
||||||
|
parent = self.parent()
|
||||||
|
if isinstance(parent, QWidget) and parent.isVisible():
|
||||||
|
self.move(parent.geometry().center() - self.rect().center())
|
||||||
|
else:
|
||||||
|
screen_geometry = QApplication.primaryScreen().availableGeometry()
|
||||||
|
self.move(screen_geometry.center() - self.rect().center())
|
||||||
|
|
||||||
|
# Set focus on first button
|
||||||
|
button = self.findChild(QPushButton)
|
||||||
|
if button is not None:
|
||||||
|
button.setFocus()
|
||||||
|
|
||||||
def reboot(self):
|
def reboot(self):
|
||||||
try:
|
try:
|
||||||
|
@ -8,6 +8,76 @@ current_theme_name = read_theme_from_config()
|
|||||||
favoriteLabelSize = 48, 48
|
favoriteLabelSize = 48, 48
|
||||||
pixmapsScaledSize = 60, 60
|
pixmapsScaledSize = 60, 60
|
||||||
|
|
||||||
|
GAME_CARD_ANIMATION = {
|
||||||
|
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
|
||||||
|
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
|
||||||
|
# Значение в пикселях.
|
||||||
|
"default_border_width": 2,
|
||||||
|
|
||||||
|
# Ширина обводки при наведении курсора.
|
||||||
|
# Увеличивает толщину рамки, когда курсор находится над карточкой.
|
||||||
|
# Значение в пикселях.
|
||||||
|
"hover_border_width": 8,
|
||||||
|
|
||||||
|
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
|
||||||
|
# Увеличивает толщину рамки, когда карточка в фокусе.
|
||||||
|
# Значение в пикселях.
|
||||||
|
"focus_border_width": 12,
|
||||||
|
|
||||||
|
# Минимальная ширина обводки во время пульсирующей анимации.
|
||||||
|
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
|
||||||
|
# Значение в пикселях.
|
||||||
|
"pulse_min_border_width": 8,
|
||||||
|
|
||||||
|
# Максимальная ширина обводки во время пульсирующей анимации.
|
||||||
|
# Определяет максимальную толщину рамки при пульсации.
|
||||||
|
# Значение в пикселях.
|
||||||
|
"pulse_max_border_width": 10,
|
||||||
|
|
||||||
|
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
|
||||||
|
# Влияет на скорость перехода от одной ширины обводки к другой.
|
||||||
|
# Значение в миллисекундах.
|
||||||
|
"thickness_anim_duration": 300,
|
||||||
|
|
||||||
|
# Длительность одного цикла пульсирующей анимации.
|
||||||
|
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
|
||||||
|
# Значение в миллисекундах.
|
||||||
|
"pulse_anim_duration": 800,
|
||||||
|
|
||||||
|
# Длительность анимации вращения градиента.
|
||||||
|
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
|
||||||
|
# Значение в миллисекундах.
|
||||||
|
"gradient_anim_duration": 3000,
|
||||||
|
|
||||||
|
# Начальный угол градиента (в градусах).
|
||||||
|
# Определяет начальную точку вращения градиента при старте анимации.
|
||||||
|
"gradient_start_angle": 360,
|
||||||
|
|
||||||
|
# Конечный угол градиента (в градусах).
|
||||||
|
# Определяет конечную точку вращения градиента.
|
||||||
|
# Значение 0 означает полный поворот на 360 градусов.
|
||||||
|
"gradient_end_angle": 0,
|
||||||
|
|
||||||
|
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
|
||||||
|
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
|
||||||
|
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
|
||||||
|
"thickness_easing_curve": "OutBack",
|
||||||
|
|
||||||
|
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
|
||||||
|
# Влияет на "чувство" возврата к исходной ширине обводки.
|
||||||
|
"thickness_easing_curve_out": "InBack",
|
||||||
|
|
||||||
|
# Цвета градиента для анимированной обводки.
|
||||||
|
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
|
||||||
|
# Влияет на внешний вид обводки при наведении или фокусе.
|
||||||
|
"gradient_colors": [
|
||||||
|
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||||
|
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||||
|
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||||
|
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||||
MAIN_WINDOW_HEADER_STYLE = """
|
MAIN_WINDOW_HEADER_STYLE = """
|
||||||
QFrame {
|
QFrame {
|
||||||
@ -416,6 +486,26 @@ def get_protondb_badge_style(tier):
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def get_anticheat_badge_style(status):
|
||||||
|
status = status.lower()
|
||||||
|
status_colors = {
|
||||||
|
"supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
|
||||||
|
"running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
|
||||||
|
"planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
|
||||||
|
"broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
|
||||||
|
"denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
|
||||||
|
}
|
||||||
|
colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||||
|
return f"""
|
||||||
|
qproperty-alignment: AlignCenter;
|
||||||
|
background-color: {colors["background"]};
|
||||||
|
color: {colors["color"]};
|
||||||
|
font-size: 14px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: 'Poppins';
|
||||||
|
font-weight: bold;
|
||||||
|
"""
|
||||||
|
|
||||||
# СТИЛИ БЕЙДЖА STEAM
|
# СТИЛИ БЕЙДЖА STEAM
|
||||||
STEAM_BADGE_STYLE= """
|
STEAM_BADGE_STYLE= """
|
||||||
qproperty-alignment: AlignCenter;
|
qproperty-alignment: AlignCenter;
|
||||||
|
1
portprotonqt/themes/standart/images/icons/ac_broken.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 1c-3.8581 0-7 3.1419-7 7s3.1419 7 7 7 7-3.1419 7-7-3.1419-7-7-7zm0 1.3988c3.1014 0 5.6012 2.4998 5.6012 5.6012s-2.4998 5.6012-5.6012 5.6012-5.6012-2.4998-5.6012-5.6012 2.4998-5.6012 5.6012-5.6012zm-2.1002 3.501a0.70007 0.70007 0 0 0-0.69938 0.69938 0.70007 0.70007 0 0 0 0.69938 0.70144h0.0062a0.70007 0.70007 0 0 0 0.70144-0.70144 0.70007 0.70007 0 0 0-0.70144-0.69938zm4.2004 0a0.70007 0.70007 0 0 0-0.69938 0.69938 0.70007 0.70007 0 0 0 0.69938 0.70144h0.0062a0.70007 0.70007 0 0 0 0.70144-0.70144 0.70007 0.70007 0 0 0-0.70144-0.69938zm-2.1002 2.9452c-0.81784 0-1.6354 0.31214-2.2499 0.93935a0.70007 0.70007 0 0 0 0.01026 0.99062 0.70007 0.70007 0 0 0 0.98857-0.01026c0.69244-0.70672 1.8098-0.70672 2.5022 0a0.70007 0.70007 0 0 0 0.98857 0.01026 0.70007 0.70007 0 0 0 0.01026-0.99062c-0.61461-0.62721-1.4321-0.93935-2.2499-0.93935z" stop-color="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.0501"/></svg>
|
After (image error) Size: 1.0 KiB |
1
portprotonqt/themes/standart/images/icons/ac_denied.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.4694 1c-0.38007 0-0.76931 0.12326-1.0795 0.39899-0.24769 0.22016-0.42499 0.54342-0.48662 0.90798-0.11341-0.02303-0.22892-0.03356-0.34306-0.03356-0.38003 0-0.76934 0.12329-1.0795 0.39899s-0.51086 0.71412-0.51086 1.1914v3.2534c-0.58842-0.47053-1.4218-0.53407-2.0788-0.13983-0.73301 0.43947-0.98585 1.3992-0.56679 2.1441 1.1968 2.1273 1.8635 3.2982 2.1124 3.6804h-0.0019c0.04201 0.06466 0.08444 0.12946 0.12678 0.1939 0.78928 1.1985 2.1179 1.8996 3.5443 1.9595a0.63645 0.63645 0 0 0 0.04475 0.044746h1.2734c2.4527 0 4.4541-2.0014 4.4541-4.4541v-5.4087c0-0.47729-0.20066-0.9175-0.51085-1.1932-0.31015-0.27573-0.69944-0.39899-1.0795-0.39899-0.11425 0-0.22955 0.010492-0.34306 0.03356-0.0619-0.36382-0.23934-0.68631-0.48662-0.90611-0.3102-0.27573-0.69944-0.39899-1.0795-0.39899-0.11414 0-0.22964 0.010534-0.34306 0.03356-0.06162-0.36456-0.23893-0.68781-0.48662-0.90798-0.3102-0.27573-0.69944-0.39899-1.0795-0.39899zm0 1.2734c0.09723 0 0.18528 0.033977 0.23305 0.076442 0.04778 0.042465 0.08576 0.081413 0.08576 0.24051v5.4106a0.63639 0.63639 0 0 0 0.63577 0.63577 0.63639 0.63639 0 0 0 0.63577-0.63577v-4.1353a0.63639 0.63639 0 0 0 0-0.00186c0-0.1591 0.038-0.19805 0.08576-0.24051 0.04778-0.042465 0.13583-0.078306 0.23305-0.078306 0.09723 0 0.18528 0.035841 0.23305 0.078306 0.04778 0.042465 0.08576 0.081413 0.08576 0.24051v4.1372a0.63639 0.63639 0 0 0 0.63577 0.63577 0.63639 0.63639 0 0 0 0.63577-0.63577v-2.8619a0.63639 0.63639 0 0 0 0-0.00186c0-0.1591 0.03799-0.19805 0.08576-0.24051 0.04777-0.042465 0.13583-0.078306 0.23305-0.078306 0.09723 0 0.18528 0.035841 0.23305 0.078306s0.08576 0.081413 0.08576 0.24051v5.4087c0 1.7649-1.4177 3.1826-3.1826 3.1826h-1.141c-1.0712 1.81e-4 -2.0676-0.53724-2.6568-1.4319-0.04114-0.06262-0.08223-0.12548-0.12305-0.18831-0.14817-0.22748-0.8766-1.4876-2.0714-3.6114-0.08788-0.1562-0.03999-0.33666 0.11373-0.42882a0.63645 0.63645 0 0 0 0.0019 0c0.2198-0.13189 0.4917-0.097253 0.67306 0.083899l0.92662 0.92476 0.0093 0.00932a0.63639 0.63639 0 0 0 0.01492 0.011187 0.63623 0.63623 0 0 0 0.04847 0.042882 0.63623 0.63623 0 0 0 0.06898 0.046611 0.63623 0.63623 0 0 0 0.07458 0.037289 0.63623 0.63623 0 0 0 0.07831 0.026102 0.63623 0.63623 0 0 0 0.07831 0.01678 0.63639 0.63639 0 0 0 0.0037 0 0.63623 0.63623 0 0 0 0.08203 0.00559 0.63623 0.63623 0 0 0 0.08203-0.00559 0.63639 0.63639 0 0 0 0.36729-0.18085 0.63623 0.63623 0 0 0 0.05407-0.063391 0.63639 0.63639 0 0 0 0.04661-0.068984 0.63623 0.63623 0 0 0 0.08576-0.31695v-4.7729c0-0.15914 0.03796-0.19802 0.08576-0.24051 0.04781-0.042493 0.13579-0.078306 0.23305-0.078306s0.18525 0.035813 0.23305 0.078306c0.04781 0.042493 0.08576 0.081374 0.08576 0.24051v4.1372a0.63623 0.63623 0 0 0 0.63577 0.63577 0.63623 0.63623 0 0 0 0.63577-0.63577v-4.1353a0.63639 0.63639 0 0 0 0-0.00186v-1.2734c0-0.1591 0.038-0.19805 0.08576-0.24051 0.04778-0.042465 0.13583-0.076442 0.23305-0.076442z" stop-color="#000000" stroke-width="0"/></svg>
|
After (image error) Size: 3.0 KiB |
1
portprotonqt/themes/standart/images/icons/ac_planned.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="m4.499 1c-0.76498 0-1.3988 0.63379-1.3988 1.3988v1.4008c0 1.5752 0.61452 2.8449 1.5464 3.6733 0.22594 0.20084 0.46887 0.37639 0.724 0.5271-0.25512 0.15071-0.49805 0.32626-0.724 0.5271-0.93192 0.82837-1.5464 2.0982-1.5464 3.6733v1.4008c0 0.76498 0.63379 1.3988 1.3988 1.3988h7.002c0.76498 0 1.3988-0.63379 1.3988-1.3988v-1.4008c0-1.5752-0.61452-2.8449-1.5464-3.6733-0.22594-0.20084-0.46887-0.37639-0.724-0.5271 0.25513-0.15071 0.49805-0.32626 0.724-0.5271 0.93192-0.82837 1.5464-2.0982 1.5464-3.6733v-1.4008c0-0.76498-0.63379-1.3988-1.3988-1.3988zm0 1.3988h7.002v1.4008h-7.002zm0.23381 2.8016h6.5344c-0.18996 0.50569-0.4851 0.90658-0.845 1.2265-0.64324 0.57176-1.5277 0.87372-2.4222 0.87372s-1.779-0.30195-2.4222-0.87372c-0.3599-0.31991-0.65504-0.7208-0.845-1.2265zm3.2672 3.499c0.89453 0 1.779 0.30195 2.4222 0.87372 0.3599 0.31991 0.65504 0.7208 0.845 1.2265h-6.5344c0.18996-0.50569 0.4851-0.90658 0.845-1.2265 0.64324-0.57176 1.5277-0.87372 2.4222-0.87372zm-3.501 3.501h7.002v1.4008h-7.002z" stop-color="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.0501"/></svg>
|
After (image error) Size: 1.2 KiB |
1
portprotonqt/themes/standart/images/icons/ac_running.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="m9.3899 1a1.4351 1.4351 0 0 0-1.4349 1.4349 1.4351 1.4351 0 0 0 1.4349 1.4349 1.4351 1.4351 0 0 0 1.4349-1.4349 1.4351 1.4351 0 0 0-1.4349-1.4349zm-0.73373 3.5667a0.69575 0.69575 0 0 0-0.01427 0.00204 0.69568 0.69568 0 0 0-0.0591 0.00611 0.69575 0.69575 0 0 0-0.02446 0.00408 0.69568 0.69568 0 0 0-0.02038 0.00408l-3.4567 0.69093a0.69575 0.69575 0 0 0-0.56049 0.68278v2.0871a0.69568 0.69568 0 0 0 0.69704 0.69501 0.69568 0.69568 0 0 0 0.69501-0.69501v-1.5164l1.9322-0.38725-0.53196 3.1815a0.69575 0.69575 0 0 0 0.26904 0.67055l2.5049 1.8771v2.4356a0.69568 0.69568 0 0 0 0.69501 0.69501 0.69568 0.69568 0 0 0 0.69704-0.69501v-2.7821a0.69575 0.69575 0 0 0-0.27922-0.55641l-2.4437-1.8343 0.40355-2.4234 1.1312 1.1312a0.69575 0.69575 0 0 0 0.27107 0.16713l2.0871 0.69704a0.69568 0.69568 0 0 0 0.88048-0.44024 0.69568 0.69568 0 0 0-0.44024-0.88048l-1.9301-0.64405-1.9709-1.9709a0.69575 0.69575 0 0 0-0.05095-0.044839 0.69568 0.69568 0 0 0-0.0061-0.00408 0.69575 0.69575 0 0 0-0.04076-0.030572 0.69568 0.69568 0 0 0-0.05911-0.036687 0.69575 0.69575 0 0 0-0.04892-0.024458 0.69568 0.69568 0 0 0-0.0061-0.00204 0.69575 0.69575 0 0 0-0.06318-0.024458 0.69568 0.69568 0 0 0-0.10394-0.026496 0.69568 0.69568 0 0 0-0.0163-0.00204 0.69575 0.69575 0 0 0-0.13656-0.00611zm-1.5673 5.9127a0.69568 0.69568 0 0 0-0.58087 0.38317l-0.2833 0.56864-2.9573-0.59106a0.69568 0.69568 0 0 0-0.81933 0.54622 0.69568 0.69568 0 0 0 0.54622 0.8173l3.4771 0.69704a0.69575 0.69575 0 0 0 0.76023-0.37094l0.52176-1.0435a0.69568 0.69568 0 0 0-0.31184-0.93347 0.69568 0.69568 0 0 0-0.3526-0.07337z" stop-color="#000000" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.0435"/></svg>
|
After (image error) Size: 1.7 KiB |
@ -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="m11.896 0.99999a0.77558 0.77558 0 0 0-0.04089 0.0023c-2.9888 0.16296-5.5645 2.0598-6.7001 4.803-1.6574 0.22304-3.1671 1.0967-4.0123 2.5628a0.7755 0.7755 0 0 0-0.03181 0.2431 0.7755 0.7755 0 0 0-0.06589 0.052255 0.7755 0.7755 0 0 0 0.04998 0.063615 0.7755 0.7755 0 0 0 0.33171 0.69977 0.7755 0.7755 0 0 0 0.18176-0.047712 0.7755 0.7755 0 0 0 0.11587 0.14768c0.30901 0.036723 0.60932 0.09847 0.8997 0.18403-1.1944 1.1741-1.8021 2.8615-1.579 4.5735a0.77558 0.77558 0 0 0 0.67023 0.67023c1.712 0.22304 3.3994-0.38463 4.5735-1.579 0.08557 0.29041 0.1473 0.59066 0.18403 0.8997a0.77558 0.77558 0 0 0 1.1587 0.58163c1.4661-0.84521 2.3398-2.3549 2.5628-4.0123 2.7432-1.1355 4.64-3.7113 4.803-6.7001a0.77558 0.77558 0 0 0 0.0023-0.0409c0-1.704-1.3995-3.1035-3.1035-3.1035zm0.02045 1.5608c0.84612 0.01261 1.5096 0.67611 1.5222 1.5222-0.14636 2.4969-1.7648 4.6608-4.1259 5.4914a0.77558 0.77558 0 0 0-0.51801 0.70658c-0.03211 0.97216-0.51756 1.7993-1.1746 2.481-0.10516-0.31723-0.23158-0.62236-0.37715-0.91561a0.7755 0.7755 0 0 0-0.36352-0.64751c-0.53452-0.83732-1.2415-1.5443-2.0789-2.0789a0.7755 0.7755 0 0 0-0.64751-0.36352c-0.29324-0.14557-0.59838-0.27199-0.91561-0.37715 0.68166-0.65705 1.5088-1.1425 2.481-1.1746a0.77558 0.77558 0 0 0 0.70658-0.51801c0.83059-2.3611 2.9944-3.9795 5.4914-4.1259zm-1.5699 1.5427c-0.36621 0-0.74435 0.11713-1.0497 0.38851-0.3053 0.27138-0.50211 0.7086-0.50211 1.161s0.19681 0.89187 0.50211 1.1633c0.3053 0.27138 0.68345 0.38851 1.0497 0.38851 0.36621 0 0.74208-0.11713 1.0474-0.38851 0.3053-0.27138 0.50211-0.71088 0.50211-1.1633s-0.19681-0.8896-0.50211-1.161c-0.3053-0.27138-0.68117-0.38851-1.0474-0.38851zm-6.1843 6.3388c0.54645 0.37575 1.0192 0.84854 1.395 1.395-0.66921 0.88979-1.7235 1.346-2.8377 1.4427 0.09666-1.1142 0.55292-2.1685 1.4427-2.8377z" stop-color="#000000" stroke-width="0"/></svg>
|
After (image error) Size: 1.9 KiB |
1
portprotonqt/themes/standart/images/icons/cancel.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 15q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q0.945 0 1.82-0.30625t1.61-0.88375l-7.84-7.84q-0.5775 0.735-0.88375 1.61t-0.30625 1.82q0 2.345 1.6275 3.9725t3.9725 1.6275zm4.41-2.17q0.5775-0.735 0.88375-1.61t0.30625-1.82q0-2.345-1.6275-3.9725t-3.9725-1.6275q-0.945 0-1.82 0.30625t-1.61 0.88375z" fill="#fff" stroke-width=".0175"/></svg>
|
After (image error) Size: 655 B |
1
portprotonqt/themes/standart/images/icons/check.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="m6.5845 11.474-3.2932-3.2932 0.82331-0.82331 2.4699 2.4699 5.3009-5.3009 0.8233 0.82331z" fill="#fff" stroke-width=".014444"/></svg>
|
After (image error) Size: 260 B |
1
portprotonqt/themes/standart/images/icons/desktop.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="m1 15v-1.5556h14v1.5556zm1.9091-2.3333q-0.525 0-0.89886-0.45694-0.37386-0.45694-0.37386-1.0986v-8.5556q0-0.64167 0.37386-1.0986 0.37386-0.45694 0.89886-0.45694h10.182q0.525 0 0.89886 0.45694 0.37386 0.45694 0.37386 1.0986v8.5556q0 0.64167-0.37386 1.0986-0.37386 0.45694-0.89886 0.45694zm0-1.5556h10.182v-8.5556h-10.182zm0 0v-8.5556z" fill="#fff" stroke-width=".017588"/></svg>
|
After (image error) Size: 504 B |
1
portprotonqt/themes/standart/images/icons/epic_games.svg
Normal file
After (image error) Size: 16 KiB |
1
portprotonqt/themes/standart/images/icons/exit.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 1v1.5547h5.4453v10.891h-5.4453v1.5547h5.4453c0.42777-7e-6 0.79303-0.15241 1.0977-0.45703 0.30463-0.30462 0.45703-0.66988 0.45703-1.0977v-10.891c0-0.42777-0.1524-0.79303-0.45703-1.0977-0.30462-0.30463-0.66988-0.45703-1.0977-0.45703h-5.4453zm-3.1113 3.1113-3.8887 3.8887 3.8887 3.8887 1.0703-1.127-1.9844-1.9844h6.3594v-1.5547h-6.3594l1.9844-1.9844-1.0703-1.127z" fill="#fff"/></svg>
|
After (image error) Size: 512 B |
1
portprotonqt/themes/standart/images/icons/gog.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 1c-3.86 0-7 3.1403-7 7s3.14 7 7 7 7-3.1403 7-7-3.14-7-7-7zm-4.0784 3.3478h1.7655c0.26874 0 0.48684 0.21779 0.48684 0.48684v2.9828c0 0.26904-0.21779 0.48684-0.48684 0.48684h-2.2523v-0.6087h1.9479c0.10074 0 0.18249-0.08175 0.18249-0.18249v-2.3742c0-0.10074-0.08175-0.18249-0.18249-0.18249h-1.1568c-0.10074 0-0.18249 0.08175-0.18249 0.18249v1.1568c0 0.10074 0.08175 0.18249 0.18249 0.18249h0.88273v0.6087h-1.1871c-0.26874 0-0.48684-0.2178-0.48684-0.48684v-1.7655c0-0.26904 0.21779-0.48684 0.48684-0.48684zm3.1957 0h1.7655c0.26843 0 0.48684 0.2184 0.48684 0.48684v1.7655c0 0.26844-0.2184 0.48684-0.48684 0.48684h-1.7655c-0.26843 0-0.48684-0.2184-0.48684-0.48684v-1.7655c0-0.26844 0.2184-0.48684 0.48684-0.48684zm3.1957 0h1.7655c0.26874 0 0.48684 0.21779 0.48684 0.48684v2.9828c0 0.26904-0.21779 0.48684-0.48684 0.48684h-2.2523v-0.6087h1.9479c0.10074 0 0.18249-0.08175 0.18249-0.18249v-2.3742c0-0.10074-0.08175-0.18249-0.18249-0.18249h-1.1568c-0.10074 0-0.18249 0.08175-0.18249 0.18249v1.1568c0 0.10074 0.08175 0.18249 0.18249 0.18249h0.88273v0.6087h-1.1871c-0.26874 0-0.48684-0.2178-0.48684-0.48684v-1.7655c0-0.26904 0.21779-0.48684 0.48684-0.48684zm-2.8913 0.6087c-0.10074 0-0.18249 0.08175-0.18249 0.18249v1.1568c0 0.10074 0.08175 0.18249 0.18249 0.18249h1.1568c0.10074 0 0.18249-0.08175 0.18249-0.18249v-1.1568c0-0.10074-0.08175-0.18249-0.18249-0.18249zm-3.5 3.9565h1.7958v0.6087h-1.4914c-0.10074 0-0.18249 0.08175-0.18249 0.18249v1.1568c0 0.10074 0.08175 0.18249 0.18249 0.18249h1.4914v0.6087h-1.7958c-0.26874 0-0.48684-0.21779-0.48684-0.48684v-1.7655c0-0.26874 0.21779-0.48684 0.48684-0.48684zm2.7391 0h1.7655c0.26844 0 0.48684 0.2184 0.48684 0.48684v1.7655c0 0.26844-0.2184 0.48684-0.48684 0.48684h-1.7655c-0.26844 0-0.48684-0.2184-0.48684-0.48684v-1.7655c0-0.26844 0.2184-0.48684 0.48684-0.48684zm3.1957 0h2.7088v2.7391h-0.6087v-2.1304h-0.50229c-0.10074 0-0.18249 0.08175-0.18249 0.18249v1.9479h-0.6087v-2.1304h-0.50229c-0.10074 0-0.18249 0.08175-0.18249 0.18249v1.9479h-0.6087v-2.2523c0-0.26874 0.21779-0.48684 0.48684-0.48684zm-2.8913 0.6087c-0.10074 0-0.18249 0.08175-0.18249 0.18249v1.1568c0 0.10074 0.08175 0.18249 0.18249 0.18249h1.1568c0.10074 0 0.18249-0.08175 0.18249-0.18249v-1.1568c0-0.10074-0.08175-0.18249-0.18249-0.18249z" fill="#fff" stroke-width=".30435"/></svg>
|
After (image error) Size: 2.4 KiB |
1
portprotonqt/themes/standart/images/icons/login.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 15v-1.5556h5.4444v-10.889h-5.4444v-1.5556h5.4444q0.64167 0 1.0986 0.45694 0.45694 0.45694 0.45694 1.0986v10.889q0 0.64167-0.45694 1.0986t-1.0986 0.45694zm-1.5556-3.1111-1.0694-1.1278 1.9833-1.9833h-6.3583v-1.5556h6.3583l-1.9833-1.9833 1.0694-1.1278 3.8889 3.8889z" fill="#fff" stroke-width=".019444"/></svg>
|
After (image error) Size: 438 B |
1
portprotonqt/themes/standart/images/icons/reboot.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 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848-1.5846 1.5848-1.5848 3.8596-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z" fill="#fff"/></svg>
|
After (image error) Size: 741 B |
1
portprotonqt/themes/standart/images/icons/shutdown.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 15q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73q0-1.47 0.55125-2.7388t1.4962-2.2138l0.98 0.98q-0.77 0.77-1.1988 1.785t-0.42875 2.1875q0 2.345 1.6275 3.9725t3.9725 1.6275 3.9725-1.6275 1.6275-3.9725q0-1.1725-0.42875-2.1875t-1.1988-1.785l0.98-0.98q0.945 0.945 1.4962 2.2138t0.55125 2.7388q0 1.4525-0.55125 2.73t-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm-0.7-6.3v-7.7h1.4v7.7z" fill="#fff" stroke-width=".0175"/></svg>
|
After (image error) Size: 567 B |
Before (image error) Size: 1.7 KiB After (image error) Size: 1.7 KiB |
Before ![]() (image error) Size: 1.6 MiB After ![]() (image error) Size: 1.4 MiB ![]() ![]() |
Before ![]() (image error) Size: 621 KiB After ![]() (image error) Size: 562 KiB ![]() ![]() |
After ![]() (image error) Size: 445 KiB |
After ![]() (image error) Size: 1.4 MiB |
Before ![]() (image error) Size: 73 KiB After ![]() (image error) Size: 106 KiB ![]() ![]() |
BIN
portprotonqt/themes/standart/images/screenshots/Оверлей.png
Normal file
After ![]() (image error) Size: 1.1 MiB |
@ -8,13 +8,79 @@ current_theme_name = read_theme_from_config()
|
|||||||
favoriteLabelSize = 48, 48
|
favoriteLabelSize = 48, 48
|
||||||
pixmapsScaledSize = 60, 60
|
pixmapsScaledSize = 60, 60
|
||||||
|
|
||||||
|
GAME_CARD_ANIMATION = {
|
||||||
|
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
|
||||||
|
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
|
||||||
|
# Значение в пикселях.
|
||||||
|
"default_border_width": 2,
|
||||||
|
|
||||||
|
# Ширина обводки при наведении курсора.
|
||||||
|
# Увеличивает толщину рамки, когда курсор находится над карточкой.
|
||||||
|
# Значение в пикселях.
|
||||||
|
"hover_border_width": 8,
|
||||||
|
|
||||||
|
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
|
||||||
|
# Увеличивает толщину рамки, когда карточка в фокусе.
|
||||||
|
# Значение в пикселях.
|
||||||
|
"focus_border_width": 12,
|
||||||
|
|
||||||
|
# Минимальная ширина обводки во время пульсирующей анимации.
|
||||||
|
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
|
||||||
|
# Значение в пикселях.
|
||||||
|
"pulse_min_border_width": 8,
|
||||||
|
|
||||||
|
# Максимальная ширина обводки во время пульсирующей анимации.
|
||||||
|
# Определяет максимальную толщину рамки при пульсации.
|
||||||
|
# Значение в пикселях.
|
||||||
|
"pulse_max_border_width": 10,
|
||||||
|
|
||||||
|
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
|
||||||
|
# Влияет на скорость перехода от одной ширины обводки к другой.
|
||||||
|
# Значение в миллисекундах.
|
||||||
|
"thickness_anim_duration": 300,
|
||||||
|
|
||||||
|
# Длительность одного цикла пульсирующей анимации.
|
||||||
|
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
|
||||||
|
# Значение в миллисекундах.
|
||||||
|
"pulse_anim_duration": 800,
|
||||||
|
|
||||||
|
# Длительность анимации вращения градиента.
|
||||||
|
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
|
||||||
|
# Значение в миллисекундах.
|
||||||
|
"gradient_anim_duration": 3000,
|
||||||
|
|
||||||
|
# Начальный угол градиента (в градусах).
|
||||||
|
# Определяет начальную точку вращения градиента при старте анимации.
|
||||||
|
"gradient_start_angle": 360,
|
||||||
|
|
||||||
|
# Конечный угол градиента (в градусах).
|
||||||
|
# Определяет конечную точку вращения градиента.
|
||||||
|
# Значение 0 означает полный поворот на 360 градусов.
|
||||||
|
"gradient_end_angle": 0,
|
||||||
|
|
||||||
|
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
|
||||||
|
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
|
||||||
|
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
|
||||||
|
"thickness_easing_curve": "OutBack",
|
||||||
|
|
||||||
|
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
|
||||||
|
# Влияет на "чувство" возврата к исходной ширине обводки.
|
||||||
|
"thickness_easing_curve_out": "InBack",
|
||||||
|
|
||||||
|
# Цвета градиента для анимированной обводки.
|
||||||
|
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
|
||||||
|
# Влияет на внешний вид обводки при наведении или фокусе.
|
||||||
|
"gradient_colors": [
|
||||||
|
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||||
|
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||||
|
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||||
|
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
CONTEXT_MENU_STYLE = """
|
CONTEXT_MENU_STYLE = """
|
||||||
QMenu {
|
QMenu {
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
background: #282a33;;
|
||||||
stop:0 rgba(40, 40, 40, 0.95),
|
|
||||||
stop:1 rgba(25, 25, 25, 0.95));
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
||||||
border-radius: 12px;
|
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-family: 'Play';
|
font-family: 'Play';
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@ -27,12 +93,12 @@ CONTEXT_MENU_STYLE = """
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
QMenu::item:selected {
|
QMenu::item:selected {
|
||||||
background: #282a33;
|
background: #409EFF;
|
||||||
color: #09bec8;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
QMenu::item:hover {
|
QMenu::item:hover {
|
||||||
background: #282a33;
|
background: #409EFF;
|
||||||
color: #09bec8;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
QMenu::item:focus {
|
QMenu::item:focus {
|
||||||
background: #409EFF;
|
background: #409EFF;
|
||||||
@ -42,6 +108,55 @@ CONTEXT_MENU_STYLE = """
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
|
||||||
|
MAIN_WINDOW_STYLE = """
|
||||||
|
QWidget {
|
||||||
|
background: #282a33;
|
||||||
|
}
|
||||||
|
QLabel {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
background: #3f424d;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.01);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 16px;
|
||||||
|
font-family: 'Play';
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background: #409EFF;
|
||||||
|
border: 2px solid #409EFF;
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background: #282a33;
|
||||||
|
}
|
||||||
|
QPushButton:focus {
|
||||||
|
border: 2px solid #409EFF;
|
||||||
|
background-color: #409EFF;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# СТИЛЬ ПРОГРЕСС-БАРА
|
||||||
|
PROGRESS_BAR_STYLE = """
|
||||||
|
QProgressBar {
|
||||||
|
color: #ffffff;
|
||||||
|
background-color: #3f424d;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
QProgressBar::chunk {
|
||||||
|
background-color: #409EFF;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
# СТИЛЬ СТАТУС-БАРА
|
||||||
|
STATUS_BAR_STYLE = """
|
||||||
|
QStatusBar {
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||||
MAIN_WINDOW_HEADER_STYLE = """
|
MAIN_WINDOW_HEADER_STYLE = """
|
||||||
QFrame {
|
QFrame {
|
||||||
@ -67,7 +182,7 @@ TITLE_LABEL_STYLE = """
|
|||||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
|
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
|
||||||
NAV_WIDGET_STYLE = """
|
NAV_WIDGET_STYLE = """
|
||||||
QWidget {
|
QWidget {
|
||||||
background: none;
|
background: transparent;
|
||||||
border: 0px solid;
|
border: 0px solid;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
@ -82,29 +197,19 @@ NAV_BUTTON_STYLE = """
|
|||||||
font-family: 'Play';
|
font-family: 'Play';
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
border: none;
|
border: #409EFF;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
}
|
}
|
||||||
NavLabel[checked = true] {
|
NavLabel[checked = true] {
|
||||||
background: rgba(0,122,255,0);
|
background: rgba(0,122,255,0);
|
||||||
color: #09bec8;
|
color: #409EFF;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
border-radius: 15px;
|
border-radius: 15px;
|
||||||
}
|
}
|
||||||
NavLabel:hover {
|
NavLabel:hover {
|
||||||
background: none;
|
background: none;
|
||||||
color: #09bec8;
|
color: #409EFF;
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
|
|
||||||
MAIN_WINDOW_STYLE = """
|
|
||||||
QMainWindow {
|
|
||||||
background: none;
|
|
||||||
}
|
|
||||||
QLabel {
|
|
||||||
color: #232627;
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -120,14 +225,7 @@ SEARCH_EDIT_STYLE = """
|
|||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
QLineEdit:focus {
|
QLineEdit:focus {
|
||||||
border: 1px solid #09bec8;
|
border: 1px solid #409EFF;
|
||||||
}
|
|
||||||
"""
|
|
||||||
|
|
||||||
SETTINGS_CHECKBOX_STYLE = """
|
|
||||||
QCheckBox:focus {
|
|
||||||
border: 2px solid #409EFF;
|
|
||||||
background: #404554;
|
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -228,7 +326,7 @@ INSTALLED_TAB_TITLE_STYLE = "font-family: 'Play'; font-size: 24px; color: #fffff
|
|||||||
ACTION_BUTTON_STYLE = """
|
ACTION_BUTTON_STYLE = """
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background: #3f424d;
|
background: #3f424d;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.20);
|
border: 2px solid rgba(255, 255, 255, 0.01);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@ -236,36 +334,40 @@ ACTION_BUTTON_STYLE = """
|
|||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background: #282a33;
|
background: #409EFF;
|
||||||
|
border: 2px solid #409EFF;
|
||||||
}
|
}
|
||||||
QPushButton:pressed {
|
QPushButton:pressed {
|
||||||
background: #282a33;
|
background: #282a33;
|
||||||
}
|
}
|
||||||
QPushButton:focus {
|
QPushButton:focus {
|
||||||
border: 2px solid #409EFF;
|
border: 2px solid #409EFF;
|
||||||
background-color: #404554;
|
background-color: #409EFF;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# СТИЛЬ КНОПОК ОВЕРЛЕЯ
|
# СТИЛЬ ОВЕРЛЕЯ
|
||||||
|
OVERLAY_WINDOW_STYLE = "background: #282a33;"
|
||||||
OVERLAY_BUTTON_STYLE = """
|
OVERLAY_BUTTON_STYLE = """
|
||||||
QPushButton {
|
QPushButton {
|
||||||
background: #3f424d;
|
background: #3f424d;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.20);
|
border: 2px solid rgba(255, 255, 255, 0.01);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
font-family: 'Play';
|
font-family: 'Play';
|
||||||
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background: #282a33;
|
background: #409EFF;
|
||||||
|
border: 2px solid #409EFF;
|
||||||
}
|
}
|
||||||
QPushButton:pressed {
|
QPushButton:pressed {
|
||||||
background: #282a33;
|
background: #282a33;
|
||||||
}
|
}
|
||||||
QPushButton:focus {
|
QPushButton:focus {
|
||||||
border: 2px solid #409EFF;
|
border: 2px solid #409EFF;
|
||||||
background-color: #404554;
|
background-color: #409EFF;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -331,10 +433,10 @@ ADDGAME_BACK_BUTTON_STYLE = """
|
|||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
}
|
}
|
||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background: #09bec8;
|
background: #409EFF;
|
||||||
}
|
}
|
||||||
QPushButton:pressed {
|
QPushButton:pressed {
|
||||||
background: #09bec8;
|
background: #409EFF;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -388,10 +490,10 @@ PLAY_BUTTON_STYLE = """
|
|||||||
min-height: 40px;
|
min-height: 40px;
|
||||||
}
|
}
|
||||||
QPushButton:hover {
|
QPushButton:hover {
|
||||||
background: #09bec8;
|
background: #409EFF;
|
||||||
}
|
}
|
||||||
QPushButton:pressed {
|
QPushButton:pressed {
|
||||||
background: #09bec8;
|
background: #409EFF;
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@ -416,6 +518,40 @@ DIALOG_BROWSE_BUTTON_STYLE = """
|
|||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ADDGAME_INPUT_STYLE = """
|
||||||
|
QLineEdit {
|
||||||
|
background: #3f424d;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.01);
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 34px;
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Play';
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
QLineEdit:hover {
|
||||||
|
background: #3f424d;
|
||||||
|
border: 2px solid #409EFF;
|
||||||
|
}
|
||||||
|
QLineEdit:focus {
|
||||||
|
border: 2px solid #409EFF;
|
||||||
|
background-color: #404554;
|
||||||
|
}
|
||||||
|
QMenu {
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 5px 10px;
|
||||||
|
background: #32343d;
|
||||||
|
}
|
||||||
|
QMenu::item {
|
||||||
|
padding: 0px 10px;
|
||||||
|
border: 10px solid transparent; /* reserve space for selection border */
|
||||||
|
}
|
||||||
|
QMenu::item:selected {
|
||||||
|
background: #3f424d;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
|
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
|
||||||
GAME_CARD_WINDOW_STYLE = """
|
GAME_CARD_WINDOW_STYLE = """
|
||||||
QFrame {
|
QFrame {
|
||||||
@ -478,6 +614,27 @@ def get_protondb_badge_style(tier):
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# СТИЛИ БЕЙДЖА WEANTICHEATYET
|
||||||
|
def get_anticheat_badge_style(status):
|
||||||
|
status = status.lower()
|
||||||
|
status_colors = {
|
||||||
|
"supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
|
||||||
|
"running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
|
||||||
|
"planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
|
||||||
|
"broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
|
||||||
|
"denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
|
||||||
|
}
|
||||||
|
colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||||
|
return f"""
|
||||||
|
qproperty-alignment: AlignCenter;
|
||||||
|
background-color: {colors["background"]};
|
||||||
|
color: {colors["color"]};
|
||||||
|
font-size: 16px;
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: 'Play';
|
||||||
|
font-weight: bold;
|
||||||
|
"""
|
||||||
|
|
||||||
# СТИЛИ БЕЙДЖА STEAM
|
# СТИЛИ БЕЙДЖА STEAM
|
||||||
STEAM_BADGE_STYLE= """
|
STEAM_BADGE_STYLE= """
|
||||||
qproperty-alignment: AlignCenter;
|
qproperty-alignment: AlignCenter;
|
||||||
@ -532,7 +689,7 @@ PARAMS_TITLE_STYLE = "color: #ffffff; font-family: 'Play'; font-size: 16px; padd
|
|||||||
PROXY_INPUT_STYLE = """
|
PROXY_INPUT_STYLE = """
|
||||||
QLineEdit {
|
QLineEdit {
|
||||||
background: #282a33;
|
background: #282a33;
|
||||||
border: 0px solid rgba(255, 255, 255, 0.2);
|
border: 2px solid rgba(255, 255, 255, 0.01);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
@ -540,8 +697,13 @@ PROXY_INPUT_STYLE = """
|
|||||||
font-family: 'Play';
|
font-family: 'Play';
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
QLineEdit:hover {
|
||||||
|
background: #3f424d;
|
||||||
|
border: 2px solid #409EFF;
|
||||||
|
}
|
||||||
QLineEdit:focus {
|
QLineEdit:focus {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 2px solid #409EFF;
|
||||||
|
background-color: #404554;
|
||||||
}
|
}
|
||||||
QMenu {
|
QMenu {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
@ -561,7 +723,7 @@ PROXY_INPUT_STYLE = """
|
|||||||
SETTINGS_COMBO_STYLE = f"""
|
SETTINGS_COMBO_STYLE = f"""
|
||||||
QComboBox {{
|
QComboBox {{
|
||||||
background: #3f424d;
|
background: #3f424d;
|
||||||
border: 0px solid rgba(255, 255, 255, 0.2);
|
border: 2px solid rgba(255, 255, 255, 0.01);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
height: 34px;
|
height: 34px;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
@ -573,19 +735,21 @@ SETTINGS_COMBO_STYLE = f"""
|
|||||||
}}
|
}}
|
||||||
QComboBox:on {{
|
QComboBox:on {{
|
||||||
background: #373a43;
|
background: #373a43;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 2px solid #409EFF;
|
||||||
|
border-bottom-style: none;
|
||||||
border-top-left-radius: 10px;
|
border-top-left-radius: 10px;
|
||||||
border-top-right-radius: 10px;
|
border-top-right-radius: 10px;
|
||||||
border-bottom-left-radius: 0px;
|
border-bottom-left-radius: 0px;
|
||||||
border-bottom-right-radius: 0px;
|
border-bottom-right-radius: 0px;
|
||||||
}}
|
}}
|
||||||
QComboBox:hover {{
|
QComboBox:hover {{
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 2px solid #409EFF;
|
||||||
|
background: #409EFF;
|
||||||
}}
|
}}
|
||||||
/* Состояние фокуса */
|
/* Состояние фокуса */
|
||||||
QComboBox:focus {{
|
QComboBox:focus {{
|
||||||
border: 2px solid #409EFF;
|
border: 2px solid #409EFF;
|
||||||
background-color: #404554;
|
background-color: #409EFF;
|
||||||
}}
|
}}
|
||||||
QComboBox::drop-down {{
|
QComboBox::drop-down {{
|
||||||
subcontrol-origin: padding;
|
subcontrol-origin: padding;
|
||||||
@ -610,15 +774,20 @@ SETTINGS_COMBO_STYLE = f"""
|
|||||||
/* Список при открытом комбобоксе */
|
/* Список при открытом комбобоксе */
|
||||||
QComboBox QAbstractItemView {{
|
QComboBox QAbstractItemView {{
|
||||||
outline: none;
|
outline: none;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 2px solid #409EFF;
|
||||||
border-top-style: none;
|
border-top-style: none;
|
||||||
|
border-top-left-radius: 0px;
|
||||||
|
border-top-right-radius: 0px;
|
||||||
|
border-bottom-left-radius: 10px;
|
||||||
|
border-bottom-right-radius: 10px;
|
||||||
}}
|
}}
|
||||||
QListView {{
|
QListView {{
|
||||||
background: #3f424d;
|
background: #3f424d;
|
||||||
}}
|
}}
|
||||||
QListView::item {{
|
QListView::item {{
|
||||||
padding: 7px 7px 7px 12px;
|
padding: 7px 7px 7px 12px;
|
||||||
border-radius: 0px;
|
margin: 0px 3px 3px 3px;
|
||||||
|
border-radius: 10px;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}}
|
}}
|
||||||
QListView::item:hover {{
|
QListView::item:hover {{
|
||||||
@ -634,6 +803,32 @@ SETTINGS_COMBO_STYLE = f"""
|
|||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
SETTINGS_CHECKBOX_STYLE = f"""
|
||||||
|
QCheckBox {{
|
||||||
|
height: 34px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Play';
|
||||||
|
font-size: 16px;
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator {{
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.01);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #282a33;
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator:hover {{
|
||||||
|
background: #3f424d;
|
||||||
|
border: 2px solid #409EFF;
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator:focus {{
|
||||||
|
border: 2px solid #409EFF;
|
||||||
|
}}
|
||||||
|
QCheckBox::indicator:checked {{
|
||||||
|
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||||
|
border: 2px solid #409EFF;
|
||||||
|
}}
|
||||||
|
"""
|
||||||
|
|
||||||
# ФУНКЦИЯ ДЛЯ ДИНАМИЧЕСКОГО ГРАДИЕНТА (ДЕТАЛИ ИГР)
|
# ФУНКЦИЯ ДЛЯ ДИНАМИЧЕСКОГО ГРАДИЕНТА (ДЕТАЛИ ИГР)
|
||||||
# Функции из этой темы срабатывает всегда вне зависимости от выбранной темы, функции из других тем работают только в этих темах
|
# Функции из этой темы срабатывает всегда вне зависимости от выбранной темы, функции из других тем работают только в этих темах
|
||||||
|
@ -7,12 +7,13 @@ from portprotonqt.config_utils import read_theme_from_config
|
|||||||
|
|
||||||
class SystemTray:
|
class SystemTray:
|
||||||
def __init__(self, app, theme=None):
|
def __init__(self, app, theme=None):
|
||||||
|
self.app = app
|
||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
self.theme = theme if theme is not None else default_styles
|
self.theme = theme if theme is not None else default_styles
|
||||||
self.current_theme_name = read_theme_from_config()
|
self.current_theme_name = read_theme_from_config()
|
||||||
self.tray = QSystemTrayIcon()
|
self.tray = QSystemTrayIcon()
|
||||||
self.tray.setIcon(cast(QIcon, self.theme_manager.get_icon("ppqt-tray", self.current_theme_name)))
|
self.tray.setIcon(cast(QIcon, self.theme_manager.get_icon("ppqt-tray", self.current_theme_name)))
|
||||||
self.tray.setToolTip("PortProton QT")
|
self.tray.setToolTip("PortProtonQt")
|
||||||
self.tray.setVisible(True)
|
self.tray.setVisible(True)
|
||||||
|
|
||||||
# Создаём меню
|
# Создаём меню
|
||||||
@ -32,4 +33,17 @@ class SystemTray:
|
|||||||
|
|
||||||
def hide_tray(self):
|
def hide_tray(self):
|
||||||
"""Скрыть иконку трея"""
|
"""Скрыть иконку трея"""
|
||||||
self.tray.hide()
|
if self.tray:
|
||||||
|
self.tray.setVisible(False)
|
||||||
|
if self.menu:
|
||||||
|
self.menu.deleteLater()
|
||||||
|
self.menu = None
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
"""Очистка ресурсов трея"""
|
||||||
|
if self.tray:
|
||||||
|
self.tray.setVisible(False)
|
||||||
|
self.tray = None
|
||||||
|
if self.menu:
|
||||||
|
self.menu.deleteLater()
|
||||||
|
self.menu = None
|
||||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "portprotonqt"
|
name = "portprotonqt"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "GPL-3.0" }
|
license = { text = "GPL-3.0" }
|
||||||
|
26
renovate.json
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": ["config:best-practices"],
|
||||||
|
"rebaseWhen": "never",
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"packageRules": [
|
||||||
|
{
|
||||||
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
|
"automerge": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"automerge": true,
|
||||||
|
"matchUpdateTypes": ["pin", "pinDigest"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"matchFileNames": [".gitea/workflows/**.yaml", ".gitea/workflows/**.yml"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enabled": false,
|
||||||
|
"matchFileNames": [".python-version"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|