51 Commits

Author SHA1 Message Date
8e11dac987 chore: v0.1.5
Some checks failed
Code check / Check code (push) Successful in 1m27s
Build AppImage, Arch and Fedora Packages / Build AppImage (push) Successful in 3m15s
Build AppImage, Arch and Fedora Packages / Build Arch Package (push) Successful in 1m50s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (41) (push) Successful in 1m14s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (42) (push) Successful in 1m6s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (rawhide) (push) Successful in 1m53s
Build AppImage, Arch and Fedora Packages / Create and Publish Release (push) Failing after 48s
Fetch Data / build (push) Failing after 20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 16:08:43 +05:00
358afbdbdb chore(localization): update
All checks were successful
Check Translations / check-translations (push) Successful in 46s
Code check / Check code (push) Successful in 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:29:11 +05:00
83730499e2 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:23:55 +05:00
84f560ed30 feat(tray): add modal game launch dialog with process detection and cancellation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:20:52 +05:00
888c9ac387 chore(theme): drop unstable mark from scale animation
All checks were successful
Code check / Check code (push) Successful in 1m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 11:11:07 +05:00
68d06ca05c fix(FlowLayout): Align incomplete rows with the first card of the longest row
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 11:09:24 +05:00
6923a5f05c chore(theme): change placeholder aspect ratio
All checks were successful
Code check / Check code (push) Successful in 1m44s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 10:03:30 +05:00
f3f85441d8 fix: scale animation is less unstable
All checks were successful
Code check / Check code (push) Successful in 1m38s
renovate / renovate (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-30 21:21:15 +05:00
eb90836710 chore: change cover aspect ratio
All checks were successful
Code check / Check code (push) Successful in 1m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-30 10:59:18 +05:00
dd125c975b fix(input_manager): revert dpad navigation to focusNextChild
All checks were successful
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-29 14:46:42 +05:00
4521d3ca1c chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m41s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 17:15:34 +05:00
dd044dbd95 feat(tray_manager): added themes select to tray
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 17:12:31 +05:00
0047b29cd2 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:58:37 +05:00
d0fbc79168 fix(input_manager): fix keyboard and dpad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:56:55 +05:00
57f6ac9c4b feat: center cards in FlowLayout with equal margins
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:53:14 +05:00
60271f7a13 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m53s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:55:02 +05:00
38ab4acc86 chore(documentation): chore card_animation_type
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:53:01 +05:00
8f54f4814c feat: added scale animation to game card hover and focus
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:48:55 +05:00
37254b89f1 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m37s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-27 11:22:23 +05:00
893e33bdce feat(tray_manager): implement double-click to toggle main window visibility
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-27 11:14:14 +05:00
1ee784d890 chore(changelog): update
Some checks failed
Check Translations / check-translations (push) Failing after 35s
Code check / Check code (push) Successful in 1m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:22:26 +05:00
39f505079c chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:18:19 +05:00
46253115ff feat: returned tray and added favorites and recent to it
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:00:16 +05:00
31a7ef3e7e chore(deps): update lock file
All checks were successful
Code check / Check code (push) Successful in 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:44:17 +05:00
Renovate Bot
cb07904c1b fix(deps): lock file maintenance python dependencies
Some checks failed
renovate/artifacts Artifact file update failure
Code check / Check code (pull_request) Successful in 1m18s
2025-08-24 17:39:06 +00:00
05e0d9d846 fix(renovate): disable poetry (bug in upstream)
All checks were successful
Code check / Check code (push) Successful in 1m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:35:30 +05:00
81433d3c56 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m38s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:13:50 +05:00
0ff66e282b fix(input_manager): enable Escape key to close dialogs
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:10:39 +05:00
831b7739ba fix(input-manager): enable drive list navigation with arrow keys in FileExplorer
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:05:33 +05:00
50e1dfda57 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 20:56:41 +05:00
fcf04e521d feat(file-explorer): add automatic scrolling for drives layout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 20:54:50 +05:00
74d0700d7c chore(renovate): use . for source uv
All checks were successful
Code check / Check code (push) Successful in 1m18s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 17:40:36 +05:00
0435c77630 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 17:39:52 +05:00
1cf93a60c8 feat: added favorites to file explorer
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 17:37:59 +05:00
31247d21c3 chore(changelog): update
Some checks failed
renovate / renovate (push) Failing after 1m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 21:35:23 +05:00
c6017a7dce fix(file explorer): don't skip /run/media
All checks were successful
Code check / Check code (push) Successful in 1m22s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 21:23:39 +05:00
c74d209dbd chore(ci): replace uv github action to manual install
All checks were successful
Code check / Check code (pull_request) Successful in 1m25s
Code check / Check code (push) Successful in 1m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 21:04:59 +05:00
5b257d3b62 fix(ci): disable cache from node
All checks were successful
Code check / Check code (push) Successful in 1m59s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:53:17 +05:00
4dcf1dbe6d fix(ci): I forget @
Some checks failed
Code check / Check code (push) Failing after 54s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:50:47 +05:00
8d6fe4aa65 chore(ci): install node 20 for uv
Some checks failed
Code check / Check code (push) Failing after 4s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:48:40 +05:00
022eb3f1e9 chore(changelog): update
Some checks failed
Check Translations / check-translations (push) Successful in 46s
Code check / Check code (push) Failing after 10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:41:42 +05:00
11b847ed05 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:36:02 +05:00
1e4e0127a4 fix(i18n): add translation for File Explorer window title
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:33:19 +05:00
c045aa7a56 fix(input_manager): correct button mappings for increase/decrease size actions
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 20:31:22 +05:00
f18e7bae6b chore(changelog): update
Some checks failed
Code check / Check code (push) Failing after 15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 11:28:02 +05:00
dcf8904037 feat(input_manager): enable cursor movement in QLineEdit with left/right arrows
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 11:25:26 +05:00
f9d24e385d fix(input_manager): prevent tab switching when using left/right arrows in QLineEdit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 11:22:06 +05:00
09028931be feat: use Backspace for move to parent directory in FileExplorer
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 11:12:15 +05:00
0294c90c54 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 2m43s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-19 17:54:40 +05:00
17dfef2d27 chore(tray): drop
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-19 17:52:09 +05:00
Renovate Bot
f0690f8811 fix(deps): lock file maintenance python dependencies
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 22:33:43 +05:00
35 changed files with 2179 additions and 1249 deletions

View File

@@ -8,7 +8,7 @@ on:
env: env:
# Common version, will be used for tagging the release # Common version, will be used for tagging the release
VERSION: 0.1.4 VERSION: 0.1.5
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}

View File

@@ -22,10 +22,16 @@ jobs:
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
- name: Install uv - name: Set up Node.js
uses: https://github.com/astral-sh/setup-uv@v6 uses: https://gitea.com/actions/setup-node@v4
with: with:
enable-cache: true node-version: 20
- name: Install uv manually
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
uv --version
- name: Sync dependencies into venv - name: Sync dependencies into venv
run: uv sync --all-extras --dev run: uv sync --all-extras --dev

View File

@@ -12,10 +12,16 @@ jobs:
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
- name: Install uv - name: Set up Node.js
uses: https://github.com/astral-sh/setup-uv@v6 uses: https://gitea.com/actions/setup-node@v4
with: with:
enable-cache: true node-version: 20
- name: Install uv manually
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
. $HOME/.local/bin/env
uv --version
- name: Download external renovate config - name: Download external renovate config
run: | run: |

View File

@@ -3,26 +3,46 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [0.1.5] - 2025-08-31
### Added ### Added
- Больше типов анимаций при открытии карточки игры (за подробностями в документацию) - Больше типов анимаций при открытии карточки игры (подробности см. в документации).
- Анимация при выходе из карточки игры (за подробностями в документацию) - Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
- Анимация при закрытии карточки игры (подробности см. в документации).
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
- Система быстрого доступа (избранного) в диалоге выбора файлов.
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
- Пункт "Выход" в трей.
- Пункт "Темы" в трей для быстрого переключения тем.
- Двойной клик по иконке трея для показа/скрытия главного окна.
- Запуск через трей показывает модальное окно для слежки за процессом запуска
### Changed ### Changed
- Уменьшена длительность анимации открытия карточки с 800 до 350мс - Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
- Контекстное меню при открытие теперь сразу фокусируется на первом элементе - Контекстное меню при открытии теперь сразу фокусируется на первом элементе.
- Анимации теперь можно настраивать через темы (за подробностями в документацию) - Анимации теперь можно настраивать через темы (подробности см. в документации).
- Общие json (steam_apps и anticheat_games) теперь перекачиваются если сломаны - Общие JSON-файлы (`steam_apps` и `anticheat_games`) теперь перекачиваются, если они повреждены.
- Временно удалена светлая тема - Временно удалена светлая тема.
- Добавление и удаление игр из Steam теперь не требует перезагрузки Steam - Добавление и удаление игр из Steam больше не требует перезапуска клиента.
- Обновлены все зависимости (затрагивает только AppImage).
- Приложение теперь не закрывается полностью, а сворачивается в трей.
- Карточки теперь все находятся друг под другом, а не в разнабой
- Изменено соотношение сторон карточек
### Fixed ### Fixed
- legendary list теперь не вызывается если вход в EGS не был произведён - `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
- Скриншоты тем теперь не теряют в качестве при масштабе отличном от 100% - Скриншоты тем больше не теряют качество при масштабе, отличном от 100%.
- Данные от HLTB теперь не отображаются в карточке если нет данных о времени прохождения - Данные от HLTB теперь не отображаются в карточке, если нет информации о времени прохождения.
- Диалог добавления игры теперь не добавляет игру если exe не существует - Диалог добавления игры больше не добавляет игру, если `exe` не существует.
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
- Заголовок окна диалога выбора файлов теперь можно перевести.
- Трей теперь можно перевести.
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
### Contributors ### Contributors
- @Alex Smith - @Alex Smith
@@ -32,24 +52,24 @@
## [0.1.4] - 2025-07-21 ## [0.1.4] - 2025-07-21
### Added ### Added
- Переводы в переопределениях (за подробностями в документацию) - Переводы в переопределениях (подробности см. в документации).
- Обложки и описания для всех автоинсталлов - Обложки и описания для всех автоинсталлов.
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры - Возможность указать ссылку для скачивания обложки в диалоге добавления игры.
- Интеграция с howlongtobeat.com - Интеграция с howlongtobeat.com.
### Changed ### Changed
- Оптимизированны обложки автоинсталлов - Оптимизированы обложки автоинсталлов.
- Папка custom_data исключена из сборки модуля для уменьшение его размера - Папка `custom_data` исключена из сборки модуля для уменьшения его размера.
- Бейдж PortProton теперь открывает PortProtonDB - Бейдж PortProton теперь открывает PortProtonDB.
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии - Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в Gamescope-сессии.
- Удалён аргумент `--session` так как тестирование gamescope сессии завершено - Удалён аргумент `--session`, так как тестирование Gamescope-сессии завершено.
- В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton" - В контекстном меню игр без exe-файла теперь отображается только пункт «Удалить из PortProton».
### Fixed ### Fixed
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси - Запрос к GitHub API при загрузке legendary теперь учитывает настройки прокси.
- Путь к portprotonqt-session-select в оверлее - Путь к `portprotonqt-session-select` в оверлее.
- Работа exiftool в AppImage - Работа `exiftool` в AppImage.
- Открытие контекстного меню у игр без exe - Открытие контекстного меню у игр без exe-файла.
### Contributors ### Contributors
- @Vector_null - @Vector_null
@@ -59,32 +79,32 @@
## [0.1.3] - 2025-07-05 ## [0.1.3] - 2025-07-05
### Added ### Added
- Аргумент `--session` для запуска приложения в gamescope (Исключительно в целях тестирования) - Аргумент `--session` для запуска приложения в Gamescope (исключительно в целях тестирования).
- Начальная поддержка EGS (Без EOS, скачивания игр и запуска игр из сторонних магазинов) - Начальная поддержка EGS (без EOS, скачивания и запуска игр из сторонних магазинов).
- Автодополнение bash для комманды portprotonqt - Автодополнение bash для команды `portprotonqt`.
- Поддержка геймпадов в диалоге выбора игры - Поддержка геймпадов в диалоге выбора игры.
- Быстрый запуск и остановка игры через контекстное меню - Быстрый запуск и остановка игры через контекстное меню.
- Иконки в контекстом меню - Иконки в контекстном меню.
- Обложки для части автоинсталлов - Обложки для части автоинсталлов.
### Changed ### Changed
- Удалены сборки для Fedora 40 - Удалены сборки для Fedora 40.
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем. - Параметры анимации GameCard перенесены в `styles.py` с подробной документацией для кастомизации тем.
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга - Статусы выделения и наведения на карточки теперь взаимоисключающие.
- Все desktop файлы создаются с коментарием "Запустить игру {название} через PortProton" - Все desktop-файлы создаются с комментарием «Запустить игру {название} через PortProton».
- Заполнители в переводах теперь стали более осмысленными - Заполнители в переводах стали более осмысленными.
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope - Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope.
- Текст бейджей теперь обрезается через ... если не помещается - Текст бейджей теперь обрезается троеточием, если не помещается.
### Fixed ### Fixed
- Дублирование обводки выделения карточек при быстром перемешении мыши - Дублирование обводки карточек при быстром перемещении мыши.
- Завершение приложения при закритие окна - Завершение приложения при закрытии окна.
- Использование системной палитры в темах - Использование системной палитры в темах.
- Ошибки темы в нативном пакете - Ошибки тем в нативном пакете.
- Ошибки темы в Gamescope - Ошибки тем в Gamescope.
- Размер иконок для desktop файлов теперь 128x128 - Размер иконок для desktop-файлов теперь 128x128.
- Пустая область при обновлении сетки игр - Пустая область при обновлении сетки игр.
- Запуск игры при открытом оверлее - Запуск игры при открытом оверлее.
### Contributors ### Contributors
- @Dervart - @Dervart
@@ -95,63 +115,63 @@
## [0.1.2] - 2025-06-15 ## [0.1.2] - 2025-06-15
### Added ### Added
- Кнопки сброса настроек и очистки кэша - Кнопки сброса настроек и очистки кэша.
- Бейдж PortProton - Бейдж PortProton.
- Зависимость от `xdg-utils` - Зависимость от `xdg-utils`.
- Интеграция статуса WeAntiCheatYet в карточку - Интеграция статуса WeAntiCheatYet в карточку.
- Переключение полноэкршанного режима через F11 или кнопку Select на геймпаде - Переключение полноэкранного режима через F11 или кнопку Select на геймпаде.
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде - Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде.
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде - Закрытие диалога добавления игры через ESC или кнопку B на геймпаде.
- Закрытие окна приложения по комбинации клавиш Ctrl+Q - Закрытие приложения комбинацией клавиш Ctrl+Q.
- Сохранение и восстановление размера окна при перезапуске - Сохранение и восстановление размера окна при перезапуске.
- Переключатель полноэкранного режима приложения - Переключатель полноэкранного режима приложения.
- Пункт в контекстном меню «Открыть папку игры» - Пункт в контекстном меню «Открыть папку игры».
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam» - Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam».
- Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного» - Пункты в контекстном меню «Добавить в избранное» и «Удалить из избранного».
- Метод сортировки «Сначала избранное» - Метод сортировки «Сначала избранное».
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена) - Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена).
- Поддержка управления геймпадом в `QMenu` и `QComboBox` - Поддержка управления геймпадом в `QMenu` и `QComboBox`.
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме - Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме.
- Оверлей на кнопку Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или переключения между сессиями - Оверлей на кнопку Insert или Xbox/PS-кнопку на геймпаде для закрытия приложения, выключения, перезагрузки, перехода в спящий режим или переключения между сессиями.
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt) - [Gamescope-сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt).
- Пресеты управления для DualShock 4 и DualSense - Пресеты управления для DualShock 4 и DualSense.
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию выключена) - Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию отключена).
- Переводы пунктов настроек - Переводы пунктов настроек.
### Changed ### Changed
- Обновлены все иконки - Обновлены все иконки.
- Переименована функция `_get_steam_home` в `get_steam_home` - Функция `_get_steam_home` переименована в `get_steam_home`.
- Переименован `steam_game` в `game_source` - `steam_game` переименован в `game_source`.
- Логика контекстного меню вынесена в `ContextMenuManager` - Логика контекстного меню вынесена в `ContextMenuManager`.
- Бейдж Steam теперь открывает Steam Community - Бейдж Steam теперь открывает Steam Community.
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary - Лицензия изменена с MIT на GPL-3.0 для совместимости с кодом legendary.
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна - Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна.
- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке - Бейджи с карточек теперь отображаются и на странице с деталями, а не только в библиотеке.
- Установлена ширина бейджа в две трети ширины карточки - Установлена ширина бейджа в 2/3 ширины карточки.
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites` - Бейджи источников (`Steam`, `EGS`, `PortProton`) отображаются только при активном фильтре `all` или `favorites`.
- Карточки теперь фокусируются в направлении движения стрелок или D-pad: - Карточки теперь фокусируются в направлении движения стрелок или D-pad.
- Поддерживается удержание D-pad для непрерывного переключения карточек - Поддерживается удержание D-pad для непрерывного переключения карточек.
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности - Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности.
- D-pad больше не переключает вкладки (только кнопки RB/LB) - D-pad больше не переключает вкладки (только кнопки RB/LB).
- Кнопка добавления игры больше не фокусируется - Кнопка добавления игры больше не получает фокус.
- Диалог добавления игры теперь открывается только в библиотеке - Диалог добавления игры открывается только в библиотеке.
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt - Все упоминания PortProtonQT заменены на PortProtonQt.
- Размер карточек теперь меняется только при отпускании слайдера - Размер карточек меняется только при отпускании слайдера.
- Слайдер теперь управляется через тригеры на геймпаде - Слайдер теперь управляется триггерами на геймпаде.
- Диалог добавления игры теперь открывается на X, а не на Y - Диалог добавления игры теперь открывается на X, а не на Y.
### Fixed ### Fixed
- Возврат к теме «standard» при выборе несуществующей темы - Возврат к теме «standard» при выборе несуществующей темы.
- Корректное открытие контекстного меню - Корректное открытие контекстного меню.
- Запуск приложения при отсутствии `exiftool` - Запуск приложения при отсутствии `exiftool`.
- Предотвращено бесконечное обращение к `get_portproton_location` - Предотвращено бесконечное обращение к `get_portproton_location`.
- Обновлены ссылки на документацию в README - Обновлены ссылки на документацию в README.
- Устранён traceback при отсутствии обложек (placeholder) - Исправлено падение при отсутствии обложек (placeholder).
- Устранены утечки памяти при загрузке обложек - Устранены утечки памяти при загрузке обложек.
- Исправлены ошибки при подключении геймпада - Исправлены ошибки при подключении геймпада.
- Предотвращено многократное открытие диалога добавления игры через геймпад - Предотвращено многократное открытие диалога добавления игры через геймпад.
- Корректная обработка событий геймпада во время игры - Корректная обработка событий геймпада во время игры.
- Убийсво всех процессов "зомби" при закрытии программы - Убийство всех процессов-зомби при закрытии программы.
### Contributors ### Contributors
- @Vector_null - @Vector_null
@@ -162,20 +182,20 @@
## [0.1.1] 2025-05-17 ## [0.1.1] 2025-05-17
### Added ### Added
- Алфавитная сортировка библиотеки - Алфавитная сортировка библиотеки.
- Проверка переводов через yaspeller - Проверка переводов через yaspeller.
- Сборка Fedora-пакета - Сборка Fedora-пакета.
- Сборка AppImage - Сборка AppImage.
### Changed ### Changed
- Удалён жёстко заданный размер окна - Удалён жёстко заданный размер окна.
- Использован `icoextract` как Python-модуль - Использован `icoextract` как Python-модуль.
### Fixed ### Fixed
- Скрытие статус-бара - Скрытие статус-бара.
- Чтение списка Steam-игр - Чтение списка Steam-игр.
- Зависание GUI - Зависание GUI.
- Сбой при повреждённом Steam - Сбой при повреждённом Steam.
### Contributors ### Contributors
- @Vector_null - @Vector_null

