Compare commits
58 Commits
1a8c733580
...
renovate/p
Author | SHA1 | Date | |
---|---|---|---|
|
8849e90697 | ||
ac20447ba3
|
|||
ba143c15a8
|
|||
13068f3959
|
|||
|
c8360d08ca | ||
b070ff1fca
|
|||
b5a2f41bdf
|
|||
9a37f31841
|
|||
aeed0112cd
|
|||
027ae68d4d
|
|||
37d41fef8d
|
|||
e37422fc95
|
|||
d7951e8587
|
|||
556533785a
|
|||
a13aca4d84
|
|||
35736e1723
|
|||
|
24a7c2e657
|
||
|
279f7ec36b
|
||
41f6943998
|
|||
3bf10dc4cd
|
|||
33b96d3185
|
|||
3573b8e373
|
|||
582ddd2218
|
|||
2753e53a4d
|
|||
46973f35e1
|
|||
8e34c92385
|
|||
d50b63bca7
|
|||
6966253e9b
|
|||
13f3af7a42
|
|||
c7bed80570
|
|||
6fde7c18db
|
|||
37782d4375
|
|||
0a8a7c538c
|
|||
|
9cc4b8c51d | ||
397dede2be
|
|||
6a66f37ba1
|
|||
4db1cce32c
|
|||
edaeca4f11
|
|||
11d44f091d
|
|||
09d9c6510a
|
|||
272be51bb0
|
|||
63933172f9
|
|||
85e9aba836
|
|||
4d3499d2c1
|
|||
a13c15bc28
|
|||
83076d3dfc
|
|||
04aaf68e36
|
|||
e91037708a
|
|||
1b743026c2
|
|||
30b4cec4d1
|
|||
db68c9050c
|
|||
1a93d5b82c
|
|||
cc0690cf9e
|
|||
809ba2c976
|
|||
68c9636e10
|
|||
f0df1f89be
|
|||
f25224b668
|
|||
0cda47fdfd
|
@@ -17,10 +17,12 @@ 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
|
||||
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
|
||||
|
||||
- name: Install tools
|
||||
run: pip3 install appimage-builder uv
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
|
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 0.1.3
|
||||
VERSION: 0.1.4
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -23,10 +23,12 @@ 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
|
||||
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
|
||||
|
||||
- name: Install tools
|
||||
run: pip3 install appimage-builder uv
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
@@ -157,6 +159,7 @@ jobs:
|
||||
mkdir -p extracted
|
||||
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
|
||||
find extracted/ -type f -exec mv {} release/ \;
|
||||
find release/ -name '*.zip' -delete
|
||||
rm -rf extracted/
|
||||
|
||||
- name: Extract changelog for version
|
||||
|
187
.gitea/workflows/code-build.yml
Normal file
@@ -0,0 +1,187 @@
|
||||
name: Build Check - AppImage, Arch, Fedora
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'build-aux/**'
|
||||
|
||||
env:
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
appimage: ${{ steps.check.outputs.appimage }}
|
||||
fedora: ${{ steps.check.outputs.fedora }}
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Ensure git is installed
|
||||
run: |
|
||||
sudo apt update
|
||||
sudo apt install -y git
|
||||
|
||||
- name: Check changed files
|
||||
id: check
|
||||
run: |
|
||||
# Get changed files
|
||||
git diff --name-only ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }} > changed_files.txt
|
||||
|
||||
echo "Changed files:"
|
||||
cat changed_files.txt
|
||||
|
||||
# Check AppImage files
|
||||
if grep -q "build-aux/AppImageBuilder.yml" changed_files.txt; then
|
||||
echo "appimage=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "appimage=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Check Fedora spec files (only fedora-git.spec)
|
||||
if grep -q "build-aux/fedora-git.spec" changed_files.txt; then
|
||||
echo "fedora=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "fedora=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Check Arch PKGBUILD-git
|
||||
if grep -q "build-aux/PKGBUILD-git" changed_files.txt; then
|
||||
echo "arch=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "arch=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-appimage:
|
||||
name: Build AppImage
|
||||
runs-on: ubuntu-22.04
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- 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 zstd git
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
pip3 install git+https://github.com/Boria138/appimage-builder.git
|
||||
pip3 install uv
|
||||
|
||||
- name: Build AppImage
|
||||
run: |
|
||||
cd build-aux
|
||||
appimage-builder
|
||||
|
||||
- name: Upload AppImage
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-AppImage
|
||||
path: build-aux/PortProtonQt*.AppImage
|
||||
|
||||
build-fedora:
|
||||
name: Build Fedora RPM
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
if: needs.changes.outputs.fedora == 'true' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [41, 42, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Install build dependencies
|
||||
run: |
|
||||
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
|
||||
python3-build pyproject-rpm-macros python3-setuptools \
|
||||
redhat-rpm-config nodejs npm
|
||||
|
||||
- name: Setup rpmbuild environment
|
||||
run: |
|
||||
useradd rpmbuild -u 5002 -g users || true
|
||||
mkdir -p /home/rpmbuild/{BUILD,RPMS,SPECS,SRPMS,SOURCES}
|
||||
chown -R rpmbuild:users /home/rpmbuild
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout repo
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Copy fedora-git.spec
|
||||
run: |
|
||||
cp build-aux/fedora-git.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
|
||||
chown -R rpmbuild:users /home/rpmbuild
|
||||
|
||||
- name: Build RPM
|
||||
run: |
|
||||
su rpmbuild -c "rpmbuild -ba /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec"
|
||||
|
||||
- name: Upload RPM package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-RPM-Fedora-${{ matrix.fedora_version }}
|
||||
path: /home/rpmbuild/RPMS/**/*.rpm
|
||||
|
||||
build-arch:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
run: |
|
||||
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
|
||||
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
|
||||
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
|
||||
yes | pacman -Scc
|
||||
pacman-key --init
|
||||
pacman -S --noconfirm archlinux-keyring
|
||||
mkdir -p /__w/portproton-repo
|
||||
pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com
|
||||
pacman-key --lsign-key 3056513887B78AEB
|
||||
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst'
|
||||
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst'
|
||||
cat << EOM >> /etc/pacman.conf
|
||||
|
||||
[chaotic-aur]
|
||||
Include = /etc/pacman.d/chaotic-mirrorlist
|
||||
EOM
|
||||
pacman -Syy
|
||||
useradd -m user -G wheel && echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
|
||||
echo "PACKAGER=\"Boris Yumankulov <boria138@altlinux.org>\"" >> /etc/makepkg.conf
|
||||
chown user -R /tmp
|
||||
chown user -R ..
|
||||
|
||||
- name: Build
|
||||
run: |
|
||||
cd /__w/portproton-repo
|
||||
git clone https://git.linux-gaming.ru/Boria138/PortProtonQt.git
|
||||
cd /__w/portproton-repo/PortProtonQt/build-aux
|
||||
chown user -R ..
|
||||
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
|
||||
|
||||
- name: Checkout
|
||||
uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Upload Arch package
|
||||
uses: https://gitea.com/actions/gitea-upload-artifact@v4
|
||||
with:
|
||||
name: PortProtonQt-Arch
|
||||
path: ${{ env.PKGDEST }}/*
|
@@ -1,4 +1,4 @@
|
||||
name: Code and build check
|
||||
name: Code check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
@@ -35,20 +35,3 @@ jobs:
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
||||
|
||||
build-uv:
|
||||
name: Build with uv
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
|
||||
- name: Install uv
|
||||
uses: https://github.com/astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Sync dependencies
|
||||
run: uv sync
|
||||
|
||||
- name: Build project
|
||||
run: uv build
|
||||
|
@@ -8,11 +8,24 @@ on:
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:41.1.4
|
||||
container: ghcr.io/renovatebot/renovate:latest
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@v4
|
||||
- run: renovate
|
||||
|
||||
- name: Install uv
|
||||
uses: https://github.com/astral-sh/setup-uv@v6
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Download external renovate config
|
||||
run: |
|
||||
mkdir -p /tmp/renovate-config
|
||||
curl -fsSL "https://git.linux-gaming.ru/Linux-Gaming/renovate-config/raw/branch/main/config.js" \
|
||||
-o /tmp/renovate-config/config.js
|
||||
|
||||
- name: Run Renovate
|
||||
run: renovate
|
||||
env:
|
||||
RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js"
|
||||
RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
|
||||
LOG_LEVEL: "debug"
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
|
@@ -3,7 +3,7 @@
|
||||
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v5.0.0
|
||||
rev: v6.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
@@ -11,15 +11,14 @@ repos:
|
||||
- id: check-yaml
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.6.14
|
||||
rev: 0.8.9
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.11.5
|
||||
rev: v0.12.8
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
- id: ruff-check
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
|
31
CHANGELOG.md
@@ -5,6 +5,32 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- Больше типов анимаций при открытии карточки игры (за подробностями в документацию)
|
||||
- Анимация при выходе из карточки игры (за подробностями в документацию)
|
||||
|
||||
### Changed
|
||||
- Уменьшена длительность анимации открытия карточки с 800 до 350мс
|
||||
- Контекстное меню при открытие теперь сразу фокусируется на первом элементе
|
||||
- Анимации теперь можно настраивать через темы (за подробностями в документацию)
|
||||
- Общие json (steam_apps и anticheat_games) теперь перекачиваются если сломаны
|
||||
- Временно удалена светлая тема
|
||||
- Добавление и удаление игр из Steam теперь не требует перезагрузки Steam
|
||||
|
||||
### Fixed
|
||||
- legendary list теперь не вызывается если вход в EGS не был произведён
|
||||
- Скриншоты тем теперь не теряют в качестве при масштабе отличном от 100%
|
||||
- Данные от HLTB теперь не отображаются в карточке если нет данных о времени прохождения
|
||||
- Диалог добавления игры теперь не добавляет игру если exe не существует
|
||||
|
||||
|
||||
### Contributors
|
||||
- @Alex Smith
|
||||
|
||||
---
|
||||
|
||||
## [0.1.4] - 2025-07-21
|
||||
|
||||
### Added
|
||||
- Переводы в переопределениях (за подробностями в документацию)
|
||||
- Обложки и описания для всех автоинсталлов
|
||||
@@ -15,10 +41,15 @@
|
||||
- Оптимизированны обложки автоинсталлов
|
||||
- Папка custom_data исключена из сборки модуля для уменьшение его размера
|
||||
- Бейдж PortProton теперь открывает PortProtonDB
|
||||
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии
|
||||
- Удалён аргумент `--session` так как тестирование gamescope сессии завершено
|
||||
- В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton"
|
||||
|
||||
### Fixed
|
||||
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
|
||||
- Путь к portprotonqt-session-select в оверлее
|
||||
- Работа exiftool в AppImage
|
||||
- Открытие контекстного меню у игр без exe
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
|
@@ -51,11 +51,11 @@ pre-commit run --all-files
|
||||
|
||||
PortProtonQt использует код и зависимости от следующих проектов:
|
||||
|
||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html).
|
||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://opensource.org/licenses/MIT).
|
||||
- [PortProton 2.0](https://git.linux-gaming.ru/CastroFidel/PortProton_2.0) — библиотека для взаимодействия с PortProton, лицензия [MIT](https://opensource.org/licenses/MIT).
|
||||
- [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), [LICENSE-icoextract](documentation/licenses/icoextract), [LICENSE-portproton](documentation/licenses/portproton), [LICENSE-legendary](documentation/licenses/legendary).
|
||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
|
||||
|
||||
> [!WARNING]
|
||||
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
|
||||
|
4
TODO.md
@@ -17,7 +17,6 @@
|
||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
||||
- [X] Избавиться от вызовов yad
|
||||
- [ ] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton
|
||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
|
||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
||||
@@ -42,7 +41,7 @@
|
||||
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
||||
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
|
||||
- [X] Добавить поддержку версий Steam для Flatpak и Snap
|
||||
- [ ] Реализовать добавлление игры как сторонней в Steam без
|
||||
- [X] Реализовать добавление игры как сторонней в Steam без перезапуска
|
||||
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
|
||||
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
|
||||
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
|
||||
@@ -65,6 +64,5 @@
|
||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||
- [ ] Доделать светлую тему
|
||||
- [ ] Добавить подсказки к управлению с геймпада
|
||||
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
|
||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
||||
|
@@ -1,5 +1,4 @@
|
||||
version: 1
|
||||
|
||||
script:
|
||||
# 1) чистим старый AppDir
|
||||
- rm -rf AppDir || true
|
||||
@@ -14,29 +13,48 @@ script:
|
||||
# 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/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*}
|
||||
- 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*}
|
||||
- shopt -s extglob
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*)
|
||||
|
||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
|
||||
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
|
||||
- rm -rf $TARGET_APPDIR/usr/share/info || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/help || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/gtk-doc || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/devhelp || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/examples || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/pkgconfig || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/bash-completion || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/pixmaps || true
|
||||
- rm -rf $TARGET_APPDIR/usr/share/mime || true
|
||||
- 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.3
|
||||
version: 0.1.4
|
||||
exec: usr/bin/python3
|
||||
exec_args: "-m portprotonqt.app $@"
|
||||
|
||||
apt:
|
||||
arch: amd64
|
||||
sources:
|
||||
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
|
||||
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
|
||||
|
||||
include:
|
||||
- python3
|
||||
- python3-minimal
|
||||
- python3-pkg-resources
|
||||
- libopengl0
|
||||
- libk5crypto3
|
||||
@@ -45,13 +63,23 @@ AppDir:
|
||||
- libxcb-cursor0
|
||||
- libimage-exiftool-perl
|
||||
- xdg-utils
|
||||
exclude: []
|
||||
|
||||
exclude:
|
||||
# Документация и man-страницы
|
||||
- "*-doc"
|
||||
- "*-man"
|
||||
- manpages
|
||||
- mandb
|
||||
# Статические библиотеки
|
||||
- "*-dev"
|
||||
- "*-static"
|
||||
# Дебаг-символы
|
||||
- "*-dbg"
|
||||
- "*-dbgsym"
|
||||
runtime:
|
||||
env:
|
||||
PYTHONHOME: '${APPDIR}/usr'
|
||||
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
|
||||
|
||||
PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
|
||||
AppImage:
|
||||
sign-key: None
|
||||
arch: x86_64
|
||||
|
@@ -1,12 +1,12 @@
|
||||
pkgname=portprotonqt
|
||||
pkgver=0.1.3
|
||||
pkgver=0.1.4
|
||||
pkgrel=1
|
||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||
arch=('any')
|
||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
|
||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
||||
sha256sums=('SKIP')
|
||||
|
@@ -6,7 +6,7 @@ arch=('any')
|
||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||
license=('GPL-3.0')
|
||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
|
||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
|
||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
||||
sha256sums=('SKIP')
|
||||
|
@@ -33,6 +33,7 @@ Requires: python3-babel
|
||||
Requires: python3-evdev
|
||||
Requires: python3-icoextract
|
||||
Requires: python3-numpy
|
||||
Requires: python3-websocket-client
|
||||
Requires: python3-orjson
|
||||
Requires: python3-psutil
|
||||
Requires: python3-pyside6
|
||||
|
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 0.1.3
|
||||
%global pypi_version 0.1.4
|
||||
%global oname PortProtonQt
|
||||
%global _python_no_extras_requires 1
|
||||
|
||||
@@ -30,6 +30,7 @@ Requires: python3-babel
|
||||
Requires: python3-evdev
|
||||
Requires: python3-icoextract
|
||||
Requires: python3-numpy
|
||||
Requires: python3-websocket-client
|
||||
Requires: python3-orjson
|
||||
Requires: python3-psutil
|
||||
Requires: python3-pyside6
|
||||
|
@@ -9,7 +9,7 @@ _portprotonqt() {
|
||||
esac
|
||||
|
||||
if [[ "$cur" == -* ]]; then
|
||||
COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) )
|
||||
COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
|
||||
return 0
|
||||
fi
|
||||
|
||||
|
@@ -1,8 +0,0 @@
|
||||
module.exports = {
|
||||
"endpoint": "https://git.linux-gaming.ru/api/v1",
|
||||
"gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
|
||||
"platform": "gitea",
|
||||
"onboardingConfigFileName": "renovate.json",
|
||||
"autodiscover": true,
|
||||
"optimizeForDisabled": true,
|
||||
};
|
@@ -765,7 +765,7 @@
|
||||
},
|
||||
{
|
||||
"normalized_name": "lost ark",
|
||||
"status": "Broken"
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "archeage unchained",
|
||||
@@ -4426,5 +4426,61 @@
|
||||
{
|
||||
"normalized_name": "carx street",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "warcos 2",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "karos classic",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dead island riptide",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "lineage",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "day of dragons",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "sonic rumble",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "black stigma",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "umamusume pretty derby",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "dirt rally",
|
||||
"status": "Supported"
|
||||
},
|
||||
{
|
||||
"normalized_name": "minifighter",
|
||||
"status": "Broken"
|
||||
},
|
||||
{
|
||||
"normalized_name": "hide & hold out h2o",
|
||||
"status": "Running"
|
||||
},
|
||||
{
|
||||
"normalized_name": "f1 25",
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "ghost of tsushima director's cut",
|
||||
"status": "Denied"
|
||||
},
|
||||
{
|
||||
"normalized_name": "sword of justice",
|
||||
"status": "Broken"
|
||||
}
|
||||
]
|
@@ -1,12 +1,56 @@
|
||||
[
|
||||
{
|
||||
"normalized_title": "return alive",
|
||||
"slug": "return-alive"
|
||||
"normalized_title": "no sleep for kaname date from ai the somnium files",
|
||||
"slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dead island 2",
|
||||
"slug": "dead-island-2"
|
||||
},
|
||||
{
|
||||
"normalized_title": "dead island",
|
||||
"slug": "dead-island-definitive-edition"
|
||||
},
|
||||
{
|
||||
"normalized_title": "wuchang fallen feathers",
|
||||
"slug": "wuchang-fallen-feathers"
|
||||
},
|
||||
{
|
||||
"normalized_title": "mindseye",
|
||||
"slug": "mindseye"
|
||||
},
|
||||
{
|
||||
"normalized_title": "alan wake",
|
||||
"slug": "alan-wake"
|
||||
},
|
||||
{
|
||||
"normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a",
|
||||
"slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a"
|
||||
},
|
||||
{
|
||||
"normalized_title": "fifa 18",
|
||||
"slug": "fifa-18"
|
||||
},
|
||||
{
|
||||
"normalized_title": "eriksholm the stolen dream",
|
||||
"slug": "eriksholm-the-stolen-dream"
|
||||
},
|
||||
{
|
||||
"normalized_title": "caravan sandwitch",
|
||||
"slug": "caravan-sandwitch"
|
||||
},
|
||||
{
|
||||
"normalized_title": "expeditions a mudrunner game",
|
||||
"slug": "expeditions-a-mudrunner-game"
|
||||
},
|
||||
{
|
||||
"normalized_title": "#drive rally",
|
||||
"slug": "drive-rally"
|
||||
},
|
||||
{
|
||||
"normalized_title": "return alive",
|
||||
"slug": "return-alive"
|
||||
},
|
||||
{
|
||||
"normalized_title": "recore",
|
||||
"slug": "recore-definitive-edition"
|
||||
|
378
dev-scripts/appimage_clean.py
Executable file
@@ -0,0 +1,378 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PySide6 Dependencies Analyzer with ldd support
|
||||
Анализирует зависимости PySide6 модулей используя ldd для определения
|
||||
реальных зависимостей скомпилированных библиотек.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Set, Dict, List
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
class PySide6DependencyAnalyzer:
|
||||
def __init__(self):
|
||||
# Системные библиотеки, которые нужно всегда оставлять
|
||||
self.system_libs = {
|
||||
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
|
||||
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus'
|
||||
}
|
||||
|
||||
self.real_dependencies = {}
|
||||
self.used_modules_code = set()
|
||||
self.used_modules_ldd = set()
|
||||
self.all_required_modules = set()
|
||||
|
||||
def find_python_files(self, directory: Path) -> List[Path]:
|
||||
"""Находит все Python файлы в директории"""
|
||||
python_files = []
|
||||
for root, dirs, files in os.walk(directory):
|
||||
dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git'}]
|
||||
|
||||
for file in files:
|
||||
if file.endswith('.py'):
|
||||
python_files.append(Path(root) / file)
|
||||
return python_files
|
||||
|
||||
def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]:
|
||||
"""Находит все PySide6 библиотеки (.so файлы)"""
|
||||
libs = {}
|
||||
|
||||
# Поиск в единственной локации
|
||||
search_path = Path("../.venv/lib/python3.10/site-packages/PySide6")
|
||||
print(f"Поиск PySide6 библиотек в: {search_path}")
|
||||
|
||||
if search_path.exists():
|
||||
# Ищем .so файлы модулей
|
||||
for so_file in search_path.glob("Qt*.*.so"):
|
||||
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
|
||||
if module_name.startswith('Qt'):
|
||||
libs[module_name] = so_file
|
||||
|
||||
# Также ищем в подпапках
|
||||
for subdir in search_path.iterdir():
|
||||
if subdir.is_dir() and subdir.name.startswith('Qt'):
|
||||
for so_file in subdir.glob("*.so*"):
|
||||
if 'Qt' in so_file.name:
|
||||
libs[subdir.name] = so_file
|
||||
break
|
||||
|
||||
return libs
|
||||
|
||||
def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]:
|
||||
"""Анализирует зависимости библиотеки с помощью ldd"""
|
||||
qt_deps = set()
|
||||
|
||||
try:
|
||||
result = subprocess.run(['ldd', str(lib_path)],
|
||||
capture_output=True, text=True, check=True)
|
||||
|
||||
# Парсим вывод ldd и ищем Qt библиотеки
|
||||
for line in result.stdout.split('\n'):
|
||||
# Ищем строки вида: libQt6Core.so.6 => /path/to/lib
|
||||
match = re.search(r'libQt6(\w+)\.so', line)
|
||||
if match:
|
||||
qt_module = f"Qt{match.group(1)}"
|
||||
qt_deps.add(qt_module)
|
||||
|
||||
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
||||
print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}")
|
||||
|
||||
return qt_deps
|
||||
|
||||
def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]:
|
||||
"""Строит граф зависимостей на основе ldd анализа"""
|
||||
dependencies = {}
|
||||
|
||||
print("Анализ реальных зависимостей с помощью ldd...")
|
||||
for module, lib_path in pyside_libs.items():
|
||||
print(f" Анализируется {module}...")
|
||||
deps = self.analyze_ldd_dependencies(lib_path)
|
||||
dependencies[module] = deps
|
||||
|
||||
if deps:
|
||||
print(f" Зависимости: {', '.join(sorted(deps))}")
|
||||
|
||||
return dependencies
|
||||
|
||||
def analyze_file_imports(self, file_path: Path) -> Set[str]:
|
||||
"""Анализирует один Python файл и возвращает используемые PySide6 модули"""
|
||||
modules = set()
|
||||
try:
|
||||
with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
|
||||
content = f.read()
|
||||
|
||||
tree = ast.parse(content)
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith('PySide6.'):
|
||||
module = alias.name.split('.', 2)[1]
|
||||
if module.startswith('Qt'):
|
||||
modules.add(module)
|
||||
|
||||
elif isinstance(node, ast.ImportFrom):
|
||||
if node.module and node.module.startswith('PySide6.'):
|
||||
module = node.module.split('.', 2)[1]
|
||||
if module.startswith('Qt'):
|
||||
modules.add(module)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Ошибка при анализе {file_path}: {e}")
|
||||
|
||||
return modules
|
||||
|
||||
def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]:
|
||||
"""Получает все зависимости для набора модулей, используя граф зависимостей из ldd"""
|
||||
all_deps = set(modules)
|
||||
|
||||
if not dependency_graph:
|
||||
return all_deps
|
||||
|
||||
# Повторяем до тех пор, пока не найдем все транзитивные зависимости
|
||||
changed = True
|
||||
iteration = 0
|
||||
while changed and iteration < 10: # Защита от бесконечного цикла
|
||||
changed = False
|
||||
current_deps = set(all_deps)
|
||||
|
||||
for module in current_deps:
|
||||
if module in dependency_graph:
|
||||
new_deps = dependency_graph[module] - all_deps
|
||||
if new_deps:
|
||||
all_deps.update(new_deps)
|
||||
changed = True
|
||||
|
||||
iteration += 1
|
||||
|
||||
return all_deps
|
||||
|
||||
def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict:
|
||||
"""Анализирует весь проект"""
|
||||
python_files = self.find_python_files(project_path)
|
||||
print(f"Найдено {len(python_files)} Python файлов")
|
||||
|
||||
# Анализ статических импортов
|
||||
used_modules_code = set()
|
||||
file_modules = {}
|
||||
|
||||
for file_path in python_files:
|
||||
modules = self.analyze_file_imports(file_path)
|
||||
if modules:
|
||||
file_modules[str(file_path.relative_to(project_path))] = list(modules)
|
||||
used_modules_code.update(modules)
|
||||
|
||||
print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}")
|
||||
|
||||
# Поиск PySide6 библиотек
|
||||
search_base = appdir_path if appdir_path else project_path
|
||||
pyside_libs = self.find_pyside6_libs(search_base)
|
||||
|
||||
if not pyside_libs:
|
||||
print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.")
|
||||
return {
|
||||
'error': 'PySide6 библиотеки не найдены',
|
||||
'analysis_method': 'failed',
|
||||
'found_libraries': 0,
|
||||
'directly_used_code': sorted(used_modules_code),
|
||||
'all_required': [],
|
||||
'removable': [],
|
||||
'available_modules': [],
|
||||
'file_usage': file_modules
|
||||
}
|
||||
|
||||
print(f"Найдено {len(pyside_libs)} PySide6 библиотек")
|
||||
|
||||
# Анализ реальных зависимостей с ldd
|
||||
real_dependencies = self.build_real_dependency_graph(pyside_libs)
|
||||
|
||||
# Определяем модули, которые реально используются через ldd
|
||||
used_modules_ldd = set()
|
||||
for module in used_modules_code:
|
||||
if module in real_dependencies:
|
||||
used_modules_ldd.update(real_dependencies[module])
|
||||
used_modules_ldd.add(module)
|
||||
|
||||
print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}")
|
||||
|
||||
# Объединяем результаты анализа кода и ldd
|
||||
all_used_modules = used_modules_code | used_modules_ldd
|
||||
|
||||
# Получаем все необходимые модули включая зависимости
|
||||
all_required = self.get_all_dependencies(all_used_modules, real_dependencies)
|
||||
|
||||
# Все доступные PySide6 модули
|
||||
available_modules = set(pyside_libs.keys())
|
||||
|
||||
# Модули, которые можно удалить
|
||||
removable = available_modules - all_required
|
||||
|
||||
return {
|
||||
'analysis_method': 'ldd + static analysis',
|
||||
'found_libraries': len(pyside_libs),
|
||||
'directly_used_code': sorted(used_modules_code),
|
||||
'directly_used_ldd': sorted(used_modules_ldd),
|
||||
'all_required': sorted(all_required),
|
||||
'removable': sorted(removable),
|
||||
'available_modules': sorted(available_modules),
|
||||
'file_usage': file_modules,
|
||||
'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()},
|
||||
'library_paths': {k: str(v) for k, v in pyside_libs.items()},
|
||||
'analysis_summary': {
|
||||
'total_modules': len(available_modules),
|
||||
'required_modules': len(all_required),
|
||||
'removable_modules': len(removable),
|
||||
'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%"
|
||||
}
|
||||
}
|
||||
|
||||
def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str:
|
||||
"""Генерирует обновленный AppImage рецепт с командами очистки"""
|
||||
|
||||
# Читаем существующий рецепт
|
||||
try:
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
recipe_content = f.read()
|
||||
except FileNotFoundError:
|
||||
print(f"Шаблон рецепта не найден: {template_path}")
|
||||
return ""
|
||||
|
||||
# Генерируем новые команды очистки
|
||||
cleanup_lines = []
|
||||
|
||||
# QML удаляем только если не используется
|
||||
qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'}
|
||||
if qml_modules.issubset(set(removable_modules)):
|
||||
cleanup_lines.append(" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/")
|
||||
|
||||
# Инструменты разработки (всегда удаляем)
|
||||
cleanup_lines.append(" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}")
|
||||
|
||||
# Модули для удаления
|
||||
if removable_modules:
|
||||
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)])
|
||||
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
|
||||
|
||||
# Генерируем команду для удаления нативных библиотек с сохранением нужных
|
||||
required_libs = set()
|
||||
for module in sorted(set(self.all_required_modules)):
|
||||
required_libs.add(f"libQt6{module.replace('Qt', '')}*")
|
||||
|
||||
# Добавляем системные библиотеки
|
||||
for lib in self.system_libs:
|
||||
required_libs.add(f"{lib}*")
|
||||
|
||||
keep_pattern = '|'.join(sorted(required_libs))
|
||||
|
||||
cleanup_lines.extend([
|
||||
" - shopt -s extglob",
|
||||
f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
|
||||
])
|
||||
|
||||
# Заменяем блок очистки в рецепте
|
||||
import re
|
||||
|
||||
# Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir:
|
||||
pattern = r'( # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n # [0-9]+\)|$)'
|
||||
|
||||
new_cleanup_block = " # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines)
|
||||
|
||||
updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL)
|
||||
|
||||
return updated_recipe
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
|
||||
parser.add_argument('project_path', help='Путь к проекту для анализа')
|
||||
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
|
||||
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
project_path = Path(args.project_path)
|
||||
if not project_path.exists():
|
||||
print(f"Ошибка: путь {project_path} не существует")
|
||||
sys.exit(1)
|
||||
|
||||
appdir_path = Path(args.appdir) if args.appdir else None
|
||||
if appdir_path and not appdir_path.exists():
|
||||
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
|
||||
appdir_path = None
|
||||
|
||||
analyzer = PySide6DependencyAnalyzer()
|
||||
results = analyzer.analyze_project(project_path, appdir_path)
|
||||
|
||||
# Сохраняем в анализатор для генерации команд
|
||||
analyzer.all_required_modules = set(results.get('all_required', []))
|
||||
|
||||
# Выводим результаты
|
||||
print("\n" + "="*60)
|
||||
print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)")
|
||||
print("="*60)
|
||||
|
||||
if 'error' in results:
|
||||
print(f"\nОШИБКА: {results['error']}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\nМетод анализа: {results['analysis_method']}")
|
||||
print(f"Найдено библиотек: {results['found_libraries']}")
|
||||
|
||||
if results['directly_used_code']:
|
||||
print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):")
|
||||
for module in results['directly_used_code']:
|
||||
print(f" • {module}")
|
||||
|
||||
if results['directly_used_ldd']:
|
||||
print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):")
|
||||
for module in results['directly_used_ldd']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nВсе необходимые модули ({len(results['all_required'])}):")
|
||||
for module in results['all_required']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nМодули, которые можно удалить ({len(results['removable'])}):")
|
||||
for module in results['removable']:
|
||||
print(f" • {module}")
|
||||
|
||||
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
|
||||
|
||||
if args.verbose and results['real_dependencies']:
|
||||
Devlin(f"\nРеальные зависимости (ldd):")
|
||||
for module, deps in results['real_dependencies'].items():
|
||||
if deps:
|
||||
print(f" {module} → {', '.join(deps)}")
|
||||
|
||||
# Обновляем AppImage рецепт
|
||||
recipe_path = Path("../build-aux/AppImageBuilder.yml")
|
||||
if recipe_path.exists():
|
||||
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
|
||||
if updated_recipe:
|
||||
with open(recipe_path, 'w', encoding='utf-8') as f:
|
||||
f.write(updated_recipe)
|
||||
print(f"\nAppImage рецепт обновлен: {recipe_path}")
|
||||
else:
|
||||
print(f"\nОШИБКА: не удалось обновить рецепт")
|
||||
else:
|
||||
print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}")
|
||||
|
||||
# Сохраняем результаты в JSON
|
||||
if args.output:
|
||||
with open(args.output, 'w', encoding='utf-8') as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
print(f"Результаты сохранены в: {args.output}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
@@ -3,10 +3,11 @@
|
||||
---
|
||||
|
||||
## 📋 Contents
|
||||
- [Overview](#overview)
|
||||
- [Adding a New Translation](#adding-a-new-translation)
|
||||
- [Updating Existing Translations](#updating-existing-translations)
|
||||
- [Compiling Translations](#compiling-translations)
|
||||
- [Overview](#-overview)
|
||||
- [Adding a New Translation](#-adding-a-new-translation)
|
||||
- [Updating Existing Translations](#-updating-existing-translations)
|
||||
- [Compiling Translations](#-compiling-translations)
|
||||
- [Spell Check](#-spell-check)
|
||||
|
||||
---
|
||||
|
||||
|
@@ -3,10 +3,11 @@
|
||||
---
|
||||
|
||||
## 📋 Содержание
|
||||
- [Обзор](#обзор)
|
||||
- [Добавление нового перевода](#добавление-нового-перевода)
|
||||
- [Обновление существующих переводов](#обновление-существующих-переводов)
|
||||
- [Компиляция переводов](#компиляция-переводов)
|
||||
- [Обзор](#-обзор)
|
||||
- [Добавление нового перевода](#-добавление-нового-перевода)
|
||||
- [Обновление существующих переводов](#-обновление-существующих-переводов)
|
||||
- [Компиляция переводов](#-компиляция-переводов)
|
||||
- [Проверка орфографии](#-проверка-орфографии)
|
||||
|
||||
---
|
||||
|
||||
|
@@ -3,15 +3,10 @@
|
||||
---
|
||||
|
||||
## 📋 Contents
|
||||
- [Overview](#overview)
|
||||
- [How It Works](#how-it-works)
|
||||
- [Data Priorities](#data-priorities)
|
||||
- [File Structure](#file-structure)
|
||||
- [For Users](#for-users)
|
||||
- [Creating User Overrides](#creating-user-overrides)
|
||||
- [Example](#example)
|
||||
- [For Developers](#for-developers)
|
||||
- [Adding Built-In Overrides](#adding-built-in-overrides)
|
||||
- [Overview](#-overview)
|
||||
- [How It Works](#-how-it-works)
|
||||
- [For Users](#-for-users)
|
||||
- [For Developers](#-for-developers)
|
||||
|
||||
---
|
||||
|
||||
|
@@ -3,15 +3,10 @@
|
||||
---
|
||||
|
||||
## 📋 Содержание
|
||||
- [Обзор](#обзор)
|
||||
- [Как это работает](#как-это-работает)
|
||||
- [Приоритеты данных](#приоритеты-данных)
|
||||
- [Структура файлов](#структура-файлов)
|
||||
- [Для пользователей](#для-пользователей)
|
||||
- [Создание пользовательских переопределений](#создание-пользовательских-переопределений)
|
||||
- [Пример](#пример)
|
||||
- [Для разработчиков](#для-разработчиков)
|
||||
- [Добавление встроенных переопределений](#добавление-встроенных-переопределений)
|
||||
- [Обзор](#-обзор)
|
||||
- [Как это работает](#-как-это-работает)
|
||||
- [Для пользователей](#-для-пользователей)
|
||||
- [Для разработчиков](#-для-разработчиков)
|
||||
|
||||
---
|
||||
|
||||
|
@@ -3,12 +3,13 @@
|
||||
---
|
||||
|
||||
## 📋 Contents
|
||||
- [Overview](#overview)
|
||||
- [Creating the Theme Folder](#creating-the-theme-folder)
|
||||
- [Style File](#style-file)
|
||||
- [Metadata](#metadata)
|
||||
- [Screenshots](#screenshots)
|
||||
- [Fonts and Icons](#fonts-and-icons)
|
||||
- [Overview](#-overview)
|
||||
- [Creating the Theme Folder](#-creating-the-theme-folder)
|
||||
- [Style File](#-style-file-stylespy)
|
||||
- [Animation configuration](#-animation-configuration)
|
||||
- [Metadata](#-metadata-metainfoini)
|
||||
- [Screenshots](#-screenshots)
|
||||
- [Fonts and Icons](#-fonts-and-icons-optional)
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +46,114 @@ def custom_button_style(color1, color2):
|
||||
|
||||
---
|
||||
|
||||
## 🎥 Animation configuration
|
||||
|
||||
The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game cards:
|
||||
|
||||
```python
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Type of animation when entering and exiting the detail page
|
||||
# Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
|
||||
"detail_page_animation_type": "fade",
|
||||
|
||||
# Border width of the card in idle state (no hover or focus).
|
||||
# Affects the thickness of the border when the card is not highlighted.
|
||||
# Value in pixels.
|
||||
"default_border_width": 2,
|
||||
|
||||
# Border width on hover.
|
||||
# Increases the border thickness when the cursor is over the card.
|
||||
# Value in pixels.
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Border width on focus (e.g., selected via keyboard).
|
||||
# Increases the border thickness when the card is focused.
|
||||
# Value in pixels.
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Minimum border width during pulsing animation.
|
||||
# Sets the minimum border thickness during the "breathing" animation.
|
||||
# Value in pixels.
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Maximum border width during pulsing animation.
|
||||
# Sets the maximum border thickness during pulsing.
|
||||
# Value in pixels.
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Duration of the border thickness animation (e.g., on hover or focus).
|
||||
# Affects the speed of transition between different border widths.
|
||||
# Value in milliseconds.
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Duration of one pulsing animation cycle.
|
||||
# Defines how fast the border "pulses" between min and max values.
|
||||
# Value in milliseconds.
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Duration of the gradient rotation animation.
|
||||
# Affects how fast the gradient border rotates around the card.
|
||||
# Value in milliseconds.
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Starting angle of the gradient (in degrees).
|
||||
# Defines the initial rotation point of the gradient when the animation starts.
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Ending angle of the gradient (in degrees).
|
||||
# Defines the end rotation point of the gradient.
|
||||
# A value of 0 means a full 360-degree rotation.
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Easing curve type for border expansion animation (on hover/focus).
|
||||
# Affects the "feel" of the animation (e.g., smooth acceleration or deceleration).
|
||||
# Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad").
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Easing curve type for border contraction animation (on mouse leave/focus loss).
|
||||
# Affects the "feel" of returning to the original border width.
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Gradient colors for the animated border.
|
||||
# A list of dictionaries where each defines a position (0.0–1.0) and color in hex format.
|
||||
# Affects the appearance of the border on hover or focus.
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Start color (cyan)
|
||||
{"position": 0.33, "color": "#FF5733"}, # 33% color (orange)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # 66% color (purple)
|
||||
{"position": 1, "color": "#00fff5"} # End color (back to cyan)
|
||||
],
|
||||
|
||||
# Duration of the fade animation when entering the detail page
|
||||
"detail_page_fade_duration": 350,
|
||||
|
||||
# Duration of the slide animation when entering the detail page
|
||||
"detail_page_slide_duration": 500,
|
||||
|
||||
# Duration of the bounce animation when entering the detail page
|
||||
"detail_page_bounce_duration": 400,
|
||||
|
||||
# Duration of the fade animation when exiting the detail page
|
||||
"detail_page_fade_duration_exit": 350,
|
||||
|
||||
# Duration of the slide animation when exiting the detail page
|
||||
"detail_page_slide_duration_exit": 500,
|
||||
|
||||
# Duration of the bounce animation when exiting the detail page
|
||||
"detail_page_bounce_duration_exit": 400,
|
||||
|
||||
# Easing curve type for animation when entering the detail page
|
||||
# Applies to slide and bounce animations
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Easing curve type for animation when exiting the detail page
|
||||
# Applies to slide and bounce animations
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Metadata (`metainfo.ini`)
|
||||
|
||||
```ini
|
||||
|
@@ -3,12 +3,13 @@
|
||||
---
|
||||
|
||||
## 📋 Содержание
|
||||
- [Обзор](#обзор)
|
||||
- [Создание папки темы](#создание-папки-темы)
|
||||
- [Файл стилей](#файл-стилей)
|
||||
- [Метаинформация](#метаинформация)
|
||||
- [Скриншоты](#скриншоты)
|
||||
- [Шрифты и иконки](#шрифты-и-иконки)
|
||||
- [Обзор](#-обзор)
|
||||
- [Создание папки темы](#-создание-папки-темы)
|
||||
- [Файл стилей](#-файл-стилей-stylespy)
|
||||
- [Конфигурация анимации](#-конфигурация-анимации)
|
||||
- [Метаинформация](#-метаинформация-metainfoini)
|
||||
- [Скриншоты](#-скриншоты)
|
||||
- [Шрифты и иконки](#-шрифты-и-иконки-опционально)
|
||||
|
||||
---
|
||||
|
||||
@@ -45,6 +46,114 @@ def custom_button_style(color1, color2):
|
||||
|
||||
---
|
||||
|
||||
## 🎥 Конфигурация анимации
|
||||
|
||||
Словарь `GAME_CARD_ANIMATION` управляет всеми параметрами анимации для карточек игр:
|
||||
|
||||
```python
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Тип анимации при входе и выходе на детальную страницу
|
||||
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
|
||||
"detail_page_animation_type": "fade",
|
||||
|
||||
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
|
||||
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
|
||||
# Значение в пикселях.
|
||||
"default_border_width": 2,
|
||||
|
||||
# Ширина обводки при наведении курсора.
|
||||
# Увеличивает толщину рамки, когда курсор находится над карточкой.
|
||||
# Значение в пикселях.
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
|
||||
# Увеличивает толщину рамки, когда карточка в фокусе.
|
||||
# Значение в пикселях.
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Минимальная ширина обводки во время пульсирующей анимации.
|
||||
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
|
||||
# Значение в пикселях.
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Максимальная ширина обводки во время пульсирующей анимации.
|
||||
# Определяет максимальную толщину рамки при пульсации.
|
||||
# Значение в пикселях.
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
|
||||
# Влияет на скорость перехода от одной ширины обводки к другой.
|
||||
# Значение в миллисекундах.
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Длительность одного цикла пульсирующей анимации.
|
||||
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
|
||||
# Значение в миллисекундах.
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Длительность анимации вращения градиента.
|
||||
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
|
||||
# Значение в миллисекундах.
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Начальный угол градиента (в градусах).
|
||||
# Определяет начальную точку вращения градиента при старте анимации.
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Конечный угол градиента (в градусах).
|
||||
# Определяет конечную точку вращения градиента.
|
||||
# Значение 0 означает полный поворот на 360 градусов.
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки.
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки.
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
|
||||
# Влияет на внешний вид обводки при наведении или фокусе.
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||
],
|
||||
|
||||
# Длительность анимации fade при входе на детальную страницу
|
||||
"detail_page_fade_duration": 350,
|
||||
|
||||
# Длительность анимации slide при входе на детальную страницу
|
||||
"detail_page_slide_duration": 500,
|
||||
|
||||
# Длительность анимации bounce при входе на детальную страницу
|
||||
"detail_page_bounce_duration": 400,
|
||||
|
||||
# Длительность анимации fade при выходе из детальной страницы
|
||||
"detail_page_fade_duration_exit": 350,
|
||||
|
||||
# Длительность анимации slide при выходе из детальной страницы
|
||||
"detail_page_slide_duration_exit": 500,
|
||||
|
||||
# Длительность анимации bounce при выходе из детальной страницы
|
||||
"detail_page_bounce_duration_exit": 400,
|
||||
|
||||
# Тип кривой сглаживания для анимации при входе на детальную страницу
|
||||
# Применяется к slide и bounce анимациям
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Тип кривой сглаживания для анимации при выходе из детальной страницы
|
||||
# Применяется к slide и bounce анимациям
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 Метаинформация (`metainfo.ini`)
|
||||
|
||||
```ini
|
||||
|
328
portprotonqt/animations.py
Normal file
@@ -0,0 +1,328 @@
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
|
||||
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
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
class SafeOpacityEffect(QGraphicsOpacityEffect):
|
||||
def __init__(self, parent=None, disable_at_full=True):
|
||||
super().__init__(parent)
|
||||
self.disable_at_full = disable_at_full
|
||||
|
||||
def setOpacity(self, opacity: float):
|
||||
opacity = max(0.0, min(1.0, opacity))
|
||||
super().setOpacity(opacity)
|
||||
if opacity < 1.0:
|
||||
self.setEnabled(True)
|
||||
elif self.disable_at_full:
|
||||
self.setEnabled(False)
|
||||
|
||||
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.thickness_anim: QPropertyAnimation | None = None
|
||||
self.gradient_anim: QPropertyAnimation | None = None
|
||||
self.pulse_anim: QPropertyAnimation | None = None
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
def setup_animations(self):
|
||||
"""Initialize animation properties."""
|
||||
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
|
||||
def start_pulse_animation(self):
|
||||
"""Start pulse animation for border width when hovered or focused."""
|
||||
if not (self.game_card._hovered or self.game_card._focused):
|
||||
return
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.start()
|
||||
|
||||
def handle_enter_event(self):
|
||||
"""Handle mouse enter event animations."""
|
||||
self.game_card._hovered = True
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, True)
|
||||
self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self.game_card._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
|
||||
self.thickness_anim.finished.connect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
def handle_leave_event(self):
|
||||
"""Handle mouse leave event animations."""
|
||||
self.game_card._hovered = False
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._focused:
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self.game_card._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
|
||||
def handle_focus_in_event(self):
|
||||
"""Handle focus in event animations."""
|
||||
if not self.game_card._hovered:
|
||||
self.game_card._focused = True
|
||||
self.game_card.focusChanged.emit(self.game_card.name, True)
|
||||
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self.game_card._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
|
||||
self.thickness_anim.finished.connect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
def handle_focus_out_event(self):
|
||||
"""Handle focus out event animations."""
|
||||
self.game_card._focused = False
|
||||
self.game_card.focusChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._hovered:
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.start_pulse_animation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self.game_card._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
|
||||
def paint_border(self, painter: QPainter):
|
||||
if not painter.isActive():
|
||||
logger.warning("Painter is not active; skipping border paint")
|
||||
return
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
pen = QPen()
|
||||
pen.setWidth(self.game_card._borderWidth)
|
||||
if self.game_card._hovered or self.game_card._focused:
|
||||
center = self.game_card.rect().center()
|
||||
gradient = QConicalGradient(center, self.game_card._gradientAngle)
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
else:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
painter.setPen(pen)
|
||||
radius = 18
|
||||
bw = round(self.game_card._borderWidth / 2)
|
||||
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
|
||||
if rect.isEmpty():
|
||||
return # Avoid drawing invalid rect
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
|
||||
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.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):
|
||||
"""Animate the detail page based on theme settings."""
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
|
||||
|
||||
if animation_type == "fade":
|
||||
original_effect = detail_page.graphicsEffect()
|
||||
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
|
||||
opacity_effect.setOpacity(0.0)
|
||||
detail_page.setGraphicsEffect(opacity_effect)
|
||||
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(0.0)
|
||||
animation.setEndValue(0.999)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
def restore_effect():
|
||||
try:
|
||||
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
||||
except RuntimeError:
|
||||
logger.debug("Original effect already deleted")
|
||||
animation.finished.connect(restore_effect)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
animation.finished.connect(opacity_effect.deleteLater)
|
||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||
start_pos = {
|
||||
"slide_left": QPoint(self.main_window.width(), 0),
|
||||
"slide_right": QPoint(-self.main_window.width(), 0),
|
||||
"slide_up": QPoint(0, self.main_window.height()),
|
||||
"slide_down": QPoint(0, -self.main_window.height())
|
||||
}[animation_type]
|
||||
detail_page.move(start_pos)
|
||||
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(start_pos)
|
||||
animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
|
||||
animation.setEasingCurve(easing_curve)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(cleanup_animation)
|
||||
animation.finished.connect(load_image_and_restore_effect)
|
||||
elif animation_type == "bounce":
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
|
||||
detail_page.setWindowOpacity(0.0)
|
||||
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
|
||||
opacity_anim.setDuration(duration)
|
||||
opacity_anim.setStartValue(0.0)
|
||||
opacity_anim.setEndValue(1.0)
|
||||
initial_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
|
||||
detail_page.width() // 2, detail_page.height() // 2)
|
||||
final_rect = detail_page.geometry()
|
||||
geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
|
||||
geometry_anim.setDuration(duration)
|
||||
geometry_anim.setStartValue(initial_rect)
|
||||
geometry_anim.setEndValue(final_rect)
|
||||
geometry_anim.setEasingCurve(easing_curve)
|
||||
group_anim = QParallelAnimationGroup()
|
||||
group_anim.addAnimation(opacity_anim)
|
||||
group_anim.addAnimation(geometry_anim)
|
||||
group_anim.finished.connect(load_image_and_restore_effect)
|
||||
group_anim.finished.connect(cleanup_animation)
|
||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = group_anim
|
||||
|
||||
def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
|
||||
"""Animate the detail page exit based on theme settings."""
|
||||
try:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
|
||||
|
||||
# Safely stop and remove any existing animation
|
||||
if detail_page in self.animations:
|
||||
try:
|
||||
animation = self.animations[detail_page]
|
||||
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
|
||||
animation.stop()
|
||||
except RuntimeError:
|
||||
logger.debug("Animation already deleted for page")
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping existing animation: {e}", exc_info=True)
|
||||
finally:
|
||||
self.animations.pop(detail_page, None)
|
||||
|
||||
# Define animation based on type
|
||||
if animation_type == "fade":
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
|
||||
original_effect = detail_page.graphicsEffect()
|
||||
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
|
||||
opacity_effect.setOpacity(0.999)
|
||||
detail_page.setGraphicsEffect(opacity_effect)
|
||||
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(0.999)
|
||||
animation.setEndValue(0.0)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
def restore_and_cleanup():
|
||||
try:
|
||||
detail_page.setGraphicsEffect(original_effect) # type: ignore
|
||||
except RuntimeError:
|
||||
logger.debug("Original effect already deleted")
|
||||
cleanup_callback()
|
||||
animation.finished.connect(restore_and_cleanup)
|
||||
animation.finished.connect(opacity_effect.deleteLater) # Clean up effect
|
||||
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
||||
end_pos = {
|
||||
"slide_left": QPoint(-self.main_window.width(), 0), # Exit to left (opposite of entry)
|
||||
"slide_right": QPoint(self.main_window.width(), 0), # Exit to right
|
||||
"slide_up": QPoint(0, self.main_window.height()), # Exit downward
|
||||
"slide_down": QPoint(0, -self.main_window.height()) # Exit upward
|
||||
}[animation_type]
|
||||
animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
|
||||
animation.setDuration(duration)
|
||||
animation.setStartValue(detail_page.pos())
|
||||
animation.setEndValue(end_pos)
|
||||
animation.setEasingCurve(easing_curve)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = animation
|
||||
animation.finished.connect(cleanup_callback)
|
||||
elif animation_type == "bounce":
|
||||
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
|
||||
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
|
||||
opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
|
||||
opacity_anim.setDuration(duration)
|
||||
opacity_anim.setStartValue(1.0)
|
||||
opacity_anim.setEndValue(0.0)
|
||||
final_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
|
||||
detail_page.width() // 2, detail_page.height() // 2)
|
||||
geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
|
||||
geometry_anim.setDuration(duration)
|
||||
geometry_anim.setStartValue(detail_page.geometry())
|
||||
geometry_anim.setEndValue(final_rect)
|
||||
geometry_anim.setEasingCurve(easing_curve)
|
||||
group_anim = QParallelAnimationGroup()
|
||||
group_anim.addAnimation(opacity_anim)
|
||||
group_anim.addAnimation(geometry_anim)
|
||||
group_anim.finished.connect(cleanup_callback)
|
||||
group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self.animations[detail_page] = group_anim
|
||||
except Exception as e:
|
||||
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
|
||||
self.animations.pop(detail_page, None)
|
||||
cleanup_callback() # Fallback to cleanup if animation setup fails
|
@@ -1,6 +1,4 @@
|
||||
import sys
|
||||
import os
|
||||
import subprocess
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QIcon
|
||||
@@ -14,7 +12,7 @@ logger = get_logger(__name__)
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
__app_version__ = "0.1.3"
|
||||
__app_version__ = "0.1.4"
|
||||
|
||||
def main():
|
||||
app = QApplication(sys.argv)
|
||||
@@ -35,13 +33,6 @@ def main():
|
||||
|
||||
window = MainWindow()
|
||||
|
||||
if args.session:
|
||||
gamescope_cmd = os.getenv("GAMESCOPE_CMD", "gamescope -f --xwayland-count 2")
|
||||
cmd = f"{gamescope_cmd} -- portprotonqt"
|
||||
logger.info(f"Executing: {cmd}")
|
||||
subprocess.Popen(cmd, shell=True)
|
||||
sys.exit(0)
|
||||
|
||||
if args.fullscreen:
|
||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||
save_fullscreen_config(True)
|
||||
|
@@ -13,9 +13,4 @@ def parse_args():
|
||||
action="store_true",
|
||||
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--session",
|
||||
action="store_true",
|
||||
help="Запустить приложение с использованием gamescope"
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
@@ -148,10 +148,7 @@ class ContextMenuManager:
|
||||
return False
|
||||
current_exe = os.path.basename(exe_path)
|
||||
|
||||
# Check if the current_exe matches the target_exe in MainWindow
|
||||
if hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe:
|
||||
return True
|
||||
return False
|
||||
return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
|
||||
|
||||
def show_context_menu(self, game_card, pos: QPoint):
|
||||
"""
|
||||
@@ -161,7 +158,6 @@ class ContextMenuManager:
|
||||
game_card: The GameCard instance requesting the context menu.
|
||||
pos: The position (in widget coordinates) where the menu should appear.
|
||||
"""
|
||||
|
||||
def get_safe_icon(icon_name: str) -> QIcon:
|
||||
icon = self.theme_manager.get_icon(icon_name)
|
||||
if isinstance(icon, QIcon):
|
||||
@@ -173,7 +169,18 @@ class ContextMenuManager:
|
||||
menu = QMenu(self.parent)
|
||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||
|
||||
# Check if the game is running
|
||||
# For non-Steam and non-Epic games, check if exe exists
|
||||
if game_card.game_source not in ("steam", "epic"):
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
|
||||
if not exe_path:
|
||||
# Show only "Delete from PortProton" if no valid exe
|
||||
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
|
||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
return
|
||||
|
||||
# Normal menu for games with valid exe or from Steam/Epic
|
||||
is_running = self._is_game_running(game_card)
|
||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||
action_icon = "stop" if is_running else "play"
|
||||
@@ -273,7 +280,12 @@ class ContextMenuManager:
|
||||
)
|
||||
)
|
||||
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
# Устанавливаем фокус на первый элемент меню
|
||||
actions = menu.actions()
|
||||
if actions:
|
||||
menu.setActiveAction(actions[0])
|
||||
|
||||
menu.exec(game_card.mapToGlobal(pos))
|
||||
|
||||
def _launch_game(self, game_card):
|
||||
"""
|
||||
@@ -697,15 +709,12 @@ Icon={icon_path}
|
||||
return None
|
||||
return exec_line
|
||||
|
||||
def _parse_exe_path(self, exec_line, game_name):
|
||||
def _parse_exe_path(self, exec_line: str, game_name: str) -> str | None:
|
||||
"""Parse the executable path from exec_line."""
|
||||
try:
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Invalid executable command: {exec_line}").format(exec_line=exec_line)
|
||||
)
|
||||
logger.debug("Invalid executable command for '%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]
|
||||
@@ -714,17 +723,11 @@ Icon={icon_path}
|
||||
else:
|
||||
exe_path = entry_exec_split[-1]
|
||||
if not exe_path or not os.path.exists(exe_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Executable not found: {path}").format(path=exe_path or "None")
|
||||
)
|
||||
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
|
||||
return None
|
||||
return exe_path
|
||||
except Exception as e:
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("Failed to parse executable: {error}").format(error=str(e))
|
||||
)
|
||||
logger.debug("Failed to parse executable for '%s': %s", game_name, e)
|
||||
return None
|
||||
|
||||
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
|
||||
|
Before Width: | Height: | Size: 447 KiB |
@@ -1,3 +0,0 @@
|
||||
name=Pulse Online
|
||||
description_ru=Многопользовательская онлайн-игра в жанре MMORPG, действие которой происходит в научно-фантастическом мире с уникальной боевой системой и глубоким крафтом. Игроки могут исследовать обширные локации, выполнять квесты, сражаться с противниками и взаимодействовать с другими участниками игры.
|
||||
description_en=A multiplayer online game in the MMORPG genre set in a sci-fi world with a unique combat system and deep crafting mechanics. Players can explore vast locations, complete quests, battle enemies, and interact with other participants in the game.
|
@@ -677,7 +677,10 @@ class AddGameDialog(QDialog):
|
||||
exe_path = self.exeEdit.text().strip()
|
||||
name = self.nameEdit.text().strip()
|
||||
|
||||
if not exe_path or not name:
|
||||
if not exe_path or not os.path.isfile(exe_path):
|
||||
return None, None
|
||||
|
||||
if not name:
|
||||
return None, None
|
||||
|
||||
portproton_path = get_portproton_location()
|
||||
|
@@ -144,14 +144,21 @@ class Downloader(QObject):
|
||||
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
|
||||
return None
|
||||
if url in self._cache:
|
||||
return self._cache[url]
|
||||
cached_path = self._cache[url]
|
||||
if os.path.exists(cached_path):
|
||||
if os.path.abspath(cached_path) == os.path.abspath(local_path):
|
||||
return cached_path
|
||||
else:
|
||||
del self._cache[url]
|
||||
url_lock = self._get_url_lock(url)
|
||||
with url_lock:
|
||||
with self._global_lock:
|
||||
if url in self._last_error:
|
||||
return None
|
||||
if url in self._cache:
|
||||
return self._cache[url]
|
||||
cached_path = self._cache[url]
|
||||
if os.path.exists(cached_path) and os.path.abspath(cached_path) == os.path.abspath(local_path):
|
||||
return cached_path
|
||||
result = download_with_cache(url, local_path, timeout, self)
|
||||
with self._global_lock:
|
||||
if result:
|
||||
|
@@ -16,13 +16,14 @@ from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_la
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
from portprotonqt.steam_api import (
|
||||
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
|
||||
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail
|
||||
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
|
||||
)
|
||||
import vdf
|
||||
import shutil
|
||||
import zlib
|
||||
from portprotonqt.downloader import Downloader
|
||||
from PySide6.QtGui import QPixmap
|
||||
import base64
|
||||
|
||||
logger = get_logger(__name__)
|
||||
downloader = Downloader()
|
||||
@@ -66,7 +67,8 @@ def get_cache_dir() -> Path:
|
||||
|
||||
def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
||||
"""
|
||||
Removes an EGS game from Steam by modifying shortcuts.vdf and deleting the launch script.
|
||||
Removes an EGS game from Steam using CEF API or by modifying shortcuts.vdf and deleting the launch script.
|
||||
Also deletes associated cover files in the Steam grid directory.
|
||||
Calls the callback with (success, message).
|
||||
|
||||
Args:
|
||||
@@ -74,6 +76,7 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
||||
portproton_dir: Path to the PortProton directory.
|
||||
callback: Callback function to handle the result (success, message).
|
||||
"""
|
||||
|
||||
if not portproton_dir:
|
||||
logger.error("PortProton directory not found")
|
||||
callback((False, "PortProton directory not found"))
|
||||
@@ -101,51 +104,89 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
||||
unsigned_id = convert_steam_id(user_id)
|
||||
user_dir = os.path.join(userdata_dir, str(unsigned_id))
|
||||
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
grid_dir = os.path.join(user_dir, "config", "grid")
|
||||
|
||||
if not os.path.exists(steam_shortcuts_path):
|
||||
logger.error("Steam shortcuts file not found")
|
||||
callback((False, "Steam shortcuts file not found"))
|
||||
return
|
||||
|
||||
# Find appid for the shortcut
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'rb') as f:
|
||||
shortcuts_data = vdf.binary_load(f)
|
||||
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||
appid = None
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||
appid = convert_steam_id(int(entry.get("appid")))
|
||||
logger.info(f"Found matching shortcut for '{game_name}' with AppID {appid}")
|
||||
break
|
||||
if not appid:
|
||||
logger.info(f"Game '{game_name}' not found in Steam shortcuts")
|
||||
callback((False, f"Game '{game_name}' not found in Steam"))
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to load shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
# Try CEF API first
|
||||
logger.info(f"Attempting to remove EGS game '{game_name}' via Steam CEF API with AppID {appid}")
|
||||
api_response = call_steam_api("removeShortcut", appid)
|
||||
if api_response is not None: # API responded, even if empty
|
||||
logger.info(f"Shortcut for AppID {appid} successfully removed via CEF API")
|
||||
|
||||
# Delete cover files
|
||||
cover_files = [
|
||||
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
try:
|
||||
os.remove(cover_file)
|
||||
logger.info(f"Deleted cover file: {cover_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete cover file {cover_file}: {e}")
|
||||
|
||||
# Delete launch script
|
||||
if os.path.exists(script_path):
|
||||
try:
|
||||
os.remove(script_path)
|
||||
logger.info(f"Removed EGS script: {script_path}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
|
||||
|
||||
callback((True, f"Game '{game_name}' was removed from Steam. Please restart Steam for changes to take effect."))
|
||||
return
|
||||
|
||||
# Fallback to VDF modification
|
||||
logger.warning("CEF API failed for EGS game removal; falling back to VDF modification")
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info("Created backup of shortcuts.vdf at %s", backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'rb') as f:
|
||||
shortcuts_data = vdf.binary_load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to load shortcuts.vdf: {e}"))
|
||||
return
|
||||
new_shortcuts = {}
|
||||
index = 0
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||
logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
|
||||
continue
|
||||
new_shortcuts[str(index)] = entry
|
||||
index += 1
|
||||
|
||||
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||
modified = False
|
||||
new_shortcuts = {}
|
||||
index = 0
|
||||
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||
modified = True
|
||||
logger.info("Removing EGS game '%s' from Steam shortcuts", game_name)
|
||||
continue
|
||||
new_shortcuts[str(index)] = entry
|
||||
index += 1
|
||||
|
||||
if not modified:
|
||||
logger.error("Game '%s' not found in Steam shortcuts", game_name)
|
||||
callback((False, f"Game '{game_name}' not found in Steam shortcuts"))
|
||||
return
|
||||
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
||||
logger.info("Updated shortcuts.vdf, removed '%s'", game_name)
|
||||
logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||
if os.path.exists(backup_path):
|
||||
@@ -157,10 +198,26 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
||||
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
# Delete cover files
|
||||
cover_files = [
|
||||
os.path.join(grid_dir, f"{appid}.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}p.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_hero.jpg"),
|
||||
os.path.join(grid_dir, f"{appid}_logo.png")
|
||||
]
|
||||
for cover_file in cover_files:
|
||||
if os.path.exists(cover_file):
|
||||
try:
|
||||
os.remove(cover_file)
|
||||
logger.info(f"Deleted cover file: {cover_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete cover file {cover_file}: {e}")
|
||||
|
||||
# Delete launch script
|
||||
if os.path.exists(script_path):
|
||||
try:
|
||||
os.remove(script_path)
|
||||
logger.info("Removed EGS script: %s", script_path)
|
||||
logger.info(f"Removed EGS script: {script_path}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
|
||||
|
||||
@@ -168,11 +225,17 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
|
||||
|
||||
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
|
||||
"""
|
||||
Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag.
|
||||
Asynchronously adds an EGS game to Steam via CEF API or shortcuts.vdf with PortProton tag.
|
||||
Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
|
||||
Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
|
||||
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
|
||||
Calls the callback with (success, message).
|
||||
|
||||
Args:
|
||||
app_name: The Legendary app_name (unique identifier for the game).
|
||||
game_title: The display name of the game.
|
||||
legendary_path: Path to the Legendary CLI executable.
|
||||
callback: Callback function to handle the result (success, message).
|
||||
"""
|
||||
if not app_name or not app_name.strip() or not game_title or not game_title.strip():
|
||||
logger.error("Invalid app_name or game_title: empty or whitespace")
|
||||
@@ -267,47 +330,47 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
grid_dir = user_dir / "config" / "grid"
|
||||
os.makedirs(grid_dir, exist_ok=True)
|
||||
|
||||
# Backup shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||
return
|
||||
# Try CEF API first
|
||||
logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API")
|
||||
api_response = call_steam_api(
|
||||
"createShortcut",
|
||||
game_title,
|
||||
script_path,
|
||||
str(Path(script_path).parent),
|
||||
icon_path,
|
||||
""
|
||||
)
|
||||
|
||||
# Generate unique appid
|
||||
unique_string = f"{script_path}{game_title}"
|
||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||
appid = baseid | 0x80000000
|
||||
if appid > 0x7FFFFFFF:
|
||||
aidvdf = appid - 0x100000000
|
||||
appid = None
|
||||
was_api_used = False
|
||||
|
||||
if api_response and isinstance(api_response, dict) and 'id' in api_response:
|
||||
appid = api_response['id']
|
||||
was_api_used = True
|
||||
logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}")
|
||||
else:
|
||||
aidvdf = appid
|
||||
logger.warning("CEF API failed for EGS game addition; falling back to VDF modification")
|
||||
# Backup shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
steam_appid = None
|
||||
downloaded_count = 0
|
||||
total_covers = 4
|
||||
download_lock = threading.Lock()
|
||||
# Generate unique appid
|
||||
unique_string = f"{script_path}{game_title}"
|
||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||
appid = baseid | 0x80000000
|
||||
if appid > 0x7FFFFFFF:
|
||||
aidvdf = appid - 0x100000000
|
||||
else:
|
||||
aidvdf = appid
|
||||
|
||||
def on_cover_download(cover_file: str, cover_type: str):
|
||||
nonlocal downloaded_count
|
||||
try:
|
||||
if cover_file and os.path.exists(cover_file):
|
||||
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
|
||||
else:
|
||||
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
|
||||
with download_lock:
|
||||
downloaded_count += 1
|
||||
if downloaded_count == total_covers:
|
||||
finalize_shortcut()
|
||||
|
||||
def finalize_shortcut():
|
||||
tags_dict = {'0': 'PortProton'}
|
||||
# Create shortcut entry
|
||||
shortcut = {
|
||||
"appid": aidvdf,
|
||||
"AppName": game_title,
|
||||
@@ -322,7 +385,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
"Devkit": 0,
|
||||
"DevkitGameID": "",
|
||||
"LastPlayTime": 0,
|
||||
"tags": tags_dict
|
||||
"tags": {'0': 'PortProton'}
|
||||
}
|
||||
logger.info(f"Shortcut entry for EGS game: {shortcut}")
|
||||
|
||||
@@ -353,6 +416,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
||||
logger.info(f"EGS game '{game_title}' added to Steam via VDF")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||
if os.path.exists(backup_path):
|
||||
@@ -364,8 +428,42 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||
return
|
||||
|
||||
logger.info(f"EGS game '{game_title}' added to Steam")
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
if not appid:
|
||||
callback((False, "Failed to create shortcut via any method"))
|
||||
return
|
||||
|
||||
steam_appid = None
|
||||
downloaded_count = 0
|
||||
total_covers = 4
|
||||
download_lock = threading.Lock()
|
||||
|
||||
def on_cover_download(cover_file: str | None, cover_type: str, index: int):
|
||||
nonlocal downloaded_count
|
||||
try:
|
||||
if cover_file is None or not os.path.exists(cover_file):
|
||||
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
|
||||
with download_lock:
|
||||
downloaded_count += 1
|
||||
if downloaded_count == total_covers:
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
return
|
||||
|
||||
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
|
||||
if was_api_used:
|
||||
try:
|
||||
with open(cover_file, 'rb') as f:
|
||||
img_b64 = base64.b64encode(f.read()).decode('utf-8')
|
||||
logger.info(f"Applying cover type '{cover_type}' via API for AppID {appid}")
|
||||
ext = Path(cover_type).suffix.lstrip('.')
|
||||
call_steam_api("setGrid", appid, index, ext, img_b64)
|
||||
except Exception as e:
|
||||
logger.error(f"Error applying cover '{cover_type}' via API: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
|
||||
with download_lock:
|
||||
downloaded_count += 1
|
||||
if downloaded_count == total_covers:
|
||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
||||
|
||||
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||
nonlocal steam_appid
|
||||
@@ -375,24 +473,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
||||
|
||||
if not steam_appid:
|
||||
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
|
||||
finalize_shortcut()
|
||||
callback((True, f"Game '{game_title}' added to Steam"))
|
||||
return
|
||||
|
||||
cover_types = [
|
||||
(".jpg", "header.jpg"),
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
("_hero.jpg", "library_hero.jpg"),
|
||||
("_logo.png", "logo.png")
|
||||
(".jpg", "header.jpg", 0),
|
||||
("p.jpg", "library_600x900_2x.jpg", 1),
|
||||
("_hero.jpg", "library_hero.jpg", 2),
|
||||
("_logo.png", "logo.png", 3)
|
||||
]
|
||||
|
||||
for suffix, cover_type in cover_types:
|
||||
for suffix, cover_type, index in cover_types:
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
|
||||
callback=lambda result, ctype=cover_type, idx=index: on_cover_download(result, ctype, idx)
|
||||
)
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps)
|
||||
@@ -747,6 +845,11 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
games: list[tuple] = []
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
user_json_path = cache_dir / "user.json"
|
||||
if not user_json_path.exists():
|
||||
callback(games)
|
||||
return
|
||||
|
||||
def process_games(installed_games: list | None):
|
||||
if installed_games is None:
|
||||
logger.info("No installed Epic Games Store games found")
|
||||
@@ -855,12 +958,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
app_name,
|
||||
f"legendary:launch:{app_name}",
|
||||
"",
|
||||
last_launch, # Время последнего запуска
|
||||
formatted_playtime, # Форматированное время игры
|
||||
protondb_tier, # ProtonDB tier
|
||||
last_launch,
|
||||
formatted_playtime,
|
||||
protondb_tier,
|
||||
status or "",
|
||||
last_launch_timestamp, # Временная метка последнего запуска
|
||||
playtime_seconds, # Время игры в секундах
|
||||
last_launch_timestamp,
|
||||
playtime_seconds,
|
||||
"epic"
|
||||
)
|
||||
pending_images -= 1
|
||||
@@ -880,7 +983,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
|
||||
get_protondb_tier_async(steam_appid, on_protondb_tier)
|
||||
else:
|
||||
logger.debug(f"No Steam app found for EGS game {title}")
|
||||
on_protondb_tier("") # Proceed with empty ProtonDB tier
|
||||
on_protondb_tier("")
|
||||
|
||||
get_steam_apps_and_index_async(on_steam_apps)
|
||||
|
||||
|
@@ -1,5 +1,5 @@
|
||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices
|
||||
from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl
|
||||
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
|
||||
@@ -11,9 +11,11 @@ from portprotonqt.config_utils import read_theme_from_config
|
||||
from portprotonqt.custom_widgets import ClickableLabel
|
||||
from portprotonqt.portproton_api import PortProtonAPI
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.animations import GameCardAnimations
|
||||
import weakref
|
||||
from typing import cast
|
||||
|
||||
|
||||
class GameCard(QFrame):
|
||||
borderWidthChanged = Signal()
|
||||
gradientAngleChanged = Signal()
|
||||
@@ -78,13 +80,8 @@ class GameCard(QFrame):
|
||||
self._focused = False
|
||||
|
||||
# Анимации
|
||||
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
self.gradient_anim = None
|
||||
self.pulse_anim = None
|
||||
|
||||
# Флаг для отслеживания подключения слота startPulseAnimation
|
||||
self._isPulseAnimationConnected = False
|
||||
self.animations = GameCardAnimations(self, self.theme)
|
||||
self.animations.setup_animations()
|
||||
|
||||
# Тень
|
||||
shadow = QGraphicsDropShadowEffect(self)
|
||||
@@ -455,133 +452,22 @@ class GameCard(QFrame):
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
|
||||
pen = QPen()
|
||||
pen.setWidth(self._borderWidth)
|
||||
if self._hovered or self._focused:
|
||||
center = self.rect().center()
|
||||
gradient = QConicalGradient(center, self._gradientAngle)
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
else:
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
|
||||
painter.setPen(pen)
|
||||
radius = 18
|
||||
bw = round(self._borderWidth / 2)
|
||||
rect = self.rect().adjusted(bw, bw, -bw, -bw)
|
||||
painter.drawRoundedRect(rect, radius, radius)
|
||||
|
||||
def startPulseAnimation(self):
|
||||
if not (self._hovered or self._focused):
|
||||
return
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
||||
self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
|
||||
self.pulse_anim.setLoopCount(0)
|
||||
self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
|
||||
self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
|
||||
self.pulse_anim.start()
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hovered = True
|
||||
self.hoverChanged.emit(self.name, True)
|
||||
self.setFocus(Qt.FocusReason.MouseFocusReason)
|
||||
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hovered = False
|
||||
self.hoverChanged.emit(self.name, False)
|
||||
if not self._focused:
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
if not self._hovered:
|
||||
self._focused = True
|
||||
self.focusChanged.emit(self.name, True)
|
||||
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
|
||||
self.thickness_anim.finished.connect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = True
|
||||
self.thickness_anim.start()
|
||||
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
|
||||
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
|
||||
self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
|
||||
self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event):
|
||||
self._focused = False
|
||||
self.focusChanged.emit(self.name, False)
|
||||
if not self._hovered:
|
||||
if self.gradient_anim:
|
||||
self.gradient_anim.stop()
|
||||
self.gradient_anim = None
|
||||
if self.pulse_anim:
|
||||
self.pulse_anim.stop()
|
||||
self.pulse_anim = None
|
||||
if self.thickness_anim:
|
||||
self.thickness_anim.stop()
|
||||
if self._isPulseAnimationConnected:
|
||||
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
|
||||
self._isPulseAnimationConnected = False
|
||||
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
|
||||
self.thickness_anim.setStartValue(self._borderWidth)
|
||||
self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
|
||||
self.thickness_anim.start()
|
||||
self.animations.handle_focus_out_event()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
|
@@ -219,9 +219,11 @@ class ResultParser:
|
||||
("comp_plus", "main_extra"),
|
||||
("comp_100", "completionist")
|
||||
]
|
||||
all_zero = all(game_data.get(json_field, 0) == 0 for json_field, _ in time_fields)
|
||||
for json_field, attr_name in time_fields:
|
||||
if json_field in game_data:
|
||||
time_hours = round(game_data[json_field] / 3600, 2)
|
||||
time_seconds = game_data[json_field]
|
||||
time_hours = None if all_zero else round(time_seconds / 3600, 2)
|
||||
setattr(game, attr_name, time_hours)
|
||||
game.similarity = self._calculate_similarity(game)
|
||||
return game
|
||||
|
@@ -21,6 +21,13 @@ image_load_queue = Queue()
|
||||
image_executor = ThreadPoolExecutor(max_workers=4)
|
||||
queue_lock = threading.Lock()
|
||||
|
||||
def get_device_pixel_ratio() -> float:
|
||||
"""
|
||||
Retrieves the device pixel ratio from QApplication, with a fallback of 1.0 if not available.
|
||||
"""
|
||||
app = QApplication.instance()
|
||||
return app.devicePixelRatio() if isinstance(app, QApplication) else 1.0
|
||||
|
||||
def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""):
|
||||
"""
|
||||
Асинхронно загружает обложку через очередь задач.
|
||||
@@ -164,7 +171,6 @@ class FullscreenDialog(QDialog):
|
||||
:param theme: Объект темы для стилизации (если None, используется default_styles)
|
||||
"""
|
||||
super().__init__(parent)
|
||||
# Удаление диалога после закрытия
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setFocus()
|
||||
@@ -173,14 +179,12 @@ class FullscreenDialog(QDialog):
|
||||
self.current_index = current_index
|
||||
self.theme = theme if theme else default_styles
|
||||
|
||||
# Убираем стандартные элементы управления окна
|
||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
|
||||
self.init_ui()
|
||||
self.update_display()
|
||||
|
||||
# Фильтруем события для закрытия диалога по клику
|
||||
self.imageLabel.installEventFilter(self)
|
||||
self.captionLabel.installEventFilter(self)
|
||||
|
||||
@@ -190,32 +194,28 @@ class FullscreenDialog(QDialog):
|
||||
self.mainLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.mainLayout.setSpacing(0)
|
||||
|
||||
# Контейнер для изображения и стрелок
|
||||
self.imageContainer = QWidget()
|
||||
self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
|
||||
self.imageContainerLayout = QHBoxLayout(self.imageContainer)
|
||||
self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.imageContainerLayout.setSpacing(0)
|
||||
|
||||
# Левая стрелка
|
||||
self.prevButton = QToolButton()
|
||||
self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
|
||||
self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE)
|
||||
self.prevButton.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
|
||||
self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.prevButton.setFixedSize(40, 40)
|
||||
self.prevButton.clicked.connect(self.show_prev)
|
||||
self.imageContainerLayout.addWidget(self.prevButton)
|
||||
|
||||
# Метка для изображения
|
||||
self.imageLabel = QLabel()
|
||||
self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
|
||||
self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
|
||||
|
||||
# Правая стрелка
|
||||
self.nextButton = QToolButton()
|
||||
self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
|
||||
self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE)
|
||||
self.nextButton.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
|
||||
self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.nextButton.setFixedSize(40, 40)
|
||||
self.nextButton.clicked.connect(self.show_next)
|
||||
@@ -223,16 +223,14 @@ class FullscreenDialog(QDialog):
|
||||
|
||||
self.mainLayout.addWidget(self.imageContainer)
|
||||
|
||||
# Небольшой отступ между изображением и подписью
|
||||
spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||
self.mainLayout.addItem(spacer)
|
||||
|
||||
# Подпись
|
||||
self.captionLabel = QLabel()
|
||||
self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.captionLabel.setFixedHeight(40)
|
||||
self.captionLabel.setWordWrap(True)
|
||||
self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE)
|
||||
self.captionLabel.setStyleSheet(getattr(self.theme, "CAPTION_LABEL_STYLE", ""))
|
||||
self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.mainLayout.addWidget(self.captionLabel)
|
||||
|
||||
@@ -241,28 +239,37 @@ class FullscreenDialog(QDialog):
|
||||
if not self.images:
|
||||
return
|
||||
|
||||
# Очищаем старое содержимое
|
||||
self.imageLabel.clear()
|
||||
self.captionLabel.clear()
|
||||
QApplication.processEvents()
|
||||
|
||||
pixmap, caption = self.images[self.current_index]
|
||||
# Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера
|
||||
# Учитываем devicePixelRatio для масштабирования высокого качества
|
||||
device_pixel_ratio = get_device_pixel_ratio()
|
||||
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
|
||||
target_height = int(self.FIXED_HEIGHT * device_pixel_ratio)
|
||||
|
||||
# Масштабируем изображение из оригинального pixmap
|
||||
scaled_pixmap = pixmap.scaled(
|
||||
self.FIXED_WIDTH - 80, # учитываем ширину стрелок
|
||||
self.FIXED_HEIGHT,
|
||||
target_width,
|
||||
target_height,
|
||||
Qt.AspectRatioMode.KeepAspectRatio,
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
|
||||
self.imageLabel.setPixmap(scaled_pixmap)
|
||||
self.captionLabel.setText(caption)
|
||||
self.setWindowTitle(caption)
|
||||
|
||||
# Принудительная перерисовка виджетов
|
||||
self.imageLabel.repaint()
|
||||
self.captionLabel.repaint()
|
||||
self.repaint()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
"""Обновляет изображение при изменении размера окна."""
|
||||
super().resizeEvent(event)
|
||||
self.update_display() # Перерисовываем изображение с учетом нового размера
|
||||
|
||||
def show_prev(self):
|
||||
"""Показывает предыдущее изображение."""
|
||||
if self.images:
|
||||
@@ -292,7 +299,6 @@ class FullscreenDialog(QDialog):
|
||||
def mousePressEvent(self, event):
|
||||
"""Закрывает диалог при клике на пустую область."""
|
||||
pos = event.pos()
|
||||
# Проверяем, находится ли клик вне imageContainer и captionLabel
|
||||
if not (self.imageContainer.geometry().contains(pos) or
|
||||
self.captionLabel.geometry().contains(pos)):
|
||||
self.close()
|
||||
@@ -305,15 +311,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
||||
"""
|
||||
def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
|
||||
"""
|
||||
:param pixmap: QPixmap для отображения в карусели
|
||||
:param pixmap: QPixmap для отображения в карусели (оригинальное, высокое разрешение)
|
||||
:param caption: Подпись к изображению
|
||||
:param images_list: Список всех изображений (кортежей (QPixmap, caption)),
|
||||
чтобы в диалоге можно было перелистывать.
|
||||
Если не передан, будет использован только текущее изображение.
|
||||
:param index: Индекс текущего изображения в images_list.
|
||||
:param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками.
|
||||
:param images_list: Список всех изображений (кортежей (QPixmap, caption))
|
||||
:param index: Индекс текущего изображения в images_list
|
||||
:param carousel: Ссылка на родительскую карусель (ImageCarousel)
|
||||
"""
|
||||
super().__init__(pixmap)
|
||||
super().__init__()
|
||||
self.original_pixmap = pixmap # Store original high-resolution pixmap
|
||||
self.caption = caption
|
||||
self.images_list = images_list if images_list is not None else [(pixmap, caption)]
|
||||
self.index = index
|
||||
@@ -323,6 +328,20 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
||||
self._click_start_position = None
|
||||
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
||||
self.update_pixmap() # Set initial pixmap
|
||||
|
||||
def update_pixmap(self, height=300):
|
||||
"""Update the displayed pixmap by scaling from the original high-resolution pixmap."""
|
||||
if self.original_pixmap.isNull():
|
||||
return
|
||||
# Scale pixmap to desired height, considering device pixel ratio
|
||||
device_pixel_ratio = get_device_pixel_ratio()
|
||||
scaled_pixmap = self.original_pixmap.scaledToHeight(
|
||||
int(height * device_pixel_ratio),
|
||||
Qt.TransformationMode.SmoothTransformation
|
||||
)
|
||||
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
|
||||
self.setPixmap(scaled_pixmap)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
@@ -339,17 +358,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
||||
event.accept()
|
||||
|
||||
def show_fullscreen(self):
|
||||
# Скрываем стрелки карусели перед открытием FullscreenDialog
|
||||
if self.carousel:
|
||||
self.carousel.prevArrow.hide()
|
||||
self.carousel.nextArrow.hide()
|
||||
dialog = FullscreenDialog(self.images_list, current_index=self.index)
|
||||
dialog.exec()
|
||||
# После закрытия диалога обновляем видимость стрелок
|
||||
if self.carousel:
|
||||
self.carousel.update_arrows_visibility()
|
||||
|
||||
|
||||
class ImageCarousel(QGraphicsView):
|
||||
"""
|
||||
Карусель изображений с адаптивностью, возможностью увеличения по клику
|
||||
@@ -357,19 +373,16 @@ class ImageCarousel(QGraphicsView):
|
||||
"""
|
||||
def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
|
||||
super().__init__(parent)
|
||||
|
||||
# Аннотируем тип scene как QGraphicsScene
|
||||
self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
|
||||
self.setScene(self.carousel_scene)
|
||||
|
||||
self.images = images # Список кортежей: (QPixmap, caption)
|
||||
self.image_items = []
|
||||
self._animation = None
|
||||
self.theme = theme if theme else default_styles
|
||||
self.max_height = 300 # Default height for images
|
||||
self.init_ui()
|
||||
self.create_arrows()
|
||||
|
||||
# Переменные для поддержки перетаскивания
|
||||
self._drag_active = False
|
||||
self._drag_start_position = None
|
||||
self._scroll_start_value = None
|
||||
@@ -380,30 +393,38 @@ class ImageCarousel(QGraphicsView):
|
||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
self.setFrameShape(QFrame.Shape.NoFrame)
|
||||
|
||||
x_offset = 10 # Отступ между изображениями
|
||||
max_height = 300 # Фиксированная высота изображений
|
||||
self.update_scene()
|
||||
|
||||
def update_scene(self):
|
||||
"""Update the scene with scaled images based on current size and scale."""
|
||||
self.carousel_scene.clear()
|
||||
self.image_items.clear()
|
||||
|
||||
x_offset = 10
|
||||
x = 0
|
||||
device_pixel_ratio = get_device_pixel_ratio()
|
||||
|
||||
for i, (pixmap, caption) in enumerate(self.images):
|
||||
item = ClickablePixmapItem(
|
||||
pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation),
|
||||
pixmap, # Pass original pixmap
|
||||
caption,
|
||||
images_list=self.images,
|
||||
index=i,
|
||||
carousel=self # Передаем ссылку на карусель
|
||||
carousel=self
|
||||
)
|
||||
item.update_pixmap(self.max_height) # Scale to current height
|
||||
item.setPos(x, 0)
|
||||
self.carousel_scene.addItem(item)
|
||||
self.image_items.append(item)
|
||||
x += item.pixmap().width() + x_offset
|
||||
x += item.pixmap().width() / device_pixel_ratio + x_offset
|
||||
|
||||
self.setSceneRect(0, 0, x, max_height)
|
||||
self.setSceneRect(0, 0, x, self.max_height)
|
||||
|
||||
def create_arrows(self):
|
||||
"""Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
|
||||
self.prevArrow = QToolButton(self)
|
||||
self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
|
||||
self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore
|
||||
self.prevArrow.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
|
||||
self.prevArrow.setFixedSize(40, 40)
|
||||
self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.prevArrow.setAutoRepeat(True)
|
||||
@@ -414,7 +435,7 @@ class ImageCarousel(QGraphicsView):
|
||||
|
||||
self.nextArrow = QToolButton(self)
|
||||
self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
|
||||
self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore
|
||||
self.nextArrow.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
|
||||
self.nextArrow.setFixedSize(40, 40)
|
||||
self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||
self.nextArrow.setAutoRepeat(True)
|
||||
@@ -423,14 +444,9 @@ class ImageCarousel(QGraphicsView):
|
||||
self.nextArrow.clicked.connect(self.scroll_right)
|
||||
self.nextArrow.raise_()
|
||||
|
||||
# Проверяем видимость стрелок при создании
|
||||
self.update_arrows_visibility()
|
||||
|
||||
def update_arrows_visibility(self):
|
||||
"""
|
||||
Показывает стрелки, если контент шире видимой области.
|
||||
Иначе скрывает их.
|
||||
"""
|
||||
if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
|
||||
if self.horizontalScrollBar().maximum() == 0:
|
||||
self.prevArrow.hide()
|
||||
@@ -444,7 +460,8 @@ class ImageCarousel(QGraphicsView):
|
||||
margin = 10
|
||||
self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
|
||||
self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
|
||||
(self.height() - self.nextArrow.height()) // 2)
|
||||
(self.height() - self.nextArrow.height()) // 2)
|
||||
self.update_scene() # Re-scale images on resize
|
||||
self.update_arrows_visibility()
|
||||
|
||||
def animate_scroll(self, end_value):
|
||||
@@ -469,19 +486,15 @@ class ImageCarousel(QGraphicsView):
|
||||
self.animate_scroll(new_value)
|
||||
|
||||
def update_images(self, new_images):
|
||||
self.carousel_scene.clear()
|
||||
self.images = new_images
|
||||
self.image_items.clear()
|
||||
self.init_ui()
|
||||
self.update_scene()
|
||||
self.update_arrows_visibility()
|
||||
|
||||
# Обработка событий мыши для перетаскивания
|
||||
def mousePressEvent(self, event):
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
self._drag_active = True
|
||||
self._drag_start_position = event.pos()
|
||||
self._scroll_start_value = self.horizontalScrollBar().value()
|
||||
# Скрываем стрелки при начале перетаскивания
|
||||
if hasattr(self, "prevArrow"):
|
||||
self.prevArrow.hide()
|
||||
if hasattr(self, "nextArrow"):
|
||||
@@ -497,6 +510,5 @@ class ImageCarousel(QGraphicsView):
|
||||
|
||||
def mouseReleaseEvent(self, event):
|
||||
self._drag_active = False
|
||||
# Показываем стрелки после завершения перетаскивания (с проверкой видимости)
|
||||
self.update_arrows_visibility()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
@@ -111,6 +111,8 @@ class InputManager(QObject):
|
||||
self.stick_value = 0 # Текущее значение стика (для плавности)
|
||||
self.dead_zone = 8000 # Мертвая зона стика
|
||||
|
||||
self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower()
|
||||
|
||||
# Add variables for continuous D-pad movement
|
||||
self.dpad_timer = QTimer(self)
|
||||
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
|
||||
@@ -849,7 +851,7 @@ class InputManager(QObject):
|
||||
return True
|
||||
|
||||
# Toggle fullscreen with F11
|
||||
if key == Qt.Key.Key_F11:
|
||||
if key == Qt.Key.Key_F11 and not self._is_gamescope_session:
|
||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||
return True
|
||||
|
||||
@@ -946,7 +948,7 @@ class InputManager(QObject):
|
||||
continue
|
||||
now = time.time()
|
||||
if event.type == ecodes.EV_KEY and event.value == 1:
|
||||
if event.code in BUTTONS['menu']:
|
||||
if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
|
||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||
else:
|
||||
self.button_pressed.emit(event.code)
|
||||
|
@@ -10,6 +10,7 @@ import psutil
|
||||
|
||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.animations import DetailPageAnimations
|
||||
from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel
|
||||
from portprotonqt.portproton_api import PortProtonAPI
|
||||
from portprotonqt.input_manager import InputManager
|
||||
@@ -35,14 +36,13 @@ from portprotonqt.howlongtobeat_api import HowLongToBeat
|
||||
from portprotonqt.downloader import Downloader
|
||||
|
||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
|
||||
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QGraphicsEffect, QGraphicsOpacityEffect, QApplication, QPushButton, QProgressBar, QCheckBox)
|
||||
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
|
||||
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
|
||||
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
|
||||
from PySide6.QtCore import Qt, QAbstractAnimation, QPropertyAnimation, QByteArray, QUrl, Signal, QTimer, Slot
|
||||
from typing import cast
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
from PySide6.QtWidgets import QSizePolicy
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -210,6 +210,7 @@ class MainWindow(QMainWindow):
|
||||
self.restore_state()
|
||||
|
||||
self.input_manager = InputManager(self)
|
||||
self.detail_animations = DetailPageAnimations(self, self.theme)
|
||||
QTimer.singleShot(0, self.loadGames)
|
||||
|
||||
if read_fullscreen_config():
|
||||
@@ -698,6 +699,15 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
if hasattr(self, '_animations') and self._animations:
|
||||
for widget, animation in list(self._animations.items()):
|
||||
try:
|
||||
if animation.state() == QAbstractAnimation.State.Running:
|
||||
animation.stop()
|
||||
widget.setWindowOpacity(1.0)
|
||||
del self._animations[widget]
|
||||
except RuntimeError:
|
||||
del self._animations[widget]
|
||||
if not hasattr(self, '_last_width'):
|
||||
self._last_width = self.width()
|
||||
if abs(self.width() - self._last_width) > 10:
|
||||
@@ -1518,24 +1528,47 @@ class MainWindow(QMainWindow):
|
||||
self._animations = {}
|
||||
imageLabel = QLabel()
|
||||
imageLabel.setFixedSize(300, 400)
|
||||
self._detail_page_active = True
|
||||
self._current_detail_page = detailPage
|
||||
|
||||
if cover_path:
|
||||
def on_pixmap_ready(pixmap):
|
||||
rounded = round_corners(pixmap, 10)
|
||||
imageLabel.setPixmap(rounded)
|
||||
# Функция загрузки изображения и обновления стилей
|
||||
def load_image_and_restore_effect():
|
||||
if not detailPage or detailPage.isHidden():
|
||||
logger.warning("Detail page is None or hidden, skipping image load")
|
||||
return
|
||||
|
||||
def on_palette_ready(palette):
|
||||
dark_palette = [self.darkenColor(color, factor=200) for color in palette]
|
||||
stops = ",\n".join(
|
||||
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
|
||||
)
|
||||
detailPage.setStyleSheet(self.theme.detail_page_style(stops))
|
||||
detailPage.setWindowOpacity(1.0)
|
||||
|
||||
self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
|
||||
if cover_path:
|
||||
def on_pixmap_ready(pixmap):
|
||||
if not detailPage or detailPage.isHidden():
|
||||
logger.warning("Detail page is None or hidden, skipping pixmap update")
|
||||
return
|
||||
rounded = round_corners(pixmap, 10)
|
||||
imageLabel.setPixmap(rounded)
|
||||
logger.debug("Pixmap set for imageLabel")
|
||||
|
||||
load_pixmap_async(cover_path, 300, 400, on_pixmap_ready)
|
||||
else:
|
||||
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||
def on_palette_ready(palette):
|
||||
if not detailPage or detailPage.isHidden():
|
||||
logger.warning("Detail page is None or hidden, skipping palette update")
|
||||
return
|
||||
dark_palette = [self.darkenColor(color, factor=200) for color in palette]
|
||||
stops = ",\n".join(
|
||||
[f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
|
||||
)
|
||||
detailPage.setStyleSheet(self.theme.detail_page_style(stops))
|
||||
detailPage.update()
|
||||
logger.debug("Stylesheet updated with palette")
|
||||
|
||||
self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
|
||||
load_pixmap_async(cover_path, 300, 400, on_pixmap_ready)
|
||||
else:
|
||||
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
|
||||
detailPage.update()
|
||||
|
||||
def cleanup_animation():
|
||||
if detailPage in self._animations:
|
||||
del self._animations[detailPage]
|
||||
|
||||
mainLayout = QVBoxLayout(detailPage)
|
||||
mainLayout.setContentsMargins(30, 30, 30, 30)
|
||||
@@ -1768,6 +1801,11 @@ class MainWindow(QMainWindow):
|
||||
|
||||
# Время прохождения (Main Story, Main + Sides, Completionist)
|
||||
def on_hltb_results(results):
|
||||
if not hasattr(self, '_detail_page_active') or not self._detail_page_active:
|
||||
return
|
||||
if not self._current_detail_page or self._current_detail_page.isHidden() or not self._current_detail_page.parent():
|
||||
return
|
||||
|
||||
if results:
|
||||
game = results[0] # Берем первый результат
|
||||
main_story_time = hltb.format_game_time(game, "main_story")
|
||||
@@ -1873,17 +1911,7 @@ class MainWindow(QMainWindow):
|
||||
self.current_play_button = playButton
|
||||
|
||||
# Анимация
|
||||
opacityEffect = QGraphicsOpacityEffect(detailPage)
|
||||
detailPage.setGraphicsEffect(opacityEffect)
|
||||
animation = QPropertyAnimation(opacityEffect, QByteArray(b"opacity"))
|
||||
animation.setDuration(800)
|
||||
animation.setStartValue(0)
|
||||
animation.setEndValue(1)
|
||||
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
|
||||
self._animations[detailPage] = animation
|
||||
animation.finished.connect(
|
||||
lambda: detailPage.setGraphicsEffect(cast(QGraphicsEffect, None))
|
||||
)
|
||||
self.detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
|
||||
|
||||
def toggleFavoriteInDetailPage(self, game_name, label):
|
||||
favorites = read_favorites()
|
||||
@@ -1939,14 +1967,42 @@ class MainWindow(QMainWindow):
|
||||
parent = parent.parent()
|
||||
|
||||
def goBackDetailPage(self, page: QWidget | None) -> None:
|
||||
if page is None or page != self.stackedWidget.currentWidget():
|
||||
if page is None or page != self.stackedWidget.currentWidget() or getattr(self, '_exit_animation_in_progress', False):
|
||||
return
|
||||
self.stackedWidget.setCurrentIndex(0)
|
||||
self.stackedWidget.removeWidget(page)
|
||||
page.deleteLater()
|
||||
self.currentDetailPage = None
|
||||
self.current_exec_line = None
|
||||
self.current_play_button = None
|
||||
self._exit_animation_in_progress = True
|
||||
self._detail_page_active = False
|
||||
self._current_detail_page = None
|
||||
|
||||
def cleanup():
|
||||
"""Helper function to clean up after animation."""
|
||||
try:
|
||||
if page in self._animations:
|
||||
animation = self._animations[page]
|
||||
try:
|
||||
if animation.state() == QAbstractAnimation.State.Running:
|
||||
animation.stop()
|
||||
except RuntimeError:
|
||||
pass # Animation already deleted
|
||||
finally:
|
||||
del self._animations[page]
|
||||
self.stackedWidget.setCurrentIndex(0)
|
||||
self.stackedWidget.removeWidget(page)
|
||||
page.deleteLater()
|
||||
self.currentDetailPage = None
|
||||
self.current_exec_line = None
|
||||
self.current_play_button = None
|
||||
self._exit_animation_in_progress = False
|
||||
except Exception as e:
|
||||
logger.error(f"Error in cleanup: {e}", exc_info=True)
|
||||
self._exit_animation_in_progress = False
|
||||
|
||||
# Start exit animation
|
||||
try:
|
||||
self.detail_animations.animate_detail_page_exit(page, cleanup)
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting exit animation: {e}", exc_info=True)
|
||||
self._exit_animation_in_progress = False
|
||||
cleanup() # Fallback to cleanup if animation fails
|
||||
|
||||
def is_target_exe_running(self):
|
||||
"""Проверяет, запущен ли процесс с именем self.target_exe через psutil."""
|
||||
|
@@ -18,6 +18,10 @@ from collections.abc import Callable
|
||||
import re
|
||||
import shutil
|
||||
import zlib
|
||||
import websocket
|
||||
import requests
|
||||
import random
|
||||
import base64
|
||||
|
||||
downloader = Downloader()
|
||||
logger = get_logger(__name__)
|
||||
@@ -291,7 +295,7 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
if os.path.exists(cache_tar):
|
||||
os.remove(cache_tar)
|
||||
logger.info("Archive %s deleted after extraction", cache_tar)
|
||||
steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or []
|
||||
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:
|
||||
@@ -303,12 +307,25 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
||||
try:
|
||||
with open(cache_json, "rb") as f:
|
||||
data = orjson.loads(f.read())
|
||||
steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or []
|
||||
# Validate JSON structure
|
||||
if not isinstance(data, list):
|
||||
logger.error("Cached JSON %s has invalid format (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)
|
||||
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 cached JSON: %s", e)
|
||||
callback([])
|
||||
logger.error("Error reading or validating 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"
|
||||
)
|
||||
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"
|
||||
@@ -448,12 +465,25 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
|
||||
try:
|
||||
with open(cache_json, "rb") as f:
|
||||
data = orjson.loads(f.read())
|
||||
anti_cheat_data = data or []
|
||||
# Validate JSON structure
|
||||
if not isinstance(data, list):
|
||||
logger.error("Cached JSON %s has invalid format (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)
|
||||
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 cached WeAntiCheatYet JSON: %s", e)
|
||||
callback([])
|
||||
logger.error("Error reading or validating 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"
|
||||
)
|
||||
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
|
||||
else:
|
||||
app_list_url = (
|
||||
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
|
||||
@@ -745,6 +775,126 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
|
||||
|
||||
load_steam_apps_async(on_steam_apps)
|
||||
|
||||
def enable_steam_cef() -> tuple[bool, str]:
|
||||
"""
|
||||
Проверяет и при необходимости активирует режим удаленной отладки Steam CEF.
|
||||
|
||||
Создает файл .cef-enable-remote-debugging в директории Steam.
|
||||
Steam необходимо перезапустить после первого создания этого файла.
|
||||
|
||||
Возвращает кортеж:
|
||||
- (True, "already_enabled") если уже было активно.
|
||||
- (True, "restart_needed") если было только что активировано и нужен перезапуск Steam.
|
||||
- (False, "steam_not_found") если директория Steam не найдена.
|
||||
"""
|
||||
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}")
|
||||
|
||||
if cef_flag_file.exists():
|
||||
logger.info("CEF Remote Debugging уже активирован.")
|
||||
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 необходимо перезапустить.")
|
||||
return (True, "restart_needed")
|
||||
except Exception as e:
|
||||
logger.error(f"Не удалось создать CEF флаг {cef_flag_file}: {e}")
|
||||
return (False, str(e))
|
||||
|
||||
def call_steam_api(js_cmd: str, *args) -> dict | None:
|
||||
"""
|
||||
Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging.
|
||||
|
||||
Args:
|
||||
js_cmd: Имя JS функции для вызова (напр. 'createShortcut').
|
||||
*args: Аргументы для передачи в JS функцию.
|
||||
|
||||
Returns:
|
||||
Словарь с результатом выполнения или None в случае ошибки.
|
||||
"""
|
||||
status, message = enable_steam_cef()
|
||||
if not (status is True and message == "already_enabled"):
|
||||
if message == "restart_needed":
|
||||
logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.")
|
||||
elif message == "steam_not_found":
|
||||
logger.error("Не удалось найти директорию Steam для проверки CEF API.")
|
||||
else:
|
||||
logger.error(f"Steam CEF API недоступен или не готов: {message}")
|
||||
return None
|
||||
|
||||
steam_debug_url = "http://localhost:8080/json"
|
||||
|
||||
try:
|
||||
response = requests.get(steam_debug_url, timeout=2)
|
||||
response.raise_for_status()
|
||||
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?")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}")
|
||||
return None
|
||||
|
||||
js_code = """
|
||||
async function createShortcut(name, exe, dir, icon, args) {
|
||||
const id = await SteamClient.Apps.AddShortcut(name, exe, dir, args);
|
||||
console.log("Shortcut created with ID:", id);
|
||||
await SteamClient.Apps.SetShortcutName(id, name);
|
||||
if (icon)
|
||||
await SteamClient.Apps.SetShortcutIcon(id, icon);
|
||||
if (args)
|
||||
await SteamClient.Apps.SetAppLaunchOptions(id, args);
|
||||
return { id };
|
||||
};
|
||||
|
||||
async function setGrid(id, i, ext, image) {
|
||||
await SteamClient.Apps.SetCustomArtworkForApp(id, image, ext, i);
|
||||
return true;
|
||||
};
|
||||
|
||||
async function removeShortcut(id) {
|
||||
await SteamClient.Apps.RemoveShortcut(+id);
|
||||
return true;
|
||||
};
|
||||
"""
|
||||
try:
|
||||
ws = websocket.create_connection(ws_url, timeout=5)
|
||||
js_args = ", ".join(orjson.dumps(arg).decode('utf-8') for arg in args)
|
||||
expression = f"{js_code} {js_cmd}({js_args});"
|
||||
payload = {
|
||||
"id": random.randint(0, 32767),
|
||||
"method": "Runtime.evaluate",
|
||||
"params": {
|
||||
"expression": expression,
|
||||
"awaitPromise": True,
|
||||
"returnByValue": True
|
||||
}
|
||||
}
|
||||
|
||||
ws.send(orjson.dumps(payload))
|
||||
response_str = ws.recv()
|
||||
ws.close()
|
||||
|
||||
response_data = orjson.loads(response_str)
|
||||
if "error" in response_data:
|
||||
logger.error(f"Ошибка выполнения JS в 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')}")
|
||||
return None
|
||||
return result.get('value')
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}")
|
||||
return None
|
||||
|
||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
|
||||
"""
|
||||
Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
|
||||
@@ -846,45 +996,42 @@ export START_FROM_STEAM=1
|
||||
grid_dir = user_dir / "config" / "grid"
|
||||
os.makedirs(grid_dir, exist_ok=True)
|
||||
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
return (False, f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
appid = None
|
||||
was_api_used = False
|
||||
|
||||
unique_string = f"{script_path}{game_name}"
|
||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||
appid = baseid | 0x80000000
|
||||
if appid > 0x7FFFFFFF:
|
||||
aidvdf = appid - 0x100000000
|
||||
logger.info("Попытка добавления ярлыка через Steam CEF API...")
|
||||
api_response = call_steam_api(
|
||||
"createShortcut",
|
||||
game_name,
|
||||
script_path,
|
||||
str(Path(script_path).parent),
|
||||
icon_path,
|
||||
""
|
||||
)
|
||||
|
||||
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}")
|
||||
else:
|
||||
aidvdf = appid
|
||||
logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).")
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
if os.path.exists(steam_shortcuts_path):
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
return (False, f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
|
||||
steam_appid = None
|
||||
downloaded_count = 0
|
||||
total_covers = 4 # количество обложек
|
||||
unique_string = f"{script_path}{game_name}"
|
||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||
appid = baseid | 0x80000000
|
||||
if appid > 0x7FFFFFFF:
|
||||
aidvdf = appid - 0x100000000
|
||||
else:
|
||||
aidvdf = appid
|
||||
|
||||
download_lock = threading.Lock()
|
||||
|
||||
def on_cover_download(cover_file: str, cover_type: str):
|
||||
nonlocal downloaded_count
|
||||
try:
|
||||
if cover_file and os.path.exists(cover_file):
|
||||
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
|
||||
else:
|
||||
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
|
||||
with download_lock:
|
||||
downloaded_count += 1
|
||||
if downloaded_count == total_covers:
|
||||
finalize_shortcut()
|
||||
|
||||
def finalize_shortcut():
|
||||
tags_dict = {'0': 'PortProton'}
|
||||
shortcut = {
|
||||
"appid": aidvdf,
|
||||
"AppName": game_name,
|
||||
@@ -899,7 +1046,7 @@ export START_FROM_STEAM=1
|
||||
"Devkit": 0,
|
||||
"DevkitGameID": "",
|
||||
"LastPlayTime": 0,
|
||||
"tags": tags_dict
|
||||
"tags": {'0': 'PortProton'}
|
||||
}
|
||||
logger.info(f"Shortcut entry to be written: {shortcut}")
|
||||
|
||||
@@ -929,6 +1076,7 @@ export START_FROM_STEAM=1
|
||||
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
||||
logger.info(f"Game '{game_name}' successfully added to Steam with covers")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||
if os.path.exists(backup_path):
|
||||
@@ -937,34 +1085,54 @@ export START_FROM_STEAM=1
|
||||
logger.info("Restored shortcuts.vdf from backup due to update failure")
|
||||
except Exception as restore_err:
|
||||
logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
|
||||
return (False, f"Failed to update shortcuts.vdf: {e}")
|
||||
appid = None
|
||||
|
||||
logger.info(f"Game '{game_name}' successfully added to Steam with covers")
|
||||
return (True, f"Game '{game_name}' added to Steam with covers")
|
||||
if not appid:
|
||||
return (False, "Не удалось создать ярлык ни одним из способов.")
|
||||
|
||||
steam_appid = None
|
||||
|
||||
def on_game_info(game_info: dict):
|
||||
nonlocal steam_appid
|
||||
steam_appid = game_info.get("appid")
|
||||
if not steam_appid or not isinstance(steam_appid, int):
|
||||
logger.info("No valid Steam appid found, skipping cover download")
|
||||
return finalize_shortcut()
|
||||
return
|
||||
logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.")
|
||||
|
||||
# Обложки и имена, соответствующие bash-скрипту и твоим размерам
|
||||
cover_types = [
|
||||
(".jpg", "header.jpg"), # базовый, сохранится как AppId.jpg
|
||||
("p.jpg", "library_600x900_2x.jpg"), # сохранится как AppIdp.jpg
|
||||
("_hero.jpg", "library_hero.jpg"), # AppId_hero.jpg
|
||||
("_logo.png", "logo.png") # AppId_logo.png
|
||||
("p.jpg", "library_600x900_2x.jpg"),
|
||||
("_hero.jpg", "library_hero.jpg"),
|
||||
("_logo.png", "logo.png"),
|
||||
(".jpg", "header.jpg")
|
||||
]
|
||||
|
||||
for suffix, cover_type in cover_types:
|
||||
def on_cover_download(result_path: str | None, steam_name: str, index: int):
|
||||
try:
|
||||
if result_path and os.path.exists(result_path):
|
||||
logger.info(f"Downloaded cover {steam_name} to {result_path}")
|
||||
if was_api_used:
|
||||
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}")
|
||||
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}")
|
||||
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}")
|
||||
|
||||
for i, (suffix, steam_name) in enumerate(cover_types):
|
||||
cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
|
||||
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
|
||||
downloader.download_async(
|
||||
cover_url,
|
||||
cover_file,
|
||||
timeout=5,
|
||||
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
|
||||
callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
|
||||
)
|
||||
|
||||
get_steam_game_info_async(game_name, exec_line, on_game_info)
|
||||
@@ -1017,19 +1185,7 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
|
||||
logger.info(f"shortcuts.vdf not found at {steam_shortcuts_path}")
|
||||
return (False, f"Game '{game_name}' not found in Steam")
|
||||
|
||||
# Generate appid for identifying cover files
|
||||
unique_string = f"{script_path}{game_name}"
|
||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||
appid = baseid | 0x80000000
|
||||
|
||||
# Create backup of shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
return (False, f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
appid = None
|
||||
|
||||
# Load and modify shortcuts.vdf
|
||||
try:
|
||||
@@ -1043,37 +1199,51 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
|
||||
return (False, f"Failed to load shortcuts.vdf: {load_err}")
|
||||
|
||||
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||
found = False
|
||||
new_shortcuts = {}
|
||||
index = 0
|
||||
|
||||
# Filter out the matching shortcut
|
||||
for _key, entry in shortcuts.items():
|
||||
if entry.get("AppName") == game_name and entry.get("Exe") == f'"{script_path}"':
|
||||
found = True
|
||||
appid = convert_steam_id(int(entry.get("appid")))
|
||||
logger.info(f"Found matching shortcut for '{game_name}' to remove")
|
||||
continue
|
||||
new_shortcuts[str(index)] = entry
|
||||
index += 1
|
||||
|
||||
if not found:
|
||||
if not appid:
|
||||
logger.info(f"Game '{game_name}' not found in Steam shortcuts")
|
||||
return (False, f"Game '{game_name}' not found in Steam")
|
||||
|
||||
# Save updated shortcuts.vdf
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
||||
logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
shutil.copy2(backup_path, steam_shortcuts_path)
|
||||
logger.info("Restored shortcuts.vdf from backup due to update failure")
|
||||
except Exception as restore_err:
|
||||
logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
|
||||
return (False, f"Failed to update shortcuts.vdf: {e}")
|
||||
api_response = call_steam_api("removeShortcut", appid)
|
||||
if api_response is not None: # API ответил, даже если ответ пустой
|
||||
logger.info(f"Ярлык для AppID {appid} успешно удален через API.")
|
||||
else:
|
||||
logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).")
|
||||
|
||||
# Create backup of shortcuts.vdf
|
||||
backup_path = f"{steam_shortcuts_path}.backup"
|
||||
try:
|
||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
return (False, f"Failed to create backup of shortcuts.vdf: {e}")
|
||||
|
||||
# Save updated shortcuts.vdf
|
||||
try:
|
||||
with open(steam_shortcuts_path, 'wb') as f:
|
||||
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
||||
logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||
if os.path.exists(backup_path):
|
||||
try:
|
||||
shutil.copy2(backup_path, steam_shortcuts_path)
|
||||
logger.info("Restored shortcuts.vdf from backup due to update failure")
|
||||
except Exception as restore_err:
|
||||
logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
|
||||
return (False, f"Failed to update shortcuts.vdf: {e}")
|
||||
|
||||
# Delete cover files
|
||||
cover_files = [
|
||||
|
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>
|
Before Width: | Height: | Size: 734 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>
|
Before Width: | Height: | Size: 213 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
|
Before Width: | Height: | Size: 622 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#fff"/></svg>
|
Before Width: | Height: | Size: 164 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
|
Before Width: | Height: | Size: 570 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>
|
Before Width: | Height: | Size: 367 B |
@@ -1 +0,0 @@
|
||||
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z"/></svg>
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z"/></svg>
|
Before Width: | Height: | Size: 392 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>
|
Before Width: | Height: | Size: 660 B |
Before Width: | Height: | Size: 7.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>
|
Before Width: | Height: | Size: 208 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#fff"/></svg>
|
Before Width: | Height: | Size: 165 B |
@@ -1 +0,0 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848t-1.5848 3.8596q-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z"/></svg>
|
Before Width: | Height: | Size: 717 B |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 1.6 MiB |
Before Width: | Height: | Size: 475 KiB |
Before Width: | Height: | Size: 151 KiB |
@@ -1,5 +0,0 @@
|
||||
[Metainfo]
|
||||
author = BlackSnaker
|
||||
author_link =
|
||||
description = Стандартная тема PortProtonQt (светлый вариант)
|
||||
name = Light
|
@@ -1,699 +0,0 @@
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.config_utils import read_theme_from_config
|
||||
|
||||
theme_manager = ThemeManager()
|
||||
current_theme_name = read_theme_from_config()
|
||||
|
||||
# КОНСТАНТЫ
|
||||
favoriteLabelSize = 48, 48
|
||||
pixmapsScaledSize = 60, 60
|
||||
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
|
||||
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
|
||||
# Значение в пикселях.
|
||||
"default_border_width": 2,
|
||||
|
||||
# Ширина обводки при наведении курсора.
|
||||
# Увеличивает толщину рамки, когда курсор находится над карточкой.
|
||||
# Значение в пикселях.
|
||||
"hover_border_width": 8,
|
||||
|
||||
# Ширина обводки при фокусе (например, при выборе с клавиатуры).
|
||||
# Увеличивает толщину рамки, когда карточка в фокусе.
|
||||
# Значение в пикселях.
|
||||
"focus_border_width": 12,
|
||||
|
||||
# Минимальная ширина обводки во время пульсирующей анимации.
|
||||
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
|
||||
# Значение в пикселях.
|
||||
"pulse_min_border_width": 8,
|
||||
|
||||
# Максимальная ширина обводки во время пульсирующей анимации.
|
||||
# Определяет максимальную толщину рамки при пульсации.
|
||||
# Значение в пикселях.
|
||||
"pulse_max_border_width": 10,
|
||||
|
||||
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
|
||||
# Влияет на скорость перехода от одной ширины обводки к другой.
|
||||
# Значение в миллисекундах.
|
||||
"thickness_anim_duration": 300,
|
||||
|
||||
# Длительность одного цикла пульсирующей анимации.
|
||||
# Определяет, как быстро рамка "пульсирует" между min и max значениями.
|
||||
# Значение в миллисекундах.
|
||||
"pulse_anim_duration": 800,
|
||||
|
||||
# Длительность анимации вращения градиента.
|
||||
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
|
||||
# Значение в миллисекундах.
|
||||
"gradient_anim_duration": 3000,
|
||||
|
||||
# Начальный угол градиента (в градусах).
|
||||
# Определяет начальную точку вращения градиента при старте анимации.
|
||||
"gradient_start_angle": 360,
|
||||
|
||||
# Конечный угол градиента (в градусах).
|
||||
# Определяет конечную точку вращения градиента.
|
||||
# Значение 0 означает полный поворот на 360 градусов.
|
||||
"gradient_end_angle": 0,
|
||||
|
||||
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
|
||||
# Влияет на "чувство" анимации (например, плавное ускорение или замедление).
|
||||
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
|
||||
"thickness_easing_curve": "OutBack",
|
||||
|
||||
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
|
||||
# Влияет на "чувство" возврата к исходной ширине обводки.
|
||||
"thickness_easing_curve_out": "InBack",
|
||||
|
||||
# Цвета градиента для анимированной обводки.
|
||||
# Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
|
||||
# Влияет на внешний вид обводки при наведении или фокусе.
|
||||
"gradient_colors": [
|
||||
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||
]
|
||||
}
|
||||
|
||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
|
||||
MAIN_WINDOW_HEADER_STYLE = """
|
||||
QFrame {
|
||||
background: transparent;
|
||||
border: 10px solid rgba(255, 255, 255, 0.10);
|
||||
border-bottom: 0px solid rgba(255, 255, 255, 0.15);
|
||||
border-top-left-radius: 30px;
|
||||
border-top-right-radius: 30px;
|
||||
border: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
|
||||
TITLE_LABEL_STYLE = """
|
||||
QLabel {
|
||||
font-family: 'RASKHAL';
|
||||
font-size: 38px;
|
||||
margin: 0 0 0 0;
|
||||
color: #007AFF;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
|
||||
NAV_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: #ffffff;
|
||||
border-bottom: 0px solid rgba(0, 0, 0, 0.10);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
|
||||
NAV_BUTTON_STYLE = """
|
||||
NavLabel {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(242, 242, 242, 0.5),
|
||||
stop:1 rgba(232, 232, 232, 0.5));
|
||||
padding: 10px 10px;
|
||||
margin: 10px 0 10px 10px;
|
||||
color: #333333;
|
||||
font-size: 16px;
|
||||
font-family: 'Poppins';
|
||||
text-transform: uppercase;
|
||||
border: 1px solid rgba(179, 179, 179, 0.4);
|
||||
border-radius: 15px;
|
||||
}
|
||||
NavLabel[checked = true] {
|
||||
background: rgba(0,122,255,0.25);
|
||||
color: #002244;
|
||||
font-weight: bold;
|
||||
border-radius: 15px;
|
||||
}
|
||||
NavLabel:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(0,122,255,0.12),
|
||||
stop:1 rgba(0,122,255,0.08));
|
||||
color: #002244;
|
||||
}
|
||||
"""
|
||||
|
||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
|
||||
MAIN_WINDOW_STYLE = """
|
||||
QMainWindow {
|
||||
background: none;
|
||||
}
|
||||
QLabel {
|
||||
color: #333333;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ПОЛЯ ПОИСКА
|
||||
SEARCH_EDIT_STYLE = """
|
||||
QLineEdit {
|
||||
background-color: rgba(30, 30, 30, 0.50);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
padding: 7px 14px;
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
color: #ffffff;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border: 1px solid rgba(0,122,255,0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
|
||||
SCROLL_AREA_STYLE = """
|
||||
QWidget {
|
||||
background: transparent;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
width: 10px;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QScrollBar::add-line:vertical {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::sub-line:vertical {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
|
||||
border: 0px solid;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar:horizontal {
|
||||
height: 10px;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
}
|
||||
QScrollBar::handle:horizontal {
|
||||
background: #bebebe;
|
||||
border: 0px solid;
|
||||
border-radius: 5px;
|
||||
}
|
||||
QScrollBar::add-line:horizontal {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::sub-line:horizontal {
|
||||
border: 0px solid;
|
||||
background: none;
|
||||
}
|
||||
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {
|
||||
border: 0px solid;
|
||||
width: 3px;
|
||||
height: 3px;
|
||||
background: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# SLIDER_SIZE_STYLE
|
||||
SLIDER_SIZE_STYLE= """
|
||||
QWidget {
|
||||
background: transparent;
|
||||
height: 25px;
|
||||
}
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px solid;
|
||||
border-radius: 3px;
|
||||
height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
|
||||
background: rgba(20, 20, 20, 0.30);
|
||||
margin: 6px 0;
|
||||
}
|
||||
QSlider::handle:horizontal {
|
||||
background: #bebebe;
|
||||
border: 0px solid;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
|
||||
border-radius: 9px;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
|
||||
LIST_WIDGET_STYLE = """
|
||||
QWidget {
|
||||
background: none;
|
||||
border: 0px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 25px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
|
||||
INSTALLED_TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627;"
|
||||
|
||||
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
|
||||
ACTION_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(242, 242, 242, 0.5),
|
||||
stop:1 rgba(232, 232, 232, 0.5));
|
||||
border: 1px solid rgba(179, 179, 179, 0.4);
|
||||
border-radius: 10px;
|
||||
color: #232627;
|
||||
font-size: 16px;
|
||||
font-family: 'Poppins';
|
||||
padding: 8px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
|
||||
TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627; background-color: none;"
|
||||
CONTENT_STYLE = """
|
||||
QLabel {
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
color: #232627;
|
||||
background-color: none;
|
||||
border-bottom: 1px solid rgba(165, 165, 165, 0.7);
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
|
||||
# LIBRARY_WIDGET_STYLE
|
||||
LIBRARY_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# CONTAINER_STYLE
|
||||
CONTAINER_STYLE= """
|
||||
QWidget {
|
||||
background-color: none;
|
||||
}
|
||||
"""
|
||||
|
||||
# OTHER_PAGES_WIDGET_STYLE
|
||||
OTHER_PAGES_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# CAROUSEL_WIDGET_STYLE
|
||||
CAROUSEL_WIDGET_STYLE= """
|
||||
QWidget {
|
||||
background: qlineargradient(spread:pad, x1:0.099, y1:0.119, x2:0.917, y2:0.936149, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(217, 193, 255, 255));
|
||||
border-radius: 0px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
|
||||
DETAIL_PAGE_NO_COVER_STYLE = "background: rgba(20,20,20,0.95); border-radius: 15px;"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
|
||||
ADDGAME_BACK_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
font-family: 'Poppins';
|
||||
padding: 4px 16px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
|
||||
DETAIL_CONTENT_FRAME_STYLE = """
|
||||
QFrame {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(20, 20, 20, 0.40),
|
||||
stop:1 rgba(20, 20, 20, 0.35));
|
||||
border: 0px solid rgba(255, 255, 255, 0.10);
|
||||
border-radius: 15px;
|
||||
}
|
||||
"""
|
||||
|
||||
# ФРЕЙМ ПОД ОБЛОЖКОЙ
|
||||
COVER_FRAME_STYLE = """
|
||||
QFrame {
|
||||
background: rgba(30, 30, 30, 0.80);
|
||||
border-radius: 15px;
|
||||
border: 0px solid rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
"""
|
||||
|
||||
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
|
||||
COVER_LABEL_STYLE = "border-radius: 100px;"
|
||||
|
||||
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
|
||||
DETAILS_WIDGET_STYLE = "background: rgba(20,20,20,0.40); border-radius: 15px; padding: 10px;"
|
||||
|
||||
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
|
||||
DETAIL_PAGE_TITLE_STYLE = "font-family: 'Orbitron'; font-size: 32px; color: #007AFF;"
|
||||
|
||||
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
|
||||
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
|
||||
|
||||
# ТЕКСТ ОПИСАНИЯ
|
||||
DETAIL_PAGE_DESC_STYLE = "font-family: 'Poppins'; font-size: 16px; color: #ffffff; line-height: 1.5;"
|
||||
|
||||
# СТИЛЬ КНОПКИ "ИГРАТЬ"
|
||||
PLAY_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
font-size: 18px;
|
||||
color: #ffffff;
|
||||
font-weight: bold;
|
||||
font-family: 'Orbitron';
|
||||
padding: 8px 16px;
|
||||
min-width: 120px;
|
||||
min-height: 40px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(0,122,255,0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КНОПКИ "ОБЗОР..." В ДИАЛОГЕ "ДОБАВИТЬ ИГРУ"
|
||||
DIALOG_BROWSE_BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 0px solid rgba(255, 255, 255, 0.20);
|
||||
border-radius: 15px;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(0,122,255,0.20),
|
||||
stop:1 rgba(0,122,255,0.15));
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background: rgba(20, 20, 20, 0.60);
|
||||
border: 0px solid rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
|
||||
GAME_CARD_WINDOW_STYLE = """
|
||||
QFrame {
|
||||
border-radius: 20px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 rgba(255, 255, 255, 0.3),
|
||||
stop:1 rgba(249, 249, 249, 0.3));
|
||||
border: 0px solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
"""
|
||||
|
||||
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
|
||||
GAME_CARD_NAME_LABEL_STYLE = """
|
||||
QLabel {
|
||||
color: #333333;
|
||||
font-family: 'Orbitron';
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(242, 242, 242, 0.5),
|
||||
stop:1 rgba(232, 232, 232, 0.5));
|
||||
border-radius: 20px;
|
||||
padding: 7px;
|
||||
qproperty-wordWrap: true;
|
||||
}
|
||||
"""
|
||||
|
||||
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
|
||||
LAST_LAUNCH_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
LAST_LAUNCH_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
|
||||
PLAY_TIME_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
|
||||
PLAY_TIME_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
|
||||
GAMEPAD_SUPPORT_VALUE_STYLE = """
|
||||
font-family: 'Poppins'; font-size: 12px; color: #00ff00;
|
||||
font-weight: bold; background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 5px; padding: 4px 8px;
|
||||
"""
|
||||
|
||||
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
|
||||
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
|
||||
CAPTION_LABEL_STYLE="color: white; font-size: 16px;"
|
||||
|
||||
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
|
||||
def get_protondb_badge_style(tier):
|
||||
tier = tier.lower()
|
||||
tier_colors = {
|
||||
"platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
|
||||
"gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
|
||||
"silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
|
||||
"bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
|
||||
"borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
|
||||
"pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
|
||||
}
|
||||
colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||
return f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background-color: {colors["background"]};
|
||||
color: {colors["color"]};
|
||||
border-radius: 5px;
|
||||
font-family: 'Poppins';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
def get_anticheat_badge_style(status):
|
||||
status = status.lower()
|
||||
status_colors = {
|
||||
"supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
|
||||
"running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
|
||||
"planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
|
||||
"broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
|
||||
"denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
|
||||
}
|
||||
colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
|
||||
return f"""
|
||||
qproperty-alignment: AlignCenter;
|
||||
background-color: {colors["background"]};
|
||||
color: {colors["color"]};
|
||||
border-radius: 5px;
|
||||
font-family: 'Poppins';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# СТИЛИ БЕЙДЖА STEAM
|
||||
STEAM_BADGE_STYLE= """
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
font-family: 'Poppins';
|
||||
font-weight: bold;
|
||||
"""
|
||||
|
||||
# Favorite Star
|
||||
FAVORITE_LABEL_STYLE = "color: gold; font-size: 32px; background: transparent; border: none;"
|
||||
|
||||
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
|
||||
MESSAGE_BOX_STYLE = """
|
||||
QMessageBox {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 rgba(40, 40, 40, 0.95),
|
||||
stop:1 rgba(25, 25, 25, 0.95));
|
||||
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||
border-radius: 12px;
|
||||
}
|
||||
QMessageBox QLabel {
|
||||
color: #ffffff;
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
}
|
||||
QMessageBox QPushButton {
|
||||
background: rgba(30, 30, 30, 0.6);
|
||||
border: 1px solid rgba(165, 165, 165, 0.7);
|
||||
border-radius: 8px;
|
||||
color: #ffffff;
|
||||
font-family: 'Poppins';
|
||||
padding: 8px 20px;
|
||||
min-width: 80px;
|
||||
}
|
||||
QMessageBox QPushButton:hover {
|
||||
background: #09bec8;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
"""
|
||||
|
||||
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
|
||||
# PARAMS_TITLE_STYLE
|
||||
PARAMS_TITLE_STYLE = "color: #232627; font-family: 'Poppins'; font-size: 16px; padding: 10px; background: transparent;"
|
||||
|
||||
PROXY_INPUT_STYLE = """
|
||||
QLineEdit {
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 0px solid rgba(165, 165, 165, 0.7);
|
||||
border-radius: 10px;
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: #ffffff;
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
}
|
||||
QLineEdit:focus {
|
||||
border: 1px solid rgba(0,122,255,0.25);
|
||||
}
|
||||
QMenu {
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
padding: 5px 10px;
|
||||
background: #c7c7c7;
|
||||
}
|
||||
QMenu::item {
|
||||
padding: 0px 10px;
|
||||
border: 10px solid transparent; /* reserve space for selection border */
|
||||
}
|
||||
QMenu::item:selected {
|
||||
background: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
"""
|
||||
|
||||
SETTINGS_COMBO_STYLE = f"""
|
||||
QComboBox {{
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
border-radius: 10px;
|
||||
height: 34px;
|
||||
padding-left: 12px;
|
||||
color: #ffffff;
|
||||
font-family: 'Poppins';
|
||||
font-size: 16px;
|
||||
min-width: 120px;
|
||||
combobox-popup: 0;
|
||||
}}
|
||||
QComboBox:on {{
|
||||
background: rgba(20, 20, 20, 0.40);
|
||||
border: 1px solid rgba(165, 165, 165, 0.7);
|
||||
border-top-left-radius: 10px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-left-radius: 0px;
|
||||
border-bottom-right-radius: 0px;
|
||||
}}
|
||||
QComboBox:hover {{
|
||||
border: 1px solid rgba(165, 165, 165, 0.7);
|
||||
}}
|
||||
QComboBox::drop-down {{
|
||||
subcontrol-origin: padding;
|
||||
subcontrol-position: center right;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.5);
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow {{
|
||||
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox::down-arrow:on {{
|
||||
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
|
||||
padding: 12px;
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
}}
|
||||
QComboBox QAbstractItemView {{
|
||||
outline: none;
|
||||
border: 1px solid rgba(165, 165, 165, 0.7);
|
||||
border-top-style: none;
|
||||
}}
|
||||
QListView {{
|
||||
background: #ffffff;
|
||||
}}
|
||||
QListView::item {{
|
||||
padding: 7px 7px 7px 12px;
|
||||
border-radius: 0px;
|
||||
color: #232627;
|
||||
}}
|
||||
QListView::item:hover {{
|
||||
background: rgba(0,122,255,0.25);
|
||||
}}
|
||||
QListView::item:selected {{
|
||||
background: rgba(0,122,255,0.25);
|
||||
}}
|
||||
"""
|
||||
|
||||
class FileExplorerStyles:
|
||||
WINDOW_STYLE = """
|
||||
QDialog {
|
||||
background-color: #2d2d2d;
|
||||
color: #ffffff;
|
||||
font-family: "Arial";
|
||||
font-size: 14px;
|
||||
}
|
||||
"""
|
||||
|
||||
PATH_LABEL_STYLE = """
|
||||
QLabel {
|
||||
color: #3daee9;
|
||||
font-size: 16px;
|
||||
padding: 5px;
|
||||
}
|
||||
"""
|
||||
|
||||
LIST_STYLE = """
|
||||
QListWidget {
|
||||
font-size: 16px;
|
||||
background-color: #353535;
|
||||
color: #eee;
|
||||
border: 1px solid #444;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #444;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background-color: #3daee9;
|
||||
color: white;
|
||||
border-radius: 2px;
|
||||
}
|
||||
"""
|
||||
|
||||
BUTTON_STYLE = """
|
||||
QPushButton {
|
||||
background-color: #3daee9;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
QPushButton:hover {
|
||||
background-color: #2c9fd8;
|
||||
}
|
||||
QPushButton:pressed {
|
||||
background-color: #1a8fc7;
|
||||
}
|
||||
"""
|
@@ -27,6 +27,10 @@ color_g = "rgba(0, 0, 0, 0)"
|
||||
color_h = "transparent"
|
||||
|
||||
GAME_CARD_ANIMATION = {
|
||||
# Тип анимации при входе и выходе на детальную страницу
|
||||
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
|
||||
"detail_page_animation_type": "fade",
|
||||
|
||||
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
|
||||
# Влияет на толщину рамки вокруг карточки, когда она не выделена.
|
||||
# Значение в пикселях.
|
||||
@@ -93,7 +97,33 @@ GAME_CARD_ANIMATION = {
|
||||
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
||||
]
|
||||
],
|
||||
|
||||
# Длительность анимации fade при входе на детальную страницу
|
||||
"detail_page_fade_duration": 350,
|
||||
|
||||
# Длительность анимации slide при входе на детальную страницу
|
||||
"detail_page_slide_duration": 500,
|
||||
|
||||
# Длительность анимации bounce при входе на детальную страницу
|
||||
"detail_page_bounce_duration": 400,
|
||||
|
||||
# Длительность анимации fade при выходе из детальной страницы
|
||||
"detail_page_fade_duration_exit": 350,
|
||||
|
||||
# Длительность анимации slide при выходе из детальной страницы
|
||||
"detail_page_slide_duration_exit": 500,
|
||||
|
||||
# Длительность анимации bounce при выходе из детальной страницы
|
||||
"detail_page_bounce_duration_exit": 400,
|
||||
|
||||
# Тип кривой сглаживания для анимации при входе на детальную страницу
|
||||
# Применяется к slide и bounce анимациям
|
||||
"detail_page_easing_curve": "OutCubic",
|
||||
|
||||
# Тип кривой сглаживания для анимации при выходе из детальной страницы
|
||||
# Применяется к slide и bounce анимациям
|
||||
"detail_page_easing_curve_exit": "InCubic"
|
||||
}
|
||||
|
||||
CONTEXT_MENU_STYLE = f"""
|
||||
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portprotonqt"
|
||||
version = "0.1.3"
|
||||
version = "0.1.4"
|
||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||
readme = "README.md"
|
||||
license = { text = "GPL-3.0" }
|
||||
@@ -28,17 +28,18 @@ requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"babel>=2.17.0",
|
||||
"beautifulsoup4>=4.13.4",
|
||||
"evdev>=1.9.1",
|
||||
"icoextract>=0.1.6",
|
||||
"evdev>=1.9.2",
|
||||
"icoextract>=0.2.0",
|
||||
"numpy>=2.2.4",
|
||||
"orjson>=3.10.16",
|
||||
"pillow>=11.2.1",
|
||||
"orjson>=3.11.2",
|
||||
"pillow>=11.3.0",
|
||||
"psutil>=7.0.0",
|
||||
"pyside6>=6.9.0",
|
||||
"pyside6>=6.9.1",
|
||||
"pyudev>=0.24.3",
|
||||
"requests>=2.32.3",
|
||||
"requests>=2.32.4",
|
||||
"tqdm>=4.67.1",
|
||||
"vdf>=3.4",
|
||||
"websocket-client>=1.8.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -102,7 +103,7 @@ ignore = [
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.2.0",
|
||||
"pre-commit>=4.3.0",
|
||||
"pyaspeller>=2.0.2",
|
||||
"pyright>=1.1.400",
|
||||
"pyright>=1.1.403",
|
||||
]
|
||||
|
@@ -15,12 +15,23 @@
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchFileNames": [".gitea/workflows/**.yaml", ".gitea/workflows/**.yml"],
|
||||
"matchFileNames": [".python-version"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchFileNames": [".python-version"],
|
||||
"matchManagers": ["github-actions", "pre-commit"],
|
||||
"enabled": false
|
||||
},
|
||||
{
|
||||
"matchManagers": ["pep621"],
|
||||
"rangeStrategy": "bump",
|
||||
"versioning": "pep440",
|
||||
"groupName": "Python dependencies"
|
||||
},
|
||||
{
|
||||
"matchPackageNames": ["numpy", "setuptools"],
|
||||
"enabled": false,
|
||||
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|