Compare commits
74 Commits
Author | SHA1 | Date | |
---|---|---|---|
65b43c1572
|
|||
f35276abfe
|
|||
6fea9a9a7e
|
|||
5189474631
|
|||
|
416cc6a268 | ||
|
3b44ed5252 | ||
c8c45dda06
|
|||
3f9f794e6f
|
|||
ba9d8b76d8
|
|||
e99c71c1f8
|
|||
baec62d1cb
|
|||
cb76961e4f
|
|||
|
081cd07253 | ||
b5efee29ea
|
|||
69360f7e7e | |||
|
39712f0591 | ||
|
60b508af18 | ||
|
b6637b4163 | ||
|
6d9eed42f8 | ||
7372e3b7f5
|
|||
e0d5bd7993 | |||
|
12f8067af1 | ||
|
716a813ca9 | ||
c62cc6853f
|
|||
2e018b4690
|
|||
ad5b25f713
|
|||
3fb8201305
|
|||
04d8302d6c
|
|||
|
f868b21178 | ||
|
ebe25b41d8 | ||
|
fae6cad52d | ||
|
42bce11ada | ||
f088c01768
|
|||
e7eee85ed4
|
|||
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,17 +12,27 @@ jobs:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
|
||||
|
||||
- name: Install tools
|
||||
- name: Upgrade pip toolchain
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
python3 -m pip install --upgrade \
|
||||
pip setuptools setuptools-scm wheel packaging build
|
||||
|
||||
- name: Install appimage-builder
|
||||
run: |
|
||||
git clone https://github.com/Boria138/appimage-builder
|
||||
cd appimage-builder
|
||||
pip install .
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
@@ -42,7 +52,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, rawhide]
|
||||
fedora_version: [41, 42, 43, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -63,7 +73,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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Copy fedora.spec
|
||||
run: |
|
||||
@@ -84,7 +94,7 @@ jobs:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
image: archlinux:base-devel@sha256:5d95edcb6e10fd865e827e93749ecd425f5056880a5a1d8971f5f2a96c7b5a9a
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
@@ -124,7 +134,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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- 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 }}
|
||||
@@ -23,12 +23,22 @@ jobs:
|
||||
- name: Install required dependencies
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
|
||||
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
|
||||
|
||||
- name: Install tools
|
||||
- name: Upgrade pip toolchain
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
python3 -m pip install --upgrade \
|
||||
pip setuptools setuptools-scm wheel packaging build
|
||||
|
||||
- name: Install appimage-builder
|
||||
run: |
|
||||
git clone https://github.com/Boria138/appimage-builder
|
||||
cd appimage-builder
|
||||
pip install .
|
||||
|
||||
- name: Install uv
|
||||
run: |
|
||||
pip install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
@@ -99,7 +109,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, rawhide]
|
||||
fedora_version: [41, 42, 43, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
name: Check Translations
|
||||
name: Check Translations (disabled until yaspeller is fixed)
|
||||
run-name: Check spelling in translation files
|
||||
on:
|
||||
push:
|
||||
@@ -12,13 +12,14 @@ on:
|
||||
|
||||
jobs:
|
||||
check-translations:
|
||||
if: false
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- 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:5d95edcb6e10fd865e827e93749ecd425f5056880a5a1d8971f5f2a96c7b5a9a
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- 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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- 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:e459af116e0cb6c7d5094c0dd4c999d4335d948324192902125b7aff91601a00
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://gitea.com/actions/setup-node@v4
|
||||
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
|
||||
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 }}
|
||||
|
@@ -11,12 +11,12 @@ repos:
|
||||
- id: check-yaml
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.8.9
|
||||
rev: 0.8.22
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.12.8
|
||||
rev: v0.13.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
|
||||
|
40
CHANGELOG.md
@@ -3,6 +3,46 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Возможность скроллинга библиотеки мышью или пальцем
|
||||
|
||||
### Changed
|
||||
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
|
||||
|
||||
### Fixed
|
||||
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
|
||||
- Исправлено зависание при добавлении или удалении игры в Wayland
|
||||
- Исправлено зависание при поиске игр
|
||||
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
|
||||
|
||||
### Contributors
|
||||
|
||||
---
|
||||
|
||||
## [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
|
||||
- @wmigor (Igor Akulov)
|
||||
|
||||
---
|
||||
|
||||
## [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,7 +54,6 @@ PortProtonQt использует код и зависимости от след
|
||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
|
||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
|
||||
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
|
||||
|
||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
|
||||
|
||||
> [!WARNING]
|
||||
|
@@ -1,16 +1,11 @@
|
||||
version: 1
|
||||
script:
|
||||
# 1) чистим старый AppDir
|
||||
- rm -rf AppDir || true
|
||||
# 2) создаём структуру каталога
|
||||
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
|
||||
# 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
|
||||
- uv venv
|
||||
- uv pip install --no-cache-dir ../
|
||||
# 4) копируем всё из .venv в AppDir
|
||||
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
|
||||
- cp -r share AppDir/usr
|
||||
# 5) чистим от ненужных модулей и бинарников
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
|
||||
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
|
||||
@@ -19,7 +14,6 @@ script:
|
||||
AppDir:
|
||||
path: ./AppDir
|
||||
after_bundle:
|
||||
# Документация, справка, примеры
|
||||
- rm -rf $TARGET_APPDIR/usr/share/man || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/doc || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true
|
||||
@@ -35,17 +29,14 @@ AppDir:
|
||||
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true
|
||||
- rm -rf $TARGET_APPDIR/usr/include || true
|
||||
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
|
||||
# Статика и отладка
|
||||
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
|
||||
# Strip ELF бинарников (исключая Python extensions)
|
||||
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
|
||||
# Удаление пустых папок
|
||||
- find $TARGET_APPDIR -type d -empty -delete || true
|
||||
app_info:
|
||||
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:
|
||||
@@ -64,15 +55,12 @@ AppDir:
|
||||
- libimage-exiftool-perl
|
||||
- xdg-utils
|
||||
exclude:
|
||||
# Документация и man-страницы
|
||||
- "*-doc"
|
||||
- "*-man"
|
||||
- manpages
|
||||
- mandb
|
||||
# Статические библиотеки
|
||||
- "*-dev"
|
||||
- "*-static"
|
||||
# Дебаг-символы
|
||||
- "*-dbg"
|
||||
- "*-dbgsym"
|
||||
runtime:
|
||||
@@ -83,3 +71,4 @@ AppDir:
|
||||
AppImage:
|
||||
sign-key: None
|
||||
arch: x86_64
|
||||
comp: zstd
|
||||
|
@@ -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
|
||||
|
@@ -217,7 +217,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "watch_dogs 2",
|
||||
"status": "Broken"
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "zero hour",
|
||||
@@ -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"
|
||||
}
|
||||
]
|
25868
data/games_appid.json
@@ -1,4 +1,140 @@
|
||||
[
|
||||
{
|
||||
"normalized_title": "dirt rally 2.0 game of the year",
|
||||
"slug": "dirt-rally-2-0-game-of-the-year-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "deus ex human revolution director’s cut",
|
||||
"slug": "deus-ex-human-revolution-director-s-cut"
|
||||
},
|
||||
{
|
||||
"normalized_title": "freelancer",
|
||||
"slug": "freelancer"
|
||||
},
|
||||
{
|
||||
"normalized_title": "everspace",
|
||||
"slug": "everspace"
|
||||
},
|
||||
{
|
||||
"normalized_title": "blades of time limited",
|
||||
"slug": "blades-of-time-limited-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "chorus",
|
||||
"slug": "chorus"
|
||||
},
|
||||
{
|
||||
"normalized_title": "tom clancy's splinter cell pandora tomorrow",
|
||||
"slug": "tom-clancys-splinter-cell-pandora-tomorrow"
|
||||
},
|
||||
{
|
||||
"normalized_title": "the alters",
|
||||
"slug": "the-alters"
|
||||
},
|
||||
{
|
||||
"normalized_title": "hard reset redux",
|
||||
"slug": "hard-reset-redux"
|
||||
},
|
||||
{
|
||||
"normalized_title": "far cry 5",
|
||||
"slug": "far-cry-5"
|
||||
},
|
||||
{
|
||||
"normalized_title": "metal eden",
|
||||
"slug": "metal-eden"
|
||||
},
|
||||
{
|
||||
"normalized_title": "indiana jones and the great circle",
|
||||
"slug": "indiana-jones-and-the-great-circle"
|
||||
},
|
||||
{
|
||||
"normalized_title": "old world",
|
||||
"slug": "old-world"
|
||||
},
|
||||
{
|
||||
"normalized_title": "witchfire",
|
||||
"slug": "witchfire"
|
||||
},
|
||||
{
|
||||
"normalized_title": "prototype",
|
||||
"slug": "prototype"
|
||||
},
|
||||
{
|
||||
"normalized_title": "mandragora whispers of the witch tree",
|
||||
"slug": "mandragora-whispers-of-the-witch-tree"
|
||||
},
|
||||
{
|
||||
"normalized_title": "grand theft auto v (gta 5)",
|
||||
"slug": "grand-theft-auto-v-gta-5"
|
||||
},
|
||||
{
|
||||
"normalized_title": "lifeless planet premier",
|
||||
"slug": "lifeless-planet-premier-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "warcraft iii the frozen throne",
|
||||
"slug": "warcraft-iii-the-frozen-throne"
|
||||
},
|
||||
{
|
||||
"normalized_title": "star wars republic commando",
|
||||
"slug": "star-wars-republic-commando"
|
||||
},
|
||||
{
|
||||
"normalized_title": "hollow knight silksong",
|
||||
"slug": "hollow-knight-silksong"
|
||||
},
|
||||
{
|
||||
"normalized_title": "arma reforger",
|
||||
"slug": "arma-reforger"
|
||||
},
|
||||
{
|
||||
"normalized_title": "arma 3",
|
||||
"slug": "arma-3"
|
||||
},
|
||||
{
|
||||
"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"
|
||||
@@ -151,10 +287,6 @@
|
||||
"normalized_title": "slitterhead",
|
||||
"slug": "slitterhead"
|
||||
},
|
||||
{
|
||||
"normalized_title": "indiana jones and the great circle",
|
||||
"slug": "indiana-jones-and-the-great-circle"
|
||||
},
|
||||
{
|
||||
"normalized_title": "crossout",
|
||||
"slug": "crossout"
|
||||
@@ -235,10 +367,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
|
||||
|
@@ -2,6 +2,7 @@
|
||||
|
||||
import argparse
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from datetime import date
|
||||
|
||||
@@ -134,6 +135,12 @@ def main():
|
||||
print(f"Updated version from {old} to {new} in {len(updated)} files:")
|
||||
for p in sorted(updated):
|
||||
print(f" - {p}")
|
||||
|
||||
try:
|
||||
subprocess.run(["uv", "lock"], check=True)
|
||||
print("Regenerated uv.lock")
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Failed to regenerate uv.lock: {e}")
|
||||
else:
|
||||
print(f"No occurrences of version {old} found in specified files.")
|
||||
|
||||
|
@@ -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 193 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 of 193 |
|
||||
|
||||
---
|
||||
|
||||
|
@@ -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 из 193 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 из 193 |
|
||||
|
||||
---
|
||||
|
||||
|
@@ -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__)
|
||||
|
||||
|
@@ -1,16 +1,20 @@
|
||||
import argparse
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
def parse_args():
|
||||
"""
|
||||
Парсит аргументы командной строки.
|
||||
Parses command-line arguments.
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
|
||||
parser.add_argument(
|
||||
"--fullscreen",
|
||||
action="store_true",
|
||||
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
||||
help="Launch the application in fullscreen mode and save this setting"
|
||||
)
|
||||
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."""
|
||||
@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
|
||||
class ContextMenuManager:
|
||||
"""Manages context menu actions for game management in PortProtonQt."""
|
||||
|
||||
def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
|
||||
def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
|
||||
"""
|
||||
Initialize the ContextMenuManager.
|
||||
|
||||
@@ -45,7 +45,8 @@ class ContextMenuManager:
|
||||
self.theme = theme
|
||||
self.theme_manager = ThemeManager()
|
||||
self.load_games = load_games_callback
|
||||
self.update_game_grid = update_game_grid_callback
|
||||
self.game_library_manager = game_library_manager
|
||||
self.update_game_grid = game_library_manager.update_game_grid
|
||||
self.legendary_path = os.path.join(
|
||||
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
|
||||
"PortProtonQt", "legendary_cache", "legendary"
|
||||
@@ -62,7 +63,7 @@ class ContextMenuManager:
|
||||
self.parent.statusBar().showMessage,
|
||||
Qt.ConnectionType.QueuedConnection
|
||||
)
|
||||
logger.debug("Connected show_status_message signal to statusBar")
|
||||
logger.debug("Connected show_status_message signal to status bar")
|
||||
self.signals.show_warning_dialog.connect(
|
||||
self._show_warning_dialog,
|
||||
Qt.ConnectionType.QueuedConnection
|
||||
@@ -74,28 +75,28 @@ class ContextMenuManager:
|
||||
|
||||
def _show_warning_dialog(self, title: str, message: str):
|
||||
"""Show a warning dialog in the main thread."""
|
||||
logger.debug("Showing warning dialog: %s - %s", title, message)
|
||||
logger.debug("Displaying warning dialog: %s - %s", title, message)
|
||||
QMessageBox.warning(self.parent, title, message)
|
||||
|
||||
def _show_info_dialog(self, title: str, message: str):
|
||||
"""Show an info dialog in the main thread."""
|
||||
logger.debug("Showing info dialog: %s - %s", title, message)
|
||||
logger.debug("Displaying info dialog: %s - %s", title, message)
|
||||
QMessageBox.information(self.parent, title, message)
|
||||
|
||||
def _show_status_message(self, message: str, timeout: int = 3000):
|
||||
"""Show a status message on the status bar if available."""
|
||||
if self.parent.statusBar():
|
||||
self.parent.statusBar().showMessage(message, timeout)
|
||||
logger.debug("Direct status message: %s", message)
|
||||
logger.debug("Displayed status message: %s", message)
|
||||
else:
|
||||
logger.warning("Status bar not available for message: %s", message)
|
||||
logger.warning("Status bar unavailable for message: %s", message)
|
||||
|
||||
def _check_portproton(self):
|
||||
"""Check if PortProton is available."""
|
||||
if self.portproton_location is None:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("PortProton is not found")
|
||||
_("PortProton directory not found")
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -119,7 +120,7 @@ class ContextMenuManager:
|
||||
installed_games = orjson.loads(f.read())
|
||||
return app_name in installed_games
|
||||
except (OSError, orjson.JSONDecodeError) as e:
|
||||
logger.error("Failed to read installed.json: %s", e)
|
||||
logger.error("Error reading installed.json: %s", e)
|
||||
return False
|
||||
|
||||
def _is_game_running(self, game_card) -> bool:
|
||||
@@ -155,7 +156,7 @@ class ContextMenuManager:
|
||||
try:
|
||||
item = file_explorer.file_list.itemAt(pos)
|
||||
if not item:
|
||||
logger.debug("No item selected at position %s", pos)
|
||||
logger.debug("No folder selected at position %s", pos)
|
||||
return
|
||||
selected = item.text()
|
||||
if not selected.endswith("/"):
|
||||
@@ -202,7 +203,7 @@ class ContextMenuManager:
|
||||
global_pos = file_explorer.file_list.mapToGlobal(pos)
|
||||
menu.exec(global_pos)
|
||||
except Exception as e:
|
||||
logger.error("Error showing folder context menu: %s", e)
|
||||
logger.error("Error displaying folder context menu: %s", e)
|
||||
|
||||
def toggle_favorite_folder(self, file_explorer, folder_path, add):
|
||||
"""Adds or removes a folder from favorites."""
|
||||
@@ -211,12 +212,12 @@ class ContextMenuManager:
|
||||
if folder_path not in favorite_folders:
|
||||
favorite_folders.append(folder_path)
|
||||
save_favorite_folders(favorite_folders)
|
||||
logger.info(f"Folder added to favorites: {folder_path}")
|
||||
logger.info("Added folder to favorites: %s", folder_path)
|
||||
else:
|
||||
if folder_path in favorite_folders:
|
||||
favorite_folders.remove(folder_path)
|
||||
save_favorite_folders(favorite_folders)
|
||||
logger.info(f"Folder removed from favorites: {folder_path}")
|
||||
logger.info("Removed folder from favorites: %s", folder_path)
|
||||
file_explorer.update_drives_list()
|
||||
|
||||
def _get_safe_icon(self, icon_name: str) -> QIcon:
|
||||
@@ -607,10 +608,10 @@ class ContextMenuManager:
|
||||
exe_path = get_egs_executable(app_name, self.legendary_config_path)
|
||||
if exe_path and os.path.exists(exe_path):
|
||||
if not generate_thumbnail(exe_path, icon_path, size=128):
|
||||
logger.error(f"Failed to generate thumbnail from exe: {exe_path}")
|
||||
logger.error("Failed to generate thumbnail for EGS game: %s", exe_path)
|
||||
icon_path = ""
|
||||
else:
|
||||
logger.error(f"No executable found for EGS game: {app_name}")
|
||||
logger.error("No executable found for EGS game: %s", app_name)
|
||||
icon_path = ""
|
||||
|
||||
egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
|
||||
@@ -750,7 +751,7 @@ Icon={icon_path}
|
||||
if not exec_line:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No executable command in .desktop file for '{game_name}'").format(game_name=game_name)
|
||||
_("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return None
|
||||
else:
|
||||
@@ -762,7 +763,7 @@ Icon={icon_path}
|
||||
except Exception as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to read .desktop file: {error}").format(error=str(e))
|
||||
_("Error reading .desktop file: {error}").format(error=str(e))
|
||||
)
|
||||
return None
|
||||
else:
|
||||
@@ -784,7 +785,7 @@ Icon={icon_path}
|
||||
try:
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
|
||||
logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line)
|
||||
return None
|
||||
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
|
||||
exe_path = entry_exec_split[2]
|
||||
@@ -793,11 +794,11 @@ Icon={icon_path}
|
||||
else:
|
||||
exe_path = entry_exec_split[-1]
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
|
||||
logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None")
|
||||
return None
|
||||
return exe_path
|
||||
except Exception as e:
|
||||
logger.debug("Failed to parse executable for '%s': %s", game_name, e)
|
||||
logger.debug("Error parsing executable for game '%s': %s", game_name, e)
|
||||
return None
|
||||
|
||||
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
|
||||
@@ -859,9 +860,16 @@ Icon={icon_path}
|
||||
_("Failed to delete custom data: {error}").format(error=str(e))
|
||||
)
|
||||
|
||||
# Reload games list and update grid
|
||||
self.load_games()
|
||||
self.update_game_grid()
|
||||
self.update_game_grid = self.game_library_manager.remove_game_incremental
|
||||
self.game_library_manager.remove_game_incremental(game_name, exec_line)
|
||||
|
||||
def add_game_incremental(self, game_data: tuple):
|
||||
"""Add game after .desktop creation."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
# Assume game_data is built from new .desktop (name, desc, cover, etc.)
|
||||
self.game_library_manager.add_game_incremental(game_data)
|
||||
self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0]))
|
||||
|
||||
def add_to_menu(self, game_name, exec_line):
|
||||
"""Copy the .desktop file to ~/.local/share/applications."""
|
||||
@@ -936,7 +944,7 @@ Icon={icon_path}
|
||||
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
|
||||
if not os.path.exists(icon_path):
|
||||
if not generate_thumbnail(exe_path, icon_path, size=128):
|
||||
logger.error(f"Failed to generate thumbnail for {exe_path}")
|
||||
logger.error("Failed to generate thumbnail for game: %s", exe_path)
|
||||
|
||||
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
|
||||
os.makedirs(desktop_dir, exist_ok=True)
|
||||
@@ -1072,7 +1080,7 @@ Icon={icon_path}
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Adding '%s' to Steam", game_name)
|
||||
logger.debug("Adding game '%s' to Steam", game_name)
|
||||
try:
|
||||
success, message = add_to_steam(game_name, exec_line, cover_path)
|
||||
self.signals.show_info_dialog.emit(
|
||||
@@ -1115,7 +1123,7 @@ Icon={icon_path}
|
||||
exe_path = self._parse_exe_path(exec_line, game_name)
|
||||
if not exe_path:
|
||||
return
|
||||
logger.debug("Removing non-EGS game '%s' from Steam", game_name)
|
||||
logger.debug("Removing game '%s' from Steam", game_name)
|
||||
try:
|
||||
success, message = remove_from_steam(game_name, exec_line)
|
||||
self.signals.show_info_dialog.emit(
|
||||
|
@@ -5,29 +5,29 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter
|
||||
|
||||
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
"""
|
||||
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
|
||||
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
|
||||
rect_width: доступная ширина контейнера.
|
||||
spacing: отступ между элементами (горизонтальный и вертикальный).
|
||||
max_scale: максимальный коэффициент масштабирования (например, 1.0).
|
||||
Computes the layout of elements considering spacing and potential scaling of cards.
|
||||
nat_sizes: Array (N, 2) with natural sizes of elements (width, height).
|
||||
rect_width: Available container width.
|
||||
spacing: Spacing between elements (horizontal and vertical).
|
||||
max_scale: Maximum scaling factor (e.g., 1.0).
|
||||
|
||||
Возвращает:
|
||||
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
|
||||
total_height: итоговая высота всех рядов.
|
||||
Returns:
|
||||
result: Array (N, 4), where each row contains [x, y, new_width, new_height].
|
||||
total_height: Total height of all rows.
|
||||
"""
|
||||
N = nat_sizes.shape[0]
|
||||
result = np.zeros((N, 4), dtype=np.int32)
|
||||
y = 0
|
||||
i = 0
|
||||
min_margin = 20 # Минимальный отступ по краям
|
||||
min_margin = 20 # Minimum margin on edges
|
||||
|
||||
# Определяем максимальное количество элементов в ряду и общий масштаб
|
||||
# Determine the maximum number of items per row and overall scale
|
||||
max_items_per_row = 0
|
||||
global_scale = 1.0
|
||||
max_row_x_start = min_margin # Начальная позиция x самого длинного ряда
|
||||
max_row_x_start = min_margin # Starting x position of the widest row
|
||||
temp_i = 0
|
||||
|
||||
# Первый проход: находим максимальное количество элементов в ряду
|
||||
# First pass: Find the maximum number of items in a row
|
||||
while temp_i < N:
|
||||
sum_width = 0
|
||||
count = 0
|
||||
@@ -42,23 +42,23 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
|
||||
if count > max_items_per_row:
|
||||
max_items_per_row = count
|
||||
# Вычисляем масштаб для самого заполненного ряда
|
||||
# Calculate scale for the most populated row
|
||||
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
|
||||
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
|
||||
global_scale = desired_scale if desired_scale < max_scale else max_scale
|
||||
# Сохраняем начальную позицию x для самого длинного ряда
|
||||
# Store starting x position for the widest row
|
||||
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
|
||||
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
|
||||
temp_i = temp_j
|
||||
|
||||
# Второй проход: размещаем элементы
|
||||
# Second pass: Place elements
|
||||
while i < N:
|
||||
sum_width = 0
|
||||
row_max_height = 0
|
||||
count = 0
|
||||
j = i
|
||||
|
||||
# Подбираем количество элементов для текущего ряда
|
||||
# Determine the number of items for the current row
|
||||
while j < N:
|
||||
w = nat_sizes[j, 0]
|
||||
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
|
||||
@@ -70,16 +70,16 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
|
||||
row_max_height = h
|
||||
j += 1
|
||||
|
||||
# Используем глобальный масштаб для всех рядов
|
||||
# Use global scale for all rows
|
||||
scale = global_scale
|
||||
scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
|
||||
|
||||
# Определяем начальную координату x
|
||||
# Determine starting x coordinate
|
||||
if count == max_items_per_row:
|
||||
# Центрируем полный ряд
|
||||
# Center the full row
|
||||
x = max(min_margin, (rect_width - scaled_row_width) // 2)
|
||||
else:
|
||||
# Выравниваем неполный ряд по левому краю, совмещая с началом самого длинного ряда
|
||||
# Align incomplete row to the left, matching the widest row's start
|
||||
x = max_row_x_start
|
||||
|
||||
for k in range(i, j):
|
||||
@@ -99,9 +99,9 @@ class FlowLayout(QLayout):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.itemList = []
|
||||
self.setContentsMargins(20, 20, 20, 20) # Отступы по краям
|
||||
self._spacing = 20 # Отступ для анимации и предотвращения перекрытий
|
||||
self._max_scale = 1.0 # Отключено масштабирование в layout
|
||||
self.setContentsMargins(20, 20, 20, 20) # Margins around the layout
|
||||
self._spacing = 20 # Spacing for animation and overlap prevention
|
||||
self._max_scale = 1.0 # Scaling disabled in layout
|
||||
|
||||
def addItem(self, item: QLayoutItem) -> None:
|
||||
self.itemList.append(item)
|
||||
|
@@ -4,15 +4,14 @@ import re
|
||||
from typing import cast, TYPE_CHECKING
|
||||
from PySide6.QtGui import QPixmap, QIcon
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar
|
||||
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller
|
||||
)
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
|
||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
|
||||
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,17 +172,18 @@ 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
|
||||
self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection
|
||||
self.path_history = {} # Dictionary to store last selected item per directory
|
||||
self.initial_path = initial_path # Store initial path if provided
|
||||
self.thumbnail_cache = {} # Cache for loaded thumbnails
|
||||
self.pending_thumbnails = set() # Track files pending thumbnail loading
|
||||
self.setup_ui()
|
||||
|
||||
# Настройки окна
|
||||
# Window settings
|
||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
|
||||
|
||||
@@ -210,8 +210,115 @@ class FileExplorer(QDialog):
|
||||
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
|
||||
self.update_file_list()
|
||||
|
||||
class ThumbnailLoader(QRunnable):
|
||||
"""Class for asynchronous thumbnail loading in a separate thread."""
|
||||
class Signals(QObject):
|
||||
thumbnail_ready = Signal(str, QIcon) # Signal for ready thumbnail: file path and icon
|
||||
|
||||
def __init__(self, file_path, mime_type, size=64):
|
||||
super().__init__()
|
||||
self.file_path = file_path
|
||||
self.mime_type = mime_type
|
||||
self.size = size
|
||||
self.signals = self.Signals()
|
||||
|
||||
@Slot()
|
||||
def run(self):
|
||||
"""Performs thumbnail loading in a background thread."""
|
||||
try:
|
||||
if self.mime_type.startswith("image/"):
|
||||
pixmap = QPixmap(self.file_path)
|
||||
if not pixmap.isNull():
|
||||
scaled_pixmap = pixmap.scaled(self.size, self.size, Qt.AspectRatioMode.KeepAspectRatio)
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QIcon(scaled_pixmap))
|
||||
else:
|
||||
logger.warning("Failed to load image: %s", self.file_path)
|
||||
elif self.file_path.lower().endswith(".exe"):
|
||||
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
|
||||
if generate_thumbnail(self.file_path, tmp.name, size=self.size):
|
||||
pixmap = QPixmap(tmp.name)
|
||||
if not pixmap.isNull():
|
||||
self.signals.thumbnail_ready.emit(self.file_path, QIcon(pixmap))
|
||||
os.unlink(tmp.name)
|
||||
else:
|
||||
logger.warning("Failed to generate thumbnail for .exe: %s", self.file_path)
|
||||
except Exception as e:
|
||||
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
|
||||
|
||||
|
||||
def async_load_thumbnails(self, files, mime_db):
|
||||
"""
|
||||
Asynchronously loads thumbnails for a list of files.
|
||||
|
||||
Args:
|
||||
files (list): List of file names to process.
|
||||
mime_db (QMimeDatabase): QMimeDatabase instance for file type detection.
|
||||
"""
|
||||
thread_pool = QThreadPool.globalInstance()
|
||||
thread_pool.setMaxThreadCount(4) # Limit the number of threads
|
||||
|
||||
for f in files:
|
||||
file_path = os.path.join(self.current_path, f)
|
||||
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
|
||||
continue # Skip if already cached or pending
|
||||
mime_type = mime_db.mimeTypeForFile(file_path).name()
|
||||
if mime_type.startswith("image/") or file_path.lower().endswith(".exe"):
|
||||
self.pending_thumbnails.add(file_path)
|
||||
loader = self.ThumbnailLoader(file_path, mime_type, size=64)
|
||||
loader.signals.thumbnail_ready.connect(self.update_thumbnail)
|
||||
thread_pool.start(loader)
|
||||
|
||||
|
||||
@Slot(str, QIcon)
|
||||
def update_thumbnail(self, file_path, icon):
|
||||
"""
|
||||
Updates the icon for a file list item after thumbnail loading.
|
||||
|
||||
Args:
|
||||
file_path (str): Path to the file for which the thumbnail was loaded.
|
||||
icon (QIcon): Loaded icon.
|
||||
"""
|
||||
try:
|
||||
# Cache the thumbnail
|
||||
self.thumbnail_cache[file_path] = icon
|
||||
self.pending_thumbnails.discard(file_path)
|
||||
# Update the item in the file list
|
||||
file_name = os.path.basename(file_path)
|
||||
for i in range(self.file_list.count()):
|
||||
item = self.file_list.item(i)
|
||||
if item.text() == file_name:
|
||||
item.setIcon(icon)
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error updating thumbnail for %s: %s", file_path, str(e))
|
||||
|
||||
|
||||
def load_visible_thumbnails(self):
|
||||
"""Load thumbnails only for visible items in the file list."""
|
||||
try:
|
||||
visible_range = self.file_list.count()
|
||||
first_visible = max(0, self.file_list.indexAt(self.file_list.viewport().rect().topLeft()).row())
|
||||
last_visible = min(visible_range - 1, self.file_list.indexAt(self.file_list.viewport().rect().bottomRight()).row() + 5)
|
||||
|
||||
files_to_load = []
|
||||
for i in range(first_visible, last_visible + 1):
|
||||
item = self.file_list.item(i)
|
||||
if not item:
|
||||
continue
|
||||
file_name = item.text()
|
||||
if file_name.endswith("/"):
|
||||
continue # Skip directories
|
||||
file_path = os.path.join(self.current_path, file_name)
|
||||
if file_path not in self.thumbnail_cache and file_path not in self.pending_thumbnails:
|
||||
files_to_load.append(file_name)
|
||||
|
||||
if files_to_load:
|
||||
self.async_load_thumbnails(files_to_load, self.mime_db)
|
||||
except Exception as e:
|
||||
logger.error("Error loading visible thumbnails: %s", str(e))
|
||||
|
||||
def get_mounted_drives(self):
|
||||
"""Получение списка смонтированных дисков из /proc/mounts, исключая системные пути"""
|
||||
"""Retrieve a list of mounted drives from /proc/mounts, excluding system paths."""
|
||||
mounted_drives = []
|
||||
try:
|
||||
with open('/proc/mounts') as f:
|
||||
@@ -220,20 +327,20 @@ class FileExplorer(QDialog):
|
||||
if len(parts) < 2:
|
||||
continue
|
||||
mount_point = parts[1]
|
||||
# Исключаем системные и временные пути, но сохраняем /run/media
|
||||
# Exclude system and temporary paths, but keep /run/media
|
||||
if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
|
||||
(mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
|
||||
continue
|
||||
# Проверяем, является ли точка монтирования директорией и доступна ли она
|
||||
# Check if the mount point is a directory and accessible
|
||||
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
|
||||
mounted_drives.append(mount_point)
|
||||
return sorted(mounted_drives)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при получении смонтированных дисков: {e}")
|
||||
logger.error(f"Error retrieving mounted drives: {e}")
|
||||
return []
|
||||
|
||||
def setup_ui(self):
|
||||
"""Настройка интерфейса"""
|
||||
"""Set up the user interface."""
|
||||
self.setWindowTitle(_("File Explorer"))
|
||||
self.setGeometry(100, 100, 600, 600)
|
||||
|
||||
@@ -242,7 +349,7 @@ class FileExplorer(QDialog):
|
||||
self.main_layout.setSpacing(10)
|
||||
self.setLayout(self.main_layout)
|
||||
|
||||
# Панель для смонтированных дисков и избранных папок
|
||||
# Panel for mounted drives and favorite folders
|
||||
self.drives_layout = QHBoxLayout()
|
||||
self.drives_scroll = QScrollArea()
|
||||
self.drives_scroll.setWidgetResizable(True)
|
||||
@@ -255,25 +362,31 @@ class FileExplorer(QDialog):
|
||||
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
|
||||
# Путь
|
||||
# Path label
|
||||
self.path_label = QLabel()
|
||||
self.path_label.setStyleSheet(self.theme.FILE_EXPLORER_PATH_LABEL_STYLE)
|
||||
self.main_layout.addWidget(self.path_label)
|
||||
|
||||
# Список файлов
|
||||
# File list
|
||||
self.file_list = QListWidget()
|
||||
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
|
||||
self.file_list.itemClicked.connect(self.handle_item_click)
|
||||
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
|
||||
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
|
||||
self.file_list.setHorizontalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
|
||||
self.file_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
|
||||
QScroller.grabGesture(self.file_list.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
|
||||
self.main_layout.addWidget(self.file_list)
|
||||
|
||||
# Кнопки
|
||||
# Connect scroll signal for lazy loading
|
||||
self.file_list.verticalScrollBar().valueChanged.connect(self.load_visible_thumbnails)
|
||||
|
||||
# Buttons
|
||||
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)
|
||||
@@ -291,40 +404,40 @@ class FileExplorer(QDialog):
|
||||
logger.warning("ContextMenuManager not found in parent")
|
||||
|
||||
def move_selection(self, direction):
|
||||
"""Перемещение выбора по списку"""
|
||||
"""Move selection in the list."""
|
||||
current_row = self.file_list.currentRow()
|
||||
if direction < 0 and current_row > 0: # Вверх
|
||||
if direction < 0 and current_row > 0: # Up
|
||||
self.file_list.setCurrentRow(current_row - 1)
|
||||
elif direction > 0 and current_row < self.file_list.count() - 1: # Вниз
|
||||
elif direction > 0 and current_row < self.file_list.count() - 1: # Down
|
||||
self.file_list.setCurrentRow(current_row + 1)
|
||||
self.file_list.scrollToItem(self.file_list.currentItem())
|
||||
|
||||
def handle_item_click(self, item):
|
||||
"""Обработка одинарного клика мышью"""
|
||||
"""Handle single mouse click."""
|
||||
try:
|
||||
self.file_list.setCurrentItem(item)
|
||||
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент
|
||||
self.path_history[self.current_path] = item.text() # Save selected item
|
||||
logger.debug("Selected item: %s", item.text())
|
||||
except Exception as e:
|
||||
logger.error("Error in handle_item_click: %s", e)
|
||||
|
||||
def handle_item_double_click(self, item):
|
||||
"""Обработка двойного клика мышью по элементу списка"""
|
||||
"""Handle double mouse click on a list item."""
|
||||
try:
|
||||
self.file_list.setCurrentItem(item)
|
||||
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент
|
||||
self.path_history[self.current_path] = item.text() # Save selected item
|
||||
selected = item.text()
|
||||
full_path = os.path.join(self.current_path, selected)
|
||||
if os.path.isdir(full_path):
|
||||
if selected == "../":
|
||||
# Переходим в родительскую директорию
|
||||
# Navigate to parent directory
|
||||
self.previous_dir()
|
||||
else:
|
||||
# Открываем директорию
|
||||
# Open directory
|
||||
self.current_path = os.path.normpath(full_path)
|
||||
self.update_file_list()
|
||||
elif not self.directory_only:
|
||||
# Выбираем файл, если directory_only=False
|
||||
# Select file if directory_only=False
|
||||
self.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.accept()
|
||||
else:
|
||||
@@ -333,7 +446,7 @@ class FileExplorer(QDialog):
|
||||
logger.error("Error in handle_item_double_click: %s", e)
|
||||
|
||||
def select_item(self):
|
||||
"""Обработка выбора файла/папки"""
|
||||
"""Handle file/folder selection."""
|
||||
if self.file_list.count() == 0:
|
||||
return
|
||||
|
||||
@@ -342,30 +455,30 @@ class FileExplorer(QDialog):
|
||||
|
||||
if os.path.isdir(full_path):
|
||||
if self.directory_only:
|
||||
# Подтверждаем выбор директории
|
||||
# Confirm directory selection
|
||||
self.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.accept()
|
||||
else:
|
||||
# Открываем директорию
|
||||
# Open directory
|
||||
self.current_path = os.path.normpath(full_path)
|
||||
self.update_file_list()
|
||||
else:
|
||||
if not self.directory_only:
|
||||
# Для файла отправляем нормализованный путь
|
||||
# Emit normalized path for file
|
||||
self.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||
self.accept()
|
||||
else:
|
||||
logger.debug("Selected item is not a directory, ignoring: %s", full_path)
|
||||
|
||||
def previous_dir(self):
|
||||
"""Возврат к родительской директории"""
|
||||
"""Navigate to parent directory."""
|
||||
try:
|
||||
if self.current_path == "/":
|
||||
return # Уже в корне
|
||||
return # Already at root
|
||||
|
||||
# Нормализуем путь (убираем конечный слеш, если есть)
|
||||
# Normalize path (remove trailing slash if present)
|
||||
normalized_path = os.path.normpath(self.current_path)
|
||||
# Получаем родительскую директорию
|
||||
# Get parent directory
|
||||
parent_dir = os.path.dirname(normalized_path)
|
||||
|
||||
if not parent_dir:
|
||||
@@ -391,7 +504,7 @@ class FileExplorer(QDialog):
|
||||
logger.error(f"Error ensuring button visible: {e}")
|
||||
|
||||
def update_drives_list(self):
|
||||
"""Обновление списка смонтированных дисков и избранных папок."""
|
||||
"""Update the list of mounted drives and favorite folders."""
|
||||
for i in reversed(range(self.drives_layout.count())):
|
||||
item = self.drives_layout.itemAt(i)
|
||||
if item and item.widget():
|
||||
@@ -403,112 +516,112 @@ class FileExplorer(QDialog):
|
||||
drives = self.get_mounted_drives()
|
||||
favorite_folders = read_favorite_folders()
|
||||
|
||||
# Добавляем смонтированные диски
|
||||
# Add mounted drives
|
||||
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))
|
||||
self.drives_layout.addWidget(button)
|
||||
self.drive_buttons.append(button)
|
||||
|
||||
# Добавляем избранные папки
|
||||
# Add favorite folders
|
||||
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))
|
||||
self.drives_layout.addWidget(button)
|
||||
self.drive_buttons.append(button)
|
||||
|
||||
# Добавляем растяжку, чтобы выровнять элементы
|
||||
# Add spacer to align elements
|
||||
spacer = QWidget()
|
||||
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.drives_layout.addWidget(spacer)
|
||||
|
||||
def select_drive(self):
|
||||
"""Обрабатывает выбор диска или избранной папки через геймпад."""
|
||||
"""Handle drive or favorite folder selection via gamepad."""
|
||||
focused_widget = QApplication.focusWidget()
|
||||
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
|
||||
drive_name = focused_widget.text().strip() # Удаляем пробелы
|
||||
logger.debug(f"Выбрано имя: {drive_name}")
|
||||
drive_name = focused_widget.text().strip() # Remove whitespace
|
||||
logger.debug(f"Selected name: {drive_name}")
|
||||
|
||||
# Специальная обработка корневого каталога
|
||||
# Special handling for root directory
|
||||
if drive_name == "/":
|
||||
if os.path.isdir("/") and os.access("/", os.R_OK):
|
||||
self.current_path = "/"
|
||||
self.update_file_list()
|
||||
logger.info("Выбран корневой каталог: /")
|
||||
logger.info("Selected root directory")
|
||||
return
|
||||
else:
|
||||
logger.warning("Корневой каталог недоступен: недостаточно прав или ошибка пути")
|
||||
logger.warning("Root directory is inaccessible: insufficient permissions or path error")
|
||||
return
|
||||
|
||||
# Проверяем избранные папки
|
||||
# Check favorite folders
|
||||
favorite_folders = read_favorite_folders()
|
||||
logger.debug(f"Избранные папки: {favorite_folders}")
|
||||
logger.debug(f"Favorite folders: {favorite_folders}")
|
||||
for folder in favorite_folders:
|
||||
folder_name = os.path.basename(os.path.normpath(folder)) or folder # Для корневых путей
|
||||
folder_name = os.path.basename(os.path.normpath(folder)) or folder # For root paths
|
||||
if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
|
||||
self.current_path = os.path.normpath(folder)
|
||||
self.update_file_list()
|
||||
logger.info(f"Выбрана избранная папка: {self.current_path}")
|
||||
logger.info(f"Selected favorite folder: {self.current_path}")
|
||||
return
|
||||
|
||||
# Проверяем смонтированные диски
|
||||
# Check mounted drives
|
||||
mounted_drives = self.get_mounted_drives()
|
||||
logger.debug(f"Смонтированные диски: {mounted_drives}")
|
||||
logger.debug(f"Mounted drives: {mounted_drives}")
|
||||
for drive in mounted_drives:
|
||||
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # Для корневых путей
|
||||
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # For root paths
|
||||
if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
|
||||
self.current_path = os.path.normpath(drive)
|
||||
self.update_file_list()
|
||||
logger.info(f"Выбран смонтированный диск: {self.current_path}")
|
||||
logger.info(f"Selected mounted drive: {self.current_path}")
|
||||
return
|
||||
|
||||
logger.warning(f"Путь недоступен: {drive_name}.")
|
||||
logger.warning(f"Path is inaccessible: {drive_name}.")
|
||||
|
||||
def change_drive(self, drive_path):
|
||||
"""Переход к выбранному диску"""
|
||||
"""Navigate to the selected drive."""
|
||||
if os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
|
||||
self.current_path = os.path.normpath(drive_path)
|
||||
self.update_file_list()
|
||||
else:
|
||||
logger.warning(f"Путь диска недоступен: {drive_path}")
|
||||
logger.warning(f"Drive path is inaccessible: {drive_path}")
|
||||
|
||||
def update_file_list(self):
|
||||
"""Обновление списка файлов с превью в виде иконок"""
|
||||
"""Update the file list with asynchronous thumbnail loading."""
|
||||
self.file_list.clear()
|
||||
self.thumbnail_cache.clear() # Clear cache when changing directories
|
||||
self.pending_thumbnails.clear() # Clear pending thumbnails
|
||||
try:
|
||||
if self.current_path != "/":
|
||||
item = QListWidgetItem("../")
|
||||
folder_icon = self.theme_manager.get_icon("folder")
|
||||
# Ensure the icon is a QIcon
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
|
||||
folder_icon = QIcon(folder_icon)
|
||||
elif not isinstance(folder_icon, QIcon):
|
||||
folder_icon = QIcon() # Fallback to empty icon
|
||||
folder_icon = QIcon()
|
||||
item.setIcon(folder_icon)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
items = os.listdir(self.current_path)
|
||||
dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))]
|
||||
|
||||
# Добавляем директории
|
||||
# Add directories
|
||||
for d in sorted(dirs):
|
||||
item = QListWidgetItem(f"{d}/")
|
||||
folder_icon = self.theme_manager.get_icon("folder")
|
||||
# Ensure the icon is a QIcon
|
||||
folder_icon = theme_manager.get_icon("folder")
|
||||
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
|
||||
folder_icon = QIcon(folder_icon)
|
||||
elif not isinstance(folder_icon, QIcon):
|
||||
folder_icon = QIcon() # Fallback to empty icon
|
||||
folder_icon = QIcon()
|
||||
item.setIcon(folder_icon)
|
||||
self.file_list.addItem(item)
|
||||
|
||||
# Добавляем файлы только если directory_only=False
|
||||
# Add files only if directory_only=False
|
||||
if not self.directory_only:
|
||||
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
|
||||
if self.file_filter:
|
||||
@@ -517,26 +630,14 @@ class FileExplorer(QDialog):
|
||||
elif isinstance(self.file_filter, tuple):
|
||||
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
|
||||
|
||||
# Add files to the list without immediate thumbnail loading
|
||||
for f in sorted(files):
|
||||
item = QListWidgetItem(f)
|
||||
file_path = os.path.join(self.current_path, f)
|
||||
mime_type = self.mime_db.mimeTypeForFile(file_path).name()
|
||||
|
||||
if mime_type.startswith("image/"):
|
||||
pixmap = QPixmap(file_path)
|
||||
if not pixmap.isNull():
|
||||
item.setIcon(QIcon(pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)))
|
||||
elif file_path.lower().endswith(".exe"):
|
||||
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
|
||||
tmp.close()
|
||||
if generate_thumbnail(file_path, tmp.name, size=64):
|
||||
pixmap = QPixmap(tmp.name)
|
||||
if not pixmap.isNull():
|
||||
item.setIcon(QIcon(pixmap))
|
||||
os.unlink(tmp.name)
|
||||
|
||||
self.file_list.addItem(item)
|
||||
|
||||
# Load thumbnails for visible items only
|
||||
self.load_visible_thumbnails()
|
||||
|
||||
self.path_label.setText(_("Path: ") + self.current_path)
|
||||
|
||||
# Restore last selected item for this directory
|
||||
@@ -558,10 +659,10 @@ class FileExplorer(QDialog):
|
||||
self.file_list.setAlternatingRowColors(True)
|
||||
|
||||
except PermissionError:
|
||||
self.path_label.setText(f"Access denied: {self.current_path}")
|
||||
self.path_label.setText(_("Access denied: %s") % self.current_path)
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Закрытие окна"""
|
||||
"""Handle window closing."""
|
||||
try:
|
||||
if self.input_manager:
|
||||
self.input_manager.disable_file_explorer_mode()
|
||||
@@ -575,13 +676,13 @@ class FileExplorer(QDialog):
|
||||
super().closeEvent(event)
|
||||
|
||||
def reject(self):
|
||||
"""Закрытие диалога"""
|
||||
"""Close the dialog."""
|
||||
if self.input_manager:
|
||||
self.input_manager.disable_file_explorer_mode()
|
||||
super().reject()
|
||||
|
||||
def accept(self):
|
||||
"""Принятие диалога"""
|
||||
"""Accept the dialog."""
|
||||
if self.input_manager:
|
||||
self.input_manager.disable_file_explorer_mode()
|
||||
super().accept()
|
||||
@@ -590,8 +691,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 +727,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 +749,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 +778,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()
|
||||
|
453
portprotonqt/game_library_manager.py
Normal file
@@ -0,0 +1,453 @@
|
||||
from typing import Protocol
|
||||
from portprotonqt.game_card import GameCard
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from portprotonqt.custom_widgets import FlowLayout
|
||||
from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
|
||||
from collections import deque
|
||||
|
||||
class MainWindowProtocol(Protocol):
|
||||
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
|
||||
|
||||
def openGameDetailPage(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
cover_path: str | None = None,
|
||||
appid: str = "",
|
||||
exec_line: str = "",
|
||||
controller_support: str = "",
|
||||
last_launch: str = "",
|
||||
formatted_playtime: str = "",
|
||||
protondb_tier: str = "",
|
||||
game_source: str = "",
|
||||
anticheat_status: str = "",
|
||||
) -> None: ...
|
||||
|
||||
def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ...
|
||||
|
||||
def on_slider_released(self) -> None: ...
|
||||
|
||||
# Required attributes
|
||||
searchEdit: CustomLineEdit
|
||||
_last_card_width: int
|
||||
current_hovered_card: GameCard | None
|
||||
current_focused_card: GameCard | None
|
||||
|
||||
class GameLibraryManager:
|
||||
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
|
||||
self.main_window = main_window
|
||||
self.theme = theme
|
||||
self.context_menu_manager: ContextMenuManager | None = context_menu_manager
|
||||
self.games: list[tuple] = []
|
||||
self.filtered_games: list[tuple] = []
|
||||
self.game_card_cache = {}
|
||||
self.pending_images = {}
|
||||
self.card_width = read_card_size()
|
||||
self.gamesListWidget: QWidget | None = None
|
||||
self.gamesListLayout: FlowLayout | None = None
|
||||
self.sizeSlider: QSlider | None = None
|
||||
self._update_timer: QTimer | None = None
|
||||
self._pending_update = False
|
||||
self.pending_deletions = deque()
|
||||
self.is_filtering = False
|
||||
self.dirty = False
|
||||
|
||||
def create_games_library_widget(self):
|
||||
"""Creates the games library widget with search, grid, and slider."""
|
||||
self.gamesLibraryWidget = QWidget()
|
||||
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(self.gamesLibraryWidget)
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Search widget
|
||||
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
|
||||
layout.addWidget(searchWidget)
|
||||
|
||||
# Scroll area for game grid
|
||||
scrollArea = QScrollArea()
|
||||
scrollArea.setWidgetResizable(True)
|
||||
scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
|
||||
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
|
||||
|
||||
self.gamesListWidget = QWidget()
|
||||
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
|
||||
self.gamesListLayout = FlowLayout(self.gamesListWidget)
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
|
||||
scrollArea.setWidget(self.gamesListWidget)
|
||||
layout.addWidget(scrollArea)
|
||||
|
||||
# Slider for card size
|
||||
sliderLayout = QHBoxLayout()
|
||||
sliderLayout.addStretch()
|
||||
|
||||
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
|
||||
self.sizeSlider.setMinimum(200)
|
||||
self.sizeSlider.setMaximum(250)
|
||||
self.sizeSlider.setValue(self.card_width)
|
||||
self.sizeSlider.setTickInterval(10)
|
||||
self.sizeSlider.setFixedWidth(150)
|
||||
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
||||
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
|
||||
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
|
||||
sliderLayout.addWidget(self.sizeSlider)
|
||||
|
||||
layout.addLayout(sliderLayout)
|
||||
|
||||
# Initialize update timer
|
||||
self._update_timer = QTimer()
|
||||
self._update_timer.setSingleShot(True)
|
||||
self._update_timer.setInterval(100) # 100ms debounce
|
||||
self._update_timer.timeout.connect(self._perform_update)
|
||||
|
||||
# Calculate initial card width
|
||||
def calculate_card_width():
|
||||
if self.gamesListLayout is None:
|
||||
return
|
||||
available_width = scrollArea.width() - 20
|
||||
spacing = self.gamesListLayout._spacing
|
||||
target_cards_per_row = 8
|
||||
calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
|
||||
calculated_width = max(200, min(calculated_width, 250))
|
||||
|
||||
QTimer.singleShot(0, calculate_card_width)
|
||||
|
||||
# Connect scroll event for lazy loading
|
||||
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
|
||||
return self.gamesLibraryWidget
|
||||
|
||||
def on_slider_released(self):
|
||||
"""Handles slider release to update card size."""
|
||||
if self.sizeSlider is None:
|
||||
return
|
||||
self.card_width = self.sizeSlider.value()
|
||||
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
||||
save_card_size(self.card_width)
|
||||
for card in self.game_card_cache.values():
|
||||
card.update_card_size(self.card_width)
|
||||
self.update_game_grid()
|
||||
|
||||
def load_visible_images(self):
|
||||
"""Loads images for visible game cards."""
|
||||
if self.gamesListWidget is None:
|
||||
return
|
||||
visible_region = self.gamesListWidget.visibleRegion()
|
||||
max_concurrent_loads = 5
|
||||
loaded_count = 0
|
||||
for card_key, card in self.game_card_cache.items():
|
||||
if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads:
|
||||
cover_path, width, height, callback = self.pending_images.pop(card_key)
|
||||
load_pixmap_async(cover_path, width, height, callback)
|
||||
loaded_count += 1
|
||||
|
||||
def _on_card_focused(self, game_name: str, is_focused: bool):
|
||||
"""Handles card focus events."""
|
||||
card_key = None
|
||||
for key, card in self.game_card_cache.items():
|
||||
if card.name == game_name:
|
||||
card_key = key
|
||||
break
|
||||
|
||||
if not card_key:
|
||||
return
|
||||
|
||||
card = self.game_card_cache[card_key]
|
||||
|
||||
if is_focused:
|
||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
self.main_window.current_hovered_card = None
|
||||
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
||||
self.main_window.current_focused_card._focused = False
|
||||
self.main_window.current_focused_card.clearFocus()
|
||||
self.main_window.current_focused_card = card
|
||||
else:
|
||||
if self.main_window.current_focused_card == card:
|
||||
self.main_window.current_focused_card = None
|
||||
|
||||
def _on_card_hovered(self, game_name: str, is_hovered: bool):
|
||||
"""Handles card hover events."""
|
||||
card_key = None
|
||||
for key, card in self.game_card_cache.items():
|
||||
if card.name == game_name:
|
||||
card_key = key
|
||||
break
|
||||
|
||||
if not card_key:
|
||||
return
|
||||
|
||||
card = self.game_card_cache[card_key]
|
||||
|
||||
if is_hovered:
|
||||
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
|
||||
self.main_window.current_focused_card._focused = False
|
||||
self.main_window.current_focused_card.clearFocus()
|
||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
self.main_window.current_hovered_card = card
|
||||
else:
|
||||
if self.main_window.current_hovered_card == card:
|
||||
self.main_window.current_hovered_card = None
|
||||
|
||||
def _perform_update(self):
|
||||
"""Performs the actual grid update."""
|
||||
if not self._pending_update:
|
||||
return
|
||||
self._pending_update = False
|
||||
self._update_game_grid_immediate()
|
||||
|
||||
def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False):
|
||||
"""Schedules a game grid update with debouncing."""
|
||||
if not is_filter:
|
||||
if games_list is not None:
|
||||
self.filtered_games = games_list
|
||||
self.dirty = True # Full rebuild only for non-filter
|
||||
self.is_filtering = is_filter
|
||||
self._pending_update = True
|
||||
|
||||
if self._update_timer is not None:
|
||||
self._update_timer.start()
|
||||
else:
|
||||
self._update_game_grid_immediate()
|
||||
|
||||
def _update_game_grid_immediate(self):
|
||||
"""Updates the game grid with the provided or current game list."""
|
||||
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||
return
|
||||
|
||||
search_text = self.main_window.searchEdit.text().strip().lower()
|
||||
|
||||
if self.is_filtering:
|
||||
# Filter mode: do not change layout, only hide/show cards
|
||||
self._apply_filter_visibility(search_text)
|
||||
else:
|
||||
# Full update: sorting, removal/addition, reorganization
|
||||
games_list = self.filtered_games if self.filtered_games else self.games
|
||||
favorites = read_favorites()
|
||||
sort_method = read_sort_method()
|
||||
|
||||
# Batch layout updates (extended scope)
|
||||
self.gamesListWidget.setUpdatesEnabled(False)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(False) # Disable layout during batch
|
||||
|
||||
try:
|
||||
# Optimized sorting: Partition favorites first, then sort subgroups
|
||||
def partition_sort_key(game):
|
||||
name = game[0]
|
||||
is_fav = name in favorites
|
||||
fav_order = 0 if is_fav else 1
|
||||
if sort_method == "playtime":
|
||||
return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0)
|
||||
elif sort_method == "alphabetical":
|
||||
return (fav_order, name.lower())
|
||||
elif sort_method == "favorites":
|
||||
return (fav_order,)
|
||||
else:
|
||||
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
|
||||
|
||||
# Quick partition: Sort favorites and non-favorites separately, then merge
|
||||
fav_games = [g for g in games_list if g[0] in favorites]
|
||||
non_fav_games = [g for g in games_list if g[0] not in favorites]
|
||||
sorted_fav = sorted(fav_games, key=partition_sort_key)
|
||||
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
|
||||
sorted_games = sorted_fav + sorted_non_fav
|
||||
|
||||
# Build set of current game keys for faster lookup
|
||||
current_game_keys = {(game[0], game[4]) for game in sorted_games}
|
||||
|
||||
# Remove cards that no longer exist (batch)
|
||||
cards_to_remove = []
|
||||
for card_key in list(self.game_card_cache.keys()):
|
||||
if card_key not in current_game_keys:
|
||||
cards_to_remove.append(card_key)
|
||||
|
||||
for card_key in cards_to_remove:
|
||||
card = self.game_card_cache.pop(card_key)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.removeWidget(card)
|
||||
self.pending_deletions.append(card) # Defer
|
||||
if card_key in self.pending_images:
|
||||
del self.pending_images[card_key]
|
||||
|
||||
# Track current layout order (only if dirty/full update needed)
|
||||
if self.dirty and self.gamesListLayout is not None:
|
||||
current_layout_order = []
|
||||
for i in range(self.gamesListLayout.count()):
|
||||
item = self.gamesListLayout.itemAt(i)
|
||||
if item is not None:
|
||||
widget = item.widget()
|
||||
if widget:
|
||||
for key, card in self.game_card_cache.items():
|
||||
if card == widget:
|
||||
current_layout_order.append(key)
|
||||
break
|
||||
else:
|
||||
current_layout_order = None # Skip reorg if not dirty
|
||||
|
||||
new_card_order = []
|
||||
cards_to_add = []
|
||||
|
||||
for game_data in sorted_games:
|
||||
game_name = game_data[0]
|
||||
exec_line = game_data[4]
|
||||
game_key = (game_name, exec_line)
|
||||
should_be_visible = not search_text or search_text in game_name.lower()
|
||||
|
||||
if game_key in self.game_card_cache:
|
||||
card = self.game_card_cache[game_key]
|
||||
if card.isVisible() != should_be_visible:
|
||||
card.setVisible(should_be_visible)
|
||||
new_card_order.append(game_key)
|
||||
else:
|
||||
if self.context_menu_manager is None:
|
||||
continue
|
||||
|
||||
card = self._create_game_card(game_data)
|
||||
self.game_card_cache[game_key] = card
|
||||
card.setVisible(should_be_visible)
|
||||
new_card_order.append(game_key)
|
||||
cards_to_add.append((game_key, card))
|
||||
|
||||
# Only reorganize if order changed AND dirty
|
||||
if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order):
|
||||
# Remove all widgets from layout (batch)
|
||||
while self.gamesListLayout.count():
|
||||
self.gamesListLayout.takeAt(0)
|
||||
|
||||
# Add widgets in new order (batch)
|
||||
for game_key in new_card_order:
|
||||
card = self.game_card_cache[game_key]
|
||||
self.gamesListLayout.addWidget(card)
|
||||
|
||||
self.dirty = False # Reset flag
|
||||
|
||||
# Deferred deletions (run in timer to avoid stack overflow)
|
||||
if self.pending_deletions:
|
||||
QTimer.singleShot(0, lambda: self._flush_deletions())
|
||||
|
||||
# Load visible images for new cards only
|
||||
if cards_to_add:
|
||||
self.load_visible_images()
|
||||
|
||||
finally:
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.setEnabled(True)
|
||||
self.gamesListWidget.setUpdatesEnabled(True)
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.update()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
self.main_window._last_card_width = self.card_width
|
||||
|
||||
self.is_filtering = False # Reset flag in any case
|
||||
|
||||
def _apply_filter_visibility(self, search_text: str):
|
||||
"""Applies visibility to cards based on search, without changing the layout."""
|
||||
visible_count = 0
|
||||
for game_key, card in self.game_card_cache.items():
|
||||
game_name = card.name # Assume GameCard has 'name' attribute
|
||||
should_be_visible = not search_text or search_text in game_name.lower()
|
||||
if card.isVisible() != should_be_visible:
|
||||
card.setVisible(should_be_visible)
|
||||
if should_be_visible:
|
||||
visible_count += 1
|
||||
# Load image only for newly visible cards
|
||||
if game_key in self.pending_images:
|
||||
cover_path, width, height, callback = self.pending_images.pop(game_key)
|
||||
load_pixmap_async(cover_path, width, height, callback)
|
||||
|
||||
# Force geometry update so FlowLayout accounts for hidden widgets
|
||||
if self.gamesListLayout is not None:
|
||||
self.gamesListLayout.update()
|
||||
if self.gamesListWidget is not None:
|
||||
self.gamesListWidget.updateGeometry()
|
||||
self.main_window._last_card_width = self.card_width
|
||||
|
||||
# If search is empty, load images for visible ones
|
||||
if not search_text:
|
||||
self.load_visible_images()
|
||||
|
||||
def _create_game_card(self, game_data: tuple) -> GameCard:
|
||||
"""Creates a new game card with all necessary connections."""
|
||||
card = GameCard(
|
||||
*game_data,
|
||||
select_callback=self.main_window.openGameDetailPage,
|
||||
theme=self.theme,
|
||||
card_width=self.card_width,
|
||||
context_menu_manager=self.context_menu_manager
|
||||
)
|
||||
|
||||
card.hoverChanged.connect(self._on_card_hovered)
|
||||
card.focusChanged.connect(self._on_card_focused)
|
||||
|
||||
if self.context_menu_manager:
|
||||
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
|
||||
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
|
||||
card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
|
||||
card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu)
|
||||
card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop)
|
||||
card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop)
|
||||
card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
|
||||
card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
|
||||
card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
|
||||
|
||||
return card
|
||||
|
||||
def _flush_deletions(self):
|
||||
"""Delete pending widgets off the main update cycle."""
|
||||
for card in list(self.pending_deletions):
|
||||
card.deleteLater()
|
||||
self.pending_deletions.remove(card)
|
||||
|
||||
def clear_layout(self, layout):
|
||||
"""Clears all widgets from the layout."""
|
||||
if layout is None:
|
||||
return
|
||||
while layout.count():
|
||||
child = layout.takeAt(0)
|
||||
if child.widget():
|
||||
widget = child.widget()
|
||||
for key, card in list(self.game_card_cache.items()):
|
||||
if card == widget:
|
||||
del self.game_card_cache[key]
|
||||
if key in self.pending_images:
|
||||
del self.pending_images[key]
|
||||
widget.deleteLater()
|
||||
|
||||
def set_games(self, games: list[tuple]):
|
||||
"""Sets the games list and updates the filtered games."""
|
||||
self.games = games
|
||||
self.filtered_games = self.games
|
||||
self.dirty = True # Full resort needed
|
||||
self.update_game_grid()
|
||||
|
||||
def add_game_incremental(self, game_data: tuple):
|
||||
"""Add a single game without full reload."""
|
||||
self.games.append(game_data)
|
||||
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
|
||||
self.dirty = True
|
||||
self.update_game_grid()
|
||||
|
||||
def remove_game_incremental(self, game_name: str, exec_line: str):
|
||||
"""Remove a single game without full reload."""
|
||||
key = (game_name, exec_line)
|
||||
self.games = [g for g in self.games if (g[0], g[4]) != key]
|
||||
self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key]
|
||||
if key in self.game_card_cache and self.gamesListLayout is not None:
|
||||
card = self.game_card_cache.pop(key)
|
||||
self.gamesListLayout.removeWidget(card)
|
||||
self.pending_deletions.append(card) # Defer deleteLater
|
||||
if key in self.pending_images:
|
||||
del self.pending_images[key]
|
||||
self.dirty = True
|
||||
self.update_game_grid()
|
||||
|
||||
def filter_games_delayed(self):
|
||||
"""Filters games based on search text and updates the grid."""
|
||||
self.update_game_grid(is_filter=True)
|
@@ -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-23 22:23+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: de_DE\n"
|
||||
@@ -23,7 +23,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -155,7 +155,7 @@ msgid "Menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command in .desktop file for '{game_name}'"
|
||||
msgid "No executable command found in .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to read .desktop file: {error}"
|
||||
msgid "Error reading .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -264,6 +264,10 @@ msgstr ""
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -360,6 +364,12 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -450,21 +460,6 @@ msgstr ""
|
||||
msgid "Gamepad haptic feedback:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -474,28 +469,6 @@ msgstr ""
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
@@ -549,9 +522,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-23 22:23+0500\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: es_ES\n"
|
||||
@@ -23,7 +23,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -155,7 +155,7 @@ msgid "Menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command in .desktop file for '{game_name}'"
|
||||
msgid "No executable command found in .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to read .desktop file: {error}"
|
||||
msgid "Error reading .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -264,6 +264,10 @@ msgstr ""
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -360,6 +364,12 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -450,21 +460,6 @@ msgstr ""
|
||||
msgid "Gamepad haptic feedback:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -474,28 +469,6 @@ msgstr ""
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
@@ -549,9 +522,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-23 22:23+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"
|
||||
@@ -21,7 +21,7 @@ msgstr ""
|
||||
msgid "Error"
|
||||
msgstr ""
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
@@ -153,7 +153,7 @@ msgid "Menu"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command in .desktop file for '{game_name}'"
|
||||
msgid "No executable command found in .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -161,7 +161,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to read .desktop file: {error}"
|
||||
msgid "Error reading .desktop file: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
@@ -262,6 +262,10 @@ msgstr ""
|
||||
msgid "Path: "
|
||||
msgstr ""
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr ""
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr ""
|
||||
|
||||
@@ -358,6 +362,12 @@ msgstr ""
|
||||
msgid "Themes"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -448,21 +458,6 @@ msgstr ""
|
||||
msgid "Gamepad haptic feedback:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -472,28 +467,6 @@ msgstr ""
|
||||
msgid "Clear Cache"
|
||||
msgstr ""
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr ""
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr ""
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr ""
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr ""
|
||||
|
||||
@@ -547,9 +520,6 @@ msgstr ""
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "Back"
|
||||
msgstr ""
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr ""
|
||||
|
||||
|
@@ -9,23 +9,24 @@ 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-23 22:23+0500\n"
|
||||
"PO-Revision-Date: 2025-09-23 22:23+0500\n"
|
||||
"Last-Translator: \n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
"Language: ru_RU\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 "
|
||||
"&& (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
"X-Generator: Poedit 3.6\n"
|
||||
|
||||
msgid "Error"
|
||||
msgstr "Ошибка"
|
||||
|
||||
msgid "PortProton is not found"
|
||||
msgstr "PortProton не найден"
|
||||
msgid "PortProton directory not found"
|
||||
msgstr "Не найден каталог PortProton"
|
||||
|
||||
msgid "Remove from Favorites"
|
||||
msgstr "Удалить из Избранного"
|
||||
@@ -86,11 +87,11 @@ msgstr "Успешно"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"'{game_name}' was added to Steam. Please restart Steam for changes to "
|
||||
"take effect."
|
||||
"'{game_name}' was added to Steam. Please restart Steam for changes to take "
|
||||
"effect."
|
||||
msgstr ""
|
||||
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите "
|
||||
"Steam, чтобы изменения вступили в силу."
|
||||
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите Steam, "
|
||||
"чтобы изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found for game: {game_name}"
|
||||
@@ -158,16 +159,16 @@ msgid "Menu"
|
||||
msgstr "Меню"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No executable command in .desktop file for '{game_name}'"
|
||||
msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда"
|
||||
msgid "No executable command found in .desktop file for '{game_name}'"
|
||||
msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to parse .desktop file for '{game_name}'"
|
||||
msgstr "Не удалось разобрать файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to read .desktop file: {error}"
|
||||
msgstr "Не удалось прочитать файл .desktop: {error}"
|
||||
msgid "Error reading .desktop file: {error}"
|
||||
msgstr "Ошибка при чтении файла .desktop: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "No .desktop file found for '{game_name}'"
|
||||
@@ -178,11 +179,11 @@ msgstr "Подтвердите удаление"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete '{game_name}'? This will remove the "
|
||||
".desktop file and custom data."
|
||||
"Are you sure you want to delete '{game_name}'? This will remove the .desktop "
|
||||
"file and custom data."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению "
|
||||
"файла .desktop и пользовательских данных."
|
||||
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению файла ."
|
||||
"desktop и пользовательских данных."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete .desktop file: {error}"
|
||||
@@ -224,11 +225,11 @@ msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"'{game_name}' was removed from Steam. Please restart Steam for changes to"
|
||||
" take effect."
|
||||
"'{game_name}' was removed from Steam. Please restart Steam for changes to take "
|
||||
"effect."
|
||||
msgstr ""
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
|
||||
" чтобы изменения вступили в силу."
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam, чтобы "
|
||||
"изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
@@ -271,6 +272,10 @@ msgstr "Выбрать"
|
||||
msgid "Path: "
|
||||
msgstr "Путь: "
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr "Доступ запрещен: %s"
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr "Редактировать игру"
|
||||
|
||||
@@ -367,6 +372,12 @@ msgstr "Настройки PortProton"
|
||||
msgid "Themes"
|
||||
msgstr "Темы"
|
||||
|
||||
msgid "Back"
|
||||
msgstr "Назад"
|
||||
|
||||
msgid "Fullscreen"
|
||||
msgstr "Полный экран"
|
||||
|
||||
msgid "Loading Steam games..."
|
||||
msgstr "Загрузка игр из Steam..."
|
||||
|
||||
@@ -457,21 +468,6 @@ msgstr "Тактильная отдача на геймпаде"
|
||||
msgid "Gamepad haptic feedback:"
|
||||
msgstr "Тактильная отдача на геймпаде:"
|
||||
|
||||
msgid "Open Legendary Login"
|
||||
msgstr "Открыть браузер для входа в Legendary"
|
||||
|
||||
msgid "Legendary Authentication:"
|
||||
msgstr "Авторизация в Legendary:"
|
||||
|
||||
msgid "Enter Legendary Authorization Code"
|
||||
msgstr "Введите код авторизации Legendary"
|
||||
|
||||
msgid "Authorization Code:"
|
||||
msgstr "Код авторизации:"
|
||||
|
||||
msgid "Submit Code"
|
||||
msgstr "Отправить код"
|
||||
|
||||
msgid "Save Settings"
|
||||
msgstr "Сохранить настройки"
|
||||
|
||||
@@ -481,35 +477,12 @@ msgstr "Сбросить настройки"
|
||||
msgid "Clear Cache"
|
||||
msgstr "Очистить кэш"
|
||||
|
||||
msgid "Opened Legendary login page in browser"
|
||||
msgstr "Открытие страницы входа в Legendary в браузере"
|
||||
|
||||
msgid "Failed to open Legendary login page"
|
||||
msgstr "Не удалось открыть страницу входа в Legendary"
|
||||
|
||||
msgid "Please enter an authorization code"
|
||||
msgstr "Пожалуйста, введите код авторизации"
|
||||
|
||||
msgid "Successfully authenticated with Legendary"
|
||||
msgstr "Успешная аутентификация в Legendary"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Legendary authentication failed: {0}"
|
||||
msgstr "Не удалось выполнить аутентификацию Legendary: {0}"
|
||||
|
||||
msgid "Legendary executable not found"
|
||||
msgstr "Не найден исполняемый файл Legendary"
|
||||
|
||||
msgid "Unexpected error during authentication"
|
||||
msgstr "Неожиданная ошибка при аутентификации"
|
||||
|
||||
msgid "Confirm Reset"
|
||||
msgstr "Подтвердите удаление"
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
|
||||
"отменить."
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
|
||||
|
||||
msgid "Settings reset. Restarting..."
|
||||
msgstr "Настройки сброшены. Перезапуск..."
|
||||
@@ -558,9 +531,6 @@ msgstr "Тема '{0}' применена успешно"
|
||||
msgid "Error applying theme '{0}'"
|
||||
msgstr "Ошибка при применение темы '{0}'"
|
||||
|
||||
msgid "Back"
|
||||
msgstr "Назад"
|
||||
|
||||
msgid "LAST LAUNCH"
|
||||
msgstr "Последний запуск"
|
||||
|
||||
@@ -684,4 +654,3 @@ msgstr "Нет избранных"
|
||||
|
||||
msgid "No recent games"
|
||||
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()
|
||||
|
@@ -22,6 +22,7 @@ import websocket
|
||||
import requests
|
||||
import random
|
||||
import base64
|
||||
import glob
|
||||
|
||||
downloader = Downloader()
|
||||
logger = get_logger(__name__)
|
||||
@@ -44,14 +45,14 @@ def safe_vdf_load(path: str | Path) -> dict:
|
||||
|
||||
def decode_text(text: str) -> str:
|
||||
"""
|
||||
Декодирует HTML-сущности в строке.
|
||||
Например, "&quot;" преобразуется в '"'.
|
||||
Остальные символы и HTML-теги остаются без изменений.
|
||||
Decodes HTML entities in a string.
|
||||
For example, "&quot;" is converted to '"'.
|
||||
Other characters and HTML tags remain unchanged.
|
||||
"""
|
||||
return html.unescape(text)
|
||||
|
||||
def get_cache_dir():
|
||||
"""Возвращает путь к каталогу кэша, создаёт его при необходимости."""
|
||||
"""Returns the path to the cache directory, creating it if necessary."""
|
||||
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
|
||||
os.makedirs(cache_dir, exist_ok=True)
|
||||
@@ -64,7 +65,7 @@ STEAM_DATA_DIRS = (
|
||||
)
|
||||
|
||||
def get_steam_home():
|
||||
"""Возвращает путь к директории Steam, используя список возможных директорий."""
|
||||
"""Returns the path to the Steam directory using a list of possible directories."""
|
||||
for dir_path in STEAM_DATA_DIRS:
|
||||
expanded_path = Path(os.path.expanduser(dir_path))
|
||||
if expanded_path.exists():
|
||||
@@ -72,7 +73,7 @@ def get_steam_home():
|
||||
return None
|
||||
|
||||
def get_last_steam_user(steam_home: Path) -> dict | None:
|
||||
"""Возвращает данные последнего пользователя Steam из loginusers.vdf."""
|
||||
"""Returns data for the last Steam user from loginusers.vdf."""
|
||||
loginusers_path = steam_home / "config/loginusers.vdf"
|
||||
data = safe_vdf_load(loginusers_path)
|
||||
if not data:
|
||||
@@ -83,20 +84,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
|
||||
try:
|
||||
return {'SteamID': int(user_id)}
|
||||
except ValueError:
|
||||
logger.error(f"Неверный формат SteamID: {user_id}")
|
||||
logger.error(f"Invalid SteamID format: {user_id}")
|
||||
return None
|
||||
logger.info("Не найден пользователь с MostRecent=1")
|
||||
logger.info("No user found with MostRecent=1")
|
||||
return None
|
||||
|
||||
def convert_steam_id(steam_id: int) -> int:
|
||||
"""
|
||||
Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число.
|
||||
Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения.
|
||||
Converts a signed 32-bit integer to an unsigned 32-bit integer.
|
||||
Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values.
|
||||
"""
|
||||
return steam_id & 0xFFFFFFFF
|
||||
|
||||
def get_steam_libs(steam_dir: Path) -> set[Path]:
|
||||
"""Возвращает набор директорий Steam libraryfolders."""
|
||||
"""Returns a set of Steam library folders."""
|
||||
libs = set()
|
||||
libs_vdf = steam_dir / "steamapps/libraryfolders.vdf"
|
||||
data = safe_vdf_load(libs_vdf)
|
||||
@@ -112,7 +113,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]:
|
||||
return libs
|
||||
|
||||
def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]:
|
||||
"""Возвращает данные о времени игры для последнего пользователя."""
|
||||
"""Returns playtime data for the last user."""
|
||||
play_data: dict[int, tuple[int, int]] = {}
|
||||
if steam_home is None:
|
||||
steam_home = get_steam_home()
|
||||
@@ -132,14 +133,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
|
||||
return play_data
|
||||
|
||||
if not last_user:
|
||||
logger.info("Не удалось определить последнего пользователя Steam")
|
||||
logger.info("Could not identify the last Steam user")
|
||||
return play_data
|
||||
|
||||
user_id = last_user['SteamID']
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
user_dir = userdata_dir / str(unsigned_id)
|
||||
if not user_dir.exists():
|
||||
logger.info(f"Директория пользователя {unsigned_id} не найдена")
|
||||
logger.info(f"User directory {unsigned_id} not found")
|
||||
return play_data
|
||||
|
||||
localconfig = user_dir / "config/localconfig.vdf"
|
||||
@@ -153,11 +154,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
|
||||
playtime = int(info.get('Playtime', 0))
|
||||
play_data[appid] = (last_played, playtime)
|
||||
except ValueError:
|
||||
logger.warning(f"Некорректные данные playtime для app {appid_str}")
|
||||
logger.warning(f"Invalid playtime data for app {appid_str}")
|
||||
return play_data
|
||||
|
||||
def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
|
||||
"""Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec)."""
|
||||
"""Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec)."""
|
||||
games: list[tuple[str, int, int, int]] = []
|
||||
steam_home = get_steam_home()
|
||||
if steam_home is None or not steam_home.exists():
|
||||
@@ -186,13 +187,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
|
||||
|
||||
def normalize_name(s):
|
||||
"""
|
||||
Приведение строки к нормальному виду:
|
||||
- перевод в нижний регистр,
|
||||
- удаление символов ™ и ®,
|
||||
- замена разделителей (-, :, ,) на пробел,
|
||||
- удаление лишних пробелов,
|
||||
- удаление суффиксов 'bin' или 'app' в конце строки,
|
||||
- удаление ключевых слов типа 'ultimate', 'edition' и т.п.
|
||||
Normalizes a string by:
|
||||
- converting to lowercase,
|
||||
- removing ™ and ® symbols,
|
||||
- replacing separators (-, :, ,) with spaces,
|
||||
- removing extra spaces,
|
||||
- removing 'bin' or 'app' suffixes,
|
||||
- removing keywords like 'ultimate', 'edition', etc.
|
||||
"""
|
||||
s = s.lower()
|
||||
for ch in ["™", "®"]:
|
||||
@@ -210,14 +211,28 @@ def normalize_name(s):
|
||||
|
||||
def is_valid_candidate(candidate):
|
||||
"""
|
||||
Проверяет, содержит ли кандидат запрещённые подстроки:
|
||||
- win32
|
||||
- win64
|
||||
- gamelauncher
|
||||
Для проверки дополнительно используется строка без пробелов.
|
||||
Возвращает True, если кандидат допустим, иначе False.
|
||||
Determines whether a given candidate string is valid for use as a game name.
|
||||
|
||||
The function performs the following checks:
|
||||
1. Normalizes the candidate using `normalize_name()`.
|
||||
2. Rejects the candidate if the normalized name is exactly "game"
|
||||
(to avoid overly generic names).
|
||||
3. Removes spaces and checks for forbidden substrings:
|
||||
- "win32"
|
||||
- "win64"
|
||||
- "gamelauncher"
|
||||
These are checked in the space-free version of the string.
|
||||
4. Returns True only if none of the forbidden conditions are met.
|
||||
|
||||
Args:
|
||||
candidate (str): The candidate string to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the candidate is valid, False otherwise.
|
||||
"""
|
||||
normalized_candidate = normalize_name(candidate)
|
||||
if normalized_candidate == "game":
|
||||
return False
|
||||
normalized_no_space = normalized_candidate.replace(" ", "")
|
||||
forbidden = ["win32", "win64", "gamelauncher"]
|
||||
for token in forbidden:
|
||||
@@ -227,7 +242,7 @@ def is_valid_candidate(candidate):
|
||||
|
||||
def filter_candidates(candidates):
|
||||
"""
|
||||
Фильтрует список кандидатов, отбрасывая недопустимые.
|
||||
Filters a list of candidates, discarding invalid ones.
|
||||
"""
|
||||
valid = []
|
||||
dropped = []
|
||||
@@ -237,18 +252,18 @@ def filter_candidates(candidates):
|
||||
else:
|
||||
dropped.append(cand)
|
||||
if dropped:
|
||||
logger.info("Отбрасываю кандидатов: %s", dropped)
|
||||
logger.info("Discarding candidates: %s", dropped)
|
||||
return valid
|
||||
|
||||
def remove_duplicates(candidates):
|
||||
"""
|
||||
Удаляет дубликаты из списка, сохраняя порядок.
|
||||
Removes duplicates from a list while preserving order.
|
||||
"""
|
||||
return list(dict.fromkeys(candidates))
|
||||
|
||||
@functools.lru_cache(maxsize=256)
|
||||
def get_exiftool_data(game_exe):
|
||||
"""Получает метаданные через exiftool"""
|
||||
"""Retrieves metadata using exiftool."""
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["exiftool", "-j", game_exe],
|
||||
@@ -257,18 +272,28 @@ def get_exiftool_data(game_exe):
|
||||
check=False
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}")
|
||||
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
|
||||
return {}
|
||||
meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
|
||||
return meta_data_list[0] if meta_data_list else {}
|
||||
except Exception as e:
|
||||
logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
|
||||
logger.error(f"Unexpected error 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")
|
||||
@@ -294,12 +319,14 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
f.write(orjson.dumps(data))
|
||||
if os.path.exists(cache_tar):
|
||||
os.remove(cache_tar)
|
||||
logger.info("Archive %s deleted after extraction", cache_tar)
|
||||
logger.info("Deleted archive: %s", 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)
|
||||
except Exception as e:
|
||||
logger.error("Error extracting Steam apps archive: %s", e)
|
||||
logger.error("Failed to extract Steam apps archive: %s", e)
|
||||
callback([])
|
||||
|
||||
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
|
||||
@@ -309,37 +336,41 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
data = orjson.loads(f.read())
|
||||
# Validate JSON structure
|
||||
if not isinstance(data, list):
|
||||
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
|
||||
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
|
||||
raise ValueError("Invalid JSON structure")
|
||||
# Validate each app entry
|
||||
for app in data:
|
||||
if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
|
||||
logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json)
|
||||
logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json)
|
||||
raise ValueError("Invalid app entry structure")
|
||||
steam_apps = data
|
||||
logger.info("Loaded %d apps from cache", len(steam_apps))
|
||||
callback(steam_apps)
|
||||
except Exception as e:
|
||||
logger.error("Error reading or validating cached JSON %s: %s", cache_json, e)
|
||||
logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e)
|
||||
# Attempt to re-download if cache is invalid or corrupted
|
||||
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):
|
||||
"""
|
||||
Строит индекс приложений по полю normalized_name.
|
||||
Builds an index of applications by normalized_name field.
|
||||
"""
|
||||
steam_apps_index = {}
|
||||
if not steam_apps:
|
||||
return steam_apps_index
|
||||
logger.info("Построение индекса Steam приложений:")
|
||||
logger.info("Building Steam apps index")
|
||||
for app in steam_apps:
|
||||
normalized = app["normalized_name"]
|
||||
steam_apps_index[normalized] = app
|
||||
@@ -347,25 +378,24 @@ def build_index(steam_apps):
|
||||
|
||||
def search_app(candidate, steam_apps_index):
|
||||
"""
|
||||
Ищет приложение по кандидату: сначала пытается точное совпадение, затем ищет подстроку.
|
||||
Searches for an application by candidate: tries exact match first, then substring match.
|
||||
"""
|
||||
candidate_norm = normalize_name(candidate)
|
||||
logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm)
|
||||
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||
if candidate_norm in steam_apps_index:
|
||||
logger.info(" Найдено точное совпадение: '%s'", candidate_norm)
|
||||
logger.info("Found exact match: '%s'", candidate_norm)
|
||||
return steam_apps_index[candidate_norm]
|
||||
for name_norm, app in steam_apps_index.items():
|
||||
if candidate_norm in name_norm:
|
||||
ratio = len(candidate_norm) / len(name_norm)
|
||||
if ratio > 0.8:
|
||||
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)",
|
||||
candidate_norm, name_norm, ratio)
|
||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
|
||||
return app
|
||||
logger.info(" Приложение для кандидата '%s' не найдено", candidate_norm)
|
||||
logger.info("No app found for candidate '%s'", candidate_norm)
|
||||
return None
|
||||
|
||||
def load_app_details(app_id):
|
||||
"""Загружает кэшированные данные для игры по appid, если они не устарели."""
|
||||
"""Loads cached game data by appid if not outdated."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
|
||||
if os.path.exists(cache_file):
|
||||
@@ -375,7 +405,7 @@ def load_app_details(app_id):
|
||||
return None
|
||||
|
||||
def save_app_details(app_id, data):
|
||||
"""Сохраняет данные по appid в файл кэша."""
|
||||
"""Saves appid data to a cache file."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
|
||||
with open(cache_file, "wb") as f:
|
||||
@@ -418,7 +448,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
|
||||
save_app_details(app_id, app_data)
|
||||
callback(app_data)
|
||||
except Exception as e:
|
||||
logger.error("Error processing Steam app info for appid %s: %s", app_id, e)
|
||||
logger.error("Failed to process Steam app info for appid %s: %s", app_id, e)
|
||||
callback(None)
|
||||
|
||||
downloader.download_async(url, cache_file, timeout=5, callback=process_response)
|
||||
@@ -427,6 +457,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")
|
||||
@@ -452,12 +483,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
||||
f.write(orjson.dumps(data))
|
||||
if os.path.exists(cache_tar):
|
||||
os.remove(cache_tar)
|
||||
logger.info("Archive %s deleted after extraction", cache_tar)
|
||||
logger.info("Deleted archive: %s", cache_tar)
|
||||
anti_cheat_data = data or []
|
||||
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
|
||||
callback(anti_cheat_data)
|
||||
except Exception as e:
|
||||
logger.error("Error extracting WeAntiCheatYet archive: %s", e)
|
||||
logger.error("Failed to extract WeAntiCheatYet archive: %s", e)
|
||||
callback([])
|
||||
|
||||
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
|
||||
@@ -467,18 +498,18 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
||||
data = orjson.loads(f.read())
|
||||
# Validate JSON structure
|
||||
if not isinstance(data, list):
|
||||
logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
|
||||
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
|
||||
raise ValueError("Invalid JSON structure")
|
||||
# Validate each anti-cheat entry
|
||||
for entry in data:
|
||||
if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
|
||||
logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json)
|
||||
logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json)
|
||||
raise ValueError("Invalid anti-cheat entry structure")
|
||||
anti_cheat_data = data
|
||||
logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
|
||||
callback(anti_cheat_data)
|
||||
except Exception as e:
|
||||
logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e)
|
||||
logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e)
|
||||
# Attempt to re-download if cache is invalid or corrupted
|
||||
app_list_url = (
|
||||
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
|
||||
@@ -492,12 +523,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
||||
|
||||
def build_weanticheatyet_index(anti_cheat_data):
|
||||
"""
|
||||
Строит индекс античит-данных по полю normalized_name.
|
||||
Builds an index of anti-cheat data by normalized_name field.
|
||||
"""
|
||||
anti_cheat_index = {}
|
||||
if not anti_cheat_data:
|
||||
return anti_cheat_index
|
||||
logger.info("Построение индекса WeAntiCheatYet данных:")
|
||||
logger.info("Building WeAntiCheatYet data index")
|
||||
for entry in anti_cheat_data:
|
||||
normalized = entry["normalized_name"]
|
||||
anti_cheat_index[normalized] = entry
|
||||
@@ -505,20 +536,19 @@ def build_weanticheatyet_index(anti_cheat_data):
|
||||
|
||||
def search_anticheat_status(candidate, anti_cheat_index):
|
||||
candidate_norm = normalize_name(candidate)
|
||||
logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm)
|
||||
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
|
||||
if candidate_norm in anti_cheat_index:
|
||||
status = anti_cheat_index[candidate_norm]["status"]
|
||||
logger.info(" Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status)
|
||||
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
|
||||
return status
|
||||
for name_norm, entry in anti_cheat_index.items():
|
||||
if candidate_norm in name_norm:
|
||||
ratio = len(candidate_norm) / len(name_norm)
|
||||
if ratio > 0.8:
|
||||
status = entry["status"]
|
||||
logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'",
|
||||
candidate_norm, name_norm, ratio, status)
|
||||
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
|
||||
return status
|
||||
logger.info(" Античит-статус для кандидата '%s' не найден", candidate_norm)
|
||||
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
|
||||
return ""
|
||||
|
||||
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
|
||||
@@ -534,7 +564,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No
|
||||
load_weanticheatyet_data_async(on_anticheat_data)
|
||||
|
||||
def load_protondb_status(appid):
|
||||
"""Загружает закешированные данные ProtonDB для игры по appid, если они не устарели."""
|
||||
"""Loads cached ProtonDB data for a game by appid if not outdated."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
|
||||
if os.path.exists(cache_file):
|
||||
@@ -543,18 +573,18 @@ def load_protondb_status(appid):
|
||||
with open(cache_file, "rb") as f:
|
||||
return orjson.loads(f.read())
|
||||
except Exception as e:
|
||||
logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e)
|
||||
logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e)
|
||||
return None
|
||||
|
||||
def save_protondb_status(appid, data):
|
||||
"""Сохраняет данные ProtonDB для игры по appid в файл кэша."""
|
||||
"""Saves ProtonDB data for a game by appid to a cache file."""
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
|
||||
try:
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(orjson.dumps(data))
|
||||
except Exception as e:
|
||||
logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e)
|
||||
logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e)
|
||||
|
||||
def get_protondb_tier_async(appid: int, callback: Callable[[str], None]):
|
||||
"""
|
||||
@@ -642,7 +672,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
if game_exe.lower().endswith('.exe'):
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error processing bat file %s: %s", game_exe, e)
|
||||
logger.error("Failed to process bat file %s: %s", game_exe, e)
|
||||
else:
|
||||
logger.error("Bat file not found: %s", game_exe)
|
||||
|
||||
@@ -777,55 +807,55 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
|
||||
|
||||
def enable_steam_cef() -> tuple[bool, str]:
|
||||
"""
|
||||
Проверяет и при необходимости активирует режим удаленной отладки Steam CEF.
|
||||
Checks and enables Steam CEF remote debugging if necessary.
|
||||
|
||||
Создает файл .cef-enable-remote-debugging в директории Steam.
|
||||
Steam необходимо перезапустить после первого создания этого файла.
|
||||
Creates a .cef-enable-remote-debugging file in the Steam directory.
|
||||
Steam must be restarted after the file is first created.
|
||||
|
||||
Возвращает кортеж:
|
||||
- (True, "already_enabled") если уже было активно.
|
||||
- (True, "restart_needed") если было только что активировано и нужен перезапуск Steam.
|
||||
- (False, "steam_not_found") если директория Steam не найдена.
|
||||
Returns a tuple:
|
||||
- (True, "already_enabled") if already enabled.
|
||||
- (True, "restart_needed") if just enabled and Steam restart is needed.
|
||||
- (False, "steam_not_found") if Steam directory is not found.
|
||||
"""
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
return (False, "steam_not_found")
|
||||
|
||||
cef_flag_file = steam_home / ".cef-enable-remote-debugging"
|
||||
logger.info(f"Проверка CEF флага: {cef_flag_file}")
|
||||
logger.info(f"Checking CEF flag: {cef_flag_file}")
|
||||
|
||||
if cef_flag_file.exists():
|
||||
logger.info("CEF Remote Debugging уже активирован.")
|
||||
logger.info("CEF Remote Debugging is already enabled")
|
||||
return (True, "already_enabled")
|
||||
else:
|
||||
try:
|
||||
os.makedirs(cef_flag_file.parent, exist_ok=True)
|
||||
cef_flag_file.touch()
|
||||
logger.info("CEF Remote Debugging активирован. Steam необходимо перезапустить.")
|
||||
logger.info("Enabled CEF Remote Debugging. Steam restart required")
|
||||
return (True, "restart_needed")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать CEF флаг {cef_flag_file}: {e}")
|
||||
logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}")
|
||||
return (False, str(e))
|
||||
|
||||
def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
"""
|
||||
Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging.
|
||||
Executes a JavaScript function in the Steam context via CEF Remote Debugging.
|
||||
|
||||
Args:
|
||||
js_cmd: Имя JS функции для вызова (напр. 'createShortcut').
|
||||
*args: Аргументы для передачи в JS функцию.
|
||||
js_cmd: Name of the JS function to call (e.g., 'createShortcut').
|
||||
*args: Arguments to pass to the JS function.
|
||||
|
||||
Returns:
|
||||
Словарь с результатом выполнения или None в случае ошибки.
|
||||
Dictionary with the result or None if an error occurs.
|
||||
"""
|
||||
status, message = enable_steam_cef()
|
||||
if not (status is True and message == "already_enabled"):
|
||||
if message == "restart_needed":
|
||||
logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.")
|
||||
logger.warning("Steam CEF API is available but requires Steam restart for full activation")
|
||||
elif message == "steam_not_found":
|
||||
logger.error("Не удалось найти директорию Steam для проверки CEF API.")
|
||||
logger.error("Could not find Steam directory to check CEF API")
|
||||
else:
|
||||
logger.error(f"Steam CEF API недоступен или не готов: {message}")
|
||||
logger.error(f"Steam CEF API is unavailable or not ready: {message}")
|
||||
return None
|
||||
|
||||
steam_debug_url = "http://localhost:8080/json"
|
||||
@@ -836,10 +866,10 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
contexts = response.json()
|
||||
ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
|
||||
if not ws_url:
|
||||
logger.warning("Не удалось найти SharedJSContext. Steam запущен с флагом -cef-enable-remote-debugging?")
|
||||
logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}")
|
||||
logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {e}")
|
||||
return None
|
||||
|
||||
js_code = """
|
||||
@@ -884,15 +914,15 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
|
||||
response_data = orjson.loads(response_str)
|
||||
if "error" in response_data:
|
||||
logger.error(f"Ошибка выполнения JS в Steam: {response_data['error']['message']}")
|
||||
logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}")
|
||||
return None
|
||||
result = response_data.get('result', {}).get('result', {})
|
||||
if result.get('type') == 'object' and result.get('subtype') == 'error':
|
||||
logger.error(f"Ошибка выполнения JS в Steam: {result.get('description')}")
|
||||
logger.error(f"JavaScript execution error in Steam: {result.get('description')}")
|
||||
return None
|
||||
return result.get('value')
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}")
|
||||
logger.error(f"WebSocket interaction error with Steam: {e}")
|
||||
return None
|
||||
|
||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
|
||||
@@ -969,24 +999,24 @@ export START_FROM_STEAM=1
|
||||
else:
|
||||
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
|
||||
if not success or not os.path.exists(generated_icon_path):
|
||||
logger.warning(f"generate_thumbnail failed to create icon for {exe_path}")
|
||||
logger.warning(f"Failed to generate thumbnail for {exe_path}")
|
||||
icon_path = ""
|
||||
else:
|
||||
logger.info(f"Generated thumbnail: {generated_icon_path}")
|
||||
icon_path = generated_icon_path
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating thumbnail for {exe_path}: {e}")
|
||||
logger.error(f"Failed to generate thumbnail for {exe_path}: {e}")
|
||||
icon_path = ""
|
||||
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
logger.error("Steam home directory not found")
|
||||
return (False, "Steam directory not found.")
|
||||
return (False, "Steam directory not found")
|
||||
|
||||
last_user = get_last_steam_user(steam_home)
|
||||
if not last_user or 'SteamID' not in last_user:
|
||||
logger.error("Failed to retrieve Steam user ID")
|
||||
return (False, "Failed to get Steam user ID.")
|
||||
return (False, "Failed to get Steam user ID")
|
||||
|
||||
userdata_dir = steam_home / "userdata"
|
||||
user_id = last_user['SteamID']
|
||||
@@ -999,7 +1029,7 @@ export START_FROM_STEAM=1
|
||||
appid = None
|
||||
was_api_used = False
|
||||
|
||||
logger.info("Попытка добавления ярлыка через Steam CEF API...")
|
||||
logger.info("Attempting to add shortcut via Steam CEF API")
|
||||
api_response = call_steam_api(
|
||||
"createShortcut",
|
||||
game_name,
|
||||
@@ -1012,9 +1042,9 @@ export START_FROM_STEAM=1
|
||||
if api_response and isinstance(api_response, dict) and 'id' in api_response:
|
||||
appid = api_response['id']
|
||||
was_api_used = True
|
||||
logger.info(f"Ярлык успешно добавлен через API. AppID: {appid}")
|
||||
logger.info(f"Shortcut successfully added via API. AppID: {appid}")
|
||||
else:
|
||||
logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).")
|
||||
logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf")
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
@@ -1088,7 +1118,7 @@ export START_FROM_STEAM=1
|
||||
appid = None
|
||||
|
||||
if not appid:
|
||||
return (False, "Не удалось создать ярлык ни одним из способов.")
|
||||
return (False, "Failed to create shortcut using any method")
|
||||
|
||||
steam_appid = None
|
||||
|
||||
@@ -1098,7 +1128,7 @@ export START_FROM_STEAM=1
|
||||
if not steam_appid or not isinstance(steam_appid, int):
|
||||
logger.info("No valid Steam appid found, skipping cover download")
|
||||
return
|
||||
logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.")
|
||||
logger.info(f"Found Steam AppID {steam_appid} for cover download")
|
||||
|
||||
cover_types = [
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
@@ -1115,15 +1145,15 @@ export START_FROM_STEAM=1
|
||||
try:
|
||||
with open(result_path, 'rb') as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
logger.info(f"Применение обложки типа '{steam_name}' через API для AppID {appid}")
|
||||
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
|
||||
ext = Path(steam_name).suffix.lstrip('.')
|
||||
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при применении обложки '{steam_name}' через API: {e}")
|
||||
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
|
||||
else:
|
||||
logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing cover {steam_name} for appid {steam_appid}: {e}")
|
||||
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
|
||||
|
||||
for i, (suffix, steam_name) in enumerate(cover_types):
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
@@ -1164,13 +1194,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
|
||||
steam_home = get_steam_home()
|
||||
if not steam_home:
|
||||
logger.error("Steam home directory not found")
|
||||
return (False, "Steam directory not found.")
|
||||
return (False, "Steam directory not found")
|
||||
|
||||
# Get current Steam user ID
|
||||
last_user = get_last_steam_user(steam_home)
|
||||
if not last_user or 'SteamID' not in last_user:
|
||||
logger.error("Failed to retrieve Steam user ID")
|
||||
return (False, "Failed to get Steam user ID.")
|
||||
return (False, "Failed to get Steam user ID")
|
||||
userdata_dir = steam_home / "userdata"
|
||||
user_id = last_user['SteamID']
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
@@ -1216,10 +1246,10 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
|
||||
return (False, f"Game '{game_name}' not found in Steam")
|
||||
|
||||
api_response = call_steam_api("removeShortcut", appid)
|
||||
if api_response is not None: # API ответил, даже если ответ пустой
|
||||
logger.info(f"Ярлык для AppID {appid} успешно удален через API.")
|
||||
if api_response is not None: # API responded, even if response is empty
|
||||
logger.info(f"Shortcut for AppID {appid} successfully removed via API")
|
||||
else:
|
||||
logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).")
|
||||
logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf")
|
||||
|
||||
# Create backup of shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
@@ -1298,5 +1328,5 @@ def is_game_in_steam(game_name: str) -> bool:
|
||||
if entry.get("AppName") == game_name:
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking if game {game_name} is in Steam: {e}")
|
||||
logger.error(f"Failed to check if game {game_name} is in Steam: {e}")
|
||||
return False
|
||||
|
@@ -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:
|
||||
|
1
portprotonqt/themes/standart/images/key_backspace.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg>
|
After Width: | Height: | Size: 751 B |
48
portprotonqt/themes/standart/images/key_context.svg
Normal file
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="48"
|
||||
height="48"
|
||||
version="1.1"
|
||||
viewBox="0 0 48 48"
|
||||
xml:space="preserve"
|
||||
id="svg2"
|
||||
sodipodi:docname="key_context.svg"
|
||||
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs2" /><sodipodi:namedview
|
||||
id="namedview2"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="8.6915209"
|
||||
inkscape:cx="72.311855"
|
||||
inkscape:cy="22.780823"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1406"
|
||||
inkscape:window-x="0"
|
||||
inkscape:window-y="0"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg2" /><path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z"
|
||||
id="path10"
|
||||
sodipodi:nodetypes="csccscc" /><path
|
||||
fill="#000000"
|
||||
d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z"
|
||||
id="path2"
|
||||
style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g
|
||||
id="g15"
|
||||
transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z"
|
||||
id="path14" /><path
|
||||
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
|
||||
d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z"
|
||||
id="path15" /></g></svg>
|
After Width: | Height: | Size: 3.3 KiB |
1
portprotonqt/themes/standart/images/key_e.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg>
|
After Width: | Height: | Size: 726 B |
1
portprotonqt/themes/standart/images/key_enter.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg>
|
After Width: | Height: | Size: 823 B |
1
portprotonqt/themes/standart/images/key_f11.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg>
|
After Width: | Height: | Size: 857 B |
1
portprotonqt/themes/standart/images/key_left.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>
|
After Width: | Height: | Size: 865 B |
1
portprotonqt/themes/standart/images/key_right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>
|
After Width: | Height: | Size: 864 B |
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
1
portprotonqt/themes/standart/images/ps_circle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>
|
After Width: | Height: | Size: 736 B |
1
portprotonqt/themes/standart/images/ps_cross.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></svg>
|
After Width: | Height: | Size: 961 B |
1
portprotonqt/themes/standart/images/ps_l1.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></svg>
|
After Width: | Height: | Size: 1015 B |
1
portprotonqt/themes/standart/images/ps_options.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></svg>
|
After Width: | Height: | Size: 1.2 KiB |
1
portprotonqt/themes/standart/images/ps_r1.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
portprotonqt/themes/standart/images/ps_share.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
1
portprotonqt/themes/standart/images/ps_triangle.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></svg>
|
After Width: | Height: | Size: 721 B |
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 |
1
portprotonqt/themes/standart/images/xbox_a.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></svg>
|
After Width: | Height: | Size: 600 B |
1
portprotonqt/themes/standart/images/xbox_b.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m15.973 13.476h8.5299q3.0163 0 4.6379 0.45406 1.6541 0.42163 2.3352 1.3946 0.68109 0.94056 0.68109 2.6595v2.5946q0 0.87569-0.71353 1.6541-0.68109 0.77839-1.6216 1.0703v0.16216q1.2325 0.12973 2.2379 1.0703 1.0379 0.90812 1.0379 2.0433v3.2433q0 2.5622-2.0433 3.6325t-6.3244 1.0703h-8.7569zm8.5299 8.5623q1.2 0 1.7838-0.1946t0.77839-0.61623q0.22703-0.45406 0.22703-1.2973v-1.0379q0-0.74596-0.1946-1.1027-0.1946-0.3892-0.81082-0.55136-0.58379-0.16216-1.8811-0.16216h-3.373v4.9622zm0.12973 8.8866q1.8487 0 2.6271-0.42163t0.77839-1.3622v-1.6865q0-1.1676-0.61623-1.6541-0.58379-0.4865-2.1081-0.4865h-4.2812v5.6109z" fill="#3f424d" stroke-width=".67675" aria-label="B"/></svg>
|
After Width: | Height: | Size: 1.0 KiB |
1
portprotonqt/themes/standart/images/xbox_lb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m13.757 18h2.8844v9.9476h5.8983v2.0524h-8.7827zm10.724 0h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.0169-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="LB"/></svg>
|
After Width: | Height: | Size: 1.3 KiB |
1
portprotonqt/themes/standart/images/xbox_rb.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m12.943 18h5.3991q2.4037 0 3.4761 0.59168 1.0724 0.57319 1.0724 1.9414v2.1079q0 1.0539-0.75809 1.6641-0.7396 0.59168-1.9784 0.75809l3.4761 4.9368h-3.3282l-3.1433-4.7149h-1.3313v4.7149h-2.8844zm5.3621 5.3806q1.0169 0 1.3867-0.25886 0.3698-0.27735 0.3698-1.0539v-0.85054q0-0.66564-0.40678-0.90601-0.38829-0.25886-1.3498-0.25886h-2.4777v3.3282zm6.9892-5.3806h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.017-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="RB"/></svg>
|
After Width: | Height: | Size: 1.6 KiB |
1
portprotonqt/themes/standart/images/xbox_start.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m16.169 14.061c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395z" fill="#3f424d" fill-rule="evenodd" stroke-width=".19943"/></svg>
|
After Width: | Height: | Size: 958 B |
1
portprotonqt/themes/standart/images/xbox_view.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m12.75 14.492c-0.62128 0-1.1257 0.38721-1.1257 0.86456v12.1c0 0.47737 0.50442 0.86456 1.1257 0.86456h3.3753v-1.7274h-2.2496v-10.373h13.498v1.7291h2.2496v-2.5937c0-0.47735-0.50268-0.86456-1.1239-0.86456zm6.7489 5.1874c-0.62128 0-1.1239 0.38721-1.1239 0.86456v12.1c0 0.47737 0.50266 0.86456 1.1239 0.86456h15.749c0.62125 0 1.1239-0.3872 1.1239-0.86456v-12.1c0-0.47735-0.50268-0.86456-1.1239-0.86456zm1.1257 1.7291h13.498v10.371h-13.498z" clip-rule="evenodd" fill="#3f424d" fill-rule="evenodd" stroke-width=".98604"/></svg>
|
After Width: | Height: | Size: 919 B |
1
portprotonqt/themes/standart/images/xbox_x.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.097 23.789-7.4272-10.314h5.8379l4.5082 7.0055 4.4758-7.0055h5.8379l-7.4272 10.314 7.7839 10.735h-5.8704l-4.8001-7.4596-4.8325 7.4596h-5.8379z" fill="#3f424d" stroke-width=".67675" aria-label="X"/></svg>
|
After Width: | Height: | Size: 605 B |
@@ -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
|
||||
},
|
||||
{
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|