24 Commits

Author SHA1 Message Date
8e11dac987 chore: v0.1.5
Some checks failed
Code check / Check code (push) Successful in 1m27s
Build AppImage, Arch and Fedora Packages / Build AppImage (push) Successful in 3m15s
Build AppImage, Arch and Fedora Packages / Build Arch Package (push) Successful in 1m50s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (41) (push) Successful in 1m14s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (42) (push) Successful in 1m6s
Build AppImage, Arch and Fedora Packages / Build Fedora RPM (rawhide) (push) Successful in 1m53s
Build AppImage, Arch and Fedora Packages / Create and Publish Release (push) Failing after 48s
Fetch Data / build (push) Failing after 20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 16:08:43 +05:00
358afbdbdb chore(localization): update
All checks were successful
Check Translations / check-translations (push) Successful in 46s
Code check / Check code (push) Successful in 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:29:11 +05:00
83730499e2 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:23:55 +05:00
84f560ed30 feat(tray): add modal game launch dialog with process detection and cancellation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 12:20:52 +05:00
888c9ac387 chore(theme): drop unstable mark from scale animation
All checks were successful
Code check / Check code (push) Successful in 1m26s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 11:11:07 +05:00
68d06ca05c fix(FlowLayout): Align incomplete rows with the first card of the longest row
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 11:09:24 +05:00
6923a5f05c chore(theme): change placeholder aspect ratio
All checks were successful
Code check / Check code (push) Successful in 1m44s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-31 10:03:30 +05:00
f3f85441d8 fix: scale animation is less unstable
All checks were successful
Code check / Check code (push) Successful in 1m38s
renovate / renovate (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-30 21:21:15 +05:00
eb90836710 chore: change cover aspect ratio
All checks were successful
Code check / Check code (push) Successful in 1m14s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-30 10:59:18 +05:00
dd125c975b fix(input_manager): revert dpad navigation to focusNextChild
All checks were successful
Code check / Check code (push) Successful in 1m39s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-29 14:46:42 +05:00
4521d3ca1c chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m41s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 17:15:34 +05:00
dd044dbd95 feat(tray_manager): added themes select to tray
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 17:12:31 +05:00
0047b29cd2 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m19s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:58:37 +05:00
d0fbc79168 fix(input_manager): fix keyboard and dpad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:56:55 +05:00
57f6ac9c4b feat: center cards in FlowLayout with equal margins
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 14:53:14 +05:00
60271f7a13 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m53s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:55:02 +05:00
38ab4acc86 chore(documentation): chore card_animation_type
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:53:01 +05:00
8f54f4814c feat: added scale animation to game card hover and focus
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-28 10:48:55 +05:00
37254b89f1 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m37s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-27 11:22:23 +05:00
893e33bdce feat(tray_manager): implement double-click to toggle main window visibility
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-27 11:14:14 +05:00
1ee784d890 chore(changelog): update
Some checks failed
Check Translations / check-translations (push) Failing after 35s
Code check / Check code (push) Successful in 1m25s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:22:26 +05:00
39f505079c chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:18:19 +05:00
46253115ff feat: returned tray and added favorites and recent to it
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-26 13:00:16 +05:00
31a7ef3e7e chore(deps): update lock file
All checks were successful
Code check / Check code (push) Successful in 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-24 22:44:17 +05:00
28 changed files with 1645 additions and 948 deletions

View File

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

View File

@@ -3,16 +3,22 @@
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [0.1.5] - 2025-08-31
### Added
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
- Анимация при закрытии карточки игры (подробности см. в документации).
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
- Система быстрого доступа (избранного) в диалоге выбора файлов.
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
- Поднятия в родительскую директорию в диалоге выбора файлов на BackSpace.
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
- Пункт "Выход" в трей.
- Пункт "Темы" в трей для быстрого переключения тем.
- Двойной клик по иконке трея для показа/скрытия главного окна.
- Запуск через трей показывает модальное окно для слежки за процессом запуска
### Changed
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
@@ -22,7 +28,9 @@
- Временно удалена светлая тема.
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
- Обновлены все зависимости (затрагивает только AppImage).
- Удалён отдельный трей, так как у PortProton есть собственный.
- Приложение теперь не закрывается полностью, а сворачивается в трей.
- Карточки теперь все находятся друг под другом, а не в разнабой
- Изменено соотношение сторон карточек
### Fixed
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
@@ -31,9 +39,10 @@
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
- Переведен заголовок окна диалога выбора файлов.
- Заголовок окна диалога выбора файлов теперь можно перевести.
- Трей теперь можно перевести.
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
- Закрытие диалога добавления / редактирования игры и диалога выбора файлов через escape.
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
### Contributors
- @Alex Smith

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,7 @@ logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
__app_version__ = "0.1.4"
__app_version__ = "0.1.5"
def main():
app = QApplication(sys.argv)
@@ -30,7 +30,7 @@ def main():
args = parse_args()
window = MainWindow()
window = MainWindow(app_name=__app_name__)
if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag")

View File

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

View File

@@ -4,7 +4,7 @@ import re
from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon
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 icoextract import IconExtractor, IconExtractorError
@@ -16,6 +16,7 @@ import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader
import psutil
if TYPE_CHECKING:
from portprotonqt.main_window import MainWindow
@@ -89,6 +90,86 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
class FileSelectedSignal(QObject):
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):
def __init__(self, parent=None, theme=None, file_filter=None, initial_path=None, directory_only=False):
super().__init__(parent)

