diff --git a/CHANGELOG.md b/CHANGELOG.md index a6472f4..1de1388 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,50 +7,57 @@ ### Added - Кнопки сброса настроек и очистки кэша -- Начальная интеграция с EGS с помощью [Legendary](https://github.com/derrod/legendary) -- Бейдж EGS - Бейдж PortProton -- Зависимость на `xdg-utils` +- Зависимость от `xdg-utils` - Интеграция статуса WeAntiCheatYet в карточку -- Стили в AddGameDialog -- Переключение полноэкранного режима через F11 или Select на геймпаде -- Выбор QCheckBox через Enter или кнопку A геймпада -- Закрытие диалога добавления игры через ESC или кнопку B геймпада +- Стили в AddGameDialog +- Переключение полноэкранного режима через F11 или кнопку Select на геймпаде +- Выбор QCheckBox через Enter или кнопку A на геймпаде +- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде - Закрытие окна приложения по комбинации клавиш 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 - Обновлены все иконки -- Переименован `_get_steam_home` → `get_steam_home` -- Переименован `steam_game` → `game_source` -- Догика контекстного меню вынесена в `ContextMenuManager` +- Переименована функция `_get_steam_home` в `get_steam_home` +- Переименован `steam_game` в `game_source` +- Логика контекстного меню вынесена в `ContextMenuManager` - Бейдж Steam теперь открывает Steam Community - Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary -- Оптимизирована генерация карточек для предотвращения лагов при поиске и изменения размера окна -- Бейджи с карточек так же теперь дублируются и на странице с деталями, а не только в библиотеке -- Установка ширины бейджа в две трети ширины карточки +- Оптимизирована генерация карточек для предотвращения задержек при поиске и изменении размера окна +- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке +- Установлена ширина бейджа в две трети ширины карточки - Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites` -- Карточки теперь фокусируются в направлении движения стрелок или D-pad, например если нажать D-pad вниз то перейдёшь на карточку со следующей колонки, а не по порядку -- D-pad больше не переключает вкладки только RB и LB +- Карточки теперь фокусируются в направлении движения стрелок или D-pad: например, при нажатии D-pad вниз фокус переходит на карточку в следующей колонке, а не по порядку +- Теперь D-pad можно зажимать для переключения карточек +- D-pad больше не переключает вкладки, только RB и LB - Кнопка добавления игры больше не фокусируется +- Диалог добавления игры теперь открывается только в библиотеке +- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt ### Fixed -- Обработка несуществующей темы с возвратом к “standart” +- Обработка несуществующей темы с возвратом к «standard» - Открытие контекстного меню - Запуск при отсутствии exiftool - Переводы пунктов настроек -- Бесконечное обращение к get_portproton_location +- Бесконечное обращение к `get_portproton_location` - Ссылки на документацию в README -- traceback при загрузке placeholder при отсутствии обложек +- Traceback при загрузке placeholder при отсутствии обложек - Утечки памяти при загрузке обложек - Ошибки при подключении геймпада из-за работы в разных потоках -- Множественное открытие диалога добавления игры на геймпаде +- Многократное открытие диалога добавления игры при использовании геймпада - Перехват событий геймпада во время работы игры --- @@ -64,16 +71,15 @@ - Сборка AppImage ### Changed -- Удалён жёстко заданный ресайз окна -- Использован icoextract как python модуль +- Удалён жёстко заданный размер окна +- Использован `icoextract` как Python-модуль ### Fixed - Скрытие статус-бара - Чтение списка Steam-игр -- Подвисание GUI -- Краш при повреждённом Steam +- Зависание GUI +- Сбой при повреждённом Steam --- - > См. подробности по каждому коммиту в истории репозитория. diff --git a/README.md b/README.md index 87e3dcb..b9e5c25 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,73 @@
- +

PortProtonQt

-

Современный, удобный графический интерфейс, написанный с использованием PySide6(Qt6) и предназначенный для упрощения управления и запуска игр на различных платформах, включая PortProton, Steam и Epic Games Store.

+

Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.