View File

@@ -45,7 +45,7 @@ AppDir:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt icon: ru.linux_gaming.PortProtonQt
version: 0.1.4 version: 0.1.5
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:

View File

@@ -1,5 +1,5 @@
pkgname=portprotonqt pkgname=portprotonqt
pkgver=0.1.4 pkgver=0.1.5
pkgrel=1 pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any') arch=('any')

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.4 %global pypi_version 0.1.5
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1

View File

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

View File

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

View File

@@ -52,102 +52,151 @@ The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game
```python ```python
GAME_CARD_ANIMATION = { GAME_CARD_ANIMATION = {
# Type of animation when entering and exiting the detail page # Type of animation when entering or exiting the detail page
# Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce" # Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Determines how the detail page appears and disappears
"detail_page_animation_type": "fade", "detail_page_animation_type": "fade",
# Border width of the card in idle state (no hover or focus). # Border width of the card in idle state (no hover or focus)
# Affects the thickness of the border when the card is not highlighted. # Affects the thickness of the border around the card when it's not selected
# Value in pixels. # Value in pixels
"default_border_width": 2, "default_border_width": 2,
# Border width on hover. # Border width on hover
# Increases the border thickness when the cursor is over the card. # Increases the border thickness when the cursor is over the card
# Value in pixels. # Value in pixels
"hover_border_width": 8, "hover_border_width": 8,
# Border width on focus (e.g., selected via keyboard). # Border width on focus (e.g., when selected via keyboard)
# Increases the border thickness when the card is focused. # Increases the border thickness when the card is focused
# Value in pixels. # Value in pixels
"focus_border_width": 12, "focus_border_width": 12,
# Minimum border width during pulsing animation. # Minimum border width during pulsing animation
# Sets the minimum border thickness during the "breathing" animation. # Determines the minimum border thickness during the "breathing" animation
# Value in pixels. # Value in pixels
"pulse_min_border_width": 8, "pulse_min_border_width": 8,
# Maximum border width during pulsing animation. # Maximum border width during pulsing animation
# Sets the maximum border thickness during pulsing. # Determines the maximum border thickness during pulsing
# Value in pixels. # Value in pixels
"pulse_max_border_width": 10, "pulse_max_border_width": 10,
# Duration of the border thickness animation (e.g., on hover or focus). # Duration of the border thickness animation (e.g., on hover or focus)
# Affects the speed of transition between different border widths. # Affects the speed of transition from one border width to another
# Value in milliseconds. # Value in milliseconds
"thickness_anim_duration": 300, "thickness_anim_duration": 300,
# Duration of one pulsing animation cycle. # Duration of one pulsing animation cycle
# Defines how fast the border "pulses" between min and max values. # Determines how fast the border "pulses" between min and max values
# Value in milliseconds. # Value in milliseconds
"pulse_anim_duration": 800, "pulse_anim_duration": 800,
# Duration of the gradient rotation animation. # Duration of the gradient rotation animation
# Affects how fast the gradient border rotates around the card. # Affects how fast the gradient border rotates around the card
# Value in milliseconds. # Value in milliseconds
"gradient_anim_duration": 3000, "gradient_anim_duration": 3000,
# Starting angle of the gradient (in degrees). # Starting angle of the gradient (in degrees)
# Defines the initial rotation point of the gradient when the animation starts. # Determines the initial rotation point of the gradient at animation start
"gradient_start_angle": 360, "gradient_start_angle": 360,
# Ending angle of the gradient (in degrees). # Ending angle of the gradient (in degrees)
# Defines the end rotation point of the gradient. # Determines the final rotation point of the gradient
# A value of 0 means a full 360-degree rotation. # Value 0 means a full 360° rotation
"gradient_end_angle": 0, "gradient_end_angle": 0,
# Easing curve type for border expansion animation (on hover/focus). # Type of card animation on hover or focus
# Affects the "feel" of the animation (e.g., smooth acceleration or deceleration). # Possible values: "gradient", "scale"
# Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad"). # "gradient" enables a rotating gradient for the border, "scale" enlarges the card
"card_animation_type": "gradient",
# Card scale in idle state
# Determines the base size of the card (1.0 = 100% of original size)
# Value as a fraction (e.g., 1.0 for normal size)
"default_scale": 1.0,
# Card scale on hover
# Increases the card size on hover
# Value as a fraction (e.g., 1.1 = 110% of original size)
"hover_scale": 1.1,
# Card scale on focus (e.g., when selected via keyboard)
# Increases the card size on focus
# Value as a fraction (e.g., 1.05 = 105% of original size)
"focus_scale": 1.05,
# Duration of scale animation
# Affects how fast the card changes size on hover or focus
# Value in milliseconds
"scale_anim_duration": 200,
# Easing curve type for border thickness increase animation (on hover/focus)
# Affects the "feel" of the animation (e.g., smooth acceleration or deceleration)
# Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack", "thickness_easing_curve": "OutBack",
# Easing curve type for border contraction animation (on mouse leave/focus loss). # Easing curve type for border thickness decrease animation (on hover/focus exit)
# Affects the "feel" of returning to the original border width. # Affects the "feel" of returning to the default border width
"thickness_easing_curve_out": "InBack", "thickness_easing_curve_out": "InBack",
# Gradient colors for the animated border. # Easing curve type for scale increase animation (on hover/focus)
# A list of dictionaries where each defines a position (0.01.0) and color in hex format. # Affects the "feel" of the scaling animation (e.g., with a "bounce" effect)
# Affects the appearance of the border on hover or focus. # Possible values: strings corresponding to QEasingCurve.Type
"scale_easing_curve": "OutBack",
# Easing curve type for scale decrease animation (on hover/focus exit)
# Affects the "feel" of returning to the original scale
"scale_easing_curve_out": "InBack",
# Gradient colors for animated border
# List of dictionaries, each specifying position (0.01.0) and color in hex format
# Affects the appearance of the border on hover or focus if card_animation_type="gradient"
"gradient_colors": [ "gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Start color (cyan) {"position": 0, "color": "#00fff5"}, # Starting color (cyan)
{"position": 0.33, "color": "#FF5733"}, # 33% color (orange) {"position": 0.33, "color": "#FF5733"}, # Color at 33% (orange)
{"position": 0.66, "color": "#9B59B6"}, # 66% color (purple) {"position": 0.66, "color": "#9B59B6"}, # Color at 66% (purple)
{"position": 1, "color": "#00fff5"} # End color (back to cyan) {"position": 1, "color": "#00fff5"} # Ending color (back to cyan)
], ],
# Duration of the fade animation when entering the detail page # Duration of fade animation when entering the detail page
# Affects the speed of page appearance with fade animation
# Value in milliseconds
"detail_page_fade_duration": 350, "detail_page_fade_duration": 350,
# Duration of the slide animation when entering the detail page # Duration of slide animation when entering the detail page
# Affects the speed of page sliding animation
# Value in milliseconds
"detail_page_slide_duration": 500, "detail_page_slide_duration": 500,
# Duration of the bounce animation when entering the detail page # Duration of bounce animation when entering the detail page
# Affects the speed of page "bounce" animation
# Value in milliseconds
"detail_page_bounce_duration": 400, "detail_page_bounce_duration": 400,
# Duration of the fade animation when exiting the detail page # Duration of fade animation when exiting the detail page
# Affects the speed of page disappearance with fade animation
# Value in milliseconds
"detail_page_fade_duration_exit": 350, "detail_page_fade_duration_exit": 350,
# Duration of the slide animation when exiting the detail page # Duration of slide animation when exiting the detail page
# Affects the speed of page sliding animation
# Value in milliseconds
"detail_page_slide_duration_exit": 500, "detail_page_slide_duration_exit": 500,
# Duration of the bounce animation when exiting the detail page # Duration of bounce animation when exiting the detail page
# Affects the speed of page "compression" animation
# Value in milliseconds
"detail_page_bounce_duration_exit": 400, "detail_page_bounce_duration_exit": 400,
# Easing curve type for animation when entering the detail page # Easing curve type for animations when entering the detail page
# Applies to slide and bounce animations # Applied to slide and bounce animations; affects the "feel" of movement
# Possible values: strings corresponding to QEasingCurve.Type
"detail_page_easing_curve": "OutCubic", "detail_page_easing_curve": "OutCubic",
# Easing curve type for animation when exiting the detail page # Easing curve type for animations when exiting the detail page
# Applies to slide and bounce animations # Applied to slide and bounce animations; affects the "feel" of movement
# Possible values: strings corresponding to QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic" "detail_page_easing_curve_exit": "InCubic"
} }
``` ```

View File

@@ -54,69 +54,104 @@ def custom_button_style(color1, color2):
GAME_CARD_ANIMATION = { GAME_CARD_ANIMATION = {
# Тип анимации при входе и выходе на детальную страницу # Тип анимации при входе и выходе на детальную страницу
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce" # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Определяет, как детальная страница появляется и исчезает
"detail_page_animation_type": "fade", "detail_page_animation_type": "fade",
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса). # Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
# Влияет на толщину рамки вокруг карточки, когда она не выделена. # Влияет на толщину рамки вокруг карточки, когда она не выделена
# Значение в пикселях. # Значение в пикселях
"default_border_width": 2, "default_border_width": 2,
# Ширина обводки при наведении курсора. # Ширина обводки при наведении курсора
# Увеличивает толщину рамки, когда курсор находится над карточкой. # Увеличивает толщину рамки, когда курсор находится над карточкой
# Значение в пикселях. # Значение в пикселях
"hover_border_width": 8, "hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры). # Ширина обводки при фокусе (например, при выборе с клавиатуры)
# Увеличивает толщину рамки, когда карточка в фокусе. # Увеличивает толщину рамки, когда карточка в фокусе
# Значение в пикселях. # Значение в пикселях
"focus_border_width": 12, "focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации. # Минимальная ширина обводки во время пульсирующей анимации
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания"). # Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
# Значение в пикселях. # Значение в пикселях
"pulse_min_border_width": 8, "pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации. # Максимальная ширина обводки во время пульсирующей анимации
# Определяет максимальную толщину рамки при пульсации. # Определяет максимальную толщину рамки при пульсации
# Значение в пикселях. # Значение в пикселях
"pulse_max_border_width": 10, "pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе). # Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
# Влияет на скорость перехода от одной ширины обводки к другой. # Влияет на скорость перехода от одной ширины обводки к другой
# Значение в миллисекундах. # Значение в миллисекундах
"thickness_anim_duration": 300, "thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации. # Длительность одного цикла пульсирующей анимации
# Определяет, как быстро рамка "пульсирует" между min и max значениями. # Определяет, как быстро рамка "пульсирует" между min и max значениями
# Значение в миллисекундах. # Значение в миллисекундах
"pulse_anim_duration": 800, "pulse_anim_duration": 800,
# Длительность анимации вращения градиента. # Длительность анимации вращения градиента
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки. # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
# Значение в миллисекундах. # Значение в миллисекундах
"gradient_anim_duration": 3000, "gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах). # Начальный угол градиента (в градусах)
# Определяет начальную точку вращения градиента при старте анимации. # Определяет начальную точку вращения градиента при старте анимации
"gradient_start_angle": 360, "gradient_start_angle": 360,
# Конечный угол градиента (в градусах). # Конечный угол градиента (в градусах)
# Определяет конечную точку вращения градиента. # Определяет конечную точку вращения градиента
# Значение 0 означает полный поворот на 360 градусов. # Значение 0 означает полный поворот на 360 градусов
"gradient_end_angle": 0, "gradient_end_angle": 0,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе). # Тип анимации для карточки при наведении или фокусе
# Влияет на "чувство" анимации (например, плавное ускорение или замедление). # Возможные значения: "gradient", "scale"
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad"). # "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
"card_animation_type": "gradient",
# Масштаб карточки в состоянии покоя
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
# Значение в долях (например, 1.0 для нормального размера)
"default_scale": 1.0,
# Масштаб карточки при наведении курсора
# Увеличивает размер карточки при наведении
# Значение в долях (например, 1.1 = 110% от исходного размера)
"hover_scale": 1.1,
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
# Увеличивает размер карточки при фокусе
# Значение в долях (например, 1.05 = 105% от исходного размера)
"focus_scale": 1.05,
# Длительность анимации масштабирования
# Влияет на скорость изменения размера карточки при наведении или фокусе
# Значение в миллисекундах
"scale_anim_duration": 200,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack", "thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса). # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходной ширине обводки. # Влияет на "чувство" возврата к исходной ширине обводки
"thickness_easing_curve_out": "InBack", "thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки. # Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex. # Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
# Влияет на внешний вид обводки при наведении или фокусе. # Возможные значения: строки, соответствующие QEasingCurve.Type
"scale_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходному масштабу
"scale_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
"gradient_colors": [ "gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан) {"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый) {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
@@ -125,29 +160,43 @@ GAME_CARD_ANIMATION = {
], ],
# Длительность анимации fade при входе на детальную страницу # Длительность анимации fade при входе на детальную страницу
# Влияет на скорость появления страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration": 350, "detail_page_fade_duration": 350,
# Длительность анимации slide при входе на детальную страницу # Длительность анимации slide при входе на детальную страницу
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration": 500, "detail_page_slide_duration": 500,
# Длительность анимации bounce при входе на детальную страницу # Длительность анимации bounce при входе на детальную страницу
# Влияет на скорость "прыжка" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration": 400, "detail_page_bounce_duration": 400,
# Длительность анимации fade при выходе из детальной страницы # Длительность анимации fade при выходе из детальной страницы
# Влияет на скорость исчезновения страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration_exit": 350, "detail_page_fade_duration_exit": 350,
# Длительность анимации slide при выходе из детальной страницы # Длительность анимации slide при выходе из детальной страницы
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration_exit": 500, "detail_page_slide_duration_exit": 500,
# Длительность анимации bounce при выходе из детальной страницы # Длительность анимации bounce при выходе из детальной страницы
# Влияет на скорость "сжатия" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration_exit": 400, "detail_page_bounce_duration_exit": 400,
# Тип кривой сглаживания для анимации при входе на детальную страницу # Тип кривой сглаживания для анимации при входе на детальную страницу
# Применяется к slide и bounce анимациям # Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve": "OutCubic", "detail_page_easing_curve": "OutCubic",
# Тип кривой сглаживания для анимации при выходе из детальной страницы # Тип кривой сглаживания для анимации при выходе из детальной страницы
# Применяется к slide и bounce анимациям # Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic" "detail_page_easing_curve_exit": "InCubic"
} }
``` ```