View File

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

View File

@@ -673,87 +673,107 @@ class InputManager(QObject):
scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
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 = {}
for card in game_cards:
y_tolerance = 10 # Allow slight variations in y-position
for card in cards:
y = card.pos().y()
if y not in rows:
rows[y] = []
rows[y].append(card)
# Sort cards in each row by x-coordinate
for y in rows:
rows[y].sort(key=lambda c: c.pos().x())
# Sort rows by y-coordinate
matched = False
for row_y in rows:
if abs(y - row_y) <= y_tolerance:
rows[row_y].append(card)
matched = True
break
if not matched:
rows[y] = [card]
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_col_idx = current_row.index(focused)
if code == ecodes.ABS_HAT0X and value != 0: # Left/Right
if code == ecodes.ABS_HAT0X and value != 0:
if value < 0: # Left
next_col_idx = current_col_idx - 1
if next_col_idx >= 0:
next_card = current_row[next_col_idx]
next_card.setFocus()
if current_col_idx > 0:
next_card = current_row[current_col_idx - 1]
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
else:
# Move to the last card of the previous row if available
if current_row_idx > 0:
prev_row = sorted_rows[current_row_idx - 1][1]
next_card = prev_row[-1] if prev_row else None
if next_card:
next_card.setFocus()
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value > 0: # Right
next_col_idx = current_col_idx + 1
if next_col_idx < len(current_row):
next_card = current_row[next_col_idx]
next_card.setFocus()
if current_col_idx < len(current_row) - 1:
next_card = current_row[current_col_idx + 1]
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
else:
# Move to the first card of the next row if available
if current_row_idx < len(sorted_rows) - 1:
next_row = sorted_rows[current_row_idx + 1][1]
next_card = next_row[0] if next_row else None
if next_card:
next_card.setFocus()
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
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
next_row_idx = current_row_idx + 1
if next_row_idx < len(sorted_rows):
next_row = sorted_rows[next_row_idx][1]
# Find card in same column or closest
target_x = focused.pos().x()
if current_row_idx < len(sorted_rows) - 1:
next_row = sorted_rows[current_row_idx + 1][1]
current_x = focused.pos().x() + focused.width() / 2
next_card = min(
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
)
if next_card:
next_card.setFocus()
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif value < 0: # Up
next_row_idx = current_row_idx - 1
if next_row_idx >= 0:
next_row = sorted_rows[next_row_idx][1]
# Find card in same column or closest
target_x = focused.pos().x()
if current_row_idx > 0:
prev_row = sorted_rows[current_row_idx - 1][1]
current_x = focused.pos().x() + focused.width() / 2
next_card = min(
next_row,
key=lambda c: abs(c.pos().x() - target_x),
prev_row,
key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
default=None
)
if next_card:
next_card.setFocus()
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
elif current_row_idx == 0:

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -26,6 +26,12 @@ msgstr ""
msgid "PortProton is not found"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
@@ -35,12 +41,6 @@ msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Import to Legendary"
msgstr ""
@@ -248,15 +248,19 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Path: "
msgstr ""
@@ -651,3 +655,24 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -26,6 +26,12 @@ msgstr ""
msgid "PortProton is not found"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
@@ -35,12 +41,6 @@ msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Import to Legendary"
msgstr ""
@@ -248,15 +248,19 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Path: "
msgstr ""
@@ -651,3 +655,24 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -24,6 +24,12 @@ msgstr ""
msgid "PortProton is not found"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
@@ -33,12 +39,6 @@ msgstr ""
msgid "Launch Game"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Import to Legendary"
msgstr ""
@@ -246,15 +246,19 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "File Explorer"
msgstr ""
msgid "Select"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Path: "
msgstr ""
@@ -649,3 +653,24 @@ msgstr ""
msgid "sec."
msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
"PO-Revision-Date: 2025-08-23 20:35+0500\n"
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
"PO-Revision-Date: 2025-08-31 12:28+0500\n"
"Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
@@ -27,6 +27,12 @@ msgstr "Ошибка"
msgid "PortProton is not found"
msgstr "PortProton не найден"
msgid "Remove from Favorites"
msgstr "Удалить из Избранного"
msgid "Add to Favorites"
msgstr "Добавить в Избранное"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
@@ -36,12 +42,6 @@ msgstr "Остановить игру"
msgid "Launch Game"
msgstr "Запустить игру"
msgid "Remove from Favorites"
msgstr "Удалить из Избранного"
msgid "Add to Favorites"
msgstr "Добавить в Избранное"
msgid "Import to Legendary"
msgstr "Импортировать игру"
@@ -255,15 +255,19 @@ msgstr "Удалить"
msgid "Select All"
msgstr "Выбрать всё"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "Cancel"
msgstr "Отмена"
msgid "File Explorer"
msgstr "Проводник"
msgid "Select"
msgstr "Выбрать"
msgid "Cancel"
msgstr "Отмена"
msgid "Path: "
msgstr "Путь: "
@@ -660,3 +664,24 @@ msgstr "мин."
msgid "sec."
msgstr "сек."
msgid "Show"
msgstr "Показать"
msgid "Favorites"
msgstr "Избранное"
msgid "Recent Games"
msgstr "Недавние"
msgid "Exit"
msgstr "Выход"
msgid "Hide"
msgstr "Скрыть"
msgid "No favorites"
msgstr "Нет избранных"
msgid "No recent games"
msgstr "Нет недавних игр"

