1 Commits

Author SHA1 Message Date
77d4287f12 chore(ci): replace uv github action to manual install
Some checks failed
Code check / Check code (pull_request) Failing after 11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 21:01:56 +05:00
80 changed files with 2577 additions and 15457 deletions

View File

@@ -12,7 +12,7 @@ jobs:
name: Build AppImage
runs-on: ubuntu-22.04
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@v4
- name: Install required dependencies
run: |
@@ -42,7 +42,7 @@ jobs:
strategy:
matrix:
fedora_version: [41, 42, 43, rawhide]
fedora_version: [41, 42, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
@@ -63,7 +63,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@v4
- name: Copy fedora.spec
run: |
@@ -84,7 +84,7 @@ jobs:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
image: archlinux:base-devel
volumes:
- /usr:/usr-host
- /opt:/opt-host
@@ -124,7 +124,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@v4
- name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -8,7 +8,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 0.1.6
VERSION: 0.1.4
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -99,7 +99,7 @@ jobs:
strategy:
matrix:
fedora_version: [41, 42, 43, rawhide]
fedora_version: [41, 42, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}

View File

@@ -15,10 +15,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@v4
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: https://gitea.com/actions/setup-python@v5
with:
python-version-file: "pyproject.toml"

View File

@@ -18,7 +18,7 @@ jobs:
fedora: ${{ steps.check.outputs.fedora }}
arch: ${{ steps.check.outputs.arch }}
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@v4
with:
fetch-depth: 0
@@ -63,7 +63,7 @@ jobs:
needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@v4
- name: Install required dependencies
run: |
@@ -115,7 +115,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@v4
- name: Copy fedora-git.spec
run: |
@@ -138,7 +138,7 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
image: archlinux:base-devel
volumes:
- /usr:/usr-host
- /opt:/opt-host
@@ -178,7 +178,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@v4
- name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -20,17 +20,17 @@ jobs:
name: Check code
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@v4
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://gitea.com/actions/setup-node@v4
with:
node-version: 20
- name: Install uv manually
run: |
curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env
echo "$HOME/.cargo/bin" >> $GITHUB_PATH
uv --version
- name: Sync dependencies into venv

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@v4
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: https://gitea.com/actions/setup-python@v5
with:
python-version-file: "pyproject.toml"

View File

@@ -8,20 +8,19 @@ on:
jobs:
renovate:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:46b57bb9816dec6409e7be57e0e5f7b26d214281044f5aedd3b160be178475e2
container: ghcr.io/renovatebot/renovate:latest
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@v4
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://gitea.com/actions/setup-node@v4
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: |
@@ -35,4 +34,3 @@ jobs:
RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
LOG_LEVEL: "debug"
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}

View File

@@ -3,44 +3,12 @@
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [0.1.6] - 2025-09-23
### Added
- Кэширование шрифтов в load_theme_fonts для предотвращения повторной загрузки
- Проверка безопасности в theme_manager.py для всех сторонних тем, с проверкой на запрещённые модули и функции (подробности см. в коде theme_manager под полями FORBIDDEN_MODULES и FORBIDDEN_FUNCTIONS)
- Фильтрация ASRock LED контроллера, чтобы предотвратить его обнаружение как геймпада
- Подсказки по управлению в интерфейсе
- Поддержка боковой кнопки мыши, которая теперь работает как кнопка "назад"
- Аргумент cli --debug-level для указания уровня дебага
### Changed
- Управления с геймпада теперь перехватывается только если окно в фокусе
### Fixed
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
### Contributors
---
## [0.1.5] - 2025-08-31
## [Unreleased]
### Added
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
- Анимация при закрытии карточки игры (подробности см. в документации).
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
- Система быстрого доступа (избранного) в диалоге выбора файлов.
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
- Пункт "Выход" в трей.
- Пункт "Темы" в трей для быстрого переключения тем.
- Двойной клик по иконке трея для показа/скрытия главного окна.
- Запуск через трей показывает модальное окно для слежки за процессом запуска
### Changed
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
@@ -50,9 +18,7 @@
- Временно удалена светлая тема.
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
- Обновлены все зависимости (затрагивает только AppImage).
- Приложение теперь не закрывается полностью, а сворачивается в трей.
- Карточки теперь все находятся друг под другом, а не в разнабой
- Изменено соотношение сторон карточек
- Удалён отдельный трей, так как у PortProton есть собственный.
### Fixed
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
@@ -61,10 +27,7 @@
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
- Заголовок окна диалога выбора файлов теперь можно перевести.
- Трей теперь можно перевести.
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
- Переведен заголовок окна диалога выбора файлов.
### Contributors
- @Alex Smith

View File

@@ -1,5 +1,5 @@
<div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
<img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
</div>
@@ -54,7 +54,6 @@ PortProtonQt использует код и зависимости от след
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/)
Полный текст лицензий см. в файле [LICENSE](LICENSE).

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,19 @@
_portprotonqt_completions() {
local cur prev opts
COMPREPLY=()
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
_portprotonqt() {
local cur prev
_init_completion || return
# Available options
opts="--fullscreen --debug-level --help -h"
# Debug level choices
debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
case "${prev}" in
--debug-level)
# Complete debug levels
COMPREPLY=( $(compgen -W "${debug_levels}" -- ${cur}) )
return 0
;;
*)
case $prev in
--help|-h)
return
;;
esac
# Complete options
if [[ ${cur} == -* ]]; then
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
if [[ "$cur" == -* ]]; then
COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
return 0
fi
return 0
}
complete -F _portprotonqt_completions portprotonqt
complete -F _portprotonqt portprotonqt

View File

@@ -1777,7 +1777,7 @@
},
{
"normalized_name": "supervive",
"status": "Running"
"status": "Denied"
},
{
"normalized_name": "splitgate 2",
@@ -4472,7 +4472,7 @@
"status": "Running"
},
{
"normalized_name": "battlefield 6",
"normalized_name": "f1 25",
"status": "Denied"
},
{
@@ -4482,65 +4482,5 @@
{
"normalized_name": "sword of justice",
"status": "Broken"
},
{
"normalized_name": "blade & soul neo",
"status": "Broken"
},
{
"normalized_name": "the finals (cn)",
"status": "Broken"
},
{
"normalized_name": "tom clancy's rainbow six siege x",
"status": "Denied"
},
{
"normalized_name": "dragonheir silent gods",
"status": "Broken"
},
{
"normalized_name": "the quinfall",
"status": "Running"
},
{
"normalized_name": "redmatch 2",
"status": "Broken"
},
{
"normalized_name": "blade & soul heroes",
"status": "Broken"
},
{
"normalized_name": "blue archive",
"status": "Running"
},
{
"normalized_name": "midnight murder club",
"status": "Broken"
},
{
"normalized_name": "dungeon done",
"status": "Broken"
},
{
"normalized_name": "project wraith",
"status": "Broken"
},
{
"normalized_name": "solo leveling arise",
"status": "Broken"
},
{
"normalized_name": "freedom wars",
"status": "Running"
},
{
"normalized_name": "open fortress",
"status": "Running"
},
{
"normalized_name": "no more room in hell 2",
"status": "Running"
}
]

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,48 +1,4 @@
[
{
"normalized_title": "astroneer",
"slug": "astroneer"
},
{
"normalized_title": "anno 2205",
"slug": "anno-2205"
},
{
"normalized_title": "anno 2070",
"slug": "anno-2070"
},
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{
"normalized_title": "ultrakill (early access)",
"slug": "ultrakill-early-access"
},
{
"normalized_title": "vintage story",
"slug": "vintage-story"
},
{
"normalized_title": "disco elysium the finul cut",
"slug": "disco-elysium-the-finul-cut"
},
{
"normalized_title": "warcraft iii reign of chaos",
"slug": "warcraft-iii-reign-of-chaos"
},
{
"normalized_title": "dying light",
"slug": "dying-light"
},
{
"normalized_title": "лихо одноглазое",
"slug": "liho-odnoglazoe"
},
{
"normalized_title": "indika",
"slug": "indika"
},
{
"normalized_title": "no sleep for kaname date from ai the somnium files",
"slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
@@ -279,6 +235,10 @@
"normalized_title": "cardlife creative survival",
"slug": "cardlife-creative-survival"
},
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{
"normalized_title": "kompas 3d v24 / компас 3d v24 beta",
"slug": "kompas-3d-v24-kompas-3d-v24-beta"

Binary file not shown.

View File

@@ -17,6 +17,4 @@ Generated-By:
start.sh
EGS
Stop Game
Fullscreen
Fulscreen
\t

View File

@@ -3,9 +3,8 @@
import sys
from pathlib import Path
import re
import ast
# Запрещенные QSS-свойства
# Запрещенные свойства
FORBIDDEN_PROPERTIES = {
"box-shadow",
"backdrop-filter",
@@ -13,55 +12,15 @@ FORBIDDEN_PROPERTIES = {
"text-shadow",
}
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_qss_files():
has_errors = False
for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
with open(qss_file, "r") as f:
content = f.read()
# Проверка на запрещённые QSS-свойства
for prop in FORBIDDEN_PROPERTIES:
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}")
print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}")
has_errors = True
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, (ast.Import, ast.ImportFrom)):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
has_errors = True
except SyntaxError as e:
print(f"ERROR: Syntax error in file {qss_file}: {e}")
has_errors = True
return has_errors
if __name__ == "__main__":