View File

@@ -26,14 +26,23 @@ class GameCardAnimations:
self.theme = theme if theme is not None else default_styles self.theme = theme if theme is not None else default_styles
self.thickness_anim: QPropertyAnimation | None = None self.thickness_anim: QPropertyAnimation | None = None
self.gradient_anim: QPropertyAnimation | None = None self.gradient_anim: QPropertyAnimation | None = None
self.scale_anim: QPropertyAnimation | None = None
self.pulse_anim: QPropertyAnimation | None = None self.pulse_anim: QPropertyAnimation | None = None
self._isPulseAnimationConnected = False self._isPulseAnimationConnected = False
def setup_animations(self): def setup_animations(self):
"""Initialize animation properties.""" """Initialize animation properties based on theme."""
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth")) self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"]) self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type == "gradient":
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
elif animation_type == "scale":
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
def start_pulse_animation(self): def start_pulse_animation(self):
"""Start pulse animation for border width when hovered or focused.""" """Start pulse animation for border width when hovered or focused."""
if not (self.game_card._hovered or self.game_card._focused): if not (self.game_card._hovered or self.game_card._focused):
@@ -57,6 +66,8 @@ class GameCardAnimations:
if not self.thickness_anim: if not self.thickness_anim:
self.setup_animations() self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
@@ -69,23 +80,44 @@ class GameCardAnimations:
self._isPulseAnimationConnected = True self._isPulseAnimationConnected = True
self.thickness_anim.start() self.thickness_anim.start()
if self.gradient_anim: if animation_type == "gradient":
self.gradient_anim.stop() if self.gradient_anim:
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) self.gradient_anim.stop()
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setLoopCount(-1) self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.start() self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_scale"])
self.scale_anim.start()
def handle_leave_event(self): def handle_leave_event(self):
"""Handle mouse leave event animations.""" """Handle mouse leave event animations."""
self.game_card._hovered = False self.game_card._hovered = False
self.game_card.hoverChanged.emit(self.game_card.name, False) self.game_card.hoverChanged.emit(self.game_card.name, False)
if not self.game_card._focused: if not self.game_card._focused:
if self.gradient_anim: animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
self.gradient_anim.stop() if animation_type == "gradient":
self.gradient_anim = None if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start()
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() self.pulse_anim.stop()
self.pulse_anim = None self.pulse_anim = None
@@ -108,6 +140,8 @@ class GameCardAnimations:
if not self.thickness_anim: if not self.thickness_anim:
self.setup_animations() self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
@@ -120,23 +154,44 @@ class GameCardAnimations:
self._isPulseAnimationConnected = True self._isPulseAnimationConnected = True
self.thickness_anim.start() self.thickness_anim.start()
if self.gradient_anim: if animation_type == "gradient":
self.gradient_anim.stop() if self.gradient_anim:
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) self.gradient_anim.stop()
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
self.gradient_anim.setLoopCount(-1) self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
self.gradient_anim.start() self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_scale"])
self.scale_anim.start()
def handle_focus_out_event(self): def handle_focus_out_event(self):
"""Handle focus out event animations.""" """Handle focus out event animations."""
self.game_card._focused = False self.game_card._focused = False
self.game_card.focusChanged.emit(self.game_card.name, False) self.game_card.focusChanged.emit(self.game_card.name, False)
if not self.game_card._hovered: if not self.game_card._hovered:
if self.gradient_anim: animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
self.gradient_anim.stop() if animation_type == "gradient":
self.gradient_anim = None if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start()
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() self.pulse_anim.stop()
self.pulse_anim = None self.pulse_anim = None
@@ -157,7 +212,8 @@ class GameCardAnimations:
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen() pen = QPen()
pen.setWidth(self.game_card._borderWidth) pen.setWidth(self.game_card._borderWidth)
if self.game_card._hovered or self.game_card._focused: animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
center = self.game_card.rect().center() center = self.game_card.rect().center()
gradient = QConicalGradient(center, self.game_card._gradientAngle) gradient = QConicalGradient(center, self.game_card._gradientAngle)
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]: for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
@@ -166,11 +222,11 @@ class GameCardAnimations:
else: else:
pen.setColor(QColor(0, 0, 0, 0)) pen.setColor(QColor(0, 0, 0, 0))
painter.setPen(pen) painter.setPen(pen)
radius = 18 radius = 18 * self.game_card._scale
bw = round(self.game_card._borderWidth / 2) bw = round(self.game_card._borderWidth / 2)
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw) rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
if rect.isEmpty(): if rect.isEmpty():
return # Avoid drawing invalid rect return
painter.drawRoundedRect(rect, radius, radius) painter.drawRoundedRect(rect, radius, radius)
class DetailPageAnimations: class DetailPageAnimations:
@@ -284,15 +340,15 @@ class DetailPageAnimations:
logger.debug("Original effect already deleted") logger.debug("Original effect already deleted")
cleanup_callback() cleanup_callback()
animation.finished.connect(restore_and_cleanup) animation.finished.connect(restore_and_cleanup)
animation.finished.connect(opacity_effect.deleteLater) # Clean up effect animation.finished.connect(opacity_effect.deleteLater)
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]: elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
end_pos = { end_pos = {
"slide_left": QPoint(-self.main_window.width(), 0), # Exit to left (opposite of entry) "slide_left": QPoint(-self.main_window.width(), 0),
"slide_right": QPoint(self.main_window.width(), 0), # Exit to right "slide_right": QPoint(self.main_window.width(), 0),
"slide_up": QPoint(0, self.main_window.height()), # Exit downward "slide_up": QPoint(0, self.main_window.height()),
"slide_down": QPoint(0, -self.main_window.height()) # Exit upward "slide_down": QPoint(0, -self.main_window.height())
}[animation_type] }[animation_type]
animation = QPropertyAnimation(detail_page, QByteArray(b"pos")) animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
animation.setDuration(duration) animation.setDuration(duration)
@@ -325,4 +381,4 @@ class DetailPageAnimations:
except Exception as e: except Exception as e:
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True) logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
self.animations.pop(detail_page, None) self.animations.pop(detail_page, None)
cleanup_callback() # Fallback to cleanup if animation setup fails cleanup_callback()

View File

@@ -3,8 +3,7 @@ from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
from portprotonqt.tray import SystemTray from portprotonqt.config_utils import save_fullscreen_config
from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.cli import parse_args from portprotonqt.cli import parse_args
@@ -12,7 +11,7 @@ logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.4" __app_version__ = "0.1.5"
def main(): def main():
app = QApplication(sys.argv) app = QApplication(sys.argv)
@@ -31,42 +30,20 @@ def main():
args = parse_args() args = parse_args()
window = MainWindow() window = MainWindow(app_name=__app_name__)
if args.fullscreen: if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag") logger.info("Launching in fullscreen mode due to --fullscreen flag")
save_fullscreen_config(True) save_fullscreen_config(True)
window.showFullScreen() window.showFullScreen()
current_theme_name = read_theme_from_config()
tray = SystemTray(app, current_theme_name)
tray.show_action.triggered.connect(window.show)
tray.hide_action.triggered.connect(window.hide)
def recreate_tray():
nonlocal tray
if tray:
logger.debug("Recreating system tray")
tray.cleanup()
tray = None
current_theme = read_theme_from_config()
tray = SystemTray(app, current_theme)
# Ensure window is not None before connecting signals
if window:
tray.show_action.triggered.connect(window.show)
tray.hide_action.triggered.connect(window.hide)
def cleanup_on_exit(): def cleanup_on_exit():
nonlocal tray, window nonlocal window
app.aboutToQuit.disconnect() app.aboutToQuit.disconnect()
if tray:
tray.cleanup()
tray = None
if window: if window:
window.close() window.close()
app.quit() app.quit()
window.settings_saved.connect(recreate_tray)
app.aboutToQuit.connect(cleanup_on_exit) app.aboutToQuit.connect(cleanup_on_exit)
window.show() window.show()

View File

@@ -549,3 +549,41 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen) cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
def read_favorite_folders():
"""
Читает список избранных папок из секции [FavoritesFolders] конфигурационного файла.
Список хранится как строка, заключённая в кавычки, с путями, разделёнными запятыми.
Если секция или параметр отсутствуют, возвращает пустой список.
"""
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)
return []
if cp.has_section("FavoritesFolders") and cp.has_option("FavoritesFolders", "folders"):
favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
return []
def save_favorite_folders(folders):
"""
Сохраняет список избранных папок в секцию [FavoritesFolders] конфигурационного файла.
Список сохраняется как строка, заключённая в двойные кавычки, где пути разделены запятыми.
"""
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)
if "FavoritesFolders" not in cp:
cp["FavoritesFolders"] = {}
fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)

View File

@@ -12,7 +12,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
@@ -150,6 +150,84 @@ class ContextMenuManager:
return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
def show_folder_context_menu(self, file_explorer, pos):
"""Shows the context menu for a folder in FileExplorer."""
try:
item = file_explorer.file_list.itemAt(pos)
if not item:
logger.debug("No item selected at position %s", pos)
return
selected = item.text()
if not selected.endswith("/"):
logger.debug("Selected item is not a folder: %s", selected)
return # Only for folders
full_path = os.path.normpath(os.path.join(file_explorer.current_path, selected.rstrip("/")))
if not os.path.isdir(full_path):
logger.debug("Path is not a directory: %s", full_path)
return
menu = QMenu(file_explorer)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
menu.setParent(file_explorer, Qt.WindowType.Popup) # Set transientParent for Wayland
favorite_folders = read_favorite_folders()
is_favorite = full_path in favorite_folders
action_text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
favorite_action = menu.addAction(self._get_safe_icon("star" if is_favorite else "star_full"), action_text)
favorite_action.triggered.connect(lambda: self.toggle_favorite_folder(file_explorer, full_path, not is_favorite))
# Disconnect file_list signals to prevent navigation during menu interaction
try:
file_explorer.file_list.itemClicked.disconnect(file_explorer.handle_item_click)
file_explorer.file_list.itemDoubleClicked.disconnect(file_explorer.handle_item_double_click)
except TypeError:
pass # Signals may not be connected
# Reconnect signals after menu closes
def reconnect_signals():
try:
file_explorer.file_list.itemClicked.connect(file_explorer.handle_item_click)
file_explorer.file_list.itemDoubleClicked.connect(file_explorer.handle_item_double_click)
except Exception as e:
logger.error("Error reconnecting file list signals: %s", e)
menu.aboutToHide.connect(reconnect_signals)
# Set focus to the first menu item
actions = menu.actions()
if actions:
menu.setActiveAction(actions[0])
# Map local position to global for menu display
global_pos = file_explorer.file_list.mapToGlobal(pos)
menu.exec(global_pos)
except Exception as e:
logger.error("Error showing folder context menu: %s", e)
def toggle_favorite_folder(self, file_explorer, folder_path, add):
"""Adds or removes a folder from favorites."""
favorite_folders = read_favorite_folders()
if add:
if folder_path not in favorite_folders:
favorite_folders.append(folder_path)
save_favorite_folders(favorite_folders)
logger.info(f"Folder added to favorites: {folder_path}")
else:
if folder_path in favorite_folders:
favorite_folders.remove(folder_path)
save_favorite_folders(favorite_folders)
logger.info(f"Folder removed from favorites: {folder_path}")
file_explorer.update_drives_list()
def _get_safe_icon(self, icon_name: str) -> QIcon:
"""Returns a QIcon, ensuring it is valid."""
icon = self.theme_manager.get_icon(icon_name)
if isinstance(icon, QIcon):
return icon
elif isinstance(icon, str) and os.path.exists(icon):
return QIcon(icon)
return QIcon()
def show_context_menu(self, game_card, pos: QPoint): def show_context_menu(self, game_card, pos: QPoint):
""" """
Show the context menu for a game card at the specified position. Show the context menu for a game card at the specified position.
@@ -158,14 +236,6 @@ class ContextMenuManager:
game_card: The GameCard instance requesting the context menu. game_card: The GameCard instance requesting the context menu.
pos: The position (in widget coordinates) where the menu should appear. pos: The position (in widget coordinates) where the menu should appear.
""" """
def get_safe_icon(icon_name: str) -> QIcon:
icon = self.theme_manager.get_icon(icon_name)
if isinstance(icon, QIcon):
return icon
elif isinstance(icon, str) and os.path.exists(icon):
return QIcon(icon)
return QIcon()
menu = QMenu(self.parent) menu = QMenu(self.parent)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE) menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
@@ -175,7 +245,7 @@ class ContextMenuManager:
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
if not exe_path: if not exe_path:
# Show only "Delete from PortProton" if no valid exe # Show only "Delete from PortProton" if no valid exe
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton")) delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line)) delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
menu.exec(game_card.mapToGlobal(pos)) menu.exec(game_card.mapToGlobal(pos))
return return
@@ -184,7 +254,7 @@ class ContextMenuManager:
is_running = self._is_game_running(game_card) is_running = self._is_game_running(game_card)
action_text = _("Stop Game") if is_running else _("Launch Game") action_text = _("Stop Game") if is_running else _("Launch Game")
action_icon = "stop" if is_running else "play" action_icon = "stop" if is_running else "play"
launch_action = menu.addAction(get_safe_icon(action_icon), action_text) launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
launch_action.triggered.connect( launch_action.triggered.connect(
lambda: self._launch_game(game_card) lambda: self._launch_game(game_card)
) )
@@ -193,11 +263,11 @@ class ContextMenuManager:
is_favorite = game_card.name in favorites is_favorite = game_card.name in favorites
icon_name = "star_full" if is_favorite else "star" icon_name = "star_full" if is_favorite else "star"
text = _("Remove from Favorites") if is_favorite else _("Add to Favorites") text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
favorite_action = menu.addAction(get_safe_icon(icon_name), text) favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite)) favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
if game_card.game_source == "epic": if game_card.game_source == "epic":
import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary")) import_action = menu.addAction(self._get_safe_icon("epic_games"), _("Import to Legendary"))
import_action.triggered.connect( import_action.triggered.connect(
lambda: self.import_to_legendary(game_card.name, game_card.appid) lambda: self.import_to_legendary(game_card.name, game_card.appid)
) )
@@ -205,13 +275,13 @@ class ContextMenuManager:
is_in_steam = is_game_in_steam(game_card.name) is_in_steam = is_game_in_steam(game_card.name)
icon_name = "delete" if is_in_steam else "steam" icon_name = "delete" if is_in_steam else "steam"
text = _("Remove from Steam") if is_in_steam else _("Add to Steam") text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(get_safe_icon(icon_name), text) steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
steam_action.triggered.connect( steam_action.triggered.connect(
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
if is_in_steam if is_in_steam
else self.add_egs_to_steam(game_card.name, game_card.appid) else self.add_egs_to_steam(game_card.name, game_card.appid)
) )
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder")) open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
open_folder_action.triggered.connect( open_folder_action.triggered.connect(
lambda: self.open_egs_game_folder(game_card.appid) lambda: self.open_egs_game_folder(game_card.appid)
) )
@@ -219,7 +289,7 @@ class ContextMenuManager:
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop") desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(desktop_path) else "desktop" icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop") text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
desktop_action = menu.addAction(get_safe_icon(icon_name), text) desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
desktop_action.triggered.connect( desktop_action.triggered.connect(
lambda: self.remove_egs_from_desktop(game_card.name) lambda: self.remove_egs_from_desktop(game_card.name)
if os.path.exists(desktop_path) if os.path.exists(desktop_path)
@@ -228,7 +298,7 @@ class ContextMenuManager:
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications") applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop") menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
menu_action = menu.addAction( menu_action = menu.addAction(
get_safe_icon("delete" if os.path.exists(menu_path) else "menu"), self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
_("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu") _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
) )
menu_action.triggered.connect( menu_action.triggered.connect(
@@ -242,19 +312,19 @@ class ContextMenuManager:
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop") desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(desktop_path) else "desktop" icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop") text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
desktop_action = menu.addAction(get_safe_icon(icon_name), text) desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
desktop_action.triggered.connect( desktop_action.triggered.connect(
lambda: self.remove_from_desktop(game_card.name) lambda: self.remove_from_desktop(game_card.name)
if os.path.exists(desktop_path) if os.path.exists(desktop_path)
else self.add_to_desktop(game_card.name, game_card.exec_line) else self.add_to_desktop(game_card.name, game_card.exec_line)
) )
edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut")) edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect( edit_action.triggered.connect(
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path) lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
) )
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton")) delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line)) delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder")) open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
open_folder_action.triggered.connect( open_folder_action.triggered.connect(
lambda: self.open_game_folder(game_card.name, game_card.exec_line) lambda: self.open_game_folder(game_card.name, game_card.exec_line)
) )
@@ -262,7 +332,7 @@ class ContextMenuManager:
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop") menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(menu_path) else "menu" icon_name = "delete" if os.path.exists(menu_path) else "menu"
text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu") text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
menu_action = menu.addAction(get_safe_icon(icon_name), text) menu_action = menu.addAction(self._get_safe_icon(icon_name), text)
menu_action.triggered.connect( menu_action.triggered.connect(
lambda: self.remove_from_menu(game_card.name) lambda: self.remove_from_menu(game_card.name)
if os.path.exists(menu_path) if os.path.exists(menu_path)
@@ -271,7 +341,7 @@ class ContextMenuManager:
is_in_steam = is_game_in_steam(game_card.name) is_in_steam = is_game_in_steam(game_card.name)
icon_name = "delete" if is_in_steam else "steam" icon_name = "delete" if is_in_steam else "steam"
text = _("Remove from Steam") if is_in_steam else _("Add to Steam") text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(get_safe_icon(icon_name), text) steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
steam_action.triggered.connect( steam_action.triggered.connect(
lambda: ( lambda: (
self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
@@ -280,7 +350,7 @@ class ContextMenuManager:
) )
) )
# Устанавливаем фокус на первый элемент меню # Set focus to the first menu item
actions = menu.actions() actions = menu.actions()
if actions: if actions:
menu.setActiveAction(actions[0]) menu.setActiveAction(actions[0])
@@ -422,7 +492,7 @@ class ContextMenuManager:
) )
return return
# Используем FileExplorer с directory_only=True # Use FileExplorer with directory_only=True
file_explorer = FileExplorer( file_explorer = FileExplorer(
parent=self.parent, parent=self.parent,
theme=self.theme, theme=self.theme,
@@ -452,10 +522,10 @@ class ContextMenuManager:
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name)) self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
threading.Thread(target=run_import, daemon=True).start() threading.Thread(target=run_import, daemon=True).start()
# Подключаем сигнал выбора файла/папки # Connect the file selection signal
file_explorer.file_signal.file_selected.connect(on_folder_selected) file_explorer.file_signal.file_selected.connect(on_folder_selected)
# Центрируем FileExplorer относительно родительского виджета # Center FileExplorer relative to the parent widget
parent_widget = self.parent parent_widget = self.parent
if parent_widget: if parent_widget:
parent_geometry = parent_widget.geometry() parent_geometry = parent_widget.geometry()
@@ -789,7 +859,7 @@ Icon={icon_path}
_("Failed to delete custom data: {error}").format(error=str(e)) _("Failed to delete custom data: {error}").format(error=str(e))
) )
# Перезагрузка списка игр и обновление сетки # Reload games list and update grid
self.load_games() self.load_games()
self.update_game_grid() self.update_game_grid()