View File

@@ -34,6 +34,7 @@ from portprotonqt.localization import _, get_egs_language, read_metadata_transla
from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat
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,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
@@ -52,10 +53,11 @@ class MainWindow(QMainWindow):
update_progress = Signal(int) # Signal to update progress bar
update_status_message = Signal(str, int) # Signal to update status message
def __init__(self):
def __init__(self, app_name: str):
super().__init__()
# Создаём менеджер тем и читаем, какая тема выбрана
self.theme_manager = ThemeManager()
self.is_exiting = False
selected_theme = read_theme_from_config()
self.current_theme_name = selected_theme
try:
@@ -67,8 +69,9 @@ class MainWindow(QMainWindow):
save_theme_to_config("standart")
if not self.theme:
self.theme = default_styles
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size()
self.setWindowTitle("PortProtonQt")
self.setWindowTitle(app_name)
self.setMinimumSize(800, 600)
self.games = []
@@ -1525,7 +1528,7 @@ class MainWindow(QMainWindow):
detailPage = QWidget()
self._animations = {}
imageLabel = QLabel()
imageLabel.setFixedSize(300, 400)
imageLabel.setFixedSize(300, 450)
self._detail_page_active = True
self._current_detail_page = detailPage
@@ -1559,7 +1562,7 @@ class MainWindow(QMainWindow):
logger.debug("Stylesheet updated with palette")
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:
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
detailPage.update()
@@ -1587,7 +1590,7 @@ class MainWindow(QMainWindow):
# Обложка (слева)
coverFrame = QFrame()
coverFrame.setFixedSize(300, 400)
coverFrame.setFixedSize(300, 450)
coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE)
shadow = QGraphicsDropShadowEffect(coverFrame)
shadow.setBlurRadius(20)
@@ -2266,46 +2269,51 @@ class MainWindow(QMainWindow):
def closeEvent(self, event):
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна."""
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
for child in children:
try:
logger.debug(f"Terminating child process {child.pid}")
child.terminate()
except psutil.NoSuchProcess:
logger.debug(f"Child process {child.pid} already terminated")
psutil.wait_procs(children, timeout=5)
for child in children:
if child.is_running():
logger.debug(f"Killing child process {child.pid}")
child.kill()
logger.debug(f"Terminating process group {proc.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (psutil.NoSuchProcess, ProcessLookupError) as e:
logger.debug(f"Process {proc.pid} already terminated: {e}")
"""Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
if hasattr(self, 'is_exiting') and self.is_exiting:
# Принудительное закрытие: завершаем процессы и приложение
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
for child in children:
try:
logger.debug(f"Terminating child process {child.pid}")
child.terminate()
except psutil.NoSuchProcess:
logger.debug(f"Child process {child.pid} already terminated")
psutil.wait_procs(children, timeout=5)
for child in children:
if child.is_running():
logger.debug(f"Killing child process {child.pid}")
child.kill()
logger.debug(f"Terminating process group {proc.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
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():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
save_window_geometry(self.width(), self.height())
save_card_size(self.card_width)
# Очищаем таймеры
if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
self.games_load_timer.stop()
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
# Очищаем таймеры и другие ресурсы
if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
self.games_load_timer.stop()
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer.isActive():
self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
# Сохраняем настройки окна
if not read_fullscreen_config():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
save_window_geometry(self.width(), self.height())
save_card_size(self.card_width)
QApplication.quit()
event.accept()
event.accept()
else:
# Сворачиваем в трей вместо закрытия
self.hide()
event.ignore()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portprotonqt"
version = "0.1.4"
version = "0.1.5"
description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md"
license = { text = "GPL-3.0" }

896
uv.lock generated

File diff suppressed because it is too large Load Diff