Compare commits
40 Commits
Author | SHA1 | Date | |
---|---|---|---|
ecfe252ae3
|
|||
1ad19bff6a
|
|||
98f07a9792
|
|||
d5c53ed1aa
|
|||
5a2ab36b60
|
|||
8e25c04f56
|
|||
f249b01dc6
|
|||
9f32afe6a3
|
|||
f475e6e0b2
|
|||
43a7c37e91
|
|||
f1cf0ffd68
|
|||
70ed3abcb5
|
|||
f061b1597e
|
|||
0f37a8fc6f
|
|||
850bc57a16
|
|||
0dcc3ea13f
|
|||
1c82b34e36
|
|||
a8c4ae6f7b
|
|||
dd4f658b66
|
|||
bff6b7fd34
|
|||
1e191bbba3
|
|||
4356e653b8
|
|||
4fc95511f1
|
|||
4d4e14ea52
|
|||
c39f5ad83b
|
|||
f3325ca35f
|
|||
50645066dd
|
|||
7945dd8980
|
|||
59c38f9c57
|
|||
a2d5d28884
|
|||
16af4b410a
|
|||
e8e42b5a86
|
|||
d16e2cdf43
|
|||
|
b60fd0d593 | ||
d93f23fe8c
|
|||
5423ada8f1
|
|||
2547c7c78d
|
|||
2e93073446
|
|||
|
9657ff20d3 | ||
849333c283
|
@@ -12,7 +12,7 @@ jobs:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, rawhide]
|
||||
fedora_version: [41, 42, 43, 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@v4
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # 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
|
||||
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
|
||||
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@v4
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
|
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 0.1.5
|
||||
VERSION: 0.1.6
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, rawhide]
|
||||
fedora_version: [41, 42, 43, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
|
@@ -15,10 +15,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@v5
|
||||
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
@@ -18,7 +18,7 @@ jobs:
|
||||
fedora: ${{ steps.check.outputs.fedora }}
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # 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@v4
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # 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@v4
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # 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
|
||||
image: archlinux:base-devel@sha256:8ccc930c28ab4f483ff9bc1b53957150fbe94afe48928ebb0b14a8af41c75023
|
||||
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@v4
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
|
@@ -20,10 +20,10 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
|
@@ -11,10 +11,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://gitea.com/actions/setup-python@v5
|
||||
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version-file: "pyproject.toml"
|
||||
|
||||
|
@@ -8,12 +8,12 @@ on:
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:46b57bb9816dec6409e7be57e0e5f7b26d214281044f5aedd3b160be178475e2
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
@@ -35,3 +35,4 @@ 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 }}
|
||||
|
22
CHANGELOG.md
@@ -3,6 +3,28 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [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
|
||||
|
||||
### Added
|
||||
|
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
|
||||
</div>
|
||||
@@ -54,6 +54,7 @@ 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).
|
||||
|
||||
|
@@ -45,7 +45,7 @@ AppDir:
|
||||
id: ru.linux_gaming.PortProtonQt
|
||||
name: PortProtonQt
|
||||
icon: ru.linux_gaming.PortProtonQt
|
||||
version: 0.1.5
|
||||
version: 0.1.6
|
||||
exec: usr/bin/python3
|
||||
exec_args: "-m portprotonqt.app $@"
|
||||
apt:
|
||||
|
@@ -1,5 +1,5 @@
|
||||
pkgname=portprotonqt
|
||||
pkgver=0.1.5
|
||||
pkgver=0.1.6
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||
arch=('any')
|
||||
|
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 0.1.5
|
||||
%global pypi_version 0.1.6
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
|
@@ -1,19 +1,30 @@
|
||||
_portprotonqt() {
|
||||
local cur prev
|
||||
_init_completion || return
|
||||
_portprotonqt_completions() {
|
||||
local cur prev opts
|
||||
COMPREPLY=()
|
||||
cur="${COMP_WORDS[COMP_CWORD]}"
|
||||
prev="${COMP_WORDS[COMP_CWORD-1]}"
|
||||
|
||||
case $prev in
|
||||
--help|-h)
|
||||
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
|
||||
;;
|
||||
*)
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$cur" == -* ]]; then
|
||||
COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
|
||||
# Complete options
|
||||
if [[ ${cur} == -* ]]; then
|
||||
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
complete -F _portprotonqt portprotonqt
|
||||
complete -F _portprotonqt_completions portprotonqt
|
||||
|
@@ -1777,7 +1777,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "supervive",
|
||||
"status": "Denied"
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "splitgate 2",
|
||||
@@ -4472,7 +4472,7 @@
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "f1 25",
|
||||
"normalized_name": "battlefield 6",
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
@@ -4482,5 +4482,65 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
13412
data/games_appid.json
@@ -1,4 +1,48 @@
|
||||
[
|
||||
{
|
||||
"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"
|
||||
@@ -235,10 +279,6 @@
|
||||
"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"
|
||||
|
@@ -17,4 +17,6 @@ Generated-By:
|
||||
start.sh
|
||||
EGS
|
||||
Stop Game
|
||||
Fullscreen
|
||||
Fulscreen
|
||||
\t
|
||||
|
@@ -3,8 +3,9 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import re
|
||||
import ast
|
||||
|
||||
# Запрещенные свойства
|
||||
# Запрещенные QSS-свойства
|
||||
FORBIDDEN_PROPERTIES = {
|
||||
"box-shadow",
|
||||
"backdrop-filter",
|
||||
@@ -12,15 +13,55 @@ 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}' on file {qss_file}")
|
||||
print(f"ERROR: Unknown QSS property found '{prop}' in 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__":
|
||||
|
@@ -21,9 +21,9 @@ Current translation status:
|
||||
|
||||
| Locale | Progress | Translated |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 203 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 203 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 203 of 203 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 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 |
|
||||
|
||||
---
|
||||
|
||||
|
@@ -21,9 +21,9 @@
|
||||
|
||||
| Локаль | Прогресс | Переведено |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 203 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 203 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 203 из 203 |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 204 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 204 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 204 из 204 |
|
||||
|
||||
---
|
||||
|
||||
|
@@ -2,8 +2,9 @@ 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__)
|
||||
|
||||
@@ -23,7 +24,8 @@ class SafeOpacityEffect(QGraphicsOpacityEffect):
|
||||
class GameCardAnimations:
|
||||
def __init__(self, game_card, theme=None):
|
||||
self.game_card = game_card
|
||||
self.theme = theme if theme is not None else default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
self.thickness_anim: QPropertyAnimation | None = None
|
||||
self.gradient_anim: QPropertyAnimation | None = None
|
||||
self.scale_anim: QPropertyAnimation | None = None
|
||||
@@ -207,7 +209,7 @@ class GameCardAnimations:
|
||||
|
||||
def paint_border(self, painter: QPainter):
|
||||
if not painter.isActive():
|
||||
logger.warning("Painter is not active; skipping border paint")
|
||||
logger.debug("Painter is not active; skipping border paint")
|
||||
return
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen()
|
||||
@@ -232,7 +234,8 @@ class GameCardAnimations:
|
||||
class DetailPageAnimations:
|
||||
def __init__(self, main_window, theme=None):
|
||||
self.main_window = main_window
|
||||
self.theme = theme if theme is not None else default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
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):
|
||||
@@ -255,7 +258,7 @@ class DetailPageAnimations:
|
||||
try:
|
||||
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
||||
except RuntimeError:
|
||||
logger.debug("Original effect already deleted")
|
||||
logger.warning("Original effect already deleted")
|
||||
animation.finished.connect(restore_effect)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
@@ -314,7 +317,7 @@ class DetailPageAnimations:
|
||||
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
|
||||
animation.stop()
|
||||
except RuntimeError:
|
||||
logger.debug("Animation already deleted for page")
|
||||
logger.warning("Animation already deleted for page")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping existing animation: {e}", exc_info=True)
|
||||
finally:
|
||||
|
@@ -4,14 +4,12 @@ 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
|
||||
from portprotonqt.logger import get_logger, setup_logger
|
||||
from portprotonqt.cli import parse_args
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
__app_version__ = "0.1.5"
|
||||
__app_version__ = "0.1.6"
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
@@ -20,15 +18,21 @@ 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.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
|
||||
|
||||
args = parse_args()
|
||||
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
|
||||
|
||||
window = MainWindow(app_name=__app_name__)
|
||||
|
||||
|
@@ -13,4 +13,10 @@ 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()
|
||||
|
@@ -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,17 +18,32 @@ 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 = {}
|
||||
@@ -44,29 +59,17 @@ 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.
|
||||
"""
|
||||
Читает из конфигурационного файла тему из секции [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"
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None:
|
||||
return "standart"
|
||||
return cp.get("Appearance", "theme", fallback="standart")
|
||||
|
||||
def save_theme_to_config(theme_name):
|
||||
"""
|
||||
Сохраняет имя выбранной темы в секции [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)
|
||||
"""Saves the selected theme name to the [Appearance] section of the configuration file."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Appearance" not in cp:
|
||||
cp["Appearance"] = {}
|
||||
cp["Appearance"]["theme"] = theme_name
|
||||
@@ -74,34 +77,18 @@ 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.
|
||||
"""
|
||||
Читает настройки времени из секции [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"
|
||||
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()
|
||||
|
||||
def save_time_config(detail_level):
|
||||
"""
|
||||
Сохраняет настройку уровня детализации времени в секции [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)
|
||||
"""Saves the time detail level to the [Time] section of the configuration file."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Time" not in cp:
|
||||
cp["Time"] = {}
|
||||
cp["Time"]["detail_level"] = detail_level
|
||||
@@ -109,48 +96,42 @@ 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():
|
||||
"""
|
||||
Возвращает путь к директории PortProton.
|
||||
Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
|
||||
наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
|
||||
используется директория по умолчанию.
|
||||
"""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.
|
||||
"""
|
||||
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 из конфигурации: {location}")
|
||||
logger.info(f"PortProton path from configuration: {location}")
|
||||
return _portproton_location
|
||||
logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
|
||||
logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
|
||||
logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
|
||||
|
||||
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"Используется директория flatpak PortProton: {default_dir}")
|
||||
logger.info(f"Using flatpak PortProton directory: {default_dir}")
|
||||
return _portproton_location
|
||||
|
||||
logger.warning("Конфигурация и директория flatpak PortProton не найдены")
|
||||
logger.warning("PortProton configuration and flatpak directory not found")
|
||||
return None
|
||||
|
||||
def parse_desktop_entry(file_path):
|
||||
"""
|
||||
Читает и парсит .desktop файл с помощью configparser.
|
||||
Если секция [Desktop Entry] отсутствует, возвращается None.
|
||||
"""Reads and parses a .desktop file using configparser.
|
||||
Returns None if the [Desktop Entry] section is missing.
|
||||
"""
|
||||
cp = configparser.ConfigParser(interpolation=None)
|
||||
cp.read(file_path, encoding="utf-8")
|
||||
@@ -159,9 +140,8 @@ def parse_desktop_entry(file_path):
|
||||
return cp["Desktop Entry"]
|
||||
|
||||
def load_theme_metainfo(theme_name):
|
||||
"""
|
||||
Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
|
||||
Ожидаемые поля: author, author_link, description, name.
|
||||
"""Loads theme metadata from metainfo.ini in the theme's root directory.
|
||||
Expected fields: author, author_link, description, name.
|
||||
"""
|
||||
meta = {}
|
||||
for themes_dir in THEMES_DIRS:
|
||||
@@ -179,34 +159,18 @@ 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.
|
||||
"""
|
||||
Читает размер карточек (ширину) из секции [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
|
||||
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)
|
||||
|
||||
def save_card_size(card_width):
|
||||
"""
|
||||
Сохраняет размер карточек (ширину) в секцию [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)
|
||||
"""Saves the card size (width) to the [Cards] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Cards" not in cp:
|
||||
cp["Cards"] = {}
|
||||
cp["Cards"]["card_width"] = str(card_width)
|
||||
@@ -214,34 +178,18 @@ 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.
|
||||
"""
|
||||
Читает метод сортировки из секции [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"
|
||||
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()
|
||||
|
||||
def save_sort_method(sort_method):
|
||||
"""
|
||||
Сохраняет метод сортировки в секцию [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)
|
||||
"""Saves the sort method to the [Games] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Games" not in cp:
|
||||
cp["Games"] = {}
|
||||
cp["Games"]["sort_method"] = sort_method
|
||||
@@ -249,34 +197,18 @@ 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.
|
||||
"""
|
||||
Читает параметр 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"
|
||||
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()
|
||||
|
||||
def save_display_filter(filter_value):
|
||||
"""
|
||||
Сохраняет параметр 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)
|
||||
"""Saves the display_filter parameter to the [Games] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Games" not in cp:
|
||||
cp["Games"] = {}
|
||||
cp["Games"]["display_filter"] = filter_value
|
||||
@@ -284,37 +216,23 @@ 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.
|
||||
"""
|
||||
Читает список избранных игр из секции [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 []
|
||||
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()]
|
||||
|
||||
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.
|
||||
"""
|
||||
Сохраняет список избранных игр в секцию [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)
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Favorites" not in cp:
|
||||
cp["Favorites"] = {}
|
||||
fav_str = ", ".join(favorites)
|
||||
@@ -323,34 +241,18 @@ 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.
|
||||
"""
|
||||
Читает настройку виброотдачи геймпада из секции [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
|
||||
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)
|
||||
|
||||
def save_rumble_config(rumble_enabled):
|
||||
"""
|
||||
Сохраняет настройку виброотдачи геймпада в секцию [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)
|
||||
"""Saves the gamepad rumble setting to the [Gamepad] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Gamepad" not in cp:
|
||||
cp["Gamepad"] = {}
|
||||
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
|
||||
@@ -358,41 +260,28 @@ 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.
|
||||
"""
|
||||
Проверяет наличие секции [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)
|
||||
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)
|
||||
|
||||
def read_proxy_config():
|
||||
"""
|
||||
Читает настройки прокси из секции [Proxy] конфигурационного файла.
|
||||
Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
|
||||
"""Reads proxy settings from the [Proxy] section.
|
||||
Returns an empty dict if proxy_url is not set or empty.
|
||||
"""
|
||||
ensure_default_proxy_config()
|
||||
cp = configparser.ConfigParser()
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфигурационного файла: %s", e)
|
||||
cp = read_config_safely(CONFIG_FILE)
|
||||
if cp is None:
|
||||
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:
|
||||
@@ -402,16 +291,10 @@ 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.
|
||||
"""
|
||||
Сохраняет настройки 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)
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Proxy" not in cp:
|
||||
cp["Proxy"] = {}
|
||||
cp["Proxy"]["proxy_url"] = proxy_url
|
||||
@@ -421,34 +304,18 @@ 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.
|
||||
"""
|
||||
Читает настройку полноэкранного режима приложения из секции [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
|
||||
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)
|
||||
|
||||
def save_fullscreen_config(fullscreen):
|
||||
"""
|
||||
Сохраняет настройку полноэкранного режима приложения в секцию [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)
|
||||
"""Saves the fullscreen mode setting to the [Display] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Display" not in cp:
|
||||
cp["Display"] = {}
|
||||
cp["Display"]["fullscreen"] = str(fullscreen)
|
||||
@@ -456,33 +323,19 @@ 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.
|
||||
"""
|
||||
Читает ширину и высоту окна из секции [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)
|
||||
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)
|
||||
|
||||
def save_window_geometry(width: int, height: int):
|
||||
"""
|
||||
Сохраняет ширину и высоту окна в секцию [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)
|
||||
"""Saves the window width and height to the [MainWindow] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "MainWindow" not in cp:
|
||||
cp["MainWindow"] = {}
|
||||
cp["MainWindow"]["width"] = str(width)
|
||||
@@ -491,59 +344,40 @@ 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("Конфигурационный файл %s удалён", CONFIG_FILE)
|
||||
logger.info("Configuration file %s deleted", CONFIG_FILE)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при удалении конфигурационного файла: %s", e)
|
||||
logger.warning(f"Failed to delete configuration file: {e}")
|
||||
|
||||
def clear_cache():
|
||||
"""
|
||||
Очищает кэш PortProtonQt, удаляя папку кэша.
|
||||
"""
|
||||
"""Clears the PortProtonQt cache by deleting the cache directory."""
|
||||
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 удалён: %s", cache_dir)
|
||||
logger.info("PortProtonQt cache deleted: %s", cache_dir)
|
||||
except Exception as e:
|
||||
logger.error("Ошибка при удалении кэша: %s", e)
|
||||
logger.warning(f"Failed to delete cache: {e}")
|
||||
|
||||
def read_auto_fullscreen_gamepad():
|
||||
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
|
||||
Returns False if the parameter is missing.
|
||||
"""
|
||||
Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [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
|
||||
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)
|
||||
|
||||
def save_auto_fullscreen_gamepad(auto_fullscreen):
|
||||
"""
|
||||
Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [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)
|
||||
"""Saves the auto-fullscreen setting for gamepad to the [Display] section."""
|
||||
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
|
||||
if "Display" not in cp:
|
||||
cp["Display"] = {}
|
||||
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
|
||||
@@ -551,36 +385,23 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
|
||||
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.
|
||||
"""
|
||||
Читает список избранных папок из секции [FavoritesFolders] конфигурационного файла.
|
||||
Список хранится как строка, заключённая в кавычки, с путями, разделёнными запятыми.
|
||||
Если секция или параметр отсутствуют, возвращает пустой список.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
return []
|
||||
if cp.has_section("FavoritesFolders") and cp.has_option("FavoritesFolders", "folders"):
|
||||
favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
|
||||
if favs.startswith('"') and favs.endswith('"'):
|
||||
favs = favs[1:-1]
|
||||
return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
|
||||
return []
|
||||
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.
|
||||
"""
|
||||
Сохраняет список избранных папок в секцию [FavoritesFolders] конфигурационного файла.
|
||||
Список сохраняется как строка, заключённая в двойные кавычки, где пути разделены запятыми.
|
||||
"""
|
||||
cp = configparser.ConfigParser()
|
||||
if os.path.exists(CONFIG_FILE):
|
||||
try:
|
||||
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||
except Exception as e:
|
||||
logger.error("Ошибка чтения конфига: %s", e)
|
||||
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])
|
||||
|
@@ -4,7 +4,6 @@ import glob
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import logging
|
||||
import orjson
|
||||
import psutil
|
||||
import signal
|
||||
@@ -17,8 +16,9 @@ from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_s
|
||||
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 = logging.getLogger(__name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class ContextMenuSignals(QObject):
|
||||
"""Signals for thread-safe UI updates from worker threads."""
|
||||
|
@@ -9,10 +9,9 @@ from PySide6.QtWidgets import (
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
|
||||
from icoextract import IconExtractor, IconExtractorError
|
||||
from PIL import Image
|
||||
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders
|
||||
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
|
||||
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
|
||||
@@ -22,6 +21,7 @@ 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):
|
||||
"""
|
||||
@@ -94,8 +94,7 @@ class GameLaunchDialog(QDialog):
|
||||
"""Modal dialog to indicate game launch progress, similar to Steam's launch dialog."""
|
||||
def __init__(self, parent=None, game_name=None, theme=None, target_exe=None):
|
||||
super().__init__(parent)
|
||||
self.theme = theme if theme else default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.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))
|
||||
@@ -123,7 +122,7 @@ class GameLaunchDialog(QDialog):
|
||||
layout.addWidget(self.progress_bar)
|
||||
|
||||
# Cancel button
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=self.theme_manager.get_icon("cancel"))
|
||||
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)
|
||||
@@ -173,8 +172,7 @@ class GameLaunchDialog(QDialog):
|
||||
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 default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
|
||||
self.file_signal = FileSelectedSignal()
|
||||
self.file_filter = file_filter # Store the file filter
|
||||
self.directory_only = directory_only # Store the directory_only flag
|
||||
@@ -272,8 +270,8 @@ class FileExplorer(QDialog):
|
||||
# Кнопки
|
||||
self.button_layout = QHBoxLayout()
|
||||
self.button_layout.setSpacing(10)
|
||||
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 = AutoSizeButton(_("Select"), icon=theme_manager.get_icon("apply"))
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=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)
|
||||
@@ -406,7 +404,7 @@ class FileExplorer(QDialog):
|
||||
# Добавляем смонтированные диски
|
||||
for drive in drives:
|
||||
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
|
||||
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
|
||||
button = AutoSizeButton(drive_name, icon=theme_manager.get_icon("mount_point"))
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
|
||||
@@ -416,7 +414,7 @@ class FileExplorer(QDialog):
|
||||
# Добавляем избранные папки
|
||||
for folder in favorite_folders:
|
||||
folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
|
||||
button = AutoSizeButton(folder_name, icon=self.theme_manager.get_icon("folder"))
|
||||
button = 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))
|
||||
@@ -484,7 +482,7 @@ class FileExplorer(QDialog):
|
||||
try:
|
||||
if self.current_path != "/":
|
||||
item = QListWidgetItem("../")
|
||||
folder_icon = self.theme_manager.get_icon("folder")
|
||||
folder_icon = 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)
|
||||
@@ -499,7 +497,7 @@ class FileExplorer(QDialog):
|
||||
# Добавляем директории
|
||||
for d in sorted(dirs):
|
||||
item = QListWidgetItem(f"{d}/")
|
||||
folder_icon = self.theme_manager.get_icon("folder")
|
||||
folder_icon = 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)
|
||||
@@ -590,8 +588,7 @@ 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 default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
|
||||
self.edit_mode = edit_mode
|
||||
self.original_name = game_name
|
||||
self.last_exe_path = exe_path # Store last selected exe path
|
||||
@@ -627,7 +624,7 @@ class AddGameDialog(QDialog):
|
||||
if exe_path:
|
||||
self.exeEdit.setText(exe_path)
|
||||
|
||||
exeBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search"))
|
||||
exeBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
|
||||
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
exeBrowseButton.clicked.connect(self.browseExe)
|
||||
exeBrowseButton.setObjectName("exeBrowseButton") # Для поиска кнопки
|
||||
@@ -649,7 +646,7 @@ class AddGameDialog(QDialog):
|
||||
if cover_path:
|
||||
self.coverEdit.setText(cover_path)
|
||||
|
||||
coverBrowseButton = AutoSizeButton(_("Browse..."), icon=self.theme_manager.get_icon("search"))
|
||||
coverBrowseButton = AutoSizeButton(_("Browse..."), icon=theme_manager.get_icon("search"))
|
||||
coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
coverBrowseButton.clicked.connect(self.browseCover)
|
||||
coverBrowseButton.setObjectName("coverBrowseButton") # Для поиска кнопки
|
||||
@@ -678,8 +675,8 @@ class AddGameDialog(QDialog):
|
||||
# Dialog buttons
|
||||
self.button_layout = QHBoxLayout()
|
||||
self.button_layout.setSpacing(10)
|
||||
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 = AutoSizeButton(_("Apply"), icon=theme_manager.get_icon("apply"))
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=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)
|
||||
|
@@ -2,12 +2,10 @@ 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
|
||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config
|
||||
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
|
||||
@@ -56,7 +54,7 @@ class GameCard(QFrame):
|
||||
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 default_styles
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
|
||||
self.display_filter = read_display_filter()
|
||||
self.current_theme_name = read_theme_from_config()
|
||||
|
@@ -3,7 +3,6 @@ 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
|
||||
@@ -177,7 +176,8 @@ class FullscreenDialog(QDialog):
|
||||
|
||||
self.images = images
|
||||
self.current_index = current_index
|
||||
self.theme = theme if theme else default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
@@ -378,7 +378,8 @@ class ImageCarousel(QGraphicsView):
|
||||
self.images = images # Список кортежей: (QPixmap, caption)
|
||||
self.image_items = []
|
||||
self._animation = None
|
||||
self.theme = theme if theme else default_styles
|
||||
self.theme_manager = ThemeManager()
|
||||
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
|
||||
self.max_height = 300 # Default height for images
|
||||
self.init_ui()
|
||||
self.create_arrows()
|
||||
|
@@ -3,10 +3,11 @@ 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.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
||||
from PySide6.QtGui import QKeyEvent
|
||||
from PySide6.QtGui import QKeyEvent, QMouseEvent
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
|
||||
@@ -31,6 +32,8 @@ class MainWindowProtocol(Protocol):
|
||||
...
|
||||
def on_slider_released(self) -> None:
|
||||
...
|
||||
def isActiveWindow(self) -> bool:
|
||||
...
|
||||
stackedWidget: QStackedWidget
|
||||
tabButtons: dict[int, QWidget]
|
||||
gamesListWidget: QWidget
|
||||
@@ -38,23 +41,29 @@ class MainWindowProtocol(Protocol):
|
||||
current_exec_line: str | None
|
||||
current_add_game_dialog: AddGameDialog | None
|
||||
|
||||
# Mapping of actions to evdev button codes, includes Xbox and PlayStation controllers
|
||||
# Mapping of actions to evdev button codes, includes Xbox, PlayStation and Nintendo Switch 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)
|
||||
'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)
|
||||
'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)
|
||||
}
|
||||
|
||||
class GamepadType(Enum):
|
||||
XBOX = "Xbox"
|
||||
PLAYSTATION = "PlayStation"
|
||||
UNKNOWN = "Unknown"
|
||||
|
||||
class InputManager(QObject):
|
||||
"""
|
||||
Manages input from gamepads and keyboards for navigating the application interface.
|
||||
@@ -76,6 +85,7 @@ 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)
|
||||
@@ -132,6 +142,38 @@ 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:
|
||||
@@ -404,17 +446,14 @@ 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']:
|
||||
@@ -559,16 +598,13 @@ 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:
|
||||
@@ -805,6 +841,20 @@ 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__}")
|
||||
@@ -1043,6 +1093,8 @@ 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:
|
||||
@@ -1061,6 +1113,10 @@ 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
|
||||
@@ -1079,6 +1135,13 @@ 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)
|
||||
@@ -1131,5 +1194,7 @@ 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)
|
||||
|
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -360,6 +360,12 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -549,9 +555,6 @@ msgstr ""
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -360,6 +360,12 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -549,9 +555,6 @@ msgstr ""
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"POT-Creation-Date: 2025-09-13 11:51+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"
|
||||
@@ -358,6 +358,12 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -547,9 +553,6 @@ msgstr ""
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -9,8 +9,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-08-31 12:28+0500\n"
|
||||
"PO-Revision-Date: 2025-08-31 12:28+0500\n"
|
||||
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
|
||||
"PO-Revision-Date: 2025-09-13 11:47+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
@@ -367,6 +367,13 @@ msgstr "Настройки PortProton"
|
||||
msgid "Themes"
|
||||
msgstr "Темы"
|
||||
|
||||
msgid "Back"
|
||||
msgstr "Назад"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Fullscreen"
|
||||
msgstr "Полный экран"
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr "Загрузка игр из Steam..."
|
||||
|
||||
@@ -558,9 +565,6 @@ msgstr "Тема '{0}' применена успешно"
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr "Ошибка при применение темы '{0}'"
|
||||
|
||||
msgid "Back"
|
||||
msgstr "Назад"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "Последний запуск"
|
||||
|
||||
|
@@ -1,16 +1,34 @@
|
||||
import logging
|
||||
|
||||
def setup_logger():
|
||||
def setup_logger(level='NOTSET'):
|
||||
"""Настройка базовой конфигурации логирования."""
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='[%(levelname)s] %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
# 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()]
|
||||
)
|
||||
|
||||
def get_logger(name):
|
||||
"""Возвращает логгер для указанного модуля."""
|
||||
return logging.getLogger(name)
|
||||
|
||||
# Инициализация логгера при импорте модуля
|
||||
# Инициализация логгера при импорте модуля (без логов по умолчанию)
|
||||
setup_logger()
|
||||
|
@@ -4,10 +4,9 @@ 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
|
||||
@@ -16,11 +15,12 @@ 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, load_logo
|
||||
from portprotonqt.theme_manager import ThemeManager, load_theme_screenshots
|
||||
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,7 +31,6 @@ 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
|
||||
@@ -60,15 +59,7 @@ class MainWindow(QMainWindow):
|
||||
self.is_exiting = False
|
||||
selected_theme = read_theme_from_config()
|
||||
self.current_theme_name = selected_theme
|
||||
try:
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
|
||||
self.theme = self.theme_manager.apply_theme("standart")
|
||||
self.current_theme_name = "standart"
|
||||
save_theme_to_config("standart")
|
||||
if not self.theme:
|
||||
self.theme = default_styles
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
|
||||
self.card_width = read_card_size()
|
||||
self.setWindowTitle(app_name)
|
||||
@@ -151,32 +142,26 @@ class MainWindow(QMainWindow):
|
||||
self.header.setStyleSheet(self.theme.MAIN_WINDOW_HEADER_STYLE)
|
||||
headerLayout = QVBoxLayout(self.header)
|
||||
headerLayout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Текст "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()
|
||||
|
||||
self.input_manager = InputManager(self)
|
||||
self.input_manager.button_pressed.connect(self.updateControlHints)
|
||||
self.input_manager.dpad_moved.connect(self.updateControlHints)
|
||||
|
||||
# 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
|
||||
self.navWidget = QWidget()
|
||||
self.navWidget.setStyleSheet(self.theme.NAV_WIDGET_STYLE)
|
||||
navLayout = QHBoxLayout(self.navWidget)
|
||||
navLayout.setContentsMargins(10, 0, 10, 0)
|
||||
navLayout.setSpacing(0)
|
||||
navLayout.setSpacing(10)
|
||||
|
||||
navLayout.addWidget(self.titleLabel)
|
||||
# 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)
|
||||
|
||||
# Вкладки
|
||||
self.tabButtons = {}
|
||||
tabs = [
|
||||
_("Library"),
|
||||
@@ -195,6 +180,16 @@ 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 (ВКЛАДКИ)
|
||||
@@ -209,9 +204,12 @@ 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)
|
||||
|
||||
@@ -223,6 +221,212 @@ 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
|
||||
@@ -672,6 +876,8 @@ class MainWindow(QMainWindow):
|
||||
|
||||
sliderLayout = QHBoxLayout()
|
||||
sliderLayout.addStretch()
|
||||
|
||||
# Слайдер
|
||||
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.sizeSlider.setMinimum(200)
|
||||
self.sizeSlider.setMaximum(250)
|
||||
@@ -682,6 +888,7 @@ 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():
|
||||
@@ -1134,36 +1341,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)
|
||||
|
||||
@@ -1205,46 +1412,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):
|
||||
"""Сбрасывает настройки и перезапускает приложение."""
|
||||
@@ -1333,7 +1540,6 @@ 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):
|
||||
@@ -2032,8 +2238,6 @@ 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:
|
||||
@@ -2089,9 +2293,6 @@ 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)
|
||||
@@ -2151,10 +2352,6 @@ 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)
|
||||
@@ -2192,9 +2389,6 @@ 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)
|
||||
@@ -2242,10 +2436,6 @@ 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)
|
||||
|
@@ -22,6 +22,7 @@ import websocket
|
||||
import requests
|
||||
import random
|
||||
import base64
|
||||
import glob
|
||||
|
||||
downloader = Downloader()
|
||||
logger = get_logger(__name__)
|
||||
@@ -265,10 +266,20 @@ 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")
|
||||
@@ -295,6 +306,8 @@ 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)
|
||||
@@ -325,11 +338,15 @@ 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):
|
||||
@@ -427,6 +444,7 @@ 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")
|
||||
@@ -483,11 +501,15 @@ 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):
|
||||
|
@@ -1,9 +1,8 @@
|
||||
import importlib.util
|
||||
import os
|
||||
import ast
|
||||
from portprotonqt.logger import get_logger
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
|
||||
|
||||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
|
||||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -14,6 +13,59 @@ 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():
|
||||
"""
|
||||
@@ -49,9 +101,13 @@ 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":
|
||||
@@ -66,7 +122,7 @@ def load_theme_fonts(theme_name):
|
||||
break
|
||||
|
||||
if not fonts_folder or not os.path.exists(fonts_folder):
|
||||
logger.error(f"Папка fonts не найдена для темы '{theme_name}'")
|
||||
logger.error(f"Fonts folder not found for theme '{theme_name}'")
|
||||
return
|
||||
|
||||
for filename in os.listdir(fonts_folder):
|
||||
@@ -75,29 +131,11 @@ def load_theme_fonts(theme_name):
|
||||
font_id = QFontDatabase.addApplicationFont(font_path)
|
||||
if font_id != -1:
|
||||
families = QFontDatabase.applicationFontFamilies(font_id)
|
||||
logger.info(f"Шрифт {filename} успешно загружен: {families}")
|
||||
logger.info(f"Font {filename} successfully loaded: {families}")
|
||||
else:
|
||||
logger.error(f"Ошибка загрузки шрифта: {filename}")
|
||||
logger.error(f"Error loading font: {filename}")
|
||||
|
||||
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
|
||||
_loaded_theme = theme_name
|
||||
|
||||
class ThemeWrapper:
|
||||
"""
|
||||
@@ -109,69 +147,83 @@ 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)
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
return getattr(default_styles, name)
|
||||
if self._default_theme is None:
|
||||
self._default_theme = load_theme("standart") # Dynamically load standard theme
|
||||
return getattr(self._default_theme, 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"Файл стилей не найден для темы '{theme_name}'")
|
||||
raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
|
||||
|
||||
class ThemeManager:
|
||||
"""
|
||||
Класс для управления темами приложения.
|
||||
|
||||
Позволяет получить список доступных тем, загрузить и применить выбранную тему.
|
||||
Реализует паттерн Singleton для единого экземпляра.
|
||||
"""
|
||||
def __init__(self):
|
||||
self.current_theme_name = None
|
||||
self.current_theme_module = None
|
||||
_instance = None
|
||||
|
||||
def get_available_themes(self):
|
||||
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:
|
||||
"""Возвращает список доступных тем."""
|
||||
return list_themes()
|
||||
|
||||
def get_theme_logo(self):
|
||||
"""Возвращает логотип для текущей или указанной темы."""
|
||||
return load_logo()
|
||||
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 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_name}' успешно применена")
|
||||
logger.info(f"Theme '{theme_name}' successfully applied")
|
||||
return theme_module
|
||||
|
||||
def get_icon(self, icon_name, theme_name=None, as_path=False):
|
||||
@@ -226,7 +278,7 @@ class ThemeManager:
|
||||
|
||||
# Если иконка всё равно не найдена
|
||||
if not icon_path or not os.path.exists(icon_path):
|
||||
logger.error(f"Предупреждение: иконка '{icon_name}' не найдена")
|
||||
logger.error(f"Warning: icon '{icon_name}' not found")
|
||||
return QIcon() if not as_path else None
|
||||
|
||||
if as_path:
|
||||
|
BIN
portprotonqt/themes/standart/images/key_backspace.png
Normal file
After Width: | Height: | Size: 880 B |
BIN
portprotonqt/themes/standart/images/key_context.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
portprotonqt/themes/standart/images/key_e.png
Normal file
After Width: | Height: | Size: 874 B |
BIN
portprotonqt/themes/standart/images/key_enter.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
portprotonqt/themes/standart/images/key_f11.png
Normal file
After Width: | Height: | Size: 943 B |
BIN
portprotonqt/themes/standart/images/key_left.png
Normal file
After Width: | Height: | Size: 933 B |
BIN
portprotonqt/themes/standart/images/key_right.png
Normal file
After Width: | Height: | Size: 956 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
BIN
portprotonqt/themes/standart/images/ps_circle.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
portprotonqt/themes/standart/images/ps_cross.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
portprotonqt/themes/standart/images/ps_l1.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
portprotonqt/themes/standart/images/ps_options.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
portprotonqt/themes/standart/images/ps_r1.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
portprotonqt/themes/standart/images/ps_share.png
Normal file
After Width: | Height: | Size: 1.4 KiB |
BIN
portprotonqt/themes/standart/images/ps_triangle.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 562 KiB After Width: | Height: | Size: 364 KiB |
Before Width: | Height: | Size: 445 KiB After Width: | Height: | Size: 430 KiB |
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.3 MiB |
Before Width: | Height: | Size: 106 KiB After Width: | Height: | Size: 104 KiB |
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 1.0 MiB |
Before Width: | Height: | Size: 7.8 KiB |
BIN
portprotonqt/themes/standart/images/xbox_a.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
portprotonqt/themes/standart/images/xbox_b.png
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
portprotonqt/themes/standart/images/xbox_lb.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
portprotonqt/themes/standart/images/xbox_rb.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
BIN
portprotonqt/themes/standart/images/xbox_start.png
Normal file
After Width: | Height: | Size: 2.1 KiB |
BIN
portprotonqt/themes/standart/images/xbox_view.png
Normal file
After Width: | Height: | Size: 2.2 KiB |
BIN
portprotonqt/themes/standart/images/xbox_x.png
Normal file
After Width: | Height: | Size: 1.8 KiB |
@@ -280,16 +280,6 @@ 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 {{
|
||||
|
@@ -9,7 +9,6 @@ from PySide6.QtGui import QIcon, QAction
|
||||
from PySide6.QtCore import QTimer
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
import portprotonqt.themes.standart.styles as default_styles
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import read_favorites, read_theme_from_config, save_theme_to_config
|
||||
from portprotonqt.dialogs import GameLaunchDialog
|
||||
@@ -31,15 +30,7 @@ class TrayManager:
|
||||
self.theme_manager = ThemeManager()
|
||||
selected_theme = read_theme_from_config()
|
||||
self.current_theme_name = selected_theme
|
||||
try:
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Тема '{selected_theme}' не найдена, применяется стандартная тема 'standart'")
|
||||
self.theme = self.theme_manager.apply_theme("standart")
|
||||
self.current_theme_name = "standart"
|
||||
save_theme_to_config("standart")
|
||||
if not self.theme:
|
||||
self.theme = default_styles
|
||||
self.theme = self.theme_manager.apply_theme(selected_theme)
|
||||
self.main_window = main_window
|
||||
self.tray_icon = QSystemTrayIcon(self.main_window)
|
||||
|
||||
|
@@ -1,10 +1,10 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
requires = ["setuptools >= 77.0.3"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portprotonqt"
|
||||
version = "0.1.5"
|
||||
version = "0.1.6"
|
||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0" }
|
||||
@@ -22,7 +22,7 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Operating System :: Linux"
|
||||
"Operating System :: POSIX :: Linux"
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
|
@@ -5,21 +5,25 @@
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true
|
||||
},
|
||||
"pre-commit": {
|
||||
"enabled": true
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"automerge": true
|
||||
},
|
||||
{
|
||||
"matchDatasources": ["python-version"],
|
||||
"enabled": false
|
||||
"matchFileNames": [".gitea/workflows/build.yml"],
|
||||
"enabled": false,
|
||||
"description": "Disabled because download-artifact@v4 is not working"
|
||||
},
|
||||
{
|
||||
"matchFileNames": [".python-version"],
|
||||
"enabled": false
|
||||
"enabled": false,
|
||||
},
|
||||
{
|
||||
"matchManagers": ["github-actions", "pre-commit", "poetry"],
|
||||
"matchManagers": ["poetry", "pyenv"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
@@ -29,9 +33,14 @@
|
||||
"groupName": "Python dependencies"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["numpy", "setuptools"],
|
||||
"matchPackageNames": ["numpy", "setuptools", "python"],
|
||||
"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"
|
||||
},
|
||||
]
|
||||
}
|
||||
|