View File

@@ -8,8 +8,8 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек. Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота). nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
rect_width: доступная ширина контейнера. rect_width: доступная ширина контейнера.
spacing: отступ между элементами. spacing: отступ между элементами (горизонтальный и вертикальный).
max_scale: максимальный коэффициент масштабирования (например, 1.2). max_scale: максимальный коэффициент масштабирования (например, 1.0).
Возвращает: Возвращает:
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height]. result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
@@ -19,16 +19,49 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
result = np.zeros((N, 4), dtype=np.int32) result = np.zeros((N, 4), dtype=np.int32)
y = 0 y = 0
i = 0 i = 0
min_margin = 20 # Минимальный отступ по краям
# Определяем максимальное количество элементов в ряду и общий масштаб
max_items_per_row = 0
global_scale = 1.0
max_row_x_start = min_margin # Начальная позиция x самого длинного ряда
temp_i = 0
# Первый проход: находим максимальное количество элементов в ряду
while temp_i < N:
sum_width = 0
count = 0
temp_j = temp_i
while temp_j < N:
w = nat_sizes[temp_j, 0]
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
break
sum_width += w
count += 1
temp_j += 1
if count > max_items_per_row:
max_items_per_row = count
# Вычисляем масштаб для самого заполненного ряда
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
global_scale = desired_scale if desired_scale < max_scale else max_scale
# Сохраняем начальную позицию x для самого длинного ряда
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
temp_i = temp_j
# Второй проход: размещаем элементы
while i < N: while i < N:
sum_width = 0 sum_width = 0
row_max_height = 0 row_max_height = 0
count = 0 count = 0
j = i j = i
# Подбираем количество элементов для текущего ряда # Подбираем количество элементов для текущего ряда
while j < N: while j < N:
w = nat_sizes[j, 0] w = nat_sizes[j, 0]
# Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
if count > 0 and (sum_width + spacing + w) > rect_width:
break break
sum_width += w sum_width += w
count += 1 count += 1
@@ -36,13 +69,19 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
if h > row_max_height: if h > row_max_height:
row_max_height = h row_max_height = h
j += 1 j += 1
# Доступная ширина ряда с учетом обязательных отступов между элементами
available_width = rect_width - spacing * (count - 1) # Используем глобальный масштаб для всех рядов
desired_scale = available_width / sum_width if sum_width > 0 else 1.0 scale = global_scale
# Разрешаем увеличение карточек, но не более max_scale scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
scale = desired_scale if desired_scale < max_scale else max_scale
# Выравниваем по левому краю (offset = 0) # Определяем начальную координату x
x = 0 if count == max_items_per_row:
# Центрируем полный ряд
x = max(min_margin, (rect_width - scaled_row_width) // 2)
else:
# Выравниваем неполный ряд по левому краю, совмещая с началом самого длинного ряда
x = max_row_x_start
for k in range(i, j): for k in range(i, j):
new_w = int(nat_sizes[k, 0] * scale) new_w = int(nat_sizes[k, 0] * scale)
new_h = int(nat_sizes[k, 1] * scale) new_h = int(nat_sizes[k, 1] * scale)
@@ -51,6 +90,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
result[k, 2] = new_w result[k, 2] = new_w
result[k, 3] = new_h result[k, 3] = new_h
x += new_w + spacing x += new_w + spacing
y += int(row_max_height * scale) + spacing y += int(row_max_height * scale) + spacing
i = j i = j
return result, y return result, y
@@ -59,18 +99,17 @@ class FlowLayout(QLayout):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.itemList = [] self.itemList = []
# Устанавливаем отступы контейнера в 0 и задаем spacing между карточками self.setContentsMargins(20, 20, 20, 20) # Отступы по краям
self.setContentsMargins(0, 0, 0, 0) self._spacing = 20 # Отступ для анимации и предотвращения перекрытий
self._spacing = 3 # отступ между карточками self._max_scale = 1.0 # Отключено масштабирование в layout
self._max_scale = 1.2 # максимальное увеличение карточек (например, на 20%)
def addItem(self, item: QLayoutItem) -> None: def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item) self.itemList.append(item)
def takeAt(self, index: int) -> QLayoutItem: def takeAt(self, index: int) -> QLayoutItem:
if 0 <= index < len(self.itemList): if 0 <= index < len(self.itemList):
return self.itemList.pop(index) return self.itemList.pop(index)
raise IndexError("Index out of range") raise IndexError("Index out of range")
def count(self) -> int: def count(self) -> int:
return len(self.itemList) return len(self.itemList)
@@ -102,7 +141,7 @@ class FlowLayout(QLayout):
size = size.expandedTo(item.minimumSize()) size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins() margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(), size += QSize(margins.left() + margins.right(),
margins.top() + margins.bottom()) margins.top() + margins.bottom())
return size return size
def doLayout(self, rect, testOnly): def doLayout(self, rect, testOnly):
@@ -110,14 +149,12 @@ class FlowLayout(QLayout):
if N == 0: if N == 0:
return 0 return 0
# Собираем натуральные размеры всех элементов в массив NumPy
nat_sizes = np.empty((N, 2), dtype=np.int32) nat_sizes = np.empty((N, 2), dtype=np.int32)
for i, item in enumerate(self.itemList): for i, item in enumerate(self.itemList):
s = item.sizeHint() s = item.sizeHint()
nat_sizes[i, 0] = s.width() nat_sizes[i, 0] = s.width()
nat_sizes[i, 1] = s.height() nat_sizes[i, 1] = s.height()
# Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale) geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
if not testOnly: if not testOnly:
@@ -152,7 +189,7 @@ class ClickableLabel(QLabel):
self._icon_size = icon_size self._icon_size = icon_size
self._icon_space = icon_space self._icon_space = icon_space
self._font_scale_factor = font_scale_factor self._font_scale_factor = font_scale_factor
self._card_width = 250 # Значение по умолчанию self._card_width = 250
if change_cursor: if change_cursor:
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.updateFontSize() self.updateFontSize()
@@ -170,28 +207,23 @@ class ClickableLabel(QLabel):
self.update() self.update()
def setCardWidth(self, card_width: int): def setCardWidth(self, card_width: int):
"""Обновляет ширину карточки и пересчитывает размер шрифта."""
self._card_width = card_width self._card_width = card_width
self.updateFontSize() self.updateFontSize()
def updateFontSize(self): def updateFontSize(self):
"""Обновляет размер шрифта на основе card_width и font_scale_factor."""
font = self.font() font = self.font()
font_size = int(self._card_width * self._font_scale_factor) font_size = int(self._card_width * self._font_scale_factor)
font.setPointSize(max(8, font_size)) # Минимальный размер шрифта 8 font.setPointSize(max(8, font_size))
self.setFont(font) self.setFont(font)
self.update() self.update()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.contentsRect() rect = self.contentsRect()
alignment = self.alignment() alignment = self.alignment()
icon_size = self._icon_size icon_size = self._icon_size
spacing = self._icon_space spacing = self._icon_space
text = self.text() text = self.text()
if self._icon: if self._icon:
@@ -200,17 +232,11 @@ class ClickableLabel(QLabel):
pixmap = None pixmap = None
fm = QFontMetrics(self.font()) fm = QFontMetrics(self.font())
# Считаем, сколько места остаётся под текст
available_width = rect.width() available_width = rect.width()
if pixmap: if pixmap:
available_width -= (icon_size + spacing) available_width -= (icon_size + spacing)
# Отступы по 2px с каждой стороны
available_width = max(0, available_width - 4) available_width = max(0, available_width - 4)
# Получаем «обрезанный» текст с многоточием
display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width) display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
text_width = fm.horizontalAdvance(display_text) text_width = fm.horizontalAdvance(display_text)
text_height = fm.height() text_height = fm.height()
total_width = text_width + (icon_size + spacing if pixmap else 0) total_width = text_width + (icon_size + spacing if pixmap else 0)
@@ -280,8 +306,6 @@ class AutoSizeButton(QPushButton):
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFlat(True) self.setFlat(True)
# Изначально выставляем минимальную ширину
self.setMinimumWidth(50) self.setMinimumWidth(50)
self.adjustFontSize() self.adjustFontSize()
@@ -312,7 +336,6 @@ class AutoSizeButton(QPushButton):
if not self._update_size: if not self._update_size:
return return
# Определяем доступную ширину внутри кнопки
available_width = self.width() available_width = self.width()
if self._icon: if self._icon:
available_width -= self._icon_size available_width -= self._icon_size
@@ -323,7 +346,6 @@ class AutoSizeButton(QPushButton):
font = QFont(self._original_font) font = QFont(self._original_font)
text = self._original_text text = self._original_text
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
chosen_size = self._max_font_size chosen_size = self._max_font_size
for font_size in range(self._max_font_size, self._min_font_size - 1, -1): for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
font.setPointSize(font_size) font.setPointSize(font_size)
@@ -336,14 +358,12 @@ class AutoSizeButton(QPushButton):
font.setPointSize(chosen_size) font.setPointSize(chosen_size)
self.setFont(font) self.setFont(font)
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
fm = QFontMetrics(font) fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(text) text_width = fm.horizontalAdvance(text)
required_width = text_width + margins.left() + margins.right() + self._padding * 2 required_width = text_width + margins.left() + margins.right() + self._padding * 2
if self._icon: if self._icon:
required_width += self._icon_size required_width += self._icon_size
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
if self.width() < required_width: if self.width() < required_width:
self.setMinimumWidth(required_width) self.setMinimumWidth(required_width)
@@ -353,7 +373,6 @@ class AutoSizeButton(QPushButton):
if not self._update_size: if not self._update_size:
return super().sizeHint() return super().sizeHint()
else: else:
# Вычисляем оптимальный размер кнопки на основе текста и отступов
font = self.font() font = self.font()
fm = QFontMetrics(font) fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(self._original_text) text_width = fm.horizontalAdvance(self._original_text)
@@ -364,7 +383,6 @@ class AutoSizeButton(QPushButton):
height = fm.height() + margins.top() + margins.bottom() + self._padding height = fm.height() + margins.top() + margins.bottom() + self._padding
return QSize(width, height) return QSize(width, height)
class NavLabel(QLabel): class NavLabel(QLabel):
clicked = Signal() clicked = Signal()
@@ -376,7 +394,6 @@ class NavLabel(QLabel):
self._isChecked = False self._isChecked = False
self.setProperty("checked", self._isChecked) self.setProperty("checked", self._isChecked)
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
# Explicitly enable focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def setCheckable(self, checkable): def setCheckable(self, checkable):
@@ -395,7 +412,6 @@ class NavLabel(QLabel):
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
# Ensure widget can take focus on click
self.setFocus(Qt.FocusReason.MouseFocusReason) self.setFocus(Qt.FocusReason.MouseFocusReason)
if self._checkable: if self._checkable:
self.setChecked(not self._isChecked) self.setChecked(not self._isChecked)

View File

