Compare commits
98 Commits
f7a4fa6a17
...
renovate/p
Author | SHA1 | Date | |
---|---|---|---|
|
cb07904c1b | ||
05e0d9d846
|
|||
81433d3c56
|
|||
0ff66e282b
|
|||
831b7739ba
|
|||
50e1dfda57
|
|||
fcf04e521d
|
|||
74d0700d7c
|
|||
0435c77630
|
|||
1cf93a60c8
|
|||
31247d21c3
|
|||
c6017a7dce
|
|||
c74d209dbd
|
|||
5b257d3b62
|
|||
4dcf1dbe6d
|
|||
8d6fe4aa65
|
|||
022eb3f1e9
|
|||
11b847ed05
|
|||
1e4e0127a4
|
|||
c045aa7a56
|
|||
f18e7bae6b
|
|||
dcf8904037
|
|||
f9d24e385d
|
|||
09028931be
|
|||
0294c90c54
|
|||
17dfef2d27
|
|||
|
f0690f8811
|
||
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
|
|||
1a8c733580
|
|||
2476bea32a
|
|||
1bbc95a5c1
|
|||
d12b801191
|
|||
233dab1269
|
|||
700a478598
|
|||
0fe727331f
|
|||
599644c4f6
|
|||
|
409e06f531 | ||
4818cf5b67
|
|||
59bfcdbbba
|
|||
989af36e5b
|
|||
8300857aaa
|
|||
aea1a36cfd
|
@@ -17,10 +17,12 @@ jobs:
|
|||||||
- name: Install required dependencies
|
- name: Install required dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
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
|
- 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
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
|
@@ -8,7 +8,7 @@ on:
|
|||||||
|
|
||||||
env:
|
env:
|
||||||
# Common version, will be used for tagging the release
|
# Common version, will be used for tagging the release
|
||||||
VERSION: 0.1.3
|
VERSION: 0.1.4
|
||||||
PKGDEST: "/tmp/portprotonqt"
|
PKGDEST: "/tmp/portprotonqt"
|
||||||
PACKAGE: "portprotonqt"
|
PACKAGE: "portprotonqt"
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -23,10 +23,12 @@ jobs:
|
|||||||
- name: Install required dependencies
|
- name: Install required dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt update
|
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
|
- 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
|
- name: Build AppImage
|
||||||
run: |
|
run: |
|
||||||
@@ -157,6 +159,7 @@ jobs:
|
|||||||
mkdir -p extracted
|
mkdir -p extracted
|
||||||
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
|
find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
|
||||||
find extracted/ -type f -exec mv {} release/ \;
|
find extracted/ -type f -exec mv {} release/ \;
|
||||||
|
find release/ -name '*.zip' -delete
|
||||||
rm -rf extracted/
|
rm -rf extracted/
|
||||||
|
|
||||||
- name: Extract changelog for version
|
- 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:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -22,10 +22,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
|
|
||||||
- name: Install uv
|
- name: Set up Node.js
|
||||||
uses: https://github.com/astral-sh/setup-uv@v6
|
uses: https://gitea.com/actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
enable-cache: true
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install uv manually
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
source $HOME/.local/bin/env
|
||||||
|
uv --version
|
||||||
|
|
||||||
- name: Sync dependencies into venv
|
- name: Sync dependencies into venv
|
||||||
run: uv sync --all-extras --dev
|
run: uv sync --all-extras --dev
|
||||||
@@ -35,20 +41,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pre-commit run --show-diff-on-failure --color=always --all-files
|
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,30 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
renovate:
|
renovate:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
container: ghcr.io/renovatebot/renovate:41.1.4
|
container: ghcr.io/renovatebot/renovate:latest
|
||||||
steps:
|
steps:
|
||||||
- uses: https://gitea.com/actions/checkout@v4
|
- uses: https://gitea.com/actions/checkout@v4
|
||||||
- run: renovate
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: https://gitea.com/actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install uv manually
|
||||||
|
run: |
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||||
|
. $HOME/.local/bin/env
|
||||||
|
uv --version
|
||||||
|
|
||||||
|
- name: 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:
|
env:
|
||||||
RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js"
|
RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
|
||||||
LOG_LEVEL: "debug"
|
LOG_LEVEL: "debug"
|
||||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
|
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: trailing-whitespace
|
- id: trailing-whitespace
|
||||||
- id: end-of-file-fixer
|
- id: end-of-file-fixer
|
||||||
@@ -11,15 +11,14 @@ repos:
|
|||||||
- id: check-yaml
|
- id: check-yaml
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||||
rev: 0.6.14
|
rev: 0.8.9
|
||||||
hooks:
|
hooks:
|
||||||
- id: uv-lock
|
- id: uv-lock
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.11.5
|
rev: v0.12.8
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff-check
|
||||||
args: [--fix]
|
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
227
CHANGELOG.md
@@ -6,16 +6,61 @@
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Переводы в переопределениях (за подробностями в документацию)
|
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
|
||||||
- Обложки и описания для всех автоинсталлов
|
- Анимация при закрытии карточки игры (подробности см. в документации).
|
||||||
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры
|
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
|
||||||
|
- Система быстрого доступа (избранного) в диалоге выбора файлов.
|
||||||
|
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
|
||||||
|
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
|
||||||
|
- Поднятия в родительскую директорию в диалоге выбора файлов на BackSpace.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Оптимизированны обложки автоинсталлов
|
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
|
||||||
- Папка custom_data исключена из сборки модуля для уменьшение его размера
|
- Контекстное меню при открытии теперь сразу фокусируется на первом элементе.
|
||||||
|
- Анимации теперь можно настраивать через темы (подробности см. в документации).
|
||||||
|
- Общие JSON-файлы (`steam_apps` и `anticheat_games`) теперь перекачиваются, если они повреждены.
|
||||||
|
- Временно удалена светлая тема.
|
||||||
|
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
|
||||||
|
- Обновлены все зависимости (затрагивает только AppImage).
|
||||||
|
- Удалён отдельный трей, так как у PortProton есть собственный.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
|
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
|
||||||
|
- Скриншоты тем больше не теряют качество при масштабе, отличном от 100%.
|
||||||
|
- Данные от HLTB теперь не отображаются в карточке, если нет информации о времени прохождения.
|
||||||
|
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
|
||||||
|
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
|
||||||
|
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
|
||||||
|
- Переведен заголовок окна диалога выбора файлов.
|
||||||
|
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
|
||||||
|
- Закрытие диалога добавления / редактирования игры и диалога выбора файлов через escape.
|
||||||
|
|
||||||
|
### Contributors
|
||||||
|
- @Alex Smith
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.1.4] - 2025-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Переводы в переопределениях (подробности см. в документации).
|
||||||
|
- Обложки и описания для всех автоинсталлов.
|
||||||
|
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры.
|
||||||
|
- Интеграция с howlongtobeat.com.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Оптимизированы обложки автоинсталлов.
|
||||||
|
- Папка `custom_data` исключена из сборки модуля для уменьшения его размера.
|
||||||
|
- Бейдж PortProton теперь открывает PortProtonDB.
|
||||||
|
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в Gamescope-сессии.
|
||||||
|
- Удалён аргумент `--session`, так как тестирование Gamescope-сессии завершено.
|
||||||
|
- В контекстном меню игр без exe-файла теперь отображается только пункт «Удалить из PortProton».
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Запрос к GitHub API при загрузке legendary теперь учитывает настройки прокси.
|
||||||
|
- Путь к `portprotonqt-session-select` в оверлее.
|
||||||
|
- Работа `exiftool` в AppImage.
|
||||||
|
- Открытие контекстного меню у игр без exe-файла.
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
- @Vector_null
|
- @Vector_null
|
||||||
@@ -25,32 +70,32 @@
|
|||||||
## [0.1.3] - 2025-07-05
|
## [0.1.3] - 2025-07-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Аргумент `--session` для запуска приложения в gamescope (Исключительно в целях тестирования)
|
- Аргумент `--session` для запуска приложения в Gamescope (исключительно в целях тестирования).
|
||||||
- Начальная поддержка EGS (Без EOS, скачивания игр и запуска игр из сторонних магазинов)
|
- Начальная поддержка EGS (без EOS, скачивания и запуска игр из сторонних магазинов).
|
||||||
- Автодополнение bash для комманды portprotonqt
|
- Автодополнение bash для команды `portprotonqt`.
|
||||||
- Поддержка геймпадов в диалоге выбора игры
|
- Поддержка геймпадов в диалоге выбора игры.
|
||||||
- Быстрый запуск и остановка игры через контекстное меню
|
- Быстрый запуск и остановка игры через контекстное меню.
|
||||||
- Иконки в контекстом меню
|
- Иконки в контекстном меню.
|
||||||
- Обложки для части автоинсталлов
|
- Обложки для части автоинсталлов.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Удалены сборки для Fedora 40
|
- Удалены сборки для Fedora 40.
|
||||||
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем.
|
- Параметры анимации GameCard перенесены в `styles.py` с подробной документацией для кастомизации тем.
|
||||||
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга
|
- Статусы выделения и наведения на карточки теперь взаимоисключающие.
|
||||||
- Все desktop файлы создаются с коментарием "Запустить игру {название} через PortProton"
|
- Все desktop-файлы создаются с комментарием «Запустить игру {название} через PortProton».
|
||||||
- Заполнители в переводах теперь стали более осмысленными
|
- Заполнители в переводах стали более осмысленными.
|
||||||
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope
|
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope.
|
||||||
- Текст бейджей теперь обрезается через ... если не помещается
|
- Текст бейджей теперь обрезается троеточием, если не помещается.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Дублирование обводки выделения карточек при быстром перемешении мыши
|
- Дублирование обводки карточек при быстром перемещении мыши.
|
||||||
- Завершение приложения при закритие окна
|
- Завершение приложения при закрытии окна.
|
||||||
- Использование системной палитры в темах
|
- Использование системной палитры в темах.
|
||||||
- Ошибки темы в нативном пакете
|
- Ошибки тем в нативном пакете.
|
||||||
- Ошибки темы в Gamescope
|
- Ошибки тем в Gamescope.
|
||||||
- Размер иконок для desktop файлов теперь 128x128
|
- Размер иконок для desktop-файлов теперь 128x128.
|
||||||
- Пустая область при обновлении сетки игр
|
- Пустая область при обновлении сетки игр.
|
||||||
- Запуск игры при открытом оверлее
|
- Запуск игры при открытом оверлее.
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
- @Dervart
|
- @Dervart
|
||||||
@@ -61,63 +106,63 @@
|
|||||||
## [0.1.2] - 2025-06-15
|
## [0.1.2] - 2025-06-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Кнопки сброса настроек и очистки кэша
|
- Кнопки сброса настроек и очистки кэша.
|
||||||
- Бейдж PortProton
|
- Бейдж PortProton.
|
||||||
- Зависимость от `xdg-utils`
|
- Зависимость от `xdg-utils`.
|
||||||
- Интеграция статуса WeAntiCheatYet в карточку
|
- Интеграция статуса WeAntiCheatYet в карточку.
|
||||||
- Переключение полноэкршанного режима через F11 или кнопку Select на геймпаде
|
- Переключение полноэкранного режима через F11 или кнопку Select на геймпаде.
|
||||||
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде
|
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде.
|
||||||
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде
|
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде.
|
||||||
- Закрытие окна приложения по комбинации клавиш Ctrl+Q
|
- Закрытие приложения комбинацией клавиш Ctrl+Q.
|
||||||
- Сохранение и восстановление размера окна при перезапуске
|
- Сохранение и восстановление размера окна при перезапуске.
|
||||||
- Переключатель полноэкранного режима приложения
|
- Переключатель полноэкранного режима приложения.
|
||||||
- Пункт в контекстном меню «Открыть папку игры»
|
- Пункт в контекстном меню «Открыть папку игры».
|
||||||
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam»
|
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam».
|
||||||
- Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного»
|
- Пункты в контекстном меню «Добавить в избранное» и «Удалить из избранного».
|
||||||
- Метод сортировки «Сначала избранное»
|
- Метод сортировки «Сначала избранное».
|
||||||
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена)
|
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена).
|
||||||
- Поддержка управления геймпадом в `QMenu` и `QComboBox`
|
- Поддержка управления геймпадом в `QMenu` и `QComboBox`.
|
||||||
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме
|
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме.
|
||||||
- Оверлей на кнопку Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или переключения между сессиями
|
- Оверлей на кнопку Insert или Xbox/PS-кнопку на геймпаде для закрытия приложения, выключения, перезагрузки, перехода в спящий режим или переключения между сессиями.
|
||||||
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
|
- [Gamescope-сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt).
|
||||||
- Пресеты управления для DualShock 4 и DualSense
|
- Пресеты управления для DualShock 4 и DualSense.
|
||||||
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию выключена)
|
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию отключена).
|
||||||
- Переводы пунктов настроек
|
- Переводы пунктов настроек.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Обновлены все иконки
|
- Обновлены все иконки.
|
||||||
- Переименована функция `_get_steam_home` в `get_steam_home`
|
- Функция `_get_steam_home` переименована в `get_steam_home`.
|
||||||
- Переименован `steam_game` в `game_source`
|
- `steam_game` переименован в `game_source`.
|
||||||
- Логика контекстного меню вынесена в `ContextMenuManager`
|
- Логика контекстного меню вынесена в `ContextMenuManager`.
|
||||||
- Бейдж Steam теперь открывает Steam Community
|
- Бейдж Steam теперь открывает Steam Community.
|
||||||
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
|
- Лицензия изменена с MIT на GPL-3.0 для совместимости с кодом legendary.
|
||||||
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна
|
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна.
|
||||||
- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке
|
- Бейджи с карточек теперь отображаются и на странице с деталями, а не только в библиотеке.
|
||||||
- Установлена ширина бейджа в две трети ширины карточки
|
- Установлена ширина бейджа в 2/3 ширины карточки.
|
||||||
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites`
|
- Бейджи источников (`Steam`, `EGS`, `PortProton`) отображаются только при активном фильтре `all` или `favorites`.
|
||||||
- Карточки теперь фокусируются в направлении движения стрелок или D-pad:
|
- Карточки теперь фокусируются в направлении движения стрелок или D-pad.
|
||||||
- Поддерживается удержание D-pad для непрерывного переключения карточек
|
- Поддерживается удержание D-pad для непрерывного переключения карточек.
|
||||||
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности
|
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности.
|
||||||
- D-pad больше не переключает вкладки (только кнопки RB/LB)
|
- D-pad больше не переключает вкладки (только кнопки RB/LB).
|
||||||
- Кнопка добавления игры больше не фокусируется
|
- Кнопка добавления игры больше не получает фокус.
|
||||||
- Диалог добавления игры теперь открывается только в библиотеке
|
- Диалог добавления игры открывается только в библиотеке.
|
||||||
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt
|
- Все упоминания PortProtonQT заменены на PortProtonQt.
|
||||||
- Размер карточек теперь меняется только при отпускании слайдера
|
- Размер карточек меняется только при отпускании слайдера.
|
||||||
- Слайдер теперь управляется через тригеры на геймпаде
|
- Слайдер теперь управляется триггерами на геймпаде.
|
||||||
- Диалог добавления игры теперь открывается на X, а не на Y
|
- Диалог добавления игры теперь открывается на X, а не на Y.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Возврат к теме «standard» при выборе несуществующей темы
|
- Возврат к теме «standard» при выборе несуществующей темы.
|
||||||
- Корректное открытие контекстного меню
|
- Корректное открытие контекстного меню.
|
||||||
- Запуск приложения при отсутствии `exiftool`
|
- Запуск приложения при отсутствии `exiftool`.
|
||||||
- Предотвращено бесконечное обращение к `get_portproton_location`
|
- Предотвращено бесконечное обращение к `get_portproton_location`.
|
||||||
- Обновлены ссылки на документацию в README
|
- Обновлены ссылки на документацию в README.
|
||||||
- Устранён traceback при отсутствии обложек (placeholder)
|
- Исправлено падение при отсутствии обложек (placeholder).
|
||||||
- Устранены утечки памяти при загрузке обложек
|
- Устранены утечки памяти при загрузке обложек.
|
||||||
- Исправлены ошибки при подключении геймпада
|
- Исправлены ошибки при подключении геймпада.
|
||||||
- Предотвращено многократное открытие диалога добавления игры через геймпад
|
- Предотвращено многократное открытие диалога добавления игры через геймпад.
|
||||||
- Корректная обработка событий геймпада во время игры
|
- Корректная обработка событий геймпада во время игры.
|
||||||
- Убийсво всех процессов "зомби" при закрытии программы
|
- Убийство всех процессов-зомби при закрытии программы.
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
- @Vector_null
|
- @Vector_null
|
||||||
@@ -128,20 +173,20 @@
|
|||||||
## [0.1.1] – 2025-05-17
|
## [0.1.1] – 2025-05-17
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- Алфавитная сортировка библиотеки
|
- Алфавитная сортировка библиотеки.
|
||||||
- Проверка переводов через yaspeller
|
- Проверка переводов через yaspeller.
|
||||||
- Сборка Fedora-пакета
|
- Сборка Fedora-пакета.
|
||||||
- Сборка AppImage
|
- Сборка AppImage.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Удалён жёстко заданный размер окна
|
- Удалён жёстко заданный размер окна.
|
||||||
- Использован `icoextract` как Python-модуль
|
- Использован `icoextract` как Python-модуль.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- Скрытие статус-бара
|
- Скрытие статус-бара.
|
||||||
- Чтение списка Steam-игр
|
- Чтение списка Steam-игр.
|
||||||
- Зависание GUI
|
- Зависание GUI.
|
||||||
- Сбой при повреждённом Steam
|
- Сбой при повреждённом Steam.
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
- @Vector_null
|
- @Vector_null
|
||||||
|
13
LICENSE
@@ -73,6 +73,19 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
|
===============================
|
||||||
|
= HowLongToBeat-Python-API : =
|
||||||
|
===============================
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2020 JaeguKim
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
|
||||||
==============
|
==============
|
||||||
= legendary: =
|
= legendary: =
|
||||||
==============
|
==============
|
||||||
|
@@ -51,11 +51,11 @@ pre-commit run --all-files
|
|||||||
|
|
||||||
PortProtonQt использует код и зависимости от следующих проектов:
|
PortProtonQt использует код и зависимости от следующих проектов:
|
||||||
|
|
||||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.html).
|
- [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://opensource.org/licenses/MIT).
|
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
|
||||||
- [PortProton 2.0](https://git.linux-gaming.ru/CastroFidel/PortProton_2.0) — библиотека для взаимодействия с PortProton, лицензия [MIT](https://opensource.org/licenses/MIT).
|
- [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]
|
> [!WARNING]
|
||||||
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
|
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
|
||||||
|
5
TODO.md
@@ -17,7 +17,6 @@
|
|||||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
|
||||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
|
||||||
- [X] Избавиться от вызовов yad
|
- [X] Избавиться от вызовов yad
|
||||||
- [X] Реализовать собственный механизм запрета ухода в спящий режим вместо использования механизма PortProton (оставлено для [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0))
|
|
||||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
- [X] Реализовать собственный системный трей вместо использования трея PortProton
|
||||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
|
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
|
||||||
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
|
||||||
@@ -42,6 +41,7 @@
|
|||||||
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
|
||||||
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
|
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
|
||||||
- [X] Добавить поддержку версий Steam для Flatpak и Snap
|
- [X] Добавить поддержку версий Steam для Flatpak и Snap
|
||||||
|
- [X] Реализовать добавление игры как сторонней в Steam без перезапуска
|
||||||
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
|
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
|
||||||
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
|
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
|
||||||
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
|
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
|
||||||
@@ -57,13 +57,12 @@
|
|||||||
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
|
- [X] Исправить наложение подписей скриншотов при первом перелистывании в полноэкранном режиме
|
||||||
- [ ] Добавить поддержку GOG (?)
|
- [ ] Добавить поддержку GOG (?)
|
||||||
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
- [X] Определиться с названием (PortProtonQt или PortProtonQT или вообще третий вариант)
|
||||||
- [ ] Добавить данные с HowLongToBeat на страницу с деталями игры (?)
|
- [X] Добавить данные с HowLongToBeat на страницу с деталями игры
|
||||||
- [X] Добавить виброотдачу на геймпаде при запуске игры
|
- [X] Добавить виброотдачу на геймпаде при запуске игры
|
||||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
|
||||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
|
||||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
|
||||||
- [ ] Доделать светлую тему
|
- [ ] Доделать светлую тему
|
||||||
- [ ] Добавить подсказки к управлению с геймпада
|
- [ ] Добавить подсказки к управлению с геймпада
|
||||||
- [ ] Добавить загрузку звуков в темы например для добавления звука запуска в тему и тд
|
|
||||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
|
||||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
||||||
|
@@ -1,5 +1,4 @@
|
|||||||
version: 1
|
version: 1
|
||||||
|
|
||||||
script:
|
script:
|
||||||
# 1) чистим старый AppDir
|
# 1) чистим старый AppDir
|
||||||
- rm -rf AppDir || true
|
- rm -rf AppDir || true
|
||||||
@@ -14,29 +13,48 @@ script:
|
|||||||
# 5) чистим от ненужных модулей и бинарников
|
# 5) чистим от ненужных модулей и бинарников
|
||||||
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
|
- 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/{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
|
- 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:
|
AppDir:
|
||||||
path: ./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:
|
app_info:
|
||||||
id: ru.linux_gaming.PortProtonQt
|
id: ru.linux_gaming.PortProtonQt
|
||||||
name: PortProtonQt
|
name: PortProtonQt
|
||||||
icon: ru.linux_gaming.PortProtonQt
|
icon: ru.linux_gaming.PortProtonQt
|
||||||
version: 0.1.3
|
version: 0.1.4
|
||||||
exec: usr/bin/python3
|
exec: usr/bin/python3
|
||||||
exec_args: "-m portprotonqt.app $@"
|
exec_args: "-m portprotonqt.app $@"
|
||||||
|
|
||||||
apt:
|
apt:
|
||||||
arch: amd64
|
arch: amd64
|
||||||
sources:
|
sources:
|
||||||
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
|
- 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'
|
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
|
||||||
|
|
||||||
include:
|
include:
|
||||||
- python3
|
- python3-minimal
|
||||||
- python3-pkg-resources
|
- python3-pkg-resources
|
||||||
- libopengl0
|
- libopengl0
|
||||||
- libk5crypto3
|
- libk5crypto3
|
||||||
@@ -45,13 +63,23 @@ AppDir:
|
|||||||
- libxcb-cursor0
|
- libxcb-cursor0
|
||||||
- libimage-exiftool-perl
|
- libimage-exiftool-perl
|
||||||
- xdg-utils
|
- xdg-utils
|
||||||
exclude: []
|
exclude:
|
||||||
|
# Документация и man-страницы
|
||||||
|
- "*-doc"
|
||||||
|
- "*-man"
|
||||||
|
- manpages
|
||||||
|
- mandb
|
||||||
|
# Статические библиотеки
|
||||||
|
- "*-dev"
|
||||||
|
- "*-static"
|
||||||
|
# Дебаг-символы
|
||||||
|
- "*-dbg"
|
||||||
|
- "*-dbgsym"
|
||||||
runtime:
|
runtime:
|
||||||
env:
|
env:
|
||||||
PYTHONHOME: '${APPDIR}/usr'
|
PYTHONHOME: '${APPDIR}/usr'
|
||||||
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
|
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:
|
AppImage:
|
||||||
sign-key: None
|
sign-key: None
|
||||||
arch: x86_64
|
arch: x86_64
|
||||||
|
@@ -1,12 +1,12 @@
|
|||||||
pkgname=portprotonqt
|
pkgname=portprotonqt
|
||||||
pkgver=0.1.3
|
pkgver=0.1.4
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
|
||||||
arch=('any')
|
arch=('any')
|
||||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||||
license=('GPL-3.0')
|
license=('GPL-3.0')
|
||||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
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-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'})
|
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
@@ -6,7 +6,7 @@ arch=('any')
|
|||||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||||
license=('GPL-3.0')
|
license=('GPL-3.0')
|
||||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
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-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'})
|
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
@@ -33,6 +33,7 @@ Requires: python3-babel
|
|||||||
Requires: python3-evdev
|
Requires: python3-evdev
|
||||||
Requires: python3-icoextract
|
Requires: python3-icoextract
|
||||||
Requires: python3-numpy
|
Requires: python3-numpy
|
||||||
|
Requires: python3-websocket-client
|
||||||
Requires: python3-orjson
|
Requires: python3-orjson
|
||||||
Requires: python3-psutil
|
Requires: python3-psutil
|
||||||
Requires: python3-pyside6
|
Requires: python3-pyside6
|
||||||
@@ -44,6 +45,7 @@ Requires: python3-pefile
|
|||||||
Requires: python3-pillow
|
Requires: python3-pillow
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
Requires: python3-beautifulsoup4
|
||||||
|
|
||||||
%description -n python3-%{pypi_name}-git
|
%description -n python3-%{pypi_name}-git
|
||||||
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
%global pypi_name portprotonqt
|
%global pypi_name portprotonqt
|
||||||
%global pypi_version 0.1.3
|
%global pypi_version 0.1.4
|
||||||
%global oname PortProtonQt
|
%global oname PortProtonQt
|
||||||
%global _python_no_extras_requires 1
|
%global _python_no_extras_requires 1
|
||||||
|
|
||||||
@@ -30,6 +30,7 @@ Requires: python3-babel
|
|||||||
Requires: python3-evdev
|
Requires: python3-evdev
|
||||||
Requires: python3-icoextract
|
Requires: python3-icoextract
|
||||||
Requires: python3-numpy
|
Requires: python3-numpy
|
||||||
|
Requires: python3-websocket-client
|
||||||
Requires: python3-orjson
|
Requires: python3-orjson
|
||||||
Requires: python3-psutil
|
Requires: python3-psutil
|
||||||
Requires: python3-pyside6
|
Requires: python3-pyside6
|
||||||
@@ -41,6 +42,7 @@ Requires: python3-pefile
|
|||||||
Requires: python3-pillow
|
Requires: python3-pillow
|
||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
|
Requires: python3-beautifulsoup4
|
||||||
|
|
||||||
%description -n python3-%{pypi_name}
|
%description -n python3-%{pypi_name}
|
||||||
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
||||||
|
@@ -9,7 +9,7 @@ _portprotonqt() {
|
|||||||
esac
|
esac
|
||||||
|
|
||||||
if [[ "$cur" == -* ]]; then
|
if [[ "$cur" == -* ]]; then
|
||||||
COMPREPLY=( $( compgen -W '--fullscreen --session' -- "$cur" ) )
|
COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
|
||||||
return 0
|
return 0
|
||||||
fi
|
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",
|
"normalized_name": "lost ark",
|
||||||
"status": "Broken"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "archeage unchained",
|
"normalized_name": "archeage unchained",
|
||||||
@@ -1405,7 +1405,7 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "wuthering waves",
|
"normalized_name": "wuthering waves",
|
||||||
"status": "Planned"
|
"status": "Running"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"normalized_name": "dota underlords",
|
"normalized_name": "dota underlords",
|
||||||
@@ -4426,5 +4426,61 @@
|
|||||||
{
|
{
|
||||||
"normalized_name": "carx street",
|
"normalized_name": "carx street",
|
||||||
"status": "Broken"
|
"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"
|
||||||
}
|
}
|
||||||
]
|
]
|
13310
data/games_appid.json
@@ -1,4 +1,180 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "no man's sky",
|
||||||
|
"slug": "no-mans-sky"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "alan wake 2",
|
||||||
|
"slug": "alan-wake-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "architect life a house design simulator",
|
||||||
|
"slug": "architect-life-a-house-design-simulator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "clair obscur expedition 33",
|
||||||
|
"slug": "clair-obscur-expedition-33"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "metro 2033 redux",
|
||||||
|
"slug": "metro-2033-redux"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "nova drift",
|
||||||
|
"slug": "nova-drift"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "deathloop",
|
||||||
|
"slug": "deathloop"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "mullet madjack",
|
||||||
|
"slug": "mullet-madjack"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "luma island",
|
||||||
|
"slug": "luma-island"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "cash cleaner simulator",
|
||||||
|
"slug": "cash-cleaner-simulator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "the plucky squire (отважный паж)",
|
||||||
|
"slug": "the-plucky-squire-otvazhnyj-pazh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "crsed cuisine royale",
|
||||||
|
"slug": "crsed-cuisine-royale"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "tainted grail the fall of avalon",
|
||||||
|
"slug": "tainted-grail-the-fall-of-avalon"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "battle of space raiders",
|
||||||
|
"slug": "battle-of-space-raiders"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "gzdoom",
|
||||||
|
"slug": "gzdoom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "rain on your parade",
|
||||||
|
"slug": "rain-on-your-parade"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "партизан (partisan widerstand hinter feindlichen linien)",
|
||||||
|
"slug": "partizan-partisan-widerstand-hinter-feindlichen-linien"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "ebola 2",
|
||||||
|
"slug": "ebola-2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "monster care simulator",
|
||||||
|
"slug": "monster-care-simulator"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "steins;gate the distant valhalla",
|
||||||
|
"slug": "steins-gate-the-distant-valhalla"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "hogwarts legacy",
|
||||||
|
"slug": "hogwarts-legacy"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "osu!",
|
||||||
|
"slug": "osu"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "stalker online (stay out)",
|
||||||
|
"slug": "stalker-online-stay-out"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "slitterhead",
|
||||||
|
"slug": "slitterhead"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "indiana jones and the great circle",
|
||||||
|
"slug": "indiana-jones-and-the-great-circle"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "crossout",
|
||||||
|
"slug": "crossout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "days gone",
|
||||||
|
"slug": "days-gone"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "warcraft iii reforged 2.0",
|
||||||
|
"slug": "warcraft-iii-reforged-2-0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "biomutant",
|
||||||
|
"slug": "biomutant"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"normalized_title": "overwatch 2",
|
||||||
|
"slug": "overwatch-2"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"normalized_title": "settlement survival",
|
"normalized_title": "settlement survival",
|
||||||
"slug": "settlement-survival"
|
"slug": "settlement-survival"
|
||||||
@@ -551,10 +727,6 @@
|
|||||||
"normalized_title": "snowrunner (ранее mudrunner 2)",
|
"normalized_title": "snowrunner (ранее mudrunner 2)",
|
||||||
"slug": "snowrunner-ranee-mudrunner-2"
|
"slug": "snowrunner-ranee-mudrunner-2"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"normalized_title": "alan wake 2",
|
|
||||||
"slug": "alan-wake-2"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"normalized_title": "verse project",
|
"normalized_title": "verse project",
|
||||||
"slug": "verse-project"
|
"slug": "verse-project"
|
||||||
|
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()
|
@@ -5,12 +5,19 @@ import json
|
|||||||
import asyncio
|
import asyncio
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import tarfile
|
import tarfile
|
||||||
|
import ssl
|
||||||
|
|
||||||
# Получаем ключи и данные из переменных окружения
|
# Получаем ключи и данные из переменных окружения
|
||||||
STEAM_KEY = os.environ.get('STEAM_KEY')
|
STEAM_KEY = os.environ.get('STEAM_KEY')
|
||||||
LINUX_GAMING_API_KEY = os.environ.get('LINUX_GAMING_API_KEY')
|
LINUX_GAMING_API_KEY = os.environ.get('LINUX_GAMING_API_KEY')
|
||||||
LINUX_GAMING_API_USERNAME = os.environ.get('LINUX_GAMING_API_USERNAME')
|
LINUX_GAMING_API_USERNAME = os.environ.get('LINUX_GAMING_API_USERNAME')
|
||||||
|
|
||||||
|
# Флаги для включения/отключения источников
|
||||||
|
ENABLE_STEAM = os.environ.get('ENABLE_STEAM', 'true').lower() == 'true'
|
||||||
|
ENABLE_ANTICHEAT = os.environ.get('ENABLE_ANTICHEAT', 'true').lower() == 'true'
|
||||||
|
ENABLE_LINUX_GAMING = os.environ.get('ENABLE_LINUX_GAMING', 'true').lower() == 'true'
|
||||||
|
DEBUG_MODE = os.environ.get('DEBUG_MODE', 'false').lower() == 'true'
|
||||||
|
|
||||||
# Конфигурация API
|
# Конфигурация API
|
||||||
STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
|
STEAM_BASE_URL = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
|
||||||
LINUX_GAMING_BASE_URL = "https://linux-gaming.ru"
|
LINUX_GAMING_BASE_URL = "https://linux-gaming.ru"
|
||||||
@@ -21,6 +28,10 @@ LINUX_GAMING_HEADERS = {
|
|||||||
"Api-Username": LINUX_GAMING_API_USERNAME
|
"Api-Username": LINUX_GAMING_API_USERNAME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Отключаем предупреждения об SSL в дебаг-режиме
|
||||||
|
if DEBUG_MODE:
|
||||||
|
print("DEBUG_MODE enabled: SSL verification is disabled (insecure, use for debugging only).")
|
||||||
|
|
||||||
def normalize_name(s):
|
def normalize_name(s):
|
||||||
"""
|
"""
|
||||||
Приведение строки к нормальному виду:
|
Приведение строки к нормальному виду:
|
||||||
@@ -69,7 +80,7 @@ async def get_app_list(session, last_appid, endpoint):
|
|||||||
url = endpoint
|
url = endpoint
|
||||||
if last_appid:
|
if last_appid:
|
||||||
url = f"{url}&last_appid={last_appid}"
|
url = f"{url}&last_appid={last_appid}"
|
||||||
async with session.get(url) as response:
|
async with session.get(url, verify_ssl=not DEBUG_MODE) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
return await response.json()
|
return await response.json()
|
||||||
|
|
||||||
@@ -79,7 +90,7 @@ async def fetch_games_json(session):
|
|||||||
"""
|
"""
|
||||||
url = "https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/HEAD/games.json"
|
url = "https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/HEAD/games.json"
|
||||||
try:
|
try:
|
||||||
async with session.get(url) as response:
|
async with session.get(url, verify_ssl=not DEBUG_MODE) as response:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
text = await response.text()
|
text = await response.text()
|
||||||
data = json.loads(text)
|
data = json.loads(text)
|
||||||
@@ -89,52 +100,130 @@ async def fetch_games_json(session):
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
async def get_linux_gaming_topics(session, category_slug):
|
async def get_linux_gaming_topics(session, category_slug):
|
||||||
"""
|
|
||||||
Получает все темы из указанной категории linux-gaming.ru.
|
|
||||||
Сохраняет только нормализованное название (normalized_title) и slug.
|
|
||||||
"""
|
|
||||||
page = 0
|
page = 0
|
||||||
all_topics = []
|
all_topics = []
|
||||||
|
max_pages = 100
|
||||||
|
|
||||||
while True:
|
while page < max_pages:
|
||||||
page += 1
|
# Пробуем несколько вариантов URL
|
||||||
url = f"{LINUX_GAMING_BASE_URL}/c/{category_slug}/l/latest.json?page={page}"
|
urls_to_try = [
|
||||||
try:
|
f"{LINUX_GAMING_BASE_URL}/c/{category_slug}/5/l/latest.json", # с id категории
|
||||||
async with session.get(url, headers=LINUX_GAMING_HEADERS) as response:
|
f"{LINUX_GAMING_BASE_URL}/c/{category_slug}/l/latest.json", # только slug
|
||||||
response.raise_for_status()
|
f"{LINUX_GAMING_BASE_URL}/c/5/l/latest.json", # только id
|
||||||
data = await response.json()
|
f"{LINUX_GAMING_BASE_URL}/latest.json" # все темы
|
||||||
topics = data.get("topic_list", {}).get("topics", [])
|
]
|
||||||
if not topics:
|
|
||||||
|
success = False
|
||||||
|
data = None
|
||||||
|
|
||||||
|
for url in urls_to_try:
|
||||||
|
try:
|
||||||
|
# Добавляем параметры пагинации
|
||||||
|
params = {
|
||||||
|
'page': page,
|
||||||
|
'order': 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
async with session.get(url, headers=LINUX_GAMING_HEADERS,
|
||||||
|
params=params, verify_ssl=not DEBUG_MODE) as response:
|
||||||
|
if response.status == 429:
|
||||||
|
print(f"Слишком много запросов на странице {page}, ожидание...")
|
||||||
|
await asyncio.sleep(5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status == 404:
|
||||||
|
if DEBUG_MODE:
|
||||||
|
print(f"URL не найден: {url}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
response.raise_for_status()
|
||||||
|
data = await response.json()
|
||||||
|
|
||||||
|
# Проверяем структуру ответа
|
||||||
|
topic_list = data.get("topic_list", {})
|
||||||
|
topics = topic_list.get("topics", [])
|
||||||
|
|
||||||
|
if not topics:
|
||||||
|
if page == 0:
|
||||||
|
if DEBUG_MODE:
|
||||||
|
print(f"Нет тем в URL: {url}")
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
print(f"Страница {page} пуста, завершаем пагинацию.")
|
||||||
|
return all_topics
|
||||||
|
|
||||||
|
if DEBUG_MODE and page == 0:
|
||||||
|
print(f"Успешно подключились к URL: {url}")
|
||||||
|
|
||||||
|
success = True
|
||||||
break
|
break
|
||||||
for topic in topics:
|
|
||||||
all_topics.append({
|
except Exception as e:
|
||||||
"normalized_title": normalize_name(topic["title"]),
|
if DEBUG_MODE:
|
||||||
"slug": topic["slug"]
|
print(f"Ошибка с URL {url}: {e}")
|
||||||
})
|
continue
|
||||||
print(f"Обработано {len(topics)} тем на странице {page}, всего: {len(all_topics)}.")
|
|
||||||
except Exception as error:
|
if not success:
|
||||||
print(f"Ошибка получения тем для страницы {page}: {error}")
|
print(f"Не удалось загрузить страницу {page}")
|
||||||
break
|
break
|
||||||
|
|
||||||
|
# Обрабатываем темы (этот блок должен быть внутри основного цикла)
|
||||||
|
try:
|
||||||
|
topic_list = data.get("topic_list", {})
|
||||||
|
topics = topic_list.get("topics", [])
|
||||||
|
|
||||||
|
page_topics_added = 0
|
||||||
|
for topic in topics:
|
||||||
|
slug = topic["slug"]
|
||||||
|
|
||||||
|
# Пропускаем тему описания категории
|
||||||
|
if slug is None or slug == "opisanie-kategorii-portprotondb":
|
||||||
|
if DEBUG_MODE:
|
||||||
|
print(f"Пропущена тема описания категории")
|
||||||
|
continue
|
||||||
|
|
||||||
|
normalized_title = normalize_name(topic["title"])
|
||||||
|
|
||||||
|
# Добавляем только валидные темы
|
||||||
|
all_topics.append({
|
||||||
|
"normalized_title": normalized_title,
|
||||||
|
"slug": slug,
|
||||||
|
})
|
||||||
|
page_topics_added += 1
|
||||||
|
|
||||||
|
if DEBUG_MODE and page_topics_added <= 3: # Показываем первые 3 темы
|
||||||
|
print(f"Добавлена тема: {normalized_title} (slug: {slug}")
|
||||||
|
|
||||||
|
print(f"Обработано {len(topics)} тем на странице {page}, добавлено: {page_topics_added}, всего: {len(all_topics)}.")
|
||||||
|
|
||||||
|
# Проверяем, есть ли еще страницы
|
||||||
|
more_topics_url = topic_list.get("more_topics_url")
|
||||||
|
if not more_topics_url:
|
||||||
|
print("Больше тем нет, завершаем пагинацию.")
|
||||||
|
break
|
||||||
|
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
# Добавляем небольшую задержку между запросами
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при обработке тем на странице {page}: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not all_topics:
|
||||||
|
print("Предупреждение: не удалось получить ни одной темы из linux-gaming.ru.")
|
||||||
|
else:
|
||||||
|
print(f"Всего получено {len(all_topics)} тем из категории {category_slug}")
|
||||||
|
|
||||||
return all_topics
|
return all_topics
|
||||||
|
|
||||||
|
|
||||||
async def request_data():
|
async def request_data():
|
||||||
"""
|
"""
|
||||||
Получает данные из Steam, AreWeAntiCheatYet и linux-gaming.ru,
|
Получает данные из Steam, AreWeAntiCheatYet и linux-gaming.ru,
|
||||||
обрабатывает их и сохраняет в JSON-файлы и tar.xz архивы.
|
обрабатывает их и сохраняет в JSON-файлы и tar.xz архивы.
|
||||||
"""
|
"""
|
||||||
# Параметры запроса для Steam
|
|
||||||
game_param = "&include_games=true"
|
|
||||||
dlc_param = "&include_dlc=false"
|
|
||||||
software_param = "&include_software=false"
|
|
||||||
videos_param = "&include_videos=false"
|
|
||||||
hardware_param = "&include_hardware=false"
|
|
||||||
|
|
||||||
endpoint = (
|
|
||||||
f"{STEAM_BASE_URL}key={STEAM_KEY}"
|
|
||||||
f"{game_param}{dlc_param}{software_param}{videos_param}{hardware_param}"
|
|
||||||
f"&max_results=50000"
|
|
||||||
)
|
|
||||||
|
|
||||||
output_json = []
|
output_json = []
|
||||||
total_parsed = 0
|
total_parsed = 0
|
||||||
linux_gaming_topics = []
|
linux_gaming_topics = []
|
||||||
@@ -143,26 +232,48 @@ async def request_data():
|
|||||||
try:
|
try:
|
||||||
async with aiohttp.ClientSession() as session:
|
async with aiohttp.ClientSession() as session:
|
||||||
# Загружаем данные Steam
|
# Загружаем данные Steam
|
||||||
have_more_results = True
|
if ENABLE_STEAM:
|
||||||
last_appid_val = None
|
# Параметры запроса для Steam
|
||||||
while have_more_results:
|
game_param = "&include_games=true"
|
||||||
app_list = await get_app_list(session, last_appid_val, endpoint)
|
dlc_param = "&include_dlc=false"
|
||||||
apps = app_list['response']['apps']
|
software_param = "&include_software=false"
|
||||||
apps = process_steam_apps(apps)
|
videos_param = "&include_videos=false"
|
||||||
output_json.extend(apps)
|
hardware_param = "&include_hardware=false"
|
||||||
total_parsed += len(apps)
|
|
||||||
have_more_results = app_list['response'].get('have_more_results', False)
|
endpoint = (
|
||||||
last_appid_val = app_list['response'].get('last_appid')
|
f"{STEAM_BASE_URL}key={STEAM_KEY}"
|
||||||
print(f"Обработано {len(apps)} игр Steam, всего: {total_parsed}.")
|
f"{game_param}{dlc_param}{software_param}{videos_param}{hardware_param}"
|
||||||
|
f"&max_results=50000"
|
||||||
|
)
|
||||||
|
|
||||||
|
have_more_results = True
|
||||||
|
last_appid_val = None
|
||||||
|
while have_more_results:
|
||||||
|
app_list = await get_app_list(session, last_appid_val, endpoint)
|
||||||
|
apps = app_list['response']['apps']
|
||||||
|
apps = process_steam_apps(apps)
|
||||||
|
output_json.extend(apps)
|
||||||
|
total_parsed += len(apps)
|
||||||
|
have_more_results = app_list['response'].get('have_more_results', False)
|
||||||
|
last_appid_val = app_list['response'].get('last_appid')
|
||||||
|
print(f"Обработано {len(apps)} игр Steam, всего: {total_parsed}.")
|
||||||
|
else:
|
||||||
|
print("Пропущена загрузка данных Steam (ENABLE_STEAM=false).")
|
||||||
|
|
||||||
# Загружаем данные AreWeAntiCheatYet
|
# Загружаем данные AreWeAntiCheatYet
|
||||||
anticheat_games = await fetch_games_json(session)
|
if ENABLE_ANTICHEAT:
|
||||||
|
anticheat_games = await fetch_games_json(session)
|
||||||
|
else:
|
||||||
|
print("Пропущена загрузка данных AreWeAntiCheatYet (ENABLE_ANTICHEAT=false).")
|
||||||
|
|
||||||
# Загружаем данные linux-gaming.ru
|
# Загружаем данные linux-gaming.ru
|
||||||
if LINUX_GAMING_API_KEY and LINUX_GAMING_API_USERNAME:
|
if ENABLE_LINUX_GAMING:
|
||||||
linux_gaming_topics = await get_linux_gaming_topics(session, CATEGORY_LINUX_GAMING)
|
if LINUX_GAMING_API_KEY and LINUX_GAMING_API_USERNAME:
|
||||||
|
linux_gaming_topics = await get_linux_gaming_topics(session, CATEGORY_LINUX_GAMING)
|
||||||
|
else:
|
||||||
|
print("Предупреждение: LINUX_GAMING_API_KEY или LINUX_GAMING_API_USERNAME не установлены.")
|
||||||
else:
|
else:
|
||||||
print("Предупреждение: LINUX_GAMING_API_KEY или LINUX_GAMING_API_USERNAME не установлены.")
|
print("Пропущена загрузка данных linux-gaming.ru (ENABLE_LINUX_GAMING=false).")
|
||||||
|
|
||||||
except Exception as error:
|
except Exception as error:
|
||||||
print(f"Ошибка получения данных: {error}")
|
print(f"Ошибка получения данных: {error}")
|
||||||
@@ -173,55 +284,55 @@ async def request_data():
|
|||||||
os.makedirs(data_dir, exist_ok=True)
|
os.makedirs(data_dir, exist_ok=True)
|
||||||
|
|
||||||
# Сохранение данных Steam
|
# Сохранение данных Steam
|
||||||
output_json_full = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.json")
|
if ENABLE_STEAM and output_json:
|
||||||
output_json_min = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid_min.json")
|
output_json_full = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.json")
|
||||||
with open(output_json_full, "w", encoding="utf-8") as f:
|
output_json_min = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid_min.json")
|
||||||
json.dump(output_json, f, ensure_ascii=False, indent=2)
|
with open(output_json_full, "w", encoding="utf-8") as f:
|
||||||
with open(output_json_min, "w", encoding="utf-8") as f:
|
json.dump(output_json, f, ensure_ascii=False, indent=2)
|
||||||
json.dump(output_json, f, ensure_ascii=False, separators=(',',':'))
|
with open(output_json_min, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(output_json, f, ensure_ascii=False, separators=(',',':'))
|
||||||
|
|
||||||
|
# Упаковка минифицированного JSON Steam в tar.xz архив
|
||||||
|
steam_archive_path = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.tar.xz")
|
||||||
|
try:
|
||||||
|
with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar:
|
||||||
|
tar.add(output_json_min, arcname=os.path.basename(output_json_min))
|
||||||
|
print(f"Упаковано минифицированное JSON Steam в архив: {steam_archive_path}")
|
||||||
|
os.remove(output_json_min)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при упаковке архива Steam: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
# Сохранение данных AreWeAntiCheatYet
|
# Сохранение данных AreWeAntiCheatYet
|
||||||
anticheat_json_full = os.path.join(data_dir, "anticheat_games.json")
|
if ENABLE_ANTICHEAT and anticheat_games:
|
||||||
anticheat_json_min = os.path.join(data_dir, "anticheat_games_min.json")
|
anticheat_json_full = os.path.join(data_dir, "anticheat_games.json")
|
||||||
with open(anticheat_json_full, "w", encoding="utf-8") as f:
|
anticheat_json_min = os.path.join(data_dir, "anticheat_games_min.json")
|
||||||
json.dump(anticheat_games, f, ensure_ascii=False, indent=2)
|
with open(anticheat_json_full, "w", encoding="utf-8") as f:
|
||||||
with open(anticheat_json_min, "w", encoding="utf-8") as f:
|
json.dump(anticheat_games, f, ensure_ascii=False, indent=2)
|
||||||
json.dump(anticheat_games, f, ensure_ascii=False, separators=(',',':'))
|
with open(anticheat_json_min, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(anticheat_games, f, ensure_ascii=False, separators=(',',':'))
|
||||||
|
|
||||||
|
# Упаковка минифицированного JSON AreWeAntiCheatYet в tar.xz архив
|
||||||
|
anticheat_archive_path = os.path.join(data_dir, "anticheat_games.tar.xz")
|
||||||
|
try:
|
||||||
|
with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar:
|
||||||
|
tar.add(anticheat_json_min, arcname=os.path.basename(anticheat_json_min))
|
||||||
|
print(f"Упаковано минифицированное JSON AreWeAntiCheatYet в архив: {anticheat_archive_path}")
|
||||||
|
os.remove(anticheat_json_min)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
# Сохранение данных linux-gaming.ru
|
# Сохранение данных linux-gaming.ru
|
||||||
linux_gaming_json_full = os.path.join(data_dir, "linux_gaming_topics.json")
|
if ENABLE_LINUX_GAMING and linux_gaming_topics:
|
||||||
linux_gaming_json_min = os.path.join(data_dir, "linux_gaming_topics_min.json")
|
linux_gaming_json_full = os.path.join(data_dir, "linux_gaming_topics.json")
|
||||||
if linux_gaming_topics:
|
linux_gaming_json_min = os.path.join(data_dir, "linux_gaming_topics_min.json")
|
||||||
with open(linux_gaming_json_full, "w", encoding="utf-8") as f:
|
with open(linux_gaming_json_full, "w", encoding="utf-8") as f:
|
||||||
json.dump(linux_gaming_topics, f, ensure_ascii=False, indent=2)
|
json.dump(linux_gaming_topics, f, ensure_ascii=False, indent=2)
|
||||||
with open(linux_gaming_json_min, "w", encoding="utf-8") as f:
|
with open(linux_gaming_json_min, "w", encoding="utf-8") as f:
|
||||||
json.dump(linux_gaming_topics, f, ensure_ascii=False, separators=(',',':'))
|
json.dump(linux_gaming_topics, f, ensure_ascii=False, separators=(',',':'))
|
||||||
|
|
||||||
# Упаковка минифицированных JSON в tar.xz архивы
|
# Упаковка минифицированного JSON linux-gaming.ru в tar.xz архив
|
||||||
# Архив для Steam
|
|
||||||
steam_archive_path = os.path.join(data_dir, f"{CATEGORY_STEAM}_appid.tar.xz")
|
|
||||||
try:
|
|
||||||
with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar:
|
|
||||||
tar.add(output_json_min, arcname=os.path.basename(output_json_min))
|
|
||||||
print(f"Упаковано минифицированное JSON Steam в архив: {steam_archive_path}")
|
|
||||||
os.remove(output_json_min)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка при упаковке архива Steam: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Архив для AreWeAntiCheatYet
|
|
||||||
anticheat_archive_path = os.path.join(data_dir, "anticheat_games.tar.xz")
|
|
||||||
try:
|
|
||||||
with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar:
|
|
||||||
tar.add(anticheat_json_min, arcname=os.path.basename(anticheat_json_min))
|
|
||||||
print(f"Упаковано минифицированное JSON AreWeAntiCheatYet в архив: {anticheat_archive_path}")
|
|
||||||
os.remove(anticheat_json_min)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
# Архив для linux-gaming.ru
|
|
||||||
if linux_gaming_topics:
|
|
||||||
linux_gaming_archive_path = os.path.join(data_dir, "linux_gaming_topics.tar.xz")
|
linux_gaming_archive_path = os.path.join(data_dir, "linux_gaming_topics.tar.xz")
|
||||||
try:
|
try:
|
||||||
with tarfile.open(linux_gaming_archive_path, "w:xz", preset=9) as tar:
|
with tarfile.open(linux_gaming_archive_path, "w:xz", preset=9) as tar:
|
||||||
|
@@ -3,10 +3,11 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Contents
|
## 📋 Contents
|
||||||
- [Overview](#overview)
|
- [Overview](#-overview)
|
||||||
- [Adding a New Translation](#adding-a-new-translation)
|
- [Adding a New Translation](#-adding-a-new-translation)
|
||||||
- [Updating Existing Translations](#updating-existing-translations)
|
- [Updating Existing Translations](#-updating-existing-translations)
|
||||||
- [Compiling Translations](#compiling-translations)
|
- [Compiling Translations](#-compiling-translations)
|
||||||
|
- [Spell Check](#-spell-check)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,9 +21,9 @@ Current translation status:
|
|||||||
|
|
||||||
| Locale | Progress | Translated |
|
| Locale | Progress | Translated |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 194 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 195 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 194 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 195 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 of 194 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 of 195 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -3,10 +3,11 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Содержание
|
## 📋 Содержание
|
||||||
- [Обзор](#обзор)
|
- [Обзор](#-обзор)
|
||||||
- [Добавление нового перевода](#добавление-нового-перевода)
|
- [Добавление нового перевода](#-добавление-нового-перевода)
|
||||||
- [Обновление существующих переводов](#обновление-существующих-переводов)
|
- [Обновление существующих переводов](#-обновление-существующих-переводов)
|
||||||
- [Компиляция переводов](#компиляция-переводов)
|
- [Компиляция переводов](#-компиляция-переводов)
|
||||||
|
- [Проверка орфографии](#-проверка-орфографии)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -20,9 +21,9 @@
|
|||||||
|
|
||||||
| Локаль | Прогресс | Переведено |
|
| Локаль | Прогресс | Переведено |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 194 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 194 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 194 из 194 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 из 195 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -3,15 +3,10 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Contents
|
## 📋 Contents
|
||||||
- [Overview](#overview)
|
- [Overview](#-overview)
|
||||||
- [How It Works](#how-it-works)
|
- [How It Works](#-how-it-works)
|
||||||
- [Data Priorities](#data-priorities)
|
- [For Users](#-for-users)
|
||||||
- [File Structure](#file-structure)
|
- [For Developers](#-for-developers)
|
||||||
- [For Users](#for-users)
|
|
||||||
- [Creating User Overrides](#creating-user-overrides)
|
|
||||||
- [Example](#example)
|
|
||||||
- [For Developers](#for-developers)
|
|
||||||
- [Adding Built-In Overrides](#adding-built-in-overrides)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -3,15 +3,10 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Содержание
|
## 📋 Содержание
|
||||||
- [Обзор](#обзор)
|
- [Обзор](#-обзор)
|
||||||
- [Как это работает](#как-это-работает)
|
- [Как это работает](#-как-это-работает)
|
||||||
- [Приоритеты данных](#приоритеты-данных)
|
- [Для пользователей](#-для-пользователей)
|
||||||
- [Структура файлов](#структура-файлов)
|
- [Для разработчиков](#-для-разработчиков)
|
||||||
- [Для пользователей](#для-пользователей)
|
|
||||||
- [Создание пользовательских переопределений](#создание-пользовательских-переопределений)
|
|
||||||
- [Пример](#пример)
|
|
||||||
- [Для разработчиков](#для-разработчиков)
|
|
||||||
- [Добавление встроенных переопределений](#добавление-встроенных-переопределений)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -3,12 +3,13 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 📋 Contents
|
## 📋 Contents
|
||||||
- [Overview](#overview)
|
- [Overview](#-overview)
|
||||||
- [Creating the Theme Folder](#creating-the-theme-folder)
|
- [Creating the Theme Folder](#-creating-the-theme-folder)
|
||||||
- [Style File](#style-file)
|
- [Style File](#-style-file-stylespy)
|
||||||
- [Metadata](#metadata)
|
- [Animation configuration](#-animation-configuration)
|
||||||
- [Screenshots](#screenshots)
|
- [Metadata](#-metadata-metainfoini)
|
||||||
- [Fonts and Icons](#fonts-and-icons)
|
- [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`)
|
## 📝 Metadata (`metainfo.ini`)
|
||||||
|
|
||||||
```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`)
|
## 📝 Метаинформация (`metainfo.ini`)
|
||||||
|
|
||||||
```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,12 +1,9 @@
|
|||||||
import sys
|
import sys
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
from PySide6.QtGui import QIcon
|
from PySide6.QtGui import QIcon
|
||||||
from portprotonqt.main_window import MainWindow
|
from portprotonqt.main_window import MainWindow
|
||||||
from portprotonqt.tray import SystemTray
|
from portprotonqt.config_utils import save_fullscreen_config
|
||||||
from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config
|
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
from portprotonqt.cli import parse_args
|
from portprotonqt.cli import parse_args
|
||||||
|
|
||||||
@@ -14,7 +11,7 @@ logger = get_logger(__name__)
|
|||||||
|
|
||||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||||
__app_name__ = "PortProtonQt"
|
__app_name__ = "PortProtonQt"
|
||||||
__app_version__ = "0.1.3"
|
__app_version__ = "0.1.4"
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
@@ -35,47 +32,18 @@ def main():
|
|||||||
|
|
||||||
window = MainWindow()
|
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:
|
if args.fullscreen:
|
||||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||||
save_fullscreen_config(True)
|
save_fullscreen_config(True)
|
||||||
window.showFullScreen()
|
window.showFullScreen()
|
||||||
|
|
||||||
current_theme_name = read_theme_from_config()
|
|
||||||
tray = SystemTray(app, current_theme_name)
|
|
||||||
tray.show_action.triggered.connect(window.show)
|
|
||||||
tray.hide_action.triggered.connect(window.hide)
|
|
||||||
|
|
||||||
def recreate_tray():
|
|
||||||
nonlocal tray
|
|
||||||
if tray:
|
|
||||||
logger.debug("Recreating system tray")
|
|
||||||
tray.cleanup()
|
|
||||||
tray = None
|
|
||||||
current_theme = read_theme_from_config()
|
|
||||||
tray = SystemTray(app, current_theme)
|
|
||||||
# Ensure window is not None before connecting signals
|
|
||||||
if window:
|
|
||||||
tray.show_action.triggered.connect(window.show)
|
|
||||||
tray.hide_action.triggered.connect(window.hide)
|
|
||||||
|
|
||||||
def cleanup_on_exit():
|
def cleanup_on_exit():
|
||||||
nonlocal tray, window
|
nonlocal window
|
||||||
app.aboutToQuit.disconnect()
|
app.aboutToQuit.disconnect()
|
||||||
if tray:
|
|
||||||
tray.cleanup()
|
|
||||||
tray = None
|
|
||||||
if window:
|
if window:
|
||||||
window.close()
|
window.close()
|
||||||
app.quit()
|
app.quit()
|
||||||
|
|
||||||
window.settings_saved.connect(recreate_tray)
|
|
||||||
app.aboutToQuit.connect(cleanup_on_exit)
|
app.aboutToQuit.connect(cleanup_on_exit)
|
||||||
|
|
||||||
window.show()
|
window.show()
|
||||||
|
@@ -13,9 +13,4 @@ def parse_args():
|
|||||||
action="store_true",
|
action="store_true",
|
||||||
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
|
||||||
"--session",
|
|
||||||
action="store_true",
|
|
||||||
help="Запустить приложение с использованием gamescope"
|
|
||||||
)
|
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
@@ -549,3 +549,41 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
|
|||||||
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
|
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
|
||||||
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
cp.write(configfile)
|
cp.write(configfile)
|
||||||
|
|
||||||
|
def read_favorite_folders():
|
||||||
|
"""
|
||||||
|
Читает список избранных папок из секции [FavoritesFolders] конфигурационного файла.
|
||||||
|
Список хранится как строка, заключённая в кавычки, с путями, разделёнными запятыми.
|
||||||
|
Если секция или параметр отсутствуют, возвращает пустой список.
|
||||||
|
"""
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
try:
|
||||||
|
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Ошибка чтения конфига: %s", e)
|
||||||
|
return []
|
||||||
|
if cp.has_section("FavoritesFolders") and cp.has_option("FavoritesFolders", "folders"):
|
||||||
|
favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
|
||||||
|
if favs.startswith('"') and favs.endswith('"'):
|
||||||
|
favs = favs[1:-1]
|
||||||
|
return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
|
||||||
|
return []
|
||||||
|
|
||||||
|
def save_favorite_folders(folders):
|
||||||
|
"""
|
||||||
|
Сохраняет список избранных папок в секцию [FavoritesFolders] конфигурационного файла.
|
||||||
|
Список сохраняется как строка, заключённая в двойные кавычки, где пути разделены запятыми.
|
||||||
|
"""
|
||||||
|
cp = configparser.ConfigParser()
|
||||||
|
if os.path.exists(CONFIG_FILE):
|
||||||
|
try:
|
||||||
|
cp.read(CONFIG_FILE, encoding="utf-8")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Ошибка чтения конфига: %s", e)
|
||||||
|
if "FavoritesFolders" not in cp:
|
||||||
|
cp["FavoritesFolders"] = {}
|
||||||
|
fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
|
||||||
|
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
|
||||||
|
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
|
||||||
|
cp.write(configfile)
|
||||||
|
@@ -12,7 +12,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
|
|||||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
||||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||||
from portprotonqt.localization import _
|
from portprotonqt.localization import _
|
||||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
|
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
|
||||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
||||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
|
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
|
||||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
|
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
|
||||||
@@ -148,10 +148,85 @@ class ContextMenuManager:
|
|||||||
return False
|
return False
|
||||||
current_exe = os.path.basename(exe_path)
|
current_exe = os.path.basename(exe_path)
|
||||||
|
|
||||||
# Check if the current_exe matches the target_exe in MainWindow
|
return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
|
||||||
if hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe:
|
|
||||||
return True
|
def show_folder_context_menu(self, file_explorer, pos):
|
||||||
return False
|
"""Shows the context menu for a folder in FileExplorer."""
|
||||||
|
try:
|
||||||
|
item = file_explorer.file_list.itemAt(pos)
|
||||||
|
if not item:
|
||||||
|
logger.debug("No item selected at position %s", pos)
|
||||||
|
return
|
||||||
|
selected = item.text()
|
||||||
|
if not selected.endswith("/"):
|
||||||
|
logger.debug("Selected item is not a folder: %s", selected)
|
||||||
|
return # Only for folders
|
||||||
|
full_path = os.path.normpath(os.path.join(file_explorer.current_path, selected.rstrip("/")))
|
||||||
|
if not os.path.isdir(full_path):
|
||||||
|
logger.debug("Path is not a directory: %s", full_path)
|
||||||
|
return
|
||||||
|
|
||||||
|
menu = QMenu(file_explorer)
|
||||||
|
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
||||||
|
menu.setParent(file_explorer, Qt.WindowType.Popup) # Set transientParent for Wayland
|
||||||
|
|
||||||
|
favorite_folders = read_favorite_folders()
|
||||||
|
is_favorite = full_path in favorite_folders
|
||||||
|
action_text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
|
||||||
|
favorite_action = menu.addAction(self._get_safe_icon("star" if is_favorite else "star_full"), action_text)
|
||||||
|
favorite_action.triggered.connect(lambda: self.toggle_favorite_folder(file_explorer, full_path, not is_favorite))
|
||||||
|
|
||||||
|
# Disconnect file_list signals to prevent navigation during menu interaction
|
||||||
|
try:
|
||||||
|
file_explorer.file_list.itemClicked.disconnect(file_explorer.handle_item_click)
|
||||||
|
file_explorer.file_list.itemDoubleClicked.disconnect(file_explorer.handle_item_double_click)
|
||||||
|
except TypeError:
|
||||||
|
pass # Signals may not be connected
|
||||||
|
|
||||||
|
# Reconnect signals after menu closes
|
||||||
|
def reconnect_signals():
|
||||||
|
try:
|
||||||
|
file_explorer.file_list.itemClicked.connect(file_explorer.handle_item_click)
|
||||||
|
file_explorer.file_list.itemDoubleClicked.connect(file_explorer.handle_item_double_click)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error reconnecting file list signals: %s", e)
|
||||||
|
|
||||||
|
menu.aboutToHide.connect(reconnect_signals)
|
||||||
|
|
||||||
|
# Set focus to the first menu item
|
||||||
|
actions = menu.actions()
|
||||||
|
if actions:
|
||||||
|
menu.setActiveAction(actions[0])
|
||||||
|
|
||||||
|
# Map local position to global for menu display
|
||||||
|
global_pos = file_explorer.file_list.mapToGlobal(pos)
|
||||||
|
menu.exec(global_pos)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error showing folder context menu: %s", e)
|
||||||
|
|
||||||
|
def toggle_favorite_folder(self, file_explorer, folder_path, add):
|
||||||
|
"""Adds or removes a folder from favorites."""
|
||||||
|
favorite_folders = read_favorite_folders()
|
||||||
|
if add:
|
||||||
|
if folder_path not in favorite_folders:
|
||||||
|
favorite_folders.append(folder_path)
|
||||||
|
save_favorite_folders(favorite_folders)
|
||||||
|
logger.info(f"Folder added to favorites: {folder_path}")
|
||||||
|
else:
|
||||||
|
if folder_path in favorite_folders:
|
||||||
|
favorite_folders.remove(folder_path)
|
||||||
|
save_favorite_folders(favorite_folders)
|
||||||
|
logger.info(f"Folder removed from favorites: {folder_path}")
|
||||||
|
file_explorer.update_drives_list()
|
||||||
|
|
||||||
|
def _get_safe_icon(self, icon_name: str) -> QIcon:
|
||||||
|
"""Returns a QIcon, ensuring it is valid."""
|
||||||
|
icon = self.theme_manager.get_icon(icon_name)
|
||||||
|
if isinstance(icon, QIcon):
|
||||||
|
return icon
|
||||||
|
elif isinstance(icon, str) and os.path.exists(icon):
|
||||||
|
return QIcon(icon)
|
||||||
|
return QIcon()
|
||||||
|
|
||||||
def show_context_menu(self, game_card, pos: QPoint):
|
def show_context_menu(self, game_card, pos: QPoint):
|
||||||
"""
|
"""
|
||||||
@@ -161,23 +236,25 @@ class ContextMenuManager:
|
|||||||
game_card: The GameCard instance requesting the context menu.
|
game_card: The GameCard instance requesting the context menu.
|
||||||
pos: The position (in widget coordinates) where the menu should appear.
|
pos: The position (in widget coordinates) where the menu should appear.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_safe_icon(icon_name: str) -> QIcon:
|
|
||||||
icon = self.theme_manager.get_icon(icon_name)
|
|
||||||
if isinstance(icon, QIcon):
|
|
||||||
return icon
|
|
||||||
elif isinstance(icon, str) and os.path.exists(icon):
|
|
||||||
return QIcon(icon)
|
|
||||||
return QIcon()
|
|
||||||
|
|
||||||
menu = QMenu(self.parent)
|
menu = QMenu(self.parent)
|
||||||
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
|
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(self._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)
|
is_running = self._is_game_running(game_card)
|
||||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||||
action_icon = "stop" if is_running else "play"
|
action_icon = "stop" if is_running else "play"
|
||||||
launch_action = menu.addAction(get_safe_icon(action_icon), action_text)
|
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
|
||||||
launch_action.triggered.connect(
|
launch_action.triggered.connect(
|
||||||
lambda: self._launch_game(game_card)
|
lambda: self._launch_game(game_card)
|
||||||
)
|
)
|
||||||
@@ -186,11 +263,11 @@ class ContextMenuManager:
|
|||||||
is_favorite = game_card.name in favorites
|
is_favorite = game_card.name in favorites
|
||||||
icon_name = "star_full" if is_favorite else "star"
|
icon_name = "star_full" if is_favorite else "star"
|
||||||
text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
|
text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
|
||||||
favorite_action = menu.addAction(get_safe_icon(icon_name), text)
|
favorite_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||||
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
|
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
|
||||||
|
|
||||||
if game_card.game_source == "epic":
|
if game_card.game_source == "epic":
|
||||||
import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary"))
|
import_action = menu.addAction(self._get_safe_icon("epic_games"), _("Import to Legendary"))
|
||||||
import_action.triggered.connect(
|
import_action.triggered.connect(
|
||||||
lambda: self.import_to_legendary(game_card.name, game_card.appid)
|
lambda: self.import_to_legendary(game_card.name, game_card.appid)
|
||||||
)
|
)
|
||||||
@@ -198,13 +275,13 @@ class ContextMenuManager:
|
|||||||
is_in_steam = is_game_in_steam(game_card.name)
|
is_in_steam = is_game_in_steam(game_card.name)
|
||||||
icon_name = "delete" if is_in_steam else "steam"
|
icon_name = "delete" if is_in_steam else "steam"
|
||||||
text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||||
steam_action = menu.addAction(get_safe_icon(icon_name), text)
|
steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||||
steam_action.triggered.connect(
|
steam_action.triggered.connect(
|
||||||
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||||
if is_in_steam
|
if is_in_steam
|
||||||
else self.add_egs_to_steam(game_card.name, game_card.appid)
|
else self.add_egs_to_steam(game_card.name, game_card.appid)
|
||||||
)
|
)
|
||||||
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
|
open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||||
open_folder_action.triggered.connect(
|
open_folder_action.triggered.connect(
|
||||||
lambda: self.open_egs_game_folder(game_card.appid)
|
lambda: self.open_egs_game_folder(game_card.appid)
|
||||||
)
|
)
|
||||||
@@ -212,7 +289,7 @@ class ContextMenuManager:
|
|||||||
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||||
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
||||||
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
|
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
|
||||||
desktop_action = menu.addAction(get_safe_icon(icon_name), text)
|
desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||||
desktop_action.triggered.connect(
|
desktop_action.triggered.connect(
|
||||||
lambda: self.remove_egs_from_desktop(game_card.name)
|
lambda: self.remove_egs_from_desktop(game_card.name)
|
||||||
if os.path.exists(desktop_path)
|
if os.path.exists(desktop_path)
|
||||||
@@ -221,7 +298,7 @@ class ContextMenuManager:
|
|||||||
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
|
||||||
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||||
menu_action = menu.addAction(
|
menu_action = menu.addAction(
|
||||||
get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
|
self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
|
||||||
_("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
|
_("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
|
||||||
)
|
)
|
||||||
menu_action.triggered.connect(
|
menu_action.triggered.connect(
|
||||||
@@ -235,19 +312,19 @@ class ContextMenuManager:
|
|||||||
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
|
||||||
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
|
||||||
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
|
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
|
||||||
desktop_action = menu.addAction(get_safe_icon(icon_name), text)
|
desktop_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||||
desktop_action.triggered.connect(
|
desktop_action.triggered.connect(
|
||||||
lambda: self.remove_from_desktop(game_card.name)
|
lambda: self.remove_from_desktop(game_card.name)
|
||||||
if os.path.exists(desktop_path)
|
if os.path.exists(desktop_path)
|
||||||
else self.add_to_desktop(game_card.name, game_card.exec_line)
|
else self.add_to_desktop(game_card.name, game_card.exec_line)
|
||||||
)
|
)
|
||||||
edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut"))
|
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut"))
|
||||||
edit_action.triggered.connect(
|
edit_action.triggered.connect(
|
||||||
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
|
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
|
||||||
)
|
)
|
||||||
delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
|
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton"))
|
||||||
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
|
||||||
open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
|
open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
|
||||||
open_folder_action.triggered.connect(
|
open_folder_action.triggered.connect(
|
||||||
lambda: self.open_game_folder(game_card.name, game_card.exec_line)
|
lambda: self.open_game_folder(game_card.name, game_card.exec_line)
|
||||||
)
|
)
|
||||||
@@ -255,7 +332,7 @@ class ContextMenuManager:
|
|||||||
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
|
||||||
icon_name = "delete" if os.path.exists(menu_path) else "menu"
|
icon_name = "delete" if os.path.exists(menu_path) else "menu"
|
||||||
text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
|
text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
|
||||||
menu_action = menu.addAction(get_safe_icon(icon_name), text)
|
menu_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||||
menu_action.triggered.connect(
|
menu_action.triggered.connect(
|
||||||
lambda: self.remove_from_menu(game_card.name)
|
lambda: self.remove_from_menu(game_card.name)
|
||||||
if os.path.exists(menu_path)
|
if os.path.exists(menu_path)
|
||||||
@@ -264,7 +341,7 @@ class ContextMenuManager:
|
|||||||
is_in_steam = is_game_in_steam(game_card.name)
|
is_in_steam = is_game_in_steam(game_card.name)
|
||||||
icon_name = "delete" if is_in_steam else "steam"
|
icon_name = "delete" if is_in_steam else "steam"
|
||||||
text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
|
||||||
steam_action = menu.addAction(get_safe_icon(icon_name), text)
|
steam_action = menu.addAction(self._get_safe_icon(icon_name), text)
|
||||||
steam_action.triggered.connect(
|
steam_action.triggered.connect(
|
||||||
lambda: (
|
lambda: (
|
||||||
self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
|
||||||
@@ -273,7 +350,12 @@ class ContextMenuManager:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
menu.exec(game_card.mapToGlobal(pos))
|
# Set focus to the first menu item
|
||||||
|
actions = menu.actions()
|
||||||
|
if actions:
|
||||||
|
menu.setActiveAction(actions[0])
|
||||||
|
|
||||||
|
menu.exec(game_card.mapToGlobal(pos))
|
||||||
|
|
||||||
def _launch_game(self, game_card):
|
def _launch_game(self, game_card):
|
||||||
"""
|
"""
|
||||||
@@ -410,7 +492,7 @@ class ContextMenuManager:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Используем FileExplorer с directory_only=True
|
# Use FileExplorer with directory_only=True
|
||||||
file_explorer = FileExplorer(
|
file_explorer = FileExplorer(
|
||||||
parent=self.parent,
|
parent=self.parent,
|
||||||
theme=self.theme,
|
theme=self.theme,
|
||||||
@@ -440,10 +522,10 @@ class ContextMenuManager:
|
|||||||
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
|
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
|
||||||
threading.Thread(target=run_import, daemon=True).start()
|
threading.Thread(target=run_import, daemon=True).start()
|
||||||
|
|
||||||
# Подключаем сигнал выбора файла/папки
|
# Connect the file selection signal
|
||||||
file_explorer.file_signal.file_selected.connect(on_folder_selected)
|
file_explorer.file_signal.file_selected.connect(on_folder_selected)
|
||||||
|
|
||||||
# Центрируем FileExplorer относительно родительского виджета
|
# Center FileExplorer relative to the parent widget
|
||||||
parent_widget = self.parent
|
parent_widget = self.parent
|
||||||
if parent_widget:
|
if parent_widget:
|
||||||
parent_geometry = parent_widget.geometry()
|
parent_geometry = parent_widget.geometry()
|
||||||
@@ -697,15 +779,12 @@ Icon={icon_path}
|
|||||||
return None
|
return None
|
||||||
return exec_line
|
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."""
|
"""Parse the executable path from exec_line."""
|
||||||
try:
|
try:
|
||||||
entry_exec_split = shlex.split(exec_line)
|
entry_exec_split = shlex.split(exec_line)
|
||||||
if not entry_exec_split:
|
if not entry_exec_split:
|
||||||
self.signals.show_warning_dialog.emit(
|
logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
|
||||||
_("Error"),
|
|
||||||
_("Invalid executable command: {exec_line}").format(exec_line=exec_line)
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
|
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
|
||||||
exe_path = entry_exec_split[2]
|
exe_path = entry_exec_split[2]
|
||||||
@@ -714,17 +793,11 @@ Icon={icon_path}
|
|||||||
else:
|
else:
|
||||||
exe_path = entry_exec_split[-1]
|
exe_path = entry_exec_split[-1]
|
||||||
if not exe_path or not os.path.exists(exe_path):
|
if not exe_path or not os.path.exists(exe_path):
|
||||||
self.signals.show_warning_dialog.emit(
|
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
|
||||||
_("Error"),
|
|
||||||
_("Executable not found: {path}").format(path=exe_path or "None")
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
return exe_path
|
return exe_path
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.signals.show_warning_dialog.emit(
|
logger.debug("Failed to parse executable for '%s': %s", game_name, e)
|
||||||
_("Error"),
|
|
||||||
_("Failed to parse executable: {error}").format(error=str(e))
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
|
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
|
||||||
@@ -786,7 +859,7 @@ Icon={icon_path}
|
|||||||
_("Failed to delete custom data: {error}").format(error=str(e))
|
_("Failed to delete custom data: {error}").format(error=str(e))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Перезагрузка списка игр и обновление сетки
|
# Reload games list and update grid
|
||||||
self.load_games()
|
self.load_games()
|
||||||
self.update_game_grid()
|
self.update_game_grid()
|
||||||
|
|
||||||
|
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.
|
|
@@ -9,7 +9,7 @@ from PySide6.QtWidgets import (
|
|||||||
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
|
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
|
||||||
from icoextract import IconExtractor, IconExtractorError
|
from icoextract import IconExtractor, IconExtractorError
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
from portprotonqt.config_utils import get_portproton_location
|
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders
|
||||||
from portprotonqt.localization import _
|
from portprotonqt.localization import _
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
import portprotonqt.themes.standart.styles as default_styles
|
import portprotonqt.themes.standart.styles as default_styles
|
||||||
@@ -106,13 +106,15 @@ class FileExplorer(QDialog):
|
|||||||
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
self.setWindowModality(Qt.WindowModality.ApplicationModal)
|
||||||
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
|
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
|
||||||
|
|
||||||
# Find InputManager from parent
|
# Find InputManager and ContextMenuManager from parent
|
||||||
self.input_manager = None
|
self.input_manager = None
|
||||||
|
self.context_menu_manager = None
|
||||||
parent = self.parent()
|
parent = self.parent()
|
||||||
while parent:
|
while parent:
|
||||||
if hasattr(parent, 'input_manager'):
|
if hasattr(parent, 'input_manager'):
|
||||||
self.input_manager = cast("MainWindow", parent).input_manager
|
self.input_manager = cast("MainWindow", parent).input_manager
|
||||||
break
|
if hasattr(parent, 'context_menu_manager'):
|
||||||
|
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
|
||||||
parent = parent.parent()
|
parent = parent.parent()
|
||||||
|
|
||||||
if self.input_manager:
|
if self.input_manager:
|
||||||
@@ -137,8 +139,9 @@ class FileExplorer(QDialog):
|
|||||||
if len(parts) < 2:
|
if len(parts) < 2:
|
||||||
continue
|
continue
|
||||||
mount_point = parts[1]
|
mount_point = parts[1]
|
||||||
# Исключаем системные и временные пути
|
# Исключаем системные и временные пути, но сохраняем /run/media
|
||||||
if mount_point.startswith(('/run', '/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')):
|
if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
|
||||||
|
(mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
|
||||||
continue
|
continue
|
||||||
# Проверяем, является ли точка монтирования директорией и доступна ли она
|
# Проверяем, является ли точка монтирования директорией и доступна ли она
|
||||||
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
|
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
|
||||||
@@ -150,7 +153,7 @@ class FileExplorer(QDialog):
|
|||||||
|
|
||||||
def setup_ui(self):
|
def setup_ui(self):
|
||||||
"""Настройка интерфейса"""
|
"""Настройка интерфейса"""
|
||||||
self.setWindowTitle("File Explorer")
|
self.setWindowTitle(_("File Explorer"))
|
||||||
self.setGeometry(100, 100, 600, 600)
|
self.setGeometry(100, 100, 600, 600)
|
||||||
|
|
||||||
self.main_layout = QVBoxLayout()
|
self.main_layout = QVBoxLayout()
|
||||||
@@ -158,7 +161,7 @@ class FileExplorer(QDialog):
|
|||||||
self.main_layout.setSpacing(10)
|
self.main_layout.setSpacing(10)
|
||||||
self.setLayout(self.main_layout)
|
self.setLayout(self.main_layout)
|
||||||
|
|
||||||
# Панель для смонтированных дисков
|
# Панель для смонтированных дисков и избранных папок
|
||||||
self.drives_layout = QHBoxLayout()
|
self.drives_layout = QHBoxLayout()
|
||||||
self.drives_scroll = QScrollArea()
|
self.drives_scroll = QScrollArea()
|
||||||
self.drives_scroll.setWidgetResizable(True)
|
self.drives_scroll.setWidgetResizable(True)
|
||||||
@@ -169,7 +172,7 @@ class FileExplorer(QDialog):
|
|||||||
self.drives_scroll.setFixedHeight(70)
|
self.drives_scroll.setFixedHeight(70)
|
||||||
self.main_layout.addWidget(self.drives_scroll)
|
self.main_layout.addWidget(self.drives_scroll)
|
||||||
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Allow focus on scroll area
|
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
|
||||||
# Путь
|
# Путь
|
||||||
self.path_label = QLabel()
|
self.path_label = QLabel()
|
||||||
@@ -181,6 +184,8 @@ class FileExplorer(QDialog):
|
|||||||
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
|
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
|
||||||
self.file_list.itemClicked.connect(self.handle_item_click)
|
self.file_list.itemClicked.connect(self.handle_item_click)
|
||||||
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
|
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
|
||||||
|
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
||||||
|
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
|
||||||
self.main_layout.addWidget(self.file_list)
|
self.main_layout.addWidget(self.file_list)
|
||||||
|
|
||||||
# Кнопки
|
# Кнопки
|
||||||
@@ -197,6 +202,13 @@ class FileExplorer(QDialog):
|
|||||||
self.select_button.clicked.connect(self.select_item)
|
self.select_button.clicked.connect(self.select_item)
|
||||||
self.cancel_button.clicked.connect(self.reject)
|
self.cancel_button.clicked.connect(self.reject)
|
||||||
|
|
||||||
|
def show_folder_context_menu(self, pos):
|
||||||
|
"""Shows the context menu for a folder using ContextMenuManager."""
|
||||||
|
if self.context_menu_manager:
|
||||||
|
self.context_menu_manager.show_folder_context_menu(self, pos)
|
||||||
|
else:
|
||||||
|
logger.warning("ContextMenuManager not found in parent")
|
||||||
|
|
||||||
def move_selection(self, direction):
|
def move_selection(self, direction):
|
||||||
"""Перемещение выбора по списку"""
|
"""Перемещение выбора по списку"""
|
||||||
current_row = self.file_list.currentRow()
|
current_row = self.file_list.currentRow()
|
||||||
@@ -286,44 +298,96 @@ class FileExplorer(QDialog):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error navigating to parent directory: {e}")
|
logger.error(f"Error navigating to parent directory: {e}")
|
||||||
|
|
||||||
|
def ensure_button_visible(self, button):
|
||||||
|
"""Ensure the specified button is visible in the drives_scroll area."""
|
||||||
|
try:
|
||||||
|
if not button or not self.drives_scroll:
|
||||||
|
return
|
||||||
|
# Ensure the button is visible in the scroll area
|
||||||
|
self.drives_scroll.ensureWidgetVisible(button, 50, 50)
|
||||||
|
logger.debug(f"Ensured button {button.text()} is visible in drives_scroll")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error ensuring button visible: {e}")
|
||||||
|
|
||||||
def update_drives_list(self):
|
def update_drives_list(self):
|
||||||
"""Обновление списка смонтированных дисков"""
|
"""Обновление списка смонтированных дисков и избранных папок."""
|
||||||
for i in reversed(range(self.drives_layout.count())):
|
for i in reversed(range(self.drives_layout.count())):
|
||||||
widget = self.drives_layout.itemAt(i).widget()
|
item = self.drives_layout.itemAt(i)
|
||||||
if widget:
|
if item and item.widget():
|
||||||
|
widget = item.widget()
|
||||||
|
self.drives_layout.removeWidget(widget)
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
|
|
||||||
|
self.drive_buttons = []
|
||||||
drives = self.get_mounted_drives()
|
drives = self.get_mounted_drives()
|
||||||
self.drive_buttons = [] # Store buttons for navigation
|
favorite_folders = read_favorite_folders()
|
||||||
|
|
||||||
|
# Добавляем смонтированные диски
|
||||||
for drive in drives:
|
for drive in drives:
|
||||||
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
|
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
|
||||||
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
|
button = AutoSizeButton(drive_name, icon=self.theme_manager.get_icon("mount_point"))
|
||||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus) # Make button focusable
|
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
|
button.clicked.connect(lambda checked, path=drive: self.change_drive(path))
|
||||||
self.drives_layout.addWidget(button)
|
self.drives_layout.addWidget(button)
|
||||||
self.drive_buttons.append(button)
|
self.drive_buttons.append(button)
|
||||||
self.drives_layout.addStretch()
|
|
||||||
|
|
||||||
# Set focus to first drive button if available
|
# Добавляем избранные папки
|
||||||
if self.drive_buttons:
|
for folder in favorite_folders:
|
||||||
self.drive_buttons[0].setFocus()
|
folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
|
||||||
|
button = AutoSizeButton(folder_name, icon=self.theme_manager.get_icon("folder"))
|
||||||
|
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||||
|
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
button.clicked.connect(lambda checked, path=folder: self.change_drive(path))
|
||||||
|
self.drives_layout.addWidget(button)
|
||||||
|
self.drive_buttons.append(button)
|
||||||
|
|
||||||
|
# Добавляем растяжку, чтобы выровнять элементы
|
||||||
|
spacer = QWidget()
|
||||||
|
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||||
|
self.drives_layout.addWidget(spacer)
|
||||||
|
|
||||||
def select_drive(self):
|
def select_drive(self):
|
||||||
"""Handle drive selection via gamepad"""
|
"""Обрабатывает выбор диска или избранной папки через геймпад."""
|
||||||
focused_widget = QApplication.focusWidget()
|
focused_widget = QApplication.focusWidget()
|
||||||
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
|
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
|
||||||
drive_path = None
|
drive_name = focused_widget.text().strip() # Удаляем пробелы
|
||||||
for drive in self.get_mounted_drives():
|
logger.debug(f"Выбрано имя: {drive_name}")
|
||||||
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
|
|
||||||
if drive_name == focused_widget.text():
|
# Специальная обработка корневого каталога
|
||||||
drive_path = drive
|
if drive_name == "/":
|
||||||
break
|
if os.path.isdir("/") and os.access("/", os.R_OK):
|
||||||
if drive_path and os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
|
self.current_path = "/"
|
||||||
self.current_path = os.path.normpath(drive_path)
|
self.update_file_list()
|
||||||
self.update_file_list()
|
logger.info("Выбран корневой каталог: /")
|
||||||
else:
|
return
|
||||||
logger.warning(f"Путь диска недоступен: {drive_path}")
|
else:
|
||||||
|
logger.warning("Корневой каталог недоступен: недостаточно прав или ошибка пути")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем избранные папки
|
||||||
|
favorite_folders = read_favorite_folders()
|
||||||
|
logger.debug(f"Избранные папки: {favorite_folders}")
|
||||||
|
for folder in favorite_folders:
|
||||||
|
folder_name = os.path.basename(os.path.normpath(folder)) or folder # Для корневых путей
|
||||||
|
if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
|
||||||
|
self.current_path = os.path.normpath(folder)
|
||||||
|
self.update_file_list()
|
||||||
|
logger.info(f"Выбрана избранная папка: {self.current_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем смонтированные диски
|
||||||
|
mounted_drives = self.get_mounted_drives()
|
||||||
|
logger.debug(f"Смонтированные диски: {mounted_drives}")
|
||||||
|
for drive in mounted_drives:
|
||||||
|
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # Для корневых путей
|
||||||
|
if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
|
||||||
|
self.current_path = os.path.normpath(drive)
|
||||||
|
self.update_file_list()
|
||||||
|
logger.info(f"Выбран смонтированный диск: {self.current_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.warning(f"Путь недоступен: {drive_name}.")
|
||||||
|
|
||||||
def change_drive(self, drive_path):
|
def change_drive(self, drive_path):
|
||||||
"""Переход к выбранному диску"""
|
"""Переход к выбранному диску"""
|
||||||
@@ -677,7 +741,10 @@ class AddGameDialog(QDialog):
|
|||||||
exe_path = self.exeEdit.text().strip()
|
exe_path = self.exeEdit.text().strip()
|
||||||
name = self.nameEdit.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
|
return None, None
|
||||||
|
|
||||||
portproton_path = get_portproton_location()
|
portproton_path = get_portproton_location()
|
||||||
|
@@ -144,14 +144,21 @@ class Downloader(QObject):
|
|||||||
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
|
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
|
||||||
return None
|
return None
|
||||||
if url in self._cache:
|
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)
|
url_lock = self._get_url_lock(url)
|
||||||
with url_lock:
|
with url_lock:
|
||||||
with self._global_lock:
|
with self._global_lock:
|
||||||
if url in self._last_error:
|
if url in self._last_error:
|
||||||
return None
|
return None
|
||||||
if url in self._cache:
|
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)
|
result = download_with_cache(url, local_path, timeout, self)
|
||||||
with self._global_lock:
|
with self._global_lock:
|
||||||
if result:
|
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.config_utils import get_portproton_location
|
||||||
from portprotonqt.steam_api import (
|
from portprotonqt.steam_api import (
|
||||||
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
|
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 vdf
|
||||||
import shutil
|
import shutil
|
||||||
import zlib
|
import zlib
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
from PySide6.QtGui import QPixmap
|
from PySide6.QtGui import QPixmap
|
||||||
|
import base64
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
downloader = Downloader()
|
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:
|
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).
|
Calls the callback with (success, message).
|
||||||
|
|
||||||
Args:
|
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.
|
portproton_dir: Path to the PortProton directory.
|
||||||
callback: Callback function to handle the result (success, message).
|
callback: Callback function to handle the result (success, message).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not portproton_dir:
|
if not portproton_dir:
|
||||||
logger.error("PortProton directory not found")
|
logger.error("PortProton directory not found")
|
||||||
callback((False, "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)
|
unsigned_id = convert_steam_id(user_id)
|
||||||
user_dir = os.path.join(userdata_dir, str(unsigned_id))
|
user_dir = os.path.join(userdata_dir, str(unsigned_id))
|
||||||
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
|
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):
|
if not os.path.exists(steam_shortcuts_path):
|
||||||
logger.error("Steam shortcuts file not found")
|
logger.error("Steam shortcuts file not found")
|
||||||
callback((False, "Steam shortcuts file not found"))
|
callback((False, "Steam shortcuts file not found"))
|
||||||
return
|
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:
|
try:
|
||||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with open(steam_shortcuts_path, 'rb') as f:
|
new_shortcuts = {}
|
||||||
shortcuts_data = vdf.binary_load(f)
|
index = 0
|
||||||
except Exception as e:
|
for _key, entry in shortcuts.items():
|
||||||
logger.error(f"Failed to load shortcuts.vdf: {e}")
|
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
|
||||||
callback((False, f"Failed to load shortcuts.vdf: {e}"))
|
logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
|
||||||
return
|
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:
|
with open(steam_shortcuts_path, 'wb') as f:
|
||||||
vdf.binary_dump({"shortcuts": new_shortcuts}, 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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||||
if os.path.exists(backup_path):
|
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}"))
|
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||||
return
|
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):
|
if os.path.exists(script_path):
|
||||||
try:
|
try:
|
||||||
os.remove(script_path)
|
os.remove(script_path)
|
||||||
logger.info("Removed EGS script: %s", script_path)
|
logger.info(f"Removed EGS script: {script_path}")
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning(f"Failed to remove EGS script '{script_path}': {str(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:
|
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.
|
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.
|
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.
|
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
|
||||||
Calls the callback with (success, message).
|
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():
|
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")
|
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"
|
grid_dir = user_dir / "config" / "grid"
|
||||||
os.makedirs(grid_dir, exist_ok=True)
|
os.makedirs(grid_dir, exist_ok=True)
|
||||||
|
|
||||||
# Backup shortcuts.vdf
|
# Try CEF API first
|
||||||
backup_path = f"{steam_shortcuts_path}.backup"
|
logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API")
|
||||||
if os.path.exists(steam_shortcuts_path):
|
api_response = call_steam_api(
|
||||||
try:
|
"createShortcut",
|
||||||
shutil.copy2(steam_shortcuts_path, backup_path)
|
game_title,
|
||||||
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
script_path,
|
||||||
except Exception as e:
|
str(Path(script_path).parent),
|
||||||
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
icon_path,
|
||||||
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
|
""
|
||||||
return
|
)
|
||||||
|
|
||||||
# Generate unique appid
|
appid = None
|
||||||
unique_string = f"{script_path}{game_title}"
|
was_api_used = False
|
||||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
|
||||||
appid = baseid | 0x80000000
|
if api_response and isinstance(api_response, dict) and 'id' in api_response:
|
||||||
if appid > 0x7FFFFFFF:
|
appid = api_response['id']
|
||||||
aidvdf = appid - 0x100000000
|
was_api_used = True
|
||||||
|
logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}")
|
||||||
else:
|
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
|
# Generate unique appid
|
||||||
downloaded_count = 0
|
unique_string = f"{script_path}{game_title}"
|
||||||
total_covers = 4
|
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||||
download_lock = threading.Lock()
|
appid = baseid | 0x80000000
|
||||||
|
if appid > 0x7FFFFFFF:
|
||||||
|
aidvdf = appid - 0x100000000
|
||||||
|
else:
|
||||||
|
aidvdf = appid
|
||||||
|
|
||||||
def on_cover_download(cover_file: str, cover_type: str):
|
# Create shortcut entry
|
||||||
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 = {
|
shortcut = {
|
||||||
"appid": aidvdf,
|
"appid": aidvdf,
|
||||||
"AppName": game_title,
|
"AppName": game_title,
|
||||||
@@ -322,7 +385,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
|||||||
"Devkit": 0,
|
"Devkit": 0,
|
||||||
"DevkitGameID": "",
|
"DevkitGameID": "",
|
||||||
"LastPlayTime": 0,
|
"LastPlayTime": 0,
|
||||||
"tags": tags_dict
|
"tags": {'0': 'PortProton'}
|
||||||
}
|
}
|
||||||
logger.info(f"Shortcut entry for EGS game: {shortcut}")
|
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:
|
with open(steam_shortcuts_path, 'wb') as f:
|
||||||
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
||||||
|
logger.info(f"EGS game '{game_title}' added to Steam via VDF")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||||
if os.path.exists(backup_path):
|
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}"))
|
callback((False, f"Failed to update shortcuts.vdf: {e}"))
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"EGS game '{game_title}' added to Steam")
|
if not appid:
|
||||||
callback((True, f"Game '{game_title}' added to Steam with covers"))
|
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]):
|
def on_steam_apps(steam_data: tuple[list, dict]):
|
||||||
nonlocal steam_appid
|
nonlocal steam_appid
|
||||||
@@ -375,24 +473,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
|
|||||||
|
|
||||||
if not steam_appid:
|
if not steam_appid:
|
||||||
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
|
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
|
return
|
||||||
|
|
||||||
cover_types = [
|
cover_types = [
|
||||||
(".jpg", "header.jpg"),
|
(".jpg", "header.jpg", 0),
|
||||||
("p.jpg", "library_600x900_2x.jpg"),
|
("p.jpg", "library_600x900_2x.jpg", 1),
|
||||||
("_hero.jpg", "library_hero.jpg"),
|
("_hero.jpg", "library_hero.jpg", 2),
|
||||||
("_logo.png", "logo.png")
|
("_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_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}/{cover_type}"
|
||||||
downloader.download_async(
|
downloader.download_async(
|
||||||
cover_url,
|
cover_url,
|
||||||
cover_file,
|
cover_file,
|
||||||
timeout=5,
|
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)
|
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] = []
|
games: list[tuple] = []
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
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):
|
def process_games(installed_games: list | None):
|
||||||
if installed_games is None:
|
if installed_games is None:
|
||||||
logger.info("No installed Epic Games Store games found")
|
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,
|
app_name,
|
||||||
f"legendary:launch:{app_name}",
|
f"legendary:launch:{app_name}",
|
||||||
"",
|
"",
|
||||||
last_launch, # Время последнего запуска
|
last_launch,
|
||||||
formatted_playtime, # Форматированное время игры
|
formatted_playtime,
|
||||||
protondb_tier, # ProtonDB tier
|
protondb_tier,
|
||||||
status or "",
|
status or "",
|
||||||
last_launch_timestamp, # Временная метка последнего запуска
|
last_launch_timestamp,
|
||||||
playtime_seconds, # Время игры в секундах
|
playtime_seconds,
|
||||||
"epic"
|
"epic"
|
||||||
)
|
)
|
||||||
pending_images -= 1
|
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)
|
get_protondb_tier_async(steam_appid, on_protondb_tier)
|
||||||
else:
|
else:
|
||||||
logger.debug(f"No Steam app found for EGS game {title}")
|
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)
|
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.QtGui import QPainter, QColor, QDesktopServices
|
||||||
from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl
|
from PySide6.QtCore import Signal, Property, Qt, QUrl
|
||||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
import portprotonqt.themes.standart.styles as default_styles
|
import portprotonqt.themes.standart.styles as default_styles
|
||||||
@@ -9,9 +9,13 @@ from portprotonqt.config_utils import read_favorites, save_favorites, read_displ
|
|||||||
from portprotonqt.theme_manager import ThemeManager
|
from portprotonqt.theme_manager import ThemeManager
|
||||||
from portprotonqt.config_utils import read_theme_from_config
|
from portprotonqt.config_utils import read_theme_from_config
|
||||||
from portprotonqt.custom_widgets import ClickableLabel
|
from portprotonqt.custom_widgets import ClickableLabel
|
||||||
|
from portprotonqt.portproton_api import PortProtonAPI
|
||||||
|
from portprotonqt.downloader import Downloader
|
||||||
|
from portprotonqt.animations import GameCardAnimations
|
||||||
import weakref
|
import weakref
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
|
||||||
class GameCard(QFrame):
|
class GameCard(QFrame):
|
||||||
borderWidthChanged = Signal()
|
borderWidthChanged = Signal()
|
||||||
gradientAngleChanged = Signal()
|
gradientAngleChanged = Signal()
|
||||||
@@ -56,6 +60,8 @@ class GameCard(QFrame):
|
|||||||
|
|
||||||
self.display_filter = read_display_filter()
|
self.display_filter = read_display_filter()
|
||||||
self.current_theme_name = read_theme_from_config()
|
self.current_theme_name = read_theme_from_config()
|
||||||
|
self.downloader = Downloader(max_workers=4)
|
||||||
|
self.portproton_api = PortProtonAPI(self.downloader)
|
||||||
|
|
||||||
self.steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
self.steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites"))
|
||||||
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
|
||||||
@@ -74,13 +80,8 @@ class GameCard(QFrame):
|
|||||||
self._focused = False
|
self._focused = False
|
||||||
|
|
||||||
# Анимации
|
# Анимации
|
||||||
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
|
self.animations = GameCardAnimations(self, self.theme)
|
||||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
self.animations.setup_animations()
|
||||||
self.gradient_anim = None
|
|
||||||
self.pulse_anim = None
|
|
||||||
|
|
||||||
# Флаг для отслеживания подключения слота startPulseAnimation
|
|
||||||
self._isPulseAnimationConnected = False
|
|
||||||
|
|
||||||
# Тень
|
# Тень
|
||||||
shadow = QGraphicsDropShadowEffect(self)
|
shadow = QGraphicsDropShadowEffect(self)
|
||||||
@@ -187,20 +188,20 @@ class GameCard(QFrame):
|
|||||||
self.egsLabel.setVisible(self.egs_visible)
|
self.egsLabel.setVisible(self.egs_visible)
|
||||||
|
|
||||||
# PortProton бейдж
|
# PortProton бейдж
|
||||||
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
|
portproton_icon = self.theme_manager.get_icon("portproton")
|
||||||
self.portprotonLabel = ClickableLabel(
|
self.portprotonLabel = ClickableLabel(
|
||||||
"PortProton",
|
"PortProton",
|
||||||
icon=portproton_icon,
|
icon=portproton_icon,
|
||||||
parent=coverWidget,
|
parent=coverWidget,
|
||||||
icon_size=icon_size,
|
icon_size=icon_size,
|
||||||
icon_space=icon_space,
|
icon_space=icon_space,
|
||||||
font_scale_factor=font_scale_factor,
|
font_scale_factor=font_scale_factor
|
||||||
change_cursor=False
|
|
||||||
)
|
)
|
||||||
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
self.portprotonLabel.setFixedWidth(badge_width)
|
self.portprotonLabel.setFixedWidth(badge_width)
|
||||||
self.portprotonLabel.setCardWidth(card_width)
|
self.portprotonLabel.setCardWidth(card_width)
|
||||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||||
|
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
|
||||||
|
|
||||||
# WeAntiCheatYet бейдж
|
# WeAntiCheatYet бейдж
|
||||||
anticheat_text = self.getAntiCheatText(anticheat_status)
|
anticheat_text = self.getAntiCheatText(anticheat_status)
|
||||||
@@ -385,6 +386,16 @@ class GameCard(QFrame):
|
|||||||
return "broken"
|
return "broken"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
def open_portproton_forum_topic(self):
|
||||||
|
"""Open the PortProton forum topic or search page for this game."""
|
||||||
|
result = self.portproton_api.get_forum_topic_slug(self.name)
|
||||||
|
base_url = "https://linux-gaming.ru/"
|
||||||
|
if result.startswith("search?q="):
|
||||||
|
url = QUrl(f"{base_url}{result}")
|
||||||
|
else:
|
||||||
|
url = QUrl(f"{base_url}t/{result}")
|
||||||
|
QDesktopServices.openUrl(url)
|
||||||
|
|
||||||
def open_protondb_report(self):
|
def open_protondb_report(self):
|
||||||
url = QUrl(f"https://www.protondb.com/app/{self.appid}")
|
url = QUrl(f"https://www.protondb.com/app/{self.appid}")
|
||||||
QDesktopServices.openUrl(url)
|
QDesktopServices.openUrl(url)
|
||||||
@@ -441,133 +452,22 @@ class GameCard(QFrame):
|
|||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
super().paintEvent(event)
|
super().paintEvent(event)
|
||||||
painter = QPainter(self)
|
self.animations.paint_border(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()
|
|
||||||
|
|
||||||
def enterEvent(self, event):
|
def enterEvent(self, event):
|
||||||
self._hovered = True
|
self.animations.handle_enter_event()
|
||||||
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()
|
|
||||||
|
|
||||||
super().enterEvent(event)
|
super().enterEvent(event)
|
||||||
|
|
||||||
def leaveEvent(self, event):
|
def leaveEvent(self, event):
|
||||||
self._hovered = False
|
self.animations.handle_leave_event()
|
||||||
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()
|
|
||||||
super().leaveEvent(event)
|
super().leaveEvent(event)
|
||||||
|
|
||||||
def focusInEvent(self, event):
|
def focusInEvent(self, event):
|
||||||
if not self._hovered:
|
self.animations.handle_focus_in_event()
|
||||||
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()
|
|
||||||
|
|
||||||
super().focusInEvent(event)
|
super().focusInEvent(event)
|
||||||
|
|
||||||
def focusOutEvent(self, event):
|
def focusOutEvent(self, event):
|
||||||
self._focused = False
|
self.animations.handle_focus_out_event()
|
||||||
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()
|
|
||||||
super().focusOutEvent(event)
|
super().focusOutEvent(event)
|
||||||
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
|
373
portprotonqt/howlongtobeat_api.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import orjson
|
||||||
|
import re
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
from threading import Thread
|
||||||
|
import requests
|
||||||
|
from bs4 import BeautifulSoup, Tag
|
||||||
|
from portprotonqt.config_utils import read_proxy_config
|
||||||
|
from portprotonqt.time_utils import format_playtime
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GameEntry:
|
||||||
|
"""Информация об игре из HowLongToBeat."""
|
||||||
|
game_id: int = -1
|
||||||
|
game_name: str | None = None
|
||||||
|
main_story: float | None = None
|
||||||
|
main_extra: float | None = None
|
||||||
|
completionist: float | None = None
|
||||||
|
similarity: float = -1.0
|
||||||
|
raw_data: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SearchConfig:
|
||||||
|
"""Конфигурация для поиска."""
|
||||||
|
api_key: str | None = None
|
||||||
|
search_url: str | None = None
|
||||||
|
|
||||||
|
class APIKeyExtractor:
|
||||||
|
"""Извлекает API ключ и URL поиска из скриптов сайта."""
|
||||||
|
@staticmethod
|
||||||
|
def extract_from_script(script_content: str) -> SearchConfig:
|
||||||
|
config = SearchConfig()
|
||||||
|
config.api_key = APIKeyExtractor._extract_api_key(script_content)
|
||||||
|
config.search_url = APIKeyExtractor._extract_search_url(script_content, config.api_key)
|
||||||
|
return config
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_api_key(script_content: str) -> str | None:
|
||||||
|
user_id_pattern = r'users\s*:\s*{\s*id\s*:\s*"([^"]+)"'
|
||||||
|
matches = re.findall(user_id_pattern, script_content)
|
||||||
|
if matches:
|
||||||
|
return ''.join(matches)
|
||||||
|
concat_pattern = r'\/api\/\w+\/"(?:\.concat\("[^"]*"\))+'
|
||||||
|
matches = re.findall(concat_pattern, script_content)
|
||||||
|
if matches:
|
||||||
|
parts = str(matches).split('.concat')
|
||||||
|
cleaned_parts = [re.sub(r'["\(\)\[\]\']', '', part) for part in parts[1:]]
|
||||||
|
return ''.join(cleaned_parts)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_search_url(script_content: str, api_key: str | None) -> str | None:
|
||||||
|
if not api_key:
|
||||||
|
return None
|
||||||
|
pattern = re.compile(
|
||||||
|
r'fetch\(\s*["\'](\/api\/[^"\']*)["\']'
|
||||||
|
r'((?:\s*\.concat\(\s*["\']([^"\']*)["\']\s*\))+)'
|
||||||
|
r'\s*,',
|
||||||
|
re.DOTALL
|
||||||
|
)
|
||||||
|
for match in pattern.finditer(script_content):
|
||||||
|
endpoint = match.group(1)
|
||||||
|
concat_calls = match.group(2)
|
||||||
|
concat_strings = re.findall(r'\.concat\(\s*["\']([^"\']*)["\']\s*\)', concat_calls)
|
||||||
|
concatenated_str = ''.join(concat_strings)
|
||||||
|
if concatenated_str == api_key:
|
||||||
|
return endpoint
|
||||||
|
return None
|
||||||
|
|
||||||
|
class HTTPClient:
|
||||||
|
"""HTTP клиент для работы с API HowLongToBeat."""
|
||||||
|
BASE_URL = 'https://howlongtobeat.com/'
|
||||||
|
SEARCH_URL = BASE_URL + "api/s/"
|
||||||
|
|
||||||
|
def __init__(self, timeout: int = 60):
|
||||||
|
self.timeout = timeout
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
|
||||||
|
'referer': self.BASE_URL
|
||||||
|
})
|
||||||
|
proxy_config = read_proxy_config()
|
||||||
|
if proxy_config:
|
||||||
|
self.session.proxies.update(proxy_config)
|
||||||
|
|
||||||
|
def get_search_config(self, parse_all_scripts: bool = False) -> SearchConfig | None:
|
||||||
|
try:
|
||||||
|
response = self.session.get(self.BASE_URL, timeout=self.timeout)
|
||||||
|
response.raise_for_status()
|
||||||
|
soup = BeautifulSoup(response.text, 'html.parser')
|
||||||
|
scripts = soup.find_all('script', src=True)
|
||||||
|
script_urls = []
|
||||||
|
for script in scripts:
|
||||||
|
if isinstance(script, Tag):
|
||||||
|
src = script.get('src')
|
||||||
|
if src is not None and isinstance(src, str):
|
||||||
|
if parse_all_scripts or '_app-' in src:
|
||||||
|
script_urls.append(src)
|
||||||
|
for script_url in script_urls:
|
||||||
|
full_url = self.BASE_URL + script_url
|
||||||
|
script_response = self.session.get(full_url, timeout=self.timeout)
|
||||||
|
if script_response.status_code == 200:
|
||||||
|
config = APIKeyExtractor.extract_from_script(script_response.text)
|
||||||
|
if config.api_key:
|
||||||
|
return config
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def search_games(self, game_name: str, page: int = 1, config: SearchConfig | None = None) -> str | None:
|
||||||
|
if not config:
|
||||||
|
config = self.get_search_config()
|
||||||
|
if not config:
|
||||||
|
config = self.get_search_config(parse_all_scripts=True)
|
||||||
|
if not config or not config.api_key:
|
||||||
|
return None
|
||||||
|
search_url = self.SEARCH_URL
|
||||||
|
if config.search_url:
|
||||||
|
search_url = self.BASE_URL + config.search_url.lstrip('/')
|
||||||
|
payload = self._build_search_payload(game_name, page, config)
|
||||||
|
headers = {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'accept': '*/*'
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = self.session.post(
|
||||||
|
search_url + config.api_key,
|
||||||
|
headers=headers,
|
||||||
|
data=orjson.dumps(payload),
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.text
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
response = self.session.post(
|
||||||
|
search_url,
|
||||||
|
headers=headers,
|
||||||
|
data=orjson.dumps(payload),
|
||||||
|
timeout=self.timeout
|
||||||
|
)
|
||||||
|
if response.status_code == 200:
|
||||||
|
return response.text
|
||||||
|
except requests.RequestException:
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _build_search_payload(self, game_name: str, page: int, config: SearchConfig) -> dict[str, Any]:
|
||||||
|
payload = {
|
||||||
|
'searchType': "games",
|
||||||
|
'searchTerms': game_name.split(),
|
||||||
|
'searchPage': page,
|
||||||
|
'size': 1, # Limit to 1 result
|
||||||
|
'searchOptions': {
|
||||||
|
'games': {
|
||||||
|
'userId': 0,
|
||||||
|
'platform': "",
|
||||||
|
'sortCategory': "popular",
|
||||||
|
'rangeCategory': "main",
|
||||||
|
'rangeTime': {'min': 0, 'max': 0},
|
||||||
|
'gameplay': {
|
||||||
|
'perspective': "",
|
||||||
|
'flow': "",
|
||||||
|
'genre': "",
|
||||||
|
"difficulty": ""
|
||||||
|
},
|
||||||
|
'rangeYear': {'max': "", 'min': ""},
|
||||||
|
'modifier': "" # Hardcoded to empty string for SearchModifiers.NONE
|
||||||
|
},
|
||||||
|
'users': {'sortCategory': "postcount"},
|
||||||
|
'lists': {'sortCategory': "follows"},
|
||||||
|
'filter': "",
|
||||||
|
'sort': 0,
|
||||||
|
'randomizer': 0
|
||||||
|
},
|
||||||
|
'useCache': True,
|
||||||
|
'fields': ["game_id", "game_name", "comp_main", "comp_plus", "comp_100"] # Request only needed fields
|
||||||
|
}
|
||||||
|
if config.api_key:
|
||||||
|
payload['searchOptions']['users']['id'] = config.api_key
|
||||||
|
return payload
|
||||||
|
|
||||||
|
class ResultParser:
|
||||||
|
"""Парсер результатов поиска."""
|
||||||
|
def __init__(self, search_query: str, minimum_similarity: float = 0.4, case_sensitive: bool = True):
|
||||||
|
self.search_query = search_query
|
||||||
|
self.minimum_similarity = minimum_similarity
|
||||||
|
self.case_sensitive = case_sensitive
|
||||||
|
self.search_numbers = self._extract_numbers(search_query)
|
||||||
|
|
||||||
|
def parse_results(self, json_response: str, target_game_id: int | None = None) -> list[GameEntry]:
|
||||||
|
try:
|
||||||
|
data = orjson.loads(json_response)
|
||||||
|
games = []
|
||||||
|
# Only process the first result
|
||||||
|
if data.get("data"):
|
||||||
|
game_data = data["data"][0]
|
||||||
|
game = self._parse_game_entry(game_data)
|
||||||
|
if target_game_id is not None:
|
||||||
|
if game.game_id == target_game_id:
|
||||||
|
games.append(game)
|
||||||
|
elif self.minimum_similarity == 0.0 or game.similarity >= self.minimum_similarity:
|
||||||
|
games.append(game)
|
||||||
|
return games
|
||||||
|
except (orjson.JSONDecodeError, KeyError, IndexError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
def _parse_game_entry(self, game_data: dict[str, Any]) -> GameEntry:
|
||||||
|
game = GameEntry()
|
||||||
|
game.game_id = game_data.get("game_id", -1)
|
||||||
|
game.game_name = game_data.get("game_name")
|
||||||
|
game.raw_data = game_data
|
||||||
|
time_fields = [
|
||||||
|
("comp_main", "main_story"),
|
||||||
|
("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_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
|
||||||
|
|
||||||
|
def _calculate_similarity(self, game: GameEntry) -> float:
|
||||||
|
return self._compare_strings(self.search_query, game.game_name)
|
||||||
|
|
||||||
|
def _compare_strings(self, a: str | None, b: str | None) -> float:
|
||||||
|
if not a or not b:
|
||||||
|
return 0.0
|
||||||
|
if self.case_sensitive:
|
||||||
|
similarity = SequenceMatcher(None, a, b).ratio()
|
||||||
|
else:
|
||||||
|
similarity = SequenceMatcher(None, a.lower(), b.lower()).ratio()
|
||||||
|
if self.search_numbers and not self._contains_numbers(b, self.search_numbers):
|
||||||
|
similarity -= 0.1
|
||||||
|
return max(0.0, similarity)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _extract_numbers(text: str) -> list[str]:
|
||||||
|
return [word for word in text.split() if word.isdigit()]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _contains_numbers(text: str, numbers: list[str]) -> bool:
|
||||||
|
if not numbers:
|
||||||
|
return True
|
||||||
|
cleaned_text = re.sub(r'([^\s\w]|_)+', '', text)
|
||||||
|
text_numbers = [word for word in cleaned_text.split() if word.isdigit()]
|
||||||
|
return any(num in text_numbers for num in numbers)
|
||||||
|
|
||||||
|
def get_cache_dir():
|
||||||
|
"""Возвращает путь к каталогу кэша, создаёт его при необходимости."""
|
||||||
|
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||||
|
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
return cache_dir
|
||||||
|
|
||||||
|
class HowLongToBeat(QObject):
|
||||||
|
"""Основной класс для работы с API HowLongToBeat."""
|
||||||
|
searchCompleted = Signal(list)
|
||||||
|
|
||||||
|
def __init__(self, minimum_similarity: float = 0.4, timeout: int = 60, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.minimum_similarity = minimum_similarity
|
||||||
|
self.http_client = HTTPClient(timeout)
|
||||||
|
self.cache_dir = get_cache_dir()
|
||||||
|
|
||||||
|
def _get_cache_file_path(self, game_name: str) -> str:
|
||||||
|
"""Возвращает путь к файлу кэша для заданного имени игры."""
|
||||||
|
safe_game_name = re.sub(r'[^\w\s-]', '', game_name).replace(' ', '_').lower()
|
||||||
|
cache_file = f"hltb_{safe_game_name}.json"
|
||||||
|
return os.path.join(self.cache_dir, cache_file)
|
||||||
|
|
||||||
|
def _load_from_cache(self, game_name: str) -> str | None:
|
||||||
|
"""Пытается загрузить данные из кэша, если они существуют."""
|
||||||
|
cache_file = self._get_cache_file_path(game_name)
|
||||||
|
try:
|
||||||
|
if os.path.exists(cache_file):
|
||||||
|
with open(cache_file, 'rb') as f:
|
||||||
|
return f.read().decode('utf-8')
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _save_to_cache(self, game_name: str, json_response: str):
|
||||||
|
"""Сохраняет данные в кэш, храня только первую игру и необходимые поля."""
|
||||||
|
cache_file = self._get_cache_file_path(game_name)
|
||||||
|
try:
|
||||||
|
# Парсим JSON и берем только первую игру
|
||||||
|
data = orjson.loads(json_response)
|
||||||
|
if data.get("data"):
|
||||||
|
first_game = data["data"][0]
|
||||||
|
simplified_data = {
|
||||||
|
"data": [{
|
||||||
|
"game_id": first_game.get("game_id", -1),
|
||||||
|
"game_name": first_game.get("game_name"),
|
||||||
|
"comp_main": first_game.get("comp_main", 0),
|
||||||
|
"comp_plus": first_game.get("comp_plus", 0),
|
||||||
|
"comp_100": first_game.get("comp_100", 0)
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
with open(cache_file, 'wb') as f:
|
||||||
|
f.write(orjson.dumps(simplified_data))
|
||||||
|
except (OSError, orjson.JSONDecodeError, IndexError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def search(self, game_name: str, case_sensitive: bool = True) -> list[GameEntry] | None:
|
||||||
|
if not game_name or not game_name.strip():
|
||||||
|
return None
|
||||||
|
# Проверяем кэш
|
||||||
|
cached_response = self._load_from_cache(game_name)
|
||||||
|
if cached_response:
|
||||||
|
try:
|
||||||
|
cached_data = orjson.loads(cached_response)
|
||||||
|
full_json = {
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"game_id": game["game_id"],
|
||||||
|
"game_name": game["game_name"],
|
||||||
|
"comp_main": game["comp_main"],
|
||||||
|
"comp_plus": game["comp_plus"],
|
||||||
|
"comp_100": game["comp_100"]
|
||||||
|
}
|
||||||
|
for game in cached_data.get("data", [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
parser = ResultParser(
|
||||||
|
game_name,
|
||||||
|
self.minimum_similarity,
|
||||||
|
case_sensitive
|
||||||
|
)
|
||||||
|
return parser.parse_results(orjson.dumps(full_json).decode('utf-8'))
|
||||||
|
except orjson.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
# Если нет в кэше, делаем запрос
|
||||||
|
json_response = self.http_client.search_games(game_name)
|
||||||
|
if not json_response:
|
||||||
|
return None
|
||||||
|
# Сохраняем в кэш только первую игру
|
||||||
|
self._save_to_cache(game_name, json_response)
|
||||||
|
parser = ResultParser(
|
||||||
|
game_name,
|
||||||
|
self.minimum_similarity,
|
||||||
|
case_sensitive
|
||||||
|
)
|
||||||
|
return parser.parse_results(json_response)
|
||||||
|
|
||||||
|
def format_game_time(self, game_entry: GameEntry, time_field: str = "main_story") -> str | None:
|
||||||
|
time_value = getattr(game_entry, time_field, None)
|
||||||
|
if time_value is None:
|
||||||
|
return None
|
||||||
|
time_seconds = int(time_value * 3600)
|
||||||
|
return format_playtime(time_seconds)
|
||||||
|
|
||||||
|
def search_with_callback(self, game_name: str, case_sensitive: bool = True):
|
||||||
|
"""Выполняет поиск игры в фоновом потоке и испускает сигнал с результатами."""
|
||||||
|
def search_thread():
|
||||||
|
try:
|
||||||
|
results = self.search(game_name, case_sensitive)
|
||||||
|
self.searchCompleted.emit(results if results else [])
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error in search_with_callback: {e}")
|
||||||
|
self.searchCompleted.emit([])
|
||||||
|
|
||||||
|
thread = Thread(target=search_thread)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
@@ -21,6 +21,13 @@ image_load_queue = Queue()
|
|||||||
image_executor = ThreadPoolExecutor(max_workers=4)
|
image_executor = ThreadPoolExecutor(max_workers=4)
|
||||||
queue_lock = threading.Lock()
|
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 = ""):
|
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)
|
:param theme: Объект темы для стилизации (если None, используется default_styles)
|
||||||
"""
|
"""
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
# Удаление диалога после закрытия
|
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
|
||||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
self.setFocus()
|
self.setFocus()
|
||||||
@@ -173,14 +179,12 @@ class FullscreenDialog(QDialog):
|
|||||||
self.current_index = current_index
|
self.current_index = current_index
|
||||||
self.theme = theme if theme else default_styles
|
self.theme = theme if theme else default_styles
|
||||||
|
|
||||||
# Убираем стандартные элементы управления окна
|
|
||||||
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||||
|
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.update_display()
|
self.update_display()
|
||||||
|
|
||||||
# Фильтруем события для закрытия диалога по клику
|
|
||||||
self.imageLabel.installEventFilter(self)
|
self.imageLabel.installEventFilter(self)
|
||||||
self.captionLabel.installEventFilter(self)
|
self.captionLabel.installEventFilter(self)
|
||||||
|
|
||||||
@@ -190,32 +194,28 @@ class FullscreenDialog(QDialog):
|
|||||||
self.mainLayout.setContentsMargins(0, 0, 0, 0)
|
self.mainLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.mainLayout.setSpacing(0)
|
self.mainLayout.setSpacing(0)
|
||||||
|
|
||||||
# Контейнер для изображения и стрелок
|
|
||||||
self.imageContainer = QWidget()
|
self.imageContainer = QWidget()
|
||||||
self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
|
self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
|
||||||
self.imageContainerLayout = QHBoxLayout(self.imageContainer)
|
self.imageContainerLayout = QHBoxLayout(self.imageContainer)
|
||||||
self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
|
self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
|
||||||
self.imageContainerLayout.setSpacing(0)
|
self.imageContainerLayout.setSpacing(0)
|
||||||
|
|
||||||
# Левая стрелка
|
|
||||||
self.prevButton = QToolButton()
|
self.prevButton = QToolButton()
|
||||||
self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
|
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.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.prevButton.setFixedSize(40, 40)
|
self.prevButton.setFixedSize(40, 40)
|
||||||
self.prevButton.clicked.connect(self.show_prev)
|
self.prevButton.clicked.connect(self.show_prev)
|
||||||
self.imageContainerLayout.addWidget(self.prevButton)
|
self.imageContainerLayout.addWidget(self.prevButton)
|
||||||
|
|
||||||
# Метка для изображения
|
|
||||||
self.imageLabel = QLabel()
|
self.imageLabel = QLabel()
|
||||||
self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
|
self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
|
||||||
self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
|
self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
|
||||||
|
|
||||||
# Правая стрелка
|
|
||||||
self.nextButton = QToolButton()
|
self.nextButton = QToolButton()
|
||||||
self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
|
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.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.nextButton.setFixedSize(40, 40)
|
self.nextButton.setFixedSize(40, 40)
|
||||||
self.nextButton.clicked.connect(self.show_next)
|
self.nextButton.clicked.connect(self.show_next)
|
||||||
@@ -223,16 +223,14 @@ class FullscreenDialog(QDialog):
|
|||||||
|
|
||||||
self.mainLayout.addWidget(self.imageContainer)
|
self.mainLayout.addWidget(self.imageContainer)
|
||||||
|
|
||||||
# Небольшой отступ между изображением и подписью
|
|
||||||
spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
|
||||||
self.mainLayout.addItem(spacer)
|
self.mainLayout.addItem(spacer)
|
||||||
|
|
||||||
# Подпись
|
|
||||||
self.captionLabel = QLabel()
|
self.captionLabel = QLabel()
|
||||||
self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
self.captionLabel.setFixedHeight(40)
|
self.captionLabel.setFixedHeight(40)
|
||||||
self.captionLabel.setWordWrap(True)
|
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.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.mainLayout.addWidget(self.captionLabel)
|
self.mainLayout.addWidget(self.captionLabel)
|
||||||
|
|
||||||
@@ -241,28 +239,37 @@ class FullscreenDialog(QDialog):
|
|||||||
if not self.images:
|
if not self.images:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Очищаем старое содержимое
|
|
||||||
self.imageLabel.clear()
|
self.imageLabel.clear()
|
||||||
self.captionLabel.clear()
|
self.captionLabel.clear()
|
||||||
QApplication.processEvents()
|
QApplication.processEvents()
|
||||||
|
|
||||||
pixmap, caption = self.images[self.current_index]
|
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(
|
scaled_pixmap = pixmap.scaled(
|
||||||
self.FIXED_WIDTH - 80, # учитываем ширину стрелок
|
target_width,
|
||||||
self.FIXED_HEIGHT,
|
target_height,
|
||||||
Qt.AspectRatioMode.KeepAspectRatio,
|
Qt.AspectRatioMode.KeepAspectRatio,
|
||||||
Qt.TransformationMode.SmoothTransformation
|
Qt.TransformationMode.SmoothTransformation
|
||||||
)
|
)
|
||||||
|
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
|
||||||
self.imageLabel.setPixmap(scaled_pixmap)
|
self.imageLabel.setPixmap(scaled_pixmap)
|
||||||
self.captionLabel.setText(caption)
|
self.captionLabel.setText(caption)
|
||||||
self.setWindowTitle(caption)
|
self.setWindowTitle(caption)
|
||||||
|
|
||||||
# Принудительная перерисовка виджетов
|
|
||||||
self.imageLabel.repaint()
|
self.imageLabel.repaint()
|
||||||
self.captionLabel.repaint()
|
self.captionLabel.repaint()
|
||||||
self.repaint()
|
self.repaint()
|
||||||
|
|
||||||
|
def resizeEvent(self, event):
|
||||||
|
"""Обновляет изображение при изменении размера окна."""
|
||||||
|
super().resizeEvent(event)
|
||||||
|
self.update_display() # Перерисовываем изображение с учетом нового размера
|
||||||
|
|
||||||
def show_prev(self):
|
def show_prev(self):
|
||||||
"""Показывает предыдущее изображение."""
|
"""Показывает предыдущее изображение."""
|
||||||
if self.images:
|
if self.images:
|
||||||
@@ -292,7 +299,6 @@ class FullscreenDialog(QDialog):
|
|||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
"""Закрывает диалог при клике на пустую область."""
|
"""Закрывает диалог при клике на пустую область."""
|
||||||
pos = event.pos()
|
pos = event.pos()
|
||||||
# Проверяем, находится ли клик вне imageContainer и captionLabel
|
|
||||||
if not (self.imageContainer.geometry().contains(pos) or
|
if not (self.imageContainer.geometry().contains(pos) or
|
||||||
self.captionLabel.geometry().contains(pos)):
|
self.captionLabel.geometry().contains(pos)):
|
||||||
self.close()
|
self.close()
|
||||||
@@ -305,15 +311,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
|||||||
"""
|
"""
|
||||||
def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
|
def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
|
||||||
"""
|
"""
|
||||||
:param pixmap: QPixmap для отображения в карусели
|
:param pixmap: QPixmap для отображения в карусели (оригинальное, высокое разрешение)
|
||||||
:param caption: Подпись к изображению
|
:param caption: Подпись к изображению
|
||||||
:param images_list: Список всех изображений (кортежей (QPixmap, caption)),
|
:param images_list: Список всех изображений (кортежей (QPixmap, caption))
|
||||||
чтобы в диалоге можно было перелистывать.
|
:param index: Индекс текущего изображения в images_list
|
||||||
Если не передан, будет использован только текущее изображение.
|
:param carousel: Ссылка на родительскую карусель (ImageCarousel)
|
||||||
: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.caption = caption
|
||||||
self.images_list = images_list if images_list is not None else [(pixmap, caption)]
|
self.images_list = images_list if images_list is not None else [(pixmap, caption)]
|
||||||
self.index = index
|
self.index = index
|
||||||
@@ -323,6 +328,20 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
|||||||
self._click_start_position = None
|
self._click_start_position = None
|
||||||
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
|
||||||
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
|
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):
|
def mousePressEvent(self, event):
|
||||||
if event.button() == Qt.MouseButton.LeftButton:
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
@@ -339,17 +358,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
|
|||||||
event.accept()
|
event.accept()
|
||||||
|
|
||||||
def show_fullscreen(self):
|
def show_fullscreen(self):
|
||||||
# Скрываем стрелки карусели перед открытием FullscreenDialog
|
|
||||||
if self.carousel:
|
if self.carousel:
|
||||||
self.carousel.prevArrow.hide()
|
self.carousel.prevArrow.hide()
|
||||||
self.carousel.nextArrow.hide()
|
self.carousel.nextArrow.hide()
|
||||||
dialog = FullscreenDialog(self.images_list, current_index=self.index)
|
dialog = FullscreenDialog(self.images_list, current_index=self.index)
|
||||||
dialog.exec()
|
dialog.exec()
|
||||||
# После закрытия диалога обновляем видимость стрелок
|
|
||||||
if self.carousel:
|
if self.carousel:
|
||||||
self.carousel.update_arrows_visibility()
|
self.carousel.update_arrows_visibility()
|
||||||
|
|
||||||
|
|
||||||
class ImageCarousel(QGraphicsView):
|
class ImageCarousel(QGraphicsView):
|
||||||
"""
|
"""
|
||||||
Карусель изображений с адаптивностью, возможностью увеличения по клику
|
Карусель изображений с адаптивностью, возможностью увеличения по клику
|
||||||
@@ -357,19 +373,16 @@ class ImageCarousel(QGraphicsView):
|
|||||||
"""
|
"""
|
||||||
def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
|
def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
|
|
||||||
# Аннотируем тип scene как QGraphicsScene
|
|
||||||
self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
|
self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
|
||||||
self.setScene(self.carousel_scene)
|
self.setScene(self.carousel_scene)
|
||||||
|
|
||||||
self.images = images # Список кортежей: (QPixmap, caption)
|
self.images = images # Список кортежей: (QPixmap, caption)
|
||||||
self.image_items = []
|
self.image_items = []
|
||||||
self._animation = None
|
self._animation = None
|
||||||
self.theme = theme if theme else default_styles
|
self.theme = theme if theme else default_styles
|
||||||
|
self.max_height = 300 # Default height for images
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
self.create_arrows()
|
self.create_arrows()
|
||||||
|
|
||||||
# Переменные для поддержки перетаскивания
|
|
||||||
self._drag_active = False
|
self._drag_active = False
|
||||||
self._drag_start_position = None
|
self._drag_start_position = None
|
||||||
self._scroll_start_value = None
|
self._scroll_start_value = None
|
||||||
@@ -380,30 +393,38 @@ class ImageCarousel(QGraphicsView):
|
|||||||
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
self.setFrameShape(QFrame.Shape.NoFrame)
|
self.setFrameShape(QFrame.Shape.NoFrame)
|
||||||
|
|
||||||
x_offset = 10 # Отступ между изображениями
|
self.update_scene()
|
||||||
max_height = 300 # Фиксированная высота изображений
|
|
||||||
|
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
|
x = 0
|
||||||
|
device_pixel_ratio = get_device_pixel_ratio()
|
||||||
|
|
||||||
for i, (pixmap, caption) in enumerate(self.images):
|
for i, (pixmap, caption) in enumerate(self.images):
|
||||||
item = ClickablePixmapItem(
|
item = ClickablePixmapItem(
|
||||||
pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation),
|
pixmap, # Pass original pixmap
|
||||||
caption,
|
caption,
|
||||||
images_list=self.images,
|
images_list=self.images,
|
||||||
index=i,
|
index=i,
|
||||||
carousel=self # Передаем ссылку на карусель
|
carousel=self
|
||||||
)
|
)
|
||||||
|
item.update_pixmap(self.max_height) # Scale to current height
|
||||||
item.setPos(x, 0)
|
item.setPos(x, 0)
|
||||||
self.carousel_scene.addItem(item)
|
self.carousel_scene.addItem(item)
|
||||||
self.image_items.append(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):
|
def create_arrows(self):
|
||||||
"""Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
|
"""Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
|
||||||
self.prevArrow = QToolButton(self)
|
self.prevArrow = QToolButton(self)
|
||||||
self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
|
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.setFixedSize(40, 40)
|
||||||
self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.prevArrow.setAutoRepeat(True)
|
self.prevArrow.setAutoRepeat(True)
|
||||||
@@ -414,7 +435,7 @@ class ImageCarousel(QGraphicsView):
|
|||||||
|
|
||||||
self.nextArrow = QToolButton(self)
|
self.nextArrow = QToolButton(self)
|
||||||
self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
|
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.setFixedSize(40, 40)
|
||||||
self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
|
||||||
self.nextArrow.setAutoRepeat(True)
|
self.nextArrow.setAutoRepeat(True)
|
||||||
@@ -423,14 +444,9 @@ class ImageCarousel(QGraphicsView):
|
|||||||
self.nextArrow.clicked.connect(self.scroll_right)
|
self.nextArrow.clicked.connect(self.scroll_right)
|
||||||
self.nextArrow.raise_()
|
self.nextArrow.raise_()
|
||||||
|
|
||||||
# Проверяем видимость стрелок при создании
|
|
||||||
self.update_arrows_visibility()
|
self.update_arrows_visibility()
|
||||||
|
|
||||||
def update_arrows_visibility(self):
|
def update_arrows_visibility(self):
|
||||||
"""
|
|
||||||
Показывает стрелки, если контент шире видимой области.
|
|
||||||
Иначе скрывает их.
|
|
||||||
"""
|
|
||||||
if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
|
if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
|
||||||
if self.horizontalScrollBar().maximum() == 0:
|
if self.horizontalScrollBar().maximum() == 0:
|
||||||
self.prevArrow.hide()
|
self.prevArrow.hide()
|
||||||
@@ -444,7 +460,8 @@ class ImageCarousel(QGraphicsView):
|
|||||||
margin = 10
|
margin = 10
|
||||||
self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
|
self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
|
||||||
self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
|
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()
|
self.update_arrows_visibility()
|
||||||
|
|
||||||
def animate_scroll(self, end_value):
|
def animate_scroll(self, end_value):
|
||||||
@@ -469,19 +486,15 @@ class ImageCarousel(QGraphicsView):
|
|||||||
self.animate_scroll(new_value)
|
self.animate_scroll(new_value)
|
||||||
|
|
||||||
def update_images(self, new_images):
|
def update_images(self, new_images):
|
||||||
self.carousel_scene.clear()
|
|
||||||
self.images = new_images
|
self.images = new_images
|
||||||
self.image_items.clear()
|
self.update_scene()
|
||||||
self.init_ui()
|
|
||||||
self.update_arrows_visibility()
|
self.update_arrows_visibility()
|
||||||
|
|
||||||
# Обработка событий мыши для перетаскивания
|
|
||||||
def mousePressEvent(self, event):
|
def mousePressEvent(self, event):
|
||||||
if event.button() == Qt.MouseButton.LeftButton:
|
if event.button() == Qt.MouseButton.LeftButton:
|
||||||
self._drag_active = True
|
self._drag_active = True
|
||||||
self._drag_start_position = event.pos()
|
self._drag_start_position = event.pos()
|
||||||
self._scroll_start_value = self.horizontalScrollBar().value()
|
self._scroll_start_value = self.horizontalScrollBar().value()
|
||||||
# Скрываем стрелки при начале перетаскивания
|
|
||||||
if hasattr(self, "prevArrow"):
|
if hasattr(self, "prevArrow"):
|
||||||
self.prevArrow.hide()
|
self.prevArrow.hide()
|
||||||
if hasattr(self, "nextArrow"):
|
if hasattr(self, "nextArrow"):
|
||||||
@@ -497,6 +510,5 @@ class ImageCarousel(QGraphicsView):
|
|||||||
|
|
||||||
def mouseReleaseEvent(self, event):
|
def mouseReleaseEvent(self, event):
|
||||||
self._drag_active = False
|
self._drag_active = False
|
||||||
# Показываем стрелки после завершения перетаскивания (с проверкой видимости)
|
|
||||||
self.update_arrows_visibility()
|
self.update_arrows_visibility()
|
||||||
super().mouseReleaseEvent(event)
|
super().mouseReleaseEvent(event)
|
||||||
|
@@ -4,7 +4,7 @@ import os
|
|||||||
from typing import Protocol, cast
|
from typing import Protocol, cast
|
||||||
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
|
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
|
||||||
from pyudev import Context, Monitor, MonitorObserver, Device
|
from pyudev import Context, Monitor, MonitorObserver, Device
|
||||||
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox
|
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
|
||||||
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
|
||||||
from PySide6.QtGui import QKeyEvent
|
from PySide6.QtGui import QKeyEvent
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
@@ -42,17 +42,17 @@ class MainWindowProtocol(Protocol):
|
|||||||
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
|
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
|
||||||
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
|
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
|
||||||
BUTTONS = {
|
BUTTONS = {
|
||||||
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS)
|
'confirm': {ecodes.BTN_SOUTH}, # A (Xbox) / Cross (PS)
|
||||||
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS)
|
'back': {ecodes.BTN_EAST}, # B (Xbox) / Circle (PS)
|
||||||
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS)
|
'add_game': {ecodes.BTN_NORTH}, # X (Xbox) / Triangle (PS)
|
||||||
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS)
|
'prev_dir': {ecodes.BTN_WEST}, # Y (Xbox) / Square (PS)
|
||||||
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS)
|
'prev_tab': {ecodes.BTN_TL}, # LB (Xbox) / L1 (PS)
|
||||||
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS)
|
'next_tab': {ecodes.BTN_TR}, # RB (Xbox) / R1 (PS)
|
||||||
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS)
|
'context_menu': {ecodes.BTN_START}, # Start (Xbox) / Options (PS)
|
||||||
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS)
|
'menu': {ecodes.BTN_SELECT}, # Select (Xbox) / Share (PS)
|
||||||
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button
|
'guide': {ecodes.BTN_MODE}, # Xbox Button / PS Button
|
||||||
'increase_size': {ecodes.ABS_RZ}, # RT (Xbox) / R2 (PS)
|
'increase_size': {ecodes.BTN_TR2}, # RT (Xbox) / R2 (PS)
|
||||||
'decrease_size': {ecodes.ABS_Z}, # LT (Xbox) / L2 (PS)
|
'decrease_size': {ecodes.BTN_TL2}, # LT (Xbox) / L2 (PS)
|
||||||
}
|
}
|
||||||
|
|
||||||
class InputManager(QObject):
|
class InputManager(QObject):
|
||||||
@@ -111,6 +111,8 @@ class InputManager(QObject):
|
|||||||
self.stick_value = 0 # Текущее значение стика (для плавности)
|
self.stick_value = 0 # Текущее значение стика (для плавности)
|
||||||
self.dead_zone = 8000 # Мертвая зона стика
|
self.dead_zone = 8000 # Мертвая зона стика
|
||||||
|
|
||||||
|
self._is_gamescope_session = 'gamescope' in os.environ.get('DESKTOP_SESSION', '').lower()
|
||||||
|
|
||||||
# Add variables for continuous D-pad movement
|
# Add variables for continuous D-pad movement
|
||||||
self.dpad_timer = QTimer(self)
|
self.dpad_timer = QTimer(self)
|
||||||
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
|
self.dpad_timer.timeout.connect(self.handle_dpad_repeat)
|
||||||
@@ -159,7 +161,20 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
def handle_file_explorer_button(self, button_code):
|
def handle_file_explorer_button(self, button_code):
|
||||||
try:
|
try:
|
||||||
|
popup = QApplication.activePopupWidget()
|
||||||
|
if isinstance(popup, QMenu):
|
||||||
|
if button_code in BUTTONS['confirm']: # A button (BTN_SOUTH)
|
||||||
|
if popup.activeAction():
|
||||||
|
popup.activeAction().trigger()
|
||||||
|
popup.close()
|
||||||
|
return
|
||||||
|
elif button_code in BUTTONS['back']: # B button
|
||||||
|
popup.close()
|
||||||
|
return
|
||||||
|
return # Skip other handling if menu is open
|
||||||
|
|
||||||
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
|
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list'):
|
||||||
|
logger.debug("No file explorer or file_list available")
|
||||||
return
|
return
|
||||||
|
|
||||||
focused_widget = QApplication.focusWidget()
|
focused_widget = QApplication.focusWidget()
|
||||||
@@ -167,27 +182,37 @@ class InputManager(QObject):
|
|||||||
if isinstance(focused_widget, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused_widget in self.file_explorer.drive_buttons:
|
if isinstance(focused_widget, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused_widget in self.file_explorer.drive_buttons:
|
||||||
self.file_explorer.select_drive() # Select the focused drive
|
self.file_explorer.select_drive() # Select the focused drive
|
||||||
elif self.file_explorer.file_list.count() == 0:
|
elif self.file_explorer.file_list.count() == 0:
|
||||||
|
logger.debug("File list is empty")
|
||||||
return
|
return
|
||||||
else:
|
else:
|
||||||
selected = self.file_explorer.file_list.currentItem().text()
|
selected = self.file_explorer.file_list.currentItem().text()
|
||||||
full_path = os.path.join(self.file_explorer.current_path, selected)
|
full_path = os.path.join(self.file_explorer.current_path, selected)
|
||||||
if os.path.isdir(full_path):
|
if os.path.isdir(full_path):
|
||||||
# Открываем директорию
|
|
||||||
self.file_explorer.current_path = os.path.normpath(full_path)
|
self.file_explorer.current_path = os.path.normpath(full_path)
|
||||||
self.file_explorer.update_file_list()
|
self.file_explorer.update_file_list()
|
||||||
elif not self.file_explorer.directory_only:
|
elif not self.file_explorer.directory_only:
|
||||||
# Выбираем файл, если directory_only=False
|
|
||||||
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||||
self.file_explorer.accept()
|
self.file_explorer.accept()
|
||||||
else:
|
else:
|
||||||
logger.debug("Selected item is not a directory, cannot select: %s", full_path)
|
logger.debug("Selected item is not a directory, cannot select: %s", full_path)
|
||||||
|
elif button_code in BUTTONS['context_menu']: # Start button (BTN_START)
|
||||||
|
if self.file_explorer.file_list.count() == 0:
|
||||||
|
logger.debug("File list is empty, cannot show context menu")
|
||||||
|
return
|
||||||
|
current_item = self.file_explorer.file_list.currentItem()
|
||||||
|
if current_item:
|
||||||
|
item_rect = self.file_explorer.file_list.visualItemRect(current_item)
|
||||||
|
pos = item_rect.center() # Use local coordinates for itemAt check
|
||||||
|
self.file_explorer.show_folder_context_menu(pos)
|
||||||
|
else:
|
||||||
|
logger.debug("No item selected for context menu")
|
||||||
elif button_code in BUTTONS['add_game']: # X button
|
elif button_code in BUTTONS['add_game']: # X button
|
||||||
if self.file_explorer.file_list.count() == 0:
|
if self.file_explorer.file_list.count() == 0:
|
||||||
|
logger.debug("File list is empty")
|
||||||
return
|
return
|
||||||
selected = self.file_explorer.file_list.currentItem().text()
|
selected = self.file_explorer.file_list.currentItem().text()
|
||||||
full_path = os.path.join(self.file_explorer.current_path, selected)
|
full_path = os.path.join(self.file_explorer.current_path, selected)
|
||||||
if os.path.isdir(full_path):
|
if os.path.isdir(full_path):
|
||||||
# Подтверждаем выбор директории
|
|
||||||
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||||
self.file_explorer.accept()
|
self.file_explorer.accept()
|
||||||
else:
|
else:
|
||||||
@@ -200,12 +225,29 @@ class InputManager(QObject):
|
|||||||
if self.original_button_handler:
|
if self.original_button_handler:
|
||||||
self.original_button_handler(button_code)
|
self.original_button_handler(button_code)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in FileExplorer button handler: {e}")
|
logger.error("Error in FileExplorer button handler: %s", e)
|
||||||
|
|
||||||
def handle_file_explorer_dpad(self, code, value, current_time):
|
def handle_file_explorer_dpad(self, code, value, current_time):
|
||||||
"""Обработка движения D-pad и левого стика для FileExplorer"""
|
"""Обработка движения D-pad и левого стика для FileExplorer"""
|
||||||
try:
|
try:
|
||||||
|
popup = QApplication.activePopupWidget()
|
||||||
|
if isinstance(popup, QMenu):
|
||||||
|
if code == ecodes.ABS_HAT0Y and value != 0:
|
||||||
|
actions = popup.actions()
|
||||||
|
if not actions:
|
||||||
|
return
|
||||||
|
current_action = popup.activeAction()
|
||||||
|
current_idx = actions.index(current_action) if current_action in actions else -1
|
||||||
|
if value > 0: # Down
|
||||||
|
next_idx = (current_idx + 1) % len(actions) if current_idx != -1 else 0
|
||||||
|
popup.setActiveAction(actions[next_idx])
|
||||||
|
elif value < 0: # Up
|
||||||
|
next_idx = (current_idx - 1) % len(actions) if current_idx != -1 else len(actions) - 1
|
||||||
|
popup.setActiveAction(actions[next_idx])
|
||||||
|
return # Skip other handling if menu is open
|
||||||
|
|
||||||
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
|
if not self.file_explorer or not hasattr(self.file_explorer, 'file_list') or not self.file_explorer.file_list:
|
||||||
|
logger.debug("No file explorer or file_list available")
|
||||||
return
|
return
|
||||||
|
|
||||||
focused_widget = QApplication.focusWidget()
|
focused_widget = QApplication.focusWidget()
|
||||||
@@ -214,14 +256,17 @@ class InputManager(QObject):
|
|||||||
if not isinstance(focused_widget, AutoSizeButton) or focused_widget not in self.file_explorer.drive_buttons:
|
if not isinstance(focused_widget, AutoSizeButton) or focused_widget not in self.file_explorer.drive_buttons:
|
||||||
# If not focused on a drive button, focus the first one
|
# If not focused on a drive button, focus the first one
|
||||||
self.file_explorer.drive_buttons[0].setFocus()
|
self.file_explorer.drive_buttons[0].setFocus()
|
||||||
|
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
|
||||||
return
|
return
|
||||||
current_idx = self.file_explorer.drive_buttons.index(focused_widget)
|
current_idx = self.file_explorer.drive_buttons.index(focused_widget)
|
||||||
if value < 0: # Left
|
if value < 0: # Left
|
||||||
next_idx = max(current_idx - 1, 0)
|
next_idx = max(current_idx - 1, 0)
|
||||||
self.file_explorer.drive_buttons[next_idx].setFocus()
|
self.file_explorer.drive_buttons[next_idx].setFocus()
|
||||||
|
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
|
||||||
elif value > 0: # Right
|
elif value > 0: # Right
|
||||||
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
|
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
|
||||||
self.file_explorer.drive_buttons[next_idx].setFocus()
|
self.file_explorer.drive_buttons[next_idx].setFocus()
|
||||||
|
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
|
||||||
elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
|
elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
|
||||||
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
|
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.file_explorer.drive_buttons:
|
||||||
# Move focus to file list if navigating down from drive buttons
|
# Move focus to file list if navigating down from drive buttons
|
||||||
@@ -262,7 +307,7 @@ class InputManager(QObject):
|
|||||||
elif self.original_dpad_handler:
|
elif self.original_dpad_handler:
|
||||||
self.original_dpad_handler(code, value, current_time)
|
self.original_dpad_handler(code, value, current_time)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in FileExplorer dpad handler: {e}")
|
logger.error("Error in FileExplorer dpad handler: %s", e)
|
||||||
|
|
||||||
def handle_navigation_repeat(self):
|
def handle_navigation_repeat(self):
|
||||||
"""Плавное повторение движения с переменной скоростью для FileExplorer"""
|
"""Плавное повторение движения с переменной скоростью для FileExplorer"""
|
||||||
@@ -740,6 +785,11 @@ class InputManager(QObject):
|
|||||||
if not app:
|
if not app:
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
|
|
||||||
|
# Ensure obj is a QObject
|
||||||
|
if not isinstance(obj, QObject):
|
||||||
|
logger.debug(f"Skipping event filter for non-QObject: {type(obj).__name__}")
|
||||||
|
return False
|
||||||
|
|
||||||
# Handle key press and release events
|
# Handle key press and release events
|
||||||
if not isinstance(event, QKeyEvent):
|
if not isinstance(event, QKeyEvent):
|
||||||
return super().eventFilter(obj, event)
|
return super().eventFilter(obj, event)
|
||||||
@@ -752,6 +802,62 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
# Handle key press events
|
# Handle key press events
|
||||||
if event.type() == QEvent.Type.KeyPress:
|
if event.type() == QEvent.Type.KeyPress:
|
||||||
|
# Handle FileExplorer specific logic
|
||||||
|
if self.file_explorer:
|
||||||
|
# Handle drive buttons in FileExplorer
|
||||||
|
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||||
|
if isinstance(focused, AutoSizeButton) and hasattr(self.file_explorer, 'drive_buttons') and focused in self.file_explorer.drive_buttons:
|
||||||
|
self.file_explorer.select_drive()
|
||||||
|
return True
|
||||||
|
elif isinstance(focused, QListWidget) and focused == self.file_explorer.file_list:
|
||||||
|
current_item = focused.currentItem()
|
||||||
|
if current_item:
|
||||||
|
selected = current_item.text()
|
||||||
|
full_path = os.path.join(self.file_explorer.current_path, selected)
|
||||||
|
if os.path.isdir(full_path):
|
||||||
|
if selected == "../":
|
||||||
|
self.file_explorer.previous_dir()
|
||||||
|
else:
|
||||||
|
self.file_explorer.current_path = os.path.normpath(full_path)
|
||||||
|
self.file_explorer.update_file_list()
|
||||||
|
elif not self.file_explorer.directory_only:
|
||||||
|
self.file_explorer.file_signal.file_selected.emit(os.path.normpath(full_path))
|
||||||
|
self.file_explorer.accept()
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
self._parent.activateFocusedWidget()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle FileExplorer navigation with right arrow key
|
||||||
|
if key == Qt.Key.Key_Right:
|
||||||
|
try:
|
||||||
|
if hasattr(self.file_explorer, 'drive_buttons') and self.file_explorer.drive_buttons:
|
||||||
|
if not isinstance(focused, AutoSizeButton) or focused not in self.file_explorer.drive_buttons:
|
||||||
|
self.file_explorer.drive_buttons[0].setFocus()
|
||||||
|
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[0])
|
||||||
|
else:
|
||||||
|
current_idx = self.file_explorer.drive_buttons.index(focused)
|
||||||
|
next_idx = min(current_idx + 1, len(self.file_explorer.drive_buttons) - 1)
|
||||||
|
self.file_explorer.drive_buttons[next_idx].setFocus()
|
||||||
|
self.file_explorer.ensure_button_visible(self.file_explorer.drive_buttons[next_idx])
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error handling right arrow in FileExplorer: {e}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle Backspace for FileExplorer navigation
|
||||||
|
if key == Qt.Key.Key_Backspace:
|
||||||
|
self.file_explorer.previous_dir()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Handle QLineEdit cursor movement with Left/Right arrows
|
||||||
|
if isinstance(focused, QLineEdit) and key in (Qt.Key.Key_Left, Qt.Key.Key_Right):
|
||||||
|
if key == Qt.Key.Key_Left:
|
||||||
|
focused.cursorBackward(False, 1) # Move cursor left by one character
|
||||||
|
elif key == Qt.Key.Key_Right:
|
||||||
|
focused.cursorForward(False, 1) # Move cursor right by one character
|
||||||
|
return True # Consume the event to prevent further processing
|
||||||
|
|
||||||
# Open system overlay with Insert
|
# Open system overlay with Insert
|
||||||
if key == Qt.Key.Key_Insert:
|
if key == Qt.Key.Key_Insert:
|
||||||
if not popup and not isinstance(active_win, QDialog):
|
if not popup and not isinstance(active_win, QDialog):
|
||||||
@@ -763,11 +869,19 @@ class InputManager(QObject):
|
|||||||
app.quit()
|
app.quit()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Close AddGameDialog with Escape
|
# Handle Backspace for FileExplorer navigation (move to parent directory)
|
||||||
if key == Qt.Key.Key_Escape and isinstance(popup, QDialog):
|
if key == Qt.Key.Key_Backspace and self.file_explorer:
|
||||||
popup.reject()
|
self.file_explorer.previous_dir()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Close Dialogs with Escape
|
||||||
|
if key == Qt.Key.Key_Escape:
|
||||||
|
if isinstance(focused, QLineEdit):
|
||||||
|
return False
|
||||||
|
if isinstance(active_win, QDialog):
|
||||||
|
active_win.reject()
|
||||||
|
return True
|
||||||
|
|
||||||
# FullscreenDialog navigation
|
# FullscreenDialog navigation
|
||||||
if isinstance(active_win, FullscreenDialog):
|
if isinstance(active_win, FullscreenDialog):
|
||||||
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace):
|
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace):
|
||||||
@@ -781,8 +895,8 @@ class InputManager(QObject):
|
|||||||
active_win.show_next()
|
active_win.show_next()
|
||||||
return True # Consume event to prevent tab switching
|
return True # Consume event to prevent tab switching
|
||||||
|
|
||||||
# Handle tab switching with Left/Right arrow keys when not in GameCard focus
|
# Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit
|
||||||
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and (not isinstance(focused, GameCard) or focused is None):
|
if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and not isinstance(focused, GameCard | QLineEdit) and not self.file_explorer:
|
||||||
idx = self._parent.stackedWidget.currentIndex()
|
idx = self._parent.stackedWidget.currentIndex()
|
||||||
total = len(self._parent.tabButtons)
|
total = len(self._parent.tabButtons)
|
||||||
if key == Qt.Key.Key_Left:
|
if key == Qt.Key.Key_Left:
|
||||||
@@ -849,7 +963,7 @@ class InputManager(QObject):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Toggle fullscreen with F11
|
# 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)
|
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -946,7 +1060,7 @@ class InputManager(QObject):
|
|||||||
continue
|
continue
|
||||||
now = time.time()
|
now = time.time()
|
||||||
if event.type == ecodes.EV_KEY and event.value == 1:
|
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)
|
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||||
else:
|
else:
|
||||||
self.button_pressed.emit(event.code)
|
self.button_pressed.emit(event.code)
|
||||||
|
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: de_DE\n"
|
"Language: de_DE\n"
|
||||||
@@ -26,6 +26,9 @@ msgstr ""
|
|||||||
msgid "PortProton is not found"
|
msgid "PortProton is not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete from PortProton"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Stop Game"
|
msgid "Stop Game"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -65,9 +68,6 @@ msgstr ""
|
|||||||
msgid "Edit Shortcut"
|
msgid "Edit Shortcut"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Delete from PortProton"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Stopped '{game_name}'"
|
msgid "Stopped '{game_name}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -170,18 +170,6 @@ msgstr ""
|
|||||||
msgid "No .desktop file found for '{game_name}'"
|
msgid "No .desktop file found for '{game_name}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Invalid executable command: {exec_line}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Executable not found: {path}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Failed to parse executable: {error}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Confirm Deletion"
|
msgid "Confirm Deletion"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -260,6 +248,9 @@ msgstr ""
|
|||||||
msgid "Select All"
|
msgid "Select All"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "File Explorer"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Select"
|
msgid "Select"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -563,6 +554,15 @@ msgstr ""
|
|||||||
msgid "PLAY TIME"
|
msgid "PLAY TIME"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN STORY"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN + SIDES"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "COMPLETIONIST"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "full"
|
msgid "full"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: es_ES\n"
|
"Language: es_ES\n"
|
||||||
@@ -26,6 +26,9 @@ msgstr ""
|
|||||||
msgid "PortProton is not found"
|
msgid "PortProton is not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete from PortProton"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Stop Game"
|
msgid "Stop Game"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -65,9 +68,6 @@ msgstr ""
|
|||||||
msgid "Edit Shortcut"
|
msgid "Edit Shortcut"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Delete from PortProton"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Stopped '{game_name}'"
|
msgid "Stopped '{game_name}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -170,18 +170,6 @@ msgstr ""
|
|||||||
msgid "No .desktop file found for '{game_name}'"
|
msgid "No .desktop file found for '{game_name}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Invalid executable command: {exec_line}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Executable not found: {path}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Failed to parse executable: {error}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Confirm Deletion"
|
msgid "Confirm Deletion"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -260,6 +248,9 @@ msgstr ""
|
|||||||
msgid "Select All"
|
msgid "Select All"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "File Explorer"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Select"
|
msgid "Select"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -563,6 +554,15 @@ msgstr ""
|
|||||||
msgid "PLAY TIME"
|
msgid "PLAY TIME"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN STORY"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN + SIDES"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "COMPLETIONIST"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "full"
|
msgid "full"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -24,6 +24,9 @@ msgstr ""
|
|||||||
msgid "PortProton is not found"
|
msgid "PortProton is not found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Delete from PortProton"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Stop Game"
|
msgid "Stop Game"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -63,9 +66,6 @@ msgstr ""
|
|||||||
msgid "Edit Shortcut"
|
msgid "Edit Shortcut"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
msgid "Delete from PortProton"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Stopped '{game_name}'"
|
msgid "Stopped '{game_name}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -168,18 +168,6 @@ msgstr ""
|
|||||||
msgid "No .desktop file found for '{game_name}'"
|
msgid "No .desktop file found for '{game_name}'"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Invalid executable command: {exec_line}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Executable not found: {path}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Failed to parse executable: {error}"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
msgid "Confirm Deletion"
|
msgid "Confirm Deletion"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -258,6 +246,9 @@ msgstr ""
|
|||||||
msgid "Select All"
|
msgid "Select All"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "File Explorer"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Select"
|
msgid "Select"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -561,6 +552,15 @@ msgstr ""
|
|||||||
msgid "PLAY TIME"
|
msgid "PLAY TIME"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN STORY"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "MAIN + SIDES"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "COMPLETIONIST"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "full"
|
msgid "full"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -9,8 +9,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-07-06 17:56+0500\n"
|
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
|
||||||
"PO-Revision-Date: 2025-07-06 17:56+0500\n"
|
"PO-Revision-Date: 2025-08-23 20:35+0500\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language: ru_RU\n"
|
"Language: ru_RU\n"
|
||||||
"Language-Team: ru_RU <LL@li.org>\n"
|
"Language-Team: ru_RU <LL@li.org>\n"
|
||||||
@@ -27,6 +27,9 @@ msgstr "Ошибка"
|
|||||||
msgid "PortProton is not found"
|
msgid "PortProton is not found"
|
||||||
msgstr "PortProton не найден"
|
msgstr "PortProton не найден"
|
||||||
|
|
||||||
|
msgid "Delete from PortProton"
|
||||||
|
msgstr "Удалить из PortProton"
|
||||||
|
|
||||||
msgid "Stop Game"
|
msgid "Stop Game"
|
||||||
msgstr "Остановить игру"
|
msgstr "Остановить игру"
|
||||||
|
|
||||||
@@ -66,9 +69,6 @@ msgstr "Добавить в меню"
|
|||||||
msgid "Edit Shortcut"
|
msgid "Edit Shortcut"
|
||||||
msgstr "Редактировать"
|
msgstr "Редактировать"
|
||||||
|
|
||||||
msgid "Delete from PortProton"
|
|
||||||
msgstr "Удалить из PortProton"
|
|
||||||
|
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Stopped '{game_name}'"
|
msgid "Stopped '{game_name}'"
|
||||||
msgstr "Остановлен(а) '{game_name}'"
|
msgstr "Остановлен(а) '{game_name}'"
|
||||||
@@ -173,18 +173,6 @@ msgstr "Не удалось прочитать файл .desktop: {error}"
|
|||||||
msgid "No .desktop file found for '{game_name}'"
|
msgid "No .desktop file found for '{game_name}'"
|
||||||
msgstr "Файл .desktop для '{game_name}' не найден"
|
msgstr "Файл .desktop для '{game_name}' не найден"
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Invalid executable command: {exec_line}"
|
|
||||||
msgstr "Недопустимая исполняемая команда: {exec_line}"
|
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Executable not found: {path}"
|
|
||||||
msgstr "Исполняемый файл не найден: {path}"
|
|
||||||
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Failed to parse executable: {error}"
|
|
||||||
msgstr "Не удалось разобрать исполняемый файл: {error}"
|
|
||||||
|
|
||||||
msgid "Confirm Deletion"
|
msgid "Confirm Deletion"
|
||||||
msgstr "Подтвердите удаление"
|
msgstr "Подтвердите удаление"
|
||||||
|
|
||||||
@@ -267,6 +255,9 @@ msgstr "Удалить"
|
|||||||
msgid "Select All"
|
msgid "Select All"
|
||||||
msgstr "Выбрать всё"
|
msgstr "Выбрать всё"
|
||||||
|
|
||||||
|
msgid "File Explorer"
|
||||||
|
msgstr "Проводник"
|
||||||
|
|
||||||
msgid "Select"
|
msgid "Select"
|
||||||
msgstr "Выбрать"
|
msgstr "Выбрать"
|
||||||
|
|
||||||
@@ -572,6 +563,15 @@ msgstr "Последний запуск"
|
|||||||
msgid "PLAY TIME"
|
msgid "PLAY TIME"
|
||||||
msgstr "Время игры"
|
msgstr "Время игры"
|
||||||
|
|
||||||
|
msgid "MAIN STORY"
|
||||||
|
msgstr "СЮЖЕТ"
|
||||||
|
|
||||||
|
msgid "MAIN + SIDES"
|
||||||
|
msgstr "СЮЖЕТ + ПОБОЧКИ"
|
||||||
|
|
||||||
|
msgid "COMPLETIONIST"
|
||||||
|
msgstr "100%"
|
||||||
|
|
||||||
msgid "full"
|
msgid "full"
|
||||||
msgstr "полная"
|
msgstr "полная"
|
||||||
|
|
||||||
|
@@ -10,6 +10,7 @@ import psutil
|
|||||||
|
|
||||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer
|
from portprotonqt.dialogs import AddGameDialog, FileExplorer
|
||||||
from portprotonqt.game_card import GameCard
|
from portprotonqt.game_card import GameCard
|
||||||
|
from portprotonqt.animations import DetailPageAnimations
|
||||||
from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel
|
from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel
|
||||||
from portprotonqt.portproton_api import PortProtonAPI
|
from portprotonqt.portproton_api import PortProtonAPI
|
||||||
from portprotonqt.input_manager import InputManager
|
from portprotonqt.input_manager import InputManager
|
||||||
@@ -31,23 +32,22 @@ from portprotonqt.config_utils import (
|
|||||||
)
|
)
|
||||||
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
|
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
|
from portprotonqt.howlongtobeat_api import HowLongToBeat
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
|
|
||||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
|
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.QtGui import QIcon, QPixmap, QColor, QDesktopServices
|
||||||
from PySide6.QtCore import Qt, QAbstractAnimation, QPropertyAnimation, QByteArray, QUrl, Signal, QTimer, Slot
|
|
||||||
from typing import cast
|
from typing import cast
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from PySide6.QtWidgets import QSizePolicy
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
class MainWindow(QMainWindow):
|
class MainWindow(QMainWindow):
|
||||||
"""Main window of PortProtonQt."""
|
"""Main window of PortProtonQt."""
|
||||||
settings_saved = Signal()
|
|
||||||
games_loaded = Signal(list)
|
games_loaded = Signal(list)
|
||||||
update_progress = Signal(int) # Signal to update progress bar
|
update_progress = Signal(int) # Signal to update progress bar
|
||||||
update_status_message = Signal(str, int) # Signal to update status message
|
update_status_message = Signal(str, int) # Signal to update status message
|
||||||
@@ -209,6 +209,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.restore_state()
|
self.restore_state()
|
||||||
|
|
||||||
self.input_manager = InputManager(self)
|
self.input_manager = InputManager(self)
|
||||||
|
self.detail_animations = DetailPageAnimations(self, self.theme)
|
||||||
QTimer.singleShot(0, self.loadGames)
|
QTimer.singleShot(0, self.loadGames)
|
||||||
|
|
||||||
if read_fullscreen_config():
|
if read_fullscreen_config():
|
||||||
@@ -248,6 +249,16 @@ class MainWindow(QMainWindow):
|
|||||||
self.updateGameGrid()
|
self.updateGameGrid()
|
||||||
self.progress_bar.setVisible(False)
|
self.progress_bar.setVisible(False)
|
||||||
|
|
||||||
|
def open_portproton_forum_topic(self, topic_name: str):
|
||||||
|
"""Open the PortProton forum topic or search page for this game."""
|
||||||
|
result = self.portproton_api.get_forum_topic_slug(topic_name)
|
||||||
|
base_url = "https://linux-gaming.ru/"
|
||||||
|
if result.startswith("search?q="):
|
||||||
|
url = QUrl(f"{base_url}{result}")
|
||||||
|
else:
|
||||||
|
url = QUrl(f"{base_url}t/{result}")
|
||||||
|
QDesktopServices.openUrl(url)
|
||||||
|
|
||||||
def _on_card_focused(self, game_name: str, is_focused: bool):
|
def _on_card_focused(self, game_name: str, is_focused: bool):
|
||||||
"""Обработчик сигнала focusChanged от GameCard."""
|
"""Обработчик сигнала focusChanged от GameCard."""
|
||||||
card_key = None
|
card_key = None
|
||||||
@@ -687,6 +698,15 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
def resizeEvent(self, event):
|
def resizeEvent(self, event):
|
||||||
super().resizeEvent(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'):
|
if not hasattr(self, '_last_width'):
|
||||||
self._last_width = self.width()
|
self._last_width = self.width()
|
||||||
if abs(self.width() - self._last_width) > 10:
|
if abs(self.width() - self._last_width) > 10:
|
||||||
@@ -1310,7 +1330,6 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.settingsDebounceTimer.start()
|
self.settingsDebounceTimer.start()
|
||||||
|
|
||||||
self.settings_saved.emit()
|
|
||||||
|
|
||||||
# Управление полноэкранным режимом
|
# Управление полноэкранным режимом
|
||||||
gamepad_connected = self.input_manager.find_gamepad() is not None
|
gamepad_connected = self.input_manager.find_gamepad() is not None
|
||||||
@@ -1507,24 +1526,47 @@ class MainWindow(QMainWindow):
|
|||||||
self._animations = {}
|
self._animations = {}
|
||||||
imageLabel = QLabel()
|
imageLabel = QLabel()
|
||||||
imageLabel.setFixedSize(300, 400)
|
imageLabel.setFixedSize(300, 400)
|
||||||
|
self._detail_page_active = True
|
||||||
|
self._current_detail_page = detailPage
|
||||||
|
|
||||||
if cover_path:
|
# Функция загрузки изображения и обновления стилей
|
||||||
def on_pixmap_ready(pixmap):
|
def load_image_and_restore_effect():
|
||||||
rounded = round_corners(pixmap, 10)
|
if not detailPage or detailPage.isHidden():
|
||||||
imageLabel.setPixmap(rounded)
|
logger.warning("Detail page is None or hidden, skipping image load")
|
||||||
|
return
|
||||||
|
|
||||||
def on_palette_ready(palette):
|
detailPage.setWindowOpacity(1.0)
|
||||||
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))
|
|
||||||
|
|
||||||
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)
|
def on_palette_ready(palette):
|
||||||
else:
|
if not detailPage or detailPage.isHidden():
|
||||||
detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
|
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 = QVBoxLayout(detailPage)
|
||||||
mainLayout.setContentsMargins(30, 30, 30, 30)
|
mainLayout.setContentsMargins(30, 30, 30, 30)
|
||||||
@@ -1579,7 +1621,7 @@ class MainWindow(QMainWindow):
|
|||||||
badge_spacing = 5
|
badge_spacing = 5
|
||||||
top_y = 10
|
top_y = 10
|
||||||
badge_y_positions = []
|
badge_y_positions = []
|
||||||
badge_width = int(300 * 2/3) # 2/3 ширины обложки (300 px)
|
badge_width = int(300 * 2/3)
|
||||||
|
|
||||||
# ProtonDB бейдж
|
# ProtonDB бейдж
|
||||||
protondb_text = GameCard.getProtonDBText(protondb_tier)
|
protondb_text = GameCard.getProtonDBText(protondb_tier)
|
||||||
@@ -1632,18 +1674,18 @@ class MainWindow(QMainWindow):
|
|||||||
egsLabel.setVisible(egs_visible)
|
egsLabel.setVisible(egs_visible)
|
||||||
|
|
||||||
# PortProton badge
|
# PortProton badge
|
||||||
portproton_icon = self.theme_manager.get_icon("ppqt-tray")
|
portproton_icon = self.theme_manager.get_icon("portproton")
|
||||||
portprotonLabel = ClickableLabel(
|
portprotonLabel = ClickableLabel(
|
||||||
"PortProton",
|
"PortProton",
|
||||||
icon=portproton_icon,
|
icon=portproton_icon,
|
||||||
parent=coverFrame,
|
parent=coverFrame,
|
||||||
icon_size=16,
|
icon_size=16,
|
||||||
icon_space=5,
|
icon_space=5,
|
||||||
change_cursor=False
|
|
||||||
)
|
)
|
||||||
portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
|
||||||
portprotonLabel.setFixedWidth(badge_width)
|
portprotonLabel.setFixedWidth(badge_width)
|
||||||
portprotonLabel.setVisible(portproton_visible)
|
portprotonLabel.setVisible(portproton_visible)
|
||||||
|
portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name))
|
||||||
|
|
||||||
# WeAntiCheatYet бейдж
|
# WeAntiCheatYet бейдж
|
||||||
anticheat_text = GameCard.getAntiCheatText(anticheat_status)
|
anticheat_text = GameCard.getAntiCheatText(anticheat_status)
|
||||||
@@ -1668,11 +1710,6 @@ class MainWindow(QMainWindow):
|
|||||||
anticheat_visible = False
|
anticheat_visible = False
|
||||||
|
|
||||||
# Расположение бейджей
|
# Расположение бейджей
|
||||||
right_margin = 8
|
|
||||||
badge_spacing = 5
|
|
||||||
top_y = 10
|
|
||||||
badge_y_positions = []
|
|
||||||
badge_width = int(300 * 2/3)
|
|
||||||
if steam_visible:
|
if steam_visible:
|
||||||
steam_x = 300 - badge_width - right_margin
|
steam_x = 300 - badge_width - right_margin
|
||||||
steamLabel.move(steam_x, top_y)
|
steamLabel.move(steam_x, top_y)
|
||||||
@@ -1726,22 +1763,102 @@ class MainWindow(QMainWindow):
|
|||||||
descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE)
|
descLabel.setStyleSheet(self.theme.DETAIL_PAGE_DESC_STYLE)
|
||||||
detailsLayout.addWidget(descLabel)
|
detailsLayout.addWidget(descLabel)
|
||||||
|
|
||||||
infoLayout = QHBoxLayout()
|
# Инициализация HowLongToBeat
|
||||||
infoLayout.setSpacing(10)
|
hltb = HowLongToBeat(parent=self)
|
||||||
|
|
||||||
|
# Создаем общий layout для всей игровой информации
|
||||||
|
gameInfoLayout = QVBoxLayout()
|
||||||
|
gameInfoLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# Первая строка: Last Launch и Play Time
|
||||||
|
firstRowLayout = QHBoxLayout()
|
||||||
|
firstRowLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# Last Launch
|
||||||
lastLaunchTitle = QLabel(_("LAST LAUNCH"))
|
lastLaunchTitle = QLabel(_("LAST LAUNCH"))
|
||||||
lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
|
lastLaunchTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
lastLaunchValue = QLabel(last_launch)
|
lastLaunchValue = QLabel(last_launch)
|
||||||
lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
|
lastLaunchValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
firstRowLayout.addWidget(lastLaunchTitle)
|
||||||
|
firstRowLayout.addWidget(lastLaunchValue)
|
||||||
|
firstRowLayout.addSpacing(30)
|
||||||
|
|
||||||
|
# Play Time
|
||||||
playTimeTitle = QLabel(_("PLAY TIME"))
|
playTimeTitle = QLabel(_("PLAY TIME"))
|
||||||
playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
|
playTimeTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
|
||||||
playTimeValue = QLabel(formatted_playtime)
|
playTimeValue = QLabel(formatted_playtime)
|
||||||
playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
|
playTimeValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
|
||||||
infoLayout.addWidget(lastLaunchTitle)
|
firstRowLayout.addWidget(playTimeTitle)
|
||||||
infoLayout.addWidget(lastLaunchValue)
|
firstRowLayout.addWidget(playTimeValue)
|
||||||
infoLayout.addSpacing(30)
|
|
||||||
infoLayout.addWidget(playTimeTitle)
|
gameInfoLayout.addLayout(firstRowLayout)
|
||||||
infoLayout.addWidget(playTimeValue)
|
|
||||||
detailsLayout.addLayout(infoLayout)
|
# Создаем placeholder для второй строки (HLTB данные)
|
||||||
|
hltbLayout = QHBoxLayout()
|
||||||
|
hltbLayout.setSpacing(10)
|
||||||
|
|
||||||
|
# Время прохождения (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")
|
||||||
|
main_extra_time = hltb.format_game_time(game, "main_extra")
|
||||||
|
completionist_time = hltb.format_game_time(game, "completionist")
|
||||||
|
|
||||||
|
# Очищаем layout перед добавлением новых элементов
|
||||||
|
while hltbLayout.count():
|
||||||
|
child = hltbLayout.takeAt(0)
|
||||||
|
if child.widget():
|
||||||
|
child.widget().deleteLater()
|
||||||
|
|
||||||
|
has_data = False
|
||||||
|
|
||||||
|
if main_story_time is not None:
|
||||||
|
mainStoryTitle = QLabel(_("MAIN STORY"))
|
||||||
|
mainStoryTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
|
mainStoryValue = QLabel(main_story_time)
|
||||||
|
mainStoryValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(mainStoryTitle)
|
||||||
|
hltbLayout.addWidget(mainStoryValue)
|
||||||
|
hltbLayout.addSpacing(30)
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
if main_extra_time is not None:
|
||||||
|
mainExtraTitle = QLabel(_("MAIN + SIDES"))
|
||||||
|
mainExtraTitle.setStyleSheet(self.theme.PLAY_TIME_TITLE_STYLE)
|
||||||
|
mainExtraValue = QLabel(main_extra_time)
|
||||||
|
mainExtraValue.setStyleSheet(self.theme.PLAY_TIME_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(mainExtraTitle)
|
||||||
|
hltbLayout.addWidget(mainExtraValue)
|
||||||
|
hltbLayout.addSpacing(30)
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
if completionist_time is not None:
|
||||||
|
completionistTitle = QLabel(_("COMPLETIONIST"))
|
||||||
|
completionistTitle.setStyleSheet(self.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||||
|
completionistValue = QLabel(completionist_time)
|
||||||
|
completionistValue.setStyleSheet(self.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||||
|
hltbLayout.addWidget(completionistTitle)
|
||||||
|
hltbLayout.addWidget(completionistValue)
|
||||||
|
has_data = True
|
||||||
|
|
||||||
|
# Если есть данные, добавляем layout во вторую строку
|
||||||
|
if has_data:
|
||||||
|
gameInfoLayout.addLayout(hltbLayout)
|
||||||
|
|
||||||
|
# Подключаем сигнал searchCompleted к on_hltb_results
|
||||||
|
hltb.searchCompleted.connect(on_hltb_results)
|
||||||
|
|
||||||
|
# Запускаем поиск в фоновом потоке
|
||||||
|
hltb.search_with_callback(name, case_sensitive=False)
|
||||||
|
|
||||||
|
# Добавляем общий layout с игровой информацией
|
||||||
|
detailsLayout.addLayout(gameInfoLayout)
|
||||||
|
|
||||||
if controller_support:
|
if controller_support:
|
||||||
cs = controller_support.lower()
|
cs = controller_support.lower()
|
||||||
@@ -1759,7 +1876,7 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
detailsLayout.addStretch(1)
|
detailsLayout.addStretch(1)
|
||||||
|
|
||||||
# Определяем текущий идентификатор игры по exec_line для корректного отображения кнопки
|
# Определяем текущий идентификатор игры по exec_line
|
||||||
entry_exec_split = shlex.split(exec_line)
|
entry_exec_split = shlex.split(exec_line)
|
||||||
if not entry_exec_split:
|
if not entry_exec_split:
|
||||||
return
|
return
|
||||||
@@ -1792,17 +1909,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.current_play_button = playButton
|
self.current_play_button = playButton
|
||||||
|
|
||||||
# Анимация
|
# Анимация
|
||||||
opacityEffect = QGraphicsOpacityEffect(detailPage)
|
self.detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
|
||||||
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))
|
|
||||||
)
|
|
||||||
|
|
||||||
def toggleFavoriteInDetailPage(self, game_name, label):
|
def toggleFavoriteInDetailPage(self, game_name, label):
|
||||||
favorites = read_favorites()
|
favorites = read_favorites()
|
||||||
@@ -1858,14 +1965,42 @@ class MainWindow(QMainWindow):
|
|||||||
parent = parent.parent()
|
parent = parent.parent()
|
||||||
|
|
||||||
def goBackDetailPage(self, page: QWidget | None) -> None:
|
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
|
return
|
||||||
self.stackedWidget.setCurrentIndex(0)
|
self._exit_animation_in_progress = True
|
||||||
self.stackedWidget.removeWidget(page)
|
self._detail_page_active = False
|
||||||
page.deleteLater()
|
self._current_detail_page = None
|
||||||
self.currentDetailPage = None
|
|
||||||
self.current_exec_line = None
|
def cleanup():
|
||||||
self.current_play_button = None
|
"""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):
|
def is_target_exe_running(self):
|
||||||
"""Проверяет, запущен ли процесс с именем self.target_exe через psutil."""
|
"""Проверяет, запущен ли процесс с именем self.target_exe через psutil."""
|
||||||
|
@@ -1,19 +1,58 @@
|
|||||||
import os
|
import os
|
||||||
|
import tarfile
|
||||||
|
import orjson
|
||||||
import requests
|
import requests
|
||||||
|
import urllib.parse
|
||||||
|
import time
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from portprotonqt.downloader import Downloader, download_with_cache
|
from portprotonqt.downloader import Downloader
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
|
||||||
|
|
||||||
|
def normalize_name(s):
|
||||||
|
"""
|
||||||
|
Приведение строки к нормальному виду:
|
||||||
|
- перевод в нижний регистр,
|
||||||
|
- удаление символов ™ и ®,
|
||||||
|
- замена разделителей (-, :, ,) на пробел,
|
||||||
|
- удаление лишних пробелов,
|
||||||
|
- удаление суффиксов 'bin' или 'app' в конце строки,
|
||||||
|
- удаление ключевых слов типа 'ultimate', 'edition' и т.п.
|
||||||
|
"""
|
||||||
|
s = s.lower()
|
||||||
|
for ch in ["™", "®"]:
|
||||||
|
s = s.replace(ch, "")
|
||||||
|
for ch in ["-", ":", ","]:
|
||||||
|
s = s.replace(ch, " ")
|
||||||
|
s = " ".join(s.split())
|
||||||
|
for suffix in ["bin", "app"]:
|
||||||
|
if s.endswith(suffix):
|
||||||
|
s = s[:-len(suffix)].strip()
|
||||||
|
|
||||||
|
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
|
||||||
|
words = s.split()
|
||||||
|
filtered_words = [word for word in words if word not in keywords_to_remove]
|
||||||
|
return " ".join(filtered_words)
|
||||||
|
|
||||||
|
def get_cache_dir():
|
||||||
|
"""Return the cache directory path, creating it if necessary."""
|
||||||
|
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
|
||||||
|
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
|
||||||
|
os.makedirs(cache_dir, exist_ok=True)
|
||||||
|
return cache_dir
|
||||||
|
|
||||||
class PortProtonAPI:
|
class PortProtonAPI:
|
||||||
"""API to fetch game assets (cover, metadata) from the PortProtonQt repository."""
|
"""API to fetch game assets (cover, metadata) and forum topics from the PortProtonQt repository."""
|
||||||
def __init__(self, downloader: Downloader | None = None):
|
def __init__(self, downloader: Downloader | None = None):
|
||||||
self.base_url = "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/custom_data"
|
self.base_url = "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/custom_data"
|
||||||
|
self.topics_url = "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/linux_gaming_topics.tar.xz"
|
||||||
self.downloader = downloader or Downloader(max_workers=4)
|
self.downloader = downloader or Downloader(max_workers=4)
|
||||||
self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
|
self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||||
self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
|
self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
|
||||||
os.makedirs(self.custom_data_dir, exist_ok=True)
|
os.makedirs(self.custom_data_dir, exist_ok=True)
|
||||||
|
self._topics_data = None
|
||||||
|
|
||||||
def _get_game_dir(self, exe_name: str) -> str:
|
def _get_game_dir(self, exe_name: str) -> str:
|
||||||
game_dir = os.path.join(self.custom_data_dir, exe_name)
|
game_dir = os.path.join(self.custom_data_dir, exe_name)
|
||||||
@@ -40,7 +79,7 @@ class PortProtonAPI:
|
|||||||
cover_url = f"{cover_url_base}{ext}"
|
cover_url = f"{cover_url_base}{ext}"
|
||||||
if self._check_file_exists(cover_url, timeout):
|
if self._check_file_exists(cover_url, timeout):
|
||||||
local_cover_path = os.path.join(game_dir, f"cover{ext}")
|
local_cover_path = os.path.join(game_dir, f"cover{ext}")
|
||||||
result = download_with_cache(cover_url, local_cover_path, timeout, self.downloader)
|
result = self.downloader.download(cover_url, local_cover_path, timeout=timeout)
|
||||||
if result:
|
if result:
|
||||||
results["cover"] = result
|
results["cover"] = result
|
||||||
logger.info(f"Downloaded cover for {exe_name} to {result}")
|
logger.info(f"Downloaded cover for {exe_name} to {result}")
|
||||||
@@ -52,7 +91,7 @@ class PortProtonAPI:
|
|||||||
|
|
||||||
if self._check_file_exists(metadata_url, timeout):
|
if self._check_file_exists(metadata_url, timeout):
|
||||||
local_metadata_path = os.path.join(game_dir, "metadata.txt")
|
local_metadata_path = os.path.join(game_dir, "metadata.txt")
|
||||||
result = download_with_cache(metadata_url, local_metadata_path, timeout, self.downloader)
|
result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout)
|
||||||
if result:
|
if result:
|
||||||
results["metadata"] = result
|
results["metadata"] = result
|
||||||
logger.info(f"Downloaded metadata for {exe_name} to {result}")
|
logger.info(f"Downloaded metadata for {exe_name} to {result}")
|
||||||
@@ -123,3 +162,66 @@ class PortProtonAPI:
|
|||||||
logger.debug(f"No assets found for {exe_name}")
|
logger.debug(f"No assets found for {exe_name}")
|
||||||
if callback:
|
if callback:
|
||||||
callback(results)
|
callback(results)
|
||||||
|
|
||||||
|
def _load_topics_data(self):
|
||||||
|
"""Load and cache linux_gaming_topics_min.json from the archive."""
|
||||||
|
if self._topics_data is not None:
|
||||||
|
return self._topics_data
|
||||||
|
|
||||||
|
cache_dir = get_cache_dir()
|
||||||
|
cache_tar = os.path.join(cache_dir, "linux_gaming_topics.tar.xz")
|
||||||
|
cache_json = os.path.join(cache_dir, "linux_gaming_topics_min.json")
|
||||||
|
|
||||||
|
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
|
||||||
|
logger.info("Using cached topics JSON: %s", cache_json)
|
||||||
|
try:
|
||||||
|
with open(cache_json, "rb") as f:
|
||||||
|
self._topics_data = orjson.loads(f.read())
|
||||||
|
logger.debug("Loaded %d topics from cache", len(self._topics_data))
|
||||||
|
return self._topics_data
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error reading cached topics JSON: %s", e)
|
||||||
|
self._topics_data = []
|
||||||
|
|
||||||
|
def process_tar(result: str | None):
|
||||||
|
if not result or not os.path.exists(result):
|
||||||
|
logger.error("Failed to download topics archive")
|
||||||
|
self._topics_data = []
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with tarfile.open(result, mode="r:xz") as tar:
|
||||||
|
member = next((m for m in tar.getmembers() if m.name == "linux_gaming_topics_min.json"), None)
|
||||||
|
if member is None:
|
||||||
|
raise RuntimeError("linux_gaming_topics_min.json not found in archive")
|
||||||
|
fobj = tar.extractfile(member)
|
||||||
|
if fobj is None:
|
||||||
|
raise RuntimeError("Failed to extract linux_gaming_topics_min.json from archive")
|
||||||
|
raw = fobj.read()
|
||||||
|
fobj.close()
|
||||||
|
self._topics_data = orjson.loads(raw)
|
||||||
|
with open(cache_json, "wb") as f:
|
||||||
|
f.write(orjson.dumps(self._topics_data))
|
||||||
|
if os.path.exists(cache_tar):
|
||||||
|
os.remove(cache_tar)
|
||||||
|
logger.info("Archive %s deleted after extraction", cache_tar)
|
||||||
|
logger.info("Loaded %d topics from archive", len(self._topics_data))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error processing topics archive: %s", e)
|
||||||
|
self._topics_data = []
|
||||||
|
|
||||||
|
self.downloader.download_async(self.topics_url, cache_tar, timeout=5, callback=process_tar)
|
||||||
|
# Wait for async download to complete if called synchronously
|
||||||
|
while self._topics_data is None:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return self._topics_data
|
||||||
|
|
||||||
|
def get_forum_topic_slug(self, game_name: str) -> str:
|
||||||
|
"""Get the forum topic slug or search URL for a given game name."""
|
||||||
|
topics = self._load_topics_data()
|
||||||
|
normalized_name = normalize_name(game_name)
|
||||||
|
for topic in topics:
|
||||||
|
if topic["normalized_title"] == normalized_name:
|
||||||
|
return topic["slug"]
|
||||||
|
logger.debug("No forum topic found for game: %s, redirecting to search", game_name)
|
||||||
|
encoded_name = urllib.parse.quote(f"#ppdb {game_name}")
|
||||||
|
return f"search?q={encoded_name}"
|
||||||
|
@@ -18,6 +18,10 @@ from collections.abc import Callable
|
|||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import zlib
|
import zlib
|
||||||
|
import websocket
|
||||||
|
import requests
|
||||||
|
import random
|
||||||
|
import base64
|
||||||
|
|
||||||
downloader = Downloader()
|
downloader = Downloader()
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -291,7 +295,7 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
|||||||
if os.path.exists(cache_tar):
|
if os.path.exists(cache_tar):
|
||||||
os.remove(cache_tar)
|
os.remove(cache_tar)
|
||||||
logger.info("Archive %s deleted after extraction", 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))
|
logger.info("Loaded %d apps from archive", len(steam_apps))
|
||||||
callback(steam_apps)
|
callback(steam_apps)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -303,12 +307,25 @@ def load_steam_apps_async(callback: Callable[[list], None]):
|
|||||||
try:
|
try:
|
||||||
with open(cache_json, "rb") as f:
|
with open(cache_json, "rb") as f:
|
||||||
data = orjson.loads(f.read())
|
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))
|
logger.info("Loaded %d apps from cache", len(steam_apps))
|
||||||
callback(steam_apps)
|
callback(steam_apps)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error reading cached JSON: %s", e)
|
logger.error("Error reading or validating cached JSON %s: %s", cache_json, e)
|
||||||
callback([])
|
# 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:
|
else:
|
||||||
app_list_url = (
|
app_list_url = (
|
||||||
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
|
"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:
|
try:
|
||||||
with open(cache_json, "rb") as f:
|
with open(cache_json, "rb") as f:
|
||||||
data = orjson.loads(f.read())
|
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))
|
logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
|
||||||
callback(anti_cheat_data)
|
callback(anti_cheat_data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error reading cached WeAntiCheatYet JSON: %s", e)
|
logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e)
|
||||||
callback([])
|
# 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:
|
else:
|
||||||
app_list_url = (
|
app_list_url = (
|
||||||
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
|
"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)
|
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]:
|
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,
|
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"
|
grid_dir = user_dir / "config" / "grid"
|
||||||
os.makedirs(grid_dir, exist_ok=True)
|
os.makedirs(grid_dir, exist_ok=True)
|
||||||
|
|
||||||
backup_path = f"{steam_shortcuts_path}.backup"
|
appid = None
|
||||||
if os.path.exists(steam_shortcuts_path):
|
was_api_used = False
|
||||||
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}")
|
|
||||||
|
|
||||||
unique_string = f"{script_path}{game_name}"
|
logger.info("Попытка добавления ярлыка через Steam CEF API...")
|
||||||
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
api_response = call_steam_api(
|
||||||
appid = baseid | 0x80000000
|
"createShortcut",
|
||||||
if appid > 0x7FFFFFFF:
|
game_name,
|
||||||
aidvdf = appid - 0x100000000
|
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:
|
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
|
unique_string = f"{script_path}{game_name}"
|
||||||
downloaded_count = 0
|
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
|
||||||
total_covers = 4 # количество обложек
|
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 = {
|
shortcut = {
|
||||||
"appid": aidvdf,
|
"appid": aidvdf,
|
||||||
"AppName": game_name,
|
"AppName": game_name,
|
||||||
@@ -899,7 +1046,7 @@ export START_FROM_STEAM=1
|
|||||||
"Devkit": 0,
|
"Devkit": 0,
|
||||||
"DevkitGameID": "",
|
"DevkitGameID": "",
|
||||||
"LastPlayTime": 0,
|
"LastPlayTime": 0,
|
||||||
"tags": tags_dict
|
"tags": {'0': 'PortProton'}
|
||||||
}
|
}
|
||||||
logger.info(f"Shortcut entry to be written: {shortcut}")
|
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:
|
with open(steam_shortcuts_path, 'wb') as f:
|
||||||
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
vdf.binary_dump({"shortcuts": shortcuts}, f)
|
||||||
|
logger.info(f"Game '{game_name}' successfully added to Steam with covers")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
||||||
if os.path.exists(backup_path):
|
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")
|
logger.info("Restored shortcuts.vdf from backup due to update failure")
|
||||||
except Exception as restore_err:
|
except Exception as restore_err:
|
||||||
logger.error(f"Failed to restore shortcuts.vdf from backup: {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")
|
if not appid:
|
||||||
return (True, f"Game '{game_name}' added to Steam with covers")
|
return (False, "Не удалось создать ярлык ни одним из способов.")
|
||||||
|
|
||||||
|
steam_appid = None
|
||||||
|
|
||||||
def on_game_info(game_info: dict):
|
def on_game_info(game_info: dict):
|
||||||
nonlocal steam_appid
|
nonlocal steam_appid
|
||||||
steam_appid = game_info.get("appid")
|
steam_appid = game_info.get("appid")
|
||||||
if not steam_appid or not isinstance(steam_appid, int):
|
if not steam_appid or not isinstance(steam_appid, int):
|
||||||
logger.info("No valid Steam appid found, skipping cover download")
|
logger.info("No valid Steam appid found, skipping cover download")
|
||||||
return finalize_shortcut()
|
return
|
||||||
|
logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.")
|
||||||
|
|
||||||
# Обложки и имена, соответствующие bash-скрипту и твоим размерам
|
|
||||||
cover_types = [
|
cover_types = [
|
||||||
(".jpg", "header.jpg"), # базовый, сохранится как AppId.jpg
|
("p.jpg", "library_600x900_2x.jpg"),
|
||||||
("p.jpg", "library_600x900_2x.jpg"), # сохранится как AppIdp.jpg
|
("_hero.jpg", "library_hero.jpg"),
|
||||||
("_hero.jpg", "library_hero.jpg"), # AppId_hero.jpg
|
("_logo.png", "logo.png"),
|
||||||
("_logo.png", "logo.png") # AppId_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_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(
|
downloader.download_async(
|
||||||
cover_url,
|
cover_url,
|
||||||
cover_file,
|
cover_file,
|
||||||
timeout=5,
|
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)
|
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}")
|
logger.info(f"shortcuts.vdf not found at {steam_shortcuts_path}")
|
||||||
return (False, f"Game '{game_name}' not found in Steam")
|
return (False, f"Game '{game_name}' not found in Steam")
|
||||||
|
|
||||||
# Generate appid for identifying cover files
|
appid = None
|
||||||
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}")
|
|
||||||
|
|
||||||
# Load and modify shortcuts.vdf
|
# Load and modify shortcuts.vdf
|
||||||
try:
|
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}")
|
return (False, f"Failed to load shortcuts.vdf: {load_err}")
|
||||||
|
|
||||||
shortcuts = shortcuts_data.get("shortcuts", {})
|
shortcuts = shortcuts_data.get("shortcuts", {})
|
||||||
found = False
|
|
||||||
new_shortcuts = {}
|
new_shortcuts = {}
|
||||||
index = 0
|
index = 0
|
||||||
|
|
||||||
# Filter out the matching shortcut
|
# Filter out the matching shortcut
|
||||||
for _key, entry in shortcuts.items():
|
for _key, entry in shortcuts.items():
|
||||||
if entry.get("AppName") == game_name and entry.get("Exe") == f'"{script_path}"':
|
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")
|
logger.info(f"Found matching shortcut for '{game_name}' to remove")
|
||||||
continue
|
continue
|
||||||
new_shortcuts[str(index)] = entry
|
new_shortcuts[str(index)] = entry
|
||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
if not found:
|
if not appid:
|
||||||
logger.info(f"Game '{game_name}' not found in Steam shortcuts")
|
logger.info(f"Game '{game_name}' not found in Steam shortcuts")
|
||||||
return (False, f"Game '{game_name}' not found in Steam")
|
return (False, f"Game '{game_name}' not found in Steam")
|
||||||
|
|
||||||
# Save updated shortcuts.vdf
|
api_response = call_steam_api("removeShortcut", appid)
|
||||||
try:
|
if api_response is not None: # API ответил, даже если ответ пустой
|
||||||
with open(steam_shortcuts_path, 'wb') as f:
|
logger.info(f"Ярлык для AppID {appid} успешно удален через API.")
|
||||||
vdf.binary_dump({"shortcuts": new_shortcuts}, f)
|
else:
|
||||||
logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'")
|
logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).")
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to update shortcuts.vdf: {e}")
|
# Create backup of shortcuts.vdf
|
||||||
if os.path.exists(backup_path):
|
backup_path = f"{steam_shortcuts_path}.backup"
|
||||||
try:
|
try:
|
||||||
shutil.copy2(backup_path, steam_shortcuts_path)
|
shutil.copy2(steam_shortcuts_path, backup_path)
|
||||||
logger.info("Restored shortcuts.vdf from backup due to update failure")
|
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
|
||||||
except Exception as restore_err:
|
except Exception as e:
|
||||||
logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
|
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
|
||||||
return (False, f"Failed to update 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
|
# Delete cover files
|
||||||
cover_files = [
|
cover_files = [
|
||||||
|
@@ -20,6 +20,8 @@ class SystemOverlay(QDialog):
|
|||||||
self.theme_manager = ThemeManager()
|
self.theme_manager = ThemeManager()
|
||||||
self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE)
|
self.setStyleSheet(self.theme.OVERLAY_WINDOW_STYLE)
|
||||||
|
|
||||||
|
self.script_path = "/usr/bin/portprotonqt-session-select"
|
||||||
|
|
||||||
# Make window stay on top and frameless
|
# Make window stay on top and frameless
|
||||||
self.setWindowFlags(
|
self.setWindowFlags(
|
||||||
Qt.WindowType.FramelessWindowHint |
|
Qt.WindowType.FramelessWindowHint |
|
||||||
@@ -79,8 +81,7 @@ class SystemOverlay(QDialog):
|
|||||||
desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
desktop_button.setStyleSheet(self.theme.OVERLAY_BUTTON_STYLE)
|
||||||
desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
desktop_button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
desktop_button.clicked.connect(self.return_to_desktop)
|
desktop_button.clicked.connect(self.return_to_desktop)
|
||||||
script_path = "/usr/bin/portprotonqt-session-select"
|
script_exists = os.path.isfile(self.script_path)
|
||||||
script_exists = os.path.isfile(script_path)
|
|
||||||
desktop_button.setEnabled(script_exists)
|
desktop_button.setEnabled(script_exists)
|
||||||
if not script_exists:
|
if not script_exists:
|
||||||
desktop_button.setToolTip(_("portprotonqt-session-select file not found at /usr/bin/"))
|
desktop_button.setToolTip(_("portprotonqt-session-select file not found at /usr/bin/"))
|
||||||
@@ -139,8 +140,8 @@ class SystemOverlay(QDialog):
|
|||||||
|
|
||||||
def return_to_desktop(self):
|
def return_to_desktop(self):
|
||||||
try:
|
try:
|
||||||
script_path = os.path.join(os.path.dirname(__file__), "portprotonqt-session-select")
|
QApplication.quit()
|
||||||
subprocess.run([script_path, "desktop"], check=True)
|
subprocess.run([self.script_path, "desktop"], check=True)
|
||||||
except subprocess.CalledProcessError as e:
|
except subprocess.CalledProcessError as e:
|
||||||
logger.error(f"Failed to return to desktop: {e}")
|
logger.error(f"Failed to return to desktop: {e}")
|
||||||
QMessageBox.warning(self, _("Error"), _("Failed to return to desktop"))
|
QMessageBox.warning(self, _("Error"), _("Failed to return to desktop"))
|
||||||
|
@@ -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;
|
|
||||||
}
|
|
||||||
"""
|
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
@@ -27,6 +27,10 @@ color_g = "rgba(0, 0, 0, 0)"
|
|||||||
color_h = "transparent"
|
color_h = "transparent"
|
||||||
|
|
||||||
GAME_CARD_ANIMATION = {
|
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.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
|
||||||
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
{"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
|
||||||
{"position": 1, "color": "#00fff5"} # Конечный цвет (возвращение к циану)
|
{"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"""
|
CONTEXT_MENU_STYLE = f"""
|
||||||
|
@@ -1,49 +0,0 @@
|
|||||||
from PySide6.QtGui import QAction, QIcon
|
|
||||||
from PySide6.QtWidgets import QSystemTrayIcon, QMenu
|
|
||||||
from portprotonqt.theme_manager import ThemeManager
|
|
||||||
from typing import cast
|
|
||||||
import portprotonqt.themes.standart.styles as default_styles
|
|
||||||
from portprotonqt.config_utils import read_theme_from_config
|
|
||||||
|
|
||||||
class SystemTray:
|
|
||||||
def __init__(self, app, theme=None):
|
|
||||||
self.app = app
|
|
||||||
self.theme_manager = ThemeManager()
|
|
||||||
self.theme = theme if theme is not None else default_styles
|
|
||||||
self.current_theme_name = read_theme_from_config()
|
|
||||||
self.tray = QSystemTrayIcon()
|
|
||||||
self.tray.setIcon(cast(QIcon, self.theme_manager.get_icon("ppqt-tray", self.current_theme_name)))
|
|
||||||
self.tray.setToolTip("PortProtonQt")
|
|
||||||
self.tray.setVisible(True)
|
|
||||||
|
|
||||||
# Создаём меню
|
|
||||||
self.menu = QMenu()
|
|
||||||
|
|
||||||
self.hide_action = QAction("Скрыть окно")
|
|
||||||
self.menu.addAction(self.hide_action)
|
|
||||||
|
|
||||||
self.show_action = QAction("Показать окно")
|
|
||||||
self.menu.addAction(self.show_action)
|
|
||||||
|
|
||||||
self.quit_action = QAction("Выход")
|
|
||||||
self.quit_action.triggered.connect(app.quit)
|
|
||||||
self.menu.addAction(self.quit_action)
|
|
||||||
|
|
||||||
self.tray.setContextMenu(self.menu)
|
|
||||||
|
|
||||||
def hide_tray(self):
|
|
||||||
"""Скрыть иконку трея"""
|
|
||||||
if self.tray:
|
|
||||||
self.tray.setVisible(False)
|
|
||||||
if self.menu:
|
|
||||||
self.menu.deleteLater()
|
|
||||||
self.menu = None
|
|
||||||
|
|
||||||
def cleanup(self):
|
|
||||||
"""Очистка ресурсов трея"""
|
|
||||||
if self.tray:
|
|
||||||
self.tray.setVisible(False)
|
|
||||||
self.tray = None
|
|
||||||
if self.menu:
|
|
||||||
self.menu.deleteLater()
|
|
||||||
self.menu = None
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "portprotonqt"
|
name = "portprotonqt"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
description = "A project to rewrite PortProton (PortWINE) using PySide"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { text = "GPL-3.0" }
|
license = { text = "GPL-3.0" }
|
||||||
@@ -27,17 +27,19 @@ classifiers = [
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"babel>=2.17.0",
|
"babel>=2.17.0",
|
||||||
"evdev>=1.9.1",
|
"beautifulsoup4>=4.13.5",
|
||||||
"icoextract>=0.1.6",
|
"evdev>=1.9.2",
|
||||||
|
"icoextract>=0.2.0",
|
||||||
"numpy>=2.2.4",
|
"numpy>=2.2.4",
|
||||||
"orjson>=3.10.16",
|
"orjson>=3.11.2",
|
||||||
"pillow>=11.2.1",
|
"pillow>=11.3.0",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.0.0",
|
||||||
"pyside6>=6.9.0",
|
"pyside6>=6.9.1",
|
||||||
"pyudev>=0.24.3",
|
"pyudev>=0.24.3",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.5",
|
||||||
"tqdm>=4.67.1",
|
"tqdm>=4.67.1",
|
||||||
"vdf>=3.4",
|
"vdf>=3.4",
|
||||||
|
"websocket-client>=1.8.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -101,7 +103,7 @@ ignore = [
|
|||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"pre-commit>=4.2.0",
|
"pre-commit>=4.3.0",
|
||||||
"pyaspeller>=2.0.2",
|
"pyaspeller>=2.0.2",
|
||||||
"pyright>=1.1.400",
|
"pyright>=1.1.404",
|
||||||
]
|
]
|
||||||
|
@@ -15,12 +15,23 @@
|
|||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchFileNames": [".gitea/workflows/**.yaml", ".gitea/workflows/**.yml"],
|
"matchFileNames": [".python-version"],
|
||||||
"enabled": false
|
"enabled": false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchFileNames": [".python-version"],
|
"matchManagers": ["github-actions", "pre-commit", "poetry"],
|
||||||
"enabled": false
|
"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)"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
363
uv.lock
generated
@@ -15,6 +15,19 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 },
|
{ url = "https://files.pythonhosted.org/packages/b7/b8/3fe70c75fe32afc4bb507f75563d39bc5642255d1d94f1f23604725780bf/babel-2.17.0-py3-none-any.whl", hash = "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2", size = 10182537 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "beautifulsoup4"
|
||||||
|
version = "4.13.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "soupsieve" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.6.15"
|
version = "2025.6.15"
|
||||||
@@ -291,68 +304,79 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "orjson"
|
name = "orjson"
|
||||||
version = "3.10.18"
|
version = "3.11.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/81/0b/fea456a3ffe74e70ba30e01ec183a9b26bec4d497f61dcfce1b601059c60/orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53", size = 5422810 }
|
sdist = { url = "https://files.pythonhosted.org/packages/df/1d/5e0ae38788bdf0721326695e65fdf41405ed535f633eb0df0f06f57552fa/orjson-3.11.2.tar.gz", hash = "sha256:91bdcf5e69a8fd8e8bdb3de32b31ff01d2bd60c1e8d5fe7d5afabdcf19920309", size = 5470739 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/16/2ceb9fb7bc2b11b1e4a3ea27794256e93dee2309ebe297fd131a778cd150/orjson-3.10.18-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a45e5d68066b408e4bc383b6e4ef05e717c65219a9e1390abc6155a520cac402", size = 248927 },
|
{ url = "https://files.pythonhosted.org/packages/a1/7b/7aebe925c6b1c46c8606a960fe1d6b681fccd4aaf3f37cd647c3309d6582/orjson-3.11.2-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d6b8a78c33496230a60dc9487118c284c15ebdf6724386057239641e1eb69761", size = 226896 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/e1/d3c0a2bba5b9906badd121da449295062b289236c39c3a7801f92c4682b0/orjson-3.10.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be3b9b143e8b9db05368b13b04c84d37544ec85bb97237b3a923f076265ec89c", size = 136995 },
|
{ url = "https://files.pythonhosted.org/packages/7d/39/c952c9b0d51063e808117dd1e53668a2e4325cc63cfe7df453d853ee8680/orjson-3.11.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc04036eeae11ad4180d1f7b5faddb5dab1dee49ecd147cd431523869514873b", size = 111845 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/51/698dd65e94f153ee5ecb2586c89702c9e9d12f165a63e74eb9ea1299f4e1/orjson-3.10.18-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9b0aa09745e2c9b3bf779b096fa71d1cc2d801a604ef6dd79c8b1bfef52b2f92", size = 132893 },
|
{ url = "https://files.pythonhosted.org/packages/f5/dc/90b7f29be38745eeacc30903b693f29fcc1097db0c2a19a71ffb3e9f2a5f/orjson-3.11.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c04325839c5754c253ff301cee8aaed7442d974860a44447bb3be785c411c27", size = 116395 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/e5/155ce5a2c43a85e790fcf8b985400138ce5369f24ee6770378ee6b691036/orjson-3.10.18-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53a245c104d2792e65c8d225158f2b8262749ffe64bc7755b00024757d957a13", size = 137017 },
|
{ url = "https://files.pythonhosted.org/packages/10/c2/fe84ba63164c22932b8d59b8810e2e58590105293a259e6dd1bfaf3422c9/orjson-3.11.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32769e04cd7fdc4a59854376211145a1bbbc0aea5e9d6c9755d3d3c301d7c0df", size = 118768 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/bb/6141ec3beac3125c0b07375aee01b5124989907d61c72c7636136e4bd03e/orjson-3.10.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9495ab2611b7f8a0a8a505bcb0f0cbdb5469caafe17b0e404c3c746f9900469", size = 138290 },
|
{ url = "https://files.pythonhosted.org/packages/a9/ce/d9748ec69b1a4c29b8e2bab8233e8c41c583c69f515b373f1fb00247d8c9/orjson-3.11.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff285d14917ea1408a821786e3677c5261fa6095277410409c694b8e7720ae0", size = 120887 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/36/6961eca0b66b7809d33c4ca58c6bd4c23a1b914fb23aba2fa2883f791434/orjson-3.10.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73be1cbcebadeabdbc468f82b087df435843c809cd079a565fb16f0f3b23238f", size = 142828 },
|
{ url = "https://files.pythonhosted.org/packages/c1/66/b90fac8e4a76e83f981912d7f9524d402b31f6c1b8bff3e498aa321c326c/orjson-3.11.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2662f908114864b63ff75ffe6ffacf996418dd6cc25e02a72ad4bda81b1ec45a", size = 123650 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/2f/0c646d5fd689d3be94f4d83fa9435a6c4322c9b8533edbb3cd4bc8c5f69a/orjson-3.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8936ee2679e38903df158037a2f1c108129dee218975122e37847fb1d4ac68", size = 132806 },
|
{ url = "https://files.pythonhosted.org/packages/33/81/56143898d1689c7f915ac67703efb97e8f2f8d5805ce8c2c3fd0f2bb6e3d/orjson-3.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab463cf5d08ad6623a4dac1badd20e88a5eb4b840050c4812c782e3149fe2334", size = 121287 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/af/65907b40c74ef4c3674ef2bcfa311c695eb934710459841b3c2da212215c/orjson-3.10.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7115fcbc8525c74e4c2b608129bef740198e9a120ae46184dac7683191042056", size = 135005 },
|
{ url = "https://files.pythonhosted.org/packages/80/de/f9c6d00c127be766a3739d0d85b52a7c941e437d8dd4d573e03e98d0f89c/orjson-3.11.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:64414241bde943cbf3c00d45fcb5223dca6d9210148ba984aae6b5d63294502b", size = 119637 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/d1/68bd20ac6a32cd1f1b10d23e7cc58ee1e730e80624e3031d77067d7150fc/orjson-3.10.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:771474ad34c66bc4d1c01f645f150048030694ea5b2709b87d3bda273ffe505d", size = 413418 },
|
{ url = "https://files.pythonhosted.org/packages/67/4c/ab70c7627022d395c1b4eb5badf6196b7144e82b46a3a17ed2354f9e592d/orjson-3.11.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:7773e71c0ae8c9660192ff144a3d69df89725325e3d0b6a6bb2c50e5ebaf9b84", size = 392478 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/31/c701ec0bcc3e80e5cb6e319c628ef7b768aaa24b0f3b4c599df2eaacfa24/orjson-3.10.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c14047dbbea52886dd87169f21939af5d55143dad22d10db6a7514f058156a8", size = 153288 },
|
{ url = "https://files.pythonhosted.org/packages/77/91/d890b873b69311db4fae2624c5603c437df9c857fb061e97706dac550a77/orjson-3.11.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:652ca14e283b13ece35bf3a86503c25592f294dbcfc5bb91b20a9c9a62a3d4be", size = 134343 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/31/5e1aa99a10893a43cfc58009f9da840990cc8a9ebb75aa452210ba18587e/orjson-3.10.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:641481b73baec8db14fdf58f8967e52dc8bda1f2aba3aa5f5c1b07ed6df50b7f", size = 137181 },
|
{ url = "https://files.pythonhosted.org/packages/47/16/1aa248541b4830274a079c4aeb2aa5d1ff17c3f013b1d0d8d16d0848f3de/orjson-3.11.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:26e99e98df8990ecfe3772bbdd7361f602149715c2cbc82e61af89bfad9528a4", size = 123887 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bf/8c/daba0ac1b8690011d9242a0f37235f7d17df6d0ad941021048523b76674e/orjson-3.10.18-cp310-cp310-win32.whl", hash = "sha256:607eb3ae0909d47280c1fc657c4284c34b785bae371d007595633f4b1a2bbe06", size = 142694 },
|
{ url = "https://files.pythonhosted.org/packages/95/e4/7419833c55ac8b5f385d00c02685a260da1f391e900fc5c3e0b797e0d506/orjson-3.11.2-cp310-cp310-win32.whl", hash = "sha256:5814313b3e75a2be7fe6c7958201c16c4560e21a813dbad25920752cecd6ad66", size = 124560 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/16/62/8b687724143286b63e1d0fab3ad4214d54566d80b0ba9d67c26aaf28a2f8/orjson-3.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:8770432524ce0eca50b7efc2a9a5f486ee0113a5fbb4231526d414e6254eba92", size = 134600 },
|
{ url = "https://files.pythonhosted.org/packages/74/f8/27ca7ef3e194c462af32ce1883187f5ec483650c559166f0de59c4c2c5f0/orjson-3.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc471ce2225ab4c42ca672f70600d46a8b8e28e8d4e536088c1ccdb1d22b35ce", size = 119700 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/c7/c54a948ce9a4278794f669a353551ce7db4ffb656c69a6e1f2264d563e50/orjson-3.10.18-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e0a183ac3b8e40471e8d843105da6fbe7c070faab023be3b08188ee3f85719b8", size = 248929 },
|
{ url = "https://files.pythonhosted.org/packages/78/7d/e295df1ac9920cbb19fb4c1afa800e86f175cb657143aa422337270a4782/orjson-3.11.2-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:888b64ef7eaeeff63f773881929434a5834a6a140a63ad45183d59287f07fc6a", size = 226502 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/60/a9c674ef1dd8ab22b5b10f9300e7e70444d4e3cda4b8258d6c2488c32143/orjson-3.10.18-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5ef7c164d9174362f85238d0cd4afdeeb89d9e523e4651add6a5d458d6f7d42d", size = 133364 },
|
{ url = "https://files.pythonhosted.org/packages/65/21/ffb0f10ea04caf418fb4e7ad1fda4b9ab3179df9d7a33b69420f191aadd5/orjson-3.11.2-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:83387cc8b26c9fa0ae34d1ea8861a7ae6cff8fb3e346ab53e987d085315a728e", size = 115999 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c1/4e/f7d1bdd983082216e414e6d7ef897b0c2957f99c545826c06f371d52337e/orjson-3.10.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd14c5d99cdc7bf93f22b12ec3b294931518aa019e2a147e8aa2f31fd3240f7", size = 136995 },
|
{ url = "https://files.pythonhosted.org/packages/90/d5/8da1e252ac3353d92e6f754ee0c85027c8a2cda90b6899da2be0df3ef83d/orjson-3.11.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7e35f003692c216d7ee901b6b916b5734d6fc4180fcaa44c52081f974c08e17", size = 111563 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/17/89/46b9181ba0ea251c9243b0c8ce29ff7c9796fa943806a9c8b02592fce8ea/orjson-3.10.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b672502323b6cd133c4af6b79e3bea36bad2d16bca6c1f645903fce83909a7a", size = 132894 },
|
{ url = "https://files.pythonhosted.org/packages/4f/81/baabc32e52c570b0e4e1044b1bd2ccbec965e0de3ba2c13082255efa2006/orjson-3.11.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4a0a4c29ae90b11d0c00bcc31533854d89f77bde2649ec602f512a7e16e00640", size = 116222 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/dd/7bce6fcc5b8c21aef59ba3c67f2166f0a1a9b0317dcca4a9d5bd7934ecfd/orjson-3.10.18-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51f8c63be6e070ec894c629186b1c0fe798662b8687f3d9fdfa5e401c6bd7679", size = 137016 },
|
{ url = "https://files.pythonhosted.org/packages/8d/b7/da2ad55ad80b49b560dce894c961477d0e76811ee6e614b301de9f2f8728/orjson-3.11.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:585d712b1880f68370108bc5534a257b561672d1592fae54938738fe7f6f1e33", size = 118594 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/4a/b8aea1c83af805dcd31c1f03c95aabb3e19a016b2a4645dd822c5686e94d/orjson-3.10.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9478ade5313d724e0495d167083c6f3be0dd2f1c9c8a38db9a9e912cdaf947", size = 138290 },
|
{ url = "https://files.pythonhosted.org/packages/61/be/014f7eab51449f3c894aa9bbda2707b5340c85650cb7d0db4ec9ae280501/orjson-3.11.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d08e342a7143f8a7c11f1c4033efe81acbd3c98c68ba1b26b96080396019701f", size = 120700 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/d6/7eb05c85d987b688707f45dcf83c91abc2251e0dd9fb4f7be96514f838b1/orjson-3.10.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:187aefa562300a9d382b4b4eb9694806e5848b0cedf52037bb5c228c61bb66d4", size = 142829 },
|
{ url = "https://files.pythonhosted.org/packages/cf/ae/c217903a30c51341868e2d8c318c59a8413baa35af54d7845071c8ccd6fe/orjson-3.11.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c0f84fc50398773a702732c87cd622737bf11c0721e6db3041ac7802a686fb", size = 123433 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/78/ddd3ee7873f2b5f90f016bc04062713d567435c53ecc8783aab3a4d34915/orjson-3.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da552683bc9da222379c7a01779bddd0ad39dd699dd6300abaf43eadee38334", size = 132805 },
|
{ url = "https://files.pythonhosted.org/packages/57/c2/b3c346f78b1ff2da310dd300cb0f5d32167f872b4d3bb1ad122c889d97b0/orjson-3.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:140f84e3c8d4c142575898c91e3981000afebf0333df753a90b3435d349a5fe5", size = 121061 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8c/09/c8e047f73d2c5d21ead9c180203e111cddeffc0848d5f0f974e346e21c8e/orjson-3.10.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e450885f7b47a0231979d9c49b567ed1c4e9f69240804621be87c40bc9d3cf17", size = 135008 },
|
{ url = "https://files.pythonhosted.org/packages/00/c8/c97798f6010327ffc75ad21dd6bca11ea2067d1910777e798c2849f1c68f/orjson-3.11.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96304a2b7235e0f3f2d9363ddccdbfb027d27338722fe469fe656832a017602e", size = 119410 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/4b/dccbf5055ef8fb6eda542ab271955fc1f9bf0b941a058490293f8811122b/orjson-3.10.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5e3c9cc2ba324187cd06287ca24f65528f16dfc80add48dc99fa6c836bb3137e", size = 413419 },
|
{ url = "https://files.pythonhosted.org/packages/37/fd/df720f7c0e35694617b7f95598b11a2cb0374661d8389703bea17217da53/orjson-3.11.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d7612bb227d5d9582f1f50a60bd55c64618fc22c4a32825d233a4f2771a428a", size = 392294 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/f3/1eac0c5e2d6d6790bd2025ebfbefcbd37f0d097103d76f9b3f9302af5a17/orjson-3.10.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:50ce016233ac4bfd843ac5471e232b865271d7d9d44cf9d33773bcd883ce442b", size = 153292 },
|
{ url = "https://files.pythonhosted.org/packages/ba/52/0120d18f60ab0fe47531d520372b528a45c9a25dcab500f450374421881c/orjson-3.11.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a134587d18fe493befc2defffef2a8d27cfcada5696cb7234de54a21903ae89a", size = 134134 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/b4/ef0abf64c8f1fabf98791819ab502c2c8c1dc48b786646533a93637d8999/orjson-3.10.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b3ceff74a8f7ffde0b2785ca749fc4e80e4315c0fd887561144059fb1c138aa7", size = 137182 },
|
{ url = "https://files.pythonhosted.org/packages/ec/10/1f967671966598366de42f07e92b0fc694ffc66eafa4b74131aeca84915f/orjson-3.11.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0b84455e60c4bc12c1e4cbaa5cfc1acdc7775a9da9cec040e17232f4b05458bd", size = 123745 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/a3/6ea878e7b4a0dc5c888d0370d7752dcb23f402747d10e2257478d69b5e63/orjson-3.10.18-cp311-cp311-win32.whl", hash = "sha256:fdba703c722bd868c04702cac4cb8c6b8ff137af2623bc0ddb3b3e6a2c8996c1", size = 142695 },
|
{ url = "https://files.pythonhosted.org/packages/43/eb/76081238671461cfd0f47e0c24f408ffa66184237d56ef18c33e86abb612/orjson-3.11.2-cp311-cp311-win32.whl", hash = "sha256:f0660efeac223f0731a70884e6914a5f04d613b5ae500744c43f7bf7b78f00f9", size = 124393 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/2a/4048700a3233d562f0e90d5572a849baa18ae4e5ce4c3ba6247e4ece57b0/orjson-3.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:c28082933c71ff4bc6ccc82a454a2bffcef6e1d7379756ca567c772e4fb3278a", size = 134603 },
|
{ url = "https://files.pythonhosted.org/packages/26/76/cc598c1811ba9ba935171267b02e377fc9177489efce525d478a2999d9cc/orjson-3.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:955811c8405251d9e09cbe8606ad8fdef49a451bcf5520095a5ed38c669223d8", size = 119561 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/45/10d934535a4993d27e1c84f1810e79ccf8b1b7418cef12151a22fe9bb1e1/orjson-3.10.18-cp311-cp311-win_arm64.whl", hash = "sha256:a6c7c391beaedd3fa63206e5c2b7b554196f14debf1ec9deb54b5d279b1b46f5", size = 131400 },
|
{ url = "https://files.pythonhosted.org/packages/d8/17/c48011750f0489006f7617b0a3cebc8230f36d11a34e7e9aca2085f07792/orjson-3.11.2-cp311-cp311-win_arm64.whl", hash = "sha256:2e4d423a6f838552e3a6d9ec734b729f61f88b1124fd697eab82805ea1a2a97d", size = 114186 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/1a/67236da0916c1a192d5f4ccbe10ec495367a726996ceb7614eaa687112f2/orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753", size = 249184 },
|
{ url = "https://files.pythonhosted.org/packages/40/02/46054ebe7996a8adee9640dcad7d39d76c2000dc0377efa38e55dc5cbf78/orjson-3.11.2-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:901d80d349d8452162b3aa1afb82cec5bee79a10550660bc21311cc61a4c5486", size = 226528 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/bc/c7f1db3b1d094dc0c6c83ed16b161a16c214aaa77f311118a93f647b32dc/orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17", size = 133279 },
|
{ url = "https://files.pythonhosted.org/packages/e2/c6/6b6f0b4d8aea1137436546b990f71be2cd8bd870aa2f5aa14dba0fcc95dc/orjson-3.11.2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:cf3bd3967a360e87ee14ed82cb258b7f18c710dacf3822fb0042a14313a673a1", size = 115931 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/84/664657cd14cc11f0d81e80e64766c7ba5c9b7fc1ec304117878cc1b4659c/orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d", size = 136799 },
|
{ url = "https://files.pythonhosted.org/packages/ae/05/4205cc97c30e82a293dd0d149b1a89b138ebe76afeca66fc129fa2aa4e6a/orjson-3.11.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26693dde66910078229a943e80eeb99fdce6cd2c26277dc80ead9f3ab97d2131", size = 111382 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9a/bb/f50039c5bb05a7ab024ed43ba25d0319e8722a0ac3babb0807e543349978/orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae", size = 132791 },
|
{ url = "https://files.pythonhosted.org/packages/50/c7/b8a951a93caa821f9272a7c917115d825ae2e4e8768f5ddf37968ec9de01/orjson-3.11.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ad4c8acb50a28211c33fc7ef85ddf5cb18d4636a5205fd3fa2dce0411a0e30c", size = 116271 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/93/8c/ee74709fc072c3ee219784173ddfe46f699598a1723d9d49cbc78d66df65/orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f", size = 137059 },
|
{ url = "https://files.pythonhosted.org/packages/17/03/1006c7f8782d5327439e26d9b0ec66500ea7b679d4bbb6b891d2834ab3ee/orjson-3.11.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:994181e7f1725bb5f2d481d7d228738e0743b16bf319ca85c29369c65913df14", size = 119086 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/37/e6d3109ee004296c80426b5a62b47bcadd96a3deab7443e56507823588c5/orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c", size = 138359 },
|
{ url = "https://files.pythonhosted.org/packages/44/61/57d22bc31f36a93878a6f772aea76b2184102c6993dea897656a66d18c74/orjson-3.11.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbb79a0476393c07656b69c8e763c3cc925fa8e1d9e9b7d1f626901bb5025448", size = 120724 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4f/5d/387dafae0e4691857c62bd02839a3bf3fa648eebd26185adfac58d09f207/orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad", size = 142853 },
|
{ url = "https://files.pythonhosted.org/packages/78/a9/4550e96b4c490c83aea697d5347b8f7eb188152cd7b5a38001055ca5b379/orjson-3.11.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:191ed27a1dddb305083d8716af413d7219f40ec1d4c9b0e977453b4db0d6fb6c", size = 123577 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/6f/875e8e282105350b9a5341c0222a13419758545ae32ad6e0fcf5f64d76aa/orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c", size = 133131 },
|
{ url = "https://files.pythonhosted.org/packages/3a/86/09b8cb3ebd513d708ef0c92d36ac3eebda814c65c72137b0a82d6d688fc4/orjson-3.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0afb89f16f07220183fd00f5f297328ed0a68d8722ad1b0c8dcd95b12bc82804", size = 121195 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/b2/73a1f0b4790dcb1e5a45f058f4f5dcadc8a85d90137b50d6bbc6afd0ae50/orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406", size = 134834 },
|
{ url = "https://files.pythonhosted.org/packages/37/68/7b40b39ac2c1c644d4644e706d0de6c9999764341cd85f2a9393cb387661/orjson-3.11.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ab6e6b4e93b1573a026b6ec16fca9541354dd58e514b62c558b58554ae04307", size = 119234 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/f5/7ed133a5525add9c14dbdf17d011dd82206ca6840811d32ac52a35935d19/orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6", size = 413368 },
|
{ url = "https://files.pythonhosted.org/packages/40/7c/bb6e7267cd80c19023d44d8cbc4ea4ed5429fcd4a7eb9950f50305697a28/orjson-3.11.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9cb23527efb61fb75527df55d20ee47989c4ee34e01a9c98ee9ede232abf6219", size = 392250 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/7c/439654221ed9c3324bbac7bdf94cf06a971206b7b62327f11a52544e4982/orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06", size = 153359 },
|
{ url = "https://files.pythonhosted.org/packages/64/f2/6730ace05583dbca7c1b406d59f4266e48cd0d360566e71482420fb849fc/orjson-3.11.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a4dd1268e4035af21b8a09e4adf2e61f87ee7bf63b86d7bb0a237ac03fad5b45", size = 134572 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/e7/d58074fa0cc9dd29a8fa2a6c8d5deebdfd82c6cfef72b0e4277c4017563a/orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5", size = 137466 },
|
{ url = "https://files.pythonhosted.org/packages/96/0f/7d3e03a30d5aac0432882b539a65b8c02cb6dd4221ddb893babf09c424cc/orjson-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff8b155b145eaf5a9d94d2c476fbe18d6021de93cf36c2ae2c8c5b775763f14e", size = 123869 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/4d/fe17581cf81fb70dfcef44e966aa4003360e4194d15a3f38cbffe873333a/orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e", size = 142683 },
|
{ url = "https://files.pythonhosted.org/packages/45/80/1513265eba6d4a960f078f4b1d2bff94a571ab2d28c6f9835e03dfc65cc6/orjson-3.11.2-cp312-cp312-win32.whl", hash = "sha256:ae3bb10279d57872f9aba68c9931aa71ed3b295fa880f25e68da79e79453f46e", size = 124430 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e6/22/469f62d25ab5f0f3aee256ea732e72dc3aab6d73bac777bd6277955bceef/orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc", size = 134754 },
|
{ url = "https://files.pythonhosted.org/packages/fb/61/eadf057b68a332351eeb3d89a4cc538d14f31cd8b5ec1b31a280426ccca2/orjson-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:d026e1967239ec11a2559b4146a61d13914504b396f74510a1c4d6b19dfd8732", size = 119598 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/10/b0/1040c447fac5b91bc1e9c004b69ee50abb0c1ffd0d24406e1350c58a7fcb/orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a", size = 131218 },
|
{ url = "https://files.pythonhosted.org/packages/6b/3f/7f4b783402143d965ab7e9a2fc116fdb887fe53bdce7d3523271cd106098/orjson-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:59f8d5ad08602711af9589375be98477d70e1d102645430b5a7985fdbf613b36", size = 114052 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/f0/8aedb6574b68096f3be8f74c0b56d36fd94bcf47e6c7ed47a7bd1474aaa8/orjson-3.10.18-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:69c34b9441b863175cc6a01f2935de994025e773f814412030f269da4f7be147", size = 249087 },
|
{ url = "https://files.pythonhosted.org/packages/c2/f3/0dd6b4750eb556ae4e2c6a9cb3e219ec642e9c6d95f8ebe5dc9020c67204/orjson-3.11.2-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a079fdba7062ab396380eeedb589afb81dc6683f07f528a03b6f7aae420a0219", size = 226419 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/f7/7118f965541aeac6844fcb18d6988e111ac0d349c9b80cda53583e758908/orjson-3.10.18-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1ebeda919725f9dbdb269f59bc94f861afbe2a27dce5608cdba2d92772364d1c", size = 133273 },
|
{ url = "https://files.pythonhosted.org/packages/44/d5/e67f36277f78f2af8a4690e0c54da6b34169812f807fd1b4bfc4dbcf9558/orjson-3.11.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:6a5f62ebbc530bb8bb4b1ead103647b395ba523559149b91a6c545f7cd4110ad", size = 115803 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fb/d9/839637cc06eaf528dd8127b36004247bf56e064501f68df9ee6fd56a88ee/orjson-3.10.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5adf5f4eed520a4959d29ea80192fa626ab9a20b2ea13f8f6dc58644f6927103", size = 136779 },
|
{ url = "https://files.pythonhosted.org/packages/24/37/ff8bc86e0dacc48f07c2b6e20852f230bf4435611bab65e3feae2b61f0ae/orjson-3.11.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7df6c7b8b0931feb3420b72838c3e2ba98c228f7aa60d461bc050cf4ca5f7b2", size = 111337 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/6d/f226ecfef31a1f0e7d6bf9a31a0bbaf384c7cbe3fce49cc9c2acc51f902a/orjson-3.10.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7592bb48a214e18cd670974f289520f12b7aed1fa0b2e2616b8ed9e069e08595", size = 132811 },
|
{ url = "https://files.pythonhosted.org/packages/b9/25/37d4d3e8079ea9784ea1625029988e7f4594ce50d4738b0c1e2bf4a9e201/orjson-3.11.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f59dfea7da1fced6e782bb3699718088b1036cb361f36c6e4dd843c5111aefe", size = 116222 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/73/2d/371513d04143c85b681cf8f3bce743656eb5b640cb1f461dad750ac4b4d4/orjson-3.10.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f872bef9f042734110642b7a11937440797ace8c87527de25e0c53558b579ccc", size = 137018 },
|
{ url = "https://files.pythonhosted.org/packages/b7/32/a63fd9c07fce3b4193dcc1afced5dd4b0f3a24e27556604e9482b32189c9/orjson-3.11.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edf49146520fef308c31aa4c45b9925fd9c7584645caca7c0c4217d7900214ae", size = 119020 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/cb/a4d37a30507b7a59bdc484e4a3253c8141bf756d4e13fcc1da760a0b00cb/orjson-3.10.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0315317601149c244cb3ecef246ef5861a64824ccbcb8018d32c66a60a84ffbc", size = 138368 },
|
{ url = "https://files.pythonhosted.org/packages/b4/b6/400792b8adc3079a6b5d649264a3224d6342436d9fac9a0ed4abc9dc4596/orjson-3.11.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50995bbeb5d41a32ad15e023305807f561ac5dcd9bd41a12c8d8d1d2c83e44e6", size = 120721 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/ae/cd10883c48d912d216d541eb3db8b2433415fde67f620afe6f311f5cd2ca/orjson-3.10.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0da26957e77e9e55a6c2ce2e7182a36a6f6b180ab7189315cb0995ec362e049", size = 142840 },
|
{ url = "https://files.pythonhosted.org/packages/40/f3/31ab8f8c699eb9e65af8907889a0b7fef74c1d2b23832719a35da7bb0c58/orjson-3.11.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cc42960515076eb639b705f105712b658c525863d89a1704d984b929b0577d1", size = 123574 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/4c/2bda09855c6b5f2c055034c9eda1529967b042ff8d81a05005115c4e6772/orjson-3.10.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb70d489bc79b7519e5803e2cc4c72343c9dc1154258adf2f8925d0b60da7c58", size = 133135 },
|
{ url = "https://files.pythonhosted.org/packages/bd/a6/ce4287c412dff81878f38d06d2c80845709c60012ca8daf861cb064b4574/orjson-3.11.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c56777cab2a7b2a8ea687fedafb84b3d7fdafae382165c31a2adf88634c432fa", size = 121225 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/4a/35971fd809a8896731930a80dfff0b8ff48eeb5d8b57bb4d0d525160017f/orjson-3.10.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9e86a6af31b92299b00736c89caf63816f70a4001e750bda179e15564d7a034", size = 134810 },
|
{ url = "https://files.pythonhosted.org/packages/69/b0/7a881b2aef4fed0287d2a4fbb029d01ed84fa52b4a68da82bdee5e50598e/orjson-3.11.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07349e88025b9b5c783077bf7a9f401ffbfb07fd20e86ec6fc5b7432c28c2c5e", size = 119201 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/70/0fa9e6310cda98365629182486ff37a1c6578e34c33992df271a476ea1cd/orjson-3.10.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c382a5c0b5931a5fc5405053d36c1ce3fd561694738626c77ae0b1dfc0242ca1", size = 413491 },
|
{ url = "https://files.pythonhosted.org/packages/cf/98/a325726b37f7512ed6338e5e65035c3c6505f4e628b09a5daf0419f054ea/orjson-3.11.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:45841fbb79c96441a8c58aa29ffef570c5df9af91f0f7a9572e5505e12412f15", size = 392193 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/32/cb/990a0e88498babddb74fb97855ae4fbd22a82960e9b06eab5775cac435da/orjson-3.10.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e4b2ae732431127171b875cb2668f883e1234711d3c147ffd69fe5be51a8012", size = 153277 },
|
{ url = "https://files.pythonhosted.org/packages/cb/4f/a7194f98b0ce1d28190e0c4caa6d091a3fc8d0107ad2209f75c8ba398984/orjson-3.11.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13d8d8db6cd8d89d4d4e0f4161acbbb373a4d2a4929e862d1d2119de4aa324ac", size = 134548 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/44/473248c3305bf782a384ed50dd8bc2d3cde1543d107138fd99b707480ca1/orjson-3.10.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d808e34ddb24fc29a4d4041dcfafbae13e129c93509b847b14432717d94b44f", size = 137367 },
|
{ url = "https://files.pythonhosted.org/packages/e8/5e/b84caa2986c3f472dc56343ddb0167797a708a8d5c3be043e1e2677b55df/orjson-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51da1ee2178ed09c00d09c1b953e45846bbc16b6420965eb7a913ba209f606d8", size = 123798 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/fd/7f1d3edd4ffcd944a6a40e9f88af2197b619c931ac4d3cfba4798d4d3815/orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea", size = 142687 },
|
{ url = "https://files.pythonhosted.org/packages/9c/5b/e398449080ce6b4c8fcadad57e51fa16f65768e1b142ba90b23ac5d10801/orjson-3.11.2-cp313-cp313-win32.whl", hash = "sha256:51dc033df2e4a4c91c0ba4f43247de99b3cbf42ee7a42ee2b2b2f76c8b2f2cb5", size = 124402 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/03/c75c6ad46be41c16f4cfe0352a2d1450546f3c09ad2c9d341110cd87b025/orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52", size = 134794 },
|
{ url = "https://files.pythonhosted.org/packages/b3/66/429e4608e124debfc4790bfc37131f6958e59510ba3b542d5fc163be8e5f/orjson-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:29d91d74942b7436f29b5d1ed9bcfc3f6ef2d4f7c4997616509004679936650d", size = 119498 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186 },
|
{ url = "https://files.pythonhosted.org/packages/7b/04/f8b5f317cce7ad3580a9ad12d7e2df0714dfa8a83328ecddd367af802f5b/orjson-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:4ca4fb5ac21cd1e48028d4f708b1bb13e39c42d45614befd2ead004a8bba8535", size = 114051 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/83/2c363022b26c3c25b3708051a19d12f3374739bb81323f05b284392080c0/orjson-3.11.2-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3dcba7101ea6a8d4ef060746c0f2e7aa8e2453a1012083e1ecce9726d7554cb7", size = 226406 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/a7/aa3c973de0b33fc93b4bd71691665ffdfeae589ea9d0625584ab10a7d0f5/orjson-3.11.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:15d17bdb76a142e1f55d91913e012e6e6769659daa6bfef3ef93f11083137e81", size = 115788 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ef/f2/e45f233dfd09fdbb052ec46352363dca3906618e1a2b264959c18f809d0b/orjson-3.11.2-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:53c9e81768c69d4b66b8876ec3c8e431c6e13477186d0db1089d82622bccd19f", size = 111318 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3e/23/cf5a73c4da6987204cbbf93167f353ff0c5013f7c5e5ef845d4663a366da/orjson-3.11.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d4f13af59a7b84c1ca6b8a7ab70d608f61f7c44f9740cd42409e6ae7b6c8d8b7", size = 121231 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/40/1d/47468a398ae68a60cc21e599144e786e035bb12829cb587299ecebc088f1/orjson-3.11.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bde64aa469b5ee46cc960ed241fae3721d6a8801dacb2ca3466547a2535951e4", size = 119204 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4d/d9/f99433d89b288b5bc8836bffb32a643f805e673cf840ef8bab6e73ced0d1/orjson-3.11.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b5ca86300aeb383c8fa759566aca065878d3d98c3389d769b43f0a2e84d52c5f", size = 392237 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d4/dc/1b9d80d40cebef603325623405136a29fb7d08c877a728c0943dd066c29a/orjson-3.11.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24e32a558ebed73a6a71c8f1cbc163a7dd5132da5270ff3d8eeb727f4b6d1bc7", size = 134578 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/b3/72e7a4c5b6485ef4e83ef6aba7f1dd041002bad3eb5d1d106ca5b0fc02c6/orjson-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e36319a5d15b97e4344110517450396845cc6789aed712b1fbf83c1bd95792f6", size = 123799 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c8/3e/a3d76b392e7acf9b34dc277171aad85efd6accc75089bb35b4c614990ea9/orjson-3.11.2-cp314-cp314-win32.whl", hash = "sha256:40193ada63fab25e35703454d65b6afc71dbc65f20041cb46c6d91709141ef7f", size = 124461 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/e3/75c6a596ff8df9e4a5894813ff56695f0a218e6ea99420b4a645c4f7795d/orjson-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:7c8ac5f6b682d3494217085cf04dadae66efee45349ad4ee2a1da3c97e2305a8", size = 119494 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/3d/9e74742fc261c5ca473c96bb3344d03995869e1dc6402772c60afb97736a/orjson-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:21cf261e8e79284242e4cb1e5924df16ae28255184aafeff19be1405f6d33f67", size = 114046 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -366,79 +390,104 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pillow"
|
name = "pillow"
|
||||||
version = "11.2.1"
|
version = "11.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707 }
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/8b/b158ad57ed44d3cc54db8d68ad7c0a58b8fc0e4c7a3f995f9d62d5b464a1/pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047", size = 3198442 },
|
{ url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/f8/bb5d956142f86c2d6cc36704943fa761f2d2e4c48b7436fd0a85c20f1713/pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95", size = 3030553 },
|
{ url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/7f/0e413bb3e2aa797b9ca2c5c38cb2e2e45d88654e5b12da91ad446964cfae/pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61", size = 4405503 },
|
{ url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/b4/cc647f4d13f3eb837d3065824aa58b9bcf10821f029dc79955ee43f793bd/pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1", size = 4490648 },
|
{ url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/6f/240b772a3b35cdd7384166461567aa6713799b4e78d180c555bd284844ea/pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c", size = 4508937 },
|
{ url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/5e/7ca9c815ade5fdca18853db86d812f2f188212792780208bdb37a0a6aef4/pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d", size = 4599802 },
|
{ url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/81/c3d9d38ce0c4878a77245d4cf2c46d45a4ad0f93000227910a46caff52f3/pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97", size = 4576717 },
|
{ url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/49/52b719b89ac7da3185b8d29c94d0e6aec8140059e3d8adcaa46da3751180/pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579", size = 4654874 },
|
{ url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/0b/ede75063ba6023798267023dc0d0401f13695d228194d2242d5a7ba2f964/pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d", size = 2331717 },
|
{ url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ed/3c/9831da3edea527c2ed9a09f31a2c04e77cd705847f13b69ca60269eec370/pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad", size = 2676204 },
|
{ url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/97/1f66ff8a1503d8cbfc5bae4dc99d54c6ec1e22ad2b946241365320caabc2/pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2", size = 2414767 },
|
{ url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/08/3fbf4b98924c73037a8e8b4c2c774784805e0fb4ebca6c5bb60795c40125/pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70", size = 3198450 },
|
{ url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/92/6505b1af3d2849d5e714fc75ba9e69b7255c05ee42383a35a4d58f576b16/pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf", size = 3030550 },
|
{ url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/8c/ac2f99d2a70ff966bc7eb13dacacfaab57c0549b2ffb351b6537c7840b12/pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7", size = 4415018 },
|
{ url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/e3/0a58b5d838687f40891fff9cbaf8669f90c96b64dc8f91f87894413856c6/pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8", size = 4498006 },
|
{ url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/f5/6ba14718135f08fbfa33308efe027dd02b781d3f1d5c471444a395933aac/pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600", size = 4517773 },
|
{ url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/f2/805ad600fc59ebe4f1ba6129cd3a75fb0da126975c8579b8f57abeb61e80/pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788", size = 4607069 },
|
{ url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/6b/4ef8a288b4bb2e0180cba13ca0a519fa27aa982875882392b65131401099/pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e", size = 4583460 },
|
{ url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/ae/f29c705a09cbc9e2a456590816e5c234382ae5d32584f451c3eb41a62062/pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e", size = 4661304 },
|
{ url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/1a/c8217b6f2f73794a5e219fbad087701f412337ae6dbb956db37d69a9bc43/pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6", size = 2331809 },
|
{ url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/72/25a8f40170dc262e86e90f37cb72cb3de5e307f75bf4b02535a61afcd519/pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193", size = 2676338 },
|
{ url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/9e/76825e39efee61efea258b479391ca77d64dbd9e5804e4ad0fa453b4ba55/pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7", size = 2414918 },
|
{ url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185 },
|
{ url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306 },
|
{ url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121 },
|
{ url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707 },
|
{ url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921 },
|
{ url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523 },
|
{ url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836 },
|
{ url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390 },
|
{ url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309 },
|
{ url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768 },
|
{ url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087 },
|
{ url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098 },
|
{ url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166 },
|
{ url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674 },
|
{ url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005 },
|
{ url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707 },
|
{ url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008 },
|
{ url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420 },
|
{ url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655 },
|
{ url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329 },
|
{ url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388 },
|
{ url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950 },
|
{ url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759 },
|
{ url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284 },
|
{ url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826 },
|
{ url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329 },
|
{ url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049 },
|
{ url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408 },
|
{ url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863 },
|
{ url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938 },
|
{ url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774 },
|
{ url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895 },
|
{ url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234 },
|
{ url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/49/c8c21e4255b4f4a2c0c68ac18125d7f5460b109acc6dfdef1a24f9b960ef/pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156", size = 3181727 },
|
{ url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6d/f1/f7255c0838f8c1ef6d55b625cfb286835c17e8136ce4351c5577d02c443b/pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772", size = 2999833 },
|
{ url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/57/9968114457bd131063da98d87790d080366218f64fa2943b65ac6739abb3/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363", size = 3437472 },
|
{ url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/1b/e35d8a158e21372ecc48aac9c453518cfe23907bb82f950d6e1c72811eb0/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0", size = 3459976 },
|
{ url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/da/2c11d03b765efff0ccc473f1c4186dc2770110464f2177efaed9cf6fae01/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01", size = 3527133 },
|
{ url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/79/1a/4e85bd7cadf78412c2a3069249a09c32ef3323650fd3005c97cca7aa21df/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193", size = 3571555 },
|
{ url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/03/239939915216de1e95e0ce2334bf17a7870ae185eb390fab6d706aadbfc0/pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013", size = 2674713 },
|
{ url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/ad/2613c04633c7257d9481ab21d6b5364b59fc5d75faafd7cb8693523945a3/pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed", size = 3181734 },
|
{ url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a4/fd/dcdda4471ed667de57bb5405bb42d751e6cfdd4011a12c248b455c778e03/pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c", size = 2999841 },
|
{ url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/89/8a2536e95e77432833f0db6fd72a8d310c8e4272a04461fb833eb021bf94/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd", size = 3437470 },
|
{ url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/8f/abd47b73c60712f88e9eda32baced7bfc3e9bd6a7619bb64b93acff28c3e/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076", size = 3460013 },
|
{ url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f6/20/5c0a0aa83b213b7a07ec01e71a3d6ea2cf4ad1d2c686cc0168173b6089e7/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b", size = 3527165 },
|
{ url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/0e/2abab98a72202d91146abc839e10c14f7cf36166f12838ea0c4db3ca6ecb/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f", size = 3571586 },
|
{ url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383 },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751 },
|
{ url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505 },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -452,10 +501,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "portprotonqt"
|
name = "portprotonqt"
|
||||||
version = "0.1.3"
|
version = "0.1.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "babel" },
|
{ name = "babel" },
|
||||||
|
{ name = "beautifulsoup4" },
|
||||||
{ name = "evdev" },
|
{ name = "evdev" },
|
||||||
{ name = "icoextract" },
|
{ name = "icoextract" },
|
||||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||||
@@ -468,6 +518,7 @@ dependencies = [
|
|||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm" },
|
||||||
{ name = "vdf" },
|
{ name = "vdf" },
|
||||||
|
{ name = "websocket-client" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -480,29 +531,31 @@ dev = [
|
|||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "babel", specifier = ">=2.17.0" },
|
{ name = "babel", specifier = ">=2.17.0" },
|
||||||
{ name = "evdev", specifier = ">=1.9.1" },
|
{ name = "beautifulsoup4", specifier = ">=4.13.4" },
|
||||||
{ name = "icoextract", specifier = ">=0.1.6" },
|
{ name = "evdev", specifier = ">=1.9.2" },
|
||||||
|
{ name = "icoextract", specifier = ">=0.2.0" },
|
||||||
{ name = "numpy", specifier = ">=2.2.4" },
|
{ name = "numpy", specifier = ">=2.2.4" },
|
||||||
{ name = "orjson", specifier = ">=3.10.16" },
|
{ name = "orjson", specifier = ">=3.11.2" },
|
||||||
{ name = "pillow", specifier = ">=11.2.1" },
|
{ name = "pillow", specifier = ">=11.3.0" },
|
||||||
{ name = "psutil", specifier = ">=7.0.0" },
|
{ name = "psutil", specifier = ">=7.0.0" },
|
||||||
{ name = "pyside6", specifier = ">=6.9.0" },
|
{ name = "pyside6", specifier = ">=6.9.1" },
|
||||||
{ name = "pyudev", specifier = ">=0.24.3" },
|
{ name = "pyudev", specifier = ">=0.24.3" },
|
||||||
{ name = "requests", specifier = ">=2.32.3" },
|
{ name = "requests", specifier = ">=2.32.4" },
|
||||||
{ name = "tqdm", specifier = ">=4.67.1" },
|
{ name = "tqdm", specifier = ">=4.67.1" },
|
||||||
{ name = "vdf", specifier = ">=3.4" },
|
{ name = "vdf", specifier = ">=3.4" },
|
||||||
|
{ name = "websocket-client", specifier = ">=1.8.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
dev = [
|
dev = [
|
||||||
{ name = "pre-commit", specifier = ">=4.2.0" },
|
{ name = "pre-commit", specifier = ">=4.3.0" },
|
||||||
{ name = "pyaspeller", specifier = ">=2.0.2" },
|
{ name = "pyaspeller", specifier = ">=2.0.2" },
|
||||||
{ name = "pyright", specifier = ">=1.1.400" },
|
{ name = "pyright", specifier = ">=1.1.403" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pre-commit"
|
name = "pre-commit"
|
||||||
version = "4.2.0"
|
version = "4.3.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cfgv" },
|
{ name = "cfgv" },
|
||||||
@@ -511,9 +564,9 @@ dependencies = [
|
|||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "virtualenv" },
|
{ name = "virtualenv" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 }
|
sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 },
|
{ url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -545,15 +598,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyright"
|
name = "pyright"
|
||||||
version = "1.1.402"
|
version = "1.1.403"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "nodeenv" },
|
{ name = "nodeenv" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207 }
|
sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526 }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004 },
|
{ url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504 },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -684,6 +737,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/de/ce/6ccd382fbe1a96926c5514afa6f2c42da3a9a8482e61f8dfc6068a9ca64f/shiboken6-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:2a39997ce275ced7853defc89d3a1f19a11c90991ac6eef3435a69bb0b7ff1de", size = 1831623 },
|
{ url = "https://files.pythonhosted.org/packages/de/ce/6ccd382fbe1a96926c5514afa6f2c42da3a9a8482e61f8dfc6068a9ca64f/shiboken6-6.9.1-cp39-abi3-win_arm64.whl", hash = "sha256:2a39997ce275ced7853defc89d3a1f19a11c90991ac6eef3435a69bb0b7ff1de", size = 1831623 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "soupsieve"
|
||||||
|
version = "2.7"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/f4/4a80cd6ef364b2e8b65b15816a843c0980f7a5a2b4dc701fc574952aa19f/soupsieve-2.7.tar.gz", hash = "sha256:ad282f9b6926286d2ead4750552c8a6142bc4c783fd66b0293547c8fe6ae126a", size = 103418 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/9c/0e6afc12c269578be5c0c1c9f4b49a8d32770a080260c333ac04cc1c832d/soupsieve-2.7-py3-none-any.whl", hash = "sha256:6e60cc5c1ffaf1cebcc12e8188320b72071e922c2e897f737cadce79ad5d30c4", size = 36677 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tqdm"
|
name = "tqdm"
|
||||||
version = "4.67.1"
|
version = "4.67.1"
|
||||||
@@ -736,3 +798,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c
|
|||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 },
|
{ url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websocket-client"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 },
|
||||||
|
]
|
||||||
|