View File

@@ -21,9 +21,9 @@ Current translation status:
| Locale | Progress | Translated |
| :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 204 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 204 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 204 of 204 |
| [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 |
---

View File

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

View File

@@ -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.01.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.01.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"
}
```

View File

@@ -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.01.0) и цвет в формате hex
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
# Цвета градиента для анимированной обводки.
# Список словарей, где каждый словарь задает позицию (0.01.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"
}
```

View File

@@ -2,9 +2,8 @@ from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstra
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.logger import get_logger
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager
logger = get_logger(__name__)
@@ -24,27 +23,17 @@ class SafeOpacityEffect(QGraphicsOpacityEffect):
class GameCardAnimations:
def __init__(self, game_card, theme=None):
self.game_card = game_card
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
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):
@@ -68,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:
@@ -82,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
@@ -142,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:
@@ -156,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
@@ -209,13 +152,12 @@ class GameCardAnimations:
def paint_border(self, painter: QPainter):
if not painter.isActive():
logger.debug("Painter is not active; skipping border paint")
logger.warning("Painter is not active; skipping border paint")
return
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"]:
@@ -224,18 +166,17 @@ 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:
def __init__(self, main_window, theme=None):
self.main_window = main_window
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.theme = theme if theme is not None else default_styles
self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
@@ -258,7 +199,7 @@ class DetailPageAnimations:
try:
detail_page.setGraphicsEffect(original_effect) # type: ignore
except RuntimeError:
logger.warning("Original effect already deleted")
logger.debug("Original effect already deleted")
animation.finished.connect(restore_effect)
animation.finished.connect(load_image_and_restore_effect)
animation.finished.connect(opacity_effect.deleteLater)
@@ -317,7 +258,7 @@ class DetailPageAnimations:
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
animation.stop()
except RuntimeError:
logger.warning("Animation already deleted for page")
logger.debug("Animation already deleted for page")
except Exception as e:
logger.error(f"Error stopping existing animation: {e}", exc_info=True)
finally:
@@ -343,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)
@@ -384,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

View File

@@ -4,12 +4,14 @@ from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow
from portprotonqt.config_utils import save_fullscreen_config
from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.logger import get_logger
from portprotonqt.cli import parse_args
logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
__app_version__ = "0.1.6"
__app_version__ = "0.1.4"
def main():
app = QApplication(sys.argv)
@@ -18,23 +20,17 @@ def main():
app.setApplicationName(__app_name__)
app.setApplicationVersion(__app_version__)
args = parse_args()
# Setup logger with specified debug level
setup_logger(args.debug_level)
# Reinitialize logger after setup to ensure it uses the new configuration
logger = get_logger(__name__)
system_locale = QLocale.system()
qt_translator = QTranslator()
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
app.installTranslator(qt_translator)
else:
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
window = MainWindow(app_name=__app_name__)
args = parse_args()
window = MainWindow()
if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag")

View File

@@ -13,10 +13,4 @@ def parse_args():
action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
)
parser.add_argument(
"--debug-level",
choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
default='NOTSET',
help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)"
)
return parser.parse_args()

View File