@@ -4,18 +4,19 @@ import re
from typing import cast, TYPE_CHECKING from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar
) )
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
from icoextract import IconExtractor, IconExtractorError from icoextract import IconExtractor, IconExtractorError
from PIL import Image from PIL import Image
from portprotonqt.config_utils import get_portproton_location from portprotonqt.config_utils import get_portproton_location, read_favorite_folders
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
import portprotonqt.themes.standart.styles as default_styles import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
import psutil
if TYPE_CHECKING: if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
@@ -89,6 +90,86 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
class FileSelectedSignal(QObject): class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу file_selected = Signal(str) # Сигнал с путем к выбранному файлу
class GameLaunchDialog(QDialog):
"""Modal dialog to indicate game launch progress, similar to Steam's launch dialog."""
def __init__(self, parent=None, game_name=None, theme=None, target_exe=None):
super().__init__(parent)
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager()
self.game_name = game_name
self.target_exe = target_exe # Store the target executable name
self.setWindowTitle(_("Launching {0}").format(self.game_name))
self.setModal(True)
self.setFixedSize(400, 200)
self.setStyleSheet(self.theme.MESSAGE_BOX_STYLE)
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.FramelessWindowHint)
# Layout
layout = QVBoxLayout(self)
layout.setContentsMargins(10, 10, 10, 10)
layout.setSpacing(10)
# Game name label
label = QLabel(_("Launching {0}").format(self.game_name))
label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
layout.addWidget(label)
# Progress bar (indeterminate)
self.progress_bar = QProgressBar()
self.progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
self.progress_bar.setRange(0, 0) # Indeterminate mode
layout.addWidget(self.progress_bar)
# Cancel button
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.clicked.connect(self.reject)
layout.addWidget(self.cancel_button, alignment=Qt.AlignmentFlag.AlignCenter)
# Center dialog on parent
if parent:
parent_geometry = parent.geometry()
center_point = parent_geometry.center()
dialog_geometry = self.geometry()
dialog_geometry.moveCenter(center_point)
self.setGeometry(dialog_geometry)
# Timer to check if the game process is running
self.check_process_timer = QTimer(self)
self.check_process_timer.timeout.connect(self.check_target_exe)
self.check_process_timer.start(500)
def is_target_exe_running(self):
"""Check if the target executable is running using psutil."""
if not self.target_exe:
return False
for proc in psutil.process_iter(attrs=["name"]):
if proc.info["name"].lower() == self.target_exe.lower():
return True
return False
def check_target_exe(self):
"""Check if the game process is running and close the dialog if it is."""
if self.is_target_exe_running():
logger.info(f"Game {self.game_name} process detected as running, closing launch dialog")
self.accept() # Close dialog when game is running
self.check_process_timer.stop()
self.check_process_timer.deleteLater()
elif not hasattr(self.parent(), 'game_processes') or not any(proc.poll() is None for proc in cast("MainWindow", self.parent()).game_processes):
# If no child processes are running, stop the timer but keep dialog open
self.check_process_timer.stop()
self.check_process_timer.deleteLater()
def reject(self):
"""Handle dialog cancellation."""
logger.info(f"Game launch cancelled for {self.game_name}")
self.check_process_timer.stop()
self.check_process_timer.deleteLater()
super().reject()
class FileExplorer(QDialog): class FileExplorer(QDialog):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False): def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent) super().__init__(parent)
@@ -106,13 +187,15 @@ class FileExplorer(QDialog):
self.setWindowModality(Qt.WindowModality.ApplicationModal) self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint) self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
# Find InputManager from parent # Find InputManager and ContextMenuManager from parent
self.input_manager = None self.input_manager = None
self.context_menu_manager = None
parent = self.parent() parent = self.parent()
while parent: while parent:
if hasattr(parent, 'input_manager'): if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager self.input_manager = cast("MainWindow", parent).input_manager
break if hasattr(parent, 'context_menu_manager'):
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
parent = parent.parent() parent = parent.parent()
if self.input_manager: if self.input_manager:
@@ -137,8 +220,9 @@ class FileExplorer(QDialog):
if len(parts) < 2: if len(parts) < 2:
continue continue
mount_point = parts[1] mount_point = parts[1]
# Исключаем системные и временные пути # Исключаем системные и временные пути, но сохраняем /run/media
if mount_point.startswith(('/run', '/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')): if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
(mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
continue continue
# Проверяем, является ли точка монтирования директорией и доступна ли она # Проверяем, является ли точка монтирования директорией и доступна ли она
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK): if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
@@ -150,7 +234,7 @@ class FileExplorer(QDialog):
def setup_ui(self): def setup_ui(self):
"""Настройка интерфейса""" """Настройка интерфейса"""
self.setWindowTitle("File Explorer") self.setWindowTitle(_("File Explorer"))
self.setGeometry(100, 100, 600, 600) self.setGeometry(100, 100, 600, 600)
self.main_layout = QVBoxLayout() self.main_layout = QVBoxLayout()
@@ -158,7 +242,7 @@ class FileExplorer(QDialog):
self.main_layout.setSpacing(10) self.main_layout.setSpacing(10)
self.setLayout(self.main_layout) self.setLayout(self.main_layout)
# Панель для смонтированных дисков # Панель для смонтированных дисков и избранных папок
self.drives_layout = QHBoxLayout() self.drives_layout = QHBoxLayout()
self.drives_scroll = QScrollArea() self.drives_scroll = QScrollArea()
self.drives_scroll.setWidgetResizable(True) self.drives_scroll.setWidgetResizable(True)
@@ -169,7 +253,7 @@ class FileExplorer(QDialog):
self.drives_scroll.setFixedHeight(70) self.drives_scroll.setFixedHeight(70)
self.main_layout.addWidget(self.drives_scroll) self.main_layout.addWidget(self.drives_scroll)
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Allow focus on scroll area self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Путь # Путь
self.path_label = QLabel() self.path_label = QLabel()
@@ -181,6 +265,8 @@ class FileExplorer(QDialog):
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE) self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
self.file_list.itemClicked.connect(self.handle_item_click) self.file_list.itemClicked.connect(self.handle_item_click)
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click) self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
self.main_layout.addWidget(self.file_list) self.main_layout.addWidget(self.file_list)
# Кнопки # Кнопки
@@ -197,6 +283,13 @@ class FileExplorer(QDialog):
self.select_button.clicked.connect(self.select_item) self.select_button.clicked.connect(self.select_item)
self.cancel_button.clicked.connect(self.reject) self.cancel_button.clicked.connect(self.reject)
def show_folder_context_menu(self, pos):
"""Shows the context menu for a folder using ContextMenuManager."""
if self.context_menu_manager:
self.context_menu_manager.show_folder_context_menu(self, pos)
else:
logger.warning("ContextMenuManager not found in parent")
def move_selection(self, direction): def move_selection(self, direction):
"""Перемещение выбора по списку""" """Перемещение выбора по списку"""
current_row = self.file_list.currentRow() current_row = self.file_list.currentRow()
@@ -286,44 +379,96 @@ class FileExplorer(QDialog):
except Exception as e: except Exception as e:
logger.error(f"Error navigating to parent directory: {e}") logger.error(f"Error navigating to parent directory: {e}")
def ensure_button_visible(self, button):
"""Ensure the specified button is visible in the drives_scroll area."""
try:
if not button or not self.drives_scroll:
return
# Ensure the button is visible in the scroll area
self.drives_scroll.ensureWidgetVisible(button, 50, 50)
logger.debug(f"Ensured button {button.text()} is visible in drives_scroll")
except Exception as e:
logger.error(f"Error ensuring button visible: {e}")
def update_drives_list(self): def update_drives_list(self):
"""Обновление списка смонтированных дисков""" """Обновление списка смонтированных дисков и избранных папок."""
for i in reversed(range(self.drives_layout.count())): for i in reversed(range(self.drives_layout.count())):
widget = self.drives_layout.itemAt(i).widget() item = self.drives_layout.itemAt(i)
if widget: if item and item.widget():
widget = item.widget()
self.drives_layout.removeWidget(widget)
widget.deleteLater() widget.deleteLater()
self.drive_buttons = []
drives = self.get_mounted_drives() drives = self.get_mounted_drives()
self.drive_buttons = [] # Store buttons for navigation favorite_folders = read_favorite_folders()
# Добавляем смонтированные диски
for drive in drives: for drive in drives:
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point")) button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE) button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Make button focusable button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.clicked.connect(lambda checked, path=drive: self.change_drive(path)) button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
self.drives_layout.addWidget(button) self.drives_layout.addWidget(button)
self.drive_buttons.append(button) self.drive_buttons.append(button)
self.drives_layout.addStretch()
# Set focus to first drive button if available # Добавляем избранные папки
if self.drive_buttons: for folder in favorite_folders:
self.drive_buttons[0].setFocus() folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
button = AutoSizeButton(folder_name, icon=self.theme_manager.get_icon("folder"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
button.clicked.connect(lambda checked, path=folder: self.change_drive(path))
self.drives_layout.addWidget(button)
self.drive_buttons.append(button)
# Добавляем растяжку, чтобы выровнять элементы
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.drives_layout.addWidget(spacer)
def select_drive(self): def select_drive(self):
"""Handle drive selection via gamepad""" """Обрабатывает выбор диска или избранной папки через геймпад."""
focused_widget = QApplication.focusWidget() focused_widget = QApplication.focusWidget()
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons: if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
drive_path = None drive_name = focused_widget.text().strip() # Удаляем пробелы
for drive in self.get_mounted_drives(): logger.debug(f"Выбрано имя: {drive_name}")
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
if drive_name == focused_widget.text(): # Специальная обработка корневого каталога
drive_path = drive if drive_name == "/":
break if os.path.isdir("/") and os.access("/", os.R_OK):
if drive_path and os.path.isdir(drive_path) and os.access(drive_path, os.R_OK): self.current_path = "/"
self.current_path = os.path.normpath(drive_path) self.update_file_list()
self.update_file_list() logger.info("Выбран корневой каталог: /")
else: return
logger.warning(f"Путь диска недоступен: {drive_path}") else:
logger.warning("Корневой каталог недоступен: недостаточно прав или ошибка пути")
return
# Проверяем избранные папки
favorite_folders = read_favorite_folders()
logger.debug(f"Избранные папки: {favorite_folders}")
for folder in favorite_folders:
folder_name = os.path.basename(os.path.normpath(folder)) or folder # Для корневых путей
if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
self.current_path = os.path.normpath(folder)
self.update_file_list()
logger.info(f"Выбрана избранная папка: {self.current_path}")
return
# Проверяем смонтированные диски
mounted_drives = self.get_mounted_drives()
logger.debug(f"Смонтированные диски: {mounted_drives}")
for drive in mounted_drives:
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # Для корневых путей
if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
self.current_path = os.path.normpath(drive)
self.update_file_list()
logger.info(f"Выбран смонтированный диск: {self.current_path}")
return
logger.warning(f"Путь недоступен: {drive_name}.")
def change_drive(self, drive_path): def change_drive(self, drive_path):
"""Переход к выбранному диску""" """Переход к выбранному диску"""

View File

@@ -12,29 +12,27 @@ from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.animations import GameCardAnimations from portprotonqt.animations import GameCardAnimations
import weakref
from typing import cast from typing import cast
class GameCard(QFrame): class GameCard(QFrame):
borderWidthChanged = Signal() borderWidthChanged = Signal()
gradientAngleChanged = Signal() gradientAngleChanged = Signal()
# Signals for context menu actions scaleChanged = Signal()
editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path editShortcutRequested = Signal(str, str, str)
deleteGameRequested = Signal(str, str) # name, exec_line deleteGameRequested = Signal(str, str)
addToMenuRequested = Signal(str, str) # name, exec_line addToMenuRequested = Signal(str, str)
removeFromMenuRequested = Signal(str) # name removeFromMenuRequested = Signal(str)
addToDesktopRequested = Signal(str, str) # name, exec_line addToDesktopRequested = Signal(str, str)
removeFromDesktopRequested = Signal(str) # name removeFromDesktopRequested = Signal(str)
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path addToSteamRequested = Signal(str, str, str)
removeFromSteamRequested = Signal(str, str) # name, exec_line removeFromSteamRequested = Signal(str, str)
openGameFolderRequested = Signal(str, str) # name, exec_line openGameFolderRequested = Signal(str, str)
hoverChanged = Signal(str, bool) hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool) focusChanged = Signal(str, bool)
def __init__(self, name, description, cover_path, appid, controller_support, exec_line, def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source, last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None): select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
super().__init__(parent) super().__init__(parent)
self.name = name self.name = name
self.description = description self.description = description
@@ -49,7 +47,9 @@ class GameCard(QFrame):
self.game_source = game_source self.game_source = game_source
self.last_launch_ts = last_launch_ts self.last_launch_ts = last_launch_ts
self.playtime_seconds = playtime_seconds self.playtime_seconds = playtime_seconds
self.card_width = card_width self.base_card_width = card_width
self.base_pixmap = None
self.base_font_size = None
self.select_callback = select_callback self.select_callback = select_callback
self.context_menu_manager = context_menu_manager self.context_menu_manager = context_menu_manager
@@ -67,75 +67,46 @@ class GameCard(QFrame):
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites")) self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
# Дополнительное пространство для анимации self.base_extra_margin = 20
extra_margin = 20
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE) self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
# Параметры анимации обводки
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"] self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"] self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
self._hovered = False self._hovered = False
self._focused = False self._focused = False
# Анимации
self.animations = GameCardAnimations(self, self.theme) self.animations = GameCardAnimations(self, self.theme)
self.animations.setup_animations() self.animations.setup_animations()
# Тень self.shadow = QGraphicsDropShadowEffect(self)
shadow = QGraphicsDropShadowEffect(self) self.shadow.setBlurRadius(20)
shadow.setBlurRadius(20) self.shadow.setColor(QColor(0, 0, 0, 150))
shadow.setColor(QColor(0, 0, 0, 150)) self.shadow.setOffset(0, 0)
shadow.setOffset(0, 0) self.setGraphicsEffect(self.shadow)
self.setGraphicsEffect(shadow)
# Отступы self.layout_ = QVBoxLayout(self)
layout = QVBoxLayout(self) self.layout_.setSpacing(5)
layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2) self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2)
layout.setSpacing(5)
# Контейнер обложки self.coverWidget = QWidget()
coverWidget = QWidget() coverLayout = QStackedLayout(self.coverWidget)
coverWidget.setFixedSize(card_width, int(card_width * 1.2))
coverLayout = QStackedLayout(coverWidget)
coverLayout.setContentsMargins(0, 0, 0, 0) coverLayout.setContentsMargins(0, 0, 0, 0)
coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll) coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
# Обложка
self.coverLabel = QLabel() self.coverLabel = QLabel()
self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE) self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
coverLayout.addWidget(self.coverLabel) coverLayout.addWidget(self.coverLabel)
# создаём слабую ссылку на label load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded)
label_ref = weakref.ref(self.coverLabel)
def on_cover_loaded(pixmap): self.favoriteLabel = ClickableLabel(self.coverWidget)
label = label_ref()
if label is None:
return
label.setPixmap(round_corners(pixmap, 15))
# асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async)
load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded)
# Значок избранного (звёздочка) в левом верхнем углу обложки
self.favoriteLabel = ClickableLabel(coverWidget)
self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize)
self.favoriteLabel.move(8, 8)
self.favoriteLabel.clicked.connect(self.toggle_favorite) self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in read_favorites() self.is_favorite = self.name in read_favorites()
self.update_favorite_icon() self.update_favorite_icon()
self.favoriteLabel.raise_() self.favoriteLabel.raise_()
# Определяем общие параметры для бейджей
badge_width = int(card_width * 2/3)
icon_size = int(card_width * 0.06) # 6% от ширины карточки
icon_space = int(card_width * 0.012) # 1.2% от ширины карточки
font_scale_factor = 0.06 # Шрифт будет 6% от card_width
# ProtonDB бейдж
tier_text = self.getProtonDBText(protondb_tier) tier_text = self.getProtonDBText(protondb_tier)
if tier_text: if tier_text:
icon_filename = self.getProtonDBIconFilename(protondb_tier) icon_filename = self.getProtonDBIconFilename(protondb_tier)
@@ -143,67 +114,50 @@ class GameCard(QFrame):
self.protondbLabel = ClickableLabel( self.protondbLabel = ClickableLabel(
tier_text, tier_text,
icon=icon, icon=icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
self.protondbLabel.setFixedWidth(badge_width)
self.protondbLabel.setCardWidth(card_width) self.protondbLabel.setCardWidth(card_width)
else: else:
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) self.protondbLabel = ClickableLabel("", parent=self.coverWidget)
self.protondbLabel.setFixedWidth(badge_width)
self.protondbLabel.setVisible(False) self.protondbLabel.setVisible(False)
# Steam бейдж
steam_icon = self.theme_manager.get_icon("steam") steam_icon = self.theme_manager.get_icon("steam")
self.steamLabel = ClickableLabel( self.steamLabel = ClickableLabel(
"Steam", "Steam",
icon=steam_icon, icon=steam_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.steamLabel.setFixedWidth(badge_width)
self.steamLabel.setCardWidth(card_width) self.steamLabel.setCardWidth(card_width)
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
# Epic Games Store бейдж
egs_icon = self.theme_manager.get_icon("epic_games") egs_icon = self.theme_manager.get_icon("epic_games")
self.egsLabel = ClickableLabel( self.egsLabel = ClickableLabel(
"Epic Games", "Epic Games",
icon=egs_icon, icon=egs_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06,
icon_space=icon_space,
font_scale_factor=font_scale_factor,
change_cursor=False change_cursor=False
) )
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.egsLabel.setFixedWidth(badge_width)
self.egsLabel.setCardWidth(card_width) self.egsLabel.setCardWidth(card_width)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
# PortProton бейдж portproton_icon = self.theme_manager.get_icon("portproton")
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
self.portprotonLabel = ClickableLabel( self.portprotonLabel = ClickableLabel(
"PortProton", "PortProton",
icon=portproton_icon, icon=portproton_icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.portprotonLabel.setFixedWidth(badge_width)
self.portprotonLabel.setCardWidth(card_width) self.portprotonLabel.setCardWidth(card_width)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic) self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
# WeAntiCheatYet бейдж
anticheat_text = self.getAntiCheatText(anticheat_status) anticheat_text = self.getAntiCheatText(anticheat_status)
if anticheat_text: if anticheat_text:
icon_filename = self.getAntiCheatIconFilename(anticheat_status) icon_filename = self.getAntiCheatIconFilename(anticheat_status)
@@ -211,40 +165,57 @@ class GameCard(QFrame):
self.anticheatLabel = ClickableLabel( self.anticheatLabel = ClickableLabel(
anticheat_text, anticheat_text,
icon=icon, icon=icon,
parent=coverWidget, parent=self.coverWidget,
icon_size=icon_size, font_scale_factor=0.06
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status)) self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
self.anticheatLabel.setFixedWidth(badge_width)
self.anticheatLabel.setCardWidth(card_width) self.anticheatLabel.setCardWidth(card_width)
else: else:
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) self.anticheatLabel = ClickableLabel("", parent=self.coverWidget)
self.anticheatLabel.setFixedWidth(badge_width)
self.anticheatLabel.setVisible(False) self.anticheatLabel.setVisible(False)
# Расположение бейджей
self._position_badges(card_width)
self.protondbLabel.clicked.connect(self.open_protondb_report) self.protondbLabel.clicked.connect(self.open_protondb_report)
self.steamLabel.clicked.connect(self.open_steam_page) self.steamLabel.clicked.connect(self.open_steam_page)
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page) self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
layout.addWidget(coverWidget) self.layout_.addWidget(self.coverWidget)
# Название игры self.nameLabel = QLabel(name)
nameLabel = QLabel(name) self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE) self.layout_.addWidget(self.nameLabel)
layout.addWidget(nameLabel)
def _position_badges(self, card_width): font_size = self.nameLabel.font().pointSizeF()
"""Позиционирует бейджи на основе ширины карточки.""" self.base_font_size = font_size if font_size > 0 else 10.0
right_margin = 8
badge_spacing = int(card_width * 0.02) # 2% от ширины карточки self.update_scale()
top_y = 10
# Force initial layout update to ensure correct geometry
self.updateGeometry()
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
parent.updateGeometry()
def on_cover_loaded(self, pixmap):
self.base_pixmap = pixmap
self.update_cover_pixmap()
def update_cover_pixmap(self):
if self.base_pixmap:
scaled_width = int(self.base_card_width * self._scale)
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
self.coverLabel.setPixmap(rounded_pixmap)
def _position_badges(self, current_width):
right_margin = int(8 * self._scale)
badge_spacing = int(current_width * 0.02)
top_y = int(10 * self._scale)
badge_y_positions = [] badge_y_positions = []
badge_width = int(card_width * 2/3) badge_width = int(current_width * 2/3)
badges = [ badges = [
(self.steam_visible, self.steamLabel), (self.steam_visible, self.steamLabel),
@@ -256,80 +227,99 @@ class GameCard(QFrame):
for is_visible, badge in badges: for is_visible, badge in badges:
if is_visible: if is_visible:
badge_x = card_width - badge_width - right_margin badge_x = current_width - badge_width - right_margin
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
badge.move(badge_x, badge_y) badge.move(int(badge_x), int(badge_y))
badge_y_positions.append(badge_y + badge.height()) badge_y_positions.append(badge_y + badge.height())
# Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
self.anticheatLabel.raise_() self.anticheatLabel.raise_()
self.protondbLabel.raise_() self.protondbLabel.raise_()
self.portprotonLabel.raise_() self.portprotonLabel.raise_()
self.egsLabel.raise_() self.egsLabel.raise_()
self.steamLabel.raise_() self.steamLabel.raise_()
def update_card_size(self, new_width: int): def update_scale(self):
"""Обновляет размер карточки, обложки и бейджей.""" scaled_width = int(self.base_card_width * self._scale)
self.card_width = new_width scaled_height = int(self.base_card_width * 1.8 * self._scale)
extra_margin = 20 scaled_extra = int(self.base_extra_margin * self._scale)
self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin) self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra)
self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
if self.coverLabel is None: self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
return self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
coverWidget = self.coverLabel.parentWidget() self.update_cover_pixmap()
if coverWidget is None:
return
coverWidget.setFixedSize(new_width, int(new_width * 1.2)) favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale))
self.coverLabel.setFixedSize(new_width, int(new_width * 1.2)) self.favoriteLabel.setFixedSize(*favorite_size)
self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale))
label_ref = weakref.ref(self.coverLabel) badge_width = int(scaled_width * 2/3)
def on_cover_loaded(pixmap): icon_size = int(scaled_width * 0.06)
label = label_ref() icon_space = int(scaled_width * 0.012)
if label:
scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, 15)
label.setPixmap(rounded_pixmap)
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded)
# Обновляем размеры и шрифты бейджей
badge_width = int(new_width * 2/3)
icon_size = int(new_width * 0.06)
icon_space = int(new_width * 0.012)
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]: for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
if label is not None: if label is not None:
label.setFixedWidth(badge_width) label.setFixedWidth(badge_width)
label.setIconSize(icon_size, icon_space) label.setIconSize(icon_size, icon_space)
label.setCardWidth(new_width) # Пересчитываем размер шрифта label.setCardWidth(scaled_width)
# Перепозиционируем бейджи self._position_badges(scaled_width)
self._position_badges(new_width)
if self.base_font_size is not None:
font = self.nameLabel.font()
new_font_size = self.base_font_size * self._scale
if new_font_size > 0:
font.setPointSizeF(new_font_size)
self.nameLabel.setFont(font)
self.shadow.setBlurRadius(int(20 * self._scale))
self.updateGeometry()
self.update() self.update()
# Ensure parent layout is updated safely
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
layout.activate()
layout.update()
parent.updateGeometry()
def update_card_size(self, new_width: int):
self.base_card_width = new_width
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.5), self.on_cover_loaded)
self.update_scale()
def update_badge_visibility(self, display_filter: str): def update_badge_visibility(self, display_filter: str):
"""Обновляет видимость бейджей на основе display_filter."""
self.display_filter = display_filter self.display_filter = display_filter
self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites")) self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites")) self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites")) self.portproton_visible = (str(self.game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
protondb_visible = bool(self.getProtonDBText(self.protondb_tier)) protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
# Обновляем видимость бейджей
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.protondbLabel.setVisible(protondb_visible) self.protondbLabel.setVisible(protondb_visible)
self.anticheatLabel.setVisible(anticheat_visible) self.anticheatLabel.setVisible(anticheat_visible)
# Перепозиционируем бейджи scaled_width = int(self.base_card_width * self._scale)
self._position_badges(self.card_width) self._position_badges(scaled_width)
# Update layout after visibility changes
self.updateGeometry()
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
layout.update()
parent.updateGeometry()
def _show_context_menu(self, pos): def _show_context_menu(self, pos):
"""Delegate context menu display to ContextMenuManager."""
if self.context_menu_manager: if self.context_menu_manager:
self.context_menu_manager.show_context_menu(self, pos) self.context_menu_manager.show_context_menu(self, pos)
@@ -387,7 +377,6 @@ class GameCard(QFrame):
return "" return ""
def open_portproton_forum_topic(self): def open_portproton_forum_topic(self):
"""Open the PortProton forum topic or search page for this game."""
result = self.portproton_api.get_forum_topic_slug(self.name) result = self.portproton_api.get_forum_topic_slug(self.name)
base_url = "https://linux-gaming.ru/" base_url = "https://linux-gaming.ru/"
if result.startswith("search?q="): if result.startswith("search?q="):
@@ -447,8 +436,18 @@ class GameCard(QFrame):
self.gradientAngleChanged.emit() self.gradientAngleChanged.emit()
self.update() self.update()
def getScale(self) -> float:
return self._scale
def setScale(self, value: float):
if self._scale != value:
self._scale = value
self.update_scale()
self.scaleChanged.emit()
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged)) borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged)) gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
def paintEvent(self, event): def paintEvent(self, event):
super().paintEvent(event) super().paintEvent(event)
@@ -487,6 +486,7 @@ class GameCard(QFrame):
) )
super().mousePressEvent(event) super().mousePressEvent(event)
def keyPressEvent(self, event): def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self.select_callback( self.select_callback(

View File

@@ -4,7 +4,7 @@ import os
from typing import Protocol, cast from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
from pyudev import Context, Monitor, MonitorObserver, Device from pyudev import Context, Monitor, MonitorObserver, Device
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
from PySide6.QtGui import QKeyEvent from PySide6.QtGui import QKeyEvent
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
@@ -42,17 +42,17 @@ class MainWindowProtocol(Protocol):
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c # https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c # https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
BUTTONS = { BUTTONS = {
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS) 'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS)
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS) 'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS)
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS) 'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS)
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS) 'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS)
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS) 'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS)
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS) 'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS)
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS) 'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS)
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS) 'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS)
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button 'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button
'increase_size': {ecodes.ABS_RZ}, # RT (Xbox) / R2 (PS) 'increase_size': {ecodes.BTN_TR2}, # RT (Xbox) / R2 (PS)
'decrease_size': {ecodes.ABS_Z}, # LT (Xbox) / L2 (PS) 'decrease_size': {ecodes.BTN_TL2}, # LT (Xbox) / L2 (PS)
} }
class InputManager(QObject): class InputManager(QObject):
@@ -161,7 +161,20 @@ class InputManager(QObject):
def handle_file_explorer_button(self, button_code): def handle_file_explorer_button(self, button_code):
try: try:
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']: # A button (BTN_SOUTH)
if popup.activeAction():
popup.activeAction().trigger()
popup.close()
return
elif button_code in BUTTONS['back']: # B button
popup.close()
return
return # Skip other handling if menu is open
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'): if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
logger.debug("No file explorer or file_list available")
return return
focused_widget = QApplication.focusWidget() focused_widget = QApplication.focusWidget()
@@ -169,27 +182,37 @@ class InputManager(QObject):
if isinstance(focused_widget, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused_widget in self.file_explorer.drive_buttons: if isinstance(focused_widget, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused_widget in self.file_explorer.drive_buttons:
self.file_explorer.select_drive() # Select the focused drive self.file_explorer.select_drive() # Select the focused drive
elif self.file_explorer.file_list.count() == 0: elif self.file_explorer.file_list.count() == 0:
logger.debug("File list is empty")
return return
else: else:
selected = self.file_explorer.file_list.currentItem().text() selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected) full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path): if os.path.isdir(full_path):
# Открываем директорию
self.file_explorer.current_path = os.path.normpath(full_path) self.file_explorer.current_path = os.path.normpath(full_path)
self.file_explorer.update_file_list() self.file_explorer.update_file_list()
elif not self.file_explorer.directory_only: elif not self.file_explorer.directory_only:
# Выбираем файл, если directory_only=False
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept() self.file_explorer.accept()
else: else:
logger.debug("Selected item is not a directory, cannot select: %s", full_path) logger.debug("Selected item is not a directory, cannot select: %s", full_path)
elif button_code in BUTTONS['context_menu']: # Start button (BTN_START)
if self.file_explorer.file_list.count() == 0:
logger.debug("File list is empty, cannot show context menu")
return
current_item = self.file_explorer.file_list.currentItem()
if current_item:
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
pos = item_rect.center() # Use local coordinates for itemAt check
self.file_explorer.show_folder_context_menu(pos)
else:
logger.debug("No item selected for context menu")
elif button_code in BUTTONS['add_game']: # X button elif button_code in BUTTONS['add_game']: # X button
if self.file_explorer.file_list.count() == 0: if self.file_explorer.file_list.count() == 0:
logger.debug("File list is empty")
return return
selected = self.file_explorer.file_list.currentItem().text() selected = self.file_explorer.file_list.currentItem().text()
full_path = os.path.join(self.file_explorer.current_path, selected) full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path): if os.path.isdir(full_path):
# Подтверждаем выбор директории
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path)) self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept() self.file_explorer.accept()
else: else:
@@ -202,12 +225,29 @@ class InputManager(QObject):
if self.original_button_handler: if self.original_button_handler:
self.original_button_handler(button_code) self.original_button_handler(button_code)
except Exception as e: except Exception as e:
logger.error(f"Error in FileExplorer button handler: {e}") logger.error("Error in FileExplorer button handler: %s", e)
def handle_file_explorer_dpad(self, code, value, current_time): def handle_file_explorer_dpad(self, code, value, current_time):
"""Обработка движения D-pad и левого стика для FileExplorer""" """Обработка движения D-pad и левого стика для FileExplorer"""
try: try:
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
if code == ecodes.ABS_HAT0Y and value != 0:
actions = popup.actions()
if not actions:
return
current_action = popup.activeAction()
current_idx = actions.index(current_action) if current_action in actions else -1
if value > 0: # Down
next_idx = (current_idx + 1) % len(actions) if current_idx != -1 else 0
popup.setActiveAction(actions[next_idx])
elif value < 0: # Up
next_idx = (current_idx - 1) % len(actions) if current_idx != -1 else len(actions) - 1
popup.setActiveAction(actions[next_idx])
return # Skip other handling if menu is open
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list: if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
logger.debug("No file explorer or file_list available")
return return
focused_widget = QApplication.focusWidget() focused_widget = QApplication.focusWidget()
@@ -216,14 +256,17 @@ class InputManager(QObject):
if not isinstance(focused_widget, AutoSizeButton) or focused_widget not in self.file_explorer.drive_buttons: if not isinstance(focused_widget, AutoSizeButton) or focused_widget not in self.file_explorer.drive_buttons:
# If not focused on a drive button, focus the first one # If not focused on a drive button, focus the first one
self.file_explorer.drive_buttons[0].setFocus() self.file_explorer.drive_buttons[0].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
return return
current_idx = self.file_explorer.drive_buttons.index(focused_widget) current_idx = self.file_explorer.drive_buttons.index(focused_widget)
if value < 0: # Left if value < 0: # Left
next_idx = max(current_idx - 1, 0) next_idx = max(current_idx - 1, 0)
self.file_explorer.drive_buttons[next_idx].setFocus() self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
elif value > 0: # Right elif value > 0: # Right
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1) next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
self.file_explorer.drive_buttons[next_idx].setFocus() self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y): elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons: if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
# Move focus to file list if navigating down from drive buttons # Move focus to file list if navigating down from drive buttons
@@ -264,7 +307,7 @@ class InputManager(QObject):
elif self.original_dpad_handler: elif self.original_dpad_handler:
self.original_dpad_handler(code, value, current_time) self.original_dpad_handler(code, value, current_time)
except Exception as e: except Exception as e:
logger.error(f"Error in FileExplorer dpad handler: {e}") logger.error("Error in FileExplorer dpad handler: %s", e)
def handle_navigation_repeat(self): def handle_navigation_repeat(self):
"""Плавное повторение движения с переменной скоростью для FileExplorer""" """Плавное повторение движения с переменной скоростью для FileExplorer"""
@@ -630,87 +673,107 @@ class InputManager(QObject):
scroll_area.ensureWidgetVisible(game_cards[0], 50, 50) scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
return return
# Group cards by rows based on y-coordinate cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively)
if not cards:
return
# Group cards by rows with tolerance for y-position
rows = {} rows = {}
for card in game_cards: y_tolerance = 10 # Allow slight variations in y-position
for card in cards:
y = card.pos().y() y = card.pos().y()
if y not in rows: matched = False
rows[y] = [] for row_y in rows:
rows[y].append(card) if abs(y - row_y) <= y_tolerance:
# Sort cards in each row by x-coordinate rows[row_y].append(card)
for y in rows: matched = True
rows[y].sort(key=lambda c: c.pos().x()) break
# Sort rows by y-coordinate if not matched:
rows[y] = [card]
sorted_rows = sorted(rows.items(), key=lambda x: x[0]) sorted_rows = sorted(rows.items(), key=lambda x: x[0])
if not sorted_rows:
return
current_row_idx = None
current_col_idx = None
for row_idx, (_y, row_cards) in enumerate(sorted_rows):
for idx, card in enumerate(row_cards):
if card == focused:
current_row_idx = row_idx
current_col_idx = idx
break
if current_row_idx is not None:
break
# Fallback: if focused card not found, select closest row by y-position
if current_row_idx is None:
if not sorted_rows: # Additional safety check
return
focused_y = focused.pos().y()
current_row_idx = min(range(len(sorted_rows)), key=lambda i: abs(sorted_rows[i][0] - focused_y))
if current_row_idx >= len(sorted_rows): # Safety check
return
current_row = sorted_rows[current_row_idx][1]
focused_x = focused.pos().x() + focused.width() / 2
current_col_idx = min(range(len(current_row)), key=lambda i: abs((current_row[i].pos().x() + current_row[i].width() / 2) - focused_x), default=0) # type: ignore
# Add null checks before using current_row_idx and current_col_idx
if current_row_idx is None or current_col_idx is None or current_row_idx >= len(sorted_rows):
return
# Find current row and column
current_y = focused.pos().y()
current_row_idx = next(i for i, (y, _) in enumerate(sorted_rows) if y == current_y)
current_row = sorted_rows[current_row_idx][1] current_row = sorted_rows[current_row_idx][1]
current_col_idx = current_row.index(focused) if code == ecodes.ABS_HAT0X and value != 0:
if code == ecodes.ABS_HAT0X and value != 0: # Left/Right
if value < 0: # Left if value < 0: # Left
next_col_idx = current_col_idx - 1 if current_col_idx > 0:
if next_col_idx >= 0: next_card = current_row[current_col_idx - 1]
next_card = current_row[next_col_idx] next_card.setFocus(Qt.FocusReason.OtherFocusReason)
next_card.setFocus()
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
else: else:
# Move to the last card of the previous row if available
if current_row_idx > 0: if current_row_idx > 0:
prev_row = sorted_rows[current_row_idx - 1][1] prev_row = sorted_rows[current_row_idx - 1][1]
next_card = prev_row[-1] if prev_row else None next_card = prev_row[-1] if prev_row else None
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value > 0: # Right elif value > 0: # Right
next_col_idx = current_col_idx + 1 if current_col_idx < len(current_row) - 1:
if next_col_idx < len(current_row): next_card = current_row[current_col_idx + 1]
next_card = current_row[next_col_idx] next_card.setFocus(Qt.FocusReason.OtherFocusReason)
next_card.setFocus()
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
else: else:
# Move to the first card of the next row if available
if current_row_idx < len(sorted_rows) - 1: if current_row_idx < len(sorted_rows) - 1:
next_row = sorted_rows[current_row_idx + 1][1] next_row = sorted_rows[current_row_idx + 1][1]
next_card = next_row[0] if next_row else None next_card = next_row[0] if next_row else None
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down elif code == ecodes.ABS_HAT0Y and value != 0:
if value > 0: # Down if value > 0: # Down
next_row_idx = current_row_idx + 1 if current_row_idx < len(sorted_rows) - 1:
if next_row_idx < len(sorted_rows): next_row = sorted_rows[current_row_idx + 1][1]
next_row = sorted_rows[next_row_idx][1] current_x = focused.pos().x() + focused.width() / 2
# Find card in same column or closest
target_x = focused.pos().x()
next_card = min( next_card = min(
next_row, next_row,
key=lambda c: abs(c.pos().x() - target_x), key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None default=None
) )
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value < 0: # Up elif value < 0: # Up
next_row_idx = current_row_idx - 1 if current_row_idx > 0:
if next_row_idx >= 0: prev_row = sorted_rows[current_row_idx - 1][1]
next_row = sorted_rows[next_row_idx][1] current_x = focused.pos().x() + focused.width() / 2
# Find card in same column or closest
target_x = focused.pos().x()
next_card = min( next_card = min(
next_row, prev_row,
key=lambda c: abs(c.pos().x() - target_x), key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None default=None
) )
if next_card: if next_card:
next_card.setFocus() next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area: if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50) scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif current_row_idx == 0: elif current_row_idx == 0:
@@ -742,6 +805,11 @@ class InputManager(QObject):
if not app: if not app:
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
# Ensure obj is a QObject
if not isinstance(obj, QObject):
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
return False
# Handle key press and release events # Handle key press and release events
if not isinstance(event, QKeyEvent): if not isinstance(event, QKeyEvent):
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
@@ -754,6 +822,62 @@ class InputManager(QObject):
# Handle key press events # Handle key press events
if event.type() == QEvent.Type.KeyPress: if event.type() == QEvent.Type.KeyPress:
# Handle FileExplorer specific logic
if self.file_explorer:
# Handle drive buttons in FileExplorer
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
if isinstance(focused, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused in self.file_explorer.drive_buttons:
self.file_explorer.select_drive()
return True
elif isinstance(focused, QListWidget) and focused == self.file_explorer.file_list:
current_item = focused.currentItem()
if current_item:
selected = current_item.text()
full_path = os.path.join(self.file_explorer.current_path, selected)
if os.path.isdir(full_path):
if selected == "../":
self.file_explorer.previous_dir()
else:
self.file_explorer.current_path = os.path.normpath(full_path)
self.file_explorer.update_file_list()
elif not self.file_explorer.directory_only:
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
self.file_explorer.accept()
return True
else:
self._parent.activateFocusedWidget()
return True
# Handle FileExplorer navigation with right arrow key
if key == Qt.Key.Key_Right:
try:
if hasattr(self.file_explorer, 'drive_buttons') and self.file_explorer.drive_buttons:
if not isinstance(focused, AutoSizeButton) or focused not in self.file_explorer.drive_buttons:
self.file_explorer.drive_buttons[0].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
else:
current_idx = self.file_explorer.drive_buttons.index(focused)
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
self.file_explorer.drive_buttons[next_idx].setFocus()
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
return True
except Exception as e:
logger.error(f"Error handling right arrow in FileExplorer: {e}")
return True
# Handle Backspace for FileExplorer navigation
if key == Qt.Key.Key_Backspace:
self.file_explorer.previous_dir()
return True
# Handle QLineEdit cursor movement with Left/Right arrows
if isinstance(focused, QLineEdit) and key in (Qt.Key.Key_Left, Qt.Key.Key_Right):
if key == Qt.Key.Key_Left:
focused.cursorBackward(False, 1) # Move cursor left by one character
elif key == Qt.Key.Key_Right:
focused.cursorForward(False, 1) # Move cursor right by one character
return True # Consume the event to prevent further processing
# Open system overlay with Insert # Open system overlay with Insert
if key == Qt.Key.Key_Insert: if key == Qt.Key.Key_Insert:
if not popup and not isinstance(active_win, QDialog): if not popup and not isinstance(active_win, QDialog):
@@ -765,11 +889,19 @@ class InputManager(QObject):
app.quit() app.quit()
return True return True
# Close AddGameDialog with Escape # Handle Backspace for FileExplorer navigation (move to parent directory)
if key == Qt.Key.Key_Escape and isinstance(popup, QDialog): if key == Qt.Key.Key_Backspace and self.file_explorer:
popup.reject() self.file_explorer.previous_dir()
return True return True
# Close Dialogs with Escape
if key == Qt.Key.Key_Escape:
if isinstance(focused, QLineEdit):
return False
if isinstance(active_win, QDialog):
active_win.reject()
return True
# FullscreenDialog navigation # FullscreenDialog navigation
if isinstance(active_win, FullscreenDialog): if isinstance(active_win, FullscreenDialog):
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace): if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace):
@@ -783,8 +915,8 @@ class InputManager(QObject):
active_win.show_next() active_win.show_next()
return True # Consume event to prevent tab switching return True # Consume event to prevent tab switching
# Handle tab switching with Left/Right arrow keys when not in GameCard focus # Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and (not isinstance(focused, GameCard) or focused is None): if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and not isinstance(focused, GameCard | QLineEdit) and not self.file_explorer:
idx = self._parent.stackedWidget.currentIndex() idx = self._parent.stackedWidget.currentIndex()
total = len(self._parent.tabButtons) total = len(self._parent.tabButtons)
if key == Qt.Key.Key_Left: if key == Qt.Key.Key_Left:

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-14 13:16+0500\n" "POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -26,18 +26,21 @@ msgstr ""
msgid "PortProton is not found" msgid "PortProton is not found"
msgstr "" msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
msgstr "" msgstr ""
msgid "Add to Favorites" msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -65,9 +68,6 @@ msgstr ""
msgid "Edit Shortcut" msgid "Edit Shortcut"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Stopped '{game_name}'" msgid "Stopped '{game_name}'"
msgstr "" msgstr ""
@@ -170,18 +170,6 @@ msgstr ""
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Invalid executable command: {exec_line}"
msgstr ""
#, python-brace-format
msgid "Executable not found: {path}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable: {error}"
msgstr ""
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "" msgstr ""
@@ -260,12 +248,19 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Select" #, python-brace-format
msgid "Launching {0}"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
@@ -660,3 +655,24 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-14 13:16+0500\n" "POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -26,18 +26,21 @@ msgstr ""
msgid "PortProton is not found" msgid "PortProton is not found"
msgstr "" msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
msgstr "" msgstr ""
msgid "Add to Favorites" msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -65,9 +68,6 @@ msgstr ""
msgid "Edit Shortcut" msgid "Edit Shortcut"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Stopped '{game_name}'" msgid "Stopped '{game_name}'"
msgstr "" msgstr ""
@@ -170,18 +170,6 @@ msgstr ""
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Invalid executable command: {exec_line}"
msgstr ""
#, python-brace-format
msgid "Executable not found: {path}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable: {error}"
msgstr ""
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "" msgstr ""
@@ -260,12 +248,19 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Select" #, python-brace-format
msgid "Launching {0}"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
@@ -660,3 +655,24 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-14 13:16+0500\n" "POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -24,18 +24,21 @@ msgstr ""
msgid "PortProton is not found" msgid "PortProton is not found"
msgstr "" msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Remove from Favorites" msgid "Remove from Favorites"
msgstr "" msgstr ""
msgid "Add to Favorites" msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Stop Game"
msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -63,9 +66,6 @@ msgstr ""
msgid "Edit Shortcut" msgid "Edit Shortcut"
msgstr "" msgstr ""
msgid "Delete from PortProton"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Stopped '{game_name}'" msgid "Stopped '{game_name}'"
msgstr "" msgstr ""
@@ -168,18 +168,6 @@ msgstr ""
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Invalid executable command: {exec_line}"
msgstr ""
#, python-brace-format
msgid "Executable not found: {path}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable: {error}"
msgstr ""
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "" msgstr ""
@@ -258,12 +246,19 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Select" #, python-brace-format
msgid "Launching {0}"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Path: " msgid "Path: "
msgstr "" msgstr ""
@@ -658,3 +653,24 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-14 13:16+0500\n" "POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: 2025-07-14 13:16+0500\n" "PO-Revision-Date: 2025-08-31 12:28+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n" "Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
@@ -27,18 +27,21 @@ msgstr "Ошибка"
msgid "PortProton is not found" msgid "PortProton is not found"
msgstr "PortProton не найден" msgstr "PortProton не найден"
msgid "Stop Game"
msgstr "Остановить игру"
msgid "Launch Game"
msgstr "Запустить игру"
msgid "Remove from Favorites" msgid "Remove from Favorites"
msgstr "Удалить из Избранного" msgstr "Удалить из Избранного"
msgid "Add to Favorites" msgid "Add to Favorites"
msgstr "Добавить в Избранное" msgstr "Добавить в Избранное"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
msgid "Stop Game"
msgstr "Остановить игру"
msgid "Launch Game"
msgstr "Запустить игру"
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "Импортировать игру" msgstr "Импортировать игру"
@@ -66,9 +69,6 @@ msgstr "Добавить в меню"
msgid "Edit Shortcut" msgid "Edit Shortcut"
msgstr "Редактировать" msgstr "Редактировать"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
#, python-brace-format #, python-brace-format
msgid "Stopped '{game_name}'" msgid "Stopped '{game_name}'"
msgstr "Остановлен(а) '{game_name}'" msgstr "Остановлен(а) '{game_name}'"
@@ -173,18 +173,6 @@ msgstr "Не удалось прочитать файл .desktop: {error}"
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
msgstr "Файл .desktop для '{game_name}' не найден" msgstr "Файл .desktop для '{game_name}' не найден"
#, python-brace-format
msgid "Invalid executable command: {exec_line}"
msgstr "Недопустимая исполняемая команда: {exec_line}"
#, python-brace-format
msgid "Executable not found: {path}"
msgstr "Исполняемый файл не найден: {path}"
#, python-brace-format
msgid "Failed to parse executable: {error}"
msgstr "Не удалось разобрать исполняемый файл: {error}"
msgid "Confirm Deletion" msgid "Confirm Deletion"
msgstr "Подтвердите удаление" msgstr "Подтвердите удаление"
@@ -267,12 +255,19 @@ msgstr "Удалить"
msgid "Select All" msgid "Select All"
msgstr "Выбрать всё" msgstr "Выбрать всё"
msgid "Select" #, python-brace-format
msgstr "Выбрать" msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "Cancel" msgid "Cancel"
msgstr "Отмена" msgstr "Отмена"
msgid "File Explorer"
msgstr "Проводник"
msgid "Select"
msgstr "Выбрать"
msgid "Path: " msgid "Path: "
msgstr "Путь: " msgstr "Путь: "
@@ -669,3 +664,24 @@ msgstr "мин."
msgid "sec." msgid "sec."
msgstr "сек." msgstr "сек."
msgid "Show"
msgstr "Показать"
msgid "Favorites"
msgstr "Избранное"
msgid "Recent Games"
msgstr "Недавние"
msgid "Exit"
msgstr "Выход"
msgid "Hide"
msgstr "Скрыть"
msgid "No favorites"
msgstr "Нет избранных"
msgid "No recent games"
msgstr "Нет недавних игр"

