Compare commits
1 Commits
v0.1.5
...
18312502ca
Author | SHA1 | Date | |
---|---|---|---|
18312502ca
|
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 0.1.5
|
||||
VERSION: 0.1.4
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
|
@@ -17,11 +17,10 @@ jobs:
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install uv manually
|
||||
run: |
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
. $HOME/.local/bin/env
|
||||
uv --version
|
||||
- name: Install uv
|
||||
uses: https://github.com/astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Download external renovate config
|
||||
run: |
|
||||
|
21
CHANGELOG.md
21
CHANGELOG.md
@@ -3,22 +3,12 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [0.1.5] - 2025-08-31
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
|
||||
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
|
||||
- Анимация при закрытии карточки игры (подробности см. в документации).
|
||||
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
|
||||
- Система быстрого доступа (избранного) в диалоге выбора файлов.
|
||||
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
|
||||
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
|
||||
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
|
||||
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
|
||||
- Пункт "Выход" в трей.
|
||||
- Пункт "Темы" в трей для быстрого переключения тем.
|
||||
- Двойной клик по иконке трея для показа/скрытия главного окна.
|
||||
- Запуск через трей показывает модальное окно для слежки за процессом запуска
|
||||
|
||||
### Changed
|
||||
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
|
||||
@@ -28,9 +18,7 @@
|
||||
- Временно удалена светлая тема.
|
||||
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
|
||||
- Обновлены все зависимости (затрагивает только AppImage).
|
||||
- Приложение теперь не закрывается полностью, а сворачивается в трей.
|
||||
- Карточки теперь все находятся друг под другом, а не в разнабой
|
||||
- Изменено соотношение сторон карточек
|
||||
- Удалён отдельный трей, так как у PortProton есть собственный.
|
||||
|
||||
### Fixed
|
||||
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
|
||||
@@ -39,10 +27,7 @@
|
||||
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
|
||||
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
|
||||
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
|
||||
- Заголовок окна диалога выбора файлов теперь можно перевести.
|
||||
- Трей теперь можно перевести.
|
||||
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
|
||||
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
|
||||
- Переведен заголовок окна диалога выбора файлов.
|
||||
|
||||
### Contributors
|
||||
- @Alex Smith
|
||||
|
@@ -45,7 +45,7 @@ AppDir:
|
||||
id: ru.linux_gaming.PortProtonQt
|
||||
name: PortProtonQt
|
||||
icon: ru.linux_gaming.PortProtonQt
|
||||
version: 0.1.5
|
||||
version: 0.1.4
|
||||
exec: usr/bin/python3
|
||||
exec_args: "-m portprotonqt.app $@"
|
||||
apt:
|
||||
|
@@ -1,5 +1,5 @@
|
||||
pkgname=portprotonqt
|
||||
pkgver=0.1.5
|
||||
pkgver=0.1.4
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||
arch=('any')
|
||||
|
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 0.1.5
|
||||
%global pypi_version 0.1.4
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
|
@@ -21,9 +21,9 @@ Current translation status:
|
||||
|
||||
| Locale | Progress | Translated |
|
||||
| :----- | -------: | ---------: |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
|
@@ -21,9 +21,9 @@
|
||||
|
||||
| Локаль | Прогресс | Переведено |
|
||||
| :----- | -------: | ---------: |
|
||||
| [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 |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
|
@@ -52,151 +52,102 @@ The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game
|
||||
|
||||
```python
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Type of animation when entering or exiting the detail page
|
||||
# Type of animation when entering and 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 around the card when it's not selected
|
||||
# Value in pixels
|
||||
# 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.
|
||||
"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., when selected via keyboard)
|
||||
# Increases the border thickness when the card is focused
|
||||
# Value in pixels
|
||||
# Border width on focus (e.g., 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
|
||||
# Determines the minimum border thickness during the "breathing" animation
|
||||
# Value in pixels
|
||||
# Minimum border width during pulsing animation.
|
||||
# Sets the minimum border thickness during the "breathing" animation.
|
||||
# Value in pixels.
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Maximum border width during pulsing animation
|
||||
# Determines the maximum border thickness during pulsing
|
||||
# Value in pixels
|
||||
# Maximum border width during pulsing animation.
|
||||
# Sets 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 from one border width to another
|
||||
# Value in milliseconds
|
||||
# Duration of the border thickness animation (e.g., on hover or focus).
|
||||
# Affects the speed of transition between different border widths.
|
||||
# Value in milliseconds.
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Duration of one pulsing animation cycle
|
||||
# Determines how fast the border "pulses" between min and max values
|
||||
# Value in milliseconds
|
||||
# Duration of one pulsing animation cycle.
|
||||
# Defines 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)
|
||||
# Determines the initial rotation point of the gradient at animation start
|
||||
# Starting angle of the gradient (in degrees).
|
||||
# Defines the initial rotation point of the gradient when the animation starts.
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Ending angle of the gradient (in degrees)
|
||||
# Determines the final rotation point of the gradient
|
||||
# Value 0 means a full 360° rotation
|
||||
# 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.
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# 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")
|
||||
# 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").
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Easing curve type for border thickness decrease animation (on hover/focus exit)
|
||||
# Affects the "feel" of returning to the default border width
|
||||
# Easing curve type for border contraction animation (on mouse leave/focus loss).
|
||||
# Affects the "feel" of returning to the original border width.
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# 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.0–1.0) and color in hex format
|
||||
# Affects the appearance of the border on hover or focus if card_animation_type="gradient"
|
||||
# Gradient colors for the animated border.
|
||||
# A list of dictionaries where each defines a position (0.0–1.0) and color in hex format.
|
||||
# Affects the appearance of the border on hover or focus.
|
||||
"gradient_colors": [
|
||||
{"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)
|
||||
{"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)
|
||||
],
|
||||
|
||||
# Duration of fade animation when entering the detail page
|
||||
# Affects the speed of page appearance with fade animation
|
||||
# Value in milliseconds
|
||||
# Duration of the fade animation when entering the detail page
|
||||
"detail_page_fade_duration": 350,
|
||||
|
||||
# Duration of slide animation when entering the detail page
|
||||
# Affects the speed of page sliding animation
|
||||
# Value in milliseconds
|
||||
# Duration of the slide animation when entering the detail page
|
||||
"detail_page_slide_duration": 500,
|
||||
|
||||
# Duration of bounce animation when entering the detail page
|
||||
# Affects the speed of page "bounce" animation
|
||||
# Value in milliseconds
|
||||
# Duration of the bounce animation when entering the detail page
|
||||
"detail_page_bounce_duration": 400,
|
||||
|
||||
# Duration of fade animation when exiting the detail page
|
||||
# Affects the speed of page disappearance with fade animation
|
||||
# Value in milliseconds
|
||||
# Duration of the fade animation when exiting the detail page
|
||||
"detail_page_fade_duration_exit": 350,
|
||||
|
||||
# Duration of slide animation when exiting the detail page
|
||||
# Affects the speed of page sliding animation
|
||||
# Value in milliseconds
|
||||
# Duration of the slide animation when exiting the detail page
|
||||
"detail_page_slide_duration_exit": 500,
|
||||
|
||||
# Duration of bounce animation when exiting the detail page
|
||||
# Affects the speed of page "compression" animation
|
||||
# Value in milliseconds
|
||||
# Duration of the bounce animation when exiting the detail page
|
||||
"detail_page_bounce_duration_exit": 400,
|
||||
|
||||
# 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
|
||||
# Easing curve type for animation when entering the detail page
|
||||
# Applies to slide and bounce animations
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# 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
|
||||
# Easing curve type for animation when exiting the detail page
|
||||
# Applies to slide and bounce animations
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
```
|
||||
|
@@ -54,104 +54,69 @@ 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,
|
||||
|
||||
# Тип анимации для карточки при наведении или фокусе
|
||||
# Возможные значения: "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")
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки.
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
|
||||
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"scale_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходному масштабу
|
||||
"scale_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex
|
||||
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
|
||||
# Цвета градиента для анимированной обводки.
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
|
||||
# Влияет на внешний вид обводки при наведении или фокусе.
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
@@ -160,43 +125,29 @@ 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 анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
# Применяется к slide и bounce анимациям
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Тип кривой сглаживания для анимации при выходе из детальной страницы
|
||||
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
# Применяется к slide и bounce анимациям
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
```
|
||||
|
@@ -26,23 +26,14 @@ 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 based on theme."""
|
||||
"""Initialize animation properties."""
|
||||
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):
|
||||
@@ -66,8 +57,6 @@ 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:
|
||||
@@ -80,44 +69,23 @@ class GameCardAnimations:
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_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()
|
||||
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()
|
||||
|
||||
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:
|
||||
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.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
@@ -140,8 +108,6 @@ 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:
|
||||
@@ -154,44 +120,23 @@ class GameCardAnimations:
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_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()
|
||||
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()
|
||||
|
||||
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:
|
||||
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.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
@@ -212,8 +157,7 @@ class GameCardAnimations:
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen()
|
||||
pen.setWidth(self.game_card._borderWidth)
|
||||
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":
|
||||
if self.game_card._hovered or self.game_card._focused:
|
||||
center = self.game_card.rect().center()
|
||||
gradient = QConicalGradient(center, self.game_card._gradientAngle)
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
@@ -222,11 +166,11 @@ class GameCardAnimations:
|
||||
else:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
painter.setPen(pen)
|
||||
radius = 18 * self.game_card._scale
|
||||
radius = 18
|
||||
bw = round(self.game_card._borderWidth / 2)
|
||||
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
|
||||
if rect.isEmpty():
|
||||
return
|
||||
return # Avoid drawing invalid rect
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
|
||||
class DetailPageAnimations:
|
||||
@@ -340,15 +284,15 @@ class DetailPageAnimations:
|
||||
logger.debug("Original effect already deleted")
|
||||
cleanup_callback()
|
||||
animation.finished.connect(restore_and_cleanup)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
animation.finished.connect(opacity_effect.deleteLater) # Clean up effect
|
||||
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),
|
||||
"slide_right": QPoint(self.main_window.width(), 0),
|
||||
"slide_up": QPoint(0, self.main_window.height()),
|
||||
"slide_down": QPoint(0, -self.main_window.height())
|
||||
"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
|
||||
}[animation_type]
|
||||
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
|
||||
animation.setDuration(duration)
|
||||
@@ -381,4 +325,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()
|
||||
cleanup_callback() # Fallback to cleanup if animation setup fails
|
||||
|
@@ -11,7 +11,7 @@ logger = get_logger(__name__)
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
__app_version__ = "0.1.5"
|
||||
__app_version__ = "0.1.4"
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
@@ -30,7 +30,7 @@ def main():
|
||||
|
||||
args = parse_args()
|
||||
|
||||
window = MainWindow(app_name=__app_name__)
|
||||
window = MainWindow()
|
||||
|
||||
if args.fullscreen:
|
||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||
|
@@ -549,41 +549,3 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
|
||||
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
||||
def read_favorite_folders():
|
||||
"""
|
||||
Читает список избранных папок из секции [FavoritesFolders] конфигурационного файла.
|
||||
Список хранится как строка, заключённая в кавычки, с путями, разделёнными запятыми.
|
||||
Если секция или параметр отсутствуют, возвращает пустой список.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
return []
|
||||
if cp.has_section("FavoritesFolders") and cp.has_option("FavoritesFolders", "folders"):
|
||||
favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
|
||||
if favs.startswith('"') and favs.endswith('"'):
|
||||
favs = favs[1:-1]
|
||||
return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
|
||||
return []
|
||||
|
||||
def save_favorite_folders(folders):
|
||||
"""
|
||||
Сохраняет список избранных папок в секцию [FavoritesFolders] конфигурационного файла.
|
||||
Список сохраняется как строка, заключённая в двойные кавычки, где пути разделены запятыми.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
if "FavoritesFolders" not in cp:
|
||||
cp["FavoritesFolders"] = {}
|
||||
fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
|
||||
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
|
||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||
cp.write(configfile)
|
||||
|
@@ -12,7 +12,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
|
||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
|
||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
|
||||
@@ -150,84 +150,6 @@ class ContextMenuManager:
|
||||
|
||||
return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
|
||||
|
||||
def show_folder_context_menu(self, file_explorer, pos):
|
||||
"""Shows the context menu for a folder in FileExplorer."""
|
||||
try:
|
||||
item = file_explorer.file_list.itemAt(pos)
|
||||
if not item:
|
||||
logger.debug("No item selected at position %s", pos)
|
||||
return
|
||||
selected = item.text()
|
||||
if not selected.endswith("/"):
|
||||
logger.debug("Selected item is not a folder: %s", selected)
|
||||
return # Only for folders
|
||||
full_path = os.path.normpath(os.path.join(file_explorer.current_path, selected.rstrip("/")))
|
||||
if not os.path.isdir(full_path):
|
||||
logger.debug("Path is not a directory: %s", full_path)
|
||||
return
|
||||
|
||||
menu = QMenu(file_explorer)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
menu.setParent(file_explorer, Qt.WindowType.Popup) # Set transientParent for Wayland
|
||||
|
||||
favorite_folders = read_favorite_folders()
|
||||
is_favorite = full_path in favorite_folders
|
||||
action_text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
|
||||
favorite_action = menu.addAction(self._get_safe_icon("star" if is_favorite else "star_full"), action_text)
|
||||
favorite_action.triggered.connect(lambda: self.toggle_favorite_folder(file_explorer, full_path, not is_favorite))
|
||||
|
||||
# Disconnect file_list signals to prevent navigation during menu interaction
|
||||
try:
|
||||
file_explorer.file_list.itemClicked.disconnect(file_explorer.handle_item_click)
|
||||
file_explorer.file_list.itemDoubleClicked.disconnect(file_explorer.handle_item_double_click)
|
||||
except TypeError:
|
||||
pass # Signals may not be connected
|
||||
|
||||
# Reconnect signals after menu closes
|
||||
def reconnect_signals():
|
||||
try:
|
||||
file_explorer.file_list.itemClicked.connect(file_explorer.handle_item_click)
|
||||
file_explorer.file_list.itemDoubleClicked.connect(file_explorer.handle_item_double_click)
|
||||
except Exception as e:
|
||||
logger.error("Error reconnecting file list signals: %s", e)
|
||||
|
||||
menu.aboutToHide.connect(reconnect_signals)
|
||||
|
||||
# Set focus to the first menu item
|
||||
actions = menu.actions()
|
||||
if actions:
|
||||
menu.setActiveAction(actions[0])
|
||||
|
||||
# Map local position to global for menu display
|
||||
global_pos = file_explorer.file_list.mapToGlobal(pos)
|
||||
menu.exec(global_pos)
|
||||
except Exception as e:
|
||||
logger.error("Error showing folder context menu: %s", e)
|
||||
|
||||
def toggle_favorite_folder(self, file_explorer, folder_path, add):
|
||||
"""Adds or removes a folder from favorites."""
|
||||
favorite_folders = read_favorite_folders()
|
||||
if add:
|
||||
if folder_path not in favorite_folders:
|
||||
favorite_folders.append(folder_path)
|
||||
save_favorite_folders(favorite_folders)
|
||||
logger.info(f"Folder added to favorites: {folder_path}")
|
||||
else:
|
||||
if folder_path in favorite_folders:
|
||||
favorite_folders.remove(folder_path)
|
||||
save_favorite_folders(favorite_folders)
|
||||
logger.info(f"Folder removed from favorites: {folder_path}")
|
||||
file_explorer.update_drives_list()
|
||||
|
||||
def _get_safe_icon(self, icon_name: str) -> QIcon:
|
||||
"""Returns a QIcon, ensuring it is valid."""
|
||||
icon = self.theme_manager.get_icon(icon_name)
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
elif isinstance(icon, str) and os.path.exists(icon):
|
||||
return QIcon(icon)
|
||||
return QIcon()
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
"""
|
||||
Show the context menu for a game card at the specified position.
|
||||
@@ -236,6 +158,14 @@ class ContextMenuManager:
|
||||
game_card: The GameCard instance requesting the context menu.
|
||||
pos: The position (in widget coordinates) where the menu should appear.
|
||||
"""
|
||||
def get_safe_icon(icon_name: str) -> QIcon:
|
||||
icon = self.theme_manager.get_icon(icon_name)
|
||||
if isinstance(icon, QIcon):
|
||||
return icon
|
||||
elif isinstance(icon, str) and os.path.exists(icon):
|
||||
return QIcon(icon)
|
||||
return QIcon()
|
||||
|
||||
menu = QMenu(self.parent)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
@@ -245,7 +175,7 @@ class ContextMenuManager:
|
||||
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
|
||||
if not exe_path:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
return
|
||||
@@ -254,7 +184,7 @@ class ContextMenuManager:
|
||||
is_running = self._is_game_running(game_card)
|
||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||
action_icon = "stop" if is_running else "play"
|
||||
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
|
||||
launch_action = menu.addAction(get_safe_icon(action_icon), action_text)
|
||||
launch_action.triggered.connect(
|
||||
lambda: self._launch_game(game_card)
|
||||
)
|
||||
@@ -263,11 +193,11 @@ class ContextMenuManager:
|
||||
is_favorite = game_card.name in favorites
|
||||
icon_name = "star_full" if is_favorite else "star"
|
||||
text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
|
||||
favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
favorite_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
|
||||
|
||||
if game_card.game_source == "epic":
|
||||
import_action = menu.addAction(self._get_safe_icon("epic_games"), _("Import to Legendary"))
|
||||
import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary"))
|
||||
import_action.triggered.connect(
|
||||
lambda: self.import_to_legendary(game_card.name, game_card.appid)
|
||||
)
|
||||
@@ -275,13 +205,13 @@ class ContextMenuManager:
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
icon_name = "delete" if is_in_steam else "steam"
|
||||
text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
steam_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
if is_in_steam
|
||||
else self.add_egs_to_steam(game_card.name, game_card.appid)
|
||||
)
|
||||
open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
|
||||
open_folder_action.triggered.connect(
|
||||
lambda: self.open_egs_game_folder(game_card.appid)
|
||||
)
|
||||
@@ -289,7 +219,7 @@ class ContextMenuManager:
|
||||
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
||||
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
|
||||
desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
desktop_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
desktop_action.triggered.connect(
|
||||
lambda: self.remove_egs_from_desktop(game_card.name)
|
||||
if os.path.exists(desktop_path)
|
||||
@@ -298,7 +228,7 @@ class ContextMenuManager:
|
||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||
menu_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
|
||||
get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
|
||||
_("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
|
||||
)
|
||||
menu_action.triggered.connect(
|
||||
@@ -312,19 +242,19 @@ class ContextMenuManager:
|
||||
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
||||
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
|
||||
desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
desktop_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
desktop_action.triggered.connect(
|
||||
lambda: self.remove_from_desktop(game_card.name)
|
||||
if os.path.exists(desktop_path)
|
||||
else self.add_to_desktop(game_card.name, game_card.exec_line)
|
||||
)
|
||||
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut"))
|
||||
edit_action.triggered.connect(
|
||||
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||
)
|
||||
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
|
||||
open_folder_action.triggered.connect(
|
||||
lambda: self.open_game_folder(game_card.name, game_card.exec_line)
|
||||
)
|
||||
@@ -332,7 +262,7 @@ class ContextMenuManager:
|
||||
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||
icon_name = "delete" if os.path.exists(menu_path) else "menu"
|
||||
text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
|
||||
menu_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
menu_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
menu_action.triggered.connect(
|
||||
lambda: self.remove_from_menu(game_card.name)
|
||||
if os.path.exists(menu_path)
|
||||
@@ -341,7 +271,7 @@ class ContextMenuManager:
|
||||
is_in_steam = is_game_in_steam(game_card.name)
|
||||
icon_name = "delete" if is_in_steam else "steam"
|
||||
text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||
steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||
steam_action = menu.addAction(get_safe_icon(icon_name), text)
|
||||
steam_action.triggered.connect(
|
||||
lambda: (
|
||||
self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||
@@ -350,7 +280,7 @@ class ContextMenuManager:
|
||||
)
|
||||
)
|
||||
|
||||
# Set focus to the first menu item
|
||||
# Устанавливаем фокус на первый элемент меню
|
||||
actions = menu.actions()
|
||||
if actions:
|
||||
menu.setActiveAction(actions[0])
|
||||
@@ -492,7 +422,7 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
|
||||
# Use FileExplorer with directory_only=True
|
||||
# Используем FileExplorer с directory_only=True
|
||||
file_explorer = FileExplorer(
|
||||
parent=self.parent,
|
||||
theme=self.theme,
|
||||
@@ -522,10 +452,10 @@ class ContextMenuManager:
|
||||
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
|
||||
threading.Thread(target=run_import, daemon=True).start()
|
||||
|
||||
# Connect the file selection signal
|
||||
# Подключаем сигнал выбора файла/папки
|
||||
file_explorer.file_signal.file_selected.connect(on_folder_selected)
|
||||
|
||||
# Center FileExplorer relative to the parent widget
|
||||
# Центрируем FileExplorer относительно родительского виджета
|
||||
parent_widget = self.parent
|
||||
if parent_widget:
|
||||
parent_geometry = parent_widget.geometry()
|
||||
@@ -859,7 +789,7 @@ Icon={icon_path}
|
||||
_("Failed to delete custom data: {error}").format(error=str(e))
|
||||
)
|
||||
|
||||
# Reload games list and update grid
|
||||
# Перезагрузка списка игр и обновление сетки
|
||||
self.load_games()
|
||||
self.update_game_grid()
|
||||
|
||||
|
@@ -8,8 +8,8 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
|
||||
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
|
||||
rect_width: доступная ширина контейнера.
|
||||
spacing: отступ между элементами (горизонтальный и вертикальный).
|
||||
max_scale: максимальный коэффициент масштабирования (например, 1.0).
|
||||
spacing: отступ между элементами.
|
||||
max_scale: максимальный коэффициент масштабирования (например, 1.2).
|
||||
|
||||
Возвращает:
|
||||
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
|
||||
@@ -19,49 +19,16 @@ 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]
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
||||
# Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width:
|
||||
break
|
||||
sum_width += w
|
||||
count += 1
|
||||
@@ -69,19 +36,13 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
if h > row_max_height:
|
||||
row_max_height = h
|
||||
j += 1
|
||||
|
||||
# Используем глобальный масштаб для всех рядов
|
||||
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
|
||||
|
||||
# Доступная ширина ряда с учетом обязательных отступов между элементами
|
||||
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
|
||||
for k in range(i, j):
|
||||
new_w = int(nat_sizes[k, 0] * scale)
|
||||
new_h = int(nat_sizes[k, 1] * scale)
|
||||
@@ -90,7 +51,6 @@ 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
|
||||
@@ -99,17 +59,18 @@ class FlowLayout(QLayout):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.itemList = []
|
||||
self.setContentsMargins(20, 20, 20, 20) # Отступы по краям
|
||||
self._spacing = 20 # Отступ для анимации и предотвращения перекрытий
|
||||
self._max_scale = 1.0 # Отключено масштабирование в layout
|
||||
# Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
|
||||
self.setContentsMargins(0, 0, 0, 0)
|
||||
self._spacing = 3 # отступ между карточками
|
||||
self._max_scale = 1.2 # максимальное увеличение карточек (например, на 20%)
|
||||
|
||||
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)
|
||||
@@ -141,7 +102,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):
|
||||
@@ -149,12 +110,14 @@ 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:
|
||||
@@ -189,7 +152,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()
|
||||
@@ -207,23 +170,28 @@ 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))
|
||||
font.setPointSize(max(8, font_size)) # Минимальный размер шрифта 8
|
||||
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:
|
||||
@@ -232,11 +200,17 @@ 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)
|
||||
@@ -306,6 +280,8 @@ class AutoSizeButton(QPushButton):
|
||||
|
||||
self.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.setFlat(True)
|
||||
|
||||
# Изначально выставляем минимальную ширину
|
||||
self.setMinimumWidth(50)
|
||||
self.adjustFontSize()
|
||||
|
||||
@@ -336,6 +312,7 @@ class AutoSizeButton(QPushButton):
|
||||
if not self._update_size:
|
||||
return
|
||||
|
||||
# Определяем доступную ширину внутри кнопки
|
||||
available_width = self.width()
|
||||
if self._icon:
|
||||
available_width -= self._icon_size
|
||||
@@ -346,6 +323,7 @@ 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)
|
||||
@@ -358,12 +336,14 @@ 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)
|
||||
|
||||
@@ -373,6 +353,7 @@ 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)
|
||||
@@ -383,6 +364,7 @@ class AutoSizeButton(QPushButton):
|
||||
height = fm.height() + margins.top() + margins.bottom() + self._padding
|
||||
return QSize(width, height)
|
||||
|
||||
|
||||
class NavLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
@@ -394,6 +376,7 @@ 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):
|
||||
@@ -412,6 +395,7 @@ 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)
|
||||
|
@@ -4,19 +4,18 @@ 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, QProgressBar
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
|
||||
from icoextract import IconExtractor, IconExtractorError
|
||||
from PIL import Image
|
||||
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.logger import get_logger
|
||||
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
|
||||
@@ -90,86 +89,6 @@ 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)
|
||||
@@ -187,15 +106,13 @@ class FileExplorer(QDialog):
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
|
||||
|
||||
# Find InputManager and ContextMenuManager from parent
|
||||
# Find InputManager from parent
|
||||
self.input_manager = None
|
||||
self.context_menu_manager = None
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
if hasattr(parent, 'input_manager'):
|
||||
self.input_manager = cast("MainWindow", parent).input_manager
|
||||
if hasattr(parent, 'context_menu_manager'):
|
||||
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
|
||||
break
|
||||
parent = parent.parent()
|
||||
|
||||
if self.input_manager:
|
||||
@@ -220,9 +137,8 @@ class FileExplorer(QDialog):
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
mount_point = parts[1]
|
||||
# Исключаем системные и временные пути, но сохраняем /run/media
|
||||
if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
|
||||
(mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
|
||||
# Исключаем системные и временные пути
|
||||
if mount_point.startswith(('/run', '/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')):
|
||||
continue
|
||||
# Проверяем, является ли точка монтирования директорией и доступна ли она
|
||||
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
|
||||
@@ -242,7 +158,7 @@ class FileExplorer(QDialog):
|
||||
self.main_layout.setSpacing(10)
|
||||
self.setLayout(self.main_layout)
|
||||
|
||||
# Панель для смонтированных дисков и избранных папок
|
||||
# Панель для смонтированных дисков
|
||||
self.drives_layout = QHBoxLayout()
|
||||
self.drives_scroll = QScrollArea()
|
||||
self.drives_scroll.setWidgetResizable(True)
|
||||
@@ -253,7 +169,7 @@ class FileExplorer(QDialog):
|
||||
self.drives_scroll.setFixedHeight(70)
|
||||
self.main_layout.addWidget(self.drives_scroll)
|
||||
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Allow focus on scroll area
|
||||
|
||||
# Путь
|
||||
self.path_label = QLabel()
|
||||
@@ -265,8 +181,6 @@ class FileExplorer(QDialog):
|
||||
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
|
||||
self.file_list.itemClicked.connect(self.handle_item_click)
|
||||
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
|
||||
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
|
||||
self.main_layout.addWidget(self.file_list)
|
||||
|
||||
# Кнопки
|
||||
@@ -283,13 +197,6 @@ class FileExplorer(QDialog):
|
||||
self.select_button.clicked.connect(self.select_item)
|
||||
self.cancel_button.clicked.connect(self.reject)
|
||||
|
||||
def show_folder_context_menu(self, pos):
|
||||
"""Shows the context menu for a folder using ContextMenuManager."""
|
||||
if self.context_menu_manager:
|
||||
self.context_menu_manager.show_folder_context_menu(self, pos)
|
||||
else:
|
||||
logger.warning("ContextMenuManager not found in parent")
|
||||
|
||||
def move_selection(self, direction):
|
||||
"""Перемещение выбора по списку"""
|
||||
current_row = self.file_list.currentRow()
|
||||
@@ -379,96 +286,44 @@ class FileExplorer(QDialog):
|
||||
except Exception as e:
|
||||
logger.error(f"Error navigating to parent directory: {e}")
|
||||
|
||||
def ensure_button_visible(self, button):
|
||||
"""Ensure the specified button is visible in the drives_scroll area."""
|
||||
try:
|
||||
if not button or not self.drives_scroll:
|
||||
return
|
||||
# Ensure the button is visible in the scroll area
|
||||
self.drives_scroll.ensureWidgetVisible(button, 50, 50)
|
||||
logger.debug(f"Ensured button {button.text()} is visible in drives_scroll")
|
||||
except Exception as e:
|
||||
logger.error(f"Error ensuring button visible: {e}")
|
||||
|
||||
def update_drives_list(self):
|
||||
"""Обновление списка смонтированных дисков и избранных папок."""
|
||||
"""Обновление списка смонтированных дисков"""
|
||||
for i in reversed(range(self.drives_layout.count())):
|
||||
item = self.drives_layout.itemAt(i)
|
||||
if item and item.widget():
|
||||
widget = item.widget()
|
||||
self.drives_layout.removeWidget(widget)
|
||||
widget = self.drives_layout.itemAt(i).widget()
|
||||
if widget:
|
||||
widget.deleteLater()
|
||||
|
||||
self.drive_buttons = []
|
||||
drives = self.get_mounted_drives()
|
||||
favorite_folders = read_favorite_folders()
|
||||
|
||||
# Добавляем смонтированные диски
|
||||
self.drive_buttons = [] # Store buttons for navigation
|
||||
for drive in drives:
|
||||
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
|
||||
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Make button focusable
|
||||
button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
|
||||
self.drives_layout.addWidget(button)
|
||||
self.drive_buttons.append(button)
|
||||
self.drives_layout.addStretch()
|
||||
|
||||
# Добавляем избранные папки
|
||||
for folder in favorite_folders:
|
||||
folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
|
||||
button = AutoSizeButton(folder_name, icon=self.theme_manager.get_icon("folder"))
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.clicked.connect(lambda checked, path=folder: self.change_drive(path))
|
||||
self.drives_layout.addWidget(button)
|
||||
self.drive_buttons.append(button)
|
||||
|
||||
# Добавляем растяжку, чтобы выровнять элементы
|
||||
spacer = QWidget()
|
||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.drives_layout.addWidget(spacer)
|
||||
# Set focus to first drive button if available
|
||||
if self.drive_buttons:
|
||||
self.drive_buttons[0].setFocus()
|
||||
|
||||
def select_drive(self):
|
||||
"""Обрабатывает выбор диска или избранной папки через геймпад."""
|
||||
"""Handle drive selection via gamepad"""
|
||||
focused_widget = QApplication.focusWidget()
|
||||
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
|
||||
drive_name = focused_widget.text().strip() # Удаляем пробелы
|
||||
logger.debug(f"Выбрано имя: {drive_name}")
|
||||
|
||||
# Специальная обработка корневого каталога
|
||||
if drive_name == "/":
|
||||
if os.path.isdir("/") and os.access("/", os.R_OK):
|
||||
self.current_path = "/"
|
||||
self.update_file_list()
|
||||
logger.info("Выбран корневой каталог: /")
|
||||
return
|
||||
else:
|
||||
logger.warning("Корневой каталог недоступен: недостаточно прав или ошибка пути")
|
||||
return
|
||||
|
||||
# Проверяем избранные папки
|
||||
favorite_folders = read_favorite_folders()
|
||||
logger.debug(f"Избранные папки: {favorite_folders}")
|
||||
for folder in favorite_folders:
|
||||
folder_name = os.path.basename(os.path.normpath(folder)) or folder # Для корневых путей
|
||||
if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
|
||||
self.current_path = os.path.normpath(folder)
|
||||
self.update_file_list()
|
||||
logger.info(f"Выбрана избранная папка: {self.current_path}")
|
||||
return
|
||||
|
||||
# Проверяем смонтированные диски
|
||||
mounted_drives = self.get_mounted_drives()
|
||||
logger.debug(f"Смонтированные диски: {mounted_drives}")
|
||||
for drive in mounted_drives:
|
||||
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # Для корневых путей
|
||||
if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
|
||||
self.current_path = os.path.normpath(drive)
|
||||
self.update_file_list()
|
||||
logger.info(f"Выбран смонтированный диск: {self.current_path}")
|
||||
return
|
||||
|
||||
logger.warning(f"Путь недоступен: {drive_name}.")
|
||||
drive_path = None
|
||||
for drive in self.get_mounted_drives():
|
||||
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
|
||||
if drive_name == focused_widget.text():
|
||||
drive_path = drive
|
||||
break
|
||||
if drive_path and os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
|
||||
self.current_path = os.path.normpath(drive_path)
|
||||
self.update_file_list()
|
||||
else:
|
||||
logger.warning(f"Путь диска недоступен: {drive_path}")
|
||||
|
||||
def change_drive(self, drive_path):
|
||||
"""Переход к выбранному диску"""
|
||||
|
@@ -12,27 +12,29 @@ 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()
|
||||
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)
|
||||
# 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
|
||||
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
|
||||
@@ -47,9 +49,7 @@ class GameCard(QFrame):
|
||||
self.game_source = game_source
|
||||
self.last_launch_ts = last_launch_ts
|
||||
self.playtime_seconds = playtime_seconds
|
||||
self.base_card_width = card_width
|
||||
self.base_pixmap = None
|
||||
self.base_font_size = None
|
||||
self.card_width = card_width
|
||||
|
||||
self.select_callback = select_callback
|
||||
self.context_menu_manager = context_menu_manager
|
||||
@@ -67,46 +67,75 @@ 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"))
|
||||
|
||||
self.base_extra_margin = 20
|
||||
# Дополнительное пространство для анимации
|
||||
extra_margin = 20
|
||||
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.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()
|
||||
|
||||
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)
|
||||
# Тень
|
||||
shadow = QGraphicsDropShadowEffect(self)
|
||||
shadow.setBlurRadius(20)
|
||||
shadow.setColor(QColor(0, 0, 0, 150))
|
||||
shadow.setOffset(0, 0)
|
||||
self.setGraphicsEffect(shadow)
|
||||
|
||||
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)
|
||||
# Отступы
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2)
|
||||
layout.setSpacing(5)
|
||||
|
||||
self.coverWidget = QWidget()
|
||||
coverLayout = QStackedLayout(self.coverWidget)
|
||||
# Контейнер обложки
|
||||
coverWidget = QWidget()
|
||||
coverWidget.setFixedSize(card_width, int(card_width * 1.2))
|
||||
coverLayout = QStackedLayout(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)
|
||||
|
||||
load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded)
|
||||
# создаём слабую ссылку на label
|
||||
label_ref = weakref.ref(self.coverLabel)
|
||||
|
||||
self.favoriteLabel = ClickableLabel(self.coverWidget)
|
||||
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.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)
|
||||
@@ -114,50 +143,67 @@ class GameCard(QFrame):
|
||||
self.protondbLabel = ClickableLabel(
|
||||
tier_text,
|
||||
icon=icon,
|
||||
parent=self.coverWidget,
|
||||
font_scale_factor=0.06
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor
|
||||
)
|
||||
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=self.coverWidget)
|
||||
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
|
||||
self.protondbLabel.setFixedWidth(badge_width)
|
||||
self.protondbLabel.setVisible(False)
|
||||
|
||||
# Steam бейдж
|
||||
steam_icon = self.theme_manager.get_icon("steam")
|
||||
self.steamLabel = ClickableLabel(
|
||||
"Steam",
|
||||
icon=steam_icon,
|
||||
parent=self.coverWidget,
|
||||
font_scale_factor=0.06
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor
|
||||
)
|
||||
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=self.coverWidget,
|
||||
font_scale_factor=0.06,
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor,
|
||||
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=self.coverWidget,
|
||||
font_scale_factor=0.06
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor
|
||||
)
|
||||
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)
|
||||
@@ -165,57 +211,40 @@ class GameCard(QFrame):
|
||||
self.anticheatLabel = ClickableLabel(
|
||||
anticheat_text,
|
||||
icon=icon,
|
||||
parent=self.coverWidget,
|
||||
font_scale_factor=0.06
|
||||
parent=coverWidget,
|
||||
icon_size=icon_size,
|
||||
icon_space=icon_space,
|
||||
font_scale_factor=font_scale_factor
|
||||
)
|
||||
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=self.coverWidget)
|
||||
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
|
||||
self.anticheatLabel.setFixedWidth(badge_width)
|
||||
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)
|
||||
|
||||
self.layout_.addWidget(self.coverWidget)
|
||||
layout.addWidget(coverWidget)
|
||||
|
||||
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)
|
||||
# Название игры
|
||||
nameLabel = QLabel(name)
|
||||
nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||
layout.addWidget(nameLabel)
|
||||
|
||||
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)
|
||||
def _position_badges(self, card_width):
|
||||
"""Позиционирует бейджи на основе ширины карточки."""
|
||||
right_margin = 8
|
||||
badge_spacing = int(card_width * 0.02) # 2% от ширины карточки
|
||||
top_y = 10
|
||||
badge_y_positions = []
|
||||
badge_width = int(current_width * 2/3)
|
||||
badge_width = int(card_width * 2/3)
|
||||
|
||||
badges = [
|
||||
(self.steam_visible, self.steamLabel),
|
||||
@@ -227,99 +256,80 @@ class GameCard(QFrame):
|
||||
|
||||
for is_visible, badge in badges:
|
||||
if is_visible:
|
||||
badge_x = current_width - badge_width - right_margin
|
||||
badge_x = card_width - badge_width - right_margin
|
||||
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
|
||||
badge.move(int(badge_x), int(badge_y))
|
||||
badge.move(badge_x, badge_y)
|
||||
badge_y_positions.append(badge_y + badge.height())
|
||||
|
||||
# Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
|
||||
self.anticheatLabel.raise_()
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
|
||||
def 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)
|
||||
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)
|
||||
|
||||
self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
|
||||
self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
|
||||
if self.coverLabel is None:
|
||||
return
|
||||
|
||||
self.update_cover_pixmap()
|
||||
coverWidget = self.coverLabel.parentWidget()
|
||||
if coverWidget is None:
|
||||
return
|
||||
|
||||
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))
|
||||
coverWidget.setFixedSize(new_width, int(new_width * 1.2))
|
||||
self.coverLabel.setFixedSize(new_width, int(new_width * 1.2))
|
||||
|
||||
badge_width = int(scaled_width * 2/3)
|
||||
icon_size = int(scaled_width * 0.06)
|
||||
icon_space = int(scaled_width * 0.012)
|
||||
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)
|
||||
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(scaled_width)
|
||||
label.setCardWidth(new_width) # Пересчитываем размер шрифта
|
||||
|
||||
self._position_badges(scaled_width)
|
||||
# Перепозиционируем бейджи
|
||||
self._position_badges(new_width)
|
||||
|
||||
if self.base_font_size is not None:
|
||||
font = self.nameLabel.font()
|
||||
new_font_size = self.base_font_size * self._scale
|
||||
if new_font_size > 0:
|
||||
font.setPointSizeF(new_font_size)
|
||||
self.nameLabel.setFont(font)
|
||||
|
||||
self.shadow.setBlurRadius(int(20 * self._scale))
|
||||
|
||||
self.updateGeometry()
|
||||
self.update()
|
||||
|
||||
# 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 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"))
|
||||
self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites"))
|
||||
self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites"))
|
||||
self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
|
||||
protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
|
||||
|
||||
# Обновляем видимость бейджей
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.egsLabel.setVisible(self.egs_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.protondbLabel.setVisible(protondb_visible)
|
||||
self.anticheatLabel.setVisible(anticheat_visible)
|
||||
|
||||
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()
|
||||
# Перепозиционируем бейджи
|
||||
self._position_badges(self.card_width)
|
||||
|
||||
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)
|
||||
|
||||
@@ -377,6 +387,7 @@ 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="):
|
||||
@@ -436,18 +447,8 @@ 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)
|
||||
@@ -486,7 +487,6 @@ 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(
|
||||
|
@@ -4,7 +4,7 @@ import os
|
||||
from typing import Protocol, cast
|
||||
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
|
||||
from pyudev import Context, Monitor, MonitorObserver, Device
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
|
||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox
|
||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from portprotonqt.logger import get_logger
|
||||
@@ -161,20 +161,7 @@ class InputManager(QObject):
|
||||
|
||||
def handle_file_explorer_button(self, button_code):
|
||||
try:
|
||||
popup = QApplication.activePopupWidget()
|
||||
if isinstance(popup, QMenu):
|
||||
if button_code in BUTTONS['confirm']: # A button (BTN_SOUTH)
|
||||
if popup.activeAction():
|
||||
popup.activeAction().trigger()
|
||||
popup.close()
|
||||
return
|
||||
elif button_code in BUTTONS['back']: # B button
|
||||
popup.close()
|
||||
return
|
||||
return # Skip other handling if menu is open
|
||||
|
||||
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
|
||||
logger.debug("No file explorer or file_list available")
|
||||
return
|
||||
|
||||
focused_widget = QApplication.focusWidget()
|
||||
@@ -182,37 +169,27 @@ class InputManager(QObject):
|
||||
if isinstance(focused_widget, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused_widget in self.file_explorer.drive_buttons:
|
||||
self.file_explorer.select_drive() # Select the focused drive
|
||||
elif self.file_explorer.file_list.count() == 0:
|
||||
logger.debug("File list is empty")
|
||||
return
|
||||
else:
|
||||
selected = self.file_explorer.file_list.currentItem().text()
|
||||
full_path = os.path.join(self.file_explorer.current_path, selected)
|
||||
if os.path.isdir(full_path):
|
||||
# Открываем директорию
|
||||
self.file_explorer.current_path = os.path.normpath(full_path)
|
||||
self.file_explorer.update_file_list()
|
||||
elif not self.file_explorer.directory_only:
|
||||
# Выбираем файл, если directory_only=False
|
||||
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.file_explorer.accept()
|
||||
else:
|
||||
logger.debug("Selected item is not a directory, cannot select: %s", full_path)
|
||||
elif button_code in BUTTONS['context_menu']: # Start button (BTN_START)
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
logger.debug("File list is empty, cannot show context menu")
|
||||
return
|
||||
current_item = self.file_explorer.file_list.currentItem()
|
||||
if current_item:
|
||||
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
|
||||
pos = item_rect.center() # Use local coordinates for itemAt check
|
||||
self.file_explorer.show_folder_context_menu(pos)
|
||||
else:
|
||||
logger.debug("No item selected for context menu")
|
||||
elif button_code in BUTTONS['add_game']: # X button
|
||||
if self.file_explorer.file_list.count() == 0:
|
||||
logger.debug("File list is empty")
|
||||
return
|
||||
selected = self.file_explorer.file_list.currentItem().text()
|
||||
full_path = os.path.join(self.file_explorer.current_path, selected)
|
||||
if os.path.isdir(full_path):
|
||||
# Подтверждаем выбор директории
|
||||
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.file_explorer.accept()
|
||||
else:
|
||||
@@ -225,29 +202,12 @@ class InputManager(QObject):
|
||||
if self.original_button_handler:
|
||||
self.original_button_handler(button_code)
|
||||
except Exception as e:
|
||||
logger.error("Error in FileExplorer button handler: %s", e)
|
||||
logger.error(f"Error in FileExplorer button handler: {e}")
|
||||
|
||||
def handle_file_explorer_dpad(self, code, value, current_time):
|
||||
"""Обработка движения D-pad и левого стика для FileExplorer"""
|
||||
try:
|
||||
popup = QApplication.activePopupWidget()
|
||||
if isinstance(popup, QMenu):
|
||||
if code == ecodes.ABS_HAT0Y and value != 0:
|
||||
actions = popup.actions()
|
||||
if not actions:
|
||||
return
|
||||
current_action = popup.activeAction()
|
||||
current_idx = actions.index(current_action) if current_action in actions else -1
|
||||
if value > 0: # Down
|
||||
next_idx = (current_idx + 1) % len(actions) if current_idx != -1 else 0
|
||||
popup.setActiveAction(actions[next_idx])
|
||||
elif value < 0: # Up
|
||||
next_idx = (current_idx - 1) % len(actions) if current_idx != -1 else len(actions) - 1
|
||||
popup.setActiveAction(actions[next_idx])
|
||||
return # Skip other handling if menu is open
|
||||
|
||||
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
|
||||
logger.debug("No file explorer or file_list available")
|
||||
return
|
||||
|
||||
focused_widget = QApplication.focusWidget()
|
||||
@@ -256,17 +216,14 @@ class InputManager(QObject):
|
||||
if not isinstance(focused_widget, AutoSizeButton) or focused_widget not in self.file_explorer.drive_buttons:
|
||||
# If not focused on a drive button, focus the first one
|
||||
self.file_explorer.drive_buttons[0].setFocus()
|
||||
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
|
||||
return
|
||||
current_idx = self.file_explorer.drive_buttons.index(focused_widget)
|
||||
if value < 0: # Left
|
||||
next_idx = max(current_idx - 1, 0)
|
||||
self.file_explorer.drive_buttons[next_idx].setFocus()
|
||||
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
|
||||
elif value > 0: # Right
|
||||
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
|
||||
self.file_explorer.drive_buttons[next_idx].setFocus()
|
||||
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
|
||||
elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
|
||||
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
|
||||
# Move focus to file list if navigating down from drive buttons
|
||||
@@ -307,7 +264,7 @@ class InputManager(QObject):
|
||||
elif self.original_dpad_handler:
|
||||
self.original_dpad_handler(code, value, current_time)
|
||||
except Exception as e:
|
||||
logger.error("Error in FileExplorer dpad handler: %s", e)
|
||||
logger.error(f"Error in FileExplorer dpad handler: {e}")
|
||||
|
||||
def handle_navigation_repeat(self):
|
||||
"""Плавное повторение движения с переменной скоростью для FileExplorer"""
|
||||
@@ -673,107 +630,87 @@ class InputManager(QObject):
|
||||
scroll_area.ensureWidgetVisible(game_cards[0], 50, 50)
|
||||
return
|
||||
|
||||
cards = self._parent.gamesListWidget.findChildren(GameCard, options=Qt.FindChildOption.FindChildrenRecursively)
|
||||
if not cards:
|
||||
return
|
||||
# Group cards by rows with tolerance for y-position
|
||||
# Group cards by rows based on y-coordinate
|
||||
rows = {}
|
||||
y_tolerance = 10 # Allow slight variations in y-position
|
||||
for card in cards:
|
||||
for card in game_cards:
|
||||
y = card.pos().y()
|
||||
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]
|
||||
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
|
||||
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]
|
||||
if code == ecodes.ABS_HAT0X and value != 0:
|
||||
current_col_idx = current_row.index(focused)
|
||||
|
||||
if code == ecodes.ABS_HAT0X and value != 0: # Left/Right
|
||||
if value < 0: # Left
|
||||
if current_col_idx > 0:
|
||||
next_card = current_row[current_col_idx - 1]
|
||||
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
next_col_idx = current_col_idx - 1
|
||||
if next_col_idx >= 0:
|
||||
next_card = current_row[next_col_idx]
|
||||
next_card.setFocus()
|
||||
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(Qt.FocusReason.OtherFocusReason)
|
||||
next_card.setFocus()
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
||||
elif value > 0: # Right
|
||||
if current_col_idx < len(current_row) - 1:
|
||||
next_card = current_row[current_col_idx + 1]
|
||||
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
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 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(Qt.FocusReason.OtherFocusReason)
|
||||
next_card.setFocus()
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
||||
elif code == ecodes.ABS_HAT0Y and value != 0:
|
||||
elif code == ecodes.ABS_HAT0Y and value != 0: # Up/Down
|
||||
if value > 0: # Down
|
||||
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_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()
|
||||
next_card = min(
|
||||
next_row,
|
||||
key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
|
||||
key=lambda c: abs(c.pos().x() - target_x),
|
||||
default=None
|
||||
)
|
||||
if next_card:
|
||||
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
next_card.setFocus()
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
||||
elif value < 0: # Up
|
||||
if current_row_idx > 0:
|
||||
prev_row = sorted_rows[current_row_idx - 1][1]
|
||||
current_x = focused.pos().x() + focused.width() / 2
|
||||
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()
|
||||
next_card = min(
|
||||
prev_row,
|
||||
key=lambda c: abs((c.pos().x() + c.width() / 2) - current_x),
|
||||
next_row,
|
||||
key=lambda c: abs(c.pos().x() - target_x),
|
||||
default=None
|
||||
)
|
||||
if next_card:
|
||||
next_card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
next_card.setFocus()
|
||||
if scroll_area:
|
||||
scroll_area.ensureWidgetVisible(next_card, 50, 50)
|
||||
elif current_row_idx == 0:
|
||||
@@ -805,11 +742,6 @@ class InputManager(QObject):
|
||||
if not app:
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
# Ensure obj is a QObject
|
||||
if not isinstance(obj, QObject):
|
||||
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
|
||||
return False
|
||||
|
||||
# Handle key press and release events
|
||||
if not isinstance(event, QKeyEvent):
|
||||
return super().eventFilter(obj, event)
|
||||
@@ -822,54 +754,6 @@ class InputManager(QObject):
|
||||
|
||||
# Handle key press events
|
||||
if event.type() == QEvent.Type.KeyPress:
|
||||
# Handle FileExplorer specific logic
|
||||
if self.file_explorer:
|
||||
# Handle drive buttons in FileExplorer
|
||||
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
if isinstance(focused, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused in self.file_explorer.drive_buttons:
|
||||
self.file_explorer.select_drive()
|
||||
return True
|
||||
elif isinstance(focused, QListWidget) and focused == self.file_explorer.file_list:
|
||||
current_item = focused.currentItem()
|
||||
if current_item:
|
||||
selected = current_item.text()
|
||||
full_path = os.path.join(self.file_explorer.current_path, selected)
|
||||
if os.path.isdir(full_path):
|
||||
if selected == "../":
|
||||
self.file_explorer.previous_dir()
|
||||
else:
|
||||
self.file_explorer.current_path = os.path.normpath(full_path)
|
||||
self.file_explorer.update_file_list()
|
||||
elif not self.file_explorer.directory_only:
|
||||
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.file_explorer.accept()
|
||||
return True
|
||||
else:
|
||||
self._parent.activateFocusedWidget()
|
||||
return True
|
||||
|
||||
# Handle FileExplorer navigation with right arrow key
|
||||
if key == Qt.Key.Key_Right:
|
||||
try:
|
||||
if hasattr(self.file_explorer, 'drive_buttons') and self.file_explorer.drive_buttons:
|
||||
if not isinstance(focused, AutoSizeButton) or focused not in self.file_explorer.drive_buttons:
|
||||
self.file_explorer.drive_buttons[0].setFocus()
|
||||
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
|
||||
else:
|
||||
current_idx = self.file_explorer.drive_buttons.index(focused)
|
||||
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
|
||||
self.file_explorer.drive_buttons[next_idx].setFocus()
|
||||
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling right arrow in FileExplorer: {e}")
|
||||
return True
|
||||
|
||||
# Handle Backspace for FileExplorer navigation
|
||||
if key == Qt.Key.Key_Backspace:
|
||||
self.file_explorer.previous_dir()
|
||||
return True
|
||||
|
||||
# Handle QLineEdit cursor movement with Left/Right arrows
|
||||
if isinstance(focused, QLineEdit) and key in (Qt.Key.Key_Left, Qt.Key.Key_Right):
|
||||
if key == Qt.Key.Key_Left:
|
||||
@@ -894,13 +778,10 @@ class InputManager(QObject):
|
||||
self.file_explorer.previous_dir()
|
||||
return True
|
||||
|
||||
# Close Dialogs with Escape
|
||||
if key == Qt.Key.Key_Escape:
|
||||
if isinstance(focused, QLineEdit):
|
||||
return False
|
||||
if isinstance(active_win, QDialog):
|
||||
active_win.reject()
|
||||
return True
|
||||
# Close AddGameDialog with Escape
|
||||
if key == Qt.Key.Key_Escape and isinstance(popup, QDialog):
|
||||
popup.reject()
|
||||
return True
|
||||
|
||||
# FullscreenDialog navigation
|
||||
if isinstance(active_win, FullscreenDialog):
|
||||
@@ -916,7 +797,7 @@ class InputManager(QObject):
|
||||
return True # Consume event to prevent tab switching
|
||||
|
||||
# Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit
|
||||
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and not isinstance(focused, GameCard | QLineEdit) and not self.file_explorer:
|
||||
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and (not isinstance(focused, GameCard | QLineEdit) or focused is None):
|
||||
idx = self._parent.stackedWidget.currentIndex()
|
||||
total = len(self._parent.tabButtons)
|
||||
if key == Qt.Key.Key_Left:
|
||||
|
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -26,12 +26,6 @@ msgstr ""
|
||||
msgid "PortProton is not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
@@ -41,6 +35,12 @@ msgstr ""
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import to Legendary"
|
||||
msgstr ""
|
||||
|
||||
@@ -248,19 +248,15 @@ 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 ""
|
||||
|
||||
@@ -655,24 +651,3 @@ 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 ""
|
||||
|
||||
|
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -26,12 +26,6 @@ msgstr ""
|
||||
msgid "PortProton is not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
@@ -41,6 +35,12 @@ msgstr ""
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import to Legendary"
|
||||
msgstr ""
|
||||
|
||||
@@ -248,19 +248,15 @@ 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 ""
|
||||
|
||||
@@ -655,24 +651,3 @@ 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 ""
|
||||
|
||||
|
@@ -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-31 12:28+0500\n"
|
||||
"POT-Creation-Date: 2025-08-23 20:35+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,12 +24,6 @@ msgstr ""
|
||||
msgid "PortProton is not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr ""
|
||||
|
||||
@@ -39,6 +33,12 @@ msgstr ""
|
||||
msgid "Launch Game"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Add to Favorites"
|
||||
msgstr ""
|
||||
|
||||
msgid "Import to Legendary"
|
||||
msgstr ""
|
||||
|
||||
@@ -246,19 +246,15 @@ 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 ""
|
||||
|
||||
@@ -653,24 +649,3 @@ 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 ""
|
||||
|
||||
|
Binary file not shown.
@@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"PO-Revision-Date: 2025-08-31 12:28+0500\n"
|
||||
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
|
||||
"PO-Revision-Date: 2025-08-23 20:35+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
@@ -27,12 +27,6 @@ msgstr "Ошибка"
|
||||
msgid "PortProton is not found"
|
||||
msgstr "PortProton не найден"
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr "Удалить из Избранного"
|
||||
|
||||
msgid "Add to Favorites"
|
||||
msgstr "Добавить в Избранное"
|
||||
|
||||
msgid "Delete from PortProton"
|
||||
msgstr "Удалить из PortProton"
|
||||
|
||||
@@ -42,6 +36,12 @@ msgstr "Остановить игру"
|
||||
msgid "Launch Game"
|
||||
msgstr "Запустить игру"
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr "Удалить из Избранного"
|
||||
|
||||
msgid "Add to Favorites"
|
||||
msgstr "Добавить в Избранное"
|
||||
|
||||
msgid "Import to Legendary"
|
||||
msgstr "Импортировать игру"
|
||||
|
||||
@@ -255,19 +255,15 @@ 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 "Путь: "
|
||||
|
||||
@@ -664,24 +660,3 @@ 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 "Нет недавних игр"
|
||||
|
||||
|
@@ -34,7 +34,6 @@ 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)
|
||||
@@ -53,11 +52,10 @@ 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, app_name: str):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Создаём менеджер тем и читаем, какая тема выбрана
|
||||
self.theme_manager = ThemeManager()
|
||||
self.is_exiting = False
|
||||
selected_theme = read_theme_from_config()
|
||||
self.current_theme_name = selected_theme
|
||||
try:
|
||||
@@ -69,9 +67,8 @@ 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(app_name)
|
||||
self.setWindowTitle("PortProtonQt")
|
||||
self.setMinimumSize(800, 600)
|
||||
|
||||
self.games = []
|
||||
@@ -1528,7 +1525,7 @@ class MainWindow(QMainWindow):
|
||||
detailPage = QWidget()
|
||||
self._animations = {}
|
||||
imageLabel = QLabel()
|
||||
imageLabel.setFixedSize(300, 450)
|
||||
imageLabel.setFixedSize(300, 400)
|
||||
self._detail_page_active = True
|
||||
self._current_detail_page = detailPage
|
||||
|
||||
@@ -1562,7 +1559,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, 450, on_pixmap_ready)
|
||||
load_pixmap_async(cover_path, 300, 400, on_pixmap_ready)
|
||||
else:
|
||||
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||
detailPage.update()
|
||||
@@ -1590,7 +1587,7 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Обложка (слева)
|
||||
coverFrame = QFrame()
|
||||
coverFrame.setFixedSize(300, 450)
|
||||
coverFrame.setFixedSize(300, 400)
|
||||
coverFrame.setStyleSheet(self.theme.COVER_FRAME_STYLE)
|
||||
shadow = QGraphicsDropShadowEffect(coverFrame)
|
||||
shadow.setBlurRadius(20)
|
||||
@@ -2269,51 +2266,46 @@ class MainWindow(QMainWindow):
|
||||
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
|
||||
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}")
|
||||
"""Завершает все дочерние процессы и сохраняет настройки при закрытии окна."""
|
||||
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 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)
|
||||
|
||||
# Сохраняем настройки окна
|
||||
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
|
||||
|
||||
event.accept()
|
||||
else:
|
||||
# Сворачиваем в трей вместо закрытия
|
||||
self.hide()
|
||||
event.ignore()
|
||||
QApplication.quit()
|
||||
event.accept()
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -29,104 +29,69 @@ 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,
|
||||
|
||||
# Тип анимации для карточки при наведении или фокусе
|
||||
# Возможные значения: "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")
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки.
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе)
|
||||
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока")
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
"scale_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
|
||||
# Влияет на "чувство" возврата к исходному масштабу
|
||||
"scale_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex
|
||||
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
|
||||
# Цвета градиента для анимированной обводки.
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
|
||||
# Влияет на внешний вид обводки при наведении или фокусе.
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
@@ -135,43 +100,29 @@ 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 анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
# Применяется к slide и bounce анимациям
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Тип кривой сглаживания для анимации при выходе из детальной страницы
|
||||
# Применяется к slide и bounce анимациям, влияет на "чувство" движения
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type
|
||||
# Применяется к slide и bounce анимациям
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
|
||||
|
@@ -1,260 +0,0 @@
|
||||
import sys
|
||||
import subprocess
|
||||
import shlex
|
||||
import signal
|
||||
import psutil
|
||||
import os
|
||||
from PySide6.QtWidgets import QSystemTrayIcon, QMenu, QApplication, QMessageBox
|
||||
from PySide6.QtGui import QIcon, QAction
|
||||
from PySide6.QtCore import QTimer
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config
|
||||
from portprotonqt.dialogs import GameLaunchDialog
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class TrayManager:
|
||||
"""Модуль управления системным треем для PortProtonQt.
|
||||
|
||||
Обеспечивает:
|
||||
- Показ/скрытие главного окна по двойному клику на иконку трея.
|
||||
- Контекстное меню с опциями: Show/Hide, Favorites, Recent Games, Themes, Exit.
|
||||
- Динамическое заполнение меню Favorites, Recent Games и Themes.
|
||||
- Сворачивание в трей при закрытии окна, полное закрытие через Exit.
|
||||
"""
|
||||
|
||||
def __init__(self, main_window, app_name: str | None = None, theme=None):
|
||||
self.app_name = app_name if app_name is not None else "PortProtonQt"
|
||||
self.theme_manager = ThemeManager()
|
||||
selected_theme = read_theme_from_config()
|
||||
self.current_theme_name = selected_theme
|
||||
try:
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
|
||||
self.theme = self.theme_manager.apply_theme("standart")
|
||||
self.current_theme_name = "standart"
|
||||
save_theme_to_config("standart")
|
||||
if not self.theme:
|
||||
self.theme = default_styles
|
||||
self.main_window = main_window
|
||||
self.tray_icon = QSystemTrayIcon(self.main_window)
|
||||
|
||||
icon = self.theme_manager.get_icon("portproton", self.current_theme_name)
|
||||
if isinstance(icon, str):
|
||||
icon = QIcon(icon)
|
||||
elif icon is None:
|
||||
icon = QIcon()
|
||||
self.tray_icon.setIcon(icon)
|
||||
|
||||
self.tray_icon.activated.connect(self.handle_tray_click)
|
||||
self.tray_icon.setToolTip(self.app_name)
|
||||
|
||||
self.tray_menu = QMenu()
|
||||
self.toggle_action = QAction(_("Show"), self.main_window)
|
||||
self.toggle_action.triggered.connect(self.toggle_window_action)
|
||||
|
||||
self.favorites_menu = QMenu(_("Favorites"))
|
||||
self.favorites_menu.aboutToShow.connect(self.populate_favorites_menu)
|
||||
|
||||
self.recent_menu = QMenu(_("Recent Games"))
|
||||
self.recent_menu.aboutToShow.connect(self.populate_recent_menu)
|
||||
|
||||
self.themes_menu = QMenu(_("Themes"))
|
||||
self.themes_menu.aboutToShow.connect(self.populate_themes_menu)
|
||||
|
||||
self.tray_menu.addAction(self.toggle_action)
|
||||
self.tray_menu.addSeparator()
|
||||
self.tray_menu.addMenu(self.favorites_menu)
|
||||
self.tray_menu.addMenu(self.recent_menu)
|
||||
self.tray_menu.addMenu(self.themes_menu)
|
||||
self.tray_menu.addSeparator()
|
||||
exit_action = QAction(_("Exit"), self.main_window)
|
||||
exit_action.triggered.connect(self.force_exit)
|
||||
self.tray_menu.addAction(exit_action)
|
||||
|
||||
self.tray_menu.aboutToShow.connect(self.update_toggle_action)
|
||||
|
||||
self.tray_icon.setContextMenu(self.tray_menu)
|
||||
self.tray_icon.show()
|
||||
|
||||
self.main_window.is_exiting = False
|
||||
|
||||
self.click_count = 0
|
||||
self.click_timer = QTimer()
|
||||
self.click_timer.setSingleShot(True)
|
||||
self.click_timer.timeout.connect(self.reset_click_count)
|
||||
|
||||
self.launch_dialog = None
|
||||
|
||||
def update_toggle_action(self):
|
||||
if self.main_window.isVisible():
|
||||
self.toggle_action.setText(_("Hide"))
|
||||
else:
|
||||
self.toggle_action.setText(_("Show"))
|
||||
|
||||
def handle_tray_click(self, reason):
|
||||
if reason == QSystemTrayIcon.ActivationReason.Trigger:
|
||||
self.click_count += 1
|
||||
if self.click_count == 1:
|
||||
self.click_timer.start(300)
|
||||
elif self.click_count == 2:
|
||||
self.click_timer.stop()
|
||||
self.toggle_window_action()
|
||||
self.click_count = 0
|
||||
|
||||
def reset_click_count(self):
|
||||
self.click_count = 0
|
||||
|
||||
def toggle_window_action(self):
|
||||
if self.main_window.isVisible():
|
||||
self.main_window.hide()
|
||||
else:
|
||||
self.main_window.show()
|
||||
self.main_window.raise_()
|
||||
self.main_window.activateWindow()
|
||||
|
||||
def populate_favorites_menu(self):
|
||||
self.favorites_menu.clear()
|
||||
favorites = read_favorites()
|
||||
if not favorites:
|
||||
no_fav_action = QAction(_("No favorites"), self.main_window)
|
||||
no_fav_action.setEnabled(False)
|
||||
self.favorites_menu.addAction(no_fav_action)
|
||||
return
|
||||
|
||||
game_map = {game[0]: (game[4], game[12]) for game in self.main_window.games}
|
||||
|
||||
for fav in sorted(favorites):
|
||||
game_data = game_map.get(fav)
|
||||
if game_data:
|
||||
exec_line, source = game_data
|
||||
action_text = f"{fav} ({source})"
|
||||
action = QAction(action_text, self.main_window)
|
||||
action.triggered.connect(lambda checked=False, el=exec_line, name=fav: self.launch_game_with_dialog(el, name))
|
||||
self.favorites_menu.addAction(action)
|
||||
else:
|
||||
logger.warning(f"Exec line not found for favorite: {fav}")
|
||||
|
||||
def populate_recent_menu(self):
|
||||
self.recent_menu.clear()
|
||||
if not self.main_window.games:
|
||||
no_recent_action = QAction(_("No recent games"), self.main_window)
|
||||
no_recent_action.setEnabled(False)
|
||||
self.recent_menu.addAction(no_recent_action)
|
||||
return
|
||||
|
||||
recent_games = sorted(self.main_window.games, key=lambda g: g[10], reverse=True)[:5]
|
||||
|
||||
for game in recent_games:
|
||||
game_name = game[0]
|
||||
exec_line = game[4]
|
||||
source = game[12]
|
||||
action_text = f"{game_name} ({source})"
|
||||
action = QAction(action_text, self.main_window)
|
||||
action.triggered.connect(lambda checked=False, el=exec_line, name=game_name: self.launch_game_with_dialog(el, name))
|
||||
self.recent_menu.addAction(action)
|
||||
|
||||
def launch_game_with_dialog(self, exec_line, game_name):
|
||||
"""Launch a game with a modal dialog indicating progress."""
|
||||
try:
|
||||
# Determine target executable
|
||||
target_exe = None
|
||||
if exec_line.startswith("steam://"):
|
||||
# Steam games are handled differently, no target_exe needed
|
||||
self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme)
|
||||
else:
|
||||
# Extract target executable from exec_line
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if entry_exec_split[0] == "env" and len(entry_exec_split) > 2:
|
||||
file_to_check = entry_exec_split[2]
|
||||
elif entry_exec_split[0] == "flatpak" and len(entry_exec_split) > 3:
|
||||
file_to_check = entry_exec_split[3]
|
||||
else:
|
||||
file_to_check = entry_exec_split[0]
|
||||
|
||||
if not os.path.exists(file_to_check):
|
||||
logger.error(f"File not found: {file_to_check}")
|
||||
QMessageBox.warning(self.main_window, _("Error"), _("File not found: {0}").format(file_to_check))
|
||||
return
|
||||
|
||||
target_exe = os.path.basename(file_to_check)
|
||||
self.launch_dialog = GameLaunchDialog(self.main_window, game_name=game_name, theme=self.theme, target_exe=target_exe)
|
||||
|
||||
self.launch_dialog.rejected.connect(lambda: self.cancel_game_launch(exec_line))
|
||||
self.launch_dialog.show()
|
||||
|
||||
self.main_window.toggleGame(exec_line)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to launch game {game_name}: {e}")
|
||||
if self.launch_dialog:
|
||||
self.launch_dialog.reject()
|
||||
self.launch_dialog = None
|
||||
QMessageBox.warning(self.main_window, _("Error"), _("Failed to launch game: {0}").format(str(e)))
|
||||
|
||||
def cancel_game_launch(self, exec_line):
|
||||
"""Cancel the game launch and terminate the process, using MainWindow's stop logic."""
|
||||
if self.main_window.game_processes and self.main_window.target_exe:
|
||||
for proc in self.main_window.game_processes:
|
||||
try:
|
||||
parent = psutil.Process(proc.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
child.terminate()
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
psutil.wait_procs(children, timeout=5)
|
||||
for child in children:
|
||||
if child.is_running():
|
||||
child.kill()
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
self.main_window.game_processes = []
|
||||
self.main_window.resetPlayButton()
|
||||
if self.launch_dialog:
|
||||
self.launch_dialog.reject()
|
||||
self.launch_dialog = None
|
||||
logger.info(f"Game launch cancelled for exec line: {exec_line}")
|
||||
|
||||
def populate_themes_menu(self):
|
||||
self.themes_menu.clear()
|
||||
available_themes = self.theme_manager.get_available_themes()
|
||||
|
||||
for theme_name in sorted(available_themes):
|
||||
action = QAction(theme_name, self.main_window)
|
||||
action.setCheckable(True)
|
||||
action.setChecked(theme_name == self.current_theme_name)
|
||||
action.triggered.connect(lambda checked=False, tn=theme_name: self.switch_theme(tn))
|
||||
self.themes_menu.addAction(action)
|
||||
|
||||
def switch_theme(self, theme_name: str):
|
||||
try:
|
||||
save_theme_to_config(theme_name)
|
||||
logger.info(f"Saved theme {theme_name}, restarting application to apply changes")
|
||||
|
||||
executable = sys.executable
|
||||
args = sys.argv
|
||||
|
||||
self.main_window.is_exiting = True
|
||||
QApplication.quit()
|
||||
|
||||
subprocess.Popen([executable] + args)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to switch theme to {theme_name}: {e}")
|
||||
save_theme_to_config("standart")
|
||||
executable = sys.executable
|
||||
args = sys.argv
|
||||
self.main_window.is_exiting = True
|
||||
QApplication.quit()
|
||||
subprocess.Popen([executable] + args)
|
||||
|
||||
def force_exit(self):
|
||||
self.main_window.is_exiting = True
|
||||
self.main_window.close()
|
||||
sys.exit(0)
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portprotonqt"
|
||||
version = "0.1.5"
|
||||
version = "0.1.4"
|
||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0" }
|
||||
@@ -27,7 +27,7 @@ classifiers = [
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"babel>=2.17.0",
|
||||
"beautifulsoup4>=4.13.5",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
"evdev>=1.9.2",
|
||||
"icoextract>=0.2.0",
|
||||
"numpy>=2.2.4",
|
||||
@@ -36,7 +36,7 @@ dependencies = [
|
||||
"psutil>=7.0.0",
|
||||
"pyside6>=6.9.1",
|
||||
"pyudev>=0.24.3",
|
||||
"requests>=2.32.5",
|
||||
"requests>=2.32.4",
|
||||
"tqdm>=4.67.1",
|
||||
"vdf>=3.4",
|
||||
"websocket-client>=1.8.0",
|
||||
@@ -105,5 +105,5 @@ ignore = [
|
||||
dev = [
|
||||
"pre-commit>=4.3.0",
|
||||
"pyaspeller>=2.0.2",
|
||||
"pyright>=1.1.404",
|
||||
"pyright>=1.1.403",
|
||||
]
|
||||
|
@@ -19,7 +19,7 @@
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions", "pre-commit", "poetry"],
|
||||
"matchManagers": ["github-actions", "pre-commit"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
|
Reference in New Issue
Block a user