@@ -7,7 +7,7 @@ logger = get_logger(__name__)
_portproton_location = None
# Paths to configuration files
# Пути к конфигурационным файлам
CONFIG_FILE = os.path.join(
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
"PortProtonQt.conf"
@@ -18,32 +18,17 @@ PORTPROTON_CONFIG_FILE = os.path.join(
"PortProton.conf"
)
# Paths to theme directories
# Пути к папкам с темами
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
]
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails."""
cp = configparser.ConfigParser()
if not os.path.exists(config_file):
logger.debug(f"Configuration file {config_file} not found")
return None
try:
cp.read(config_file, encoding="utf-8")
return cp
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.warning(f"Invalid configuration file format: {e}")
return None
except Exception as e:
logger.warning(f"Failed to read configuration file: {e}")
return None
def read_config():
"""Reads the configuration file and returns a dictionary of parameters.
Example line in config (no sections):
"""
Читает конфигурационный файл и возвращает словарь параметров.
Пример строки в конфиге (без секций):
detail_level = detailed
"""
config_dict = {}
@@ -59,17 +44,29 @@ def read_config():
return config_dict
def read_theme_from_config():
"""Reads the theme from the [Appearance] section of the configuration file.
Returns 'standart' if the parameter is not set.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None:
return "standart"
Читает из конфигурационного файла тему из секции [Appearance].
Если параметр не задан, возвращает "standart".
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return "standart"
return cp.get("Appearance", "theme", fallback="standart")
def save_theme_to_config(theme_name):
"""Saves the selected theme name to the [Appearance] section of the configuration file."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Appearance" not in cp:
cp["Appearance"] = {}
cp["Appearance"]["theme"] = theme_name
@@ -77,18 +74,34 @@ def save_theme_to_config(theme_name):
cp.write(configfile)
def read_time_config():
"""Reads time settings from the [Time] section of the configuration file.
If the section or parameter is missing, saves and returns 'detailed' as default.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
save_time_config("detailed")
return "detailed"
return cp.get("Time", "detail_level", fallback="detailed").lower()
Читает настройки времени из секции [Time] конфигурационного файла.
Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_time_config("detailed")
return "detailed"
if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
save_time_config("detailed")
return "detailed"
return cp.get("Time", "detail_level", fallback="detailed").lower()
return "detailed"
def save_time_config(detail_level):
"""Saves the time detail level to the [Time] section of the configuration file."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет настройку уровня детализации времени в секции [Time].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Time" not in cp:
cp["Time"] = {}
cp["Time"]["detail_level"] = detail_level
@@ -96,42 +109,48 @@ def save_time_config(detail_level):
cp.write(configfile)
def read_file_content(file_path):
"""Reads the content of a file and returns it as a string."""
"""
Читает содержимое файла и возвращает его как строку.
"""
with open(file_path, encoding="utf-8") as f:
return f.read().strip()
def get_portproton_location():
"""Returns the path to the PortProton directory.
Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
If the path is invalid, uses the default directory.
"""
Возвращает путь к директории PortProton.
Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
используется директория по умолчанию.
"""
global _portproton_location
if _portproton_location is not None:
return _portproton_location
# Попытка чтения пути из конфигурационного файла
if os.path.isfile(PORTPROTON_CONFIG_FILE):
try:
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
if location and os.path.isdir(location):
_portproton_location = location
logger.info(f"PortProton path from configuration: {location}")
logger.info(f"Путь PortProton из конфигурации: {location}")
return _portproton_location
logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
except (OSError, PermissionError) as e:
logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_dir):
_portproton_location = default_dir
logger.info(f"Using flatpak PortProton directory: {default_dir}")
logger.info(f"Используется директория flatpak PortProton: {default_dir}")
return _portproton_location
logger.warning("PortProton configuration and flatpak directory not found")
logger.warning("Конфигурация и директория flatpak PortProton не найдены")
return None
def parse_desktop_entry(file_path):
"""Reads and parses a .desktop file using configparser.
Returns None if the [Desktop Entry] section is missing.
"""
Читает и парсит .desktop файл с помощью configparser.
Если секция [Desktop Entry] отсутствует, возвращается None.
"""
cp = configparser.ConfigParser(interpolation=None)
cp.read(file_path, encoding="utf-8")
@@ -140,8 +159,9 @@ def parse_desktop_entry(file_path):
return cp["Desktop Entry"]
def load_theme_metainfo(theme_name):
"""Loads theme metadata from metainfo.ini in the theme's root directory.
Expected fields: author, author_link, description, name.
"""
Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
Ожидаемые поля: author, author_link, description, name.
"""
meta = {}
for themes_dir in THEMES_DIRS:
@@ -159,18 +179,34 @@ def load_theme_metainfo(theme_name):
return meta
def read_card_size():
"""Reads the card size (width) from the [Cards] section.
Returns 250 if the parameter is not set.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
save_card_size(250)
return 250
return cp.getint("Cards", "card_width", fallback=250)
Читает размер карточек (ширину) из секции [Cards],
Если параметр не задан, возвращает 250.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_card_size(250)
return 250
if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
save_card_size(250)
return 250
return cp.getint("Cards", "card_width", fallback=250)
return 250
def save_card_size(card_width):
"""Saves the card size (width) to the [Cards] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет размер карточек (ширину) в секцию [Cards].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Cards" not in cp:
cp["Cards"] = {}
cp["Cards"]["card_width"] = str(card_width)
@@ -178,18 +214,34 @@ def save_card_size(card_width):
cp.write(configfile)
def read_sort_method():
"""Reads the sort method from the [Games] section.
Returns 'last_launch' if the parameter is not set.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
save_sort_method("last_launch")
return "last_launch"
return cp.get("Games", "sort_method", fallback="last_launch").lower()
Читает метод сортировки из секции [Games].
Если параметр не задан, возвращает last_launch.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_sort_method("last_launch")
return "last_launch"
if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
save_sort_method("last_launch")
return "last_launch"
return cp.get("Games", "sort_method", fallback="last_launch").lower()
return "last_launch"
def save_sort_method(sort_method):
"""Saves the sort method to the [Games] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет метод сортировки в секцию [Games].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Games" not in cp:
cp["Games"] = {}
cp["Games"]["sort_method"] = sort_method
@@ -197,18 +249,34 @@ def save_sort_method(sort_method):
cp.write(configfile)
def read_display_filter():
"""Reads the display_filter parameter from the [Games] section.
Returns 'all' if the parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
save_display_filter("all")
return "all"
return cp.get("Games", "display_filter", fallback="all").lower()
Читает параметр display_filter из секции [Games].
Если параметр отсутствует, сохраняет и возвращает значение "all".
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
save_display_filter("all")
return "all"
if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
save_display_filter("all")
return "all"
return cp.get("Games", "display_filter", fallback="all").lower()
return "all"
def save_display_filter(filter_value):
"""Saves the display_filter parameter to the [Games] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет параметр display_filter в секцию [Games] конфигурационного файла.
"""
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 "Games" not in cp:
cp["Games"] = {}
cp["Games"]["display_filter"] = filter_value
@@ -216,23 +284,37 @@ def save_display_filter(filter_value):
cp.write(configfile)
def read_favorites():
"""Reads the list of favorite games from the [Favorites] section.
The list is stored as a quoted string with comma-separated names.
Returns an empty list if the section or parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Favorites") or not cp.has_option("Favorites", "games"):
return []
favs = cp.get("Favorites", "games", fallback="").strip()
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [s.strip() for s in favs.split(",") if s.strip()]
Читает список избранных игр из секции [Favorites] конфигурационного файла.
Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми.
Если секция или параметр отсутствуют, возвращает пустой список.
"""
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("Favorites") and cp.has_option("Favorites", "games"):
favs = cp.get("Favorites", "games", fallback="").strip()
# Если строка начинается и заканчивается кавычками, удаляем их
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [s.strip() for s in favs.split(",") if s.strip()]
return []
def save_favorites(favorites):
"""Saves the list of favorite games to the [Favorites] section.
The list is stored as a quoted string with comma-separated names.
"""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла.
Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
"""
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 "Favorites" not in cp:
cp["Favorites"] = {}
fav_str = ", ".join(favorites)
@@ -241,18 +323,34 @@ def save_favorites(favorites):
cp.write(configfile)
def read_rumble_config():
"""Reads the gamepad rumble setting from the [Gamepad] section.
Returns False if the parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
save_rumble_config(False)
return False
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
Читает настройку виброотдачи геймпада из секции [Gamepad].
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_rumble_config(False)
return False
if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
save_rumble_config(False)
return False
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
return False
def save_rumble_config(rumble_enabled):
"""Saves the gamepad rumble setting to the [Gamepad] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет настройку виброотдачи геймпада в секцию [Gamepad].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Gamepad" not in cp:
cp["Gamepad"] = {}
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
@@ -260,28 +358,41 @@ def save_rumble_config(rumble_enabled):
cp.write(configfile)
def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file.
Creates it with empty values if missing.
"""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Proxy" not in cp:
cp.add_section("Proxy")
cp["Proxy"]["proxy_url"] = ""
cp["Proxy"]["proxy_user"] = ""
cp["Proxy"]["proxy_password"] = ""
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
Проверяет наличие секции [Proxy] в конфигурационном файле.
Если секция отсутствует, создаёт её с пустыми значениями.
"""
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 not cp.has_section("Proxy"):
cp.add_section("Proxy")
cp["Proxy"]["proxy_url"] = ""
cp["Proxy"]["proxy_user"] = ""
cp["Proxy"]["proxy_password"] = ""
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_proxy_config():
"""Reads proxy settings from the [Proxy] section.
Returns an empty dict if proxy_url is not set or empty.
"""
Читает настройки прокси из секции [Proxy] конфигурационного файла.
Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
"""
ensure_default_proxy_config()
cp = read_config_safely(CONFIG_FILE)
if cp is None:
cp = configparser.ConfigParser()
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return {}
proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
if proxy_url:
# Если указаны логин и пароль, добавляем их к URL
proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
@@ -291,10 +402,16 @@ def read_proxy_config():
return {}
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
"""Saves proxy settings to the [Proxy] section.
Creates the section if it does not exist.
"""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла.
Если секция отсутствует, создаёт её.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Proxy" not in cp:
cp["Proxy"] = {}
cp["Proxy"]["proxy_url"] = proxy_url
@@ -304,18 +421,34 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
cp.write(configfile)
def read_fullscreen_config():
"""Reads the fullscreen mode setting from the [Display] section.
Returns False if the parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
save_fullscreen_config(False)
return False
return cp.getboolean("Display", "fullscreen", fallback=False)
Читает настройку полноэкранного режима приложения из секции [Display].
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_fullscreen_config(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
save_fullscreen_config(False)
return False
return cp.getboolean("Display", "fullscreen", fallback=False)
return False
def save_fullscreen_config(fullscreen):
"""Saves the fullscreen mode setting to the [Display] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет настройку полноэкранного режима приложения в секцию [Display].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp:
cp["Display"] = {}
cp["Display"]["fullscreen"] = str(fullscreen)
@@ -323,19 +456,33 @@ def save_fullscreen_config(fullscreen):
cp.write(configfile)
def read_window_geometry() -> tuple[int, int]:
"""Reads the window width and height from the [MainWindow] section.
Returns (0, 0) if the parameters are missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("MainWindow"):
return (0, 0)
width = cp.getint("MainWindow", "width", fallback=0)
height = cp.getint("MainWindow", "height", fallback=0)
return (width, height)
Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0).
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return (0, 0)
if cp.has_section("MainWindow"):
width = cp.getint("MainWindow", "width", fallback=0)
height = cp.getint("MainWindow", "height", fallback=0)
return (width, height)
return (0, 0)
def save_window_geometry(width: int, height: int):
"""Saves the window width and height to the [MainWindow] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "MainWindow" not in cp:
cp["MainWindow"] = {}
cp["MainWindow"]["width"] = str(width)
@@ -344,67 +491,61 @@ def save_window_geometry(width: int, height: int):
cp.write(configfile)
def reset_config():
"""Resets the configuration file by deleting it.
Subsequent reads will use default values.
"""
Сбрасывает конфигурационный файл, удаляя его.
После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
"""
if os.path.exists(CONFIG_FILE):
try:
os.remove(CONFIG_FILE)
logger.info("Configuration file %s deleted", CONFIG_FILE)
logger.info("Конфигурационный файл %s удалён", CONFIG_FILE)
except Exception as e:
logger.warning(f"Failed to delete configuration file: {e}")
logger.error("Ошибка при удалении конфигурационного файла: %s", e)
def clear_cache():
"""Clears the PortProtonQt cache by deleting the cache directory."""
"""
Очищает кэш PortProtonQt, удаляя папку кэша.
"""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
if os.path.exists(cache_dir):
try:
shutil.rmtree(cache_dir)
logger.info("PortProtonQt cache deleted: %s", cache_dir)
logger.info("Кэш PortProtonQt удалён: %s", cache_dir)
except Exception as e:
logger.warning(f"Failed to delete cache: {e}")
logger.error("Ошибка при удалении кэша: %s", e)
def read_auto_fullscreen_gamepad():
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
Returns False if the parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
save_auto_fullscreen_gamepad(False)
return False
return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [Display].
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_auto_fullscreen_gamepad(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
save_auto_fullscreen_gamepad(False)
return False
return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
return False
def save_auto_fullscreen_gamepad(auto_fullscreen):
"""Saves the auto-fullscreen setting for gamepad to the [Display] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
"""
Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [Display].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp:
cp["Display"] = {}
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():
"""Reads the list of favorite folders from the [FavoritesFolders] section.
The list is stored as a quoted string with comma-separated paths.
Returns an empty list if the section or parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("FavoritesFolders") or not cp.has_option("FavoritesFolders", "folders"):
return []
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()))]
def save_favorite_folders(folders):
"""Saves the list of favorite folders to the [FavoritesFolders] section.
The list is stored as a quoted string with comma-separated paths.
"""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "FavoritesFolders" not in cp:
cp["FavoritesFolders"] = {}
fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)