View File

@@ -34,6 +34,7 @@ from portprotonqt.localization import _, get_egs_language, read_metadata_transla
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy) QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
@@ -48,15 +49,15 @@ logger = get_logger(__name__)
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
"""Main window of PortProtonQt.""" """Main window of PortProtonQt."""
settings_saved = Signal()
games_loaded = Signal(list) games_loaded = Signal(list)
update_progress = Signal(int) # Signal to update progress bar update_progress = Signal(int) # Signal to update progress bar
update_status_message = Signal(str, int) # Signal to update status message update_status_message = Signal(str, int) # Signal to update status message
def __init__(self): def __init__(self, app_name: str):
super().__init__() super().__init__()
# Создаём менеджер тем и читаем, какая тема выбрана # Создаём менеджер тем и читаем, какая тема выбрана
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.is_exiting = False
selected_theme = read_theme_from_config() selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme self.current_theme_name = selected_theme
try: try:
@@ -68,8 +69,9 @@ class MainWindow(QMainWindow):
save_theme_to_config("standart") save_theme_to_config("standart")
if not self.theme: if not self.theme:
self.theme = default_styles self.theme = default_styles
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size() self.card_width = read_card_size()
self.setWindowTitle("PortProtonQt") self.setWindowTitle(app_name)
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
self.games = [] self.games = []
@@ -1331,7 +1333,6 @@ class MainWindow(QMainWindow):
self.settingsDebounceTimer.start() self.settingsDebounceTimer.start()
self.settings_saved.emit()
# Управление полноэкранным режимом # Управление полноэкранным режимом
gamepad_connected = self.input_manager.find_gamepad() is not None gamepad_connected = self.input_manager.find_gamepad() is not None
@@ -1527,7 +1528,7 @@ class MainWindow(QMainWindow):
detailPage = QWidget() detailPage = QWidget()
self._animations = {} self._animations = {}
imageLabel = QLabel() imageLabel = QLabel()
imageLabel.setFixedSize(300, 400) imageLabel.setFixedSize(300, 450)
self._detail_page_active = True self._detail_page_active = True
self._current_detail_page = detailPage self._current_detail_page = detailPage
@@ -1561,7 +1562,7 @@ class MainWindow(QMainWindow):
logger.debug("Stylesheet updated with palette") logger.debug("Stylesheet updated with palette")
self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready) self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
load_pixmap_async(cover_path, 300, 400, on_pixmap_ready) load_pixmap_async(cover_path, 300, 450, on_pixmap_ready)
else: else:
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE) detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
detailPage.update() detailPage.update()
@@ -1589,7 +1590,7 @@ class MainWindow(QMainWindow):
# Обложка (слева) # Обложка (слева)
coverFrame = QFrame() coverFrame = QFrame()
coverFrame.setFixedSize(300, 400) coverFrame.setFixedSize(300, 450)
coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE) coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE)
shadow = QGraphicsDropShadowEffect(coverFrame) shadow = QGraphicsDropShadowEffect(coverFrame)
shadow.setBlurRadius(20) shadow.setBlurRadius(20)
@@ -1676,7 +1677,7 @@ class MainWindow(QMainWindow):
egsLabel.setVisible(egs_visible) egsLabel.setVisible(egs_visible)
# PortProton badge # PortProton badge
portproton_icon = self.theme_manager.get_icon("ppqt-tray") portproton_icon = self.theme_manager.get_icon("portproton")
portprotonLabel = ClickableLabel( portprotonLabel = ClickableLabel(
"PortProton", "PortProton",
icon=portproton_icon, icon=portproton_icon,
@@ -2268,46 +2269,51 @@ class MainWindow(QMainWindow):
def closeEvent(self, event): def closeEvent(self, event):
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна.""" """Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
for proc in self.game_processes: if hasattr(self, 'is_exiting') and self.is_exiting:
try: # Принудительное закрытие: завершаем процессы и приложение
parent = psutil.Process(proc.pid) for proc in self.game_processes:
children = parent.children(recursive=True) try:
for child in children: parent = psutil.Process(proc.pid)
try: children = parent.children(recursive=True)
logger.debug(f"Terminating child process {child.pid}") for child in children:
child.terminate() try:
except psutil.NoSuchProcess: logger.debug(f"Terminating child process {child.pid}")
logger.debug(f"Child process {child.pid} already terminated") child.terminate()
psutil.wait_procs(children, timeout=5) except psutil.NoSuchProcess:
for child in children: logger.debug(f"Child process {child.pid} already terminated")
if child.is_running(): psutil.wait_procs(children, timeout=5)
logger.debug(f"Killing child process {child.pid}") for child in children:
child.kill() if child.is_running():
logger.debug(f"Terminating process group {proc.pid}") logger.debug(f"Killing child process {child.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM) child.kill()
except (psutil.NoSuchProcess, ProcessLookupError) as e: logger.debug(f"Terminating process group {proc.pid}")
logger.debug(f"Process {proc.pid} already terminated: {e}") os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (psutil.NoSuchProcess, ProcessLookupError) as e:
logger.debug(f"Process {proc.pid} already terminated: {e}")
self.game_processes = [] # Очищаем список процессов self.game_processes = [] # Очищаем список процессов
# Сохраняем настройки окна # Очищаем таймеры
if not read_fullscreen_config(): if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}") self.games_load_timer.stop()
save_window_geometry(self.width(), self.height()) if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
save_card_size(self.card_width) self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
# Очищаем таймеры и другие ресурсы # Сохраняем настройки окна
if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive(): if not read_fullscreen_config():
self.games_load_timer.stop() logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive(): save_window_geometry(self.width(), self.height())
self.settingsDebounceTimer.stop() save_card_size(self.card_width)
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
QApplication.quit() event.accept()
event.accept() else:
# Сворачиваем в трей вместо закрытия
self.hide()
event.ignore()

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -29,69 +29,104 @@ color_h = "transparent"
GAME_CARD_ANIMATION = { GAME_CARD_ANIMATION = {
# Тип анимации при входе и выходе на детальную страницу # Тип анимации при входе и выходе на детальную страницу
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce" # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Определяет, как детальная страница появляется и исчезает
"detail_page_animation_type": "fade", "detail_page_animation_type": "fade",
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса). # Ширина обводки карточки в состоянии покоя (без наведения или фокуса)
# Влияет на толщину рамки вокруг карточки, когда она не выделена. # Влияет на толщину рамки вокруг карточки, когда она не выделена
# Значение в пикселях. # Значение в пикселях
"default_border_width": 2, "default_border_width": 2,
# Ширина обводки при наведении курсора. # Ширина обводки при наведении курсора
# Увеличивает толщину рамки, когда курсор находится над карточкой. # Увеличивает толщину рамки, когда курсор находится над карточкой
# Значение в пикселях. # Значение в пикселях
"hover_border_width": 8, "hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры). # Ширина обводки при фокусе (например, при выборе с клавиатуры)
# Увеличивает толщину рамки, когда карточка в фокусе. # Увеличивает толщину рамки, когда карточка в фокусе
# Значение в пикселях. # Значение в пикселях
"focus_border_width": 12, "focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации. # Минимальная ширина обводки во время пульсирующей анимации
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания"). # Определяет минимальную толщину рамки при пульсации (анимация "дыхания")
# Значение в пикселях. # Значение в пикселях
"pulse_min_border_width": 8, "pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации. # Максимальная ширина обводки во время пульсирующей анимации
# Определяет максимальную толщину рамки при пульсации. # Определяет максимальную толщину рамки при пульсации
# Значение в пикселях. # Значение в пикселях
"pulse_max_border_width": 10, "pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе). # Длительность анимации изменения толщины обводки (например, при наведении или фокусе)
# Влияет на скорость перехода от одной ширины обводки к другой. # Влияет на скорость перехода от одной ширины обводки к другой
# Значение в миллисекундах. # Значение в миллисекундах
"thickness_anim_duration": 300, "thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации. # Длительность одного цикла пульсирующей анимации
# Определяет, как быстро рамка "пульсирует" между min и max значениями. # Определяет, как быстро рамка "пульсирует" между min и max значениями
# Значение в миллисекундах. # Значение в миллисекундах
"pulse_anim_duration": 800, "pulse_anim_duration": 800,
# Длительность анимации вращения градиента. # Длительность анимации вращения градиента
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки. # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
# Значение в миллисекундах. # Значение в миллисекундах
"gradient_anim_duration": 3000, "gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах). # Начальный угол градиента (в градусах)
# Определяет начальную точку вращения градиента при старте анимации. # Определяет начальную точку вращения градиента при старте анимации
"gradient_start_angle": 360, "gradient_start_angle": 360,
# Конечный угол градиента (в градусах). # Конечный угол градиента (в градусах)
# Определяет конечную точку вращения градиента. # Определяет конечную точку вращения градиента
# Значение 0 означает полный поворот на 360 градусов. # Значение 0 означает полный поворот на 360 градусов
"gradient_end_angle": 0, "gradient_end_angle": 0,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе). # Тип анимации для карточки при наведении или фокусе
# Влияет на "чувство" анимации (например, плавное ускорение или замедление). # Возможные значения: "gradient", "scale"
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad"). # "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки
"card_animation_type": "gradient",
# Масштаб карточки в состоянии покоя
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
# Значение в долях (например, 1.0 для нормального размера)
"default_scale": 1.0,
# Масштаб карточки при наведении курсора
# Увеличивает размер карточки при наведении
# Значение в долях (например, 1.1 = 110% от исходного размера)
"hover_scale": 1.1,
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
# Увеличивает размер карточки при фокусе
# Значение в долях (например, 1.05 = 105% от исходного размера)
"focus_scale": 1.05,
# Длительность анимации масштабирования
# Влияет на скорость изменения размера карточки при наведении или фокусе
# Значение в миллисекундах
"scale_anim_duration": 200,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack", "thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса). # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходной ширине обводки. # Влияет на "чувство" возврата к исходной ширине обводки
"thickness_easing_curve_out": "InBack", "thickness_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки. # Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex. # Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
# Влияет на внешний вид обводки при наведении или фокусе. # Возможные значения: строки, соответствующие QEasingCurve.Type
"scale_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходному масштабу
"scale_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
"gradient_colors": [ "gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан) {"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый) {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
@@ -100,29 +135,43 @@ GAME_CARD_ANIMATION = {
], ],
# Длительность анимации fade при входе на детальную страницу # Длительность анимации fade при входе на детальную страницу
# Влияет на скорость появления страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration": 350, "detail_page_fade_duration": 350,
# Длительность анимации slide при входе на детальную страницу # Длительность анимации slide при входе на детальную страницу
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration": 500, "detail_page_slide_duration": 500,
# Длительность анимации bounce при входе на детальную страницу # Длительность анимации bounce при входе на детальную страницу
# Влияет на скорость "прыжка" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration": 400, "detail_page_bounce_duration": 400,
# Длительность анимации fade при выходе из детальной страницы # Длительность анимации fade при выходе из детальной страницы
# Влияет на скорость исчезновения страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration_exit": 350, "detail_page_fade_duration_exit": 350,
# Длительность анимации slide при выходе из детальной страницы # Длительность анимации slide при выходе из детальной страницы
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration_exit": 500, "detail_page_slide_duration_exit": 500,
# Длительность анимации bounce при выходе из детальной страницы # Длительность анимации bounce при выходе из детальной страницы
# Влияет на скорость "сжатия" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration_exit": 400, "detail_page_bounce_duration_exit": 400,
# Тип кривой сглаживания для анимации при входе на детальную страницу # Тип кривой сглаживания для анимации при входе на детальную страницу
# Применяется к slide и bounce анимациям # Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve": "OutCubic", "detail_page_easing_curve": "OutCubic",
# Тип кривой сглаживания для анимации при выходе из детальной страницы # Тип кривой сглаживания для анимации при выходе из детальной страницы
# Применяется к slide и bounce анимациям # Применяется к slide и bounce анимациям, влияет на "чувство" движения
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic" "detail_page_easing_curve_exit": "InCubic"
} }