+ ## В планах - [X] Адаптировать структуру проекта для поддержки инструментов сборки -- [ ] Добавить возможность управление с геймпада -- [ ] Добавить возможность управление с тачскрина -- [X] Добавить возможность управление с мыши и клавиатуры +- [X] Добавить возможность управления с геймпада +- [ ] Добавить возможность управления с тачскрина +- [X] Добавить возможность управления с мыши и клавиатуры - [X] Добавить систему тем [Документация](documentation/theme_guide) -- [X] Вынести все константы такие как уровень закругления карточек в темы (Частично вынесено) -- [X] Добавить метадату для тем (скришоты, описание, домащняя страница и автор) -- [ ] Продумать систему вкладок вместо той что есть сейчас -- [ ] Добавить Gamescope сессию на подобие той что есть в SteamOS -- [ ] Написать адаптивный дизайн (За эталон берём SteamDeck с разрешением 1280х800) -- [X] Брать описание и названия игр с базы данных Steam -- [X] Брать обложки для игр со SteamGridDB или CDN Steam -- [X] Оптимизировать работу со SteamApi что бы ускорить время запуска -- [X] Улучшить функцию поиска SteamApi что бы исправить некорректное определение ID (Graven определается как ENGRAVEN или GRAVENFALL, Spore определается как SporeBound или Spore Valley) -- [ ] Убрать логи со SteamApi в релизной версии потому что логи замедляют код -- [X] Что-то придумать с ограничением SteamApi в 50 тысяч игр за один запрос (иногда туда не попадают нужные игры и остаются без обложки) -- [X] Избавится от любого вызова yad -- [X] Написать свою реализацию запрета ухода в сон, а не использовать ту что в PortProton (Оставим это [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0)) -- [X] Написать свою реализацию трея, а не использовать ту что в PortProton -- [X] Добавить в поиск экранную клавиатуру (Реализовавывать собственную клавиатуру слишком затратно, лучше положится на встроенную в DE клавиатуру malit в KDE, gjs-osk в GNOME,Squeekboard в phosh, стимовская в SteamOS и так далее) -- [X] Добавить сортировку карточек по различным критериям (сейчас есть: недавние, кол-во наиграного времени, избранное или по алфавиту) +- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено) +- [X] Добавить метаданные для тем (скриншоты, описание, домашняя страница и автор) +- [ ] Продумать систему вкладок вместо текущей +- [X] [Добавить сессию Gamescope, аналогичную той, что используется в SteamOS](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt) +- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800) +- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots) +- [X] Получать описания и названия игр из базы данных Steam +- [X] Получать обложки для игр из SteamGridDB или CDN Steam +- [X] Оптимизировать работу со Steam API для ускорения времени запуска +- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley) +- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода +- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) +- [X] Избавиться от вызовов yad +- [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0)) +- [X] Реализовать собственный системный трей вместо использования трея PortProton +- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.) +- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) - [X] Добавить индикацию запуска приложения -- [X] Достичь паритета функционала с Ingame -- [ ] Достичь паритета функционала с PortProton -- [X] Добавить возможность изменения названия, описания и обложки через файлы .local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover} -- [X] Добавить встроенное переопределение имени, описания и обложки, например по пути portprotonqt/custom_data [Документация](documentation/metadata_override/) -- [X] Добавить в карточку игры сведения о поддержке геймадов +- [X] Достигнуть паритета функциональности с Ingame +- [ ] Достигнуть паритета функциональности с PortProton +- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}` +- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/) +- [X] Добавить в карточку игры сведения о поддержке геймпада - [X] Добавить в карточки данные с ProtonDB -- [X] Добавить в карточки данные с Are We Anti-Cheat Yet? -- [X] Продублировать бейджы с карточки на страницу с деталями игрыы -- [X] Добавить парсинг ярлыков со Steam -- [X] Добавить парсинг ярлыков с EGS -- [ ] Избавится от бинарника legendary -- [ ] Добавить запуск и скачивание игр с EGS -- [ ] Добавить авторизацию в EGS через WebView, а не вручную -- [X] Брать описания для игр с EGS из их [api](https://store-content.ak.epicgames.com/api) -- [X] Брать slug через Graphql [запрос](https://launcher.store.epicgames.com/graphql) -- [X] Добавить на карточку бейдж того что игра со стима -- [X] Добавить поддержку Flatpak и Snap версии Steam -- [X] Выводить данные о самом недавнем пользователе Steam, а не первом попавшемся -- [X] Исправить склонения в детальном выводе времени, например не 3 часов назад, а 3 часа назад +- [X] Добавить в карточки данные с AreWeAntiCheatYet +- [X] Продублировать бейджи с карточки на страницу с деталями игры +- [X] Добавить парсинг ярлыков из Steam +- [X] Добавить парсинг ярлыков из EGS (скрыто для переработки) +- [ ] Избавиться от бинарника legendary +- [ ] Добавить запуск и скачивание игр из EGS +- [ ] Добавить авторизацию в EGS через WebView вместо ручного ввода +- [X] Получать описания для игр из EGS через их [API](https://store-content.ak.epicgames.com/api) +- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql) +- [X] Добавить на карточку бейдж, указывающий, что игра из Steam +- [X] Добавить поддержку версий Steam для Flatpak и Snap +- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся +- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад» - [X] Добавить перевод через gettext [Документация](documentation/localization_guide) -- [X] Писать описание игр и прочие данные на языке системы -- [X] Добавить недокументированные параметры конфигурации в GUI (time detail_level, games sort_method, games display_filter) -- [X] Добавить систему избранного к карточкам -- [X] Заменить все print на logging -- [ ] Привести все логи к одному языку -- [X] Стилизовать все элементы без стилей(QMessageBox, QSlider, QDialog) -- [X] Убрать жёсткую привязку путей на стрелочки QComboBox в styles.py +- [X] Отображать описания игр и другие данные на языке системы +- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter) +- [X] Добавить систему избранного для карточек +- [X] Заменить все `print` на `logging` +- [ ] Привести все логи к единому языку +- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog) +- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py` - [X] Исправить частичное применение тем на лету -- [X] Исправить наложение подписей скриншотов при первом перелистывание в полноэкранном режиме -- [ ] Добавить GOG (?) -- [ ] Определится уже наконец с названием (PortProtonQt или PortProtonQT) +- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме +- [ ] Добавить поддержку GOG (?) +- [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 uv python install 3.10 @@ -70,6 +77,12 @@ source .venv/bin/activate Запуск производится по команде portprotonqt +### Установка (release) + +Выберите подходящий пакет для вашей системы или AppImage. + +Запуск производится по команде portprotonqt или по ярлыку в меню + ### Разработка В проект встроен линтер (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) - Дизайнер - программист -* [Mikhail Tergoev(Castro-Fidel)](https://github.com/Castro-Fidel) - Автор оригинального проекта PortProton +* [Mikhail Tergoev(Castro-Fidel)](https://git.linux-gaming.ru/CastroFidel) - Автор оригинального проекта PortProton > [!WARNING] > Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована diff --git a/build-aux/fedora-git.spec b/build-aux/fedora-git.spec index 3eace5a..feb0d77 100644 --- a/build-aux/fedora-git.spec +++ b/build-aux/fedora-git.spec @@ -45,7 +45,7 @@ Requires: perl-Image-ExifTool Requires: xdg-utils %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 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} %{_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 diff --git a/build-aux/fedora.spec b/build-aux/fedora.spec index 24c5f1c..ba26d25 100644 --- a/build-aux/fedora.spec +++ b/build-aux/fedora.spec @@ -42,7 +42,7 @@ Requires: perl-Image-ExifTool Requires: xdg-utils %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 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} %{_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 diff --git a/build-aux/share/metainfo/ru.linux_gaming.PortProtonQt.metainfo.xml b/build-aux/share/metainfo/ru.linux_gaming.PortProtonQt.metainfo.xml new file mode 100644 index 0000000..a1eedb9 --- /dev/null +++ b/build-aux/share/metainfo/ru.linux_gaming.PortProtonQt.metainfo.xml @@ -0,0 +1,65 @@ + + + PortProtonQt + ru.linux_gaming.PortProtonQt + CC0-1.0 + GPL-3.0-or-later + Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store + Современный графический интерфейс для управления и запуска игр из PortProton, Steam и 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.

+

+ Это приложение предоставляет стильный и интуитивно понятный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет ваши игровые библиотеки в одном удобном хабе для простой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают целостный игровой опыт, устраняя необходимость в использовании нескольких лаунчеров. Уникальная интеграция с PortProton улучшает игровой процесс на Linux, позволяя с лёгкостью запускать Windows-игры с минимальными настройками. +