View File

@@ -4,6 +4,7 @@ import glob
import shutil
import subprocess
import threading
import logging
import orjson
import psutil
import signal
@@ -11,14 +12,13 @@ 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
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
logger = logging.getLogger(__name__)
class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads."""
@@ -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()

View File

@@ -8,8 +8,8 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
rect_width: доступная ширина контейнера.
spacing: отступ между элементами (горизонтальный и вертикальный).
max_scale: максимальный коэффициент масштабирования (например, 1.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)

View File

@@ -4,24 +4,23 @@ 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, read_theme_from_config
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
logger = get_logger(__name__)
theme_manager = ThemeManager()
def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
"""
@@ -90,89 +89,11 @@ 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 theme_manager.apply_theme(read_theme_from_config())
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=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)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager()
self.file_signal = FileSelectedSignal()
self.file_filter = file_filter # Store the file filter
self.directory_only = directory_only # Store the directory_only flag
@@ -185,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:
@@ -218,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):
@@ -240,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)
@@ -251,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()
@@ -263,15 +181,13 @@ 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)
# Кнопки
self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Select"), icon=theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.select_button = AutoSizeButton(_("Select"), icon=self.theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button)
@@ -281,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()
@@ -377,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=theme_manager.get_icon("mount_point"))
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button.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=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):
"""Переход к выбранному диску"""
@@ -482,7 +339,7 @@ class FileExplorer(QDialog):
try:
if self.current_path != "/":
item = QListWidgetItem("../")
folder_icon = theme_manager.get_icon("folder")
folder_icon = self.theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
@@ -497,7 +354,7 @@ class FileExplorer(QDialog):
# Добавляем директории
for d in sorted(dirs):
item = QListWidgetItem(f"{d}/")
folder_icon = theme_manager.get_icon("folder")
folder_icon = self.theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
@@ -588,7 +445,8 @@ class AddGameDialog(QDialog):
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
super().__init__(parent)
from portprotonqt.context_menu_manager import CustomLineEdit # Локальный импорт
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.theme = theme if theme else default_styles
self.theme_manager = ThemeManager()
self.edit_mode = edit_mode
self.original_name = game_name
self.last_exe_path = exe_path # Store last selected exe path
@@ -624,7 +482,7 @@ class AddGameDialog(QDialog):
if exe_path:
self.exeEdit.setText(exe_path)
exeBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
exeBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search"))
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
exeBrowseButton.clicked.connect(self.browseExe)
exeBrowseButton.setObjectName("exeBrowseButton") # Для поиска кнопки
@@ -646,7 +504,7 @@ class AddGameDialog(QDialog):
if cover_path:
self.coverEdit.setText(cover_path)
coverBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
coverBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search"))
coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
coverBrowseButton.clicked.connect(self.browseCover)
coverBrowseButton.setObjectName("coverBrowseButton") # Для поиска кнопки
@@ -675,8 +533,8 @@ class AddGameDialog(QDialog):
# Dialog buttons
self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.select_button = AutoSizeButton(_("Apply"), icon=self.theme_manager.get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
self.select_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.button_layout.addWidget(self.select_button)

View File

@@ -2,35 +2,39 @@ from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import Signal, Property, Qt, QUrl
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
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
@@ -45,16 +49,14 @@ 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
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu)
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.theme = theme if theme is not None else default_styles
self.display_filter = read_display_filter()
self.current_theme_name = read_theme_from_config()
@@ -65,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)
@@ -112,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)
@@ -163,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),
@@ -225,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)
@@ -375,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="):
@@ -434,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)
@@ -484,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(

View File

@@ -3,6 +3,7 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.downloader import Downloader
@@ -176,8 +177,7 @@ class FullscreenDialog(QDialog):
self.images = images
self.current_index = current_index
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.theme = theme if theme else default_styles
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
@@ -378,8 +378,7 @@ class ImageCarousel(QGraphicsView):
self.images = images # Список кортежей: (QPixmap, caption)
self.image_items = []
self._animation = None
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.theme = theme if theme else default_styles
self.max_height = 300 # Default height for images
self.init_ui()
self.create_arrows()

View File

@@ -3,11 +3,10 @@ import threading
import os
from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
from enum import Enum
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, QMouseEvent
from PySide6.QtGui import QKeyEvent
from portprotonqt.logger import get_logger
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
@@ -32,8 +31,6 @@ class MainWindowProtocol(Protocol):
...
def on_slider_released(self) -> None:
...
def isActiveWindow(self) -> bool:
...
stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget]
gamesListWidget: QWidget
@@ -41,29 +38,23 @@ class MainWindowProtocol(Protocol):
current_exec_line: str | None
current_add_game_dialog: AddGameDialog | None
# Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo Switch controllers
# Mapping of actions to evdev button codes, includes Xbox and PlayStation controllers
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-nintendo
BUTTONS = {
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS) / B (Switch)
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS) / A (Switch)
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS) / Y (Switch)
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS) / X (Switch)
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS) / L (Switch)
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS) / R (Switch)
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS) / + (Switch)
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS) / - (Switch)
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button / Home (Switch)
'increase_size': {ecodes.ABS_RZ}, # RT (Xbox) / R2 (PS) / ZR (Switch)
'decrease_size': {ecodes.ABS_Z}, # LT (Xbox) / L2 (PS) / ZL (Switch)
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS)
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS)
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS)
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS)
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS)
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS)
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS)
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS)
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button
'increase_size': {ecodes.BTN_TR2}, # RT (Xbox) / R2 (PS)
'decrease_size': {ecodes.BTN_TL2}, # LT (Xbox) / L2 (PS)
}
class GamepadType(Enum):
XBOX = "Xbox"
PLAYSTATION = "PlayStation"
UNKNOWN = "Unknown"
class InputManager(QObject):
"""
Manages input from gamepads and keyboards for navigating the application interface.
@@ -85,7 +76,6 @@ class InputManager(QObject):
super().__init__(cast(QObject, main_window))
self._parent = main_window
self._gamepad_handling_enabled = True
self.gamepad_type = GamepadType.UNKNOWN
# Ensure attributes exist on main_window
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
@@ -142,38 +132,6 @@ class InputManager(QObject):
# Initialize evdev + hotplug
self.init_gamepad()
def detect_gamepad_type(self, device: InputDevice) -> GamepadType:
"""
Определяет тип геймпада по capabilities
"""
caps = device.capabilities()
keys = set(caps.get(ecodes.EV_KEY, []))
# Для EV_ABS вытаскиваем только коды (первый элемент кортежа)
abs_axes = {a if isinstance(a, int) else a[0] for a in caps.get(ecodes.EV_ABS, [])}
# Xbox layout
if {ecodes.BTN_SOUTH, ecodes.BTN_EAST, ecodes.BTN_NORTH, ecodes.BTN_WEST}.issubset(keys):
if {ecodes.ABS_X, ecodes.ABS_Y, ecodes.ABS_RX, ecodes.ABS_RY}.issubset(abs_axes):
self.gamepad_type = GamepadType.XBOX
return GamepadType.XBOX
# PlayStation layout
if ecodes.BTN_TOUCH in keys or (ecodes.BTN_DPAD_UP in keys and ecodes.BTN_EAST in keys):
self.gamepad_type = GamepadType.PLAYSTATION
logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
return GamepadType.PLAYSTATION
# Steam Controller / Deck (трекпады)
if any(a for a in abs_axes if a >= ecodes.ABS_MT_SLOT):
self.gamepad_type = GamepadType.XBOX
logger.info(f"Detected {self.gamepad_type.value} controller: {device.name}")
return GamepadType.XBOX
# Fallback
self.gamepad_type = GamepadType.XBOX
return GamepadType.XBOX
def enable_file_explorer_mode(self, file_explorer):
"""Настройка обработки геймпада для FileExplorer"""
try:
@@ -203,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()
@@ -224,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:
@@ -267,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()
@@ -298,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
@@ -349,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"""
@@ -446,14 +361,17 @@ class InputManager(QObject):
if not self._gamepad_handling_enabled:
return
try:
# Ignore gamepad events if a game is launched
if getattr(self._parent, '_gameLaunched', False):
return
app = QApplication.instance()
if not app:
return
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
modal_dialog = QApplication.activeModalWidget()
if not app or not active:
return
# Handle Guide button to open system overlay
if button_code in BUTTONS['guide']:
@@ -598,13 +516,16 @@ class InputManager(QObject):
if not self._gamepad_handling_enabled:
return
try:
# Ignore gamepad events if a game is launched
if getattr(self._parent, '_gameLaunched', False):
return
app = QApplication.instance()
if not app:
return
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
if not app or not active:
return
# Update D-pad state
if value != 0:
@@ -709,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:
@@ -841,25 +742,6 @@ class InputManager(QObject):
if not app:
return super().eventFilter(obj, event)
if event.type() == QEvent.Type.MouseButtonPress:
mouse_event = cast(QMouseEvent, event)
if mouse_event.button() == Qt.MouseButton.ExtraButton1:
# Handle ExtraButton1 as "back" action, similar to Escape
active_win = QApplication.activeWindow()
focused = QApplication.focusWidget()
if isinstance(focused, QLineEdit):
return False # Skip if in QLineEdit
if isinstance(active_win, QDialog):
active_win.reject()
return True
self._parent.goBackDetailPage(self._parent.currentDetailPage)
return True
# 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)
@@ -872,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:
@@ -944,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):
@@ -966,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:
@@ -1093,8 +924,6 @@ class InputManager(QObject):
new_gamepad = self.find_gamepad()
if new_gamepad and new_gamepad != self.gamepad:
logger.info(f"Gamepad connected: {new_gamepad.name}")
self.detect_gamepad_type(new_gamepad)
logger.info(f"Detected gamepad type: {self.gamepad_type.value}")
self.stop_rumble()
self.gamepad = new_gamepad
if self.gamepad_thread:
@@ -1113,10 +942,6 @@ class InputManager(QObject):
try:
devices = [InputDevice(path) for path in list_devices()]
for device in devices:
# Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2)
if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
logger.debug(f"Skipping ASRock LED controller: {device.name}")
continue
caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
return device
@@ -1135,13 +960,6 @@ class InputManager(QObject):
if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS):
continue
now = time.time()
# Проверка фокуса: игнорируем события, если окно не в фокусе
app = QApplication.instance()
active = QApplication.activeWindow()
if not app or not active:
continue
if event.type == ecodes.EV_KEY and event.value == 1:
if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen)
@@ -1194,7 +1012,5 @@ class InputManager(QObject):
self.gamepad_thread.join()
if self.gamepad:
self.gamepad.close()
self.gamepad = None
self.gamepad_type = GamepadType.UNKNOWN
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-13 11:51+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 ""
@@ -360,12 +356,6 @@ msgstr ""
msgid "Themes"
msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -555,6 +545,9 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -658,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 ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-13 11:51+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 ""
@@ -360,12 +356,6 @@ msgstr ""
msgid "Themes"
msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -555,6 +545,9 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -658,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 ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-13 11:51+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 ""
@@ -358,12 +354,6 @@ msgstr ""
msgid "Themes"
msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -553,6 +543,9 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -656,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 ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"PO-Revision-Date: 2025-09-13 11:47+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 "Путь: "
@@ -367,13 +363,6 @@ msgstr "Настройки PortProton"
msgid "Themes"
msgstr "Темы"
msgid "Back"
msgstr "Назад"
#, fuzzy
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..."
@@ -565,6 +554,9 @@ msgstr "Тема '{0}' применена успешно"
msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'"
msgid "Back"
msgstr "Назад"
msgid "LAST LAUNCH"
msgstr "Последний запуск"
@@ -668,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 "Нет недавних игр"