View File

@@ -1,49 +0,0 @@
from PySide6.QtGui import QAction, QIcon
from PySide6.QtWidgets import QSystemTrayIcon, QMenu
from portprotonqt.theme_manager import ThemeManager
from typing import cast
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.config_utils import read_theme_from_config
class SystemTray:
def __init__(self, app, theme=None):
self.app = app
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else default_styles
self.current_theme_name = read_theme_from_config()
self.tray = QSystemTrayIcon()
self.tray.setIcon(cast(QIcon, self.theme_manager.get_icon("ppqt-tray", self.current_theme_name)))
self.tray.setToolTip("PortProtonQt")
self.tray.setVisible(True)
# Создаём меню
self.menu = QMenu()
self.hide_action = QAction("Скрыть окно")
self.menu.addAction(self.hide_action)
self.show_action = QAction("Показать окно")
self.menu.addAction(self.show_action)
self.quit_action = QAction("Выход")
self.quit_action.triggered.connect(app.quit)
self.menu.addAction(self.quit_action)
self.tray.setContextMenu(self.menu)
def hide_tray(self):
"""Скрыть иконку трея"""
if self.tray:
self.tray.setVisible(False)
if self.menu:
self.menu.deleteLater()
self.menu = None
def cleanup(self):
"""Очистка ресурсов трея"""
if self.tray:
self.tray.setVisible(False)
self.tray = None
if self.menu:
self.menu.deleteLater()
self.menu = None

