merge upstream
All checks were successful
Code and build check / Check code (pull_request) Successful in 1m25s
Code and build check / Build with uv (pull_request) Successful in 48s

This commit is contained in:
2025-06-12 09:52:06 +00:00
33 changed files with 1155 additions and 465 deletions

View File

@ -7,50 +7,57 @@
### Added ### Added
- Кнопки сброса настроек и очистки кэша - Кнопки сброса настроек и очистки кэша
- Начальная интеграция с EGS с помощью [Legendary](https://github.com/derrod/legendary)
- Бейдж EGS
- Бейдж PortProton - Бейдж PortProton
- Зависимость на `xdg-utils` - Зависимость от `xdg-utils`
- Интеграция статуса WeAntiCheatYet в карточку - Интеграция статуса WeAntiCheatYet в карточку
- Стили в AddGameDialog - Стили в 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 при управлении геймпадом
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме
- Оверлей на кнопку Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или между сессиями
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
- Мапинги управления для Dualshock 4 и DualSense
- Настройка тактильной обратной связи на геймпаде при запуске игры (по умолчанию отключена)
### Changed ### Changed
- Обновлены все иконки - Обновлены все иконки
- Переименован `_get_steam_home` `get_steam_home` - Переименована функция `_get_steam_home` в `get_steam_home`
- Переименован `steam_game` `game_source` - Переименован `steam_game` в `game_source`
- Догика контекстного меню вынесена в `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 больше не переключает вкладки только RB и LB - Теперь D-pad можно зажимать для переключения карточек
- D-pad больше не переключает вкладки, только RB и LB
- Кнопка добавления игры больше не фокусируется - Кнопка добавления игры больше не фокусируется
- Диалог добавления игры теперь открывается только в библиотеке
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt
### Fixed ### Fixed
- Обработка несуществующей темы с возвратом к standart” - Обработка несуществующей темы с возвратом к «standar
- Открытие контекстного меню - Открытие контекстного меню
- Запуск при отсутствии exiftool - Запуск при отсутствии exiftool
- Переводы пунктов настроек - Переводы пунктов настроек
- Бесконечное обращение к get_portproton_location - Бесконечное обращение к `get_portproton_location`
- Ссылки на документацию в README - Ссылки на документацию в README
- traceback при загрузке placeholder при отсутствии обложек - Traceback при загрузке placeholder при отсутствии обложек
- Утечки памяти при загрузке обложек - Утечки памяти при загрузке обложек
- Ошибки при подключении геймпада из-за работы в разных потоках - Ошибки при подключении геймпада из-за работы в разных потоках
- Множественное открытие диалога добавления игры на геймпаде - Многократное открытие диалога добавления игры при использовании геймпада
- Перехват событий геймпада во время работы игры - Перехват событий геймпада во время работы игры
--- ---
@ -64,16 +71,15 @@
- Сборка AppImage - Сборка AppImage
### Changed ### Changed
- Удалён жёстко заданный ресайз окна - Удалён жёстко заданный размер окна
- Использован icoextract как python модуль - Использован `icoextract` как Python-модуль
### Fixed ### Fixed
- Скрытие статус-бара - Скрытие статус-бара
- Чтение списка Steam-игр - Чтение списка Steam-игр
- Подвисание GUI - Зависание GUI
- Краш при повреждённом Steam - Сбой при повреждённом Steam
--- ---
> См. подробности по каждому коммиту в истории репозитория. > См. подробности по каждому коммиту в истории репозитория.

117
README.md
View File

@ -1,66 +1,73 @@
<div align="center"> <div align="center">
<img src="https://raw.githubusercontent.com/Castro-Fidel/PortWINE/master/data_from_portwine/img/gui/portproton.svg" width="64"> <img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64">
<h1 align="center">PortProtonQt</h1> <h1 align="center">PortProtonQt</h1>
<p align="center">Современный, удобный графический интерфейс, написанный с использованием PySide6(Qt6) и предназначенный для упрощения управления и запуска игр на различных платформах, включая PortProton, Steam и Epic Games Store.</p> <p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
</div> </div>
## В планах ## В планах
- [X] Адаптировать структуру проекта для поддержки инструментов сборки - [X] Адаптировать структуру проекта для поддержки инструментов сборки
- [ ] Добавить возможность управление с геймпада - [X] Добавить возможность управления с геймпада
- [ ] Добавить возможность управление с тачскрина - [ ] Добавить возможность управления с тачскрина
- [X] Добавить возможность управление с мыши и клавиатуры - [X] Добавить возможность управления с мыши и клавиатуры
- [X] Добавить систему тем [Документация](documentation/theme_guide) - [X] Добавить систему тем [Документация](documentation/theme_guide)
- [X] Вынести все константы такие как уровень закругления карточек в темы (Частично вынесено) - [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
- [X] Добавить метадату для тем (скришоты, описание, домащняя страница и автор) - [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор)
- [ ] Продумать систему вкладок вместо той что есть сейчас - [ ] Продумать систему вкладок вместо текущей
- [ ] Добавить Gamescope сессию на подобие той что есть в SteamOS - [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
- [ ] Написать адаптивный дизайн (За эталон берём SteamDeck с разрешением 1280х800) - [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
- [X] Брать описание и названия игр с базы данных Steam - [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
- [X] Брать обложки для игр со SteamGridDB или CDN Steam - [X] Получать описания и названия игр из базы данных Steam
- [X] Оптимизировать работу со SteamApi что бы ускорить время запуска - [X] Получать обложки для игр из SteamGridDB или CDN Steam
- [X] Улучшить функцию поиска SteamApi что бы исправить некорректное определение ID (Graven определается как ENGRAVEN или GRAVENFALL, Spore определается как SporeBound или Spore Valley) - [X] Оптимизировать работу со Steam API для ускорения времени запуска
- [ ] Убрать логи со SteamApi в релизной версии потому что логи замедляют код - [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
- [X] Что-то придумать с ограничением SteamApi в 50 тысяч игр за один запрос (иногда туда не попадают нужные игры и остаются без обложки) - [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [X] Избавится от любого вызова yad - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
- [X] Написать свою реализацию запрета ухода в сон, а не использовать ту что в PortProton (Оставим это [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0)) - [X] Избавиться от вызовов yad
- [X] Написать свою реализацию трея, а не использовать ту что в PortProton - [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0))
- [X] Добавить в поиск экранную клавиатуру (Реализовавывать собственную клавиатуру слишком затратно, лучше положится на встроенную в DE клавиатуру malit в KDE, gjs-osk в GNOME,Squeekboard в phosh, стимовская в SteamOS и так далее) - [X] Реализовать собственный системный трей вместо использования трея PortProton
- [X] Добавить сортировку карточек по различным критериям (сейчас есть: недавние, кол-во наиграного времени, избранное или по алфавиту) - [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
- [X] Добавить индикацию запуска приложения - [X] Добавить индикацию запуска приложения
- [X] Достичь паритета функционала с Ingame - [X] Достигнуть паритета функциональности с Ingame
- [ ] Достичь паритета функционала с PortProton - [ ] Достигнуть паритета функциональности с PortProton
- [X] Добавить возможность изменения названия, описания и обложки через файлы .local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover} - [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
- [X] Добавить встроенное переопределение имени, описания и обложки, например по пути portprotonqt/custom_data [Документация](documentation/metadata_override/) - [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
- [X] Добавить в карточку игры сведения о поддержке геймадов - [X] Добавить в карточку игры сведения о поддержке геймпада
- [X] Добавить в карточки данные с ProtonDB - [X] Добавить в карточки данные с ProtonDB
- [X] Добавить в карточки данные с Are We Anti-Cheat Yet? - [X] Добавить в карточки данные с AreWeAntiCheatYet
- [X] Продублировать бейджы с карточки на страницу с деталями игрыы - [X] Продублировать бейджи с карточки на страницу с деталями игры
- [X] Добавить парсинг ярлыков со Steam - [X] Добавить парсинг ярлыков из Steam
- [X] Добавить парсинг ярлыков с EGS - [X] Добавить парсинг ярлыков из EGS (скрыто для переработки)
- [ ] Избавится от бинарника legendary - [ ] Избавиться от бинарника legendary
- [ ] Добавить запуск и скачивание игр с EGS - [ ] Добавить запуск и скачивание игр из EGS
- [ ] Добавить авторизацию в 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)
- [X] Добавить на карточку бейдж того что игра со стима - [X] Добавить на карточку бейдж, указывающий, что игра из Steam
- [X] Добавить поддержку Flatpak и Snap версии Steam - [X] Добавить поддержку версий Steam для Flatpak и Snap
- [X] Выводить данные о самом недавнем пользователе Steam, а не первом попавшемся - [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
- [X] Исправить склонения в детальном выводе времени, например не 3 часов назад, а 3 часа назад - [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide) - [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
- [X] Писать описание игр и прочие данные на языке системы - [X] Отображать описания игр и другие данные на языке системы
- [X] Добавить недокументированные параметры конфигурации в GUI (time detail_level, games sort_method, games display_filter) - [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
- [X] Добавить систему избранного к карточкам - [X] Добавить систему избранного для карточек
- [X] Заменить все print на logging - [X] Заменить все `print` на `logging`
- [ ] Привести все логи к одному языку - [ ] Привести все логи к единому языку
- [X] Стилизовать все элементы без стилей(QMessageBox, QSlider, QDialog) - [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
- [X] Убрать жёсткую привязку путей на стрелочки QComboBox в styles.py - [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
- [X] Исправить частичное применение тем на лету - [X] Исправить частичное применение тем на лету
- [X] Исправить наложение подписей скриншотов при первом перелистывание в полноэкранном режиме - [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
- [ ] Добавить GOG (?) - [ ] Добавить поддержку GOG (?)
- [ ] Определится уже наконец с названием (PortProtonQt или PortProtonQT) - [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?)
- [X] Добавить виброотдачу на геймпаде при запуске игры
- [ ] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
- [ ] Скопировать логику управления с D-pad на стрелки с клавиатуры
### Установка (debug) ### Установка (devel)
```sh ```sh
uv python install 3.10 uv python install 3.10
@ -70,6 +77,12 @@ source .venv/bin/activate
Запуск производится по команде portprotonqt Запуск производится по команде portprotonqt
### Установка (release)
Выберите подходящий пакет для вашей системы или AppImage.
Запуск производится по команде portprotonqt или по ярлыку в меню
### Разработка ### Разработка
В проект встроен линтер (ruff), статический анализатор (pyright) и проверка lock файла, если эти проверки не пройдут PR не будет принят, поэтому перед коммитом введите такую команду В проект встроен линтер (ruff), статический анализатор (pyright) и проверка lock файла, если эти проверки не пройдут PR не будет принят, поэтому перед коммитом введите такую команду
@ -89,9 +102,9 @@ pre-commit run --all-files
## Авторы ## Авторы
* [Boria138](https://github.com/Boria138) - Программист * [Boria138](https://git.linux-gaming.ru/Boria138) - Программист
* [BlackSnaker](https://github.com/BlackSnaker) - Дизайнер - программист * [BlackSnaker](https://github.com/BlackSnaker) - Дизайнер - программист
* [Mikhail Tergoev(Castro-Fidel)](https://github.com/Castro-Fidel) - Автор оригинального проекта PortProton * [Mikhail Tergoev(Castro-Fidel)](https://git.linux-gaming.ru/CastroFidel) - Автор оригинального проекта PortProton
> [!WARNING] > [!WARNING]
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована > Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована

View File

@ -45,7 +45,7 @@ Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
%description -n python3-%{pypi_name}-git %description -n python3-%{pypi_name}-git
PortProtonQt is a modern, user-friendly graphical interface designed to streamline the management and launching of games across multiple platforms, including PortProton, Steam, and Epic Games Store. This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
%prep %prep
git clone https://git.linux-gaming.ru/Boria138/PortProtonQt.git git clone https://git.linux-gaming.ru/Boria138/PortProtonQt.git
@ -62,6 +62,8 @@ cp -r build-aux/share %{buildroot}/usr/
%files -n python3-%{pypi_name}-git -f %{pyproject_files} %files -n python3-%{pypi_name}-git -f %{pyproject_files}
%{_bindir}/%{pypi_name} %{_bindir}/%{pypi_name}
%{_datadir}/* %{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%changelog %changelog

View File

@ -42,7 +42,7 @@ Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
%description -n python3-%{pypi_name} %description -n python3-%{pypi_name}
PortProtonQt is a modern, user-friendly graphical interface designed to streamline the management and launching of games across multiple platforms, including PortProton, Steam, and Epic Games Store. This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
%prep %prep
git clone https://git.linux-gaming.ru/Boria138/PortProtonQt git clone https://git.linux-gaming.ru/Boria138/PortProtonQt
@ -61,6 +61,8 @@ cp -r build-aux/share %{buildroot}/usr/
%files -n python3-%{pypi_name} -f %{pyproject_files} %files -n python3-%{pypi_name} -f %{pyproject_files}
%{_bindir}/%{pypi_name} %{_bindir}/%{pypi_name}
%{_datadir}/* %{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%changelog %changelog

View File

@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop">
<name>PortProtonQt</name>
<id>ru.linux_gaming.PortProtonQt</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>GPL-3.0-or-later</project_license>
<summary>Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store</summary>
<summary xml:lang="ru">Современный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store</summary>
<description>
<p>
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.</p>
<p xml:lang="ru">
Это приложение предоставляет стильный и интуитивно понятный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет ваши игровые библиотеки в одном удобном хабе для простой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают целостный игровой опыт, устраняя необходимость в использовании нескольких лаунчеров. Уникальная интеграция с PortProton улучшает игровой процесс на Linux, позволяя с лёгкостью запускать Windows-игры с минимальными настройками.
</p>
</description>
<launchable type="desktop-id">ru.linux_gaming.PortProtonQt.desktop</launchable>
<developer id="ru.linux_gaming">
<name>Boria138</name>
</developer>
<recommends>
<control>keyboard</control>
<control>pointing</control>
<control>touch</control>
<control>gamepad</control>
</recommends>
<branding>
<color type="primary" scheme_preference="light">#007AFF</color>
<color type="primary" scheme_preference="dark">#09BEC8</color>
</branding>
<categories>
<category>Game</category>
<category>Utility</category>
</categories>
<url type="homepage">https://git.linux-gaming.ru/Boria138/PortProtonQt</url>
<url type="bugtracker">https://git.linux-gaming.ru/Boria138/PortProtonQt/issues</url>
<screenshots>
<screenshot type="default">
<image>https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/screenshots/%D0%91%D0%B8%D0%B1%D0%BB%D0%B8%D0%BE%D1%82%D0%B5%D0%BA%D0%B0.png</image>
<caption>Library</caption>
<caption xml:lang="ru">Библиотека</caption>
</screenshot>
<screenshot>
<image>https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/screenshots/%D0%9A%D0%B0%D1%80%D1%82%D0%BE%D1%87%D0%BA%D0%B0.png</image>
<caption>Card detail page</caption>
<caption xml:lang="ru">Детали игры</caption>
</screenshot>
<screenshot>
<image>https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/screenshots/%D0%9D%D0%B0%D1%81%D1%82%D1%80%D0%BE%D0%B9%D0%BA%D0%B8.png</image>
<caption>Settings</caption>
<caption xml:lang="ru">Настройки</caption>
</screenshot>
</screenshots>
<keywords>
<keyword translate="no">wine</keyword>
<keyword translate="no">proton</keyword>
<keyword translate="no">steam</keyword>
<keyword translate="no">windows</keyword>
<keyword translate="no">epic games store</keyword>
<keyword translate="no">egs</keyword>
<keyword translate="no">qt</keyword>
<keyword translate="no">portproton</keyword>
<keyword>games</keyword>
</keywords>
<content_rating type="oars-1.1" />
</component>

View File

@ -106,7 +106,7 @@ def compile_locales() -> None:
def extract_strings() -> None: def extract_strings() -> None:
input_dir = (Path(__file__).parent.parent / "portprotonqt").resolve() input_dir = (Path(__file__).parent.parent / "portprotonqt").resolve()
CommandLineInterface().run([ CommandLineInterface().run([
"pybabel", "extract", "--project=PortProtonQT", "pybabel", "extract", "--project=PortProtonQt",
f"--version={_get_version()}", f"--version={_get_version()}",
"--strip-comment-tag", "--strip-comment-tag",
"--no-location", "--no-location",
@ -231,7 +231,7 @@ def main(args) -> int:
return 0 return 0
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(prog="l10n", description="Localization utility for PortProtonQT.") parser = argparse.ArgumentParser(prog="l10n", description="Localization utility for PortProtonQt.")
parser.add_argument("--create-new", nargs='+', type=str, default=False, help="Create .po for new locales") parser.add_argument("--create-new", nargs='+', type=str, default=False, help="Create .po for new locales")
parser.add_argument("--update-all", action='store_true', help="Extract/update locales and update README coverage") parser.add_argument("--update-all", action='store_true', help="Extract/update locales and update README coverage")
parser.add_argument("--spellcheck", action='store_true', help="Run spellcheck on POT and PO files") parser.add_argument("--spellcheck", action='store_true', help="Run spellcheck on POT and PO files")

View File

@ -20,9 +20,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 154 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 162 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 154 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 162 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 154 of 154 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 162 of 162 |
--- ---

View File

@ -20,9 +20,9 @@
| Локаль | Прогресс | Переведено | | Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 154 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 162 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 154 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 162 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 154 из 154 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 162 из 162 |
--- ---

View File

@ -4,8 +4,9 @@ from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
from portprotonqt.tray import SystemTray from portprotonqt.tray import SystemTray
from portprotonqt.config_utils import read_theme_from_config from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.cli import parse_args
logger = get_logger(__name__) logger = get_logger(__name__)
@ -28,7 +29,17 @@ 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()
window = MainWindow() window = MainWindow()
# Обработка флага --fullscreen
if args.fullscreen:
logger.info("Запуск в полноэкранном режиме по флагу --fullscreen")
save_fullscreen_config(True)
window.showFullScreen()
current_theme_name = read_theme_from_config() current_theme_name = read_theme_from_config()
tray = SystemTray(app, current_theme_name) tray = SystemTray(app, current_theme_name)
tray.show_action.triggered.connect(window.show) tray.show_action.triggered.connect(window.show)
@ -43,7 +54,9 @@ def main():
tray.hide_action.triggered.connect(window.hide) tray.hide_action.triggered.connect(window.hide)
window.settings_saved.connect(recreate_tray) window.settings_saved.connect(recreate_tray)
window.show() window.show()
sys.exit(app.exec()) sys.exit(app.exec())
if __name__ == '__main__': if __name__ == '__main__':

16
portprotonqt/cli.py Normal file
View File

@ -0,0 +1,16 @@
import argparse
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def parse_args():
"""
Парсит аргументы командной строки.
"""
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
parser.add_argument(
"--fullscreen",
action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
)
return parser.parse_args()

View File

@ -10,7 +10,7 @@ _portproton_location = None
# Пути к конфигурационным файлам # Пути к конфигурационным файлам
CONFIG_FILE = os.path.join( CONFIG_FILE = os.path.join(
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")), os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
"PortProtonQT.conf" "PortProtonQt.conf"
) )
PORTPROTON_CONFIG_FILE = os.path.join( PORTPROTON_CONFIG_FILE = os.path.join(
@ -21,7 +21,7 @@ PORTPROTON_CONFIG_FILE = os.path.join(
# Пути к папкам с темами # Пути к папкам с темами
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQT", "themes"), os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
] ]
@ -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] конфигурационного файла.
@ -472,14 +504,14 @@ def reset_config():
def clear_cache(): def clear_cache():
""" """
Очищает кэш PortProtonQT, удаляя папку кэша. Очищает кэш PortProtonQt, удаляя папку кэша.
""" """
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQT") cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
if os.path.exists(cache_dir): if os.path.exists(cache_dir):
try: try:
shutil.rmtree(cache_dir) shutil.rmtree(cache_dir)
logger.info("Кэш PortProtonQT удалён: %s", cache_dir) logger.info("Кэш PortProtonQt удалён: %s", cache_dir)
except Exception as e: except Exception as e:
logger.error("Ошибка при удалении кэша: %s", e) logger.error("Ошибка при удалении кэша: %s", e)

View File

@ -6,12 +6,13 @@ import subprocess
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu from PySide6.QtWidgets import QMessageBox, QDialog, QMenu
from PySide6.QtCore import QUrl, QPoint from PySide6.QtCore import QUrl, QPoint
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from portprotonqt.config_utils import parse_desktop_entry 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
class ContextMenuManager: class ContextMenuManager:
"""Manages context menu actions for game management in PortProtonQT.""" """Manages context menu actions for game management in PortProtonQt."""
def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback): def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
""" """
@ -40,6 +41,18 @@ class ContextMenuManager:
""" """
menu = QMenu(self.parent) menu = QMenu(self.parent)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
favorites = read_favorites()
is_favorite = game_card.name in favorites
if is_favorite:
favorite_action = menu.addAction(_("Remove from Favorites"))
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, False))
else:
favorite_action = menu.addAction(_("Add to Favorites"))
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, True))
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")
@ -79,6 +92,26 @@ class ContextMenuManager:
menu.exec(game_card.mapToGlobal(pos)) menu.exec(game_card.mapToGlobal(pos))
def toggle_favorite(self, game_card, add: bool):
"""
Toggle the favorite status of a game and update its icon.
Args:
game_card: The GameCard instance to toggle.
add: True to add to favorites, False to remove.
"""
favorites = read_favorites()
if add and game_card.name not in favorites:
favorites.append(game_card.name)
game_card.is_favorite = True
self.parent.statusBar().showMessage(_("Added '{0}' to favorites").format(game_card.name), 3000)
elif not add and game_card.name in favorites:
favorites.remove(game_card.name)
game_card.is_favorite = False
self.parent.statusBar().showMessage(_("Removed '{0}' from favorites").format(game_card.name), 3000)
save_favorites(favorites)
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:
@ -225,7 +258,7 @@ class ContextMenuManager:
"XDG_DATA_HOME", "XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share") os.path.join(os.path.expanduser("~"), ".local", "share")
) )
custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name) custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
if os.path.exists(custom_folder): if os.path.exists(custom_folder):
try: try:
shutil.rmtree(custom_folder) shutil.rmtree(custom_folder)
@ -321,7 +354,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."""
from portprotonqt.dialogs import AddGameDialog # Local import to avoid circular dependency
if not self._check_portproton(): if not self._check_portproton():
return return
@ -385,7 +417,7 @@ class ContextMenuManager:
"XDG_DATA_HOME", "XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share") os.path.join(os.path.expanduser("~"), ".local", "share")
) )
custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name) custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", exe_name)
os.makedirs(custom_folder, exist_ok=True) os.makedirs(custom_folder, exist_ok=True)
ext = os.path.splitext(new_cover_path)[1].lower() ext = os.path.splitext(new_cover_path)[1].lower()

View File

@ -303,7 +303,7 @@ class Downloader(QObject):
local_path = os.path.join( local_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQT", "legendary_cache", "legendary" "PortProtonQt", "legendary_cache", "legendary"
) )
logger.info(f"Downloading legendary binary version {version} from {binary_url} to {local_path}") logger.info(f"Downloading legendary binary version {version} from {binary_url} to {local_path}")

View File

@ -22,7 +22,7 @@ def get_cache_dir() -> Path:
"XDG_CACHE_HOME", "XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache") os.path.join(os.path.expanduser("~"), ".cache")
) )
cache_dir = Path(xdg_cache_home) / "PortProtonQT" cache_dir = Path(xdg_cache_home) / "PortProtonQt"
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir return cache_dir
@ -36,7 +36,7 @@ def get_egs_game_description_async(
Asynchronously fetches the game description from the Epic Games Store API. Asynchronously fetches the game description from the Epic Games Store API.
Prioritizes GraphQL API with namespace for slug and description. Prioritizes GraphQL API with namespace for slug and description.
Falls back to legacy API if GraphQL provides a slug but no description. Falls back to legacy API if GraphQL provides a slug but no description.
Caches results in ~/.cache/PortProtonQT/egs_app_{app_name}.json. Caches results in ~/.cache/PortProtonQt/egs_app_{app_name}.json.
Handles DNS resolution failures gracefully. Handles DNS resolution failures gracefully.
""" """
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
@ -423,7 +423,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
except Exception as e: except Exception as e:
logger.warning("Error processing metadata for %s: %s", app_name, str(e)) logger.warning("Error processing metadata for %s: %s", app_name, str(e))
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_description_fetched(api_description: str): def on_description_fetched(api_description: str):

View File

@ -199,7 +199,7 @@ class GameCard(QFrame):
icon_size=16, icon_size=16,
icon_space=3, icon_space=3,
) )
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(int(card_width * 2/3))
anticheat_visible = True anticheat_visible = True
else: else:
@ -261,46 +261,45 @@ class GameCard(QFrame):
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"))
self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites")) self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
# Обновляем видимость бейджей
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.protondbLabel.setVisible(protondb_visible)
self.anticheatLabel.setVisible(anticheat_visible)
# Reposition badges # Подготавливаем список всех бейджей с их текущей видимостью
badges = [
(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 right_margin = 8
badge_spacing = 5 badge_spacing = 5
top_y = 10 top_y = 10
badge_y_positions = [] badge_y_positions = []
badge_width = int(self.coverLabel.width() * 2/3) badge_width = int(self.coverLabel.width() * 2/3)
if self.steam_visible:
steam_x = self.coverLabel.width() - badge_width - right_margin
self.steamLabel.move(steam_x, top_y)
badge_y_positions.append(top_y + self.steamLabel.height())
if self.egs_visible:
egs_x = self.coverLabel.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 self.portproton_visible:
portproton_x = self.coverLabel.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 self.protondbLabel.isVisible():
protondb_x = self.coverLabel.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 self.anticheatLabel.isVisible():
anticheat_x = self.coverLabel.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_() for is_visible, badge in badges:
self.protondbLabel.raise_() if is_visible:
self.portprotonLabel.raise_() badge_x = self.coverLabel.width() - badge_width - right_margin
self.egsLabel.raise_() badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
self.steamLabel.raise_() 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."""

View File

@ -35,10 +35,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
y = (scaled.height() - height) // 2 y = (scaled.height() - height) // 2
cropped = scaled.copy(x, y, width, height) cropped = scaled.copy(x, y, width, height)
callback(cropped) callback(cropped)
# Removed: pixmap = None (unnecessary, causes type error)
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQT", "images") image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
os.makedirs(image_folder, exist_ok=True) os.makedirs(image_folder, exist_ok=True)
if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"): if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):

View File

@ -1,16 +1,16 @@
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 from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
from PySide6.QtGui import QKeyEvent from PySide6.QtGui import QKeyEvent
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.image_utils import FullscreenDialog from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel 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__)
@ -25,6 +25,8 @@ class MainWindowProtocol(Protocol):
... ...
def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None: def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None:
... ...
def openSystemOverlay(self) -> None:
...
stackedWidget: QStackedWidget stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget] tabButtons: dict[int, QWidget]
gamesListWidget: QWidget gamesListWidget: QWidget
@ -32,24 +34,25 @@ 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_A, ecodes.BTN_SOUTH}, # A / Cross
'back': {ecodes.BTN_B}, 'back': {ecodes.BTN_B, ecodes.BTN_EAST}, # B / Circle
'add_game': {ecodes.BTN_Y}, 'add_game': {ecodes.BTN_Y, ecodes.BTN_NORTH}, # Y / Triangle
'prev_tab': {ecodes.BTN_TL, ecodes.BTN_TRIGGER_HAPPY7}, 'prev_tab': {ecodes.BTN_TL}, # LB / L1
'next_tab': {ecodes.BTN_TR, ecodes.BTN_TRIGGER_HAPPY5}, 'next_tab': {ecodes.BTN_TR}, # RB / R1
'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR}, 'context_menu': {ecodes.BTN_START}, # Start / Options
'context_menu': {ecodes.BTN_START}, 'menu': {ecodes.BTN_SELECT}, # Select / Share
'menu': {ecodes.BTN_SELECT}, 'guide': {ecodes.BTN_MODE}, # Xbox / PS Home
} }
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
@ -69,7 +72,6 @@ class InputManager(QObject):
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None) self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None) self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None) self._parent.current_add_game_dialog = getattr(self._parent, 'current_add_game_dialog', None)
self.axis_deadzone = axis_deadzone self.axis_deadzone = axis_deadzone
self.initial_axis_move_delay = initial_axis_move_delay self.initial_axis_move_delay = initial_axis_move_delay
self.repeat_axis_move_delay = repeat_axis_move_delay self.repeat_axis_move_delay = repeat_axis_move_delay
@ -80,6 +82,13 @@ 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
# Add variables for continuous D-pad movement
self.dpad_timer = QTimer(self)
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
self.current_dpad_code = None # Tracks the current D-pad axis (e.g., ABS_HAT0X, ABS_HAT0Y)
self.current_dpad_value = 0 # Tracks the current D-pad direction value (e.g., -1, 1)
# Connect signals to slots # Connect signals to slots
self.button_pressed.connect(self.handle_button_slot) self.button_pressed.connect(self.handle_button_slot)
@ -117,6 +126,48 @@ 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 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:
try: try:
@ -129,10 +180,66 @@ class InputManager(QObject):
return return
active = QApplication.activeWindow() active = QApplication.activeWindow()
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
# Handle Guide button to open system overlay
if button_code in BUTTONS['guide']:
if not popup and not isinstance(active, QDialog):
self._parent.openSystemOverlay()
return
# Handle QMenu (context menu)
if isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']:
if popup.activeAction():
popup.activeAction().trigger()
popup.close()
return
elif button_code in BUTTONS['back']:
popup.close()
return
return
# Handle QComboBox
if isinstance(focused, QComboBox):
if button_code in BUTTONS['confirm']:
focused.showPopup()
return
# Handle QListView
if isinstance(focused, QListView):
combo = None
parent = focused.parentWidget()
while parent:
if isinstance(parent, QComboBox):
combo = parent
break
parent = parent.parentWidget()
if button_code in BUTTONS['confirm']:
idx = focused.currentIndex()
if idx.isValid():
if combo:
combo.setCurrentIndex(idx.row())
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
else:
focused.activated.emit(idx)
focused.clicked.emit(idx)
focused.hide()
return
if button_code in BUTTONS['back']:
if combo:
combo.hidePopup()
combo.setFocus(Qt.FocusReason.OtherFocusReason)
else:
focused.clearSelection()
focused.hide()
# Закрытие AddGameDialog на кнопку B # Закрытие AddGameDialog на кнопку B
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
# FullscreenDialog # FullscreenDialog
@ -149,22 +256,26 @@ class InputManager(QObject):
if isinstance(focused, GameCard): if isinstance(focused, GameCard):
if button_code in BUTTONS['context_menu']: if button_code in BUTTONS['context_menu']:
pos = QPoint(focused.width() // 2, focused.height() // 2) pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos) menu = focused._show_context_menu(pos)
if menu:
menu.setFocus(Qt.FocusReason.OtherFocusReason)
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']:
self._parent.openAddGameDialog() if self._parent.stackedWidget.currentIndex() == 0:
self._parent.openAddGameDialog()
elif button_code in BUTTONS['prev_tab']: elif button_code in BUTTONS['prev_tab']:
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)
@ -176,6 +287,14 @@ class InputManager(QObject):
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)
def handle_dpad_repeat(self) -> None:
"""Handle repeated D-pad input while the D-pad is held."""
if self.current_dpad_code is not None and self.current_dpad_value != 0:
now = time.time()
if (now - self.last_move_time) >= self.current_axis_delay:
self.handle_dpad_slot(self.current_dpad_code, self.current_dpad_value, now)
self.last_move_time = now
self.current_axis_delay = self.repeat_axis_move_delay
@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:
@ -188,6 +307,85 @@ class InputManager(QObject):
if not app: if not app:
return return
active = QApplication.activeWindow() active = QApplication.activeWindow()
focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
# Update D-pad state
if value != 0:
self.current_dpad_code = code
self.current_dpad_value = value
if not self.axis_moving:
self.axis_moving = True
self.last_move_time = current_time
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000)) # Start timer (in milliseconds)
else:
self.current_dpad_code = None
self.current_dpad_value = 0
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
self.dpad_timer.stop() # Stop timer when D-pad is released
return
# Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad
if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0:
if isinstance(active, QMessageBox): # Specific handling for QMessageBox
if not focused or not active.focusWidget():
# If no widget is focused, focus the first focusable widget
focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return
if value > 0: # Right
active.focusNextChild()
elif value < 0: # Left
active.focusPreviousChild()
return
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0: # Keep up/down for other dialogs
if not focused or not active.focusWidget():
# If no widget is focused, focus the first focusable widget
focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus(Qt.FocusReason.OtherFocusReason)
return
if value > 0: # Down
active.focusNextChild()
elif value < 0: # Up
active.focusPreviousChild()
return
# Handle QMenu navigation with D-pad
if isinstance(popup, QMenu):
if code == ecodes.ABS_HAT0Y and value != 0:
actions = popup.actions()
if actions:
current_idx = actions.index(popup.activeAction()) if popup.activeAction() in actions else 0
if value < 0: # Up
next_idx = (current_idx - 1) % len(actions)
popup.setActiveAction(actions[next_idx])
elif value > 0: # Down
next_idx = (current_idx + 1) % len(actions)
popup.setActiveAction(actions[next_idx])
return
return
# Handle QListView navigation with D-pad
if isinstance(focused, QListView) and code == ecodes.ABS_HAT0Y and value != 0:
model = focused.model()
current_index = focused.currentIndex()
if model and current_index.isValid():
row_count = model.rowCount()
current_row = current_index.row()
if value > 0: # Down
next_row = min(current_row + 1, row_count - 1)
focused.setCurrentIndex(model.index(next_row, current_index.column()))
elif value < 0: # Up
prev_row = max(current_row - 1, 0)
focused.setCurrentIndex(model.index(prev_row, current_index.column()))
focused.scrollTo(focused.currentIndex(), QListView.ScrollHint.PositionAtCenter)
return
# Fullscreen horizontal navigation # Fullscreen horizontal navigation
if isinstance(active, FullscreenDialog) and code == ecodes.ABS_HAT0X: if isinstance(active, FullscreenDialog) and code == ecodes.ABS_HAT0X:
@ -197,19 +395,6 @@ class InputManager(QObject):
active.show_next() active.show_next()
return return
# Handle repeated D-pad movement
if value != 0:
if not self.axis_moving:
self.axis_moving = True
elif (current_time - self.last_move_time) < self.current_axis_delay:
return
self.last_move_time = current_time
self.current_axis_delay = self.repeat_axis_move_delay
else:
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
return
# Library tab navigation (index 0) # Library tab navigation (index 0)
if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
@ -280,7 +465,6 @@ class InputManager(QObject):
next_card.setFocus() next_card.setFocus()
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down
if value > 0: # Down if value > 0: # Down
next_row_idx = current_row_idx + 1 next_row_idx = current_row_idx + 1
@ -350,6 +534,12 @@ class InputManager(QObject):
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget() popup = QApplication.activePopupWidget()
# Open system overlay with Insert
if key == Qt.Key.Key_Insert:
if not popup and not isinstance(QApplication.activeWindow(), 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()
@ -390,6 +580,23 @@ class InputManager(QObject):
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) # Tab switching with Left/Right keys (non-GameCard focus or no focus)
idx = self._parent.stackedWidget.currentIndex() idx = self._parent.stackedWidget.currentIndex()
total = len(self._parent.tabButtons) total = len(self._parent.tabButtons)
@ -520,6 +727,9 @@ class InputManager(QObject):
if focusables: if focusables:
focusables[0].setFocus() focusables[0].setFocus()
return True return True
elif focused:
focused.focusNextChild()
return True
# Navigate up through tab content # Navigate up through tab content
if key == Qt.Key.Key_Up: if key == Qt.Key.Key_Up:
if isinstance(focused, NavLabel): if isinstance(focused, NavLabel):
@ -540,8 +750,10 @@ class InputManager(QObject):
elif key == Qt.Key.Key_E: elif key == Qt.Key.Key_E:
if isinstance(focused, QLineEdit): if isinstance(focused, QLineEdit):
return False return False
self._parent.openAddGameDialog() # Only open AddGameDialog if in library tab (index 0)
return True if self._parent.stackedWidget.currentIndex() == 0:
self._parent.openAddGameDialog()
return True
# Toggle fullscreen with F11 # Toggle fullscreen with F11
if key == Qt.Key.Key_F11: if key == Qt.Key.Key_F11:
@ -559,17 +771,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)
@ -577,6 +789,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()
@ -590,6 +803,7 @@ 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()
@ -626,9 +840,7 @@ 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)
@ -644,6 +856,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
@ -652,6 +865,8 @@ class InputManager(QObject):
def cleanup(self) -> None: def cleanup(self) -> None:
try: try:
self.running = False self.running = False
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:

View File

@ -1,6 +1,6 @@
# German (Germany) translations for PortProtonQT. # German (Germany) translations for PortProtonQt.
# Copyright (C) 2025 boria138 # Copyright (C) 2025 boria138
# This file is distributed under the same license as the PortProtonQT # This file is distributed under the same license as the PortProtonQt
# project. # project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025. # FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
# #
@ -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-06 20:01+0500\n" "POT-Creation-Date: 2025-06-11 23:15+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"
@ -20,6 +20,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Remove from Desktop" msgid "Remove from Desktop"
msgstr "" msgstr ""
@ -47,6 +53,14 @@ msgstr ""
msgid "Add to Steam" msgid "Add to Steam"
msgstr "" msgstr ""
#, python-brace-format
msgid "Added '{0}' to favorites"
msgstr ""
#, python-brace-format
msgid "Removed '{0}' from favorites"
msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
@ -362,19 +376,10 @@ msgstr ""
msgid "Auto Fullscreen on Gamepad connected:" msgid "Auto Fullscreen on Gamepad connected:"
msgstr "" msgstr ""
msgid "Open Legendary Login" msgid "Gamepad haptic feedback"
msgstr "" msgstr ""
msgid "Legendary Authentication:" msgid "Gamepad haptic feedback:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr "" msgstr ""
msgid "Save Settings" msgid "Save Settings"
@ -392,22 +397,6 @@ msgstr ""
msgid "Failed to open Legendary login page" msgid "Failed to open Legendary login page"
msgstr "" msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@ -505,6 +494,42 @@ msgstr ""
msgid "Launching" msgid "Launching"
msgstr "" msgstr ""
msgid "System Overlay"
msgstr ""
msgid "Reboot"
msgstr ""
msgid "Shutdown"
msgstr ""
msgid "Suspend"
msgstr ""
msgid "Exit Application"
msgstr ""
msgid "Return to Desktop"
msgstr ""
msgid "portprotonqt-session-select file not found at /usr/bin/"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Failed to reboot the system"
msgstr ""
msgid "Failed to shutdown the system"
msgstr ""
msgid "Failed to suspend the system"
msgstr ""
msgid "Failed to return to desktop"
msgstr ""
msgid "just now" msgid "just now"
msgstr "" msgstr ""

View File

@ -1,6 +1,6 @@
# Spanish (Spain) translations for PortProtonQT. # Spanish (Spain) translations for PortProtonQt.
# Copyright (C) 2025 boria138 # Copyright (C) 2025 boria138
# This file is distributed under the same license as the PortProtonQT # This file is distributed under the same license as the PortProtonQt
# project. # project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025. # FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
# #
@ -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-06 20:01+0500\n" "POT-Creation-Date: 2025-06-11 23:15+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"
@ -20,6 +20,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Remove from Desktop" msgid "Remove from Desktop"
msgstr "" msgstr ""
@ -47,6 +53,14 @@ msgstr ""
msgid "Add to Steam" msgid "Add to Steam"
msgstr "" msgstr ""
#, python-brace-format
msgid "Added '{0}' to favorites"
msgstr ""
#, python-brace-format
msgid "Removed '{0}' from favorites"
msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
@ -362,19 +376,10 @@ msgstr ""
msgid "Auto Fullscreen on Gamepad connected:" msgid "Auto Fullscreen on Gamepad connected:"
msgstr "" msgstr ""
msgid "Open Legendary Login" msgid "Gamepad haptic feedback"
msgstr "" msgstr ""
msgid "Legendary Authentication:" msgid "Gamepad haptic feedback:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr "" msgstr ""
msgid "Save Settings" msgid "Save Settings"
@ -392,22 +397,6 @@ msgstr ""
msgid "Failed to open Legendary login page" msgid "Failed to open Legendary login page"
msgstr "" msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@ -505,6 +494,42 @@ msgstr ""
msgid "Launching" msgid "Launching"
msgstr "" msgstr ""
msgid "System Overlay"
msgstr ""
msgid "Reboot"
msgstr ""
msgid "Shutdown"
msgstr ""
msgid "Suspend"
msgstr ""
msgid "Exit Application"
msgstr ""
msgid "Return to Desktop"
msgstr ""
msgid "portprotonqt-session-select file not found at /usr/bin/"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Failed to reboot the system"
msgstr ""
msgid "Failed to shutdown the system"
msgstr ""
msgid "Failed to suspend the system"
msgstr ""
msgid "Failed to return to desktop"
msgstr ""
msgid "just now" msgid "just now"
msgstr "" msgstr ""

View File

@ -1,15 +1,15 @@
# Translations template for PortProtonQT. # Translations template for PortProtonQt.
# Copyright (C) 2025 boria138 # Copyright (C) 2025 boria138
# This file is distributed under the same license as the PortProtonQT # This file is distributed under the same license as the PortProtonQt
# project. # project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025. # FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
# #
#, fuzzy #, fuzzy
msgid "" 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-06 20:01+0500\n" "POT-Creation-Date: 2025-06-11 23:15+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"
@ -18,6 +18,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Remove from Desktop" msgid "Remove from Desktop"
msgstr "" msgstr ""
@ -45,6 +51,14 @@ msgstr ""
msgid "Add to Steam" msgid "Add to Steam"
msgstr "" msgstr ""
#, python-brace-format
msgid "Added '{0}' to favorites"
msgstr ""
#, python-brace-format
msgid "Removed '{0}' from favorites"
msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
@ -360,19 +374,10 @@ msgstr ""
msgid "Auto Fullscreen on Gamepad connected:" msgid "Auto Fullscreen on Gamepad connected:"
msgstr "" msgstr ""
msgid "Open Legendary Login" msgid "Gamepad haptic feedback"
msgstr "" msgstr ""
msgid "Legendary Authentication:" msgid "Gamepad haptic feedback:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr "" msgstr ""
msgid "Save Settings" msgid "Save Settings"
@ -390,22 +395,6 @@ msgstr ""
msgid "Failed to open Legendary login page" msgid "Failed to open Legendary login page"
msgstr "" msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@ -503,6 +492,42 @@ msgstr ""
msgid "Launching" msgid "Launching"
msgstr "" msgstr ""
msgid "System Overlay"
msgstr ""
msgid "Reboot"
msgstr ""
msgid "Shutdown"
msgstr ""
msgid "Suspend"
msgstr ""
msgid "Exit Application"
msgstr ""
msgid "Return to Desktop"
msgstr ""
msgid "portprotonqt-session-select file not found at /usr/bin/"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Failed to reboot the system"
msgstr ""
msgid "Failed to shutdown the system"
msgstr ""
msgid "Failed to suspend the system"
msgstr ""
msgid "Failed to return to desktop"
msgstr ""
msgid "just now" msgid "just now"
msgstr "" msgstr ""

View File

@ -1,6 +1,6 @@
# Russian (Russia) translations for PortProtonQT. # Russian (Russia) translations for PortProtonQt.
# Copyright (C) 2025 boria138 # Copyright (C) 2025 boria138
# This file is distributed under the same license as the PortProtonQT # This file is distributed under the same license as the PortProtonQt
# project. # project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025. # FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
# #
@ -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-06 20:01+0500\n" "POT-Creation-Date: 2025-06-11 23:15+0500\n"
"PO-Revision-Date: 2025-06-06 20:01+0500\n" "PO-Revision-Date: 2025-06-11 23:15+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"
@ -21,6 +21,12 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n" "Generated-By: Babel 2.17.0\n"
msgid "Remove from Favorites"
msgstr "Удалить из Избранного"
msgid "Add to Favorites"
msgstr "Добавить в Избранное"
msgid "Remove from Desktop" msgid "Remove from Desktop"
msgstr "Удалить с рабочего стола" msgstr "Удалить с рабочего стола"
@ -48,6 +54,14 @@ msgstr "Удалить из Steam"
msgid "Add to Steam" msgid "Add to Steam"
msgstr "Добавить в Steam" msgstr "Добавить в Steam"
#, python-brace-format
msgid "Added '{0}' to favorites"
msgstr "Добавление '{0}' в избранное"
#, python-brace-format
msgid "Removed '{0}' from favorites"
msgstr "Удаление '{0}' из избранного"
msgid "Error" msgid "Error"
msgstr "Ошибка" msgstr "Ошибка"
@ -369,20 +383,11 @@ msgstr "Режим полноэкранного отображения прил
msgid "Auto Fullscreen on Gamepad connected:" msgid "Auto Fullscreen on Gamepad connected:"
msgstr "Режим полноэкранного отображения приложения при подключении геймпада:" msgstr "Режим полноэкранного отображения приложения при подключении геймпада:"
msgid "Open Legendary Login" msgid "Gamepad haptic feedback"
msgstr "Открыть браузер для входа в Legendary" msgstr "Тактильная обратная связь на геймпаде"
msgid "Legendary Authentication:" msgid "Gamepad haptic feedback:"
msgstr "Авторизация в Legendary:" msgstr "Тактильная обратная связь на геймпаде:"
msgid "Enter Legendary Authorization Code"
msgstr "Введите код авторизации Legendary"
msgid "Authorization Code:"
msgstr "Код авторизации:"
msgid "Submit Code"
msgstr "Отправить код"
msgid "Save Settings" msgid "Save Settings"
msgstr "Сохранить настройки" msgstr "Сохранить настройки"
@ -399,22 +404,6 @@ msgstr "Открытие страницы входа в Legendary в брауз
msgid "Failed to open Legendary login page" msgid "Failed to open Legendary login page"
msgstr "Не удалось открыть страницу входа в Legendary" msgstr "Не удалось открыть страницу входа в Legendary"
msgid "Please enter an authorization code"
msgstr "Пожалуйста, введите код авторизации"
msgid "Successfully authenticated with Legendary"
msgstr "Успешная аутентификация с Legendary"
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr "Сбой аутентификации в Legendary: {0}"
msgid "Legendary executable not found"
msgstr "Не найден исполняемый файл Legendary"
msgid "Unexpected error during authentication"
msgstr "Неожиданная ошибка при аутентификации"
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "Подтвердите удаление" msgstr "Подтвердите удаление"
@ -514,6 +503,42 @@ msgstr "Невозможно запустить игру пока запущен
msgid "Launching" msgid "Launching"
msgstr "Идёт запуск" msgstr "Идёт запуск"
msgid "System Overlay"
msgstr "Системный оверлей"
msgid "Reboot"
msgstr "Перезагрузить"
msgid "Shutdown"
msgstr "Выключить"
msgid "Suspend"
msgstr "Перейти в ждущий режим"
msgid "Exit Application"
msgstr "Выйти из приложения"
msgid "Return to Desktop"
msgstr "Вернуться на рабочий стол"
msgid "portprotonqt-session-select file not found at /usr/bin/"
msgstr "portprotonqt-session-select не найдет"
msgid "Cancel"
msgstr "Отмена"
msgid "Failed to reboot the system"
msgstr "Не удалось перезагрузить систему"
msgid "Failed to shutdown the system"
msgstr "Не удалось завершить работу системы"
msgid "Failed to suspend the system"
msgstr "Не удалось перейти в ждущий режим"
msgid "Failed to return to desktop"
msgstr "Не удалось вернуться на рабочий стол"
msgid "just now" msgid "just now"
msgstr "только что" msgstr "только что"

View File

@ -13,6 +13,7 @@ from portprotonqt.game_card import GameCard
from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel
from portprotonqt.input_manager import InputManager from portprotonqt.input_manager import InputManager
from portprotonqt.context_menu_manager import ContextMenuManager from portprotonqt.context_menu_manager import ContextMenuManager
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
@ -25,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
@ -43,7 +44,7 @@ from datetime import datetime
logger = get_logger(__name__) logger = get_logger(__name__)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
"""Main window of PortProtonQT.""" """Main window of PortProtonQt."""
settings_saved = Signal() settings_saved = Signal()
games_loaded = Signal(list) games_loaded = Signal(list)
update_progress = Signal(int) # Signal to update progress bar update_progress = Signal(int) # Signal to update progress bar
@ -72,10 +73,10 @@ class MainWindow(QMainWindow):
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed) self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
read_time_config() read_time_config()
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQT/legendary # Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
self.legendary_config_path = os.path.join( self.legendary_config_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQT", "legendary_cache" "PortProtonQt", "legendary_cache"
) )
os.makedirs(self.legendary_config_path, exist_ok=True) os.makedirs(self.legendary_config_path, exist_ok=True)
os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path os.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path
@ -97,10 +98,11 @@ class MainWindow(QMainWindow):
if not self.theme: if not self.theme:
self.theme = default_styles self.theme = default_styles
self.card_width = read_card_size() self.card_width = read_card_size()
self.setWindowTitle("PortProtonQT") self.setWindowTitle("PortProtonQt")
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
self.games = [] self.games = []
self.filtered_games = self.games
self.game_processes = [] self.game_processes = []
self.target_exe = None self.target_exe = None
self.current_running_button = None self.current_running_button = None
@ -259,39 +261,28 @@ 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, epic_games): def on_all_games(portproton_games, steam_games):
games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites] games = [game for game in portproton_games + steam_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: load_egs_games_async( lambda sg: on_all_games(pg, sg)
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, epic_games): def on_all_games(portproton_games, steam_games):
seen = set() seen = set()
games = [] games = []
for game in portproton_games + steam_games + epic_games: for game in portproton_games + steam_games:
name = game[0] # Уникальный ключ: имя + exec_line
if name not in seen: key = (game[0], game[4])
seen.add(name) if key not in seen:
seen.add(key)
games.append(game) games.append(game)
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: load_egs_games_async( lambda sg: on_all_games(pg, sg)
self.legendary_path,
lambda eg: on_all_games(pg, sg, eg),
self.downloader,
self.update_progress.emit,
self.update_status_message.emit
)
) )
) )
return [] return []
@ -394,7 +385,7 @@ class MainWindow(QMainWindow):
builtin_custom_folder = os.path.join(repo_root, "portprotonqt", "custom_data") builtin_custom_folder = os.path.join(repo_root, "portprotonqt", "custom_data")
xdg_data_home = os.getenv("XDG_DATA_HOME", xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")) os.path.join(os.path.expanduser("~"), ".local", "share"))
user_custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data") user_custom_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data")
os.makedirs(user_custom_folder, exist_ok=True) os.makedirs(user_custom_folder, exist_ok=True)
builtin_cover = "" builtin_cover = ""
@ -500,6 +491,11 @@ class MainWindow(QMainWindow):
btn.setChecked(i == index) btn.setChecked(i == index)
self.stackedWidget.setCurrentIndex(index) self.stackedWidget.setCurrentIndex(index)
def openSystemOverlay(self):
"""Opens the system overlay dialog."""
overlay = SystemOverlay(self, self.theme)
overlay.exec()
def createSearchWidget(self) -> tuple[QWidget, QLineEdit]: def createSearchWidget(self) -> tuple[QWidget, QLineEdit]:
self.container = QWidget() self.container = QWidget()
self.container.setStyleSheet(self.theme.CONTAINER_STYLE) self.container.setStyleSheet(self.theme.CONTAINER_STYLE)
@ -539,14 +535,20 @@ 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):
self.card_width = value
self.sizeSlider.setToolTip(f"{value} px")
save_card_size(value)
self.updateGameGrid()
def filterGamesDelayed(self): def filterGamesDelayed(self):
"""Filters games based on search text and updates the grid.""" """Filters games based on search text and updates the grid."""
text = self.searchEdit.text().strip().lower() text = self.searchEdit.text().strip().lower()
if text == "": if text == "":
self.updateGameGrid() # Use self.games directly self.filtered_games = self.games
else: else:
filtered = [game for game in self.games if text in game[0].lower()] self.filtered_games = [game for game in self.games if text in game[0].lower()]
self.updateGameGrid(filtered) self.updateGameGrid(self.filtered_games)
def createInstalledTab(self): def createInstalledTab(self):
self.gamesLibraryWidget = QWidget() self.gamesLibraryWidget = QWidget()
@ -579,33 +581,16 @@ 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)
sliderLayout.addWidget(self.sizeSlider) sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout) layout.addLayout(sliderLayout)
self.sliderDebounceTimer = QTimer(self)
self.sliderDebounceTimer.setSingleShot(True)
self.sliderDebounceTimer.setInterval(40)
def on_slider_value_changed():
self.setUpdatesEnabled(False)
self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px")
self.updateGameGrid()
self.setUpdatesEnabled(True)
self.sizeSlider.valueChanged.connect(lambda val: self.sliderDebounceTimer.start())
self.sliderDebounceTimer.timeout.connect(on_slider_value_changed)
def calculate_card_width(): def calculate_card_width():
available_width = scrollArea.width() - 20 available_width = scrollArea.width() - 20
spacing = self.gamesListLayout._spacing spacing = self.gamesListLayout._spacing
target_cards_per_row = 8 target_cards_per_row = 8
calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
calculated_width = max(200, min(calculated_width, 250)) calculated_width = max(200, min(calculated_width, 250))
if not self.sizeSlider.value() == self.card_width:
self.card_width = calculated_width
self.sizeSlider.setValue(self.card_width)
self.sizeSlider.setToolTip(f"{self.card_width} px")
self.updateGameGrid()
QTimer.singleShot(0, calculate_card_width) QTimer.singleShot(0, calculate_card_width)
@ -621,7 +606,6 @@ class MainWindow(QMainWindow):
self._last_width = self.width() self._last_width = self.width()
if abs(self.width() - self._last_width) > 10: if abs(self.width() - self._last_width) > 10:
self._last_width = self.width() self._last_width = self.width()
self.sliderDebounceTimer.start()
def loadVisibleImages(self): def loadVisibleImages(self):
visible_region = self.gamesListWidget.visibleRegion() visible_region = self.gamesListWidget.visibleRegion()
@ -638,22 +622,38 @@ class MainWindow(QMainWindow):
if games_list is None: if games_list is None:
games_list = self.games games_list = self.games
if not games_list: if not games_list:
self.clearLayout(self.gamesListLayout) # Скрываем все карточки, если список пуст
for card in self.game_card_cache.values():
card.hide()
self.game_card_cache.clear() self.game_card_cache.clear()
self.pending_images.clear() self.pending_images.clear()
self.gamesListWidget.updateGeometry()
return return
# Create a set of game names for quick lookup # Создаем словарь текущих игр с уникальным ключом (name + exec_line)
current_games = {game_data[0]: game_data for game_data in games_list} current_games = {(game_data[0], game_data[4]): game_data for game_data in games_list}
# Check if the grid is already up-to-date # Проверяем, изменился ли список игр или размер карточек
if set(current_games.keys()) == set(self.game_card_cache.keys()) and self.card_width == getattr(self, '_last_card_width', None): current_game_keys = set(current_games.keys())
return # No changes needed, skip update cached_game_keys = set(self.game_card_cache.keys())
card_width_changed = self.card_width != getattr(self, '_last_card_width', None)
# Track if layout has changed to decide if geometry update is needed if current_game_keys == cached_game_keys and not card_width_changed:
# Список игр и размер карточек не изменились, обновляем только видимость
search_text = self.searchEdit.text().strip().lower()
for game_key, card in self.game_card_cache.items():
game_name = game_key[0]
card.setVisible(search_text in game_name.lower() or not search_text)
self.loadVisibleImages()
return
# Обновляем размер карточек, если он изменился
if card_width_changed:
for card in self.game_card_cache.values():
card.setFixedWidth(self.card_width + 20) # Учитываем extra_margin в GameCard
# Удаляем карточки, которых больше нет в списке
layout_changed = False layout_changed = False
# Remove cards for games no longer in the list
for card_key in list(self.game_card_cache.keys()): for card_key in list(self.game_card_cache.keys()):
if card_key not in current_games: if card_key not in current_games:
card = self.game_card_cache.pop(card_key) card = self.game_card_cache.pop(card_key)
@ -663,11 +663,15 @@ class MainWindow(QMainWindow):
del self.pending_images[card_key] del self.pending_images[card_key]
layout_changed = True layout_changed = True
# Add or update cards for current games # Добавляем новые карточки и обновляем существующие
for game_data in games_list: for game_data in games_list:
game_name = game_data[0] game_name = game_data[0]
if game_name not in self.game_card_cache: game_key = (game_name, game_data[4])
# Create new card search_text = self.searchEdit.text().strip().lower()
should_be_visible = search_text in game_name.lower() or not search_text
if game_key not in self.game_card_cache:
# Создаем новую карточку
card = GameCard( card = GameCard(
*game_data, *game_data,
select_callback=self.openGameDetailPage, select_callback=self.openGameDetailPage,
@ -675,7 +679,7 @@ 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
) )
# Connect context menu signals # Подключаем сигналы контекстного меню
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)
card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu) card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
@ -685,23 +689,25 @@ class MainWindow(QMainWindow):
card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam) card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam) card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder) card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
self.game_card_cache[game_name] = card self.game_card_cache[game_key] = card
self.gamesListLayout.addWidget(card) self.gamesListLayout.addWidget(card)
layout_changed = True layout_changed = True
elif self.card_width != getattr(self, '_last_card_width', None): else:
# Update size only if card_width has changed # Обновляем видимость существующей карточки
card = self.game_card_cache[game_name] card = self.game_card_cache[game_key]
card.setFixedWidth(self.card_width + 20) # Account for extra_margin in GameCard card.setVisible(should_be_visible)
# Store the current card_width # Сохраняем текущий card_width
self._last_card_width = self.card_width self._last_card_width = self.card_width
# Trigger lazy image loading for visible cards # Принудительно обновляем макет
self.loadVisibleImages()
# Update layout geometry only if the layout has changed
if layout_changed: if layout_changed:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry() self.gamesListWidget.updateGeometry()
self.gamesListWidget.update()
# Загружаем изображения для видимых карточек
self.loadVisibleImages()
def clearLayout(self, layout): def clearLayout(self, layout):
"""Удаляет все виджеты из layout.""" """Удаляет все виджеты из layout."""
@ -742,6 +748,7 @@ class MainWindow(QMainWindow):
return return
dialog = AddGameDialog(self, self.theme) dialog = AddGameDialog(self, self.theme)
dialog.setFocus(Qt.FocusReason.OtherFocusReason)
self.current_add_game_dialog = dialog # Сохраняем ссылку на диалог self.current_add_game_dialog = dialog # Сохраняем ссылку на диалог
# Предзаполняем путь к .exe при drag-and-drop # Предзаполняем путь к .exe при drag-and-drop
@ -778,7 +785,7 @@ class MainWindow(QMainWindow):
os.path.join(os.path.expanduser("~"), ".local", "share")) os.path.join(os.path.expanduser("~"), ".local", "share"))
custom_folder = os.path.join( custom_folder = os.path.join(
xdg_data_home, xdg_data_home,
"PortProtonQT", "PortProtonQt",
"custom_data", "custom_data",
exe_name exe_name
) )
@ -920,7 +927,7 @@ 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"), "epic games store"] self.filter_labels = [_("all"), "steam", "portproton", _("favorites")]
self.gamesDisplayCombo = QComboBox() self.gamesDisplayCombo = QComboBox()
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)
@ -983,6 +990,7 @@ class MainWindow(QMainWindow):
self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected")) self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.autoFullscreenGamepadTitle = QLabel(_("Auto Fullscreen on Gamepad connected:")) self.autoFullscreenGamepadTitle = QLabel(_("Auto Fullscreen on Gamepad connected:"))
self.autoFullscreenGamepadTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.autoFullscreenGamepadTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.autoFullscreenGamepadTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.autoFullscreenGamepadTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
@ -990,36 +998,16 @@ 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. Legendary Authentication # 7. Gamepad haptic feedback config
self.legendaryAuthButton = AutoSizeButton( self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
_("Open Legendary Login"), self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
icon=self.theme_manager.get_icon("login") self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
) self.gamepadRumbleTitle = QLabel(_("Gamepad haptic feedback:"))
self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) self.gamepadRumbleTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.gamepadRumbleTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin) current_rumble_state = read_rumble_config()
self.legendaryAuthTitle = QLabel(_("Legendary Authentication:")) self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
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)
@ -1071,37 +1059,6 @@ 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(
@ -1172,6 +1129,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)
@ -1288,11 +1249,15 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(_("Theme '{0}' applied successfully").format(selected_theme), 3000) self.statusBar().showMessage(_("Theme '{0}' applied successfully").format(selected_theme), 3000)
xdg_data_home = os.getenv("XDG_DATA_HOME", xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")) os.path.join(os.path.expanduser("~"), ".local", "share"))
state_file = os.path.join(xdg_data_home, "PortProtonQT", "state.txt") state_file = os.path.join(xdg_data_home, "PortProtonQt", "state.txt")
os.makedirs(os.path.dirname(state_file), exist_ok=True) os.makedirs(os.path.dirname(state_file), exist_ok=True)
with open(state_file, "w", encoding="utf-8") as f: try:
f.write("theme_tab\n") with open(state_file, "w", encoding="utf-8") as f:
QTimer.singleShot(500, lambda: self.restart_application()) f.write("theme_tab\n")
logger.info(f"State saved to {state_file}")
QTimer.singleShot(500, lambda: self.restart_application())
except Exception as e:
logger.error(f"Failed to save state to {state_file}: {e}")
else: else:
self.statusBar().showMessage(_("Error applying theme '{0}'").format(selected_theme), 3000) self.statusBar().showMessage(_("Error applying theme '{0}'").format(selected_theme), 3000)
@ -1310,14 +1275,28 @@ class MainWindow(QMainWindow):
def restore_state(self): def restore_state(self):
"""Восстанавливает состояние приложения после перезапуска.""" """Восстанавливает состояние приложения после перезапуска."""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
state_file = os.path.join(xdg_cache_home, "PortProtonQT", "state.txt") state_file = os.path.join(xdg_data_home, "PortProtonQt", "state.txt")
logger.info(f"Checking for state file: {state_file}")
if os.path.exists(state_file): if os.path.exists(state_file):
with open(state_file, encoding="utf-8") as f: try:
state = f.read().strip() with open(state_file, encoding="utf-8") as f:
if state == "theme_tab": state = f.read().strip()
self.switchTab(5) logger.info(f"State file contents: '{state}'")
os.remove(state_file) if state == "theme_tab":
logger.info("Restoring to theme tab (index 5)")
if self.stackedWidget.count() > 5:
self.switchTab(5)
else:
logger.warning("Theme tab (index 5) not available yet")
else:
logger.warning(f"Unexpected state value: '{state}'")
os.remove(state_file)
logger.info(f"State file {state_file} removed")
except Exception as e:
logger.error(f"Failed to read or process state file {state_file}: {e}")
else:
logger.info(f"State file {state_file} does not exist")
# ЛОГИКА ДЕТАЛЬНОЙ СТРАНИЦЫ ИГРЫ # ЛОГИКА ДЕТАЛЬНОЙ СТРАНИЦЫ ИГРЫ
def getColorPalette_async(self, cover_path, num_colors=5, sample_step=10, callback=None): def getColorPalette_async(self, cover_path, num_colors=5, sample_step=10, callback=None):
@ -1514,7 +1493,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

View File

@ -49,7 +49,7 @@ def decode_text(text: str) -> str:
def get_cache_dir(): def get_cache_dir():
"""Возвращает путь к каталогу кэша, создаёт его при необходимости.""" """Возвращает путь к каталогу кэша, создаёт его при необходимости."""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQT") cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
os.makedirs(cache_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
return cache_dir return cache_dir

View File

@ -0,0 +1,109 @@
import subprocess
from PySide6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QMessageBox
from PySide6.QtWidgets import QApplication
from PySide6.QtCore import Qt
from portprotonqt.logger import get_logger
import os
from portprotonqt.localization import _
logger = get_logger(__name__)
class SystemOverlay(QDialog):
"""Overlay dialog for system actions like reboot, sleep, shutdown, suspend, and exit."""
def __init__(self, parent, theme):
super().__init__(parent)
self.theme = theme
self.setWindowTitle(_("System Overlay"))
self.setModal(True)
self.setFixedSize(400, 300)
layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(10)
# Reboot button
reboot_button = QPushButton(_("Reboot"))
reboot_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
reboot_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
reboot_button.clicked.connect(self.reboot)
layout.addWidget(reboot_button)
# Shutdown button
shutdown_button = QPushButton(_("Shutdown"))
shutdown_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
shutdown_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
shutdown_button.clicked.connect(self.shutdown)
layout.addWidget(shutdown_button)
# Suspend button
suspend_button = QPushButton(_("Suspend"))
suspend_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
suspend_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
suspend_button.clicked.connect(self.suspend)
layout.addWidget(suspend_button)
# Exit application button
exit_button = QPushButton(_("Exit Application"))
exit_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
exit_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
exit_button.clicked.connect(self.exit_application)
layout.addWidget(exit_button)
# Return to Desktop button
desktop_button = QPushButton(_("Return to Desktop"))
desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
desktop_button.clicked.connect(self.return_to_desktop)
script_path = "/usr/bin/portprotonqt-session-select"
script_exists = os.path.isfile(script_path)
desktop_button.setEnabled(script_exists)
if not script_exists:
desktop_button.setToolTip(_("portprotonqt-session-select file not found at /usr/bin/"))
layout.addWidget(desktop_button)
# Cancel button
cancel_button = QPushButton(_("Cancel"))
cancel_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
cancel_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
cancel_button.clicked.connect(self.reject)
layout.addWidget(cancel_button)
# Set focus to the first button
reboot_button.setFocus()
def reboot(self):
try:
subprocess.run(["systemctl", "reboot"], check=True)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to reboot: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to reboot the system"))
self.accept()
def shutdown(self):
try:
subprocess.run(["systemctl", "poweroff"], check=True)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to shutdown: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to shutdown the system"))
self.accept()
def suspend(self):
try:
subprocess.run(["systemctl", "suspend"], check=True)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to suspend: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to suspend the system"))
self.accept()
def return_to_desktop(self):
try:
script_path = os.path.join(os.path.dirname(__file__), "portprotonqt-session-select")
subprocess.run([script_path, "desktop"], check=True)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to return to desktop: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to return to desktop"))
self.accept()
def exit_application(self):
QApplication.quit()
self.accept()

View File

@ -11,7 +11,7 @@ logger = get_logger(__name__)
# Папка, где располагаются все дополнительные темы # Папка, где располагаются все дополнительные темы
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQT", "themes"), os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
] ]

View File

@ -1,5 +1,5 @@
[Metainfo] [Metainfo]
author = BlackSnaker author = BlackSnaker
author_link = author_link =
description = Стандартная тема PortProtonQT (светлый вариант) description = Стандартная тема PortProtonQt (светлый вариант)
name = Light name = Light

View File

@ -416,6 +416,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;

View File

@ -1,5 +1,5 @@
[Metainfo] [Metainfo]
author = Dervart author = Dervart
author_link = author_link =
description = Стандартная тема PortProtonQT (тёмный вариант) description = Стандартная тема PortProtonQt (тёмный вариант)
name = Clean Dark name = Clean Dark

View File

@ -8,6 +8,40 @@ current_theme_name = read_theme_from_config()
favoriteLabelSize = 48, 48 favoriteLabelSize = 48, 48
pixmapsScaledSize = 60, 60 pixmapsScaledSize = 60, 60
CONTEXT_MENU_STYLE = """
QMenu {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
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;
font-family: 'Play';
font-size: 16px;
padding: 5px;
}
QMenu::item {
padding: 8px 20px;
background: transparent;
border-radius: 8px;
color: #ffffff;
}
QMenu::item:selected {
background: #282a33;
color: #09bec8;
}
QMenu::item:hover {
background: #282a33;
color: #09bec8;
}
QMenu::item:focus {
background: #409EFF;
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
}
"""
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА # СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
MAIN_WINDOW_HEADER_STYLE = """ MAIN_WINDOW_HEADER_STYLE = """
QFrame { QFrame {
@ -90,6 +124,13 @@ SEARCH_EDIT_STYLE = """
} }
""" """
SETTINGS_CHECKBOX_STYLE = """
QCheckBox:focus {
border: 2px solid #409EFF;
background: #404554;
}
"""
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea # ОТКЛЮЧАЕМ РАМКУ У QScrollArea
SCROLL_AREA_STYLE = """ SCROLL_AREA_STYLE = """
QWidget { QWidget {
@ -207,6 +248,28 @@ ACTION_BUTTON_STYLE = """
} }
""" """
# СТИЛЬ КНОПОК ОВЕРЛЕЯ
OVERLAY_BUTTON_STYLE = """
QPushButton {
background: #3f424d;
border: 1px solid rgba(255, 255, 255, 0.20);
border-radius: 10px;
color: #ffffff;
font-size: 16px;
font-family: 'Play';
}
QPushButton:hover {
background: #282a33;
}
QPushButton:pressed {
background: #282a33;
}
QPushButton:focus {
border: 2px solid #409EFF;
background-color: #404554;
}
"""
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ # ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
TAB_TITLE_STYLE = "font-family: 'Play'; font-size: 24px; color: #ffffff; background-color: none;" TAB_TITLE_STYLE = "font-family: 'Play'; font-size: 24px; color: #ffffff; background-color: none;"
CONTENT_STYLE = """ CONTENT_STYLE = """
@ -416,6 +479,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;
@ -457,6 +541,10 @@ MESSAGE_BOX_STYLE = """
background: #09bec8; background: #09bec8;
border-color: rgba(255, 255, 255, 0.3); border-color: rgba(255, 255, 255, 0.3);
} }
QMessageBox QPushButton:focus {
border: 2px solid #409EFF;
background: #404554;
}
""" """
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON # СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON

View File

@ -10,7 +10,7 @@ logger = get_logger(__name__)
def get_cache_file_path(): def get_cache_file_path():
"""Возвращает путь к файлу кеша portproton_last_launch.""" """Возвращает путь к файлу кеша portproton_last_launch."""
cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
return os.path.join(cache_home, "PortProtonQT", "last_launch") return os.path.join(cache_home, "PortProtonQt", "last_launch")
def save_last_launch(exe_name, launch_time): def save_last_launch(exe_name, launch_time):
""" """