View File

@@ -1,34 +1,16 @@
import logging
def setup_logger(level='NOTSET'):
def setup_logger():
"""Настройка базовой конфигурации логирования."""
# Clear existing handlers to prevent duplicates
root_logger = logging.getLogger()
for handler in root_logger.handlers[:]:
root_logger.removeHandler(handler)
# Convert string level to logging level constant, map ALL to DEBUG
if level.upper() == 'ALL':
log_level = logging.DEBUG
else:
log_level = getattr(logging, level.upper(), logging.NOTSET)
# Configure logging with null handler if level is NOTSET
if log_level == logging.NOTSET:
logging.basicConfig(
level=logging.NOTSET,
handlers=[logging.NullHandler()]
)
else:
logging.basicConfig(
level=log_level,
format='[%(levelname)s] %(message)s',
handlers=[logging.StreamHandler()]
)
logging.basicConfig(
level=logging.INFO,
format='[%(levelname)s] %(message)s',
handlers=[logging.StreamHandler()]
)
def get_logger(name):
"""Возвращает логгер для указанного модуля."""
return logging.getLogger(name)
# Инициализация логгера при импорте модуля (без логов по умолчанию)
# Инициализация логгера при импорте модуля
setup_logger()

View File

@@ -4,9 +4,10 @@ import shutil
import signal
import subprocess
import sys
import portprotonqt.themes.standart.styles as default_styles
import psutil
from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer
from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
@@ -15,12 +16,11 @@ from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.input_manager import InputManager
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from portprotonqt.system_overlay import SystemOverlay
from portprotonqt.input_manager import GamepadType
from portprotonqt.image_utils import load_pixmap_async, round_corners, ImageCarousel
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_steam_installed_games
from portprotonqt.egs_api import load_egs_games_async, get_egs_executable
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots, load_logo
from portprotonqt.time_utils import save_last_launch, get_last_launch, parse_playtime_file, format_playtime, get_last_launch_timestamp, format_last_launch
from portprotonqt.config_utils import (
get_portproton_location, read_theme_from_config, save_theme_to_config, parse_desktop_entry,
@@ -31,9 +31,9 @@ from portprotonqt.config_utils import (
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
)
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.logger import get_logger
from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
@@ -52,17 +52,23 @@ 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
self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
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.card_width = read_card_size()
self.setWindowTitle(app_name)
self.setWindowTitle("PortProtonQt")
self.setMinimumSize(800, 600)
self.games = []
@@ -142,26 +148,32 @@ class MainWindow(QMainWindow):
self.header.setStyleSheet(self.theme.MAIN_WINDOW_HEADER_STYLE)
headerLayout = QVBoxLayout(self.header)
headerLayout.setContentsMargins(0, 0, 0, 0)
headerLayout.addStretch()
self.input_manager = InputManager(self)
self.input_manager.button_pressed.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints)
# Текст "PortProton" слева
self.titleLabel = QLabel()
pixmap = load_logo()
if pixmap is None:
width, height = self.theme.pixmapsScaledSize
pixmap = QPixmap(width, height)
pixmap.fill(QColor(0, 0, 0, 0))
width, height = self.theme.pixmapsScaledSize
scaled_pixmap = pixmap.scaled(width, height,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation)
self.titleLabel.setPixmap(scaled_pixmap)
self.titleLabel.setFixedSize(scaled_pixmap.size())
self.titleLabel.setStyleSheet(self.theme.TITLE_LABEL_STYLE)
headerLayout.addStretch()
# 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
self.navWidget = QWidget()
self.navWidget.setStyleSheet(self.theme.NAV_WIDGET_STYLE)
navLayout = QHBoxLayout(self.navWidget)
navLayout.setContentsMargins(10, 0, 10, 0)
navLayout.setSpacing(10)
navLayout.setSpacing(0)
# Left navigation button (key_left or button_lb)
self.leftNavButton = QLabel()
self.leftNavButton.setFixedSize(32, 32)
self.leftNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
navLayout.addWidget(self.leftNavButton)
navLayout.addWidget(self.titleLabel)
# Вкладки
self.tabButtons = {}
tabs = [
_("Library"),
@@ -180,16 +192,6 @@ class MainWindow(QMainWindow):
self.tabButtons[i] = btn
self.tabButtons[0].setChecked(True)
# Right navigation button (key_right or button_rb)
self.rightNavButton = QLabel()
self.rightNavButton.setFixedSize(32, 32)
self.rightNavButton.setAlignment(Qt.AlignmentFlag.AlignCenter)
navLayout.addWidget(self.rightNavButton)
# Initial update of navigation buttons based on input device
self.updateNavButtons()
mainLayout.addWidget(self.navWidget)
# 3. QStackedWidget (ВКЛАДКИ)
@@ -204,12 +206,9 @@ class MainWindow(QMainWindow):
self.createPortProtonTab() # вкладка 4
self.createThemeTab() # вкладка 5
# Подсказки управления
self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget)
self.restore_state()
self.input_manager = InputManager(self)
self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames)
@@ -221,212 +220,6 @@ class MainWindow(QMainWindow):
self.resize(width, height)
else:
self.showNormal()
def get_button_icon(self, action: str, gtype: GamepadType) -> str:
"""Get the icon name for a specific action and gamepad type."""
mappings = {
'confirm': {
GamepadType.XBOX: "xbox_a",
GamepadType.PLAYSTATION: "ps_cross",
},
'back': {
GamepadType.XBOX: "xbox_b",
GamepadType.PLAYSTATION: "ps_circle",
},
'add_game': {
GamepadType.XBOX: "xbox_x",
GamepadType.PLAYSTATION: "ps_triangle",
},
'context_menu': {
GamepadType.XBOX: "xbox_start",
GamepadType.PLAYSTATION: "ps_options",
},
'menu': {
GamepadType.XBOX: "xbox_view",
GamepadType.PLAYSTATION: "ps_share",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
def get_nav_icon(self, direction: str, gtype: GamepadType) -> str:
"""Get the icon name for navigation direction and gamepad type."""
if direction == 'left':
action = 'prev_tab'
else:
action = 'next_tab'
mappings = {
'prev_tab': {
GamepadType.XBOX: "xbox_lb",
GamepadType.PLAYSTATION: "ps_l1",
},
'next_tab': {
GamepadType.XBOX: "xbox_rb",
GamepadType.PLAYSTATION: "ps_r1",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
def createControlHintsWidget(self) -> QWidget:
from portprotonqt.localization import _
"""Creates a widget displaying control hints for gamepad and keyboard."""
logger.debug("Creating control hints widget")
hintsWidget = QWidget()
hintsWidget.setStyleSheet(self.theme.STATUS_BAR_STYLE)
hintsLayout = QHBoxLayout(hintsWidget)
hintsLayout.setContentsMargins(10, 0, 10, 0)
hintsLayout.setSpacing(20)
gamepad_actions = [
("confirm", _("Select")),
("back", _("Back")),
("add_game", _("Add Game")),
("context_menu", _("Menu")),
("menu", _("Fullscreen")),
]
keyboard_hints = [
("key_enter", _("Select")),
("key_backspace", _("Back")),
("key_e", _("Add Game")),
("key_context", _("Menu")),
("key_f11", _("Fullscreen")),
]
self.hintsLabels = []
def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(6)
# иконка кнопки
icon_label = QLabel()
icon_label.setFixedSize(32, 32)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
for candidate in (
self.theme_manager.get_theme_image(icon_name, self.current_theme_name),
self.theme_manager.get_theme_image("placeholder", self.current_theme_name),
):
if candidate is not None and pixmap.load(str(candidate)):
break
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
layout.addWidget(icon_label)
# текст действия
text_label = QLabel(action_text)
text_label.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
layout.addWidget(text_label)
if is_gamepad:
container.setVisible(False)
self.hintsLabels.append((container, icon_label, action)) # Store action for dynamic update
else:
container.setVisible(True)
self.hintsLabels.append((container, icon_label, None)) # Keyboard, no action
hintsLayout.addWidget(container)
# Create gamepad hints
for action, text in gamepad_actions:
makeHint("placeholder", text, True, action) # Initial placeholder
# Create keyboard hints
for icon, text in keyboard_hints:
makeHint(icon, text, False)
hintsLayout.addStretch()
return hintsWidget
def updateNavButtons(self, *args) -> None:
"""Updates navigation buttons based on gamepad connection status and type."""
is_gamepad_connected = self.input_manager.gamepad is not None
gtype = self.input_manager.gamepad_type
logger.debug("Updating nav buttons, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
# Left navigation button
left_pix = QPixmap()
if is_gamepad_connected:
left_icon_name = self.get_nav_icon('left', gtype)
else:
left_icon_name = "key_left"
left_icon = self.theme_manager.get_theme_image(left_icon_name, self.current_theme_name)
if left_icon:
left_pix.load(str(left_icon))
if not left_pix.isNull():
self.leftNavButton.setPixmap(left_pix.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
self.leftNavButton.setVisible(True) # Always visible, icon changes
# Right navigation button
right_pix = QPixmap()
if is_gamepad_connected:
right_icon_name = self.get_nav_icon('right', gtype)
else:
right_icon_name = "key_right"
right_icon = self.theme_manager.get_theme_image(right_icon_name, self.current_theme_name)
if right_icon:
right_pix.load(str(right_icon))
if not right_pix.isNull():
self.rightNavButton.setPixmap(right_pix.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
self.rightNavButton.setVisible(True) # Always visible, icon changes
def updateControlHints(self, *args) -> None:
"""Updates control hints based on gamepad connection status and type."""
is_gamepad_connected = self.input_manager.gamepad is not None
gtype = self.input_manager.gamepad_type
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu']
for container, icon_label, action in self.hintsLabels:
if action in gamepad_actions: # Gamepad hint
if is_gamepad_connected:
container.setVisible(True)
# Update icon based on type
icon_name = self.get_button_icon(action, gtype)
icon_path = self.theme_manager.get_theme_image(icon_name, self.current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# Fallback to placeholder
placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
else: # Keyboard hint
container.setVisible(not is_gamepad_connected)
# Update navigation buttons
self.updateNavButtons()
@Slot(list)
def on_games_loaded(self, games: list[tuple]):
self.games = games
@@ -876,8 +669,6 @@ class MainWindow(QMainWindow):
sliderLayout = QHBoxLayout()
sliderLayout.addStretch()
# Слайдер
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250)
@@ -888,7 +679,6 @@ class MainWindow(QMainWindow):
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout)
def calculate_card_width():
@@ -1341,36 +1131,36 @@ class MainWindow(QMainWindow):
self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
# # 8. Legendary Authentication
# self.legendaryAuthButton = AutoSizeButton(
# _("Open Legendary Login"),
# icon=self.theme_manager.get_icon("login")
# )
# self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
# self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin)
# self.legendaryAuthTitle = QLabel(_("Legendary Authentication:"))
# self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
# self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton)
#
# self.legendaryCodeEdit = CustomLineEdit(self, theme=self.theme)
# self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code"))
# self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
# self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.legendaryCodeTitle = QLabel(_("Authorization Code:"))
# self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
# self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
# formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit)
#
# self.submitCodeButton = AutoSizeButton(
# _("Submit Code"),
# icon=self.theme_manager.get_icon("save")
# )
# self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
# self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.submitCodeButton.clicked.connect(self.submitLegendaryCode)
# formLayout.addRow(QLabel(""), self.submitCodeButton)
# 8. Legendary Authentication
self.legendaryAuthButton = AutoSizeButton(
_("Open Legendary Login"),
icon=self.theme_manager.get_icon("login")
)
self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.legendaryAuthButton.clicked.connect(self.openLegendaryLogin)
self.legendaryAuthTitle = QLabel(_("Legendary Authentication:"))
self.legendaryAuthTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryAuthTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.legendaryAuthTitle, self.legendaryAuthButton)
self.legendaryCodeEdit = CustomLineEdit(self, theme=self.theme)
self.legendaryCodeEdit.setPlaceholderText(_("Enter Legendary Authorization Code"))
self.legendaryCodeEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
self.legendaryCodeEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.legendaryCodeTitle = QLabel(_("Authorization Code:"))
self.legendaryCodeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.legendaryCodeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.legendaryCodeTitle, self.legendaryCodeEdit)
self.submitCodeButton = AutoSizeButton(
_("Submit Code"),
icon=self.theme_manager.get_icon("save")
)
self.submitCodeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.submitCodeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.submitCodeButton.clicked.connect(self.submitLegendaryCode)
formLayout.addRow(QLabel(""), self.submitCodeButton)
layout.addLayout(formLayout)
@@ -1412,46 +1202,46 @@ class MainWindow(QMainWindow):
layout.addStretch(1)
self.stackedWidget.addWidget(self.portProtonWidget)
# def openLegendaryLogin(self):
# """Opens the Legendary login page in the default web browser."""
# login_url = "https://legendary.gl/epiclogin"
# try:
# QDesktopServices.openUrl(QUrl(login_url))
# self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000)
# except Exception as e:
# logger.error(f"Failed to open Legendary login page: {e}")
# self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
#
# def submitLegendaryCode(self):
# """Submits the Legendary authorization code using the legendary CLI."""
# auth_code = self.legendaryCodeEdit.text().strip()
# if not auth_code:
# QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
# return
#
# try:
# # Execute legendary auth command
# result = subprocess.run(
# [self.legendary_path, "auth", "--code", auth_code],
# capture_output=True,
# text=True,
# check=True
# )
# logger.info("Legendary authentication successful: %s", result.stdout)
# self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
# self.legendaryCodeEdit.clear()
# # Reload Epic Games Store games after successful authentication
# self.games = self.loadGames()
# self.updateGameGrid()
# except subprocess.CalledProcessError as e:
# logger.error("Legendary authentication failed: %s", e.stderr)
# self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
# except FileNotFoundError:
# logger.error("Legendary executable not found at %s", self.legendary_path)
# self.statusBar().showMessage(_("Legendary executable not found"), 5000)
# except Exception as e:
# logger.error("Unexpected error during Legendary authentication: %s", str(e))
# self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
def openLegendaryLogin(self):
"""Opens the Legendary login page in the default web browser."""
login_url = "https://legendary.gl/epiclogin"
try:
QDesktopServices.openUrl(QUrl(login_url))
self.statusBar().showMessage(_("Opened Legendary login page in browser"), 3000)
except Exception as e:
logger.error(f"Failed to open Legendary login page: {e}")
self.statusBar().showMessage(_("Failed to open Legendary login page"), 3000)
def submitLegendaryCode(self):
"""Submits the Legendary authorization code using the legendary CLI."""
auth_code = self.legendaryCodeEdit.text().strip()
if not auth_code:
QMessageBox.warning(self, _("Error"), _("Please enter an authorization code"))
return
try:
# Execute legendary auth command
result = subprocess.run(
[self.legendary_path, "auth", "--code", auth_code],
capture_output=True,
text=True,
check=True
)
logger.info("Legendary authentication successful: %s", result.stdout)
self.statusBar().showMessage(_("Successfully authenticated with Legendary"), 3000)
self.legendaryCodeEdit.clear()
# Reload Epic Games Store games after successful authentication
self.games = self.loadGames()
self.updateGameGrid()
except subprocess.CalledProcessError as e:
logger.error("Legendary authentication failed: %s", e.stderr)
self.statusBar().showMessage(_("Legendary authentication failed: {0}").format(e.stderr), 5000)
except FileNotFoundError:
logger.error("Legendary executable not found at %s", self.legendary_path)
self.statusBar().showMessage(_("Legendary executable not found"), 5000)
except Exception as e:
logger.error("Unexpected error during Legendary authentication: %s", str(e))
self.statusBar().showMessage(_("Unexpected error during authentication"), 5000)
def resetSettings(self):
"""Сбрасывает настройки и перезапускает приложение."""
@@ -1540,6 +1330,7 @@ class MainWindow(QMainWindow):
self.settingsDebounceTimer.start()
# Управление полноэкранным режимом
gamepad_connected = self.input_manager.find_gamepad() is not None
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
@@ -1734,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
@@ -1768,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()
@@ -1796,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)
@@ -2238,6 +2029,8 @@ class MainWindow(QMainWindow):
elif not child_running:
# Игра завершилась сбрасываем флаг, сбрасываем кнопку и останавливаем таймер
self._gameLaunched = False
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
self.resetPlayButton()
#self._uninhibit_screensaver()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
@@ -2293,6 +2086,9 @@ class MainWindow(QMainWindow):
# Проверяем, запущена ли игра
if self.game_processes and self.target_exe == current_exe:
# Останавливаем игру
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
@@ -2352,6 +2148,10 @@ class MainWindow(QMainWindow):
icon = QIcon()
update_button.setIcon(icon)
# Delay disabling gamepad handling
if hasattr(self, 'input_manager'):
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
self.checkProcessTimer = QTimer(self)
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
self.checkProcessTimer.start(500)
@@ -2389,6 +2189,9 @@ class MainWindow(QMainWindow):
# Если игра уже запущена для этого exe останавливаем её
if self.game_processes and self.target_exe == current_exe:
if hasattr(self, 'input_manager'):
self.input_manager.enable_gamepad_handling()
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
@@ -2436,6 +2239,10 @@ class MainWindow(QMainWindow):
env_vars['START_FROM_STEAM'] = '1'
env_vars['PROCESS_LOG'] = '1'
# Delay disabling gamepad handling to allow rumble to complete
if hasattr(self, 'input_manager'):
QTimer.singleShot(200, self.input_manager.disable_gamepad_handling)
# Запускаем игру
try:
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
@@ -2459,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()

View File

@@ -22,7 +22,6 @@ import websocket
import requests
import random
import base64
import glob
downloader = Downloader()
logger = get_logger(__name__)
@@ -266,20 +265,10 @@ def get_exiftool_data(game_exe):
logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
return {}
def delete_cached_app_files(cache_dir: str, pattern: str):
"""Deletes cached files matching the given pattern in the cache directory."""
try:
for file_path in glob.glob(os.path.join(cache_dir, pattern)):
os.remove(file_path)
logger.info(f"Deleted cached file: {file_path}")
except Exception as e:
logger.error(f"Failed to delete cached files matching {pattern}: {e}")
def load_steam_apps_async(callback: Callable[[list], None]):
"""
Asynchronously loads the list of Steam applications, using cache if available.
Calls the callback with the list of apps.
Deletes cached app detail files when downloading a new steam_apps.json.
"""
cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "games_appid.tar.xz")
@@ -306,8 +295,6 @@ def load_steam_apps_async(callback: Callable[[list], None]):
if os.path.exists(cache_tar):
os.remove(cache_tar)
logger.info("Archive %s deleted after extraction", cache_tar)
# Delete all cached app detail files (steam_app_*.json)
delete_cached_app_files(cache_dir, "steam_app_*.json")
steam_apps = data if isinstance(data, list) else []
logger.info("Loaded %d apps from archive", len(steam_apps))
callback(steam_apps)
@@ -338,15 +325,11 @@ def load_steam_apps_async(callback: Callable[[list], None]):
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
)
# Delete cached app detail files before re-downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else:
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
)
# Delete cached app detail files before downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
def build_index(steam_apps):
@@ -444,7 +427,6 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
"""
Asynchronously loads the list of WeAntiCheatYet data, using cache if available.
Calls the callback with the list of anti-cheat data.
Deletes cached anti-cheat files when downloading a new anticheat_games.json.
"""
cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz")
@@ -501,15 +483,11 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
)
# Delete cached anti-cheat files before re-downloading
delete_cached_app_files(cache_dir, "anticheat_*.json") # Adjust pattern if app-specific files are added
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else:
app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
)
# Delete cached anti-cheat files before downloading
delete_cached_app_files(cache_dir, "anticheat_*.json") # Adjust pattern if app-specific files are added
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
def build_weanticheatyet_index(anti_cheat_data):

