Compare commits
51 Commits
8849e90697
...
v0.1.5
Author | SHA1 | Date | |
---|---|---|---|
8e11dac987
|
|||
358afbdbdb
|
|||
83730499e2
|
|||
84f560ed30
|
|||
888c9ac387
|
|||
68d06ca05c
|
|||
6923a5f05c
|
|||
f3f85441d8
|
|||
eb90836710
|
|||
dd125c975b
|
|||
4521d3ca1c
|
|||
dd044dbd95
|
|||
0047b29cd2
|
|||
d0fbc79168
|
|||
57f6ac9c4b
|
|||
60271f7a13
|
|||
38ab4acc86
|
|||
8f54f4814c
|
|||
37254b89f1
|
|||
893e33bdce
|
|||
1ee784d890
|
|||
39f505079c
|
|||
46253115ff
|
|||
31a7ef3e7e
|
|||
|
cb07904c1b | ||
05e0d9d846
|
|||
81433d3c56
|
|||
0ff66e282b
|
|||
831b7739ba
|
|||
50e1dfda57
|
|||
fcf04e521d
|
|||
74d0700d7c
|
|||
0435c77630
|
|||
1cf93a60c8
|
|||
31247d21c3
|
|||
c6017a7dce
|
|||
c74d209dbd
|
|||
5b257d3b62
|
|||
4dcf1dbe6d
|
|||
8d6fe4aa65
|
|||
022eb3f1e9
|
|||
11b847ed05
|
|||
1e4e0127a4
|
|||
c045aa7a56
|
|||
f18e7bae6b
|
|||
dcf8904037
|
|||
f9d24e385d
|
|||
09028931be
|
|||
0294c90c54
|
|||
17dfef2d27
|
|||
|
f0690f8811
|
@@ -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 }}
|
||||||
|
@@ -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
|
||||||
|
@@ -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: |
|
||||||
|
246
CHANGELOG.md
246
CHANGELOG.md
@@ -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
|
||||||
|
@@ -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:
|
||||||
|
@@ -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')
|
||||||
|
@@ -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
|
||||||
|
|
||||||
|
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -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.0–1.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.0–1.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"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@@ -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.0–1.0) и цвет в формате hex.
|
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
|
||||||
# Влияет на внешний вид обводки при наведении или фокусе.
|
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||||
|
"scale_easing_curve": "OutBack",
|
||||||
|
|
||||||
|
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
|
||||||
|
# Влияет на "чувство" возврата к исходному масштабу
|
||||||
|
"scale_easing_curve_out": "InBack",
|
||||||
|
|
||||||
|
# Цвета градиента для анимированной обводки
|
||||||
|
# Список словарей, где каждый словарь задает позицию (0.0–1.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"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@@ -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()
|
||||||
|
@@ -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()
|
||||||
|
@@ -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)
|
||||||
|
@@ -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()
|
||||||
|
|
||||||
|
@@ -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)
|
||||||
|
@@ -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):
|
||||||
"""Переход к выбранному диску"""
|
"""Переход к выбранному диску"""
|
||||||
|
@@ -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(
|
||||||
|
@@ -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:
|
||||||
|
Binary file not shown.
@@ -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 ""
|
||||||
|
|
||||||
|
Binary file not shown.
@@ -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 ""
|
||||||
|
|
||||||
|
@@ -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 ""
|
||||||
|
|
||||||
|
Binary file not shown.
@@ -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 "Нет недавних игр"
|
||||||
|
|
||||||
|
@@ -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()
|
||||||
|
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 |
@@ -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.0–1.0) и цвет в формате hex.
|
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
|
||||||
# Влияет на внешний вид обводки при наведении или фокусе.
|
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||||
|
"scale_easing_curve": "OutBack",
|
||||||
|
|
||||||
|
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
|
||||||
|
# Влияет на "чувство" возврата к исходному масштабу
|
||||||
|
"scale_easing_curve_out": "InBack",
|
||||||
|
|
||||||
|
# Цвета градиента для анимированной обводки
|
||||||
|
# Список словарей, где каждый словарь задает позицию (0.0–1.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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -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
|
|
260
portprotonqt/tray_manager.py
Normal file
260
portprotonqt/tray_manager.py
Normal 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)
|
@@ -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",
|
||||||
]
|
]
|
||||||
|
@@ -19,7 +19,7 @@
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchManagers": ["github-actions", "pre-commit"],
|
"matchManagers": ["github-actions", "pre-commit", "poetry"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Reference in New Issue
Block a user