+
+ ru.linux_gaming.PortProtonQt.desktop + + Boria138 + + + keyboard + pointing + touch + gamepad + + + #007AFF + #09BEC8 + + + Game + Utility + + https://git.linux-gaming.ru/Boria138/PortProtonQt + https://git.linux-gaming.ru/Boria138/PortProtonQt/issues + + + 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 + Library + Библиотека + + + 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 + Card detail page + Детали игры + + + 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 + Settings + Настройки + + + + wine + proton + steam + windows + epic games store + egs + qt + portproton + games + + +
diff --git a/dev-scripts/l10n.py b/dev-scripts/l10n.py index 4acbb5b..6a9ff5c 100755 --- a/dev-scripts/l10n.py +++ b/dev-scripts/l10n.py @@ -106,7 +106,7 @@ def compile_locales() -> None: def extract_strings() -> None: input_dir = (Path(__file__).parent.parent / "portprotonqt").resolve() CommandLineInterface().run([ - "pybabel", "extract", "--project=PortProtonQT", + "pybabel", "extract", "--project=PortProtonQt", f"--version={_get_version()}", "--strip-comment-tag", "--no-location", @@ -231,7 +231,7 @@ def main(args) -> int: return 0 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("--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") diff --git a/documentation/localization_guide/README.md b/documentation/localization_guide/README.md index 84fdcc5..08dd168 100644 --- a/documentation/localization_guide/README.md +++ b/documentation/localization_guide/README.md @@ -20,9 +20,9 @@ Current translation status: | Locale | Progress | Translated | | :----- | -------: | ---------: | -| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 154 | -| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 154 | -| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 154 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 162 | +| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 162 of 162 | --- diff --git a/documentation/localization_guide/README.ru.md b/documentation/localization_guide/README.ru.md index eb4ccca..a8efde8 100644 --- a/documentation/localization_guide/README.ru.md +++ b/documentation/localization_guide/README.ru.md @@ -20,9 +20,9 @@ | Локаль | Прогресс | Переведено | | :----- | -------: | ---------: | -| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 154 | -| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 154 | -| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 154 из 154 | +| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 162 | +| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 162 | +| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 162 из 162 | --- diff --git a/portprotonqt/app.py b/portprotonqt/app.py index fdd2bbf..d215643 100644 --- a/portprotonqt/app.py +++ b/portprotonqt/app.py @@ -4,8 +4,9 @@ from PySide6.QtWidgets import QApplication from PySide6.QtGui import QIcon from portprotonqt.main_window import MainWindow 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.cli import parse_args logger = get_logger(__name__) @@ -28,7 +29,17 @@ def main(): else: logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}") + # Парсинг аргументов командной строки + args = parse_args() + window = MainWindow() + + # Обработка флага --fullscreen + if args.fullscreen: + logger.info("Запуск в полноэкранном режиме по флагу --fullscreen") + save_fullscreen_config(True) + window.showFullScreen() + current_theme_name = read_theme_from_config() tray = SystemTray(app, current_theme_name) tray.show_action.triggered.connect(window.show) @@ -43,7 +54,9 @@ def main(): tray.hide_action.triggered.connect(window.hide) window.settings_saved.connect(recreate_tray) + window.show() + sys.exit(app.exec()) if __name__ == '__main__': diff --git a/portprotonqt/cli.py b/portprotonqt/cli.py new file mode 100644 index 0000000..f781dfc --- /dev/null +++ b/portprotonqt/cli.py @@ -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() diff --git a/portprotonqt/config_utils.py b/portprotonqt/config_utils.py index 3c9ca86..2066d2c 100644 --- a/portprotonqt/config_utils.py +++ b/portprotonqt/config_utils.py @@ -10,7 +10,7 @@ _portproton_location = None # Пути к конфигурационным файлам CONFIG_FILE = os.path.join( os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")), - "PortProtonQT.conf" + "PortProtonQt.conf" ) 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")) 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") ] @@ -322,6 +322,41 @@ def save_favorites(favorites): with open(CONFIG_FILE, "w", encoding="utf-8") as 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(): """ Проверяет наличие секции [Proxy] в конфигурационном файле. @@ -342,7 +377,6 @@ def ensure_default_proxy_config(): with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: cp.write(configfile) - def read_proxy_config(): """ Читает настройки прокси из секции [Proxy] конфигурационного файла. @@ -421,8 +455,6 @@ def save_fullscreen_config(fullscreen): with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: cp.write(configfile) - - def read_window_geometry() -> tuple[int, int]: """ Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла. @@ -472,14 +504,14 @@ def reset_config(): def clear_cache(): """ - Очищает кэш PortProtonQT, удаляя папку кэша. + Очищает кэш PortProtonQt, удаляя папку кэша. """ 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): try: shutil.rmtree(cache_dir) - logger.info("Кэш PortProtonQT удалён: %s", cache_dir) + logger.info("Кэш PortProtonQt удалён: %s", cache_dir) except Exception as e: logger.error("Ошибка при удалении кэша: %s", e) diff --git a/portprotonqt/context_menu_manager.py b/portprotonqt/context_menu_manager.py index 6d9eca2..4e5772b 100644 --- a/portprotonqt/context_menu_manager.py +++ b/portprotonqt/context_menu_manager.py @@ -6,12 +6,13 @@ import subprocess from PySide6.QtWidgets import QMessageBox, QDialog, QMenu from PySide6.QtCore import QUrl, QPoint 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.steam_api import is_game_in_steam, add_to_steam, remove_from_steam +from portprotonqt.dialogs import AddGameDialog 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): """ @@ -40,6 +41,18 @@ class ContextMenuManager: """ 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"): 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") @@ -79,6 +92,26 @@ class ContextMenuManager: 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): """Check if PortProton is available.""" if self.portproton_location is None: @@ -225,7 +258,7 @@ class ContextMenuManager: "XDG_DATA_HOME", 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): try: shutil.rmtree(custom_folder) @@ -321,7 +354,6 @@ class ContextMenuManager: def edit_game_shortcut(self, game_name, exec_line, cover_path): """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(): return @@ -385,7 +417,7 @@ class ContextMenuManager: "XDG_DATA_HOME", 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) ext = os.path.splitext(new_cover_path)[1].lower() diff --git a/portprotonqt/downloader.py b/portprotonqt/downloader.py index 8118b39..0e8e610 100644 --- a/portprotonqt/downloader.py +++ b/portprotonqt/downloader.py @@ -303,7 +303,7 @@ class Downloader(QObject): local_path = os.path.join( 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}") diff --git a/portprotonqt/egs_api.py b/portprotonqt/egs_api.py index a80a295..8f12c3f 100644 --- a/portprotonqt/egs_api.py +++ b/portprotonqt/egs_api.py @@ -22,7 +22,7 @@ def get_cache_dir() -> Path: "XDG_CACHE_HOME", 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) return cache_dir @@ -36,7 +36,7 @@ def get_egs_game_description_async( Asynchronously fetches the game description from the Epic Games Store API. Prioritizes GraphQL API with namespace for slug and 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. """ 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: 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 "" def on_description_fetched(api_description: str): diff --git a/portprotonqt/game_card.py b/portprotonqt/game_card.py index e24e3be..8ad4a20 100644 --- a/portprotonqt/game_card.py +++ b/portprotonqt/game_card.py @@ -199,7 +199,7 @@ class GameCard(QFrame): icon_size=16, 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)) anticheat_visible = True else: @@ -261,46 +261,45 @@ class GameCard(QFrame): 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.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.egsLabel.setVisible(self.egs_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 badge_spacing = 5 top_y = 10 badge_y_positions = [] 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_() - self.protondbLabel.raise_() - self.portprotonLabel.raise_() - self.egsLabel.raise_() - self.steamLabel.raise_() + for is_visible, badge in badges: + if is_visible: + badge_x = self.coverLabel.width() - badge_width - right_margin + badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y + badge.move(badge_x, badge_y) + badge_y_positions.append(badge_y + badge.height()) + + # Поднимаем бейджи в правильном порядке (от нижнего к верхнему) + self.anticheatLabel.raise_() + self.protondbLabel.raise_() + self.portprotonLabel.raise_() + self.egsLabel.raise_() + self.steamLabel.raise_() def _show_context_menu(self, pos): """Delegate context menu display to ContextMenuManager.""" diff --git a/portprotonqt/image_utils.py b/portprotonqt/image_utils.py index 0c2dda5..922f23f 100644 --- a/portprotonqt/image_utils.py +++ b/portprotonqt/image_utils.py @@ -35,10 +35,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q y = (scaled.height() - height) // 2 cropped = scaled.copy(x, y, width, height) callback(cropped) - # Removed: pixmap = None (unnecessary, causes type error) 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) if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"): diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index daaafbc..9713485 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -1,16 +1,16 @@ import time import threading from typing import Protocol, cast -from evdev import InputDevice, ecodes, list_devices -import pyudev -from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog -from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot +from evdev import InputDevice, InputEvent, ecodes, list_devices, ff +from pyudev import Context, Monitor, MonitorObserver, Device +from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox +from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer from PySide6.QtGui import QKeyEvent from portprotonqt.logger import get_logger from portprotonqt.image_utils import FullscreenDialog from portprotonqt.custom_widgets import NavLabel 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__) @@ -25,6 +25,8 @@ class MainWindowProtocol(Protocol): ... def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None: ... + def openSystemOverlay(self) -> None: + ... stackedWidget: QStackedWidget tabButtons: dict[int, QWidget] gamesListWidget: QWidget @@ -32,24 +34,25 @@ class MainWindowProtocol(Protocol): current_exec_line: str | 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 = { - 'confirm': {ecodes.BTN_A}, - 'back': {ecodes.BTN_B}, - 'add_game': {ecodes.BTN_Y}, - 'prev_tab': {ecodes.BTN_TL, ecodes.BTN_TRIGGER_HAPPY7}, - 'next_tab': {ecodes.BTN_TR, ecodes.BTN_TRIGGER_HAPPY5}, - 'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR}, - 'context_menu': {ecodes.BTN_START}, - 'menu': {ecodes.BTN_SELECT}, + 'confirm': {ecodes.BTN_A, ecodes.BTN_SOUTH}, # A / Cross + 'back': {ecodes.BTN_B, ecodes.BTN_EAST}, # B / Circle + 'add_game': {ecodes.BTN_Y, ecodes.BTN_NORTH}, # Y / Triangle + 'prev_tab': {ecodes.BTN_TL}, # LB / L1 + 'next_tab': {ecodes.BTN_TR}, # RB / R1 + 'context_menu': {ecodes.BTN_START}, # Start / Options + 'menu': {ecodes.BTN_SELECT}, # Select / Share + 'guide': {ecodes.BTN_MODE}, # Xbox / PS Home } class InputManager(QObject): """ Manages input from gamepads and keyboards for navigating the application interface. Supports gamepad hotplugging, button and axis events, and keyboard event filtering - for seamless UI interaction. Enables fullscreen mode when a gamepad is connected - and restores normal mode when disconnected. + for seamless UI interaction. """ # Signals for gamepad events 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.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.axis_deadzone = axis_deadzone self.initial_axis_move_delay = initial_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.running = True 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 self.button_pressed.connect(self.handle_button_slot) @@ -117,6 +126,48 @@ class InputManager(QObject): except Exception as e: 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) def handle_button_slot(self, button_code: int) -> None: try: @@ -129,10 +180,66 @@ class InputManager(QObject): return active = QApplication.activeWindow() 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 if button_code in BUTTONS['back'] and isinstance(active, QDialog): - active.reject() # Закрываем диалог + active.reject() return # FullscreenDialog @@ -149,22 +256,26 @@ class InputManager(QObject): if isinstance(focused, GameCard): if button_code in BUTTONS['context_menu']: 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 # 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: + self.trigger_rumble() self._parent.toggleGame(self._parent.current_exec_line, None) return # Standard navigation - if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']: + if button_code in BUTTONS['confirm']: 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)) 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']: idx = (self._parent.stackedWidget.currentIndex() - 1) % len(self._parent.tabButtons) self._parent.switchTab(idx) @@ -176,6 +287,14 @@ class InputManager(QObject): except Exception as e: 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) def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None: @@ -188,6 +307,85 @@ class InputManager(QObject): if not app: return 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 if isinstance(active, FullscreenDialog) and code == ecodes.ABS_HAT0X: @@ -197,19 +395,6 @@ class InputManager(QObject): active.show_next() 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) if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y): focused = QApplication.focusWidget() @@ -280,7 +465,6 @@ class InputManager(QObject): next_card.setFocus() if scroll_area: scroll_area.ensureWidgetVisible(next_card, 50, 50) - elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down if value > 0: # Down next_row_idx = current_row_idx + 1 @@ -350,6 +534,12 @@ class InputManager(QObject): focused = QApplication.focusWidget() 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 if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier: app.quit() @@ -390,6 +580,23 @@ class InputManager(QObject): focused._show_context_menu(pos) return True + # Handle Up/Down keys for non-GameCard tabs + if key in (Qt.Key.Key_Up, Qt.Key.Key_Down) and not isinstance(focused, GameCard): + page = self._parent.stackedWidget.currentWidget() + if key == Qt.Key.Key_Down: + if isinstance(focused, NavLabel): + focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively) + focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus] + if focusables: + focusables[0].setFocus() + return True + elif focused: + focused.focusNextChild() + return True + elif key == Qt.Key.Key_Up and focused: + focused.focusPreviousChild() + return True + # Tab switching with Left/Right keys (non-GameCard focus or no focus) idx = self._parent.stackedWidget.currentIndex() total = len(self._parent.tabButtons) @@ -520,6 +727,9 @@ class InputManager(QObject): if focusables: focusables[0].setFocus() return True + elif focused: + focused.focusNextChild() + return True # Navigate up through tab content if key == Qt.Key.Key_Up: if isinstance(focused, NavLabel): @@ -540,8 +750,10 @@ class InputManager(QObject): elif key == Qt.Key.Key_E: if isinstance(focused, QLineEdit): return False - self._parent.openAddGameDialog() - return True + # Only open AddGameDialog if in library tab (index 0) + if self._parent.stackedWidget.currentIndex() == 0: + self._parent.openAddGameDialog() + return True # Toggle fullscreen with F11 if key == Qt.Key.Key_F11: @@ -559,17 +771,17 @@ class InputManager(QObject): def run_udev_monitor(self) -> None: try: - context = pyudev.Context() - monitor = pyudev.Monitor.from_netlink(context) + context = Context() + monitor = Monitor.from_netlink(context) monitor.filter_by(subsystem='input') - observer = pyudev.MonitorObserver(monitor, self.handle_udev_event) + observer = MonitorObserver(monitor, self.handle_udev_event) observer.start() while self.running: time.sleep(1) except Exception as e: 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: if action == 'add': time.sleep(0.1) @@ -577,6 +789,7 @@ class InputManager(QObject): elif action == 'remove' and self.gamepad: if not any(self.gamepad.path == path for path in list_devices()): logger.info("Gamepad disconnected") + self.stop_rumble() self.gamepad = None if self.gamepad_thread: self.gamepad_thread.join() @@ -590,6 +803,7 @@ class InputManager(QObject): new_gamepad = self.find_gamepad() if new_gamepad and new_gamepad != self.gamepad: logger.info(f"Gamepad connected: {new_gamepad.name}") + self.stop_rumble() self.gamepad = new_gamepad if self.gamepad_thread: self.gamepad_thread.join() @@ -626,9 +840,7 @@ class InputManager(QObject): continue now = time.time() if event.type == ecodes.EV_KEY and event.value == 1: - # Обработка кнопки Select для переключения полноэкранного режима if event.code in BUTTONS['menu']: - # Переключаем полноэкранный режим self.toggle_fullscreen.emit(not self._is_fullscreen) else: self.button_pressed.emit(event.code) @@ -644,6 +856,7 @@ class InputManager(QObject): finally: if self.gamepad: try: + self.stop_rumble() self.gamepad.close() except Exception: pass @@ -652,6 +865,8 @@ class InputManager(QObject): def cleanup(self) -> None: try: self.running = False + self.dpad_timer.stop() + self.stop_rumble() if self.gamepad_thread: self.gamepad_thread.join() if self.gamepad: diff --git a/portprotonqt/locales/de_DE/LC_MESSAGES/messages.mo b/portprotonqt/locales/de_DE/LC_MESSAGES/messages.mo index 0f3b271..af70946 100644 Binary files a/portprotonqt/locales/de_DE/LC_MESSAGES/messages.mo and b/portprotonqt/locales/de_DE/LC_MESSAGES/messages.mo differ diff --git a/portprotonqt/locales/de_DE/LC_MESSAGES/messages.po b/portprotonqt/locales/de_DE/LC_MESSAGES/messages.po index 882d392..14a21f6 100644 --- a/portprotonqt/locales/de_DE/LC_MESSAGES/messages.po +++ b/portprotonqt/locales/de_DE/LC_MESSAGES/messages.po @@ -1,6 +1,6 @@ -# German (Germany) translations for PortProtonQT. +# German (Germany) translations for PortProtonQt. # 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. # FIRST AUTHOR , 2025. # @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\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" "Last-Translator: FULL NAME \n" "Language: de_DE\n" @@ -20,6 +20,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" +msgid "Remove from Favorites" +msgstr "" + +msgid "Add to Favorites" +msgstr "" + msgid "Remove from Desktop" msgstr "" @@ -47,6 +53,14 @@ msgstr "" msgid "Add to Steam" msgstr "" +#, python-brace-format +msgid "Added '{0}' to favorites" +msgstr "" + +#, python-brace-format +msgid "Removed '{0}' from favorites" +msgstr "" + msgid "Error" msgstr "" @@ -362,19 +376,10 @@ msgstr "" msgid "Auto Fullscreen on Gamepad connected:" msgstr "" -msgid "Open Legendary Login" +msgid "Gamepad haptic feedback" msgstr "" -msgid "Legendary Authentication:" -msgstr "" - -msgid "Enter Legendary Authorization Code" -msgstr "" - -msgid "Authorization Code:" -msgstr "" - -msgid "Submit Code" +msgid "Gamepad haptic feedback:" msgstr "" msgid "Save Settings" @@ -392,22 +397,6 @@ msgstr "" msgid "Failed to open Legendary login page" 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" msgstr "" @@ -505,6 +494,42 @@ msgstr "" msgid "Launching" 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" msgstr "" diff --git a/portprotonqt/locales/es_ES/LC_MESSAGES/messages.mo b/portprotonqt/locales/es_ES/LC_MESSAGES/messages.mo index 50770aa..e423857 100644 Binary files a/portprotonqt/locales/es_ES/LC_MESSAGES/messages.mo and b/portprotonqt/locales/es_ES/LC_MESSAGES/messages.mo differ diff --git a/portprotonqt/locales/es_ES/LC_MESSAGES/messages.po b/portprotonqt/locales/es_ES/LC_MESSAGES/messages.po index b67ae5b..e139416 100644 --- a/portprotonqt/locales/es_ES/LC_MESSAGES/messages.po +++ b/portprotonqt/locales/es_ES/LC_MESSAGES/messages.po @@ -1,6 +1,6 @@ -# Spanish (Spain) translations for PortProtonQT. +# Spanish (Spain) translations for PortProtonQt. # 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. # FIRST AUTHOR , 2025. # @@ -9,7 +9,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\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" "Last-Translator: FULL NAME \n" "Language: es_ES\n" @@ -20,6 +20,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" +msgid "Remove from Favorites" +msgstr "" + +msgid "Add to Favorites" +msgstr "" + msgid "Remove from Desktop" msgstr "" @@ -47,6 +53,14 @@ msgstr "" msgid "Add to Steam" msgstr "" +#, python-brace-format +msgid "Added '{0}' to favorites" +msgstr "" + +#, python-brace-format +msgid "Removed '{0}' from favorites" +msgstr "" + msgid "Error" msgstr "" @@ -362,19 +376,10 @@ msgstr "" msgid "Auto Fullscreen on Gamepad connected:" msgstr "" -msgid "Open Legendary Login" +msgid "Gamepad haptic feedback" msgstr "" -msgid "Legendary Authentication:" -msgstr "" - -msgid "Enter Legendary Authorization Code" -msgstr "" - -msgid "Authorization Code:" -msgstr "" - -msgid "Submit Code" +msgid "Gamepad haptic feedback:" msgstr "" msgid "Save Settings" @@ -392,22 +397,6 @@ msgstr "" msgid "Failed to open Legendary login page" 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" msgstr "" @@ -505,6 +494,42 @@ msgstr "" msgid "Launching" 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" msgstr "" diff --git a/portprotonqt/locales/messages.pot b/portprotonqt/locales/messages.pot index 46314b4..10519af 100644 --- a/portprotonqt/locales/messages.pot +++ b/portprotonqt/locales/messages.pot @@ -1,15 +1,15 @@ -# Translations template for PortProtonQT. +# Translations template for PortProtonQt. # 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. # FIRST AUTHOR , 2025. # #, fuzzy msgid "" msgstr "" -"Project-Id-Version: PortProtonQT 0.1.1\n" +"Project-Id-Version: PortProtonQt 0.1.1\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" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -18,6 +18,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" +msgid "Remove from Favorites" +msgstr "" + +msgid "Add to Favorites" +msgstr "" + msgid "Remove from Desktop" msgstr "" @@ -45,6 +51,14 @@ msgstr "" msgid "Add to Steam" msgstr "" +#, python-brace-format +msgid "Added '{0}' to favorites" +msgstr "" + +#, python-brace-format +msgid "Removed '{0}' from favorites" +msgstr "" + msgid "Error" msgstr "" @@ -360,19 +374,10 @@ msgstr "" msgid "Auto Fullscreen on Gamepad connected:" msgstr "" -msgid "Open Legendary Login" +msgid "Gamepad haptic feedback" msgstr "" -msgid "Legendary Authentication:" -msgstr "" - -msgid "Enter Legendary Authorization Code" -msgstr "" - -msgid "Authorization Code:" -msgstr "" - -msgid "Submit Code" +msgid "Gamepad haptic feedback:" msgstr "" msgid "Save Settings" @@ -390,22 +395,6 @@ msgstr "" msgid "Failed to open Legendary login page" 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" msgstr "" @@ -503,6 +492,42 @@ msgstr "" msgid "Launching" 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" msgstr "" diff --git a/portprotonqt/locales/ru_RU/LC_MESSAGES/messages.mo b/portprotonqt/locales/ru_RU/LC_MESSAGES/messages.mo index d774613..e073216 100644 Binary files a/portprotonqt/locales/ru_RU/LC_MESSAGES/messages.mo and b/portprotonqt/locales/ru_RU/LC_MESSAGES/messages.mo differ diff --git a/portprotonqt/locales/ru_RU/LC_MESSAGES/messages.po b/portprotonqt/locales/ru_RU/LC_MESSAGES/messages.po index 3fe79d9..a26b290 100644 --- a/portprotonqt/locales/ru_RU/LC_MESSAGES/messages.po +++ b/portprotonqt/locales/ru_RU/LC_MESSAGES/messages.po @@ -1,6 +1,6 @@ -# Russian (Russia) translations for PortProtonQT. +# Russian (Russia) translations for PortProtonQt. # 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. # FIRST AUTHOR , 2025. # @@ -9,8 +9,8 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2025-06-06 20:01+0500\n" -"PO-Revision-Date: 2025-06-06 20:01+0500\n" +"POT-Creation-Date: 2025-06-11 23:15+0500\n" +"PO-Revision-Date: 2025-06-11 23:15+0500\n" "Last-Translator: \n" "Language: ru_RU\n" "Language-Team: ru_RU \n" @@ -21,6 +21,12 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.17.0\n" +msgid "Remove from Favorites" +msgstr "Удалить из Избранного" + +msgid "Add to Favorites" +msgstr "Добавить в Избранное" + msgid "Remove from Desktop" msgstr "Удалить с рабочего стола" @@ -48,6 +54,14 @@ msgstr "Удалить из Steam" msgid "Add to 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" msgstr "Ошибка" @@ -369,20 +383,11 @@ msgstr "Режим полноэкранного отображения прил msgid "Auto Fullscreen on Gamepad connected:" msgstr "Режим полноэкранного отображения приложения при подключении геймпада:" -msgid "Open Legendary Login" -msgstr "Открыть браузер для входа в Legendary" +msgid "Gamepad haptic feedback" +msgstr "Тактильная обратная связь на геймпаде" -msgid "Legendary Authentication:" -msgstr "Авторизация в Legendary:" - -msgid "Enter Legendary Authorization Code" -msgstr "Введите код авторизации Legendary" - -msgid "Authorization Code:" -msgstr "Код авторизации:" - -msgid "Submit Code" -msgstr "Отправить код" +msgid "Gamepad haptic feedback:" +msgstr "Тактильная обратная связь на геймпаде:" msgid "Save Settings" msgstr "Сохранить настройки" @@ -399,22 +404,6 @@ msgstr "Открытие страницы входа в Legendary в брауз msgid "Failed to open Legendary login page" 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" msgstr "Подтвердите удаление" @@ -514,6 +503,42 @@ msgstr "Невозможно запустить игру пока запущен msgid "Launching" 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" msgstr "только что" diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 142f2ed..2cb38fa 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -13,6 +13,7 @@ from portprotonqt.game_card import GameCard from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel from portprotonqt.input_manager import InputManager 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.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, save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config, save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config, - clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad + clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config ) from portprotonqt.localization import _ from portprotonqt.logger import get_logger @@ -43,7 +44,7 @@ from datetime import datetime logger = get_logger(__name__) class MainWindow(QMainWindow): - """Main window of PortProtonQT.""" + """Main window of PortProtonQt.""" settings_saved = Signal() games_loaded = Signal(list) update_progress = Signal(int) # Signal to update progress bar @@ -72,10 +73,10 @@ class MainWindow(QMainWindow): self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed) 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( 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.environ["LEGENDARY_CONFIG_PATH"] = self.legendary_config_path @@ -97,10 +98,11 @@ class MainWindow(QMainWindow): if not self.theme: self.theme = default_styles self.card_width = read_card_size() - self.setWindowTitle("PortProtonQT") + self.setWindowTitle("PortProtonQt") self.setMinimumSize(800, 600) self.games = [] + self.filtered_games = self.games self.game_processes = [] self.target_exe = None self.current_running_button = None @@ -259,39 +261,28 @@ class MainWindow(QMainWindow): self.update_status_message.emit ) elif display_filter == "favorites": - def on_all_games(portproton_games, steam_games, epic_games): - games = [game for game in portproton_games + steam_games + epic_games if game[0] in favorites] + def on_all_games(portproton_games, steam_games): + games = [game for game in portproton_games + steam_games if game[0] in favorites] self.games_loaded.emit(games) self._load_portproton_games_async( lambda pg: self._load_steam_games_async( - lambda sg: load_egs_games_async( - self.legendary_path, - lambda eg: on_all_games(pg, sg, eg), - self.downloader, - self.update_progress.emit, - self.update_status_message.emit - ) + lambda sg: on_all_games(pg, sg) ) ) else: - def on_all_games(portproton_games, steam_games, epic_games): + def on_all_games(portproton_games, steam_games): seen = set() games = [] - for game in portproton_games + steam_games + epic_games: - name = game[0] - if name not in seen: - seen.add(name) + for game in portproton_games + steam_games: + # Уникальный ключ: имя + exec_line + key = (game[0], game[4]) + if key not in seen: + seen.add(key) games.append(game) self.games_loaded.emit(games) self._load_portproton_games_async( lambda pg: self._load_steam_games_async( - lambda sg: load_egs_games_async( - self.legendary_path, - lambda eg: on_all_games(pg, sg, eg), - self.downloader, - self.update_progress.emit, - self.update_status_message.emit - ) + lambda sg: on_all_games(pg, sg) ) ) return [] @@ -394,7 +385,7 @@ class MainWindow(QMainWindow): builtin_custom_folder = os.path.join(repo_root, "portprotonqt", "custom_data") xdg_data_home = os.getenv("XDG_DATA_HOME", 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) builtin_cover = "" @@ -500,6 +491,11 @@ class MainWindow(QMainWindow): btn.setChecked(i == 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]: self.container = QWidget() self.container.setStyleSheet(self.theme.CONTAINER_STYLE) @@ -539,14 +535,20 @@ class MainWindow(QMainWindow): def startSearchDebounce(self, text): 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): """Filters games based on search text and updates the grid.""" text = self.searchEdit.text().strip().lower() if text == "": - self.updateGameGrid() # Use self.games directly + self.filtered_games = self.games else: - filtered = [game for game in self.games if text in game[0].lower()] - self.updateGameGrid(filtered) + self.filtered_games = [game for game in self.games if text in game[0].lower()] + self.updateGameGrid(self.filtered_games) def createInstalledTab(self): self.gamesLibraryWidget = QWidget() @@ -579,33 +581,16 @@ class MainWindow(QMainWindow): self.sizeSlider.setFixedWidth(150) self.sizeSlider.setToolTip(f"{self.card_width} px") self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) + self.sizeSlider.valueChanged.connect(self.on_slider_value_changed) sliderLayout.addWidget(self.sizeSlider) 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(): available_width = scrollArea.width() - 20 spacing = self.gamesListLayout._spacing target_cards_per_row = 8 calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row 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) @@ -621,7 +606,6 @@ class MainWindow(QMainWindow): self._last_width = self.width() if abs(self.width() - self._last_width) > 10: self._last_width = self.width() - self.sliderDebounceTimer.start() def loadVisibleImages(self): visible_region = self.gamesListWidget.visibleRegion() @@ -638,22 +622,38 @@ class MainWindow(QMainWindow): if games_list is None: games_list = self.games if not games_list: - self.clearLayout(self.gamesListLayout) + # Скрываем все карточки, если список пуст + for card in self.game_card_cache.values(): + card.hide() self.game_card_cache.clear() self.pending_images.clear() + self.gamesListWidget.updateGeometry() return - # Create a set of game names for quick lookup - current_games = {game_data[0]: game_data for game_data in games_list} + # Создаем словарь текущих игр с уникальным ключом (name + exec_line) + 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): - return # No changes needed, skip update + # Проверяем, изменился ли список игр или размер карточек + current_game_keys = set(current_games.keys()) + 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 - - # Remove cards for games no longer in the list for card_key in list(self.game_card_cache.keys()): if card_key not in current_games: card = self.game_card_cache.pop(card_key) @@ -663,11 +663,15 @@ class MainWindow(QMainWindow): del self.pending_images[card_key] layout_changed = True - # Add or update cards for current games + # Добавляем новые карточки и обновляем существующие for game_data in games_list: game_name = game_data[0] - if game_name not in self.game_card_cache: - # Create new card + game_key = (game_name, game_data[4]) + 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( *game_data, select_callback=self.openGameDetailPage, @@ -675,7 +679,7 @@ class MainWindow(QMainWindow): card_width=self.card_width, context_menu_manager=self.context_menu_manager ) - # Connect context menu signals + # Подключаем сигналы контекстного меню card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut) card.deleteGameRequested.connect(self.context_menu_manager.delete_game) 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.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam) 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) layout_changed = True - elif self.card_width != getattr(self, '_last_card_width', None): - # Update size only if card_width has changed - card = self.game_card_cache[game_name] - card.setFixedWidth(self.card_width + 20) # Account for extra_margin in GameCard + else: + # Обновляем видимость существующей карточки + card = self.game_card_cache[game_key] + card.setVisible(should_be_visible) - # Store the current card_width + # Сохраняем текущий 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: + self.gamesListLayout.update() self.gamesListWidget.updateGeometry() + self.gamesListWidget.update() + + # Загружаем изображения для видимых карточек + self.loadVisibleImages() def clearLayout(self, layout): """Удаляет все виджеты из layout.""" @@ -742,6 +748,7 @@ class MainWindow(QMainWindow): return dialog = AddGameDialog(self, self.theme) + dialog.setFocus(Qt.FocusReason.OtherFocusReason) self.current_add_game_dialog = dialog # Сохраняем ссылку на диалог # Предзаполняем путь к .exe при drag-and-drop @@ -778,7 +785,7 @@ class MainWindow(QMainWindow): os.path.join(os.path.expanduser("~"), ".local", "share")) custom_folder = os.path.join( xdg_data_home, - "PortProtonQT", + "PortProtonQt", "custom_data", exe_name ) @@ -920,7 +927,7 @@ class MainWindow(QMainWindow): # 3. Games display_filter 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.addItems(self.filter_labels) 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.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.autoFullscreenGamepadTitle = QLabel(_("Auto Fullscreen on Gamepad connected:")) self.autoFullscreenGamepadTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) self.autoFullscreenGamepadTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) @@ -990,36 +998,16 @@ class MainWindow(QMainWindow): self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen) formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox) - # 7. Legendary Authentication - self.legendaryAuthButton = AutoSizeButton( - _("Open Legendary Login"), - icon=self.theme_manager.get_icon("login") - ) - self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) - self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin) - self.legendaryAuthTitle = QLabel(_("Legendary Authentication:")) - self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) - self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) - formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton) - - self.legendaryCodeEdit = QLineEdit() - self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code")) - self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE) - self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self.legendaryCodeTitle = QLabel(_("Authorization Code:")) - self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) - self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) - formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit) - - self.submitCodeButton = AutoSizeButton( - _("Submit Code"), - icon=self.theme_manager.get_icon("save") - ) - self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) - self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus) - self.submitCodeButton.clicked.connect(self.submitLegendaryCode) - formLayout.addRow(QLabel(""), self.submitCodeButton) + # 7. Gamepad haptic feedback config + self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback")) + self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) + self.gamepadRumbleTitle = QLabel(_("Gamepad haptic feedback:")) + self.gamepadRumbleTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE) + self.gamepadRumbleTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus) + current_rumble_state = read_rumble_config() + self.gamepadRumbleCheckBox.setChecked(current_rumble_state) + formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox) layout.addLayout(formLayout) @@ -1071,37 +1059,6 @@ class MainWindow(QMainWindow): logger.error(f"Failed to open Legendary login page: {e}") 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): """Сбрасывает настройки и перезапускает приложение.""" reply = QMessageBox.question( @@ -1172,6 +1129,10 @@ class MainWindow(QMainWindow): auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked() 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(): card.update_badge_visibility(filter_key) @@ -1288,11 +1249,15 @@ class MainWindow(QMainWindow): self.statusBar().showMessage(_("Theme '{0}' applied successfully").format(selected_theme), 3000) xdg_data_home = os.getenv("XDG_DATA_HOME", 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) - with open(state_file, "w", encoding="utf-8") as f: - f.write("theme_tab\n") - QTimer.singleShot(500, lambda: self.restart_application()) + try: + with open(state_file, "w", encoding="utf-8") as f: + 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: self.statusBar().showMessage(_("Error applying theme '{0}'").format(selected_theme), 3000) @@ -1310,14 +1275,28 @@ class MainWindow(QMainWindow): def restore_state(self): """Восстанавливает состояние приложения после перезапуска.""" - xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) - state_file = os.path.join(xdg_cache_home, "PortProtonQT", "state.txt") + xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) + 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): - with open(state_file, encoding="utf-8") as f: - state = f.read().strip() - if state == "theme_tab": - self.switchTab(5) - os.remove(state_file) + try: + with open(state_file, encoding="utf-8") as f: + state = f.read().strip() + logger.info(f"State file contents: '{state}'") + 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): @@ -1514,7 +1493,7 @@ class MainWindow(QMainWindow): icon_size=16, 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.clicked.connect(lambda: QDesktopServices.openUrl(QUrl(f"https://areweanticheatyet.com/game/{name.lower().replace(' ', '-')}"))) anticheat_visible = True diff --git a/portprotonqt/steam_api.py b/portprotonqt/steam_api.py index 514ba73..fed091e 100644 --- a/portprotonqt/steam_api.py +++ b/portprotonqt/steam_api.py @@ -49,7 +49,7 @@ def decode_text(text: str) -> str: def get_cache_dir(): """Возвращает путь к каталогу кэша, создаёт его при необходимости.""" 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) return cache_dir diff --git a/portprotonqt/system_overlay.py b/portprotonqt/system_overlay.py new file mode 100644 index 0000000..6359dce --- /dev/null +++ b/portprotonqt/system_overlay.py @@ -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() diff --git a/portprotonqt/theme_manager.py b/portprotonqt/theme_manager.py index 0686e7d..428b069 100644 --- a/portprotonqt/theme_manager.py +++ b/portprotonqt/theme_manager.py @@ -11,7 +11,7 @@ logger = get_logger(__name__) # Папка, где располагаются все дополнительные темы xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) 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") ] diff --git a/portprotonqt/themes/standart-light/metainfo.ini b/portprotonqt/themes/standart-light/metainfo.ini index 1b5282d..e899572 100644 --- a/portprotonqt/themes/standart-light/metainfo.ini +++ b/portprotonqt/themes/standart-light/metainfo.ini @@ -1,5 +1,5 @@ [Metainfo] author = BlackSnaker author_link = -description = Стандартная тема PortProtonQT (светлый вариант) +description = Стандартная тема PortProtonQt (светлый вариант) name = Light diff --git a/portprotonqt/themes/standart-light/styles.py b/portprotonqt/themes/standart-light/styles.py index 84d5da4..83f2435 100644 --- a/portprotonqt/themes/standart-light/styles.py +++ b/portprotonqt/themes/standart-light/styles.py @@ -416,6 +416,26 @@ def get_protondb_badge_style(tier): 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_BADGE_STYLE= """ qproperty-alignment: AlignCenter; diff --git a/portprotonqt/themes/standart/metainfo.ini b/portprotonqt/themes/standart/metainfo.ini index 3814979..78b9340 100644 --- a/portprotonqt/themes/standart/metainfo.ini +++ b/portprotonqt/themes/standart/metainfo.ini @@ -1,5 +1,5 @@ [Metainfo] author = Dervart author_link = -description = Стандартная тема PortProtonQT (тёмный вариант) +description = Стандартная тема PortProtonQt (тёмный вариант) name = Clean Dark diff --git a/portprotonqt/themes/standart/styles.py b/portprotonqt/themes/standart/styles.py index b34d6ca..9e9b288 100644 --- a/portprotonqt/themes/standart/styles.py +++ b/portprotonqt/themes/standart/styles.py @@ -8,6 +8,40 @@ current_theme_name = read_theme_from_config() favoriteLabelSize = 48, 48 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 = """ QFrame { @@ -90,6 +124,13 @@ SEARCH_EDIT_STYLE = """ } """ +SETTINGS_CHECKBOX_STYLE = """ + QCheckBox:focus { + border: 2px solid #409EFF; + background: #404554; + } +""" + # ОТКЛЮЧАЕМ РАМКУ У QScrollArea SCROLL_AREA_STYLE = """ 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;" CONTENT_STYLE = """ @@ -416,6 +479,27 @@ def get_protondb_badge_style(tier): 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_BADGE_STYLE= """ qproperty-alignment: AlignCenter; @@ -457,6 +541,10 @@ MESSAGE_BOX_STYLE = """ background: #09bec8; border-color: rgba(255, 255, 255, 0.3); } + QMessageBox QPushButton:focus { + border: 2px solid #409EFF; + background: #404554; + } """ # СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON diff --git a/portprotonqt/time_utils.py b/portprotonqt/time_utils.py index 71fc463..5829d89 100644 --- a/portprotonqt/time_utils.py +++ b/portprotonqt/time_utils.py @@ -10,7 +10,7 @@ logger = get_logger(__name__) def get_cache_file_path(): """Возвращает путь к файлу кеша portproton_last_launch.""" 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): """