View File

@@ -1,8 +1,9 @@
import importlib.util
import os
import ast
from portprotonqt.logger import get_logger
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
logger = get_logger(__name__)
@@ -13,59 +14,6 @@ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
]
_loaded_theme = None
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_theme_safety(theme_file: str) -> bool:
"""
Проверяет файл темы на наличие запрещённых модулей и функций.
Возвращает True, если файл безопасен, иначе False.
"""
has_errors = False
try:
with open(theme_file) as f:
content = f.read()
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, ast.Import | ast.ImportFrom):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
has_errors = True
except SyntaxError as e:
logger.error(f"Syntax error in file {theme_file}: {e}")
has_errors = True
except Exception as e:
logger.error(f"Failed to check theme safety for {theme_file}: {e}")
has_errors = True
return not has_errors
def list_themes():
"""
@@ -101,13 +49,9 @@ def load_theme_screenshots(theme_name):
def load_theme_fonts(theme_name):
"""
Загружает все шрифты выбранной темы, если они ещё не были загружены.
Загружает все шрифты выбранной темы.
:param theme_name: Имя темы.
"""
global _loaded_theme
if _loaded_theme == theme_name:
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
return
QFontDatabase.removeAllApplicationFonts()
fonts_folder = None
if theme_name == "standart":
@@ -122,7 +66,7 @@ def load_theme_fonts(theme_name):
break
if not fonts_folder or not os.path.exists(fonts_folder):
logger.error(f"Fonts folder not found for theme '{theme_name}'")
logger.error(f"Папка fonts не найдена для темы '{theme_name}'")
return
for filename in os.listdir(fonts_folder):
@@ -131,11 +75,29 @@ def load_theme_fonts(theme_name):
font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Font {filename} successfully loaded: {families}")
logger.info(f"Шрифт {filename} успешно загружен: {families}")
else:
logger.error(f"Error loading font: {filename}")
logger.error(f"Ошибка загрузки шрифта: {filename}")
_loaded_theme = theme_name
def load_logo():
logo_path = None
base_dir = os.path.dirname(os.path.abspath(__file__))
logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg")
file_extension = os.path.splitext(logo_path)[1].lower()
if file_extension == ".svg":
renderer = QSvgRenderer(logo_path)
if not renderer.isValid():
logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}")
return None
pixmap = QPixmap(128, 128)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return pixmap
class ThemeWrapper:
"""
@@ -147,83 +109,69 @@ class ThemeWrapper:
self.custom_theme = custom_theme
self.metainfo = metainfo or {}
self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
self._default_theme = None # Lazy-loaded default theme
def __getattr__(self, name):
if hasattr(self.custom_theme, name):
return getattr(self.custom_theme, name)
if self._default_theme is None:
self._default_theme = load_theme("standart") # Dynamically load standard theme
return getattr(self._default_theme, name)
import portprotonqt.themes.standart.styles as default_styles
return getattr(default_styles, name)
def load_theme(theme_name):
"""
Динамически загружает модуль стилей выбранной темы и метаинформацию.
Все темы, включая стандартную, проходят проверку безопасности.
Если выбрана стандартная тема, импортируется оригинальный styles.py.
Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
"""
if theme_name == "standart":
import portprotonqt.themes.standart.styles as default_styles
return default_styles
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
styles_file = os.path.join(theme_folder, "styles.py")
if os.path.exists(styles_file):
# Проверяем безопасность темы перед загрузкой
if not check_theme_safety(styles_file):
logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'")
raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions")
spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
if spec is None or spec.loader is None:
continue
custom_theme = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_theme)
if theme_name == "standart":
return custom_theme
meta = load_theme_metainfo(theme_name)
wrapper = ThemeWrapper(custom_theme, metainfo=meta)
wrapper.screenshots = load_theme_screenshots(theme_name)
return wrapper
raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'")
class ThemeManager:
"""
Класс для управления темами приложения.
Реализует паттерн Singleton для единого экземпляра.
Позволяет получить список доступных тем, загрузить и применить выбранную тему.
"""
_instance = None
def __init__(self):
self.current_theme_name = None
self.current_theme_module = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.current_theme_name = None
cls._instance.current_theme_module = None
return cls._instance
def get_available_themes(self) -> list:
def get_available_themes(self):
"""Возвращает список доступных тем."""
return list_themes()
def apply_theme(self, theme_name: str):
"""
Применяет указанную тему, если она ещё не применена.
Возвращает модуль темы или обёртку.
"""
if self.current_theme_name == theme_name and self.current_theme_module is not None:
logger.debug(f"Theme '{theme_name}' is already applied, skipping")
return self.current_theme_module
try:
theme_module = load_theme(theme_name)
except FileNotFoundError:
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
theme_module = load_theme("standart")
theme_name = "standart"
save_theme_to_config("standart")
def get_theme_logo(self):
"""Возвращает логотип для текущей или указанной темы."""
return load_logo()
def apply_theme(self, theme_name):
"""
Применяет выбранную тему: загружает модуль стилей, шрифты и логотип.
Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации.
:param theme_name: Имя темы.
:return: Загруженный модуль темы (или обёртка).
"""
theme_module = load_theme(theme_name)
load_theme_fonts(theme_name)
self.current_theme_name = theme_name
self.current_theme_module = theme_module
save_theme_to_config(theme_name)
logger.info(f"Theme '{theme_name}' successfully applied")
logger.info(f"Тема '{theme_name}' успешно применена")
return theme_module
def get_icon(self, icon_name, theme_name=None, as_path=False):
@@ -278,7 +226,7 @@ class ThemeManager:
# Если иконка всё равно не найдена
if not icon_path or not os.path.exists(icon_path):
logger.error(f"Warning: icon '{icon_name}' not found")
logger.error(f"Предупреждение: иконка '{icon_name}' не найдена")
return QIcon() if not as_path else None
if as_path:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.1 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -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.01.0) и цвет в формате hex
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
# Цвета градиента для анимированной обводки.
# Список словарей, где каждый словарь задает позицию (0.01.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"
}
@@ -280,6 +231,16 @@ MAIN_WINDOW_HEADER_STYLE = f"""
}}
"""
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
TITLE_LABEL_STYLE = """
QLabel {
font-family: 'RASKHAL';
font-size: 38px;
margin: 0 0 0 0;
color: #007AFF;
}
"""
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
NAV_WIDGET_STYLE = f"""
QWidget {{

View File

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

View File

@@ -1,10 +1,10 @@
[build-system]
requires = ["setuptools >= 77.0.3"]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[project]
name = "portprotonqt"
version = "0.1.6"
version = "0.1.4"
description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md"
license = { text = "GPL-3.0" }
@@ -22,12 +22,12 @@ classifiers = [
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Operating System :: POSIX :: Linux"
"Operating System :: Linux"
]
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",
]

View File

@@ -5,25 +5,21 @@
"lockFileMaintenance": {
"enabled": true
},
"pre-commit": {
"enabled": true
},
"packageRules": [
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
},
{
"matchFileNames": [".gitea/workflows/build.yml"],
"enabled": false,
"description": "Disabled because download-artifact@v4 is not working"
"matchDatasources": ["python-version"],
"enabled": false
},
{
"matchFileNames": [".python-version"],
"enabled": false,
"enabled": false
},
{
"matchManagers": ["poetry", "pyenv"],
"matchManagers": ["github-actions", "pre-commit"],
"enabled": false
},
{
@@ -33,14 +29,9 @@
"groupName": "Python dependencies"
},
{
"matchPackageNames": ["numpy", "setuptools", "python"],
"matchPackageNames": ["numpy", "setuptools"],
"enabled": false,
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
},
{
"matchDatasources": ["github-runners", "python-version"],
"enabled": false,
"description": "Prevent Renovate from updating runs-on to unsupported ubuntu-24.04"
},
}
]
}

896
uv.lock generated

File diff suppressed because it is too large Load Diff