View File

@@ -0,0 +1,260 @@
import sys
import subprocess
import shlex
import signal
import psutil
import os
from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QMessageBox
from PySide6.QtGui import QIcon, QAction
from PySide6.QtCore import QTimer
from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config
from portprotonqt.dialogs import GameLaunchDialog
logger = get_logger(__name__)
class TrayManager:
"""Модуль управления системным треем для PortProtonQt.
Обеспечивает:
- Показ/скрытие главного окна по двойному клику на иконку трея.
- Контекстное меню с опциями: Show/Hide, Favorites, Recent Games, Themes, Exit.
- Динамическое заполнение меню Favorites, Recent Games и Themes.
- Сворачивание в трей при закрытии окна, полное закрытие через Exit.
"""
def __init__(self, main_window, app_name: str | None = None, theme=None):
self.app_name = app_name if app_name is not None else "PortProtonQt"
self.theme_manager = ThemeManager()
selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme
try:
self.theme = self.theme_manager.apply_theme(selected_theme)
except FileNotFoundError:
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
self.theme = self.theme_manager.apply_theme("standart")
self.current_theme_name = "standart"
save_theme_to_config("standart")
if not self.theme:
self.theme = default_styles
self.main_window = main_window
self.tray_icon = QSystemTrayIcon(self.main_window)
icon = self.theme_manager.get_icon("portproton", self.current_theme_name)
if isinstance(icon, str):
icon = QIcon(icon)
elif icon is None:
icon = QIcon()
self.tray_icon.setIcon(icon)
self.tray_icon.activated.connect(self.handle_tray_click)
self.tray_icon.setToolTip(self.app_name)
self.tray_menu = QMenu()
self.toggle_action = QAction(_("Show"), self.main_window)
self.toggle_action.triggered.connect(self.toggle_window_action)
self.favorites_menu = QMenu(_("Favorites"))
self.favorites_menu.aboutToShow.connect(self.populate_favorites_menu)
self.recent_menu = QMenu(_("Recent Games"))
self.recent_menu.aboutToShow.connect(self.populate_recent_menu)
self.themes_menu = QMenu(_("Themes"))
self.themes_menu.aboutToShow.connect(self.populate_themes_menu)
self.tray_menu.addAction(self.toggle_action)
self.tray_menu.addSeparator()
self.tray_menu.addMenu(self.favorites_menu)
self.tray_menu.addMenu(self.recent_menu)
self.tray_menu.addMenu(self.themes_menu)
self.tray_menu.addSeparator()
exit_action = QAction(_("Exit"), self.main_window)
exit_action.triggered.connect(self.force_exit)
self.tray_menu.addAction(exit_action)
self.tray_menu.aboutToShow.connect(self.update_toggle_action)
self.tray_icon.setContextMenu(self.tray_menu)
self.tray_icon.show()
self.main_window.is_exiting = False
self.click_count = 0
self.click_timer = QTimer()
self.click_timer.setSingleShot(True)
self.click_timer.timeout.connect(self.reset_click_count)
self.launch_dialog = None
def update_toggle_action(self):
if self.main_window.isVisible():
self.toggle_action.setText(_("Hide"))
else:
self.toggle_action.setText(_("Show"))
def handle_tray_click(self, reason):
if reason == QSystemTrayIcon.ActivationReason.Trigger:
self.click_count += 1
if self.click_count == 1:
self.click_timer.start(300)
elif self.click_count == 2:
self.click_timer.stop()
self.toggle_window_action()
self.click_count = 0
def reset_click_count(self):
self.click_count = 0
def toggle_window_action(self):
if self.main_window.isVisible():
self.main_window.hide()
else:
self.main_window.show()
self.main_window.raise_()
self.main_window.activateWindow()
def populate_favorites_menu(self):
self.favorites_menu.clear()
favorites = read_favorites()
if not favorites:
no_fav_action = QAction(_("No favorites"), self.main_window)
no_fav_action.setEnabled(False)
self.favorites_menu.addAction(no_fav_action)
return
game_map = {game[0]: (game[4], game[12]) for game in self.main_window.games}
for fav in sorted(favorites):
game_data = game_map.get(fav)
if game_data:
exec_line, source = game_data
action_text = f"{fav} ({source})"
action = QAction(action_text, self.main_window)
action.triggered.connect(lambda checked=False, el=exec_line, name=fav: self.launch_game_with_dialog(el, name))
self.favorites_menu.addAction(action)
else:
logger.warning(f"Exec line not found for favorite: {fav}")
def populate_recent_menu(self):
self.recent_menu.clear()
if not self.main_window.games:
no_recent_action = QAction(_("No recent games"), self.main_window)
no_recent_action.setEnabled(False)
self.recent_menu.addAction(no_recent_action)
return
recent_games = sorted(self.main_window.games, key=lambda g: g[10], reverse=True)[:5]
for game in recent_games:
game_name = game[0]
exec_line = game[4]
source = game[12]
action_text = f"{game_name} ({source})"
action = QAction(action_text, self.main_window)
action.triggered.connect(lambda checked=False, el=exec_line, name=game_name: self.launch_game_with_dialog(el, name))
self.recent_menu.addAction(action)
def launch_game_with_dialog(self, exec_line, game_name):
"""Launch a game with a modal dialog indicating progress."""
try:
# Determine target executable
target_exe = None
if exec_line.startswith("steam://"):
# Steam games are handled differently, no target_exe needed
self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme)
else:
# Extract target executable from exec_line
entry_exec_split = shlex.split(exec_line)
if entry_exec_split[0] == "env" and len(entry_exec_split) > 2:
file_to_check = entry_exec_split[2]
elif entry_exec_split[0] == "flatpak" and len(entry_exec_split) > 3:
file_to_check = entry_exec_split[3]
else:
file_to_check = entry_exec_split[0]
if not os.path.exists(file_to_check):
logger.error(f"File not found: {file_to_check}")
QMessageBox.warning(self.main_window, _("Error"), _("File not found: {0}").format(file_to_check))
return
target_exe = os.path.basename(file_to_check)
self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme, target_exe=target_exe)
self.launch_dialog.rejected.connect(lambda: self.cancel_game_launch(exec_line))
self.launch_dialog.show()
self.main_window.toggleGame(exec_line)
except Exception as e:
logger.error(f"Failed to launch game {game_name}: {e}")
if self.launch_dialog:
self.launch_dialog.reject()
self.launch_dialog = None
QMessageBox.warning(self.main_window, _("Error"), _("Failed to launch game: {0}").format(str(e)))
def cancel_game_launch(self, exec_line):
"""Cancel the game launch and terminate the process, using MainWindow's stop logic."""
if self.main_window.game_processes and self.main_window.target_exe:
for proc in self.main_window.game_processes:
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
for child in children:
try:
child.terminate()
except psutil.NoSuchProcess:
pass
psutil.wait_procs(children, timeout=5)
for child in children:
if child.is_running():
child.kill()
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except psutil.NoSuchProcess:
pass
self.main_window.game_processes = []
self.main_window.resetPlayButton()
if self.launch_dialog:
self.launch_dialog.reject()
self.launch_dialog = None
logger.info(f"Game launch cancelled for exec line: {exec_line}")
def populate_themes_menu(self):
self.themes_menu.clear()
available_themes = self.theme_manager.get_available_themes()
for theme_name in sorted(available_themes):
action = QAction(theme_name, self.main_window)
action.setCheckable(True)
action.setChecked(theme_name == self.current_theme_name)
action.triggered.connect(lambda checked=False, tn=theme_name: self.switch_theme(tn))
self.themes_menu.addAction(action)
def switch_theme(self, theme_name: str):
try:
save_theme_to_config(theme_name)
logger.info(f"Saved theme {theme_name}, restarting application to apply changes")
executable = sys.executable
args = sys.argv
self.main_window.is_exiting = True
QApplication.quit()
subprocess.Popen([executable] + args)
except Exception as e:
logger.error(f"Failed to switch theme to {theme_name}: {e}")
save_theme_to_config("standart")
executable = sys.executable
args = sys.argv
self.main_window.is_exiting = True
QApplication.quit()
subprocess.Popen([executable] + args)
def force_exit(self):
self.main_window.is_exiting = True
self.main_window.close()
sys.exit(0)

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "portprotonqt" name = "portprotonqt"
version = "0.1.4" version = "0.1.5"
description = "A project to rewrite PortProton (PortWINE) using PySide" description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md" readme = "README.md"
license = { text = "GPL-3.0" } license = { text = "GPL-3.0" }
@@ -27,7 +27,7 @@ classifiers = [
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [ dependencies = [
"babel>=2.17.0", "babel>=2.17.0",
"beautifulsoup4>=4.13.4", "beautifulsoup4>=4.13.5",
"evdev>=1.9.2", "evdev>=1.9.2",
"icoextract>=0.2.0", "icoextract>=0.2.0",
"numpy>=2.2.4", "numpy>=2.2.4",
@@ -36,7 +36,7 @@ dependencies = [
"psutil>=7.0.0", "psutil>=7.0.0",
"pyside6>=6.9.1", "pyside6>=6.9.1",
"pyudev>=0.24.3", "pyudev>=0.24.3",
"requests>=2.32.4", "requests>=2.32.5",
"tqdm>=4.67.1", "tqdm>=4.67.1",
"vdf>=3.4", "vdf>=3.4",
"websocket-client>=1.8.0", "websocket-client>=1.8.0",
@@ -105,5 +105,5 @@ ignore = [
dev = [ dev = [
"pre-commit>=4.3.0", "pre-commit>=4.3.0",
"pyaspeller>=2.0.2", "pyaspeller>=2.0.2",
"pyright>=1.1.403", "pyright>=1.1.404",
] ]

View File

@@ -19,7 +19,7 @@
"enabled": false "enabled": false
}, },
{ {
"matchManagers": ["github-actions", "pre-commit"], "matchManagers": ["github-actions", "pre-commit", "poetry"],
"enabled": false "enabled": false
}, },
{ {

878
uv.lock generated

File diff suppressed because it is too large Load Diff