Compare commits
	
		
			226 Commits
		
	
	
		
			v0.1.4
			...
			accc9b18b6
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| accc9b18b6 | |||
| 82249d7eab | |||
| 476c896940 | |||
| b1047ba18e | |||
| 987199d8e6 | |||
|  | ef1acd4581 | ||
| 96f884904c | |||
| b856a2afae | |||
| 55ef0030e6 | |||
| 8aaeaa4824 | |||
| f55372b480 | |||
| 4d6f32f053 | |||
| a2f5141b20 | |||
| e3cb2857e7 | |||
| efe8a35832 | |||
| 61fae97dad | |||
| 5442100f64 | |||
| 2d6ef84798 | |||
|  | f4aee15b5d | ||
| 87a65108a5 | |||
| bb617708ac | |||
| 1cf332cd87 | |||
| 577ad4d3a3 | |||
| ef3f2d6e96 | |||
| 657d7728a6 | |||
| 9452bfda2e | |||
| 7eb2db0d68 | |||
| 6ef7a03366 | |||
| e5af354b56 | |||
| e6e5f6c8ea | |||
| 84306bb31b | |||
| 60af4d1482 | |||
| 692e11b21d | |||
| b1a804811e | |||
| 9a30cfaea7 | |||
| 5dd2f71f5e | |||
| dba172361b | |||
| a9c70b8818 | |||
| 135ace732f | |||
| 8b727f64e1 | |||
| a8eb591da5 | |||
| fe4ca1ee87 | |||
| ffe3e9d3d6 | |||
| 49d39b5d61 | |||
|  | 03566da704 | ||
|  | 7f996ab6a0 | ||
|  | 9e17978155 | ||
| 5d0185b1b4 | |||
| 5c134be04e | |||
| 8c66695192 | |||
| 7a141d8e46 | |||
| abb2377fb7 | |||
| 75f4f346de | |||
| 87a9f85272 | |||
| 240f685ece | |||
| af4e3e95bb | |||
| 017d9a42cf | |||
| 18b7c4054b | |||
| dd7f71b70a | |||
| 8fd44c575b | |||
| 65b43c1572 | |||
| f35276abfe | |||
| 6fea9a9a7e | |||
| 5189474631 | |||
|  | 416cc6a268 | ||
|  | 3b44ed5252 | ||
| c8c45dda06 | |||
| 3f9f794e6f | |||
| ba9d8b76d8 | |||
| e99c71c1f8 | |||
| baec62d1cb | |||
| cb76961e4f | |||
|  | 081cd07253 | ||
| b5efee29ea | |||
| 69360f7e7e | |||
|  | 39712f0591 | ||
|  | 60b508af18 | ||
|  | b6637b4163 | ||
|  | 6d9eed42f8 | ||
| 7372e3b7f5 | |||
| e0d5bd7993 | |||
|  | 12f8067af1 | ||
|  | 716a813ca9 | ||
| c62cc6853f | |||
| 2e018b4690 | |||
| ad5b25f713 | |||
| 3fb8201305 | |||
| 04d8302d6c | |||
|  | f868b21178 | ||
|  | ebe25b41d8 | ||
|  | fae6cad52d | ||
|  | 42bce11ada | ||
| f088c01768 | |||
| e7eee85ed4 | |||
| ecfe252ae3 | |||
| 1ad19bff6a | |||
| 98f07a9792 | |||
| d5c53ed1aa | |||
| 5a2ab36b60 | |||
| 8e25c04f56 | |||
| f249b01dc6 | |||
| 9f32afe6a3 | |||
| f475e6e0b2 | |||
| 43a7c37e91 | |||
| f1cf0ffd68 | |||
| 70ed3abcb5 | |||
| f061b1597e | |||
| 0f37a8fc6f | |||
| 850bc57a16 | |||
| 0dcc3ea13f | |||
| 1c82b34e36 | |||
| a8c4ae6f7b | |||
| dd4f658b66 | |||
| bff6b7fd34 | |||
| 1e191bbba3 | |||
| 4356e653b8 | |||
| 4fc95511f1 | |||
| 4d4e14ea52 | |||
| c39f5ad83b | |||
| f3325ca35f | |||
| 50645066dd | |||
| 7945dd8980 | |||
| 59c38f9c57 | |||
| a2d5d28884 | |||
| 16af4b410a | |||
| e8e42b5a86 | |||
| d16e2cdf43 | |||
|  | b60fd0d593 | ||
| d93f23fe8c | |||
| 5423ada8f1 | |||
| 2547c7c78d | |||
| 2e93073446 | |||
|  | 9657ff20d3 | ||
| 849333c283 | |||
| 8e11dac987 | |||
| 358afbdbdb | |||
| 83730499e2 | |||
| 84f560ed30 | |||
| 888c9ac387 | |||
| 68d06ca05c | |||
| 6923a5f05c | |||
| f3f85441d8 | |||
| eb90836710 | |||
| dd125c975b | |||
| 4521d3ca1c | |||
| dd044dbd95 | |||
| 0047b29cd2 | |||
| d0fbc79168 | |||
| 57f6ac9c4b | |||
| 60271f7a13 | |||
| 38ab4acc86 | |||
| 8f54f4814c | |||
| 37254b89f1 | |||
| 893e33bdce | |||
| 1ee784d890 | |||
| 39f505079c | |||
| 46253115ff | |||
| 31a7ef3e7e | |||
|  | 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 | 
| @@ -12,17 +12,27 @@ jobs: | ||||
|     name: Build AppImage | ||||
|     runs-on: ubuntu-22.04 | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Install required dependencies | ||||
|         run: | | ||||
|             sudo apt update | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme | ||||
|  | ||||
|       - name: Install tools | ||||
|       - name: Upgrade pip toolchain | ||||
|         run: | | ||||
|             pip3 install git+https://github.com/Frederic98/appimage-builder.git | ||||
|             pip3 install uv | ||||
|           python3 -m pip install --upgrade \ | ||||
|             pip setuptools setuptools-scm wheel packaging build | ||||
|  | ||||
|       - name: Install appimage-builder | ||||
|         run: | | ||||
|           git clone https://github.com/Boria138/appimage-builder | ||||
|           cd appimage-builder | ||||
|           pip install . | ||||
|  | ||||
|       - name: Install uv | ||||
|         run: | | ||||
|           pip install uv | ||||
|  | ||||
|       - name: Build AppImage | ||||
|         run: | | ||||
| @@ -42,7 +52,7 @@ jobs: | ||||
|  | ||||
|     strategy: | ||||
|       matrix: | ||||
|         fedora_version: [41, 42, rawhide] | ||||
|         fedora_version: [41, 42, 43, rawhide] | ||||
|  | ||||
|     container: | ||||
|       image: fedora:${{ matrix.fedora_version }} | ||||
| @@ -63,7 +73,7 @@ jobs: | ||||
|           echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros | ||||
|  | ||||
|       - name: Checkout repo | ||||
|         uses: https://gitea.com/actions/checkout@v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Copy fedora.spec | ||||
|         run: | | ||||
| @@ -84,7 +94,7 @@ jobs: | ||||
|     name: Build Arch Package | ||||
|     runs-on: ubuntu-22.04 | ||||
|     container: | ||||
|       image: archlinux:base-devel | ||||
|       image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
| @@ -124,7 +134,7 @@ jobs: | ||||
|           su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" | ||||
|  | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Upload Arch package | ||||
|         uses: https://gitea.com/actions/gitea-upload-artifact@v4 | ||||
|   | ||||
| @@ -8,7 +8,7 @@ on: | ||||
|  | ||||
| env: | ||||
|   # Common version, will be used for tagging the release | ||||
|   VERSION: 0.1.4 | ||||
|   VERSION: 0.1.7 | ||||
|   PKGDEST: "/tmp/portprotonqt" | ||||
|   PACKAGE: "portprotonqt" | ||||
|   GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} | ||||
| @@ -23,12 +23,22 @@ jobs: | ||||
|       - name: Install required dependencies | ||||
|         run: | | ||||
|             sudo apt update | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme | ||||
|  | ||||
|       - name: Install tools | ||||
|       - name: Upgrade pip toolchain | ||||
|         run: | | ||||
|             pip3 install git+https://github.com/Frederic98/appimage-builder.git | ||||
|             pip3 install uv | ||||
|           python3 -m pip install --upgrade \ | ||||
|             pip setuptools setuptools-scm wheel packaging build | ||||
|  | ||||
|       - name: Install appimage-builder | ||||
|         run: | | ||||
|           git clone https://github.com/Boria138/appimage-builder | ||||
|           cd appimage-builder | ||||
|           pip install . | ||||
|  | ||||
|       - name: Install uv | ||||
|         run: | | ||||
|           pip install uv | ||||
|  | ||||
|       - name: Build AppImage | ||||
|         run: | | ||||
| @@ -99,7 +109,7 @@ jobs: | ||||
|  | ||||
|     strategy: | ||||
|       matrix: | ||||
|         fedora_version: [41, 42, rawhide] | ||||
|         fedora_version: [41, 42, 43, rawhide] | ||||
|  | ||||
|     container: | ||||
|       image: fedora:${{ matrix.fedora_version }} | ||||
| @@ -159,6 +169,7 @@ jobs: | ||||
|           mkdir -p extracted | ||||
|           find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \; | ||||
|           find extracted/ -type f -exec mv {} release/ \; | ||||
|           find release/ -name '*.zip' -delete | ||||
|           rm -rf extracted/ | ||||
|  | ||||
|       - name: Extract changelog for version | ||||
| @@ -169,6 +180,8 @@ jobs: | ||||
|  | ||||
|       - name: Release | ||||
|         uses: https://gitea.com/actions/gitea-release-action@v1 | ||||
|         env: | ||||
|             NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18 | ||||
|         with: | ||||
|           body_path: changelog.txt | ||||
|           token: ${{ env.GITEA_TOKEN }} | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| name: Check Translations | ||||
| name: Check Translations (disabled until yaspeller is fixed) | ||||
| run-name: Check spelling in translation files | ||||
| on: | ||||
|   push: | ||||
| @@ -12,13 +12,14 @@ on: | ||||
|  | ||||
| jobs: | ||||
|   check-translations: | ||||
|     if: false | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Python | ||||
|         uses: https://gitea.com/actions/setup-python@v5 | ||||
|         uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | ||||
|         with: | ||||
|           python-version-file: "pyproject.toml" | ||||
|  | ||||
|   | ||||
| @@ -18,7 +18,7 @@ jobs: | ||||
|       fedora:   ${{ steps.check.outputs.fedora }} | ||||
|       arch:     ${{ steps.check.outputs.arch }} | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|         with: | ||||
|           fetch-depth: 0 | ||||
|  | ||||
| @@ -63,16 +63,16 @@ jobs: | ||||
|     needs: changes | ||||
|     if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch' | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Install required dependencies | ||||
|         run: | | ||||
|             sudo apt update | ||||
|             sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git | ||||
|             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/Frederic98/appimage-builder.git | ||||
|             pip3 install git+https://github.com/Boria138/appimage-builder.git | ||||
|             pip3 install uv | ||||
|  | ||||
|       - name: Build AppImage | ||||
| @@ -115,7 +115,7 @@ jobs: | ||||
|           echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros | ||||
|  | ||||
|       - name: Checkout repo | ||||
|         uses: https://gitea.com/actions/checkout@v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Copy fedora-git.spec | ||||
|         run: | | ||||
| @@ -138,7 +138,7 @@ jobs: | ||||
|     needs: changes | ||||
|     if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' | ||||
|     container: | ||||
|       image: archlinux:base-devel | ||||
|       image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821 | ||||
|       volumes: | ||||
|         - /usr:/usr-host | ||||
|         - /opt:/opt-host | ||||
| @@ -178,7 +178,7 @@ jobs: | ||||
|           su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" | ||||
|  | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Upload Arch package | ||||
|         uses: https://gitea.com/actions/gitea-upload-artifact@v4 | ||||
|   | ||||
| @@ -20,12 +20,18 @@ jobs: | ||||
|     name: Check code | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@v4 | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Install uv | ||||
|         uses: https://github.com/astral-sh/setup-uv@v6 | ||||
|       - name: Set up Node.js | ||||
|         uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 | ||||
|         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 | ||||
|         run: uv sync --all-extras --dev | ||||
|   | ||||
| @@ -11,10 +11,10 @@ jobs: | ||||
|     runs-on: ubuntu-latest | ||||
|     steps: | ||||
|       - name: Checkout | ||||
|         uses: https://gitea.com/actions/checkout@v4 | ||||
|         uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Python | ||||
|         uses: https://gitea.com/actions/setup-python@v5 | ||||
|         uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 | ||||
|         with: | ||||
|           python-version-file: "pyproject.toml" | ||||
|  | ||||
|   | ||||
| @@ -8,11 +8,31 @@ on: | ||||
| jobs: | ||||
|   renovate: | ||||
|     runs-on: ubuntu-latest | ||||
|     container: ghcr.io/renovatebot/renovate:41.1.4 | ||||
|     container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6 | ||||
|     steps: | ||||
|       - uses: https://gitea.com/actions/checkout@v4 | ||||
|       - run: renovate | ||||
|       - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 | ||||
|  | ||||
|       - name: Set up Node.js | ||||
|         uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 | ||||
|         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: | ||||
|           RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js" | ||||
|           RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js" | ||||
|           LOG_LEVEL: "debug" | ||||
|           RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} | ||||
|           RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }} | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
| exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)' | ||||
| repos: | ||||
|   - repo: https://github.com/pre-commit/pre-commit-hooks | ||||
|     rev: v5.0.0 | ||||
|     rev: v6.0.0 | ||||
|     hooks: | ||||
|       - id: trailing-whitespace | ||||
|       - id: end-of-file-fixer | ||||
| @@ -11,15 +11,14 @@ repos: | ||||
|       - id: check-yaml | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/uv-pre-commit | ||||
|     rev: 0.6.14 | ||||
|     rev: 0.8.22 | ||||
|     hooks: | ||||
|       - id: uv-lock | ||||
|  | ||||
|   - repo: https://github.com/astral-sh/ruff-pre-commit | ||||
|     rev: v0.11.5 | ||||
|     rev: v0.14.0 | ||||
|     hooks: | ||||
|       - id: ruff | ||||
|         args: [--fix] | ||||
|       - id: ruff-check | ||||
|  | ||||
|   - repo: local | ||||
|     hooks: | ||||
|   | ||||
							
								
								
									
										298
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						| @@ -3,27 +3,127 @@ | ||||
| Все заметные изменения в этом проекте фиксируются в этом файле. | ||||
| Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). | ||||
|  | ||||
| ## [0.1.7] - 2025-10-12 | ||||
|  | ||||
| ### Added | ||||
| - Возможность скроллинга библиотеки мышью или пальцем | ||||
| - Импорт и экспорт бекапа префикса | ||||
| - Диалог для управление Winetricks | ||||
| - Кнопки для удаления префикса, wine или proton | ||||
| - Все настройки Wine с оригинального PortProton | ||||
| - Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках | ||||
| - Вкладка автоустановок | ||||
| - В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита | ||||
|  | ||||
| ### Changed | ||||
| - Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр | ||||
| - В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений | ||||
| - Исправлено зависание при добавлении или удалении игры в Wayland | ||||
| - Исправлено зависание при поиске игр | ||||
| - Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity) | ||||
| - Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада | ||||
| - Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена | ||||
| - При сохранении настроек теперь не меняется размер окна | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## [0.1.6] - 2025-09-23 | ||||
|  | ||||
| ### Added | ||||
| - Кэширование шрифтов в load_theme_fonts для предотвращения повторной загрузки | ||||
| - Проверка безопасности в theme_manager.py для всех сторонних тем, с проверкой на запрещённые модули и функции (подробности см. в коде theme_manager под полями FORBIDDEN_MODULES и FORBIDDEN_FUNCTIONS) | ||||
| - Фильтрация ASRock LED контроллера, чтобы предотвратить его обнаружение как геймпада | ||||
| - Подсказки по управлению в интерфейсе | ||||
| - Поддержка боковой кнопки мыши, которая теперь работает как кнопка "назад" | ||||
| - Аргумент cli --debug-level для указания уровня дебага | ||||
|  | ||||
| ### Changed | ||||
| - Управления с геймпада теперь перехватывается только если окно в фокусе | ||||
|  | ||||
| ### Fixed | ||||
| - Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON | ||||
| - Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## [0.1.5] - 2025-08-31 | ||||
|  | ||||
| ### Added | ||||
| - Больше типов анимаций при открытии карточки игры (подробности см. в документации). | ||||
| - Второй тип анимации при наведении и фокусе карточки (подробности см. в документации). | ||||
| - Анимация при закрытии карточки игры (подробности см. в документации). | ||||
| - Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок). | ||||
| - Система быстрого доступа (избранного) в диалоге выбора файлов. | ||||
| - Автоматическая прокрутка для панели дисков в диалоге выбора файлов. | ||||
| - Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру. | ||||
| - Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace. | ||||
| - Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр. | ||||
| - Пункт "Выход" в трей. | ||||
| - Пункт "Темы" в трей для быстрого переключения тем. | ||||
| - Двойной клик по иконке трея для показа/скрытия главного окна. | ||||
| - Запуск через трей показывает модальное окно для слежки за процессом запуска | ||||
|  | ||||
| ### Changed | ||||
| - Уменьшена длительность анимации открытия карточки с 800 до 350 мс. | ||||
| - Контекстное меню при открытии теперь сразу фокусируется на первом элементе. | ||||
| - Анимации теперь можно настраивать через темы (подробности см. в документации). | ||||
| - Общие JSON-файлы (`steam_apps` и `anticheat_games`) теперь перекачиваются, если они повреждены. | ||||
| - Временно удалена светлая тема. | ||||
| - Добавление и удаление игр из Steam больше не требует перезапуска клиента. | ||||
| - Обновлены все зависимости (затрагивает только AppImage). | ||||
| - Приложение теперь не закрывается полностью, а сворачивается в трей. | ||||
| - Карточки теперь все находятся друг под другом, а не в разнабой | ||||
| - Изменено соотношение сторон карточек | ||||
|  | ||||
| ### Fixed | ||||
| - `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 | ||||
| - Переводы в переопределениях (подробности см. в документации). | ||||
| - Обложки и описания для всех автоинсталлов. | ||||
| - Возможность указать ссылку для скачивания обложки в диалоге добавления игры. | ||||
| - Интеграция с howlongtobeat.com. | ||||
|  | ||||
| ### Changed | ||||
| - Оптимизированны обложки автоинсталлов | ||||
| - Папка custom_data исключена из сборки модуля для уменьшение его размера | ||||
| - Бейдж PortProton теперь открывает PortProtonDB | ||||
| - Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии | ||||
| - Удалён аргумент `--session` так как тестирование gamescope сессии завершено | ||||
| - В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton" | ||||
| - Оптимизированы обложки автоинсталлов. | ||||
| - Папка `custom_data` исключена из сборки модуля для уменьшения его размера. | ||||
| - Бейдж PortProton теперь открывает PortProtonDB. | ||||
| - Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в Gamescope-сессии. | ||||
| - Удалён аргумент `--session`, так как тестирование Gamescope-сессии завершено. | ||||
| - В контекстном меню игр без exe-файла теперь отображается только пункт «Удалить из PortProton». | ||||
|  | ||||
| ### Fixed | ||||
| - Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси | ||||
| - Путь к portprotonqt-session-select в оверлее | ||||
| - Работа exiftool в AppImage | ||||
| - Открытие контекстного меню у игр без exe | ||||
| - Запрос к GitHub API при загрузке legendary теперь учитывает настройки прокси. | ||||
| - Путь к `portprotonqt-session-select` в оверлее. | ||||
| - Работа `exiftool` в AppImage. | ||||
| - Открытие контекстного меню у игр без exe-файла. | ||||
|  | ||||
| ### Contributors | ||||
| - @Vector_null | ||||
| @@ -33,32 +133,32 @@ | ||||
| ## [0.1.3] - 2025-07-05 | ||||
|  | ||||
| ### Added | ||||
| - Аргумент `--session` для запуска приложения в gamescope (Исключительно в целях тестирования) | ||||
| - Начальная поддержка EGS (Без EOS, скачивания игр и запуска игр из сторонних магазинов) | ||||
| - Автодополнение bash для комманды portprotonqt | ||||
| - Поддержка геймпадов в диалоге выбора игры | ||||
| - Быстрый запуск и остановка игры через контекстное меню | ||||
| - Иконки в контекстом меню | ||||
| - Обложки для части автоинсталлов | ||||
| - Аргумент `--session` для запуска приложения в Gamescope (исключительно в целях тестирования). | ||||
| - Начальная поддержка EGS (без EOS, скачивания и запуска игр из сторонних магазинов). | ||||
| - Автодополнение bash для команды `portprotonqt`. | ||||
| - Поддержка геймпадов в диалоге выбора игры. | ||||
| - Быстрый запуск и остановка игры через контекстное меню. | ||||
| - Иконки в контекстном меню. | ||||
| - Обложки для части автоинсталлов. | ||||
|  | ||||
| ### Changed | ||||
| - Удалены сборки для Fedora 40 | ||||
| - Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем. | ||||
| - Статус выделения и наведения на карточки теперь взаимоисключают друг друга | ||||
| - Все desktop файлы создаются с коментарием "Запустить игру {название} через PortProton" | ||||
| - Заполнители в переводах теперь стали более осмысленными | ||||
| - Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope | ||||
| - Текст бейджей теперь обрезается через ... если не помещается | ||||
| - Удалены сборки для Fedora 40. | ||||
| - Параметры анимации GameCard перенесены в `styles.py` с подробной документацией для кастомизации тем. | ||||
| - Статусы выделения и наведения на карточки теперь взаимоисключающие. | ||||
| - Все desktop-файлы создаются с комментарием «Запустить игру {название} через PortProton». | ||||
| - Заполнители в переводах стали более осмысленными. | ||||
| - Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope. | ||||
| - Текст бейджей теперь обрезается троеточием, если не помещается. | ||||
|  | ||||
| ### Fixed | ||||
| - Дублирование обводки выделения карточек при быстром перемешении мыши | ||||
| - Завершение приложения при закритие окна | ||||
| - Использование системной палитры в темах | ||||
| - Ошибки темы в нативном пакете | ||||
| - Ошибки темы в Gamescope | ||||
| - Размер иконок для desktop файлов теперь 128x128 | ||||
| - Пустая область при обновлении сетки игр | ||||
| - Запуск игры при открытом оверлее | ||||
| - Дублирование обводки карточек при быстром перемещении мыши. | ||||
| - Завершение приложения при закрытии окна. | ||||
| - Использование системной палитры в темах. | ||||
| - Ошибки тем в нативном пакете. | ||||
| - Ошибки тем в Gamescope. | ||||
| - Размер иконок для desktop-файлов теперь 128x128. | ||||
| - Пустая область при обновлении сетки игр. | ||||
| - Запуск игры при открытом оверлее. | ||||
|  | ||||
| ### Contributors | ||||
| - @Dervart | ||||
| @@ -69,63 +169,63 @@ | ||||
| ## [0.1.2] - 2025-06-15 | ||||
|  | ||||
| ### Added | ||||
| - Кнопки сброса настроек и очистки кэша | ||||
| - Бейдж PortProton | ||||
| - Зависимость от `xdg-utils` | ||||
| - Интеграция статуса WeAntiCheatYet в карточку | ||||
| - Переключение полноэкршанного режима через F11 или кнопку Select на геймпаде | ||||
| - Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде | ||||
| - Закрытие диалога добавления игры через ESC или кнопку B на геймпаде | ||||
| - Закрытие окна приложения по комбинации клавиш Ctrl+Q | ||||
| - Сохранение и восстановление размера окна при перезапуске | ||||
| - Переключатель полноэкранного режима приложения | ||||
| - Пункт в контекстном меню «Открыть папку игры» | ||||
| - Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam» | ||||
| - Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного» | ||||
| - Метод сортировки «Сначала избранное» | ||||
| - Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена) | ||||
| - Поддержка управления геймпадом в `QMenu` и `QComboBox` | ||||
| - Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме | ||||
| - Оверлей на кнопку  Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или переключения между сессиями | ||||
| - [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt) | ||||
| - Пресеты управления для DualShock 4 и DualSense | ||||
| - Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию выключена) | ||||
| - Переводы пунктов настроек | ||||
| - Кнопки сброса настроек и очистки кэша. | ||||
| - Бейдж PortProton. | ||||
| - Зависимость от `xdg-utils`. | ||||
| - Интеграция статуса WeAntiCheatYet в карточку. | ||||
| - Переключение полноэкранного режима через F11 или кнопку Select на геймпаде. | ||||
| - Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде. | ||||
| - Закрытие диалога добавления игры через ESC или кнопку B на геймпаде. | ||||
| - Закрытие приложения комбинацией клавиш Ctrl+Q. | ||||
| - Сохранение и восстановление размера окна при перезапуске. | ||||
| - Переключатель полноэкранного режима приложения. | ||||
| - Пункт в контекстном меню «Открыть папку игры». | ||||
| - Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam». | ||||
| - Пункты в контекстном меню «Добавить в избранное» и «Удалить из избранного». | ||||
| - Метод сортировки «Сначала избранное». | ||||
| - Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена). | ||||
| - Поддержка управления геймпадом в `QMenu` и `QComboBox`. | ||||
| - Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме. | ||||
| - Оверлей на кнопку Insert или Xbox/PS-кнопку на геймпаде для закрытия приложения, выключения, перезагрузки, перехода в спящий режим или переключения между сессиями. | ||||
| - [Gamescope-сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt). | ||||
| - Пресеты управления для DualShock 4 и DualSense. | ||||
| - Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию отключена). | ||||
| - Переводы пунктов настроек. | ||||
|  | ||||
| ### Changed | ||||
| - Обновлены все иконки | ||||
| - Переименована функция `_get_steam_home` в `get_steam_home` | ||||
| - Переименован `steam_game` в `game_source` | ||||
| - Логика контекстного меню вынесена в `ContextMenuManager` | ||||
| - Бейдж Steam теперь открывает Steam Community | ||||
| - Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary | ||||
| - Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна | ||||
| - Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке | ||||
| - Установлена ширина бейджа в две трети ширины карточки | ||||
| - Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites` | ||||
| - Карточки теперь фокусируются в направлении движения стрелок или D-pad: | ||||
| - Поддерживается удержание D-pad для непрерывного переключения карточек | ||||
| - Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности | ||||
| - D-pad больше не переключает вкладки (только кнопки RB/LB) | ||||
| - Кнопка добавления игры больше не фокусируется | ||||
| - Диалог добавления игры теперь открывается только в библиотеке | ||||
| - Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt | ||||
| - Размер карточек теперь меняется только при отпускании слайдера | ||||
| - Слайдер теперь управляется через тригеры на геймпаде | ||||
| - Диалог добавления игры теперь открывается на X, а не на Y | ||||
| - Обновлены все иконки. | ||||
| - Функция `_get_steam_home` переименована в `get_steam_home`. | ||||
| - `steam_game` переименован в `game_source`. | ||||
| - Логика контекстного меню вынесена в `ContextMenuManager`. | ||||
| - Бейдж Steam теперь открывает Steam Community. | ||||
| - Лицензия изменена с MIT на GPL-3.0 для совместимости с кодом legendary. | ||||
| - Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна. | ||||
| - Бейджи с карточек теперь отображаются и на странице с деталями, а не только в библиотеке. | ||||
| - Установлена ширина бейджа в 2/3 ширины карточки. | ||||
| - Бейджи источников (`Steam`, `EGS`, `PortProton`) отображаются только при активном фильтре `all` или `favorites`. | ||||
| - Карточки теперь фокусируются в направлении движения стрелок или D-pad. | ||||
| - Поддерживается удержание D-pad для непрерывного переключения карточек. | ||||
| - Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности. | ||||
| - D-pad больше не переключает вкладки (только кнопки RB/LB). | ||||
| - Кнопка добавления игры больше не получает фокус. | ||||
| - Диалог добавления игры открывается только в библиотеке. | ||||
| - Все упоминания PortProtonQT заменены на PortProtonQt. | ||||
| - Размер карточек меняется только при отпускании слайдера. | ||||
| - Слайдер теперь управляется триггерами на геймпаде. | ||||
| - Диалог добавления игры теперь открывается на X, а не на Y. | ||||
|  | ||||
| ### Fixed | ||||
| - Возврат к теме «standard» при выборе несуществующей темы | ||||
| - Корректное открытие контекстного меню | ||||
| - Запуск приложения при отсутствии `exiftool` | ||||
| - Предотвращено бесконечное обращение к `get_portproton_location` | ||||
| - Обновлены ссылки на документацию в README | ||||
| - Устранён traceback при отсутствии обложек (placeholder) | ||||
| - Устранены утечки памяти при загрузке обложек | ||||
| - Исправлены ошибки при подключении геймпада | ||||
| - Предотвращено многократное открытие диалога добавления игры через геймпад | ||||
| - Корректная обработка событий геймпада во время игры | ||||
| - Убийсво всех процессов "зомби" при закрытии программы | ||||
| - Возврат к теме «standard» при выборе несуществующей темы. | ||||
| - Корректное открытие контекстного меню. | ||||
| - Запуск приложения при отсутствии `exiftool`. | ||||
| - Предотвращено бесконечное обращение к `get_portproton_location`. | ||||
| - Обновлены ссылки на документацию в README. | ||||
| - Исправлено падение при отсутствии обложек (placeholder). | ||||
| - Устранены утечки памяти при загрузке обложек. | ||||
| - Исправлены ошибки при подключении геймпада. | ||||
| - Предотвращено многократное открытие диалога добавления игры через геймпад. | ||||
| - Корректная обработка событий геймпада во время игры. | ||||
| - Убийство всех процессов-зомби при закрытии программы. | ||||
|  | ||||
| ### Contributors | ||||
| - @Vector_null | ||||
| @@ -136,20 +236,20 @@ | ||||
| ## [0.1.1] – 2025-05-17 | ||||
|  | ||||
| ### Added | ||||
| - Алфавитная сортировка библиотеки | ||||
| - Проверка переводов через yaspeller | ||||
| - Сборка Fedora-пакета | ||||
| - Сборка AppImage | ||||
| - Алфавитная сортировка библиотеки. | ||||
| - Проверка переводов через yaspeller. | ||||
| - Сборка Fedora-пакета. | ||||
| - Сборка AppImage. | ||||
|  | ||||
| ### Changed | ||||
| - Удалён жёстко заданный размер окна | ||||
| - Использован `icoextract` как Python-модуль | ||||
| - Удалён жёстко заданный размер окна. | ||||
| - Использован `icoextract` как Python-модуль. | ||||
|  | ||||
| ### Fixed | ||||
| - Скрытие статус-бара | ||||
| - Чтение списка Steam-игр | ||||
| - Зависание GUI | ||||
| - Сбой при повреждённом Steam | ||||
| - Скрытие статус-бара. | ||||
| - Чтение списка Steam-игр. | ||||
| - Зависание GUI. | ||||
| - Сбой при повреждённом Steam. | ||||
|  | ||||
| ### Contributors | ||||
| - @Vector_null | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <div align="center"> | ||||
|   <img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64"> | ||||
|   <img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64"> | ||||
|   <h1 align="center">PortProtonQt</h1> | ||||
|   <p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p> | ||||
| </div> | ||||
| @@ -54,7 +54,6 @@ PortProtonQt использует код и зависимости от след | ||||
| - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE). | ||||
| - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE). | ||||
| - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md). | ||||
|  | ||||
| Полный текст лицензий см. в файле [LICENSE](LICENSE). | ||||
|  | ||||
| > [!WARNING] | ||||
|   | ||||
							
								
								
									
										17
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						| @@ -1,6 +1,6 @@ | ||||
| - [X] Адаптировать структуру проекта для поддержки инструментов сборки | ||||
| - [X] Добавить возможность управления с геймпада | ||||
| - [ ] Добавить возможность управления с тачскрина | ||||
| - [X] Добавить возможность управления с тачскрина (Формально и так есть) | ||||
| - [X] Добавить возможность управления с мыши и клавиатуры | ||||
| - [X] Добавить систему тем [Документация](documentation/theme_guide) | ||||
| - [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено) | ||||
| @@ -11,18 +11,18 @@ | ||||
| - [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800) | ||||
| - [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots) | ||||
| - [X] Получать описания и названия игр из базы данных Steam | ||||
| - [X] Получать обложки для игр из SteamGridDB или CDN Steam | ||||
| - [X] Получать обложки для игр из CDN Steam | ||||
| - [X] Оптимизировать работу со Steam API для ускорения времени запуска | ||||
| - [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley) | ||||
| - [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода | ||||
| - [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода | ||||
| - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) | ||||
| - [X] Избавиться от вызовов yad | ||||
| - [X] Реализовать собственный системный трей вместо использования трея PortProton | ||||
| - [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.) | ||||
| - [X] Добавить экранную клавиатуру в поиск | ||||
| - [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) | ||||
| - [X] Добавить индикацию запуска приложения | ||||
| - [X] Достигнуть паритета функциональности с Ingame | ||||
| - [ ] Достигнуть паритета функциональности с PortProton | ||||
| - [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов) | ||||
| - [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}` | ||||
| - [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/) | ||||
| - [X] Добавить переводы в переопределения | ||||
| @@ -41,7 +41,7 @@ | ||||
| - [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql) | ||||
| - [X] Добавить на карточку бейдж, указывающий, что игра из Steam | ||||
| - [X] Добавить поддержку версий Steam для Flatpak и Snap | ||||
| - [ ] Реализовать добавление игры как сторонней в Steam без перезапуска | ||||
| - [X] Реализовать добавление игры как сторонней в Steam без перезапуска | ||||
| - [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся | ||||
| - [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад» | ||||
| - [X] Добавить перевод через gettext [Документация](documentation/localization_guide) | ||||
| @@ -49,7 +49,7 @@ | ||||
| - [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter) | ||||
| - [X] Добавить систему избранного для карточек | ||||
| - [X] Заменить все `print` на `logging` | ||||
| - [ ] Привести все логи к единому языку | ||||
| - [X] Привести все логи к единому языку | ||||
| - [X] Уменьшить количество подстановок в переводах | ||||
| - [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog) | ||||
| - [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py` | ||||
| @@ -62,7 +62,6 @@ | ||||
| - [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63) | ||||
| - [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)) | ||||
| - [X] Скопировать логику управления с D-pad на стрелки с клавиатуры | ||||
| - [ ] Доделать светлую тему | ||||
| - [ ] Добавить подсказки к управлению с геймпада | ||||
| - [X] Добавить подсказки к управлению с геймпада | ||||
| - [X] Добавить миниатюры к выбору файлов в диалоге добавления игры | ||||
| - [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры | ||||
|   | ||||
| @@ -1,25 +1,19 @@ | ||||
| version: 1 | ||||
| script: | ||||
|   # 1) чистим старый AppDir | ||||
|   - rm -rf AppDir || true | ||||
|   # 2) создаём структуру каталога | ||||
|   - mkdir -p AppDir/usr/local/lib/python3.10/dist-packages | ||||
|   # 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml | ||||
|   - uv venv | ||||
|   - uv pip install --no-cache-dir ../ | ||||
|   # 4) копируем всё из .venv в AppDir | ||||
|   - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages | ||||
|   - cp -r share AppDir/usr | ||||
|   # 5) чистим от ненужных модулей и бинарников | ||||
|   - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ | ||||
|   - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate} | ||||
|   - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*} | ||||
|   - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*} | ||||
|   - shopt -s extglob | ||||
|   - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*) | ||||
|   - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*) | ||||
| AppDir: | ||||
|   path: ./AppDir | ||||
|   after_bundle: | ||||
|     # Документация, справка, примеры | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/man || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/doc || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/doc-base || true | ||||
| @@ -35,17 +29,14 @@ AppDir: | ||||
|     - rm -rf $TARGET_APPDIR/usr/share/metainfo || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/include || true | ||||
|     - rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true | ||||
|     # Статика и отладка | ||||
|     - find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true | ||||
|     # Strip ELF бинарников (исключая Python extensions) | ||||
|     - "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true" | ||||
|     # Удаление пустых папок | ||||
|     - find $TARGET_APPDIR -type d -empty -delete || true | ||||
|   app_info: | ||||
|     id: ru.linux_gaming.PortProtonQt | ||||
|     name: PortProtonQt | ||||
|     icon: ru.linux_gaming.PortProtonQt | ||||
|     version: 0.1.4 | ||||
|     version: 0.1.7 | ||||
|     exec: usr/bin/python3 | ||||
|     exec_args: "-m portprotonqt.app $@" | ||||
|   apt: | ||||
| @@ -63,16 +54,18 @@ AppDir: | ||||
|       - libxcb-cursor0 | ||||
|       - libimage-exiftool-perl | ||||
|       - xdg-utils | ||||
|       - cabextract | ||||
|       - curl | ||||
|       - 7zip | ||||
|       - unzip | ||||
|       - unrar | ||||
|     exclude: | ||||
|       # Документация и man-страницы | ||||
|       - "*-doc" | ||||
|       - "*-man" | ||||
|       - manpages | ||||
|       - mandb | ||||
|       # Статические библиотеки | ||||
|       - "*-dev" | ||||
|       - "*-static" | ||||
|       # Дебаг-символы | ||||
|       - "*-dbg" | ||||
|       - "*-dbgsym" | ||||
|   runtime: | ||||
| @@ -82,5 +75,5 @@ AppDir: | ||||
|       PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34' | ||||
| AppImage: | ||||
|   sign-key: None | ||||
|   comp: xz | ||||
|   arch: x86_64 | ||||
|   comp: zstd | ||||
|   | ||||
| @@ -1,12 +1,12 @@ | ||||
| pkgname=portprotonqt | ||||
| pkgver=0.1.4 | ||||
| pkgver=0.1.7 | ||||
| pkgrel=1 | ||||
| pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" | ||||
| arch=('any') | ||||
| url="https://git.linux-gaming.ru/Boria138/PortProtonQt" | ||||
| license=('GPL-3.0') | ||||
| depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4') | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') | ||||
| makedepends=('python-'{'build','installer','setuptools','wheel'}) | ||||
| source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver") | ||||
| sha256sums=('SKIP') | ||||
|   | ||||
| @@ -6,7 +6,7 @@ arch=('any') | ||||
| url="https://git.linux-gaming.ru/Boria138/PortProtonQt" | ||||
| license=('GPL-3.0') | ||||
| depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4') | ||||
|     'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') | ||||
| makedepends=('python-'{'build','installer','setuptools','wheel'}) | ||||
| source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git") | ||||
| sha256sums=('SKIP') | ||||
|   | ||||
| @@ -33,6 +33,7 @@ Requires:       python3-babel | ||||
| Requires:       python3-evdev | ||||
| Requires:       python3-icoextract | ||||
| Requires:       python3-numpy | ||||
| Requires:       python3-websocket-client | ||||
| Requires:       python3-orjson | ||||
| Requires:       python3-psutil | ||||
| Requires:       python3-pyside6 | ||||
| @@ -45,6 +46,11 @@ Requires:       python3-pillow | ||||
| Requires:       perl-Image-ExifTool | ||||
| Requires:       xdg-utils | ||||
| Requires:       python3-beautifulsoup4 | ||||
| Requires:       cabextract | ||||
| Requires:       gzip | ||||
| Requires:       unzip | ||||
| Requires:       curl | ||||
| Requires:       unrar | ||||
|  | ||||
| %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. | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| %global pypi_name portprotonqt | ||||
| %global pypi_version 0.1.4 | ||||
| %global pypi_version 0.1.7 | ||||
| %global oname PortProtonQt | ||||
| %global _python_no_extras_requires 1 | ||||
|  | ||||
| @@ -30,6 +30,7 @@ Requires:       python3-babel | ||||
| Requires:       python3-evdev | ||||
| Requires:       python3-icoextract | ||||
| Requires:       python3-numpy | ||||
| Requires:       python3-websocket-client | ||||
| Requires:       python3-orjson | ||||
| Requires:       python3-psutil | ||||
| Requires:       python3-pyside6 | ||||
| @@ -42,6 +43,11 @@ Requires:       python3-pillow | ||||
| Requires:       perl-Image-ExifTool | ||||
| Requires:       xdg-utils | ||||
| Requires:       python3-beautifulsoup4 | ||||
| Requires:       cabextract | ||||
| Requires:       gzip | ||||
| Requires:       unzip | ||||
| Requires:       curl | ||||
| Requires:       unrar | ||||
|  | ||||
| %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. | ||||
|   | ||||
| @@ -1,19 +1,30 @@ | ||||
| _portprotonqt() { | ||||
|     local cur prev | ||||
|     _init_completion || return | ||||
| _portprotonqt_completions() { | ||||
|     local cur prev opts | ||||
|     COMPREPLY=() | ||||
|     cur="${COMP_WORDS[COMP_CWORD]}" | ||||
|     prev="${COMP_WORDS[COMP_CWORD-1]}" | ||||
|  | ||||
|     case $prev in | ||||
|         --help|-h) | ||||
|             return | ||||
|     # Available options | ||||
|     opts="--fullscreen --debug-level --help -h" | ||||
|  | ||||
|     # Debug level choices | ||||
|     debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL" | ||||
|  | ||||
|     case "${prev}" in | ||||
|         --debug-level) | ||||
|             # Complete debug levels | ||||
|             COMPREPLY=( $(compgen -W "${debug_levels}" -- ${cur}) ) | ||||
|             return 0 | ||||
|             ;; | ||||
|         *) | ||||
|             ;; | ||||
|     esac | ||||
|  | ||||
|     if [[ "$cur" == -* ]]; then | ||||
|         COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) ) | ||||
|     # Complete options | ||||
|     if [[ ${cur} == -* ]]; then | ||||
|         COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) ) | ||||
|         return 0 | ||||
|     fi | ||||
|  | ||||
|     return 0 | ||||
| } | ||||
|  | ||||
| complete -F _portprotonqt portprotonqt | ||||
| complete -F _portprotonqt_completions portprotonqt | ||||
|   | ||||
| @@ -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, | ||||
| }; | ||||
| @@ -217,7 +217,7 @@ | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "watch_dogs 2", | ||||
|     "status": "Broken" | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "zero hour", | ||||
| @@ -765,7 +765,7 @@ | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "lost ark", | ||||
|     "status": "Broken" | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "archeage unchained", | ||||
| @@ -1777,7 +1777,7 @@ | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "supervive", | ||||
|     "status": "Denied" | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "splitgate 2", | ||||
| @@ -4426,5 +4426,121 @@ | ||||
|   { | ||||
|     "normalized_name": "carx street", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "warcos 2", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "karos classic", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "dead island riptide", | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "lineage", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "day of dragons", | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "sonic rumble", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "black stigma", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "umamusume pretty derby", | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "dirt rally", | ||||
|     "status": "Supported" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "minifighter", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "hide & hold out h2o", | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "battlefield 6", | ||||
|     "status": "Denied" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "ghost of tsushima director's cut", | ||||
|     "status": "Denied" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "sword of justice", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "blade & soul neo", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "the finals (cn)", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "tom clancy's rainbow six siege x", | ||||
|     "status": "Denied" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "dragonheir silent gods", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "the quinfall", | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "redmatch 2", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "blade & soul heroes", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "blue archive", | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "midnight murder club", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "dungeon done", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "project wraith", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "solo leveling arise", | ||||
|     "status": "Broken" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "freedom wars", | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "open fortress", | ||||
|     "status": "Running" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_name": "no more room in hell 2", | ||||
|     "status": "Running" | ||||
|   } | ||||
| ] | ||||
							
								
								
									
										34300
									
								
								data/games_appid.json
									
									
									
									
									
								
							
							
						
						| @@ -1,12 +1,192 @@ | ||||
| [ | ||||
|   { | ||||
|     "normalized_title": "return alive", | ||||
|     "slug": "return-alive" | ||||
|     "normalized_title": "dirt rally 2.0 game of the year", | ||||
|     "slug": "dirt-rally-2-0-game-of-the-year-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "deus ex human revolution director’s cut", | ||||
|     "slug": "deus-ex-human-revolution-director-s-cut" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "freelancer", | ||||
|     "slug": "freelancer" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "everspace", | ||||
|     "slug": "everspace" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "blades of time limited", | ||||
|     "slug": "blades-of-time-limited-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "chorus", | ||||
|     "slug": "chorus" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "tom clancy's splinter cell pandora tomorrow", | ||||
|     "slug": "tom-clancys-splinter-cell-pandora-tomorrow" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "the alters", | ||||
|     "slug": "the-alters" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "hard reset redux", | ||||
|     "slug": "hard-reset-redux" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "far cry 5", | ||||
|     "slug": "far-cry-5" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "metal eden", | ||||
|     "slug": "metal-eden" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "indiana jones and the great circle", | ||||
|     "slug": "indiana-jones-and-the-great-circle" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "old world", | ||||
|     "slug": "old-world" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "witchfire", | ||||
|     "slug": "witchfire" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "prototype", | ||||
|     "slug": "prototype" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "mandragora whispers of the witch tree", | ||||
|     "slug": "mandragora-whispers-of-the-witch-tree" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "grand theft auto v (gta 5)", | ||||
|     "slug": "grand-theft-auto-v-gta-5" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "lifeless planet premier", | ||||
|     "slug": "lifeless-planet-premier-edition" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "warcraft iii the frozen throne", | ||||
|     "slug": "warcraft-iii-the-frozen-throne" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "star wars republic commando", | ||||
|     "slug": "star-wars-republic-commando" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "hollow knight silksong", | ||||
|     "slug": "hollow-knight-silksong" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "arma reforger", | ||||
|     "slug": "arma-reforger" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "arma 3", | ||||
|     "slug": "arma-3" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "astroneer", | ||||
|     "slug": "astroneer" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "anno 2205", | ||||
|     "slug": "anno-2205" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "anno 2070", | ||||
|     "slug": "anno-2070" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "kompas 3d v23 / компас 3d v23", | ||||
|     "slug": "kompas-3d-v23-kompas-3d-v23" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "ultrakill (early access)", | ||||
|     "slug": "ultrakill-early-access" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "vintage story", | ||||
|     "slug": "vintage-story" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "disco elysium the finul cut", | ||||
|     "slug": "disco-elysium-the-finul-cut" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "warcraft iii reign of chaos", | ||||
|     "slug": "warcraft-iii-reign-of-chaos" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "dying light", | ||||
|     "slug": "dying-light" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "лихо одноглазое", | ||||
|     "slug": "liho-odnoglazoe" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "indika", | ||||
|     "slug": "indika" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "no sleep for kaname date from ai the somnium files", | ||||
|     "slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files" | ||||
|   }, | ||||
|   { | ||||
|     "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" | ||||
| @@ -107,10 +287,6 @@ | ||||
|     "normalized_title": "slitterhead", | ||||
|     "slug": "slitterhead" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "indiana jones and the great circle", | ||||
|     "slug": "indiana-jones-and-the-great-circle" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "crossout", | ||||
|     "slug": "crossout" | ||||
| @@ -191,10 +367,6 @@ | ||||
|     "normalized_title": "cardlife creative survival", | ||||
|     "slug": "cardlife-creative-survival" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "kompas 3d v23 / компас 3d v23", | ||||
|     "slug": "kompas-3d-v23-kompas-3d-v23" | ||||
|   }, | ||||
|   { | ||||
|     "normalized_title": "kompas 3d v24 / компас 3d v24 beta", | ||||
|     "slug": "kompas-3d-v24-kompas-3d-v24-beta" | ||||
|   | ||||
| @@ -17,4 +17,6 @@ Generated-By: | ||||
| start.sh | ||||
| EGS | ||||
| Stop Game | ||||
| Fullscreen | ||||
| Fulscreen | ||||
| \t | ||||
|   | ||||
							
								
								
									
										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() | ||||
| @@ -2,6 +2,7 @@ | ||||
|  | ||||
| import argparse | ||||
| import re | ||||
| import subprocess | ||||
| from pathlib import Path | ||||
| from datetime import date | ||||
|  | ||||
| @@ -134,6 +135,12 @@ def main(): | ||||
|         print(f"Updated version from {old} to {new} in {len(updated)} files:") | ||||
|         for p in sorted(updated): | ||||
|             print(f" - {p}") | ||||
|  | ||||
|         try: | ||||
|             subprocess.run(["uv", "lock"], check=True) | ||||
|             print("Regenerated uv.lock") | ||||
|         except subprocess.CalledProcessError as e: | ||||
|             print(f"Failed to regenerate uv.lock: {e}") | ||||
|     else: | ||||
|         print(f"No occurrences of version {old} found in specified files.") | ||||
|  | ||||
|   | ||||
| @@ -3,8 +3,9 @@ | ||||
| import sys | ||||
| from pathlib import Path | ||||
| import re | ||||
| import ast | ||||
|  | ||||
| # Запрещенные свойства | ||||
| # Запрещенные QSS-свойства | ||||
| FORBIDDEN_PROPERTIES = { | ||||
|     "box-shadow", | ||||
|     "backdrop-filter", | ||||
| @@ -12,15 +13,55 @@ FORBIDDEN_PROPERTIES = { | ||||
|     "text-shadow", | ||||
| } | ||||
|  | ||||
| # Запрещенные модули и функции | ||||
| FORBIDDEN_MODULES = { | ||||
|     "os", | ||||
|     "subprocess", | ||||
|     "shutil", | ||||
|     "sys", | ||||
|     "socket", | ||||
|     "ctypes", | ||||
|     "pathlib", | ||||
|     "glob", | ||||
| } | ||||
| FORBIDDEN_FUNCTIONS = { | ||||
|     "exec", | ||||
|     "eval", | ||||
|     "open", | ||||
|     "__import__", | ||||
| } | ||||
|  | ||||
| def check_qss_files(): | ||||
|     has_errors = False | ||||
|     for qss_file in Path("portprotonqt/themes").glob("**/*.py"): | ||||
|         with open(qss_file, "r") as f: | ||||
|             content = f.read() | ||||
|  | ||||
|             # Проверка на запрещённые QSS-свойства | ||||
|             for prop in FORBIDDEN_PROPERTIES: | ||||
|                 if re.search(rf"{prop}\s*:", content, re.IGNORECASE): | ||||
|                     print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}") | ||||
|                     print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}") | ||||
|                     has_errors = True | ||||
|  | ||||
|             # Проверка на опасные импорты и функции | ||||
|             try: | ||||
|                 tree = ast.parse(content) | ||||
|                 for node in ast.walk(tree): | ||||
|                     # Проверка импортов | ||||
|                     if isinstance(node, (ast.Import, ast.ImportFrom)): | ||||
|                         for name in node.names: | ||||
|                             if name.name in FORBIDDEN_MODULES: | ||||
|                                 print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}") | ||||
|                                 has_errors = True | ||||
|                     # Проверка вызовов функций | ||||
|                     if isinstance(node, ast.Call): | ||||
|                         if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS: | ||||
|                             print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}") | ||||
|                             has_errors = True | ||||
|             except SyntaxError as e: | ||||
|                 print(f"ERROR: Syntax error in file {qss_file}: {e}") | ||||
|                 has_errors = True | ||||
|  | ||||
|     return has_errors | ||||
|  | ||||
| if __name__ == "__main__": | ||||
|   | ||||
| @@ -3,10 +3,11 @@ | ||||
| --- | ||||
|  | ||||
| ## 📋 Contents | ||||
| - [Overview](#overview) | ||||
| - [Adding a New Translation](#adding-a-new-translation) | ||||
| - [Updating Existing Translations](#updating-existing-translations) | ||||
| - [Compiling Translations](#compiling-translations) | ||||
| - [Overview](#-overview) | ||||
| - [Adding a New Translation](#-adding-a-new-translation) | ||||
| - [Updating Existing Translations](#-updating-existing-translations) | ||||
| - [Compiling Translations](#-compiling-translations) | ||||
| - [Spell Check](#-spell-check) | ||||
|  | ||||
| --- | ||||
|  | ||||
| @@ -20,9 +21,9 @@ Current translation status: | ||||
|  | ||||
| | Locale | Progress | Translated | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 197 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 197 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 of 197 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 241 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 241 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 241 of 241 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -3,10 +3,11 @@ | ||||
| --- | ||||
|  | ||||
| ## 📋 Содержание | ||||
| - [Обзор](#обзор) | ||||
| - [Добавление нового перевода](#добавление-нового-перевода) | ||||
| - [Обновление существующих переводов](#обновление-существующих-переводов) | ||||
| - [Компиляция переводов](#компиляция-переводов) | ||||
| - [Обзор](#-обзор) | ||||
| - [Добавление нового перевода](#-добавление-нового-перевода) | ||||
| - [Обновление существующих переводов](#-обновление-существующих-переводов) | ||||
| - [Компиляция переводов](#-компиляция-переводов) | ||||
| - [Проверка орфографии](#-проверка-орфографии) | ||||
|  | ||||
| --- | ||||
|  | ||||
| @@ -20,9 +21,9 @@ | ||||
|  | ||||
| | Локаль | Прогресс | Переведено | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 197 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 197 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 из 197 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 241 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 241 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 241 из 241 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -3,15 +3,10 @@ | ||||
| --- | ||||
|  | ||||
| ## 📋 Contents | ||||
| - [Overview](#overview) | ||||
| - [How It Works](#how-it-works) | ||||
|   - [Data Priorities](#data-priorities) | ||||
|   - [File Structure](#file-structure) | ||||
| - [For Users](#for-users) | ||||
|   - [Creating User Overrides](#creating-user-overrides) | ||||
|   - [Example](#example) | ||||
| - [For Developers](#for-developers) | ||||
|   - [Adding Built-In Overrides](#adding-built-in-overrides) | ||||
| - [Overview](#-overview) | ||||
| - [How It Works](#-how-it-works) | ||||
| - [For Users](#-for-users) | ||||
| - [For Developers](#-for-developers) | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -3,15 +3,10 @@ | ||||
| --- | ||||
|  | ||||
| ## 📋 Содержание | ||||
| - [Обзор](#обзор) | ||||
| - [Как это работает](#как-это-работает) | ||||
|   - [Приоритеты данных](#приоритеты-данных) | ||||
|   - [Структура файлов](#структура-файлов) | ||||
| - [Для пользователей](#для-пользователей) | ||||
|   - [Создание пользовательских переопределений](#создание-пользовательских-переопределений) | ||||
|   - [Пример](#пример) | ||||
| - [Для разработчиков](#для-разработчиков) | ||||
|   - [Добавление встроенных переопределений](#добавление-встроенных-переопределений) | ||||
| - [Обзор](#-обзор) | ||||
| - [Как это работает](#-как-это-работает) | ||||
| - [Для пользователей](#-для-пользователей) | ||||
| - [Для разработчиков](#-для-разработчиков) | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -3,12 +3,13 @@ | ||||
| --- | ||||
|  | ||||
| ## 📋 Contents | ||||
| - [Overview](#overview) | ||||
| - [Creating the Theme Folder](#creating-the-theme-folder) | ||||
| - [Style File](#style-file) | ||||
| - [Metadata](#metadata) | ||||
| - [Screenshots](#screenshots) | ||||
| - [Fonts and Icons](#fonts-and-icons) | ||||
| - [Overview](#-overview) | ||||
| - [Creating the Theme Folder](#-creating-the-theme-folder) | ||||
| - [Style File](#-style-file-stylespy) | ||||
| - [Animation configuration](#-animation-configuration) | ||||
| - [Metadata](#-metadata-metainfoini) | ||||
| - [Screenshots](#-screenshots) | ||||
| - [Fonts and Icons](#-fonts-and-icons-optional) | ||||
|  | ||||
| --- | ||||
|  | ||||
| @@ -45,6 +46,163 @@ 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 or exiting the detail page | ||||
|     # Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce" | ||||
|     # Determines how the detail page appears and disappears | ||||
|     "detail_page_animation_type": "fade", | ||||
|  | ||||
|     # Border width of the card in idle state (no hover or focus) | ||||
|     # Affects the thickness of the border around the card when it's not selected | ||||
|     # Value in pixels | ||||
|     "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., when selected via keyboard) | ||||
|     # Increases the border thickness when the card is focused | ||||
|     # Value in pixels | ||||
|     "focus_border_width": 12, | ||||
|  | ||||
|     # Minimum border width during pulsing animation | ||||
|     # Determines the minimum border thickness during the "breathing" animation | ||||
|     # Value in pixels | ||||
|     "pulse_min_border_width": 8, | ||||
|  | ||||
|     # Maximum border width during pulsing animation | ||||
|     # Determines the maximum border thickness during pulsing | ||||
|     # Value in pixels | ||||
|     "pulse_max_border_width": 10, | ||||
|  | ||||
|     # Duration of the border thickness animation (e.g., on hover or focus) | ||||
|     # Affects the speed of transition from one border width to another | ||||
|     # Value in milliseconds | ||||
|     "thickness_anim_duration": 300, | ||||
|  | ||||
|     # Duration of one pulsing animation cycle | ||||
|     # Determines 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) | ||||
|     # Determines the initial rotation point of the gradient at animation start | ||||
|     "gradient_start_angle": 360, | ||||
|  | ||||
|     # Ending angle of the gradient (in degrees) | ||||
|     # Determines the final rotation point of the gradient | ||||
|     # Value 0 means a full 360° rotation | ||||
|     "gradient_end_angle": 0, | ||||
|  | ||||
|     # Type of card animation on hover or focus | ||||
|     # Possible values: "gradient", "scale" | ||||
|     # "gradient" enables a rotating gradient for the border, "scale" enlarges the card | ||||
|     "card_animation_type": "gradient", | ||||
|  | ||||
|     # Card scale in idle state | ||||
|     # Determines the base size of the card (1.0 = 100% of original size) | ||||
|     # Value as a fraction (e.g., 1.0 for normal size) | ||||
|     "default_scale": 1.0, | ||||
|  | ||||
|     # Card scale on hover | ||||
|     # Increases the card size on hover | ||||
|     # Value as a fraction (e.g., 1.1 = 110% of original size) | ||||
|     "hover_scale": 1.1, | ||||
|  | ||||
|     # Card scale on focus (e.g., when selected via keyboard) | ||||
|     # Increases the card size on focus | ||||
|     # Value as a fraction (e.g., 1.05 = 105% of original size) | ||||
|     "focus_scale": 1.05, | ||||
|  | ||||
|     # Duration of scale animation | ||||
|     # Affects how fast the card changes size on hover or focus | ||||
|     # Value in milliseconds | ||||
|     "scale_anim_duration": 200, | ||||
|  | ||||
|     # Easing curve type for border thickness increase animation (on hover/focus) | ||||
|     # Affects the "feel" of the animation (e.g., smooth acceleration or deceleration) | ||||
|     # Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad") | ||||
|     "thickness_easing_curve": "OutBack", | ||||
|  | ||||
|     # Easing curve type for border thickness decrease animation (on hover/focus exit) | ||||
|     # Affects the "feel" of returning to the default border width | ||||
|     "thickness_easing_curve_out": "InBack", | ||||
|  | ||||
|     # Easing curve type for scale increase animation (on hover/focus) | ||||
|     # Affects the "feel" of the scaling animation (e.g., with a "bounce" effect) | ||||
|     # Possible values: strings corresponding to QEasingCurve.Type | ||||
|     "scale_easing_curve": "OutBack", | ||||
|  | ||||
|     # Easing curve type for scale decrease animation (on hover/focus exit) | ||||
|     # Affects the "feel" of returning to the original scale | ||||
|     "scale_easing_curve_out": "InBack", | ||||
|  | ||||
|     # Gradient colors for animated border | ||||
|     # List of dictionaries, each specifying position (0.0–1.0) and color in hex format | ||||
|     # Affects the appearance of the border on hover or focus if card_animation_type="gradient" | ||||
|     "gradient_colors": [ | ||||
|         {"position": 0, "color": "#00fff5"},    # Starting color (cyan) | ||||
|         {"position": 0.33, "color": "#FF5733"}, # Color at 33% (orange) | ||||
|         {"position": 0.66, "color": "#9B59B6"}, # Color at 66% (purple) | ||||
|         {"position": 1, "color": "#00fff5"}     # Ending color (back to cyan) | ||||
|     ], | ||||
|  | ||||
|     # Duration of fade animation when entering the detail page | ||||
|     # Affects the speed of page appearance with fade animation | ||||
|     # Value in milliseconds | ||||
|     "detail_page_fade_duration": 350, | ||||
|  | ||||
|     # Duration of slide animation when entering the detail page | ||||
|     # Affects the speed of page sliding animation | ||||
|     # Value in milliseconds | ||||
|     "detail_page_slide_duration": 500, | ||||
|  | ||||
|     # Duration of bounce animation when entering the detail page | ||||
|     # Affects the speed of page "bounce" animation | ||||
|     # Value in milliseconds | ||||
|     "detail_page_bounce_duration": 400, | ||||
|  | ||||
|     # Duration of fade animation when exiting the detail page | ||||
|     # Affects the speed of page disappearance with fade animation | ||||
|     # Value in milliseconds | ||||
|     "detail_page_fade_duration_exit": 350, | ||||
|  | ||||
|     # Duration of slide animation when exiting the detail page | ||||
|     # Affects the speed of page sliding animation | ||||
|     # Value in milliseconds | ||||
|     "detail_page_slide_duration_exit": 500, | ||||
|  | ||||
|     # Duration of bounce animation when exiting the detail page | ||||
|     # Affects the speed of page "compression" animation | ||||
|     # Value in milliseconds | ||||
|     "detail_page_bounce_duration_exit": 400, | ||||
|  | ||||
|     # Easing curve type for animations when entering the detail page | ||||
|     # Applied to slide and bounce animations; affects the "feel" of movement | ||||
|     # Possible values: strings corresponding to QEasingCurve.Type | ||||
|     "detail_page_easing_curve": "OutCubic", | ||||
|  | ||||
|     # Easing curve type for animations when exiting the detail page | ||||
|     # Applied to slide and bounce animations; affects the "feel" of movement | ||||
|     # Possible values: strings corresponding to QEasingCurve.Type | ||||
|     "detail_page_easing_curve_exit": "InCubic" | ||||
| } | ||||
| ``` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 📝 Metadata (`metainfo.ini`) | ||||
|  | ||||
| ```ini | ||||
|   | ||||
| @@ -3,12 +3,13 @@ | ||||
| --- | ||||
|  | ||||
| ## 📋 Содержание | ||||
| - [Обзор](#обзор) | ||||
| - [Создание папки темы](#создание-папки-темы) | ||||
| - [Файл стилей](#файл-стилей) | ||||
| - [Метаинформация](#метаинформация) | ||||
| - [Скриншоты](#скриншоты) | ||||
| - [Шрифты и иконки](#шрифты-и-иконки) | ||||
| - [Обзор](#-обзор) | ||||
| - [Создание папки темы](#-создание-папки-темы) | ||||
| - [Файл стилей](#-файл-стилей-stylespy) | ||||
| - [Конфигурация анимации](#-конфигурация-анимации) | ||||
| - [Метаинформация](#-метаинформация-metainfoini) | ||||
| - [Скриншоты](#-скриншоты) | ||||
| - [Шрифты и иконки](#-шрифты-и-иконки-опционально) | ||||
|  | ||||
| --- | ||||
|  | ||||
| @@ -45,6 +46,163 @@ 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, | ||||
|  | ||||
|     # Тип анимации для карточки при наведении или фокусе | ||||
|     # Возможные значения: "gradient", "scale" | ||||
|     # "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки | ||||
|     "card_animation_type": "gradient", | ||||
|  | ||||
|     # Масштаб карточки в состоянии покоя | ||||
|     # Определяет базовый размер карточки (1.0 = 100% от исходного размера) | ||||
|     # Значение в долях (например, 1.0 для нормального размера) | ||||
|     "default_scale": 1.0, | ||||
|  | ||||
|     # Масштаб карточки при наведении курсора | ||||
|     # Увеличивает размер карточки при наведении | ||||
|     # Значение в долях (например, 1.1 = 110% от исходного размера) | ||||
|     "hover_scale": 1.1, | ||||
|  | ||||
|     # Масштаб карточки при фокусе (например, при выборе с клавиатуры) | ||||
|     # Увеличивает размер карточки при фокусе | ||||
|     # Значение в долях (например, 1.05 = 105% от исходного размера) | ||||
|     "focus_scale": 1.05, | ||||
|  | ||||
|     # Длительность анимации масштабирования | ||||
|     # Влияет на скорость изменения размера карточки при наведении или фокусе | ||||
|     # Значение в миллисекундах | ||||
|     "scale_anim_duration": 200, | ||||
|  | ||||
|     # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе) | ||||
|     # Влияет на "чувство" анимации (например, плавное ускорение или замедление) | ||||
|     # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad") | ||||
|     "thickness_easing_curve": "OutBack", | ||||
|  | ||||
|     # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса) | ||||
|     # Влияет на "чувство" возврата к исходной ширине обводки | ||||
|     "thickness_easing_curve_out": "InBack", | ||||
|  | ||||
|     # Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе) | ||||
|     # Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока") | ||||
|     # Возможные значения: строки, соответствующие QEasingCurve.Type | ||||
|     "scale_easing_curve": "OutBack", | ||||
|  | ||||
|     # Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса) | ||||
|     # Влияет на "чувство" возврата к исходному масштабу | ||||
|     "scale_easing_curve_out": "InBack", | ||||
|  | ||||
|     # Цвета градиента для анимированной обводки | ||||
|     # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex | ||||
|     # Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient" | ||||
|     "gradient_colors": [ | ||||
|         {"position": 0, "color": "#00fff5"},    # Начальный цвет (циан) | ||||
|         {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый) | ||||
|         {"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный) | ||||
|         {"position": 1, "color": "#00fff5"}     # Конечный цвет (возвращение к циану) | ||||
|     ], | ||||
|  | ||||
|     # Длительность анимации fade при входе на детальную страницу | ||||
|     # Влияет на скорость появления страницы при fade-анимации | ||||
|     # Значение в миллисекундах | ||||
|     "detail_page_fade_duration": 350, | ||||
|  | ||||
|     # Длительность анимации slide при входе на детальную страницу | ||||
|     # Влияет на скорость скольжения страницы при slide-анимации | ||||
|     # Значение в миллисекундах | ||||
|     "detail_page_slide_duration": 500, | ||||
|  | ||||
|     # Длительность анимации bounce при входе на детальную страницу | ||||
|     # Влияет на скорость "прыжка" страницы при bounce-анимации | ||||
|     # Значение в миллисекундах | ||||
|     "detail_page_bounce_duration": 400, | ||||
|  | ||||
|     # Длительность анимации fade при выходе из детальной страницы | ||||
|     # Влияет на скорость исчезновения страницы при fade-анимации | ||||
|     # Значение в миллисекундах | ||||
|     "detail_page_fade_duration_exit": 350, | ||||
|  | ||||
|     # Длительность анимации slide при выходе из детальной страницы | ||||
|     # Влияет на скорость скольжения страницы при slide-анимации | ||||
|     # Значение в миллисекундах | ||||
|     "detail_page_slide_duration_exit": 500, | ||||
|  | ||||
|     # Длительность анимации bounce при выходе из детальной страницы | ||||
|     # Влияет на скорость "сжатия" страницы при bounce-анимации | ||||
|     # Значение в миллисекундах | ||||
|     "detail_page_bounce_duration_exit": 400, | ||||
|  | ||||
|     # Тип кривой сглаживания для анимации при входе на детальную страницу | ||||
|     # Применяется к slide и bounce анимациям, влияет на "чувство" движения | ||||
|     # Возможные значения: строки, соответствующие QEasingCurve.Type | ||||
|     "detail_page_easing_curve": "OutCubic", | ||||
|  | ||||
|     # Тип кривой сглаживания для анимации при выходе из детальной страницы | ||||
|     # Применяется к slide и bounce анимациям, влияет на "чувство" движения | ||||
|     # Возможные значения: строки, соответствующие QEasingCurve.Type | ||||
|     "detail_page_easing_curve_exit": "InCubic" | ||||
| } | ||||
| ``` | ||||
|  | ||||
| --- | ||||
|  | ||||
| ## 📝 Метаинформация (`metainfo.ini`) | ||||
|  | ||||
| ```ini | ||||
|   | ||||
							
								
								
									
										387
									
								
								portprotonqt/animations.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,387 @@ | ||||
| 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 | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.config_utils import read_theme_from_config | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| 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_manager = ThemeManager() | ||||
|         self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) | ||||
|         self.thickness_anim: QPropertyAnimation | None = None | ||||
|         self.gradient_anim: QPropertyAnimation | None = None | ||||
|         self.scale_anim: QPropertyAnimation | None = None | ||||
|         self.pulse_anim: QPropertyAnimation | None = None | ||||
|         self._isPulseAnimationConnected = False | ||||
|  | ||||
|     def setup_animations(self): | ||||
|         """Initialize animation properties based on theme.""" | ||||
|         self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth")) | ||||
|         self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"]) | ||||
|  | ||||
|         animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") | ||||
|         if animation_type == "gradient": | ||||
|             self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) | ||||
|             self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) | ||||
|         elif animation_type == "scale": | ||||
|             self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) | ||||
|             self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) | ||||
|  | ||||
|     def start_pulse_animation(self): | ||||
|         """Start pulse animation for border width when hovered or focused.""" | ||||
|         if not (self.game_card._hovered or self.game_card._focused): | ||||
|             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() | ||||
|  | ||||
|         animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") | ||||
|  | ||||
|         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 animation_type == "gradient": | ||||
|             if self.gradient_anim: | ||||
|                 self.gradient_anim.stop() | ||||
|             self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) | ||||
|             self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) | ||||
|             self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) | ||||
|             self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) | ||||
|             self.gradient_anim.setLoopCount(-1) | ||||
|             self.gradient_anim.start() | ||||
|         elif animation_type == "scale": | ||||
|             if self.scale_anim: | ||||
|                 self.scale_anim.stop() | ||||
|             self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) | ||||
|             self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) | ||||
|             self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]])) | ||||
|             self.scale_anim.setStartValue(self.game_card._scale) | ||||
|             self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_scale"]) | ||||
|             self.scale_anim.start() | ||||
|  | ||||
|     def handle_leave_event(self): | ||||
|         """Handle mouse leave event animations.""" | ||||
|         self.game_card._hovered = False | ||||
|         self.game_card.hoverChanged.emit(self.game_card.name, False) | ||||
|         if not self.game_card._focused: | ||||
|             animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") | ||||
|             if animation_type == "gradient": | ||||
|                 if self.gradient_anim: | ||||
|                     self.gradient_anim.stop() | ||||
|                     self.gradient_anim = None | ||||
|             elif animation_type == "scale": | ||||
|                 if self.scale_anim: | ||||
|                     self.scale_anim.stop() | ||||
|                 self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) | ||||
|                 self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) | ||||
|                 self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]])) | ||||
|                 self.scale_anim.setStartValue(self.game_card._scale) | ||||
|                 self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"]) | ||||
|                 self.scale_anim.start() | ||||
|             if self.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() | ||||
|  | ||||
|             animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") | ||||
|  | ||||
|             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 animation_type == "gradient": | ||||
|                 if self.gradient_anim: | ||||
|                     self.gradient_anim.stop() | ||||
|                 self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle")) | ||||
|                 self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) | ||||
|                 self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) | ||||
|                 self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) | ||||
|                 self.gradient_anim.setLoopCount(-1) | ||||
|                 self.gradient_anim.start() | ||||
|             elif animation_type == "scale": | ||||
|                 if self.scale_anim: | ||||
|                     self.scale_anim.stop() | ||||
|                 self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) | ||||
|                 self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) | ||||
|                 self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]])) | ||||
|                 self.scale_anim.setStartValue(self.game_card._scale) | ||||
|                 self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_scale"]) | ||||
|                 self.scale_anim.start() | ||||
|  | ||||
|     def handle_focus_out_event(self): | ||||
|         """Handle focus out event animations.""" | ||||
|         self.game_card._focused = False | ||||
|         self.game_card.focusChanged.emit(self.game_card.name, False) | ||||
|         if not self.game_card._hovered: | ||||
|             animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") | ||||
|             if animation_type == "gradient": | ||||
|                 if self.gradient_anim: | ||||
|                     self.gradient_anim.stop() | ||||
|                     self.gradient_anim = None | ||||
|             elif animation_type == "scale": | ||||
|                 if self.scale_anim: | ||||
|                     self.scale_anim.stop() | ||||
|                 self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale")) | ||||
|                 self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"]) | ||||
|                 self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]])) | ||||
|                 self.scale_anim.setStartValue(self.game_card._scale) | ||||
|                 self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"]) | ||||
|                 self.scale_anim.start() | ||||
|             if self.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.debug("Painter is not active; skipping border paint") | ||||
|             return | ||||
|         painter.setRenderHint(QPainter.RenderHint.Antialiasing) | ||||
|         pen = QPen() | ||||
|         pen.setWidth(self.game_card._borderWidth) | ||||
|         animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") | ||||
|         if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient": | ||||
|             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 * self.game_card._scale | ||||
|         bw = round(self.game_card._borderWidth / 2) | ||||
|         rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw) | ||||
|         if rect.isEmpty(): | ||||
|             return | ||||
|         painter.drawRoundedRect(rect, radius, radius) | ||||
|  | ||||
| class DetailPageAnimations: | ||||
|     def __init__(self, main_window, theme=None): | ||||
|         self.main_window = main_window | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) | ||||
|         self.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.warning("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.warning("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) | ||||
|             elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]: | ||||
|                 duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500) | ||||
|                 easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")]) | ||||
|                 end_pos = { | ||||
|                     "slide_left": QPoint(-self.main_window.width(), 0), | ||||
|                     "slide_right": QPoint(self.main_window.width(), 0), | ||||
|                     "slide_up": QPoint(0, self.main_window.height()), | ||||
|                     "slide_down": QPoint(0, -self.main_window.height()) | ||||
|                 }[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() | ||||
| @@ -1,72 +1,78 @@ | ||||
| import sys | ||||
| import os | ||||
| import subprocess | ||||
| from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo | ||||
| from PySide6.QtWidgets import QApplication | ||||
| from PySide6.QtGui import QIcon | ||||
| from portprotonqt.main_window import MainWindow | ||||
| from portprotonqt.tray import SystemTray | ||||
| from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location | ||||
| from portprotonqt.logger import get_logger, setup_logger | ||||
| from portprotonqt.cli import parse_args | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| __app_id__ = "ru.linux_gaming.PortProtonQt" | ||||
| __app_name__ = "PortProtonQt" | ||||
| __app_version__ = "0.1.4" | ||||
| __app_version__ = "0.1.7" | ||||
|  | ||||
| def get_version(): | ||||
|     try: | ||||
|         commit = subprocess.check_output( | ||||
|             ['git', 'rev-parse', '--short', 'HEAD'], | ||||
|             stderr=subprocess.DEVNULL | ||||
|         ).decode('utf-8').strip() | ||||
|         return f"{__app_version__} ({commit})" | ||||
|     except (subprocess.CalledProcessError, FileNotFoundError, OSError): | ||||
|         return __app_version__ | ||||
|  | ||||
| def main(): | ||||
|     os.environ['PW_CLI'] = '1' | ||||
|     os.environ['PROCESS_LOG'] = '1' | ||||
|     os.environ['START_FROM_STEAM'] = '1' | ||||
|  | ||||
|     portproton_path = get_portproton_location() | ||||
|  | ||||
|     if portproton_path is None: | ||||
|         return | ||||
|  | ||||
|     script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh') | ||||
|     subprocess.run([script_path, 'cli', '--initial']) | ||||
|  | ||||
|     app = QApplication(sys.argv) | ||||
|     app.setWindowIcon(QIcon.fromTheme(__app_id__)) | ||||
|     app.setDesktopFileName(__app_id__) | ||||
|     app.setApplicationName(__app_name__) | ||||
|     app.setApplicationVersion(__app_version__) | ||||
|  | ||||
|     args = parse_args() | ||||
|  | ||||
|     # Setup logger with specified debug level | ||||
|     setup_logger(args.debug_level) | ||||
|  | ||||
|     # Reinitialize logger after setup to ensure it uses the new configuration | ||||
|     logger = get_logger(__name__) | ||||
|  | ||||
|     system_locale = QLocale.system() | ||||
|     qt_translator = QTranslator() | ||||
|     translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) | ||||
|     if qt_translator.load(system_locale, "qtbase", "_", translations_path): | ||||
|         app.installTranslator(qt_translator) | ||||
|     else: | ||||
|         logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}") | ||||
|         logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language") | ||||
|  | ||||
|     args = parse_args() | ||||
|  | ||||
|     window = MainWindow() | ||||
|     version = get_version() | ||||
|     window = MainWindow(app_name=__app_name__, version=version) | ||||
|  | ||||
|     if args.fullscreen: | ||||
|         logger.info("Launching in fullscreen mode due to --fullscreen flag") | ||||
|         save_fullscreen_config(True) | ||||
|         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(): | ||||
|         nonlocal tray, window | ||||
|         nonlocal window | ||||
|         app.aboutToQuit.disconnect() | ||||
|         if tray: | ||||
|             tray.cleanup() | ||||
|             tray = None | ||||
|         if window: | ||||
|             window.close() | ||||
|         app.quit() | ||||
|  | ||||
|     window.settings_saved.connect(recreate_tray) | ||||
|     app.aboutToQuit.connect(cleanup_on_exit) | ||||
|  | ||||
|     window.show() | ||||
|   | ||||
| @@ -1,16 +1,20 @@ | ||||
| import argparse | ||||
| from portprotonqt.logger import get_logger | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| def parse_args(): | ||||
|     """ | ||||
|     Парсит аргументы командной строки. | ||||
|     Parses command-line arguments. | ||||
|     """ | ||||
|     parser = argparse.ArgumentParser(description="PortProtonQt CLI") | ||||
|     parser.add_argument( | ||||
|         "--fullscreen", | ||||
|         action="store_true", | ||||
|         help="Запустить приложение в полноэкранном режиме и сохранить эту настройку" | ||||
|         help="Launch the application in fullscreen mode and save this setting" | ||||
|     ) | ||||
|     parser.add_argument( | ||||
|         "--debug-level", | ||||
|         choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], | ||||
|         default='NOTSET', | ||||
|         help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)" | ||||
|     ) | ||||
|     return parser.parse_args() | ||||
|   | ||||
| @@ -7,7 +7,7 @@ logger = get_logger(__name__) | ||||
|  | ||||
| _portproton_location = None | ||||
|  | ||||
| # Пути к конфигурационным файлам | ||||
| # Paths to configuration files | ||||
| CONFIG_FILE = os.path.join( | ||||
|     os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")), | ||||
|     "PortProtonQt.conf" | ||||
| @@ -18,17 +18,32 @@ PORTPROTON_CONFIG_FILE = os.path.join( | ||||
|     "PortProton.conf" | ||||
| ) | ||||
|  | ||||
| # Пути к папкам с темами | ||||
| # Paths to theme directories | ||||
| xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
| THEMES_DIRS = [ | ||||
|     os.path.join(xdg_data_home, "PortProtonQt", "themes"), | ||||
|     os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") | ||||
| ] | ||||
|  | ||||
| def read_config_safely(config_file: str) -> configparser.ConfigParser | None: | ||||
|     """Safely reads a configuration file and returns a ConfigParser object or None if reading fails.""" | ||||
|     cp = configparser.ConfigParser() | ||||
|     if not os.path.exists(config_file): | ||||
|         logger.debug(f"Configuration file {config_file} not found") | ||||
|         return None | ||||
|     try: | ||||
|         cp.read(config_file, encoding="utf-8") | ||||
|         return cp | ||||
|     except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|         logger.warning(f"Invalid configuration file format: {e}") | ||||
|         return None | ||||
|     except Exception as e: | ||||
|         logger.warning(f"Failed to read configuration file: {e}") | ||||
|         return None | ||||
|  | ||||
| def read_config(): | ||||
|     """ | ||||
|     Читает конфигурационный файл и возвращает словарь параметров. | ||||
|     Пример строки в конфиге (без секций): | ||||
|     """Reads the configuration file and returns a dictionary of parameters. | ||||
|     Example line in config (no sections): | ||||
|       detail_level = detailed | ||||
|     """ | ||||
|     config_dict = {} | ||||
| @@ -44,29 +59,17 @@ def read_config(): | ||||
|     return config_dict | ||||
|  | ||||
| def read_theme_from_config(): | ||||
|     """Reads the theme from the [Appearance] section of the configuration file. | ||||
|     Returns 'standart' if the parameter is not set. | ||||
|     """ | ||||
|     Читает из конфигурационного файла тему из секции [Appearance]. | ||||
|     Если параметр не задан, возвращает "standart". | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|             return "standart" | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None: | ||||
|         return "standart" | ||||
|     return cp.get("Appearance", "theme", fallback="standart") | ||||
|  | ||||
| def save_theme_to_config(theme_name): | ||||
|     """ | ||||
|     Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|     """Saves the selected theme name to the [Appearance] section of the configuration file.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Appearance" not in cp: | ||||
|         cp["Appearance"] = {} | ||||
|     cp["Appearance"]["theme"] = theme_name | ||||
| @@ -74,34 +77,18 @@ def save_theme_to_config(theme_name): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_time_config(): | ||||
|     """Reads time settings from the [Time] section of the configuration file. | ||||
|     If the section or parameter is missing, saves and returns 'detailed' as default. | ||||
|     """ | ||||
|     Читает настройки времени из секции [Time] конфигурационного файла. | ||||
|     Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|             save_time_config("detailed") | ||||
|             return "detailed" | ||||
|         if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"): | ||||
|             save_time_config("detailed") | ||||
|             return "detailed" | ||||
|         return cp.get("Time", "detail_level", fallback="detailed").lower() | ||||
|     return "detailed" | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Time") or not cp.has_option("Time", "detail_level"): | ||||
|         save_time_config("detailed") | ||||
|         return "detailed" | ||||
|     return cp.get("Time", "detail_level", fallback="detailed").lower() | ||||
|  | ||||
| def save_time_config(detail_level): | ||||
|     """ | ||||
|     Сохраняет настройку уровня детализации времени в секции [Time]. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|     """Saves the time detail level to the [Time] section of the configuration file.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Time" not in cp: | ||||
|         cp["Time"] = {} | ||||
|     cp["Time"]["detail_level"] = detail_level | ||||
| @@ -109,48 +96,42 @@ def save_time_config(detail_level): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_file_content(file_path): | ||||
|     """ | ||||
|     Читает содержимое файла и возвращает его как строку. | ||||
|     """ | ||||
|     """Reads the content of a file and returns it as a string.""" | ||||
|     with open(file_path, encoding="utf-8") as f: | ||||
|         return f.read().strip() | ||||
|  | ||||
| def get_portproton_location(): | ||||
|     """ | ||||
|     Возвращает путь к директории PortProton. | ||||
|     Сначала проверяется кэшированный путь. Если он отсутствует, проверяется | ||||
|     наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен, | ||||
|     используется директория по умолчанию. | ||||
|     """Returns the path to the PortProton directory. | ||||
|     Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE. | ||||
|     If the path is invalid, uses the default directory. | ||||
|     """ | ||||
|     global _portproton_location | ||||
|     if _portproton_location is not None: | ||||
|         return _portproton_location | ||||
|  | ||||
|     # Попытка чтения пути из конфигурационного файла | ||||
|     if os.path.isfile(PORTPROTON_CONFIG_FILE): | ||||
|         try: | ||||
|             location = read_file_content(PORTPROTON_CONFIG_FILE).strip() | ||||
|             if location and os.path.isdir(location): | ||||
|                 _portproton_location = location | ||||
|                 logger.info(f"Путь PortProton из конфигурации: {location}") | ||||
|                 logger.info(f"PortProton path from configuration: {location}") | ||||
|                 return _portproton_location | ||||
|             logger.warning(f"Недействительный путь в конфиге PortProton: {location}") | ||||
|             logger.warning(f"Invalid PortProton path in configuration: {location}, using default path") | ||||
|         except (OSError, PermissionError) as e: | ||||
|             logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}") | ||||
|             logger.warning(f"Failed to read PortProton configuration file: {e}, using default path") | ||||
|  | ||||
|     default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") | ||||
|     if os.path.isdir(default_dir): | ||||
|         _portproton_location = default_dir | ||||
|         logger.info(f"Используется директория flatpak PortProton: {default_dir}") | ||||
|         logger.info(f"Using flatpak PortProton directory: {default_dir}") | ||||
|         return _portproton_location | ||||
|  | ||||
|     logger.warning("Конфигурация и директория flatpak PortProton не найдены") | ||||
|     logger.warning("PortProton configuration and flatpak directory not found") | ||||
|     return None | ||||
|  | ||||
| def parse_desktop_entry(file_path): | ||||
|     """ | ||||
|     Читает и парсит .desktop файл с помощью configparser. | ||||
|     Если секция [Desktop Entry] отсутствует, возвращается None. | ||||
|     """Reads and parses a .desktop file using configparser. | ||||
|     Returns None if the [Desktop Entry] section is missing. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser(interpolation=None) | ||||
|     cp.read(file_path, encoding="utf-8") | ||||
| @@ -159,9 +140,8 @@ def parse_desktop_entry(file_path): | ||||
|     return cp["Desktop Entry"] | ||||
|  | ||||
| def load_theme_metainfo(theme_name): | ||||
|     """ | ||||
|     Загружает метаинформацию темы из файла metainfo.ini в корне папки темы. | ||||
|     Ожидаемые поля: author, author_link, description, name. | ||||
|     """Loads theme metadata from metainfo.ini in the theme's root directory. | ||||
|     Expected fields: author, author_link, description, name. | ||||
|     """ | ||||
|     meta = {} | ||||
|     for themes_dir in THEMES_DIRS: | ||||
| @@ -179,34 +159,18 @@ def load_theme_metainfo(theme_name): | ||||
|     return meta | ||||
|  | ||||
| def read_card_size(): | ||||
|     """Reads the card size (width) from the [Cards] section. | ||||
|     Returns 250 if the parameter is not set. | ||||
|     """ | ||||
|     Читает размер карточек (ширину) из секции [Cards], | ||||
|     Если параметр не задан, возвращает 250. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|             save_card_size(250) | ||||
|             return 250 | ||||
|         if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"): | ||||
|             save_card_size(250) | ||||
|             return 250 | ||||
|         return cp.getint("Cards", "card_width", fallback=250) | ||||
|     return 250 | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"): | ||||
|         save_card_size(250) | ||||
|         return 250 | ||||
|     return cp.getint("Cards", "card_width", fallback=250) | ||||
|  | ||||
| def save_card_size(card_width): | ||||
|     """ | ||||
|     Сохраняет размер карточек (ширину) в секцию [Cards]. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|     """Saves the card size (width) to the [Cards] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Cards" not in cp: | ||||
|         cp["Cards"] = {} | ||||
|     cp["Cards"]["card_width"] = str(card_width) | ||||
| @@ -214,34 +178,18 @@ def save_card_size(card_width): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_sort_method(): | ||||
|     """Reads the sort method from the [Games] section. | ||||
|     Returns 'last_launch' if the parameter is not set. | ||||
|     """ | ||||
|     Читает метод сортировки из секции [Games]. | ||||
|     Если параметр не задан, возвращает last_launch. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|             save_sort_method("last_launch") | ||||
|             return "last_launch" | ||||
|         if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"): | ||||
|             save_sort_method("last_launch") | ||||
|             return "last_launch" | ||||
|         return cp.get("Games", "sort_method", fallback="last_launch").lower() | ||||
|     return "last_launch" | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "sort_method"): | ||||
|         save_sort_method("last_launch") | ||||
|         return "last_launch" | ||||
|     return cp.get("Games", "sort_method", fallback="last_launch").lower() | ||||
|  | ||||
| def save_sort_method(sort_method): | ||||
|     """ | ||||
|     Сохраняет метод сортировки в секцию [Games]. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|     """Saves the sort method to the [Games] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Games" not in cp: | ||||
|         cp["Games"] = {} | ||||
|     cp["Games"]["sort_method"] = sort_method | ||||
| @@ -249,34 +197,18 @@ def save_sort_method(sort_method): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_display_filter(): | ||||
|     """Reads the display_filter parameter from the [Games] section. | ||||
|     Returns 'all' if the parameter is missing. | ||||
|     """ | ||||
|     Читает параметр display_filter из секции [Games]. | ||||
|     Если параметр отсутствует, сохраняет и возвращает значение "all". | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка чтения конфига: %s", e) | ||||
|             save_display_filter("all") | ||||
|             return "all" | ||||
|         if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"): | ||||
|             save_display_filter("all") | ||||
|             return "all" | ||||
|         return cp.get("Games", "display_filter", fallback="all").lower() | ||||
|     return "all" | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "display_filter"): | ||||
|         save_display_filter("all") | ||||
|         return "all" | ||||
|     return cp.get("Games", "display_filter", fallback="all").lower() | ||||
|  | ||||
| def save_display_filter(filter_value): | ||||
|     """ | ||||
|     Сохраняет параметр display_filter в секцию [Games] конфигурационного файла. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка чтения конфига: %s", e) | ||||
|     """Saves the display_filter parameter to the [Games] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Games" not in cp: | ||||
|         cp["Games"] = {} | ||||
|     cp["Games"]["display_filter"] = filter_value | ||||
| @@ -284,37 +216,23 @@ def save_display_filter(filter_value): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_favorites(): | ||||
|     """Reads the list of favorite games from the [Favorites] section. | ||||
|     The list is stored as a quoted string with comma-separated names. | ||||
|     Returns an empty list if the section or parameter is missing. | ||||
|     """ | ||||
|     Читает список избранных игр из секции [Favorites] конфигурационного файла. | ||||
|     Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми. | ||||
|     Если секция или параметр отсутствуют, возвращает пустой список. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка чтения конфига: %s", e) | ||||
|             return [] | ||||
|         if cp.has_section("Favorites") and cp.has_option("Favorites", "games"): | ||||
|             favs = cp.get("Favorites", "games", fallback="").strip() | ||||
|             # Если строка начинается и заканчивается кавычками, удаляем их | ||||
|             if favs.startswith('"') and favs.endswith('"'): | ||||
|                 favs = favs[1:-1] | ||||
|             return [s.strip() for s in favs.split(",") if s.strip()] | ||||
|     return [] | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Favorites") or not cp.has_option("Favorites", "games"): | ||||
|         return [] | ||||
|     favs = cp.get("Favorites", "games", fallback="").strip() | ||||
|     if favs.startswith('"') and favs.endswith('"'): | ||||
|         favs = favs[1:-1] | ||||
|     return [s.strip() for s in favs.split(",") if s.strip()] | ||||
|  | ||||
| def save_favorites(favorites): | ||||
|     """Saves the list of favorite games to the [Favorites] section. | ||||
|     The list is stored as a quoted string with comma-separated names. | ||||
|     """ | ||||
|     Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла. | ||||
|     Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка чтения конфига: %s", e) | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Favorites" not in cp: | ||||
|         cp["Favorites"] = {} | ||||
|     fav_str = ", ".join(favorites) | ||||
| @@ -323,76 +241,66 @@ def save_favorites(favorites): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_rumble_config(): | ||||
|     """Reads the gamepad rumble setting from the [Gamepad] section. | ||||
|     Returns False if the parameter is missing. | ||||
|     """ | ||||
|     Читает настройку виброотдачи геймпада из секции [Gamepad]. | ||||
|     Если параметр отсутствует, сохраняет и возвращает False по умолчанию. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|             save_rumble_config(False) | ||||
|             return False | ||||
|         if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"): | ||||
|             save_rumble_config(False) | ||||
|             return False | ||||
|         return cp.getboolean("Gamepad", "rumble_enabled", fallback=False) | ||||
|     return False | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"): | ||||
|         save_rumble_config(False) | ||||
|         return False | ||||
|     return cp.getboolean("Gamepad", "rumble_enabled", fallback=False) | ||||
|  | ||||
| def save_rumble_config(rumble_enabled): | ||||
|     """ | ||||
|     Сохраняет настройку виброотдачи геймпада в секцию [Gamepad]. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|     """Saves the gamepad rumble setting to the [Gamepad] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Gamepad" not in cp: | ||||
|         cp["Gamepad"] = {} | ||||
|     cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled) | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_gamepad_type(): | ||||
|     """Reads the gamepad type from the [Gamepad] section. | ||||
|     Returns 'xbox' if the parameter is missing. | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"): | ||||
|         save_gamepad_type("xbox") | ||||
|         return "xbox" | ||||
|     return cp.get("Gamepad", "type", fallback="xbox").lower() | ||||
|  | ||||
| def save_gamepad_type(gpad_type): | ||||
|     """Saves the gamepad type to the [Gamepad] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Gamepad" not in cp: | ||||
|         cp["Gamepad"] = {} | ||||
|     cp["Gamepad"]["type"] = gpad_type | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def ensure_default_proxy_config(): | ||||
|     """Ensures the [Proxy] section exists in the configuration file. | ||||
|     Creates it with empty values if missing. | ||||
|     """ | ||||
|     Проверяет наличие секции [Proxy] в конфигурационном файле. | ||||
|     Если секция отсутствует, создаёт её с пустыми значениями. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|             return | ||||
|         if not cp.has_section("Proxy"): | ||||
|             cp.add_section("Proxy") | ||||
|             cp["Proxy"]["proxy_url"] = "" | ||||
|             cp["Proxy"]["proxy_user"] = "" | ||||
|             cp["Proxy"]["proxy_password"] = "" | ||||
|             with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|                 cp.write(configfile) | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Proxy" not in cp: | ||||
|         cp.add_section("Proxy") | ||||
|         cp["Proxy"]["proxy_url"] = "" | ||||
|         cp["Proxy"]["proxy_user"] = "" | ||||
|         cp["Proxy"]["proxy_password"] = "" | ||||
|         with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|             cp.write(configfile) | ||||
|  | ||||
| def read_proxy_config(): | ||||
|     """ | ||||
|     Читает настройки прокси из секции [Proxy] конфигурационного файла. | ||||
|     Если параметр proxy_url не задан или пустой, возвращает пустой словарь. | ||||
|     """Reads proxy settings from the [Proxy] section. | ||||
|     Returns an empty dict if proxy_url is not set or empty. | ||||
|     """ | ||||
|     ensure_default_proxy_config() | ||||
|     cp = configparser.ConfigParser() | ||||
|     try: | ||||
|         cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|     except Exception as e: | ||||
|         logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None: | ||||
|         return {} | ||||
|  | ||||
|     proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip() | ||||
|     if proxy_url: | ||||
|         # Если указаны логин и пароль, добавляем их к URL | ||||
|         proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip() | ||||
|         proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip() | ||||
|         if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password: | ||||
| @@ -402,16 +310,10 @@ def read_proxy_config(): | ||||
|     return {} | ||||
|  | ||||
| def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""): | ||||
|     """Saves proxy settings to the [Proxy] section. | ||||
|     Creates the section if it does not exist. | ||||
|     """ | ||||
|     Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла. | ||||
|     Если секция отсутствует, создаёт её. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Proxy" not in cp: | ||||
|         cp["Proxy"] = {} | ||||
|     cp["Proxy"]["proxy_url"] = proxy_url | ||||
| @@ -421,34 +323,18 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_fullscreen_config(): | ||||
|     """Reads the fullscreen mode setting from the [Display] section. | ||||
|     Returns False if the parameter is missing. | ||||
|     """ | ||||
|     Читает настройку полноэкранного режима приложения из секции [Display]. | ||||
|     Если параметр отсутствует, сохраняет и возвращает False по умолчанию. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|             save_fullscreen_config(False) | ||||
|             return False | ||||
|         if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"): | ||||
|             save_fullscreen_config(False) | ||||
|             return False | ||||
|         return cp.getboolean("Display", "fullscreen", fallback=False) | ||||
|     return False | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"): | ||||
|         save_fullscreen_config(False) | ||||
|         return False | ||||
|     return cp.getboolean("Display", "fullscreen", fallback=False) | ||||
|  | ||||
| def save_fullscreen_config(fullscreen): | ||||
|     """ | ||||
|     Сохраняет настройку полноэкранного режима приложения в секцию [Display]. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|     """Saves the fullscreen mode setting to the [Display] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Display" not in cp: | ||||
|         cp["Display"] = {} | ||||
|     cp["Display"]["fullscreen"] = str(fullscreen) | ||||
| @@ -456,33 +342,19 @@ def save_fullscreen_config(fullscreen): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_window_geometry() -> tuple[int, int]: | ||||
|     """Reads the window width and height from the [MainWindow] section. | ||||
|     Returns (0, 0) if the parameters are missing. | ||||
|     """ | ||||
|     Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла. | ||||
|     Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0). | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|             return (0, 0) | ||||
|         if cp.has_section("MainWindow"): | ||||
|             width = cp.getint("MainWindow", "width", fallback=0) | ||||
|             height = cp.getint("MainWindow", "height", fallback=0) | ||||
|             return (width, height) | ||||
|     return (0, 0) | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("MainWindow"): | ||||
|         return (0, 0) | ||||
|     width = cp.getint("MainWindow", "width", fallback=0) | ||||
|     height = cp.getint("MainWindow", "height", fallback=0) | ||||
|     return (width, height) | ||||
|  | ||||
| def save_window_geometry(width: int, height: int): | ||||
|     """ | ||||
|     Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка в конфигурационном файле: %s", e) | ||||
|     """Saves the window width and height to the [MainWindow] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "MainWindow" not in cp: | ||||
|         cp["MainWindow"] = {} | ||||
|     cp["MainWindow"]["width"] = str(width) | ||||
| @@ -491,61 +363,67 @@ def save_window_geometry(width: int, height: int): | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def reset_config(): | ||||
|     """ | ||||
|     Сбрасывает конфигурационный файл, удаляя его. | ||||
|     После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении. | ||||
|     """Resets the configuration file by deleting it. | ||||
|     Subsequent reads will use default values. | ||||
|     """ | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             os.remove(CONFIG_FILE) | ||||
|             logger.info("Конфигурационный файл %s удалён", CONFIG_FILE) | ||||
|             logger.info("Configuration file %s deleted", CONFIG_FILE) | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка при удалении конфигурационного файла: %s", e) | ||||
|             logger.warning(f"Failed to delete configuration file: {e}") | ||||
|  | ||||
| def clear_cache(): | ||||
|     """ | ||||
|     Очищает кэш PortProtonQt, удаляя папку кэша. | ||||
|     """ | ||||
|     """Clears the PortProtonQt cache by deleting the cache directory.""" | ||||
|     xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) | ||||
|     cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") | ||||
|     if os.path.exists(cache_dir): | ||||
|         try: | ||||
|             shutil.rmtree(cache_dir) | ||||
|             logger.info("Кэш PortProtonQt удалён: %s", cache_dir) | ||||
|             logger.info("PortProtonQt cache deleted: %s", cache_dir) | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка при удалении кэша: %s", e) | ||||
|             logger.warning(f"Failed to delete cache: {e}") | ||||
|  | ||||
| def read_auto_fullscreen_gamepad(): | ||||
|     """Reads the auto-fullscreen setting for gamepad from the [Display] section. | ||||
|     Returns False if the parameter is missing. | ||||
|     """ | ||||
|     Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [Display]. | ||||
|     Если параметр отсутствует, сохраняет и возвращает False по умолчанию. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except Exception as e: | ||||
|             logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|             save_auto_fullscreen_gamepad(False) | ||||
|             return False | ||||
|         if not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"): | ||||
|             save_auto_fullscreen_gamepad(False) | ||||
|             return False | ||||
|         return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False) | ||||
|     return False | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"): | ||||
|         save_auto_fullscreen_gamepad(False) | ||||
|         return False | ||||
|     return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False) | ||||
|  | ||||
| def save_auto_fullscreen_gamepad(auto_fullscreen): | ||||
|     """ | ||||
|     Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [Display]. | ||||
|     """ | ||||
|     cp = configparser.ConfigParser() | ||||
|     if os.path.exists(CONFIG_FILE): | ||||
|         try: | ||||
|             cp.read(CONFIG_FILE, encoding="utf-8") | ||||
|         except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: | ||||
|             logger.error("Ошибка чтения конфигурационного файла: %s", e) | ||||
|     """Saves the auto-fullscreen setting for gamepad to the [Display] section.""" | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "Display" not in cp: | ||||
|         cp["Display"] = {} | ||||
|     cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen) | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|  | ||||
| def read_favorite_folders(): | ||||
|     """Reads the list of favorite folders from the [FavoritesFolders] section. | ||||
|     The list is stored as a quoted string with comma-separated paths. | ||||
|     Returns an empty list if the section or parameter is missing. | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) | ||||
|     if cp is None or not cp.has_section("FavoritesFolders") or not cp.has_option("FavoritesFolders", "folders"): | ||||
|         return [] | ||||
|     favs = cp.get("FavoritesFolders", "folders", fallback="").strip() | ||||
|     if favs.startswith('"') and favs.endswith('"'): | ||||
|         favs = favs[1:-1] | ||||
|     return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))] | ||||
|  | ||||
| def save_favorite_folders(folders): | ||||
|     """Saves the list of favorite folders to the [FavoritesFolders] section. | ||||
|     The list is stored as a quoted string with comma-separated paths. | ||||
|     """ | ||||
|     cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() | ||||
|     if "FavoritesFolders" not in cp: | ||||
|         cp["FavoritesFolders"] = {} | ||||
|     fav_str = ", ".join([os.path.normpath(folder) for folder in folders]) | ||||
|     cp["FavoritesFolders"]["folders"] = f'"{fav_str}"' | ||||
|     with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: | ||||
|         cp.write(configfile) | ||||
|   | ||||
| @@ -4,7 +4,6 @@ import glob | ||||
| import shutil | ||||
| import subprocess | ||||
| import threading | ||||
| import logging | ||||
| import orjson | ||||
| import psutil | ||||
| import signal | ||||
| @@ -12,13 +11,14 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati | ||||
| from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt | ||||
| from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence | ||||
| from portprotonqt.localization import _ | ||||
| from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites | ||||
| 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.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam | ||||
| from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
| from portprotonqt.logger import get_logger | ||||
|  | ||||
| logger = logging.getLogger(__name__) | ||||
| logger = get_logger(__name__) | ||||
|  | ||||
| class ContextMenuSignals(QObject): | ||||
|     """Signals for thread-safe UI updates from worker threads.""" | ||||
| @@ -29,7 +29,7 @@ class ContextMenuSignals(QObject): | ||||
| class ContextMenuManager: | ||||
|     """Manages context menu actions for game management in PortProtonQt.""" | ||||
|  | ||||
|     def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback): | ||||
|     def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager): | ||||
|         """ | ||||
|         Initialize the ContextMenuManager. | ||||
|  | ||||
| @@ -45,7 +45,8 @@ class ContextMenuManager: | ||||
|         self.theme = theme | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.load_games = load_games_callback | ||||
|         self.update_game_grid = update_game_grid_callback | ||||
|         self.game_library_manager = game_library_manager | ||||
|         self.update_game_grid = game_library_manager.update_game_grid | ||||
|         self.legendary_path = os.path.join( | ||||
|             os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), | ||||
|             "PortProtonQt", "legendary_cache", "legendary" | ||||
| @@ -62,7 +63,7 @@ class ContextMenuManager: | ||||
|                 self.parent.statusBar().showMessage, | ||||
|                 Qt.ConnectionType.QueuedConnection | ||||
|             ) | ||||
|             logger.debug("Connected show_status_message signal to statusBar") | ||||
|             logger.debug("Connected show_status_message signal to status bar") | ||||
|         self.signals.show_warning_dialog.connect( | ||||
|             self._show_warning_dialog, | ||||
|             Qt.ConnectionType.QueuedConnection | ||||
| @@ -74,28 +75,28 @@ class ContextMenuManager: | ||||
|  | ||||
|     def _show_warning_dialog(self, title: str, message: str): | ||||
|         """Show a warning dialog in the main thread.""" | ||||
|         logger.debug("Showing warning dialog: %s - %s", title, message) | ||||
|         logger.debug("Displaying warning dialog: %s - %s", title, message) | ||||
|         QMessageBox.warning(self.parent, title, message) | ||||
|  | ||||
|     def _show_info_dialog(self, title: str, message: str): | ||||
|         """Show an info dialog in the main thread.""" | ||||
|         logger.debug("Showing info dialog: %s - %s", title, message) | ||||
|         logger.debug("Displaying info dialog: %s - %s", title, message) | ||||
|         QMessageBox.information(self.parent, title, message) | ||||
|  | ||||
|     def _show_status_message(self, message: str, timeout: int = 3000): | ||||
|         """Show a status message on the status bar if available.""" | ||||
|         if self.parent.statusBar(): | ||||
|             self.parent.statusBar().showMessage(message, timeout) | ||||
|             logger.debug("Direct status message: %s", message) | ||||
|             logger.debug("Displayed status message: %s", message) | ||||
|         else: | ||||
|             logger.warning("Status bar not available for message: %s", message) | ||||
|             logger.warning("Status bar unavailable for message: %s", message) | ||||
|  | ||||
|     def _check_portproton(self): | ||||
|         """Check if PortProton is available.""" | ||||
|         if self.portproton_location is None: | ||||
|             self.signals.show_warning_dialog.emit( | ||||
|                 _("Error"), | ||||
|                 _("PortProton is not found") | ||||
|                 _("PortProton directory not found") | ||||
|             ) | ||||
|             return False | ||||
|         return True | ||||
| @@ -119,7 +120,7 @@ class ContextMenuManager: | ||||
|                 installed_games = orjson.loads(f.read()) | ||||
|             return app_name in installed_games | ||||
|         except (OSError, orjson.JSONDecodeError) as e: | ||||
|             logger.error("Failed to read installed.json: %s", e) | ||||
|             logger.error("Error reading installed.json: %s", e) | ||||
|             return False | ||||
|  | ||||
|     def _is_game_running(self, game_card) -> bool: | ||||
| @@ -150,6 +151,84 @@ class ContextMenuManager: | ||||
|  | ||||
|         return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe | ||||
|  | ||||
|     def show_folder_context_menu(self, file_explorer, pos): | ||||
|         """Shows the context menu for a folder in FileExplorer.""" | ||||
|         try: | ||||
|             item = file_explorer.file_list.itemAt(pos) | ||||
|             if not item: | ||||
|                 logger.debug("No folder 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 displaying 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("Added folder to favorites: %s", folder_path) | ||||
|         else: | ||||
|             if folder_path in favorite_folders: | ||||
|                 favorite_folders.remove(folder_path) | ||||
|                 save_favorite_folders(favorite_folders) | ||||
|                 logger.info("Removed folder from favorites: %s", folder_path) | ||||
|         file_explorer.update_drives_list() | ||||
|  | ||||
|     def _get_safe_icon(self, icon_name: str) -> QIcon: | ||||
|         """Returns a QIcon, ensuring it is valid.""" | ||||
|         icon = self.theme_manager.get_icon(icon_name) | ||||
|         if isinstance(icon, QIcon): | ||||
|             return icon | ||||
|         elif isinstance(icon, str) and os.path.exists(icon): | ||||
|             return QIcon(icon) | ||||
|         return QIcon() | ||||
|  | ||||
|     def show_context_menu(self, game_card, pos: QPoint): | ||||
|         """ | ||||
|         Show the context menu for a game card at the specified position. | ||||
| @@ -158,14 +237,6 @@ class ContextMenuManager: | ||||
|             game_card: The GameCard instance requesting the context menu. | ||||
|             pos: The position (in widget coordinates) where the menu should appear. | ||||
|         """ | ||||
|         def get_safe_icon(icon_name: str) -> QIcon: | ||||
|             icon = self.theme_manager.get_icon(icon_name) | ||||
|             if isinstance(icon, QIcon): | ||||
|                 return icon | ||||
|             elif isinstance(icon, str) and os.path.exists(icon): | ||||
|                 return QIcon(icon) | ||||
|             return QIcon() | ||||
|  | ||||
|         menu = QMenu(self.parent) | ||||
|         menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE) | ||||
|  | ||||
| @@ -175,7 +246,7 @@ class ContextMenuManager: | ||||
|             exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None | ||||
|             if not exe_path: | ||||
|                 # Show only "Delete from PortProton" if no valid exe | ||||
|                 delete_action = menu.addAction(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)) | ||||
|                 menu.exec(game_card.mapToGlobal(pos)) | ||||
|                 return | ||||
| @@ -184,7 +255,7 @@ class ContextMenuManager: | ||||
|         is_running = self._is_game_running(game_card) | ||||
|         action_text = _("Stop Game") if is_running else _("Launch Game") | ||||
|         action_icon = "stop" if is_running else "play" | ||||
|         launch_action = menu.addAction(get_safe_icon(action_icon), action_text) | ||||
|         launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text) | ||||
|         launch_action.triggered.connect( | ||||
|             lambda: self._launch_game(game_card) | ||||
|         ) | ||||
| @@ -193,11 +264,11 @@ class ContextMenuManager: | ||||
|         is_favorite = game_card.name in favorites | ||||
|         icon_name = "star_full" if is_favorite else "star" | ||||
|         text = _("Remove from Favorites") if is_favorite else _("Add to Favorites") | ||||
|         favorite_action = menu.addAction(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)) | ||||
|  | ||||
|         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( | ||||
|                 lambda: self.import_to_legendary(game_card.name, game_card.appid) | ||||
|             ) | ||||
| @@ -205,13 +276,13 @@ class ContextMenuManager: | ||||
|                 is_in_steam = is_game_in_steam(game_card.name) | ||||
|                 icon_name = "delete" if is_in_steam else "steam" | ||||
|                 text = _("Remove from Steam") if is_in_steam else _("Add to Steam") | ||||
|                 steam_action = menu.addAction(get_safe_icon(icon_name), text) | ||||
|                 steam_action = menu.addAction(self._get_safe_icon(icon_name), text) | ||||
|                 steam_action.triggered.connect( | ||||
|                     lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) | ||||
|                     if is_in_steam | ||||
|                     else self.add_egs_to_steam(game_card.name, game_card.appid) | ||||
|                 ) | ||||
|                 open_folder_action = menu.addAction(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( | ||||
|                     lambda: self.open_egs_game_folder(game_card.appid) | ||||
|                 ) | ||||
| @@ -219,7 +290,7 @@ class ContextMenuManager: | ||||
|                 desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop") | ||||
|                 icon_name = "delete" if os.path.exists(desktop_path) else "desktop" | ||||
|                 text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop") | ||||
|                 desktop_action = menu.addAction(get_safe_icon(icon_name), text) | ||||
|                 desktop_action = menu.addAction(self._get_safe_icon(icon_name), text) | ||||
|                 desktop_action.triggered.connect( | ||||
|                     lambda: self.remove_egs_from_desktop(game_card.name) | ||||
|                     if os.path.exists(desktop_path) | ||||
| @@ -228,7 +299,7 @@ class ContextMenuManager: | ||||
|                 applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications") | ||||
|                 menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop") | ||||
|                 menu_action = menu.addAction( | ||||
|                     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") | ||||
|                 ) | ||||
|                 menu_action.triggered.connect( | ||||
| @@ -242,19 +313,19 @@ class ContextMenuManager: | ||||
|             desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop") | ||||
|             icon_name = "delete" if os.path.exists(desktop_path) else "desktop" | ||||
|             text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop") | ||||
|             desktop_action = menu.addAction(get_safe_icon(icon_name), text) | ||||
|             desktop_action = menu.addAction(self._get_safe_icon(icon_name), text) | ||||
|             desktop_action.triggered.connect( | ||||
|                 lambda: self.remove_from_desktop(game_card.name) | ||||
|                 if os.path.exists(desktop_path) | ||||
|                 else self.add_to_desktop(game_card.name, game_card.exec_line) | ||||
|             ) | ||||
|             edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut")) | ||||
|             edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut")) | ||||
|             edit_action.triggered.connect( | ||||
|                 lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path) | ||||
|             ) | ||||
|             delete_action = menu.addAction(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)) | ||||
|             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( | ||||
|                 lambda: self.open_game_folder(game_card.name, game_card.exec_line) | ||||
|             ) | ||||
| @@ -262,7 +333,7 @@ class ContextMenuManager: | ||||
|             menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop") | ||||
|             icon_name = "delete" if os.path.exists(menu_path) else "menu" | ||||
|             text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu") | ||||
|             menu_action = menu.addAction(get_safe_icon(icon_name), text) | ||||
|             menu_action = menu.addAction(self._get_safe_icon(icon_name), text) | ||||
|             menu_action.triggered.connect( | ||||
|                 lambda: self.remove_from_menu(game_card.name) | ||||
|                 if os.path.exists(menu_path) | ||||
| @@ -271,7 +342,7 @@ class ContextMenuManager: | ||||
|             is_in_steam = is_game_in_steam(game_card.name) | ||||
|             icon_name = "delete" if is_in_steam else "steam" | ||||
|             text = _("Remove from Steam") if is_in_steam else _("Add to Steam") | ||||
|             steam_action = menu.addAction(get_safe_icon(icon_name), text) | ||||
|             steam_action = menu.addAction(self._get_safe_icon(icon_name), text) | ||||
|             steam_action.triggered.connect( | ||||
|                 lambda: ( | ||||
|                     self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) | ||||
| @@ -280,7 +351,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): | ||||
|         """ | ||||
| @@ -417,7 +493,7 @@ class ContextMenuManager: | ||||
|             ) | ||||
|             return | ||||
|  | ||||
|         # Используем FileExplorer с directory_only=True | ||||
|         # Use FileExplorer with directory_only=True | ||||
|         file_explorer = FileExplorer( | ||||
|             parent=self.parent, | ||||
|             theme=self.theme, | ||||
| @@ -447,10 +523,10 @@ class ContextMenuManager: | ||||
|             self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name)) | ||||
|             threading.Thread(target=run_import, daemon=True).start() | ||||
|  | ||||
|         # Подключаем сигнал выбора файла/папки | ||||
|         # Connect the file selection signal | ||||
|         file_explorer.file_signal.file_selected.connect(on_folder_selected) | ||||
|  | ||||
|         # Центрируем FileExplorer относительно родительского виджета | ||||
|         # Center FileExplorer relative to the parent widget | ||||
|         parent_widget = self.parent | ||||
|         if parent_widget: | ||||
|             parent_geometry = parent_widget.geometry() | ||||
| @@ -532,10 +608,10 @@ class ContextMenuManager: | ||||
|         exe_path = get_egs_executable(app_name, self.legendary_config_path) | ||||
|         if exe_path and os.path.exists(exe_path): | ||||
|             if not generate_thumbnail(exe_path, icon_path, size=128): | ||||
|                 logger.error(f"Failed to generate thumbnail from exe: {exe_path}") | ||||
|                 logger.error("Failed to generate thumbnail for EGS game: %s", exe_path) | ||||
|                 icon_path = "" | ||||
|         else: | ||||
|             logger.error(f"No executable found for EGS game: {app_name}") | ||||
|             logger.error("No executable found for EGS game: %s", app_name) | ||||
|             icon_path = "" | ||||
|  | ||||
|         egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops") | ||||
| @@ -675,7 +751,7 @@ Icon={icon_path} | ||||
|                     if not exec_line: | ||||
|                         self.signals.show_warning_dialog.emit( | ||||
|                             _("Error"), | ||||
|                             _("No executable command in .desktop file for '{game_name}'").format(game_name=game_name) | ||||
|                             _("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name) | ||||
|                         ) | ||||
|                         return None | ||||
|                 else: | ||||
| @@ -687,7 +763,7 @@ Icon={icon_path} | ||||
|             except Exception as e: | ||||
|                 self.signals.show_warning_dialog.emit( | ||||
|                     _("Error"), | ||||
|                     _("Failed to read .desktop file: {error}").format(error=str(e)) | ||||
|                     _("Error reading .desktop file: {error}").format(error=str(e)) | ||||
|                 ) | ||||
|                 return None | ||||
|         else: | ||||
| @@ -709,7 +785,7 @@ Icon={icon_path} | ||||
|         try: | ||||
|             entry_exec_split = shlex.split(exec_line) | ||||
|             if not entry_exec_split: | ||||
|                 logger.debug("Invalid executable command for '%s': %s", game_name, exec_line) | ||||
|                 logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line) | ||||
|                 return None | ||||
|             if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3: | ||||
|                 exe_path = entry_exec_split[2] | ||||
| @@ -718,11 +794,11 @@ Icon={icon_path} | ||||
|             else: | ||||
|                 exe_path = entry_exec_split[-1] | ||||
|             if not exe_path or not os.path.exists(exe_path): | ||||
|                 logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None") | ||||
|                 logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None") | ||||
|                 return None | ||||
|             return exe_path | ||||
|         except Exception as e: | ||||
|             logger.debug("Failed to parse executable for '%s': %s", game_name, e) | ||||
|             logger.debug("Error parsing executable for game '%s': %s", game_name, e) | ||||
|             return None | ||||
|  | ||||
|     def _remove_file(self, file_path, error_message, success_message, game_name, location=""): | ||||
| @@ -784,9 +860,16 @@ Icon={icon_path} | ||||
|                         _("Failed to delete custom data: {error}").format(error=str(e)) | ||||
|                     ) | ||||
|  | ||||
|         # Перезагрузка списка игр и обновление сетки | ||||
|         self.load_games() | ||||
|         self.update_game_grid() | ||||
|         self.update_game_grid = self.game_library_manager.remove_game_incremental | ||||
|         self.game_library_manager.remove_game_incremental(game_name, exec_line) | ||||
|  | ||||
|     def add_game_incremental(self, game_data: tuple): | ||||
|         """Add game after .desktop creation.""" | ||||
|         if not self._check_portproton(): | ||||
|             return | ||||
|         # Assume game_data is built from new .desktop (name, desc, cover, etc.) | ||||
|         self.game_library_manager.add_game_incremental(game_data) | ||||
|         self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0])) | ||||
|  | ||||
|     def add_to_menu(self, game_name, exec_line): | ||||
|         """Copy the .desktop file to ~/.local/share/applications.""" | ||||
| @@ -861,7 +944,7 @@ Icon={icon_path} | ||||
|         icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png") | ||||
|         if not os.path.exists(icon_path): | ||||
|             if not generate_thumbnail(exe_path, icon_path, size=128): | ||||
|                 logger.error(f"Failed to generate thumbnail for {exe_path}") | ||||
|                 logger.error("Failed to generate thumbnail for game: %s", exe_path) | ||||
|  | ||||
|         desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip() | ||||
|         os.makedirs(desktop_dir, exist_ok=True) | ||||
| @@ -997,7 +1080,7 @@ Icon={icon_path} | ||||
|         exe_path = self._parse_exe_path(exec_line, game_name) | ||||
|         if not exe_path: | ||||
|             return | ||||
|         logger.debug("Adding '%s' to Steam", game_name) | ||||
|         logger.debug("Adding game '%s' to Steam", game_name) | ||||
|         try: | ||||
|             success, message = add_to_steam(game_name, exec_line, cover_path) | ||||
|             self.signals.show_info_dialog.emit( | ||||
| @@ -1040,7 +1123,7 @@ Icon={icon_path} | ||||
|             exe_path = self._parse_exe_path(exec_line, game_name) | ||||
|             if not exe_path: | ||||
|                 return | ||||
|             logger.debug("Removing non-EGS game '%s' from Steam", game_name) | ||||
|             logger.debug("Removing game '%s' from Steam", game_name) | ||||
|             try: | ||||
|                 success, message = remove_from_steam(game_name, exec_line) | ||||
|                 self.signals.show_info_dialog.emit( | ||||
|   | ||||
| Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB | 
| 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. | ||||
| Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB | 
| Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB | 
| Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB | 
| Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB | 
| Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB | 
| Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB | 
| @@ -5,30 +5,63 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter | ||||
|  | ||||
| def compute_layout(nat_sizes, rect_width, spacing, max_scale): | ||||
|     """ | ||||
|     Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек. | ||||
|     nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота). | ||||
|     rect_width: доступная ширина контейнера. | ||||
|     spacing: отступ между элементами. | ||||
|     max_scale: максимальный коэффициент масштабирования (например, 1.2). | ||||
|     Computes the layout of elements considering spacing and potential scaling of cards. | ||||
|     nat_sizes: Array (N, 2) with natural sizes of elements (width, height). | ||||
|     rect_width: Available container width. | ||||
|     spacing: Spacing between elements (horizontal and vertical). | ||||
|     max_scale: Maximum scaling factor (e.g., 1.0). | ||||
|  | ||||
|     Возвращает: | ||||
|       result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height]. | ||||
|       total_height: итоговая высота всех рядов. | ||||
|     Returns: | ||||
|       result: Array (N, 4), where each row contains [x, y, new_width, new_height]. | ||||
|       total_height: Total height of all rows. | ||||
|     """ | ||||
|     N = nat_sizes.shape[0] | ||||
|     result = np.zeros((N, 4), dtype=np.int32) | ||||
|     y = 0 | ||||
|     i = 0 | ||||
|     min_margin = 20  # Minimum margin on edges | ||||
|  | ||||
|     # Determine the maximum number of items per row and overall scale | ||||
|     max_items_per_row = 0 | ||||
|     global_scale = 1.0 | ||||
|     max_row_x_start = min_margin  # Starting x position of the widest row | ||||
|     temp_i = 0 | ||||
|  | ||||
|     # First pass: Find the maximum number of items in a row | ||||
|     while temp_i < N: | ||||
|         sum_width = 0 | ||||
|         count = 0 | ||||
|         temp_j = temp_i | ||||
|         while temp_j < N: | ||||
|             w = nat_sizes[temp_j, 0] | ||||
|             if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin: | ||||
|                 break | ||||
|             sum_width += w | ||||
|             count += 1 | ||||
|             temp_j += 1 | ||||
|  | ||||
|         if count > max_items_per_row: | ||||
|             max_items_per_row = count | ||||
|             # Calculate scale for the most populated row | ||||
|             available_width = rect_width - spacing * (count - 1) - 2 * min_margin | ||||
|             desired_scale = available_width / sum_width if sum_width > 0 else 1.0 | ||||
|             global_scale = desired_scale if desired_scale < max_scale else max_scale | ||||
|             # Store starting x position for the widest row | ||||
|             scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1) | ||||
|             max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2) | ||||
|         temp_i = temp_j | ||||
|  | ||||
|     # Second pass: Place elements | ||||
|     while i < N: | ||||
|         sum_width = 0 | ||||
|         row_max_height = 0 | ||||
|         count = 0 | ||||
|         j = i | ||||
|         # Подбираем количество элементов для текущего ряда | ||||
|  | ||||
|         # Determine the number of items for the current row | ||||
|         while j < N: | ||||
|             w = nat_sizes[j, 0] | ||||
|             # Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим | ||||
|             if count > 0 and (sum_width + spacing + w) > rect_width: | ||||
|             if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin: | ||||
|                 break | ||||
|             sum_width += w | ||||
|             count += 1 | ||||
| @@ -36,13 +69,19 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale): | ||||
|             if h > row_max_height: | ||||
|                 row_max_height = h | ||||
|             j += 1 | ||||
|         # Доступная ширина ряда с учетом обязательных отступов между элементами | ||||
|         available_width = rect_width - spacing * (count - 1) | ||||
|         desired_scale = available_width / sum_width if sum_width > 0 else 1.0 | ||||
|         # Разрешаем увеличение карточек, но не более max_scale | ||||
|         scale = desired_scale if desired_scale < max_scale else max_scale | ||||
|         # Выравниваем по левому краю (offset = 0) | ||||
|         x = 0 | ||||
|  | ||||
|         # Use global scale for all rows | ||||
|         scale = global_scale | ||||
|         scaled_row_width = int(sum_width * scale) + spacing * (count - 1) | ||||
|  | ||||
|         # Determine starting x coordinate | ||||
|         if count == max_items_per_row: | ||||
|             # Center the full row | ||||
|             x = max(min_margin, (rect_width - scaled_row_width) // 2) | ||||
|         else: | ||||
|             # Align incomplete row to the left, matching the widest row's start | ||||
|             x = max_row_x_start | ||||
|  | ||||
|         for k in range(i, j): | ||||
|             new_w = int(nat_sizes[k, 0] * scale) | ||||
|             new_h = int(nat_sizes[k, 1] * scale) | ||||
| @@ -51,6 +90,7 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale): | ||||
|             result[k, 2] = new_w | ||||
|             result[k, 3] = new_h | ||||
|             x += new_w + spacing | ||||
|  | ||||
|         y += int(row_max_height * scale) + spacing | ||||
|         i = j | ||||
|     return result, y | ||||
| @@ -59,18 +99,17 @@ class FlowLayout(QLayout): | ||||
|     def __init__(self, parent=None): | ||||
|         super().__init__(parent) | ||||
|         self.itemList = [] | ||||
|         # Устанавливаем отступы контейнера в 0 и задаем spacing между карточками | ||||
|         self.setContentsMargins(0, 0, 0, 0) | ||||
|         self._spacing = 3  # отступ между карточками | ||||
|         self._max_scale = 1.2  # максимальное увеличение карточек (например, на 20%) | ||||
|         self.setContentsMargins(20, 20, 20, 20)  # Margins around the layout | ||||
|         self._spacing = 20  # Spacing for animation and overlap prevention | ||||
|         self._max_scale = 1.0  # Scaling disabled in layout | ||||
|  | ||||
|     def addItem(self, item: QLayoutItem) -> None: | ||||
|             self.itemList.append(item) | ||||
|         self.itemList.append(item) | ||||
|  | ||||
|     def takeAt(self, index: int) -> QLayoutItem: | ||||
|             if 0 <= index < len(self.itemList): | ||||
|                 return self.itemList.pop(index) | ||||
|             raise IndexError("Index out of range") | ||||
|         if 0 <= index < len(self.itemList): | ||||
|             return self.itemList.pop(index) | ||||
|         raise IndexError("Index out of range") | ||||
|  | ||||
|     def count(self) -> int: | ||||
|         return len(self.itemList) | ||||
| @@ -87,7 +126,21 @@ class FlowLayout(QLayout): | ||||
|         return True | ||||
|  | ||||
|     def heightForWidth(self, width): | ||||
|         return self.doLayout(QRect(0, 0, width, 0), True) | ||||
|         # Аналогично фильтруем видимые для тестового расчёта высоты | ||||
|         visible_items = [] | ||||
|         nat_sizes = np.empty((0, 2), dtype=np.int32) | ||||
|         for item in self.itemList: | ||||
|             if item.widget() and item.widget().isVisible(): | ||||
|                 visible_items.append(item) | ||||
|                 s = item.sizeHint() | ||||
|                 new_row = np.array([[s.width(), s.height()]], dtype=np.int32) | ||||
|                 nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row | ||||
|  | ||||
|         if len(visible_items) == 0: | ||||
|             return 0 | ||||
|  | ||||
|         _, total_height = compute_layout(nat_sizes, width, self._spacing, self._max_scale) | ||||
|         return total_height | ||||
|  | ||||
|     def setGeometry(self, rect): | ||||
|         super().setGeometry(rect) | ||||
| @@ -102,32 +155,50 @@ class FlowLayout(QLayout): | ||||
|             size = size.expandedTo(item.minimumSize()) | ||||
|         margins = self.contentsMargins() | ||||
|         size += QSize(margins.left() + margins.right(), | ||||
|                              margins.top() + margins.bottom()) | ||||
|                       margins.top() + margins.bottom()) | ||||
|         return size | ||||
|  | ||||
|     def doLayout(self, rect, testOnly): | ||||
|         N = len(self.itemList) | ||||
|         if N == 0: | ||||
|         N_total = len(self.itemList) | ||||
|         if N_total == 0: | ||||
|             return 0 | ||||
|  | ||||
|         # Собираем натуральные размеры всех элементов в массив NumPy | ||||
|         nat_sizes = np.empty((N, 2), dtype=np.int32) | ||||
|         # Фильтруем только видимые элементы | ||||
|         visible_items = [] | ||||
|         visible_indices = []  # Индексы в оригинальном itemList для установки геометрии | ||||
|         nat_sizes = np.empty((0, 2), dtype=np.int32) | ||||
|         for i, item in enumerate(self.itemList): | ||||
|             s = item.sizeHint() | ||||
|             nat_sizes[i, 0] = s.width() | ||||
|             nat_sizes[i, 1] = s.height() | ||||
|             if item.widget() and item.widget().isVisible(): | ||||
|                 visible_items.append(item) | ||||
|                 visible_indices.append(i) | ||||
|                 s = item.sizeHint() | ||||
|                 new_row = np.array([[s.width(), s.height()]], dtype=np.int32) | ||||
|                 nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row | ||||
|  | ||||
|         N = len(visible_items) | ||||
|         if N == 0: | ||||
|             # Если все скрыты, устанавливаем нулевые геометрии для всех | ||||
|             if not testOnly: | ||||
|                 for item in self.itemList: | ||||
|                     item.setGeometry(QRect()) | ||||
|             return 0 | ||||
|  | ||||
|         # Вычисляем геометрию с учетом spacing и max_scale через numba-функцию | ||||
|         geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale) | ||||
|  | ||||
|         if not testOnly: | ||||
|             for i, item in enumerate(self.itemList): | ||||
|                 x = geom_array[i, 0] + rect.x() | ||||
|                 y = geom_array[i, 1] + rect.y() | ||||
|                 w = geom_array[i, 2] | ||||
|                 h = geom_array[i, 3] | ||||
|             # Устанавливаем геометрии только для видимых | ||||
|             for idx, (_vis_idx, item) in enumerate(zip(visible_indices, visible_items, strict=True)): | ||||
|                 x = geom_array[idx, 0] + rect.x() | ||||
|                 y = geom_array[idx, 1] + rect.y() | ||||
|                 w = geom_array[idx, 2] | ||||
|                 h = geom_array[idx, 3] | ||||
|                 item.setGeometry(QRect(QPoint(x, y), QSize(w, h))) | ||||
|  | ||||
|             # Для невидимых — нулевая геометрия | ||||
|             for i in range(N_total): | ||||
|                 if i not in visible_indices: | ||||
|                     self.itemList[i].setGeometry(QRect()) | ||||
|  | ||||
|         return total_height | ||||
|  | ||||
| class ClickableLabel(QLabel): | ||||
| @@ -152,7 +223,7 @@ class ClickableLabel(QLabel): | ||||
|         self._icon_size = icon_size | ||||
|         self._icon_space = icon_space | ||||
|         self._font_scale_factor = font_scale_factor | ||||
|         self._card_width = 250  # Значение по умолчанию | ||||
|         self._card_width = 250 | ||||
|         if change_cursor: | ||||
|             self.setCursor(Qt.CursorShape.PointingHandCursor) | ||||
|         self.updateFontSize() | ||||
| @@ -170,28 +241,23 @@ class ClickableLabel(QLabel): | ||||
|         self.update() | ||||
|  | ||||
|     def setCardWidth(self, card_width: int): | ||||
|         """Обновляет ширину карточки и пересчитывает размер шрифта.""" | ||||
|         self._card_width = card_width | ||||
|         self.updateFontSize() | ||||
|  | ||||
|     def updateFontSize(self): | ||||
|         """Обновляет размер шрифта на основе card_width и font_scale_factor.""" | ||||
|         font = self.font() | ||||
|         font_size = int(self._card_width * self._font_scale_factor) | ||||
|         font.setPointSize(max(8, font_size))  # Минимальный размер шрифта 8 | ||||
|         font.setPointSize(max(8, font_size)) | ||||
|         self.setFont(font) | ||||
|         self.update() | ||||
|  | ||||
|     def paintEvent(self, event): | ||||
|         painter = QPainter(self) | ||||
|         painter.setRenderHint(QPainter.RenderHint.Antialiasing) | ||||
|  | ||||
|         rect = self.contentsRect() | ||||
|         alignment = self.alignment() | ||||
|  | ||||
|         icon_size = self._icon_size | ||||
|         spacing = self._icon_space | ||||
|  | ||||
|         text = self.text() | ||||
|  | ||||
|         if self._icon: | ||||
| @@ -200,17 +266,11 @@ class ClickableLabel(QLabel): | ||||
|             pixmap = None | ||||
|  | ||||
|         fm = QFontMetrics(self.font()) | ||||
|  | ||||
|         # Считаем, сколько места остаётся под текст | ||||
|         available_width = rect.width() | ||||
|         if pixmap: | ||||
|             available_width -= (icon_size + spacing) | ||||
|         # Отступы по 2px с каждой стороны | ||||
|         available_width = max(0, available_width - 4) | ||||
|  | ||||
|         # Получаем «обрезанный» текст с многоточием | ||||
|         display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width) | ||||
|  | ||||
|         text_width = fm.horizontalAdvance(display_text) | ||||
|         text_height = fm.height() | ||||
|         total_width = text_width + (icon_size + spacing if pixmap else 0) | ||||
| @@ -280,8 +340,6 @@ class AutoSizeButton(QPushButton): | ||||
|  | ||||
|         self.setCursor(Qt.CursorShape.PointingHandCursor) | ||||
|         self.setFlat(True) | ||||
|  | ||||
|         # Изначально выставляем минимальную ширину | ||||
|         self.setMinimumWidth(50) | ||||
|         self.adjustFontSize() | ||||
|  | ||||
| @@ -312,7 +370,6 @@ class AutoSizeButton(QPushButton): | ||||
|         if not self._update_size: | ||||
|             return | ||||
|  | ||||
|         # Определяем доступную ширину внутри кнопки | ||||
|         available_width = self.width() | ||||
|         if self._icon: | ||||
|             available_width -= self._icon_size | ||||
| @@ -323,7 +380,6 @@ class AutoSizeButton(QPushButton): | ||||
|         font = QFont(self._original_font) | ||||
|         text = self._original_text | ||||
|  | ||||
|         # Подбираем максимально возможный размер шрифта, при котором текст укладывается | ||||
|         chosen_size = self._max_font_size | ||||
|         for font_size in range(self._max_font_size, self._min_font_size - 1, -1): | ||||
|             font.setPointSize(font_size) | ||||
| @@ -336,14 +392,12 @@ class AutoSizeButton(QPushButton): | ||||
|         font.setPointSize(chosen_size) | ||||
|         self.setFont(font) | ||||
|  | ||||
|         # После выбора шрифта вычисляем требуемую ширину для полного отображения текста | ||||
|         fm = QFontMetrics(font) | ||||
|         text_width = fm.horizontalAdvance(text) | ||||
|         required_width = text_width + margins.left() + margins.right() + self._padding * 2 | ||||
|         if self._icon: | ||||
|             required_width += self._icon_size | ||||
|  | ||||
|         # Если текущая ширина меньше требуемой, обновляем минимальную ширину | ||||
|         if self.width() < required_width: | ||||
|             self.setMinimumWidth(required_width) | ||||
|  | ||||
| @@ -353,7 +407,6 @@ class AutoSizeButton(QPushButton): | ||||
|         if not self._update_size: | ||||
|             return super().sizeHint() | ||||
|         else: | ||||
|             # Вычисляем оптимальный размер кнопки на основе текста и отступов | ||||
|             font = self.font() | ||||
|             fm = QFontMetrics(font) | ||||
|             text_width = fm.horizontalAdvance(self._original_text) | ||||
| @@ -364,7 +417,6 @@ class AutoSizeButton(QPushButton): | ||||
|             height = fm.height() + margins.top() + margins.bottom() + self._padding | ||||
|             return QSize(width, height) | ||||
|  | ||||
|  | ||||
| class NavLabel(QLabel): | ||||
|     clicked = Signal() | ||||
|  | ||||
| @@ -376,7 +428,6 @@ class NavLabel(QLabel): | ||||
|         self._isChecked = False | ||||
|         self.setProperty("checked", self._isChecked) | ||||
|         self.setCursor(Qt.CursorShape.PointingHandCursor) | ||||
|         # Explicitly enable focus | ||||
|         self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|  | ||||
|     def setCheckable(self, checkable): | ||||
| @@ -395,7 +446,6 @@ class NavLabel(QLabel): | ||||
|  | ||||
|     def mousePressEvent(self, event): | ||||
|         if event.button() == Qt.MouseButton.LeftButton: | ||||
|             # Ensure widget can take focus on click | ||||
|             self.setFocus(Qt.FocusReason.MouseFocusReason) | ||||
|             if self._checkable: | ||||
|                 self.setChecked(not self._isChecked) | ||||
|   | ||||
| @@ -144,14 +144,21 @@ class Downloader(QObject): | ||||
|                 logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем") | ||||
|                 return None | ||||
|             if url in self._cache: | ||||
|                 return self._cache[url] | ||||
|                 cached_path = self._cache[url] | ||||
|                 if os.path.exists(cached_path): | ||||
|                     if os.path.abspath(cached_path) == os.path.abspath(local_path): | ||||
|                         return cached_path | ||||
|                 else: | ||||
|                     del self._cache[url] | ||||
|         url_lock = self._get_url_lock(url) | ||||
|         with url_lock: | ||||
|             with self._global_lock: | ||||
|                 if url in self._last_error: | ||||
|                     return None | ||||
|                 if url in self._cache: | ||||
|                     return self._cache[url] | ||||
|                     cached_path = self._cache[url] | ||||
|                     if os.path.exists(cached_path) and os.path.abspath(cached_path) == os.path.abspath(local_path): | ||||
|                         return cached_path | ||||
|             result = download_with_cache(url, local_path, timeout, self) | ||||
|             with self._global_lock: | ||||
|                 if result: | ||||
|   | ||||
| @@ -16,13 +16,14 @@ from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_la | ||||
| from portprotonqt.config_utils import get_portproton_location | ||||
| from portprotonqt.steam_api import ( | ||||
|     get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async, | ||||
|     search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail | ||||
|     search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api | ||||
| ) | ||||
| import vdf | ||||
| import shutil | ||||
| import zlib | ||||
| from portprotonqt.downloader import Downloader | ||||
| from PySide6.QtGui import QPixmap | ||||
| import base64 | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| downloader = Downloader() | ||||
| @@ -66,7 +67,8 @@ def get_cache_dir() -> Path: | ||||
|  | ||||
| def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None: | ||||
|     """ | ||||
|     Removes an EGS game from Steam by modifying shortcuts.vdf and deleting the launch script. | ||||
|     Removes an EGS game from Steam using CEF API or by modifying shortcuts.vdf and deleting the launch script. | ||||
|     Also deletes associated cover files in the Steam grid directory. | ||||
|     Calls the callback with (success, message). | ||||
|  | ||||
|     Args: | ||||
| @@ -74,6 +76,7 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl | ||||
|         portproton_dir: Path to the PortProton directory. | ||||
|         callback: Callback function to handle the result (success, message). | ||||
|     """ | ||||
|  | ||||
|     if not portproton_dir: | ||||
|         logger.error("PortProton directory not found") | ||||
|         callback((False, "PortProton directory not found")) | ||||
| @@ -101,51 +104,89 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl | ||||
|     unsigned_id = convert_steam_id(user_id) | ||||
|     user_dir = os.path.join(userdata_dir, str(unsigned_id)) | ||||
|     steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf") | ||||
|     backup_path = f"{steam_shortcuts_path}.backup" | ||||
|     grid_dir = os.path.join(user_dir, "config", "grid") | ||||
|  | ||||
|     if not os.path.exists(steam_shortcuts_path): | ||||
|         logger.error("Steam shortcuts file not found") | ||||
|         callback((False, "Steam shortcuts file not found")) | ||||
|         return | ||||
|  | ||||
|     # Find appid for the shortcut | ||||
|     try: | ||||
|         with open(steam_shortcuts_path, 'rb') as f: | ||||
|             shortcuts_data = vdf.binary_load(f) | ||||
|         shortcuts = shortcuts_data.get("shortcuts", {}) | ||||
|         appid = None | ||||
|         for _key, entry in shortcuts.items(): | ||||
|             if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path: | ||||
|                 appid = convert_steam_id(int(entry.get("appid"))) | ||||
|                 logger.info(f"Found matching shortcut for '{game_name}' with AppID {appid}") | ||||
|                 break | ||||
|         if not appid: | ||||
|             logger.info(f"Game '{game_name}' not found in Steam shortcuts") | ||||
|             callback((False, f"Game '{game_name}' not found in Steam")) | ||||
|             return | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to load shortcuts.vdf: {e}") | ||||
|         callback((False, f"Failed to load shortcuts.vdf: {e}")) | ||||
|         return | ||||
|  | ||||
|     # Try CEF API first | ||||
|     logger.info(f"Attempting to remove EGS game '{game_name}' via Steam CEF API with AppID {appid}") | ||||
|     api_response = call_steam_api("removeShortcut", appid) | ||||
|     if api_response is not None:  # API responded, even if empty | ||||
|         logger.info(f"Shortcut for AppID {appid} successfully removed via CEF API") | ||||
|  | ||||
|         # Delete cover files | ||||
|         cover_files = [ | ||||
|             os.path.join(grid_dir, f"{appid}.jpg"), | ||||
|             os.path.join(grid_dir, f"{appid}p.jpg"), | ||||
|             os.path.join(grid_dir, f"{appid}_hero.jpg"), | ||||
|             os.path.join(grid_dir, f"{appid}_logo.png") | ||||
|         ] | ||||
|         for cover_file in cover_files: | ||||
|             if os.path.exists(cover_file): | ||||
|                 try: | ||||
|                     os.remove(cover_file) | ||||
|                     logger.info(f"Deleted cover file: {cover_file}") | ||||
|                 except Exception as e: | ||||
|                     logger.error(f"Failed to delete cover file {cover_file}: {e}") | ||||
|  | ||||
|         # Delete launch script | ||||
|         if os.path.exists(script_path): | ||||
|             try: | ||||
|                 os.remove(script_path) | ||||
|                 logger.info(f"Removed EGS script: {script_path}") | ||||
|             except OSError as e: | ||||
|                 logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}") | ||||
|  | ||||
|         callback((True, f"Game '{game_name}' was removed from Steam. Please restart Steam for changes to take effect.")) | ||||
|         return | ||||
|  | ||||
|     # Fallback to VDF modification | ||||
|     logger.warning("CEF API failed for EGS game removal; falling back to VDF modification") | ||||
|     backup_path = f"{steam_shortcuts_path}.backup" | ||||
|     try: | ||||
|         shutil.copy2(steam_shortcuts_path, backup_path) | ||||
|         logger.info("Created backup of shortcuts.vdf at %s", backup_path) | ||||
|         logger.info(f"Created backup of shortcuts.vdf at {backup_path}") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|         callback((False, f"Failed to create backup of shortcuts.vdf: {e}")) | ||||
|         return | ||||
|  | ||||
|     try: | ||||
|         with open(steam_shortcuts_path, 'rb') as f: | ||||
|             shortcuts_data = vdf.binary_load(f) | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to load shortcuts.vdf: {e}") | ||||
|         callback((False, f"Failed to load shortcuts.vdf: {e}")) | ||||
|         return | ||||
|         new_shortcuts = {} | ||||
|         index = 0 | ||||
|         for _key, entry in shortcuts.items(): | ||||
|             if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path: | ||||
|                 logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts") | ||||
|                 continue | ||||
|             new_shortcuts[str(index)] = entry | ||||
|             index += 1 | ||||
|  | ||||
|     shortcuts = shortcuts_data.get("shortcuts", {}) | ||||
|     modified = False | ||||
|     new_shortcuts = {} | ||||
|     index = 0 | ||||
|  | ||||
|     for _key, entry in shortcuts.items(): | ||||
|         if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path: | ||||
|             modified = True | ||||
|             logger.info("Removing EGS game '%s' from Steam shortcuts", game_name) | ||||
|             continue | ||||
|         new_shortcuts[str(index)] = entry | ||||
|         index += 1 | ||||
|  | ||||
|     if not modified: | ||||
|         logger.error("Game '%s' not found in Steam shortcuts", game_name) | ||||
|         callback((False, f"Game '{game_name}' not found in Steam shortcuts")) | ||||
|         return | ||||
|  | ||||
|     try: | ||||
|         with open(steam_shortcuts_path, 'wb') as f: | ||||
|             vdf.binary_dump({"shortcuts": new_shortcuts}, f) | ||||
|         logger.info("Updated shortcuts.vdf, removed '%s'", game_name) | ||||
|         logger.info(f"Updated shortcuts.vdf, removed '{game_name}'") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to update shortcuts.vdf: {e}") | ||||
|         if os.path.exists(backup_path): | ||||
| @@ -157,10 +198,26 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl | ||||
|         callback((False, f"Failed to update shortcuts.vdf: {e}")) | ||||
|         return | ||||
|  | ||||
|     # Delete cover files | ||||
|     cover_files = [ | ||||
|         os.path.join(grid_dir, f"{appid}.jpg"), | ||||
|         os.path.join(grid_dir, f"{appid}p.jpg"), | ||||
|         os.path.join(grid_dir, f"{appid}_hero.jpg"), | ||||
|         os.path.join(grid_dir, f"{appid}_logo.png") | ||||
|     ] | ||||
|     for cover_file in cover_files: | ||||
|         if os.path.exists(cover_file): | ||||
|             try: | ||||
|                 os.remove(cover_file) | ||||
|                 logger.info(f"Deleted cover file: {cover_file}") | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to delete cover file {cover_file}: {e}") | ||||
|  | ||||
|     # Delete launch script | ||||
|     if os.path.exists(script_path): | ||||
|         try: | ||||
|             os.remove(script_path) | ||||
|             logger.info("Removed EGS script: %s", script_path) | ||||
|             logger.info(f"Removed EGS script: {script_path}") | ||||
|         except OSError as e: | ||||
|             logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}") | ||||
|  | ||||
| @@ -168,11 +225,17 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl | ||||
|  | ||||
| def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None: | ||||
|     """ | ||||
|     Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag. | ||||
|     Asynchronously adds an EGS game to Steam via CEF API or shortcuts.vdf with PortProton tag. | ||||
|     Creates a launch script using legendary CLI with --no-wine and PortProton wrapper. | ||||
|     Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh. | ||||
|     Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail. | ||||
|     Calls the callback with (success, message). | ||||
|  | ||||
|     Args: | ||||
|         app_name: The Legendary app_name (unique identifier for the game). | ||||
|         game_title: The display name of the game. | ||||
|         legendary_path: Path to the Legendary CLI executable. | ||||
|         callback: Callback function to handle the result (success, message). | ||||
|     """ | ||||
|     if not app_name or not app_name.strip() or not game_title or not game_title.strip(): | ||||
|         logger.error("Invalid app_name or game_title: empty or whitespace") | ||||
| @@ -267,47 +330,47 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}" | ||||
|     grid_dir = user_dir / "config" / "grid" | ||||
|     os.makedirs(grid_dir, exist_ok=True) | ||||
|  | ||||
|     # Backup shortcuts.vdf | ||||
|     backup_path = f"{steam_shortcuts_path}.backup" | ||||
|     if os.path.exists(steam_shortcuts_path): | ||||
|         try: | ||||
|             shutil.copy2(steam_shortcuts_path, backup_path) | ||||
|             logger.info(f"Created backup of shortcuts.vdf at {backup_path}") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|             callback((False, f"Failed to create backup of shortcuts.vdf: {e}")) | ||||
|             return | ||||
|     # Try CEF API first | ||||
|     logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API") | ||||
|     api_response = call_steam_api( | ||||
|         "createShortcut", | ||||
|         game_title, | ||||
|         script_path, | ||||
|         str(Path(script_path).parent), | ||||
|         icon_path, | ||||
|         "" | ||||
|     ) | ||||
|  | ||||
|     # Generate unique appid | ||||
|     unique_string = f"{script_path}{game_title}" | ||||
|     baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff | ||||
|     appid = baseid | 0x80000000 | ||||
|     if appid > 0x7FFFFFFF: | ||||
|         aidvdf = appid - 0x100000000 | ||||
|     appid = None | ||||
|     was_api_used = False | ||||
|  | ||||
|     if api_response and isinstance(api_response, dict) and 'id' in api_response: | ||||
|         appid = api_response['id'] | ||||
|         was_api_used = True | ||||
|         logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}") | ||||
|     else: | ||||
|         aidvdf = appid | ||||
|         logger.warning("CEF API failed for EGS game addition; falling back to VDF modification") | ||||
|         # Backup shortcuts.vdf | ||||
|         backup_path = f"{steam_shortcuts_path}.backup" | ||||
|         if os.path.exists(steam_shortcuts_path): | ||||
|             try: | ||||
|                 shutil.copy2(steam_shortcuts_path, backup_path) | ||||
|                 logger.info(f"Created backup of shortcuts.vdf at {backup_path}") | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|                 callback((False, f"Failed to create backup of shortcuts.vdf: {e}")) | ||||
|                 return | ||||
|  | ||||
|     steam_appid = None | ||||
|     downloaded_count = 0 | ||||
|     total_covers = 4 | ||||
|     download_lock = threading.Lock() | ||||
|         # Generate unique appid | ||||
|         unique_string = f"{script_path}{game_title}" | ||||
|         baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff | ||||
|         appid = baseid | 0x80000000 | ||||
|         if appid > 0x7FFFFFFF: | ||||
|             aidvdf = appid - 0x100000000 | ||||
|         else: | ||||
|             aidvdf = appid | ||||
|  | ||||
|     def on_cover_download(cover_file: str, cover_type: str): | ||||
|         nonlocal downloaded_count | ||||
|         try: | ||||
|             if cover_file and os.path.exists(cover_file): | ||||
|                 logger.info(f"Downloaded cover {cover_type} to {cover_file}") | ||||
|             else: | ||||
|                 logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}") | ||||
|         with download_lock: | ||||
|             downloaded_count += 1 | ||||
|             if downloaded_count == total_covers: | ||||
|                 finalize_shortcut() | ||||
|  | ||||
|     def finalize_shortcut(): | ||||
|         tags_dict = {'0': 'PortProton'} | ||||
|         # Create shortcut entry | ||||
|         shortcut = { | ||||
|             "appid": aidvdf, | ||||
|             "AppName": game_title, | ||||
| @@ -322,7 +385,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}" | ||||
|             "Devkit": 0, | ||||
|             "DevkitGameID": "", | ||||
|             "LastPlayTime": 0, | ||||
|             "tags": tags_dict | ||||
|             "tags": {'0': 'PortProton'} | ||||
|         } | ||||
|         logger.info(f"Shortcut entry for EGS game: {shortcut}") | ||||
|  | ||||
| @@ -353,6 +416,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}" | ||||
|  | ||||
|             with open(steam_shortcuts_path, 'wb') as f: | ||||
|                 vdf.binary_dump({"shortcuts": shortcuts}, f) | ||||
|             logger.info(f"EGS game '{game_title}' added to Steam via VDF") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to update shortcuts.vdf: {e}") | ||||
|             if os.path.exists(backup_path): | ||||
| @@ -364,8 +428,42 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}" | ||||
|             callback((False, f"Failed to update shortcuts.vdf: {e}")) | ||||
|             return | ||||
|  | ||||
|         logger.info(f"EGS game '{game_title}' added to Steam") | ||||
|         callback((True, f"Game '{game_title}' added to Steam with covers")) | ||||
|     if not appid: | ||||
|         callback((False, "Failed to create shortcut via any method")) | ||||
|         return | ||||
|  | ||||
|     steam_appid = None | ||||
|     downloaded_count = 0 | ||||
|     total_covers = 4 | ||||
|     download_lock = threading.Lock() | ||||
|  | ||||
|     def on_cover_download(cover_file: str | None, cover_type: str, index: int): | ||||
|         nonlocal downloaded_count | ||||
|         try: | ||||
|             if cover_file is None or not os.path.exists(cover_file): | ||||
|                 logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}") | ||||
|                 with download_lock: | ||||
|                     downloaded_count += 1 | ||||
|                     if downloaded_count == total_covers: | ||||
|                         callback((True, f"Game '{game_title}' added to Steam with covers")) | ||||
|                 return | ||||
|  | ||||
|             logger.info(f"Downloaded cover {cover_type} to {cover_file}") | ||||
|             if was_api_used: | ||||
|                 try: | ||||
|                     with open(cover_file, 'rb') as f: | ||||
|                         img_b64 = base64.b64encode(f.read()).decode('utf-8') | ||||
|                     logger.info(f"Applying cover type '{cover_type}' via API for AppID {appid}") | ||||
|                     ext = Path(cover_type).suffix.lstrip('.') | ||||
|                     call_steam_api("setGrid", appid, index, ext, img_b64) | ||||
|                 except Exception as e: | ||||
|                     logger.error(f"Error applying cover '{cover_type}' via API: {e}") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}") | ||||
|         with download_lock: | ||||
|             downloaded_count += 1 | ||||
|             if downloaded_count == total_covers: | ||||
|                 callback((True, f"Game '{game_title}' added to Steam with covers")) | ||||
|  | ||||
|     def on_steam_apps(steam_data: tuple[list, dict]): | ||||
|         nonlocal steam_appid | ||||
| @@ -375,24 +473,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}" | ||||
|  | ||||
|         if not steam_appid: | ||||
|             logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download") | ||||
|             finalize_shortcut() | ||||
|             callback((True, f"Game '{game_title}' added to Steam")) | ||||
|             return | ||||
|  | ||||
|         cover_types = [ | ||||
|             (".jpg", "header.jpg"), | ||||
|             ("p.jpg", "library_600x900_2x.jpg"), | ||||
|             ("_hero.jpg", "library_hero.jpg"), | ||||
|             ("_logo.png", "logo.png") | ||||
|             (".jpg", "header.jpg", 0), | ||||
|             ("p.jpg", "library_600x900_2x.jpg", 1), | ||||
|             ("_hero.jpg", "library_hero.jpg", 2), | ||||
|             ("_logo.png", "logo.png", 3) | ||||
|         ] | ||||
|  | ||||
|         for suffix, cover_type in cover_types: | ||||
|         for suffix, cover_type, index in cover_types: | ||||
|             cover_file = os.path.join(grid_dir, f"{appid}{suffix}") | ||||
|             cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}" | ||||
|             downloader.download_async( | ||||
|                 cover_url, | ||||
|                 cover_file, | ||||
|                 timeout=5, | ||||
|                 callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype) | ||||
|                 callback=lambda result, ctype=cover_type, idx=index: on_cover_download(result, ctype, idx) | ||||
|             ) | ||||
|  | ||||
|     get_steam_apps_and_index_async(on_steam_apps) | ||||
| @@ -747,6 +845,11 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu | ||||
|     games: list[tuple] = [] | ||||
|     cache_dir.mkdir(parents=True, exist_ok=True) | ||||
|  | ||||
|     user_json_path = cache_dir / "user.json" | ||||
|     if not user_json_path.exists(): | ||||
|         callback(games) | ||||
|         return | ||||
|  | ||||
|     def process_games(installed_games: list | None): | ||||
|         if installed_games is None: | ||||
|             logger.info("No installed Epic Games Store games found") | ||||
| @@ -855,12 +958,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu | ||||
|                                         app_name, | ||||
|                                         f"legendary:launch:{app_name}", | ||||
|                                         "", | ||||
|                                         last_launch,  # Время последнего запуска | ||||
|                                         formatted_playtime,  # Форматированное время игры | ||||
|                                         protondb_tier,  # ProtonDB tier | ||||
|                                         last_launch, | ||||
|                                         formatted_playtime, | ||||
|                                         protondb_tier, | ||||
|                                         status or "", | ||||
|                                         last_launch_timestamp,  # Временная метка последнего запуска | ||||
|                                         playtime_seconds,  # Время игры в секундах | ||||
|                                         last_launch_timestamp, | ||||
|                                         playtime_seconds, | ||||
|                                         "epic" | ||||
|                                     ) | ||||
|                                     pending_images -= 1 | ||||
| @@ -880,7 +983,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu | ||||
|                     get_protondb_tier_async(steam_appid, on_protondb_tier) | ||||
|                 else: | ||||
|                     logger.debug(f"No Steam app found for EGS game {title}") | ||||
|                     on_protondb_tier("")  # Proceed with empty ProtonDB tier | ||||
|                     on_protondb_tier("") | ||||
|  | ||||
|             get_steam_apps_and_index_async(on_steam_apps) | ||||
|  | ||||
|   | ||||
| @@ -1,38 +1,37 @@ | ||||
| from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices | ||||
| from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl | ||||
| from PySide6.QtGui import QPainter, QColor, QDesktopServices | ||||
| from PySide6.QtCore import Signal, Property, Qt, QUrl | ||||
| from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel | ||||
| from collections.abc import Callable | ||||
| import portprotonqt.themes.standart.styles as default_styles | ||||
| from portprotonqt.image_utils import load_pixmap_async, round_corners | ||||
| from portprotonqt.localization import _ | ||||
| from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter | ||||
| from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
| from portprotonqt.config_utils import read_theme_from_config | ||||
| from portprotonqt.custom_widgets import ClickableLabel | ||||
| from portprotonqt.portproton_api import PortProtonAPI | ||||
| from portprotonqt.downloader import Downloader | ||||
| import weakref | ||||
| from portprotonqt.animations import GameCardAnimations | ||||
| from typing import cast | ||||
|  | ||||
|  | ||||
| class GameCard(QFrame): | ||||
|     borderWidthChanged = Signal() | ||||
|     gradientAngleChanged = Signal() | ||||
|     # Signals for context menu actions | ||||
|     editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path | ||||
|     deleteGameRequested = Signal(str, str)        # name, exec_line | ||||
|     addToMenuRequested = Signal(str, str)         # name, exec_line | ||||
|     removeFromMenuRequested = Signal(str)         # name | ||||
|     addToDesktopRequested = Signal(str, str)      # name, exec_line | ||||
|     removeFromDesktopRequested = Signal(str)      # name | ||||
|     addToSteamRequested = Signal(str, str, str)   # name, exec_line, cover_path | ||||
|     removeFromSteamRequested = Signal(str, str)   # name, exec_line | ||||
|     openGameFolderRequested = Signal(str, str)    # name, exec_line | ||||
|     scaleChanged = Signal() | ||||
|     editShortcutRequested = Signal(str, str, str) | ||||
|     deleteGameRequested = Signal(str, str) | ||||
|     addToMenuRequested = Signal(str, str) | ||||
|     removeFromMenuRequested = Signal(str) | ||||
|     addToDesktopRequested = Signal(str, str) | ||||
|     removeFromDesktopRequested = Signal(str) | ||||
|     addToSteamRequested = Signal(str, str, str) | ||||
|     removeFromSteamRequested = Signal(str, str) | ||||
|     openGameFolderRequested = Signal(str, str) | ||||
|     hoverChanged = Signal(str, bool) | ||||
|     focusChanged = Signal(str, bool) | ||||
|  | ||||
|     def __init__(self, name, description, cover_path, appid, controller_support, exec_line, | ||||
|                 last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source, | ||||
|                 select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None): | ||||
|                  last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source, | ||||
|                  select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None): | ||||
|         super().__init__(parent) | ||||
|         self.name = name | ||||
|         self.description = description | ||||
| @@ -47,14 +46,16 @@ class GameCard(QFrame): | ||||
|         self.game_source = game_source | ||||
|         self.last_launch_ts = last_launch_ts | ||||
|         self.playtime_seconds = playtime_seconds | ||||
|         self.card_width = card_width | ||||
|         self.base_card_width = card_width | ||||
|         self.base_pixmap = None | ||||
|         self.base_font_size = None | ||||
|  | ||||
|         self.select_callback = select_callback | ||||
|         self.context_menu_manager = context_menu_manager | ||||
|         self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) | ||||
|         self.customContextMenuRequested.connect(self._show_context_menu) | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.theme = theme if theme is not None else default_styles | ||||
|         self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) | ||||
|  | ||||
|         self.display_filter = read_display_filter() | ||||
|         self.current_theme_name = read_theme_from_config() | ||||
| @@ -65,80 +66,46 @@ class GameCard(QFrame): | ||||
|         self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) | ||||
|         self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites")) | ||||
|  | ||||
|         # Дополнительное пространство для анимации | ||||
|         extra_margin = 20 | ||||
|         self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin) | ||||
|         self.base_extra_margin = 20 | ||||
|         self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE) | ||||
|  | ||||
|         # Параметры анимации обводки | ||||
|         self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"] | ||||
|         self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"] | ||||
|         self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"] | ||||
|         self._hovered = False | ||||
|         self._focused = False | ||||
|  | ||||
|         # Анимации | ||||
|         self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth")) | ||||
|         self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"]) | ||||
|         self.gradient_anim = None | ||||
|         self.pulse_anim = None | ||||
|         self.animations = GameCardAnimations(self, self.theme) | ||||
|         self.animations.setup_animations() | ||||
|  | ||||
|         # Флаг для отслеживания подключения слота startPulseAnimation | ||||
|         self._isPulseAnimationConnected = False | ||||
|         self.shadow = QGraphicsDropShadowEffect(self) | ||||
|         self.shadow.setBlurRadius(20) | ||||
|         self.shadow.setColor(QColor(0, 0, 0, 150)) | ||||
|         self.shadow.setOffset(0, 0) | ||||
|         self.setGraphicsEffect(self.shadow) | ||||
|  | ||||
|         # Тень | ||||
|         shadow = QGraphicsDropShadowEffect(self) | ||||
|         shadow.setBlurRadius(20) | ||||
|         shadow.setColor(QColor(0, 0, 0, 150)) | ||||
|         shadow.setOffset(0, 0) | ||||
|         self.setGraphicsEffect(shadow) | ||||
|         self.layout_ = QVBoxLayout(self) | ||||
|         self.layout_.setSpacing(5) | ||||
|         self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2) | ||||
|  | ||||
|         # Отступы | ||||
|         layout = QVBoxLayout(self) | ||||
|         layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2) | ||||
|         layout.setSpacing(5) | ||||
|  | ||||
|         # Контейнер обложки | ||||
|         coverWidget = QWidget() | ||||
|         coverWidget.setFixedSize(card_width, int(card_width * 1.2)) | ||||
|         coverLayout = QStackedLayout(coverWidget) | ||||
|         self.coverWidget = QWidget() | ||||
|         coverLayout = QStackedLayout(self.coverWidget) | ||||
|         coverLayout.setContentsMargins(0, 0, 0, 0) | ||||
|         coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll) | ||||
|  | ||||
|         # Обложка | ||||
|         self.coverLabel = QLabel() | ||||
|         self.coverLabel.setFixedSize(card_width, int(card_width * 1.2)) | ||||
|         self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE) | ||||
|         coverLayout.addWidget(self.coverLabel) | ||||
|  | ||||
|         # создаём слабую ссылку на label | ||||
|         label_ref = weakref.ref(self.coverLabel) | ||||
|         load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded) | ||||
|  | ||||
|         def on_cover_loaded(pixmap): | ||||
|             label = label_ref() | ||||
|             if label is None: | ||||
|                 return | ||||
|             label.setPixmap(round_corners(pixmap, 15)) | ||||
|  | ||||
|         # асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async) | ||||
|         load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded) | ||||
|  | ||||
|         # Значок избранного (звёздочка) в левом верхнем углу обложки | ||||
|         self.favoriteLabel = ClickableLabel(coverWidget) | ||||
|         self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize) | ||||
|         self.favoriteLabel.move(8, 8) | ||||
|         self.favoriteLabel = ClickableLabel(self.coverWidget) | ||||
|         self.favoriteLabel.clicked.connect(self.toggle_favorite) | ||||
|         self.is_favorite = self.name in read_favorites() | ||||
|         self.update_favorite_icon() | ||||
|         self.favoriteLabel.raise_() | ||||
|  | ||||
|         # Определяем общие параметры для бейджей | ||||
|         badge_width = int(card_width * 2/3) | ||||
|         icon_size = int(card_width * 0.06)  # 6% от ширины карточки | ||||
|         icon_space = int(card_width * 0.012)  # 1.2% от ширины карточки | ||||
|         font_scale_factor = 0.06  # Шрифт будет 6% от card_width | ||||
|  | ||||
|         # ProtonDB бейдж | ||||
|         tier_text = self.getProtonDBText(protondb_tier) | ||||
|         if tier_text: | ||||
|             icon_filename = self.getProtonDBIconFilename(protondb_tier) | ||||
| @@ -146,67 +113,50 @@ class GameCard(QFrame): | ||||
|             self.protondbLabel = ClickableLabel( | ||||
|                 tier_text, | ||||
|                 icon=icon, | ||||
|                 parent=coverWidget, | ||||
|                 icon_size=icon_size, | ||||
|                 icon_space=icon_space, | ||||
|                 font_scale_factor=font_scale_factor | ||||
|                 parent=self.coverWidget, | ||||
|                 font_scale_factor=0.06 | ||||
|             ) | ||||
|             self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) | ||||
|             self.protondbLabel.setFixedWidth(badge_width) | ||||
|             self.protondbLabel.setCardWidth(card_width) | ||||
|         else: | ||||
|             self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) | ||||
|             self.protondbLabel.setFixedWidth(badge_width) | ||||
|             self.protondbLabel = ClickableLabel("", parent=self.coverWidget) | ||||
|             self.protondbLabel.setVisible(False) | ||||
|  | ||||
|         # Steam бейдж | ||||
|         steam_icon = self.theme_manager.get_icon("steam") | ||||
|         self.steamLabel = ClickableLabel( | ||||
|             "Steam", | ||||
|             icon=steam_icon, | ||||
|             parent=coverWidget, | ||||
|             icon_size=icon_size, | ||||
|             icon_space=icon_space, | ||||
|             font_scale_factor=font_scale_factor | ||||
|             parent=self.coverWidget, | ||||
|             font_scale_factor=0.06 | ||||
|         ) | ||||
|         self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) | ||||
|         self.steamLabel.setFixedWidth(badge_width) | ||||
|         self.steamLabel.setCardWidth(card_width) | ||||
|         self.steamLabel.setVisible(self.steam_visible) | ||||
|  | ||||
|         # Epic Games Store бейдж | ||||
|         egs_icon = self.theme_manager.get_icon("epic_games") | ||||
|         self.egsLabel = ClickableLabel( | ||||
|             "Epic Games", | ||||
|             icon=egs_icon, | ||||
|             parent=coverWidget, | ||||
|             icon_size=icon_size, | ||||
|             icon_space=icon_space, | ||||
|             font_scale_factor=font_scale_factor, | ||||
|             parent=self.coverWidget, | ||||
|             font_scale_factor=0.06, | ||||
|             change_cursor=False | ||||
|         ) | ||||
|         self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) | ||||
|         self.egsLabel.setFixedWidth(badge_width) | ||||
|         self.egsLabel.setCardWidth(card_width) | ||||
|         self.egsLabel.setVisible(self.egs_visible) | ||||
|  | ||||
|         # PortProton бейдж | ||||
|         portproton_icon = self.theme_manager.get_icon("ppqt-tray") | ||||
|         portproton_icon = self.theme_manager.get_icon("portproton") | ||||
|         self.portprotonLabel = ClickableLabel( | ||||
|             "PortProton", | ||||
|             icon=portproton_icon, | ||||
|             parent=coverWidget, | ||||
|             icon_size=icon_size, | ||||
|             icon_space=icon_space, | ||||
|             font_scale_factor=font_scale_factor | ||||
|             parent=self.coverWidget, | ||||
|             font_scale_factor=0.06 | ||||
|         ) | ||||
|         self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) | ||||
|         self.portprotonLabel.setFixedWidth(badge_width) | ||||
|         self.portprotonLabel.setCardWidth(card_width) | ||||
|         self.portprotonLabel.setVisible(self.portproton_visible) | ||||
|         self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic) | ||||
|  | ||||
|         # WeAntiCheatYet бейдж | ||||
|         anticheat_text = self.getAntiCheatText(anticheat_status) | ||||
|         if anticheat_text: | ||||
|             icon_filename = self.getAntiCheatIconFilename(anticheat_status) | ||||
| @@ -214,40 +164,57 @@ class GameCard(QFrame): | ||||
|             self.anticheatLabel = ClickableLabel( | ||||
|                 anticheat_text, | ||||
|                 icon=icon, | ||||
|                 parent=coverWidget, | ||||
|                 icon_size=icon_size, | ||||
|                 icon_space=icon_space, | ||||
|                 font_scale_factor=font_scale_factor | ||||
|                 parent=self.coverWidget, | ||||
|                 font_scale_factor=0.06 | ||||
|             ) | ||||
|             self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status)) | ||||
|             self.anticheatLabel.setFixedWidth(badge_width) | ||||
|             self.anticheatLabel.setCardWidth(card_width) | ||||
|         else: | ||||
|             self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space) | ||||
|             self.anticheatLabel.setFixedWidth(badge_width) | ||||
|             self.anticheatLabel = ClickableLabel("", parent=self.coverWidget) | ||||
|             self.anticheatLabel.setVisible(False) | ||||
|  | ||||
|         # Расположение бейджей | ||||
|         self._position_badges(card_width) | ||||
|         self.protondbLabel.clicked.connect(self.open_protondb_report) | ||||
|         self.steamLabel.clicked.connect(self.open_steam_page) | ||||
|         self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page) | ||||
|  | ||||
|         layout.addWidget(coverWidget) | ||||
|         self.layout_.addWidget(self.coverWidget) | ||||
|  | ||||
|         # Название игры | ||||
|         nameLabel = QLabel(name) | ||||
|         nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) | ||||
|         nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE) | ||||
|         layout.addWidget(nameLabel) | ||||
|         self.nameLabel = QLabel(name) | ||||
|         self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) | ||||
|         self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE) | ||||
|         self.layout_.addWidget(self.nameLabel) | ||||
|  | ||||
|     def _position_badges(self, card_width): | ||||
|         """Позиционирует бейджи на основе ширины карточки.""" | ||||
|         right_margin = 8 | ||||
|         badge_spacing = int(card_width * 0.02)  # 2% от ширины карточки | ||||
|         top_y = 10 | ||||
|         font_size = self.nameLabel.font().pointSizeF() | ||||
|         self.base_font_size = font_size if font_size > 0 else 10.0 | ||||
|  | ||||
|         self.update_scale() | ||||
|  | ||||
|         # Force initial layout update to ensure correct geometry | ||||
|         self.updateGeometry() | ||||
|         parent = self.parentWidget() | ||||
|         if parent: | ||||
|             layout = parent.layout() | ||||
|             if layout: | ||||
|                 layout.invalidate() | ||||
|             parent.updateGeometry() | ||||
|  | ||||
|     def on_cover_loaded(self, pixmap): | ||||
|         self.base_pixmap = pixmap | ||||
|         self.update_cover_pixmap() | ||||
|  | ||||
|     def update_cover_pixmap(self): | ||||
|         if self.base_pixmap: | ||||
|             scaled_width = int(self.base_card_width * self._scale) | ||||
|             scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) | ||||
|             rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale)) | ||||
|             self.coverLabel.setPixmap(rounded_pixmap) | ||||
|  | ||||
|     def _position_badges(self, current_width): | ||||
|         right_margin = int(8 * self._scale) | ||||
|         badge_spacing = int(current_width * 0.02) | ||||
|         top_y = int(10 * self._scale) | ||||
|         badge_y_positions = [] | ||||
|         badge_width = int(card_width * 2/3) | ||||
|         badge_width = int(current_width * 2/3) | ||||
|  | ||||
|         badges = [ | ||||
|             (self.steam_visible, self.steamLabel), | ||||
| @@ -259,80 +226,99 @@ class GameCard(QFrame): | ||||
|  | ||||
|         for is_visible, badge in badges: | ||||
|             if is_visible: | ||||
|                 badge_x = card_width - badge_width - right_margin | ||||
|                 badge_x = current_width - badge_width - right_margin | ||||
|                 badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y | ||||
|                 badge.move(badge_x, badge_y) | ||||
|                 badge.move(int(badge_x), int(badge_y)) | ||||
|                 badge_y_positions.append(badge_y + badge.height()) | ||||
|  | ||||
|         # Поднимаем бейджи в правильном порядке (от нижнего к верхнему) | ||||
|         self.anticheatLabel.raise_() | ||||
|         self.protondbLabel.raise_() | ||||
|         self.portprotonLabel.raise_() | ||||
|         self.egsLabel.raise_() | ||||
|         self.steamLabel.raise_() | ||||
|  | ||||
|     def update_card_size(self, new_width: int): | ||||
|         """Обновляет размер карточки, обложки и бейджей.""" | ||||
|         self.card_width = new_width | ||||
|         extra_margin = 20 | ||||
|         self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin) | ||||
|     def update_scale(self): | ||||
|         scaled_width = int(self.base_card_width * self._scale) | ||||
|         scaled_height = int(self.base_card_width * 1.8 * self._scale) | ||||
|         scaled_extra = int(self.base_extra_margin * self._scale) | ||||
|         self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra) | ||||
|         self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2) | ||||
|  | ||||
|         if self.coverLabel is None: | ||||
|             return | ||||
|         self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5)) | ||||
|         self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5)) | ||||
|  | ||||
|         coverWidget = self.coverLabel.parentWidget() | ||||
|         if coverWidget is None: | ||||
|             return | ||||
|         self.update_cover_pixmap() | ||||
|  | ||||
|         coverWidget.setFixedSize(new_width, int(new_width * 1.2)) | ||||
|         self.coverLabel.setFixedSize(new_width, int(new_width * 1.2)) | ||||
|         favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale)) | ||||
|         self.favoriteLabel.setFixedSize(*favorite_size) | ||||
|         self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale)) | ||||
|  | ||||
|         label_ref = weakref.ref(self.coverLabel) | ||||
|         def on_cover_loaded(pixmap): | ||||
|             label = label_ref() | ||||
|             if label: | ||||
|                 scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) | ||||
|                 rounded_pixmap = round_corners(scaled_pixmap, 15) | ||||
|                 label.setPixmap(rounded_pixmap) | ||||
|  | ||||
|         load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded) | ||||
|  | ||||
|         # Обновляем размеры и шрифты бейджей | ||||
|         badge_width = int(new_width * 2/3) | ||||
|         icon_size = int(new_width * 0.06) | ||||
|         icon_space = int(new_width * 0.012) | ||||
|         badge_width = int(scaled_width * 2/3) | ||||
|         icon_size = int(scaled_width * 0.06) | ||||
|         icon_space = int(scaled_width * 0.012) | ||||
|         for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]: | ||||
|             if label is not None: | ||||
|                 label.setFixedWidth(badge_width) | ||||
|                 label.setIconSize(icon_size, icon_space) | ||||
|                 label.setCardWidth(new_width)  # Пересчитываем размер шрифта | ||||
|                 label.setCardWidth(scaled_width) | ||||
|  | ||||
|         # Перепозиционируем бейджи | ||||
|         self._position_badges(new_width) | ||||
|         self._position_badges(scaled_width) | ||||
|  | ||||
|         if self.base_font_size is not None: | ||||
|             font = self.nameLabel.font() | ||||
|             new_font_size = self.base_font_size * self._scale | ||||
|             if new_font_size > 0: | ||||
|                 font.setPointSizeF(new_font_size) | ||||
|                 self.nameLabel.setFont(font) | ||||
|  | ||||
|         self.shadow.setBlurRadius(int(20 * self._scale)) | ||||
|  | ||||
|         self.updateGeometry() | ||||
|         self.update() | ||||
|  | ||||
|         # Ensure parent layout is updated safely | ||||
|         parent = self.parentWidget() | ||||
|         if parent: | ||||
|             layout = parent.layout() | ||||
|             if layout: | ||||
|                 layout.invalidate() | ||||
|                 layout.activate() | ||||
|                 layout.update() | ||||
|             parent.updateGeometry() | ||||
|  | ||||
|     def update_card_size(self, new_width: int): | ||||
|         self.base_card_width = new_width | ||||
|         load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.5), self.on_cover_loaded) | ||||
|         self.update_scale() | ||||
|  | ||||
|     def update_badge_visibility(self, display_filter: str): | ||||
|         """Обновляет видимость бейджей на основе display_filter.""" | ||||
|         self.display_filter = display_filter | ||||
|         self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites")) | ||||
|         self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites")) | ||||
|         self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites")) | ||||
|         self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites")) | ||||
|         self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) | ||||
|         self.portproton_visible = (str(self.game_source).lower() == "portproton" and self.display_filter in ("all", "favorites")) | ||||
|         protondb_visible = bool(self.getProtonDBText(self.protondb_tier)) | ||||
|         anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) | ||||
|  | ||||
|         # Обновляем видимость бейджей | ||||
|         self.steamLabel.setVisible(self.steam_visible) | ||||
|         self.egsLabel.setVisible(self.egs_visible) | ||||
|         self.portprotonLabel.setVisible(self.portproton_visible) | ||||
|         self.protondbLabel.setVisible(protondb_visible) | ||||
|         self.anticheatLabel.setVisible(anticheat_visible) | ||||
|  | ||||
|         # Перепозиционируем бейджи | ||||
|         self._position_badges(self.card_width) | ||||
|         scaled_width = int(self.base_card_width * self._scale) | ||||
|         self._position_badges(scaled_width) | ||||
|  | ||||
|         # Update layout after visibility changes | ||||
|         self.updateGeometry() | ||||
|         parent = self.parentWidget() | ||||
|         if parent: | ||||
|             layout = parent.layout() | ||||
|             if layout: | ||||
|                 layout.invalidate() | ||||
|                 layout.update() | ||||
|             parent.updateGeometry() | ||||
|  | ||||
|     def _show_context_menu(self, pos): | ||||
|         """Delegate context menu display to ContextMenuManager.""" | ||||
|         if self.context_menu_manager: | ||||
|             self.context_menu_manager.show_context_menu(self, pos) | ||||
|  | ||||
| @@ -390,7 +376,6 @@ class GameCard(QFrame): | ||||
|         return "" | ||||
|  | ||||
|     def open_portproton_forum_topic(self): | ||||
|         """Open the PortProton forum topic or search page for this game.""" | ||||
|         result = self.portproton_api.get_forum_topic_slug(self.name) | ||||
|         base_url = "https://linux-gaming.ru/" | ||||
|         if result.startswith("search?q="): | ||||
| @@ -450,138 +435,38 @@ class GameCard(QFrame): | ||||
|             self.gradientAngleChanged.emit() | ||||
|             self.update() | ||||
|  | ||||
|     def getScale(self) -> float: | ||||
|         return self._scale | ||||
|  | ||||
|     def setScale(self, value: float): | ||||
|         if self._scale != value: | ||||
|             self._scale = value | ||||
|             self.update_scale() | ||||
|             self.scaleChanged.emit() | ||||
|  | ||||
|     borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged)) | ||||
|     gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged)) | ||||
|     scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged)) | ||||
|  | ||||
|  | ||||
|     def paintEvent(self, event): | ||||
|         super().paintEvent(event) | ||||
|         painter = QPainter(self) | ||||
|         painter.setRenderHint(QPainter.RenderHint.Antialiasing) | ||||
|  | ||||
|         pen = QPen() | ||||
|         pen.setWidth(self._borderWidth) | ||||
|         if self._hovered or self._focused: | ||||
|             center = self.rect().center() | ||||
|             gradient = QConicalGradient(center, self._gradientAngle) | ||||
|             for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]: | ||||
|                 gradient.setColorAt(stop["position"], QColor(stop["color"])) | ||||
|             pen.setBrush(QBrush(gradient)) | ||||
|         else: | ||||
|             pen.setColor(QColor(0, 0, 0, 0)) | ||||
|  | ||||
|         painter.setPen(pen) | ||||
|         radius = 18 | ||||
|         bw = round(self._borderWidth / 2) | ||||
|         rect = self.rect().adjusted(bw, bw, -bw, -bw) | ||||
|         painter.drawRoundedRect(rect, radius, radius) | ||||
|  | ||||
|     def startPulseAnimation(self): | ||||
|         if not (self._hovered or self._focused): | ||||
|             return | ||||
|         if self.pulse_anim: | ||||
|             self.pulse_anim.stop() | ||||
|         self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth")) | ||||
|         self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"]) | ||||
|         self.pulse_anim.setLoopCount(0) | ||||
|         self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"]) | ||||
|         self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"]) | ||||
|         self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"]) | ||||
|         self.pulse_anim.start() | ||||
|         self.animations.paint_border(QPainter(self)) | ||||
|  | ||||
|     def enterEvent(self, event): | ||||
|         self._hovered = True | ||||
|         self.hoverChanged.emit(self.name, True) | ||||
|         self.setFocus(Qt.FocusReason.MouseFocusReason) | ||||
|  | ||||
|         self.thickness_anim.stop() | ||||
|         if self._isPulseAnimationConnected: | ||||
|             self.thickness_anim.finished.disconnect(self.startPulseAnimation) | ||||
|             self._isPulseAnimationConnected = False | ||||
|         self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]])) | ||||
|         self.thickness_anim.setStartValue(self._borderWidth) | ||||
|         self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"]) | ||||
|         self.thickness_anim.finished.connect(self.startPulseAnimation) | ||||
|         self._isPulseAnimationConnected = True | ||||
|         self.thickness_anim.start() | ||||
|  | ||||
|         if self.gradient_anim: | ||||
|             self.gradient_anim.stop() | ||||
|         self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle")) | ||||
|         self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) | ||||
|         self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) | ||||
|         self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) | ||||
|         self.gradient_anim.setLoopCount(-1) | ||||
|         self.gradient_anim.start() | ||||
|  | ||||
|         self.animations.handle_enter_event() | ||||
|         super().enterEvent(event) | ||||
|  | ||||
|     def leaveEvent(self, event): | ||||
|         self._hovered = False | ||||
|         self.hoverChanged.emit(self.name, False) | ||||
|         if not self._focused: | ||||
|             if self.gradient_anim: | ||||
|                 self.gradient_anim.stop() | ||||
|                 self.gradient_anim = None | ||||
|             if self.pulse_anim: | ||||
|                 self.pulse_anim.stop() | ||||
|                 self.pulse_anim = None | ||||
|             if self.thickness_anim: | ||||
|                 self.thickness_anim.stop() | ||||
|             if self._isPulseAnimationConnected: | ||||
|                 self.thickness_anim.finished.disconnect(self.startPulseAnimation) | ||||
|                 self._isPulseAnimationConnected = False | ||||
|             self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]])) | ||||
|             self.thickness_anim.setStartValue(self._borderWidth) | ||||
|             self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"]) | ||||
|             self.thickness_anim.start() | ||||
|         self.animations.handle_leave_event() | ||||
|         super().leaveEvent(event) | ||||
|  | ||||
|     def focusInEvent(self, event): | ||||
|         if not self._hovered: | ||||
|             self._focused = True | ||||
|             self.focusChanged.emit(self.name, True) | ||||
|  | ||||
|             self.thickness_anim.stop() | ||||
|             if self._isPulseAnimationConnected: | ||||
|                 self.thickness_anim.finished.disconnect(self.startPulseAnimation) | ||||
|                 self._isPulseAnimationConnected = False | ||||
|             self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]])) | ||||
|             self.thickness_anim.setStartValue(self._borderWidth) | ||||
|             self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"]) | ||||
|             self.thickness_anim.finished.connect(self.startPulseAnimation) | ||||
|             self._isPulseAnimationConnected = True | ||||
|             self.thickness_anim.start() | ||||
|  | ||||
|             if self.gradient_anim: | ||||
|                 self.gradient_anim.stop() | ||||
|             self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle")) | ||||
|             self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) | ||||
|             self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]) | ||||
|             self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) | ||||
|             self.gradient_anim.setLoopCount(-1) | ||||
|             self.gradient_anim.start() | ||||
|  | ||||
|         self.animations.handle_focus_in_event() | ||||
|         super().focusInEvent(event) | ||||
|  | ||||
|     def focusOutEvent(self, event): | ||||
|         self._focused = False | ||||
|         self.focusChanged.emit(self.name, False) | ||||
|         if not self._hovered: | ||||
|             if self.gradient_anim: | ||||
|                 self.gradient_anim.stop() | ||||
|                 self.gradient_anim = None | ||||
|             if self.pulse_anim: | ||||
|                 self.pulse_anim.stop() | ||||
|                 self.pulse_anim = None | ||||
|             if self.thickness_anim: | ||||
|                 self.thickness_anim.stop() | ||||
|             if self._isPulseAnimationConnected: | ||||
|                 self.thickness_anim.finished.disconnect(self.startPulseAnimation) | ||||
|                 self._isPulseAnimationConnected = False | ||||
|             self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]])) | ||||
|             self.thickness_anim.setStartValue(self._borderWidth) | ||||
|             self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"]) | ||||
|             self.thickness_anim.start() | ||||
|         self.animations.handle_focus_out_event() | ||||
|         super().focusOutEvent(event) | ||||
|  | ||||
|     def mousePressEvent(self, event): | ||||
| @@ -601,6 +486,7 @@ class GameCard(QFrame): | ||||
|             ) | ||||
|         super().mousePressEvent(event) | ||||
|  | ||||
|  | ||||
|     def keyPressEvent(self, event): | ||||
|         if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): | ||||
|             self.select_callback( | ||||
|   | ||||
							
								
								
									
										467
									
								
								portprotonqt/game_library_manager.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,467 @@ | ||||
| from typing import Protocol | ||||
| from portprotonqt.game_card import GameCard | ||||
| from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller | ||||
| from PySide6.QtCore import Qt, QTimer | ||||
| from portprotonqt.custom_widgets import FlowLayout | ||||
| from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size | ||||
| from portprotonqt.image_utils import load_pixmap_async | ||||
| from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit | ||||
| from collections import deque | ||||
|  | ||||
| class MainWindowProtocol(Protocol): | ||||
|     """Protocol defining the interface that MainWindow must implement for GameLibraryManager.""" | ||||
|  | ||||
|     def openGameDetailPage( | ||||
|         self, | ||||
|         name: str, | ||||
|         description: str, | ||||
|         cover_path: str | None = None, | ||||
|         appid: str = "", | ||||
|         exec_line: str = "", | ||||
|         controller_support: str = "", | ||||
|         last_launch: str = "", | ||||
|         formatted_playtime: str = "", | ||||
|         protondb_tier: str = "", | ||||
|         game_source: str = "", | ||||
|         anticheat_status: str = "", | ||||
|     ) -> None: ... | ||||
|  | ||||
|     def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ... | ||||
|  | ||||
|     def on_slider_released(self) -> None: ... | ||||
|  | ||||
|     # Required attributes | ||||
|     searchEdit: CustomLineEdit | ||||
|     _last_card_width: int | ||||
|     current_hovered_card: GameCard | None | ||||
|     current_focused_card: GameCard | None | ||||
|     gamesListWidget: QWidget | None | ||||
|  | ||||
| class GameLibraryManager: | ||||
|     def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None): | ||||
|         self.main_window = main_window | ||||
|         self.theme = theme | ||||
|         self.context_menu_manager: ContextMenuManager | None = context_menu_manager | ||||
|         self.games: list[tuple] = [] | ||||
|         self.filtered_games: list[tuple] = [] | ||||
|         self.game_card_cache = {} | ||||
|         self.pending_images = {} | ||||
|         self.card_width = read_card_size() | ||||
|         self.gamesListWidget: QWidget | None = None | ||||
|         self.gamesListLayout: FlowLayout | None = None | ||||
|         self.sizeSlider: QSlider | None = None | ||||
|         self._update_timer: QTimer | None = None | ||||
|         self._pending_update = False | ||||
|         self.pending_deletions = deque() | ||||
|         self.is_filtering = False | ||||
|         self.dirty = False | ||||
|  | ||||
|     def force_update_cards_library(self): | ||||
|         if self.gamesListWidget and self.gamesListLayout: | ||||
|             self.gamesListLayout.invalidate() | ||||
|             self.gamesListWidget.updateGeometry() | ||||
|             widget = self.gamesListWidget | ||||
|             QTimer.singleShot(0, lambda: ( | ||||
|                 widget.adjustSize(), | ||||
|                 widget.updateGeometry() | ||||
|             )) | ||||
|  | ||||
|     def create_games_library_widget(self): | ||||
|         """Creates the games library widget with search, grid, and slider.""" | ||||
|         self.gamesLibraryWidget = QWidget() | ||||
|         self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) | ||||
|         layout = QVBoxLayout(self.gamesLibraryWidget) | ||||
|         layout.setSpacing(15) | ||||
|  | ||||
|         # Search widget | ||||
|         searchWidget, self.searchEdit = self.main_window.createSearchWidget() | ||||
|         layout.addWidget(searchWidget) | ||||
|  | ||||
|         # Scroll area for game grid | ||||
|         scrollArea = QScrollArea() | ||||
|         scrollArea.setWidgetResizable(True) | ||||
|         scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE) | ||||
|         QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture) | ||||
|  | ||||
|         self.gamesListWidget = QWidget() | ||||
|         self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE) | ||||
|         self.gamesListLayout = FlowLayout(self.gamesListWidget) | ||||
|         self.gamesListWidget.setLayout(self.gamesListLayout) | ||||
|  | ||||
|         scrollArea.setWidget(self.gamesListWidget) | ||||
|         layout.addWidget(scrollArea) | ||||
|  | ||||
|         # Slider for card size | ||||
|         sliderLayout = QHBoxLayout() | ||||
|         sliderLayout.addStretch() | ||||
|  | ||||
|         self.sizeSlider = QSlider(Qt.Orientation.Horizontal) | ||||
|         self.sizeSlider.setMinimum(200) | ||||
|         self.sizeSlider.setMaximum(250) | ||||
|         self.sizeSlider.setValue(self.card_width) | ||||
|         self.sizeSlider.setTickInterval(10) | ||||
|         self.sizeSlider.setFixedWidth(150) | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE) | ||||
|         self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released) | ||||
|         sliderLayout.addWidget(self.sizeSlider) | ||||
|  | ||||
|         layout.addLayout(sliderLayout) | ||||
|  | ||||
|         # Initialize update timer | ||||
|         self._update_timer = QTimer() | ||||
|         self._update_timer.setSingleShot(True) | ||||
|         self._update_timer.setInterval(100)  # 100ms debounce | ||||
|         self._update_timer.timeout.connect(self._perform_update) | ||||
|  | ||||
|         # Calculate initial card width | ||||
|         def calculate_card_width(): | ||||
|             if self.gamesListLayout is None: | ||||
|                 return | ||||
|             available_width = scrollArea.width() - 20 | ||||
|             spacing = self.gamesListLayout._spacing | ||||
|             target_cards_per_row = 8 | ||||
|             calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row | ||||
|             calculated_width = max(200, min(calculated_width, 250)) | ||||
|  | ||||
|         QTimer.singleShot(0, calculate_card_width) | ||||
|  | ||||
|         # Connect scroll event for lazy loading | ||||
|         scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images) | ||||
|  | ||||
|         return self.gamesLibraryWidget | ||||
|  | ||||
|     def on_slider_released(self): | ||||
|         """Handles slider release to update card size.""" | ||||
|         if self.sizeSlider is None: | ||||
|             return | ||||
|         self.card_width = self.sizeSlider.value() | ||||
|         self.sizeSlider.setToolTip(f"{self.card_width} px") | ||||
|         save_card_size(self.card_width) | ||||
|         for card in self.game_card_cache.values(): | ||||
|             card.update_card_size(self.card_width) | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def load_visible_images(self): | ||||
|         """Loads images for visible game cards.""" | ||||
|         if self.gamesListWidget is None: | ||||
|             return | ||||
|         visible_region = self.gamesListWidget.visibleRegion() | ||||
|         max_concurrent_loads = 5 | ||||
|         loaded_count = 0 | ||||
|         for card_key, card in self.game_card_cache.items(): | ||||
|             if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads: | ||||
|                 cover_path, width, height, callback = self.pending_images.pop(card_key) | ||||
|                 load_pixmap_async(cover_path, width, height, callback) | ||||
|                 loaded_count += 1 | ||||
|  | ||||
|     def _on_card_focused(self, game_name: str, is_focused: bool): | ||||
|         """Handles card focus events.""" | ||||
|         card_key = None | ||||
|         for key, card in self.game_card_cache.items(): | ||||
|             if card.name == game_name: | ||||
|                 card_key = key | ||||
|                 break | ||||
|  | ||||
|         if not card_key: | ||||
|             return | ||||
|  | ||||
|         card = self.game_card_cache[card_key] | ||||
|  | ||||
|         if is_focused: | ||||
|             if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: | ||||
|                 self.main_window.current_hovered_card._hovered = False | ||||
|                 self.main_window.current_hovered_card.leaveEvent(None) | ||||
|                 self.main_window.current_hovered_card = None | ||||
|             if self.main_window.current_focused_card and self.main_window.current_focused_card != card: | ||||
|                 self.main_window.current_focused_card._focused = False | ||||
|                 self.main_window.current_focused_card.clearFocus() | ||||
|             self.main_window.current_focused_card = card | ||||
|         else: | ||||
|             if self.main_window.current_focused_card == card: | ||||
|                 self.main_window.current_focused_card = None | ||||
|  | ||||
|     def _on_card_hovered(self, game_name: str, is_hovered: bool): | ||||
|         """Handles card hover events.""" | ||||
|         card_key = None | ||||
|         for key, card in self.game_card_cache.items(): | ||||
|             if card.name == game_name: | ||||
|                 card_key = key | ||||
|                 break | ||||
|  | ||||
|         if not card_key: | ||||
|             return | ||||
|  | ||||
|         card = self.game_card_cache[card_key] | ||||
|  | ||||
|         if is_hovered: | ||||
|             if self.main_window.current_focused_card and self.main_window.current_focused_card != card: | ||||
|                 self.main_window.current_focused_card._focused = False | ||||
|                 self.main_window.current_focused_card.clearFocus() | ||||
|             if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card: | ||||
|                 self.main_window.current_hovered_card._hovered = False | ||||
|                 self.main_window.current_hovered_card.leaveEvent(None) | ||||
|             self.main_window.current_hovered_card = card | ||||
|         else: | ||||
|             if self.main_window.current_hovered_card == card: | ||||
|                 self.main_window.current_hovered_card = None | ||||
|  | ||||
|     def _perform_update(self): | ||||
|         """Performs the actual grid update.""" | ||||
|         if not self._pending_update: | ||||
|             return | ||||
|         self._pending_update = False | ||||
|         self._update_game_grid_immediate() | ||||
|  | ||||
|     def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False): | ||||
|         """Schedules a game grid update with debouncing.""" | ||||
|         if not is_filter: | ||||
|             if games_list is not None: | ||||
|                 self.filtered_games = games_list | ||||
|             self.dirty = True  # Full rebuild only for non-filter | ||||
|         self.is_filtering = is_filter | ||||
|         self._pending_update = True | ||||
|  | ||||
|         if self._update_timer is not None: | ||||
|             self._update_timer.start() | ||||
|         else: | ||||
|             self._update_game_grid_immediate() | ||||
|  | ||||
|     def _update_game_grid_immediate(self): | ||||
|         """Updates the game grid with the provided or current game list.""" | ||||
|         if self.gamesListLayout is None or self.gamesListWidget is None: | ||||
|             return | ||||
|  | ||||
|         search_text = self.main_window.searchEdit.text().strip().lower() | ||||
|  | ||||
|         if self.is_filtering: | ||||
|             # Filter mode: do not change layout, only hide/show cards | ||||
|             self._apply_filter_visibility(search_text) | ||||
|         else: | ||||
|             # Full update: sorting, removal/addition, reorganization | ||||
|             games_list = self.filtered_games if self.filtered_games else self.games | ||||
|             favorites = read_favorites() | ||||
|             sort_method = read_sort_method() | ||||
|  | ||||
|             # Batch layout updates (extended scope) | ||||
|             self.gamesListWidget.setUpdatesEnabled(False) | ||||
|             if self.gamesListLayout is not None: | ||||
|                 self.gamesListLayout.setEnabled(False)  # Disable layout during batch | ||||
|  | ||||
|             try: | ||||
|                 # Optimized sorting: Partition favorites first, then sort subgroups | ||||
|                 def partition_sort_key(game): | ||||
|                     name = game[0] | ||||
|                     is_fav = name in favorites | ||||
|                     fav_order = 0 if is_fav else 1 | ||||
|                     if sort_method == "playtime": | ||||
|                         return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0) | ||||
|                     elif sort_method == "alphabetical": | ||||
|                         return (fav_order, name.lower()) | ||||
|                     elif sort_method == "favorites": | ||||
|                         return (fav_order,) | ||||
|                     else: | ||||
|                         return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0) | ||||
|  | ||||
|                 # Quick partition: Sort favorites and non-favorites separately, then merge | ||||
|                 fav_games = [g for g in games_list if g[0] in favorites] | ||||
|                 non_fav_games = [g for g in games_list if g[0] not in favorites] | ||||
|                 sorted_fav = sorted(fav_games, key=partition_sort_key) | ||||
|                 sorted_non_fav = sorted(non_fav_games, key=partition_sort_key) | ||||
|                 sorted_games = sorted_fav + sorted_non_fav | ||||
|  | ||||
|                 # Build set of current game keys for faster lookup | ||||
|                 current_game_keys = {(game[0], game[4]) for game in sorted_games} | ||||
|  | ||||
|                 # Remove cards that no longer exist (batch) | ||||
|                 cards_to_remove = [] | ||||
|                 for card_key in list(self.game_card_cache.keys()): | ||||
|                     if card_key not in current_game_keys: | ||||
|                         cards_to_remove.append(card_key) | ||||
|  | ||||
|                 for card_key in cards_to_remove: | ||||
|                     card = self.game_card_cache.pop(card_key) | ||||
|                     if self.gamesListLayout is not None: | ||||
|                         self.gamesListLayout.removeWidget(card) | ||||
|                     self.pending_deletions.append(card)  # Defer | ||||
|                     if card_key in self.pending_images: | ||||
|                         del self.pending_images[card_key] | ||||
|  | ||||
|                 # Track current layout order (only if dirty/full update needed) | ||||
|                 if self.dirty and self.gamesListLayout is not None: | ||||
|                     current_layout_order = [] | ||||
|                     for i in range(self.gamesListLayout.count()): | ||||
|                         item = self.gamesListLayout.itemAt(i) | ||||
|                         if item is not None: | ||||
|                             widget = item.widget() | ||||
|                             if widget: | ||||
|                                 for key, card in self.game_card_cache.items(): | ||||
|                                     if card == widget: | ||||
|                                         current_layout_order.append(key) | ||||
|                                         break | ||||
|                 else: | ||||
|                     current_layout_order = None  # Skip reorg if not dirty | ||||
|  | ||||
|                 new_card_order = [] | ||||
|                 cards_to_add = [] | ||||
|  | ||||
|                 for game_data in sorted_games: | ||||
|                     game_name = game_data[0] | ||||
|                     exec_line = game_data[4] | ||||
|                     game_key = (game_name, exec_line) | ||||
|                     should_be_visible = not search_text or search_text in game_name.lower() | ||||
|  | ||||
|                     if game_key in self.game_card_cache: | ||||
|                         card = self.game_card_cache[game_key] | ||||
|                         if card.isVisible() != should_be_visible: | ||||
|                             card.setVisible(should_be_visible) | ||||
|                         new_card_order.append(game_key) | ||||
|                     else: | ||||
|                         if self.context_menu_manager is None: | ||||
|                             continue | ||||
|  | ||||
|                         card = self._create_game_card(game_data) | ||||
|                         self.game_card_cache[game_key] = card | ||||
|                         card.setVisible(should_be_visible) | ||||
|                         new_card_order.append(game_key) | ||||
|                         cards_to_add.append((game_key, card)) | ||||
|  | ||||
|                 # Only reorganize if order changed AND dirty | ||||
|                 if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order): | ||||
|                     # Remove all widgets from layout (batch) | ||||
|                     while self.gamesListLayout.count(): | ||||
|                         self.gamesListLayout.takeAt(0) | ||||
|  | ||||
|                     # Add widgets in new order (batch) | ||||
|                     for game_key in new_card_order: | ||||
|                         card = self.game_card_cache[game_key] | ||||
|                         self.gamesListLayout.addWidget(card) | ||||
|  | ||||
|                 self.dirty = False  # Reset flag | ||||
|  | ||||
|                 # Deferred deletions (run in timer to avoid stack overflow) | ||||
|                 if self.pending_deletions: | ||||
|                     QTimer.singleShot(0, lambda: self._flush_deletions()) | ||||
|  | ||||
|                 # Load visible images for new cards only | ||||
|                 if cards_to_add: | ||||
|                     self.load_visible_images() | ||||
|  | ||||
|             finally: | ||||
|                 if self.gamesListLayout is not None: | ||||
|                     self.gamesListLayout.setEnabled(True) | ||||
|                 self.gamesListWidget.setUpdatesEnabled(True) | ||||
|                 if self.gamesListLayout is not None: | ||||
|                     self.gamesListLayout.update() | ||||
|                 self.gamesListWidget.updateGeometry() | ||||
|                 self.main_window._last_card_width = self.card_width | ||||
|  | ||||
|                 self.force_update_cards_library() | ||||
|  | ||||
|         self.is_filtering = False  # Reset flag in any case | ||||
|  | ||||
|     def _apply_filter_visibility(self, search_text: str): | ||||
|         """Applies visibility to cards based on search, without changing the layout.""" | ||||
|         visible_count = 0 | ||||
|         for game_key, card in self.game_card_cache.items(): | ||||
|             game_name = card.name  # Assume GameCard has 'name' attribute | ||||
|             should_be_visible = not search_text or search_text in game_name.lower() | ||||
|             if card.isVisible() != should_be_visible: | ||||
|                 card.setVisible(should_be_visible) | ||||
|             if should_be_visible: | ||||
|                 visible_count += 1 | ||||
|                 # Load image only for newly visible cards | ||||
|                 if game_key in self.pending_images: | ||||
|                     cover_path, width, height, callback = self.pending_images.pop(game_key) | ||||
|                     load_pixmap_async(cover_path, width, height, callback) | ||||
|  | ||||
|         # Force full relayout after visibility changes | ||||
|         if self.gamesListLayout is not None: | ||||
|             self.gamesListLayout.invalidate()  # Принудительно инвалидируем для пересчёта | ||||
|             self.gamesListLayout.update() | ||||
|         if self.gamesListWidget is not None: | ||||
|             self.gamesListWidget.updateGeometry() | ||||
|         self.main_window._last_card_width = self.card_width | ||||
|  | ||||
|         # If search is empty, load images for visible ones | ||||
|         if not search_text: | ||||
|             self.load_visible_images() | ||||
|  | ||||
|     def _create_game_card(self, game_data: tuple) -> GameCard: | ||||
|         """Creates a new game card with all necessary connections.""" | ||||
|         card = GameCard( | ||||
|             *game_data, | ||||
|             select_callback=self.main_window.openGameDetailPage, | ||||
|             theme=self.theme, | ||||
|             card_width=self.card_width, | ||||
|             context_menu_manager=self.context_menu_manager | ||||
|         ) | ||||
|  | ||||
|         card.hoverChanged.connect(self._on_card_hovered) | ||||
|         card.focusChanged.connect(self._on_card_focused) | ||||
|  | ||||
|         if self.context_menu_manager: | ||||
|             card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut) | ||||
|             card.deleteGameRequested.connect(self.context_menu_manager.delete_game) | ||||
|             card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu) | ||||
|             card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu) | ||||
|             card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop) | ||||
|             card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop) | ||||
|             card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam) | ||||
|             card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam) | ||||
|             card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder) | ||||
|  | ||||
|         return card | ||||
|  | ||||
|     def _flush_deletions(self): | ||||
|         """Delete pending widgets off the main update cycle.""" | ||||
|         for card in list(self.pending_deletions): | ||||
|             card.deleteLater() | ||||
|             self.pending_deletions.remove(card) | ||||
|  | ||||
|     def clear_layout(self, layout): | ||||
|         """Clears all widgets from the layout.""" | ||||
|         if layout is None: | ||||
|             return | ||||
|         while layout.count(): | ||||
|             child = layout.takeAt(0) | ||||
|             if child.widget(): | ||||
|                 widget = child.widget() | ||||
|                 for key, card in list(self.game_card_cache.items()): | ||||
|                     if card == widget: | ||||
|                         del self.game_card_cache[key] | ||||
|                         if key in self.pending_images: | ||||
|                             del self.pending_images[key] | ||||
|                 widget.deleteLater() | ||||
|  | ||||
|     def set_games(self, games: list[tuple]): | ||||
|         """Sets the games list and updates the filtered games.""" | ||||
|         self.games = games | ||||
|         self.filtered_games = self.games | ||||
|         self.dirty = True  # Full resort needed | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def add_game_incremental(self, game_data: tuple): | ||||
|         """Add a single game without full reload.""" | ||||
|         self.games.append(game_data) | ||||
|         self.filtered_games.append(game_data)  # Assume no filter active; adjust if needed | ||||
|         self.dirty = True | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def remove_game_incremental(self, game_name: str, exec_line: str): | ||||
|         """Remove a single game without full reload.""" | ||||
|         key = (game_name, exec_line) | ||||
|         self.games = [g for g in self.games if (g[0], g[4]) != key] | ||||
|         self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key] | ||||
|         if key in self.game_card_cache and self.gamesListLayout is not None: | ||||
|             card = self.game_card_cache.pop(key) | ||||
|             self.gamesListLayout.removeWidget(card) | ||||
|             self.pending_deletions.append(card)  # Defer deleteLater | ||||
|             if key in self.pending_images: | ||||
|                 del self.pending_images[key] | ||||
|         self.dirty = True | ||||
|         self.update_game_grid() | ||||
|  | ||||
|     def filter_games_delayed(self): | ||||
|         """Filters games based on search text and updates the grid.""" | ||||
|         self.update_game_grid(is_filter=True) | ||||
| @@ -219,9 +219,11 @@ class ResultParser: | ||||
|             ("comp_plus", "main_extra"), | ||||
|             ("comp_100", "completionist") | ||||
|         ] | ||||
|         all_zero = all(game_data.get(json_field, 0) == 0 for json_field, _ in time_fields) | ||||
|         for json_field, attr_name in time_fields: | ||||
|             if json_field in game_data: | ||||
|                 time_hours = round(game_data[json_field] / 3600, 2) | ||||
|                 time_seconds = game_data[json_field] | ||||
|                 time_hours = None if all_zero else round(time_seconds / 3600, 2) | ||||
|                 setattr(game, attr_name, time_hours) | ||||
|         game.similarity = self._calculate_similarity(game) | ||||
|         return game | ||||
|   | ||||
| @@ -3,7 +3,6 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath | ||||
| from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation | ||||
| from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy | ||||
| from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication | ||||
| import portprotonqt.themes.standart.styles as default_styles | ||||
| from portprotonqt.config_utils import read_theme_from_config | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
| from portprotonqt.downloader import Downloader | ||||
| @@ -21,6 +20,13 @@ image_load_queue = Queue() | ||||
| image_executor = ThreadPoolExecutor(max_workers=4) | ||||
| queue_lock = threading.Lock() | ||||
|  | ||||
| def get_device_pixel_ratio() -> float: | ||||
|     """ | ||||
|     Retrieves the device pixel ratio from QApplication, with a fallback of 1.0 if not available. | ||||
|     """ | ||||
|     app = QApplication.instance() | ||||
|     return app.devicePixelRatio() if isinstance(app, QApplication) else 1.0 | ||||
|  | ||||
| def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""): | ||||
|     """ | ||||
|     Асинхронно загружает обложку через очередь задач. | ||||
| @@ -164,23 +170,21 @@ class FullscreenDialog(QDialog): | ||||
|         :param theme: Объект темы для стилизации (если None, используется default_styles) | ||||
|         """ | ||||
|         super().__init__(parent) | ||||
|         # Удаление диалога после закрытия | ||||
|         self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose) | ||||
|         self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) | ||||
|         self.setFocus() | ||||
|  | ||||
|         self.images = images | ||||
|         self.current_index = current_index | ||||
|         self.theme = theme if theme else default_styles | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) | ||||
|  | ||||
|         # Убираем стандартные элементы управления окна | ||||
|         self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog) | ||||
|         self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) | ||||
|  | ||||
|         self.init_ui() | ||||
|         self.update_display() | ||||
|  | ||||
|         # Фильтруем события для закрытия диалога по клику | ||||
|         self.imageLabel.installEventFilter(self) | ||||
|         self.captionLabel.installEventFilter(self) | ||||
|  | ||||
| @@ -190,32 +194,28 @@ class FullscreenDialog(QDialog): | ||||
|         self.mainLayout.setContentsMargins(0, 0, 0, 0) | ||||
|         self.mainLayout.setSpacing(0) | ||||
|  | ||||
|         # Контейнер для изображения и стрелок | ||||
|         self.imageContainer = QWidget() | ||||
|         self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT) | ||||
|         self.imageContainerLayout = QHBoxLayout(self.imageContainer) | ||||
|         self.imageContainerLayout.setContentsMargins(0, 0, 0, 0) | ||||
|         self.imageContainerLayout.setSpacing(0) | ||||
|  | ||||
|         # Левая стрелка | ||||
|         self.prevButton = QToolButton() | ||||
|         self.prevButton.setArrowType(Qt.ArrowType.LeftArrow) | ||||
|         self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE) | ||||
|         self.prevButton.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", "")) | ||||
|         self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor) | ||||
|         self.prevButton.setFixedSize(40, 40) | ||||
|         self.prevButton.clicked.connect(self.show_prev) | ||||
|         self.imageContainerLayout.addWidget(self.prevButton) | ||||
|  | ||||
|         # Метка для изображения | ||||
|         self.imageLabel = QLabel() | ||||
|         self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT) | ||||
|         self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) | ||||
|         self.imageContainerLayout.addWidget(self.imageLabel, stretch=1) | ||||
|  | ||||
|         # Правая стрелка | ||||
|         self.nextButton = QToolButton() | ||||
|         self.nextButton.setArrowType(Qt.ArrowType.RightArrow) | ||||
|         self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) | ||||
|         self.nextButton.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", "")) | ||||
|         self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor) | ||||
|         self.nextButton.setFixedSize(40, 40) | ||||
|         self.nextButton.clicked.connect(self.show_next) | ||||
| @@ -223,16 +223,14 @@ class FullscreenDialog(QDialog): | ||||
|  | ||||
|         self.mainLayout.addWidget(self.imageContainer) | ||||
|  | ||||
|         # Небольшой отступ между изображением и подписью | ||||
|         spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) | ||||
|         self.mainLayout.addItem(spacer) | ||||
|  | ||||
|         # Подпись | ||||
|         self.captionLabel = QLabel() | ||||
|         self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) | ||||
|         self.captionLabel.setFixedHeight(40) | ||||
|         self.captionLabel.setWordWrap(True) | ||||
|         self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE) | ||||
|         self.captionLabel.setStyleSheet(getattr(self.theme, "CAPTION_LABEL_STYLE", "")) | ||||
|         self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor) | ||||
|         self.mainLayout.addWidget(self.captionLabel) | ||||
|  | ||||
| @@ -241,28 +239,37 @@ class FullscreenDialog(QDialog): | ||||
|         if not self.images: | ||||
|             return | ||||
|  | ||||
|         # Очищаем старое содержимое | ||||
|         self.imageLabel.clear() | ||||
|         self.captionLabel.clear() | ||||
|         QApplication.processEvents() | ||||
|  | ||||
|         pixmap, caption = self.images[self.current_index] | ||||
|         # Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера | ||||
|         # Учитываем devicePixelRatio для масштабирования высокого качества | ||||
|         device_pixel_ratio = get_device_pixel_ratio() | ||||
|         target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio) | ||||
|         target_height = int(self.FIXED_HEIGHT * device_pixel_ratio) | ||||
|  | ||||
|         # Масштабируем изображение из оригинального pixmap | ||||
|         scaled_pixmap = pixmap.scaled( | ||||
|             self.FIXED_WIDTH - 80,  # учитываем ширину стрелок | ||||
|             self.FIXED_HEIGHT, | ||||
|             target_width, | ||||
|             target_height, | ||||
|             Qt.AspectRatioMode.KeepAspectRatio, | ||||
|             Qt.TransformationMode.SmoothTransformation | ||||
|         ) | ||||
|         scaled_pixmap.setDevicePixelRatio(device_pixel_ratio) | ||||
|         self.imageLabel.setPixmap(scaled_pixmap) | ||||
|         self.captionLabel.setText(caption) | ||||
|         self.setWindowTitle(caption) | ||||
|  | ||||
|         # Принудительная перерисовка виджетов | ||||
|         self.imageLabel.repaint() | ||||
|         self.captionLabel.repaint() | ||||
|         self.repaint() | ||||
|  | ||||
|     def resizeEvent(self, event): | ||||
|         """Обновляет изображение при изменении размера окна.""" | ||||
|         super().resizeEvent(event) | ||||
|         self.update_display()  # Перерисовываем изображение с учетом нового размера | ||||
|  | ||||
|     def show_prev(self): | ||||
|         """Показывает предыдущее изображение.""" | ||||
|         if self.images: | ||||
| @@ -292,7 +299,6 @@ class FullscreenDialog(QDialog): | ||||
|     def mousePressEvent(self, event): | ||||
|         """Закрывает диалог при клике на пустую область.""" | ||||
|         pos = event.pos() | ||||
|         # Проверяем, находится ли клик вне imageContainer и captionLabel | ||||
|         if not (self.imageContainer.geometry().contains(pos) or | ||||
|                 self.captionLabel.geometry().contains(pos)): | ||||
|             self.close() | ||||
| @@ -305,15 +311,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem): | ||||
|     """ | ||||
|     def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None): | ||||
|         """ | ||||
|         :param pixmap: QPixmap для отображения в карусели | ||||
|         :param pixmap: QPixmap для отображения в карусели (оригинальное, высокое разрешение) | ||||
|         :param caption: Подпись к изображению | ||||
|         :param images_list: Список всех изображений (кортежей (QPixmap, caption)), | ||||
|                             чтобы в диалоге можно было перелистывать. | ||||
|                             Если не передан, будет использован только текущее изображение. | ||||
|         :param index: Индекс текущего изображения в images_list. | ||||
|         :param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками. | ||||
|         :param images_list: Список всех изображений (кортежей (QPixmap, caption)) | ||||
|         :param index: Индекс текущего изображения в images_list | ||||
|         :param carousel: Ссылка на родительскую карусель (ImageCarousel) | ||||
|         """ | ||||
|         super().__init__(pixmap) | ||||
|         super().__init__() | ||||
|         self.original_pixmap = pixmap  # Store original high-resolution pixmap | ||||
|         self.caption = caption | ||||
|         self.images_list = images_list if images_list is not None else [(pixmap, caption)] | ||||
|         self.index = index | ||||
| @@ -323,6 +328,20 @@ class ClickablePixmapItem(QGraphicsPixmapItem): | ||||
|         self._click_start_position = None | ||||
|         self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton) | ||||
|         self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable) | ||||
|         self.update_pixmap()  # Set initial pixmap | ||||
|  | ||||
|     def update_pixmap(self, height=300): | ||||
|         """Update the displayed pixmap by scaling from the original high-resolution pixmap.""" | ||||
|         if self.original_pixmap.isNull(): | ||||
|             return | ||||
|         # Scale pixmap to desired height, considering device pixel ratio | ||||
|         device_pixel_ratio = get_device_pixel_ratio() | ||||
|         scaled_pixmap = self.original_pixmap.scaledToHeight( | ||||
|             int(height * device_pixel_ratio), | ||||
|             Qt.TransformationMode.SmoothTransformation | ||||
|         ) | ||||
|         scaled_pixmap.setDevicePixelRatio(device_pixel_ratio) | ||||
|         self.setPixmap(scaled_pixmap) | ||||
|  | ||||
|     def mousePressEvent(self, event): | ||||
|         if event.button() == Qt.MouseButton.LeftButton: | ||||
| @@ -339,17 +358,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem): | ||||
|         event.accept() | ||||
|  | ||||
|     def show_fullscreen(self): | ||||
|         # Скрываем стрелки карусели перед открытием FullscreenDialog | ||||
|         if self.carousel: | ||||
|             self.carousel.prevArrow.hide() | ||||
|             self.carousel.nextArrow.hide() | ||||
|         dialog = FullscreenDialog(self.images_list, current_index=self.index) | ||||
|         dialog.exec() | ||||
|         # После закрытия диалога обновляем видимость стрелок | ||||
|         if self.carousel: | ||||
|             self.carousel.update_arrows_visibility() | ||||
|  | ||||
|  | ||||
| class ImageCarousel(QGraphicsView): | ||||
|     """ | ||||
|     Карусель изображений с адаптивностью, возможностью увеличения по клику | ||||
| @@ -357,19 +373,17 @@ class ImageCarousel(QGraphicsView): | ||||
|     """ | ||||
|     def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None): | ||||
|         super().__init__(parent) | ||||
|  | ||||
|         # Аннотируем тип scene как QGraphicsScene | ||||
|         self.carousel_scene: QGraphicsScene = QGraphicsScene(self) | ||||
|         self.setScene(self.carousel_scene) | ||||
|  | ||||
|         self.images = images  # Список кортежей: (QPixmap, caption) | ||||
|         self.image_items = [] | ||||
|         self._animation = None | ||||
|         self.theme = theme if theme else default_styles | ||||
|         self.theme_manager = ThemeManager() | ||||
|         self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) | ||||
|         self.max_height = 300  # Default height for images | ||||
|         self.init_ui() | ||||
|         self.create_arrows() | ||||
|  | ||||
|         # Переменные для поддержки перетаскивания | ||||
|         self._drag_active = False | ||||
|         self._drag_start_position = None | ||||
|         self._scroll_start_value = None | ||||
| @@ -380,30 +394,38 @@ class ImageCarousel(QGraphicsView): | ||||
|         self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) | ||||
|         self.setFrameShape(QFrame.Shape.NoFrame) | ||||
|  | ||||
|         x_offset = 10  # Отступ между изображениями | ||||
|         max_height = 300  # Фиксированная высота изображений | ||||
|         self.update_scene() | ||||
|  | ||||
|     def update_scene(self): | ||||
|         """Update the scene with scaled images based on current size and scale.""" | ||||
|         self.carousel_scene.clear() | ||||
|         self.image_items.clear() | ||||
|  | ||||
|         x_offset = 10 | ||||
|         x = 0 | ||||
|         device_pixel_ratio = get_device_pixel_ratio() | ||||
|  | ||||
|         for i, (pixmap, caption) in enumerate(self.images): | ||||
|             item = ClickablePixmapItem( | ||||
|                 pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation), | ||||
|                 pixmap,  # Pass original pixmap | ||||
|                 caption, | ||||
|                 images_list=self.images, | ||||
|                 index=i, | ||||
|                 carousel=self  # Передаем ссылку на карусель | ||||
|                 carousel=self | ||||
|             ) | ||||
|             item.update_pixmap(self.max_height)  # Scale to current height | ||||
|             item.setPos(x, 0) | ||||
|             self.carousel_scene.addItem(item) | ||||
|             self.image_items.append(item) | ||||
|             x += item.pixmap().width() + x_offset | ||||
|             x += item.pixmap().width() / device_pixel_ratio + x_offset | ||||
|  | ||||
|         self.setSceneRect(0, 0, x, max_height) | ||||
|         self.setSceneRect(0, 0, x, self.max_height) | ||||
|  | ||||
|     def create_arrows(self): | ||||
|         """Создаёт кнопки-стрелки и привязывает их к функциям прокрутки.""" | ||||
|         self.prevArrow = QToolButton(self) | ||||
|         self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow) | ||||
|         self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore | ||||
|         self.prevArrow.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", "")) | ||||
|         self.prevArrow.setFixedSize(40, 40) | ||||
|         self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor) | ||||
|         self.prevArrow.setAutoRepeat(True) | ||||
| @@ -414,7 +436,7 @@ class ImageCarousel(QGraphicsView): | ||||
|  | ||||
|         self.nextArrow = QToolButton(self) | ||||
|         self.nextArrow.setArrowType(Qt.ArrowType.RightArrow) | ||||
|         self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore | ||||
|         self.nextArrow.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", "")) | ||||
|         self.nextArrow.setFixedSize(40, 40) | ||||
|         self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor) | ||||
|         self.nextArrow.setAutoRepeat(True) | ||||
| @@ -423,14 +445,9 @@ class ImageCarousel(QGraphicsView): | ||||
|         self.nextArrow.clicked.connect(self.scroll_right) | ||||
|         self.nextArrow.raise_() | ||||
|  | ||||
|         # Проверяем видимость стрелок при создании | ||||
|         self.update_arrows_visibility() | ||||
|  | ||||
|     def update_arrows_visibility(self): | ||||
|         """ | ||||
|         Показывает стрелки, если контент шире видимой области. | ||||
|         Иначе скрывает их. | ||||
|         """ | ||||
|         if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"): | ||||
|             if self.horizontalScrollBar().maximum() == 0: | ||||
|                 self.prevArrow.hide() | ||||
| @@ -444,7 +461,8 @@ class ImageCarousel(QGraphicsView): | ||||
|         margin = 10 | ||||
|         self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2) | ||||
|         self.nextArrow.move(self.width() - self.nextArrow.width() - margin, | ||||
|                               (self.height() - self.nextArrow.height()) // 2) | ||||
|                             (self.height() - self.nextArrow.height()) // 2) | ||||
|         self.update_scene()  # Re-scale images on resize | ||||
|         self.update_arrows_visibility() | ||||
|  | ||||
|     def animate_scroll(self, end_value): | ||||
| @@ -469,19 +487,15 @@ class ImageCarousel(QGraphicsView): | ||||
|         self.animate_scroll(new_value) | ||||
|  | ||||
|     def update_images(self, new_images): | ||||
|         self.carousel_scene.clear() | ||||
|         self.images = new_images | ||||
|         self.image_items.clear() | ||||
|         self.init_ui() | ||||
|         self.update_scene() | ||||
|         self.update_arrows_visibility() | ||||
|  | ||||
|     # Обработка событий мыши для перетаскивания | ||||
|     def mousePressEvent(self, event): | ||||
|         if event.button() == Qt.MouseButton.LeftButton: | ||||
|             self._drag_active = True | ||||
|             self._drag_start_position = event.pos() | ||||
|             self._scroll_start_value = self.horizontalScrollBar().value() | ||||
|             # Скрываем стрелки при начале перетаскивания | ||||
|             if hasattr(self, "prevArrow"): | ||||
|                 self.prevArrow.hide() | ||||
|             if hasattr(self, "nextArrow"): | ||||
| @@ -497,6 +511,5 @@ class ImageCarousel(QGraphicsView): | ||||
|  | ||||
|     def mouseReleaseEvent(self, event): | ||||
|         self._drag_active = False | ||||
|         # Показываем стрелки после завершения перетаскивания (с проверкой видимости) | ||||
|         self.update_arrows_visibility() | ||||
|         super().mouseReleaseEvent(event) | ||||
|   | ||||
							
								
								
									
										73
									
								
								portprotonqt/keyboard_layouts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,73 @@ | ||||
| # keyboard_layouts.py | ||||
| keyboard_layouts = { | ||||
|     'en': { | ||||
|         'normal': [ | ||||
|             ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"], | ||||
|             ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'], | ||||
|             ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?'] | ||||
|         ] | ||||
|     }, | ||||
|     'ru': { | ||||
|         'normal': [ | ||||
|             ['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='], | ||||
|             ['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'], | ||||
|             ['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'], | ||||
|             ['⬆', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['Ё', '!', '"', '№', ';', '%', ':', '?', '*', '(', ')', '_', '+'], | ||||
|             ['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'], | ||||
|             ['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'], | ||||
|             ['⬆', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ','] | ||||
|         ] | ||||
|     }, | ||||
|     'fr': { | ||||
|         'normal': [ | ||||
|             ['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='], | ||||
|             ['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'], | ||||
|             ['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'], | ||||
|             ['⬆', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'], | ||||
|             ['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'], | ||||
|             ['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'], | ||||
|             ['⬆', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§'] | ||||
|         ] | ||||
|     }, | ||||
|     'es': { | ||||
|         'normal': [ | ||||
|             ['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'], | ||||
|             ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'], | ||||
|             ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_'] | ||||
|         ] | ||||
|     }, | ||||
|     'de': { | ||||
|         'normal': [ | ||||
|             ['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'], | ||||
|             ['⬆', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'], | ||||
|             ['⬆', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_'] | ||||
|         ] | ||||
|     } | ||||
| } | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-07-14 13:16+0500\n" | ||||
| "POT-Creation-Date: 2025-10-15 15:31+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: de_DE\n" | ||||
| @@ -23,13 +23,7 @@ msgstr "" | ||||
| msgid "Error" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "PortProton is not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Stop Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launch Game" | ||||
| msgid "PortProton directory not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Remove from Favorites" | ||||
| @@ -38,6 +32,15 @@ msgstr "" | ||||
| msgid "Add to Favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete from PortProton" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Stop Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launch Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Import to Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -65,9 +68,6 @@ msgstr "" | ||||
| msgid "Edit Shortcut" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete from PortProton" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Stopped '{game_name}'" | ||||
| msgstr "" | ||||
| @@ -155,7 +155,7 @@ msgid "Menu" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No executable command in .desktop file for '{game_name}'" | ||||
| msgid "No executable command found in .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -163,25 +163,13 @@ msgid "Failed to parse .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to read .desktop file: {error}" | ||||
| msgid "Error reading .desktop file: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No .desktop file found for '{game_name}'" | ||||
| 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" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -203,6 +191,10 @@ msgstr "" | ||||
| msgid "Failed to delete custom data: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Added '{game_name}' successfully" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Game name and executable path are required" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -260,15 +252,26 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select" | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Path: " | ||||
| msgstr "" | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -305,6 +308,45 @@ msgstr "" | ||||
| msgid "No cover selected" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Set" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Libraries" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Information" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fonts" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Warning" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No components selected." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed. Check logs." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Components installed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Epic Games Store games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -353,9 +395,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -365,6 +404,34 @@ msgstr "" | ||||
| msgid "Themes" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Back" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Processed {} installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -377,13 +444,106 @@ msgstr "" | ||||
| msgid "Find Games ..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| #, python-brace-format | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Various Wine parameters and versions..." | ||||
| msgid "Prefix:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Configuration" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Uninstaller" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Create Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Load Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Compatibility Tool" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start restore process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore failed." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete prefix: {}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete compatibility tool '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Compatibility tool '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete compatibility tool: {}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Main PortProton parameters..." | ||||
| @@ -419,6 +579,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -455,21 +618,6 @@ msgstr "" | ||||
| msgid "Gamepad haptic feedback:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Open Legendary Login" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary Authentication:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Enter Legendary Authorization Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Authorization Code:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Submit Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Save Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -479,28 +627,6 @@ msgstr "" | ||||
| msgid "Clear Cache" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Opened Legendary login page in browser" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to open Legendary login page" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Please enter an authorization code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Successfully authenticated with Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Legendary authentication failed: {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary executable not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Unexpected error during authentication" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Reset" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -554,9 +680,6 @@ msgstr "" | ||||
| msgid "Error applying theme '{0}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Back" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "LAST LAUNCH" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -660,3 +783,24 @@ msgstr "" | ||||
| msgid "sec." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Show" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Recent Games" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Exit" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Hide" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No recent games" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-07-14 13:16+0500\n" | ||||
| "POT-Creation-Date: 2025-10-15 15:31+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: es_ES\n" | ||||
| @@ -23,13 +23,7 @@ msgstr "" | ||||
| msgid "Error" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "PortProton is not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Stop Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launch Game" | ||||
| msgid "PortProton directory not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Remove from Favorites" | ||||
| @@ -38,6 +32,15 @@ msgstr "" | ||||
| msgid "Add to Favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete from PortProton" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Stop Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launch Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Import to Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -65,9 +68,6 @@ msgstr "" | ||||
| msgid "Edit Shortcut" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete from PortProton" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Stopped '{game_name}'" | ||||
| msgstr "" | ||||
| @@ -155,7 +155,7 @@ msgid "Menu" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No executable command in .desktop file for '{game_name}'" | ||||
| msgid "No executable command found in .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -163,25 +163,13 @@ msgid "Failed to parse .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to read .desktop file: {error}" | ||||
| msgid "Error reading .desktop file: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No .desktop file found for '{game_name}'" | ||||
| 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" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -203,6 +191,10 @@ msgstr "" | ||||
| msgid "Failed to delete custom data: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Added '{game_name}' successfully" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Game name and executable path are required" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -260,15 +252,26 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select" | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Path: " | ||||
| msgstr "" | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -305,6 +308,45 @@ msgstr "" | ||||
| msgid "No cover selected" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Set" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Libraries" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Information" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fonts" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Warning" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No components selected." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed. Check logs." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Components installed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Epic Games Store games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -353,9 +395,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -365,6 +404,34 @@ msgstr "" | ||||
| msgid "Themes" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Back" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Processed {} installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -377,13 +444,106 @@ msgstr "" | ||||
| msgid "Find Games ..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| #, python-brace-format | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Various Wine parameters and versions..." | ||||
| msgid "Prefix:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Configuration" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Uninstaller" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Create Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Load Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Compatibility Tool" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start restore process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore failed." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete prefix: {}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete compatibility tool '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Compatibility tool '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete compatibility tool: {}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Main PortProton parameters..." | ||||
| @@ -419,6 +579,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -455,21 +618,6 @@ msgstr "" | ||||
| msgid "Gamepad haptic feedback:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Open Legendary Login" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary Authentication:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Enter Legendary Authorization Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Authorization Code:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Submit Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Save Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -479,28 +627,6 @@ msgstr "" | ||||
| msgid "Clear Cache" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Opened Legendary login page in browser" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to open Legendary login page" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Please enter an authorization code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Successfully authenticated with Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Legendary authentication failed: {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary executable not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Unexpected error during authentication" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Reset" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -554,9 +680,6 @@ msgstr "" | ||||
| msgid "Error applying theme '{0}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Back" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "LAST LAUNCH" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -660,3 +783,24 @@ msgstr "" | ||||
| msgid "sec." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Show" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Recent Games" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Exit" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Hide" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No recent games" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PortProtonQt 0.1.1\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-07-14 13:16+0500\n" | ||||
| "POT-Creation-Date: 2025-10-15 15:31+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | ||||
| @@ -21,13 +21,7 @@ msgstr "" | ||||
| msgid "Error" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "PortProton is not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Stop Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launch Game" | ||||
| msgid "PortProton directory not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Remove from Favorites" | ||||
| @@ -36,6 +30,15 @@ msgstr "" | ||||
| msgid "Add to Favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete from PortProton" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Stop Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launch Game" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Import to Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -63,9 +66,6 @@ msgstr "" | ||||
| msgid "Edit Shortcut" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete from PortProton" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Stopped '{game_name}'" | ||||
| msgstr "" | ||||
| @@ -153,7 +153,7 @@ msgid "Menu" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No executable command in .desktop file for '{game_name}'" | ||||
| msgid "No executable command found in .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| @@ -161,25 +161,13 @@ msgid "Failed to parse .desktop file for '{game_name}'" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to read .desktop file: {error}" | ||||
| msgid "Error reading .desktop file: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No .desktop file found for '{game_name}'" | ||||
| 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" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -201,6 +189,10 @@ msgstr "" | ||||
| msgid "Failed to delete custom data: {error}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Added '{game_name}' successfully" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Game name and executable path are required" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -258,15 +250,26 @@ msgstr "" | ||||
| msgid "Select All" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select" | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Select" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Path: " | ||||
| msgstr "" | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -303,6 +306,45 @@ msgstr "" | ||||
| msgid "No cover selected" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix Manager" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Set" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Libraries" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Information" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fonts" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Settings" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Warning" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No components selected." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed. Check logs." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Components installed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Epic Games Store games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -351,9 +393,6 @@ msgstr "" | ||||
| msgid "Auto Install" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -363,6 +402,34 @@ msgstr "" | ||||
| msgid "Themes" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Back" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Processed {} installation..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
| @@ -375,13 +442,106 @@ msgstr "" | ||||
| msgid "Find Games ..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| #, python-brace-format | ||||
| msgid "Added '{name}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Various Wine parameters and versions..." | ||||
| msgid "Prefix:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Wine Configuration" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Registry Editor" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Uninstaller" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Create Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Load Prefix Backup" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Compatibility Tool" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Delete Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Clear Prefix" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to start restore process." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix backup failed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore completed." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Prefix restore failed." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete prefix '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete prefix: {}" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete compatibility tool '{}'?" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Compatibility tool '{}' deleted." | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete compatibility tool: {}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Main PortProton parameters..." | ||||
| @@ -417,6 +577,9 @@ msgstr "" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -453,21 +616,6 @@ msgstr "" | ||||
| msgid "Gamepad haptic feedback:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Open Legendary Login" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary Authentication:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Enter Legendary Authorization Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Authorization Code:" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Submit Code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Save Settings" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -477,28 +625,6 @@ msgstr "" | ||||
| msgid "Clear Cache" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Opened Legendary login page in browser" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Failed to open Legendary login page" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Please enter an authorization code" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Successfully authenticated with Legendary" | ||||
| msgstr "" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Legendary authentication failed: {0}" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Legendary executable not found" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Unexpected error during authentication" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Confirm Reset" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -552,9 +678,6 @@ msgstr "" | ||||
| msgid "Error applying theme '{0}'" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Back" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "LAST LAUNCH" | ||||
| msgstr "" | ||||
|  | ||||
| @@ -658,3 +781,24 @@ msgstr "" | ||||
| msgid "sec." | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Show" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Recent Games" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Exit" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Hide" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No favorites" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "No recent games" | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,8 +9,8 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-07-14 13:16+0500\n" | ||||
| "PO-Revision-Date: 2025-07-14 13:16+0500\n" | ||||
| "POT-Creation-Date: 2025-10-15 15:31+0500\n" | ||||
| "PO-Revision-Date: 2025-10-15 15:31+0500\n" | ||||
| "Last-Translator: \n" | ||||
| "Language: ru_RU\n" | ||||
| "Language-Team: ru_RU <LL@li.org>\n" | ||||
| @@ -24,14 +24,8 @@ msgstr "" | ||||
| msgid "Error" | ||||
| msgstr "Ошибка" | ||||
|  | ||||
| msgid "PortProton is not found" | ||||
| msgstr "PortProton не найден" | ||||
|  | ||||
| msgid "Stop Game" | ||||
| msgstr "Остановить игру" | ||||
|  | ||||
| msgid "Launch Game" | ||||
| msgstr "Запустить игру" | ||||
| msgid "PortProton directory not found" | ||||
| msgstr "Не найден каталог PortProton" | ||||
|  | ||||
| msgid "Remove from Favorites" | ||||
| msgstr "Удалить из Избранного" | ||||
| @@ -39,6 +33,15 @@ msgstr "Удалить из Избранного" | ||||
| msgid "Add to Favorites" | ||||
| msgstr "Добавить в Избранное" | ||||
|  | ||||
| msgid "Delete from PortProton" | ||||
| msgstr "Удалить из PortProton" | ||||
|  | ||||
| msgid "Stop Game" | ||||
| msgstr "Остановить игру" | ||||
|  | ||||
| msgid "Launch Game" | ||||
| msgstr "Запустить игру" | ||||
|  | ||||
| msgid "Import to Legendary" | ||||
| msgstr "Импортировать игру" | ||||
|  | ||||
| @@ -66,9 +69,6 @@ msgstr "Добавить в меню" | ||||
| msgid "Edit Shortcut" | ||||
| msgstr "Редактировать" | ||||
|  | ||||
| msgid "Delete from PortProton" | ||||
| msgstr "Удалить из PortProton" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Stopped '{game_name}'" | ||||
| msgstr "Остановлен(а) '{game_name}'" | ||||
| @@ -158,33 +158,21 @@ msgid "Menu" | ||||
| msgstr "Меню" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No executable command in .desktop file for '{game_name}'" | ||||
| msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда" | ||||
| msgid "No executable command found in .desktop file for '{game_name}'" | ||||
| msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to parse .desktop file for '{game_name}'" | ||||
| msgstr "Не удалось разобрать файл .desktop для '{game_name}'" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to read .desktop file: {error}" | ||||
| msgstr "Не удалось прочитать файл .desktop: {error}" | ||||
| msgid "Error reading .desktop file: {error}" | ||||
| msgstr "Ошибка при чтении файла .desktop: {error}" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "No .desktop file found for '{game_name}'" | ||||
| 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" | ||||
| msgstr "Подтвердите удаление" | ||||
|  | ||||
| @@ -208,6 +196,10 @@ msgstr "'{game_name}' был(а) успешно удалён(а)" | ||||
| msgid "Failed to delete custom data: {error}" | ||||
| msgstr "Не удалось удалить пользовательские данные: {error}" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Added '{game_name}' successfully" | ||||
| msgstr "'{game_name}' успешно добавлен(а)" | ||||
|  | ||||
| msgid "Game name and executable path are required" | ||||
| msgstr "Требуются название игры и путь к исполняемому файлу" | ||||
|  | ||||
| @@ -267,15 +259,26 @@ msgstr "Удалить" | ||||
| msgid "Select All" | ||||
| msgstr "Выбрать всё" | ||||
|  | ||||
| msgid "Select" | ||||
| msgstr "Выбрать" | ||||
| #, python-brace-format | ||||
| msgid "Launching {0}" | ||||
| msgstr "Идёт запуск {0}" | ||||
|  | ||||
| msgid "Cancel" | ||||
| msgstr "Отмена" | ||||
|  | ||||
| msgid "File Explorer" | ||||
| msgstr "Проводник" | ||||
|  | ||||
| msgid "Select" | ||||
| msgstr "Выбрать" | ||||
|  | ||||
| msgid "Path: " | ||||
| msgstr "Путь: " | ||||
|  | ||||
| #, python-format | ||||
| msgid "Access denied: %s" | ||||
| msgstr "Доступ запрещён: %s" | ||||
|  | ||||
| msgid "Edit Game" | ||||
| msgstr "Редактировать игру" | ||||
|  | ||||
| @@ -312,6 +315,45 @@ msgstr "Скачивание обложки..." | ||||
| msgid "No cover selected" | ||||
| msgstr "Обложка не выбрана" | ||||
|  | ||||
| msgid "Prefix Manager" | ||||
| msgstr "Менеджер префиксов" | ||||
|  | ||||
| msgid "Set" | ||||
| msgstr "Выбор" | ||||
|  | ||||
| msgid "Libraries" | ||||
| msgstr "Библиотеки" | ||||
|  | ||||
| msgid "Information" | ||||
| msgstr "Описание" | ||||
|  | ||||
| msgid "Fonts" | ||||
| msgstr "Шрифты" | ||||
|  | ||||
| msgid "Settings" | ||||
| msgstr "Настройки" | ||||
|  | ||||
| msgid "Force Install" | ||||
| msgstr "Принудительно установить" | ||||
|  | ||||
| msgid "Install" | ||||
| msgstr "Установить" | ||||
|  | ||||
| msgid "Winetricks not found. Please try again." | ||||
| msgstr "Winetricks не найден. Повторите попытку." | ||||
|  | ||||
| msgid "Warning" | ||||
| msgstr "Предупреждение" | ||||
|  | ||||
| msgid "No components selected." | ||||
| msgstr "Не выбрано ни одного компонента." | ||||
|  | ||||
| msgid "Installation failed. Check logs." | ||||
| msgstr "Установка не удалась. Проверьте журналы." | ||||
|  | ||||
| msgid "Components installed successfully." | ||||
| msgstr "Компоненты успешно установлены." | ||||
|  | ||||
| msgid "Loading Epic Games Store games..." | ||||
| msgstr "Загрузка игр из Epic Games Store..." | ||||
|  | ||||
| @@ -360,9 +402,6 @@ msgstr "Библиотека" | ||||
| msgid "Auto Install" | ||||
| msgstr "Автоустановка" | ||||
|  | ||||
| msgid "Emulators" | ||||
| msgstr "Эмуляторы" | ||||
|  | ||||
| msgid "Wine Settings" | ||||
| msgstr "Настройки wine" | ||||
|  | ||||
| @@ -372,6 +411,34 @@ msgstr "Настройки PortProton" | ||||
| msgid "Themes" | ||||
| msgstr "Темы" | ||||
|  | ||||
| msgid "Back" | ||||
| msgstr "Назад" | ||||
|  | ||||
| msgid "Fullscreen" | ||||
| msgstr "Полный экран" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "Поиск" | ||||
|  | ||||
| msgid "Installation already in progress." | ||||
| msgstr "Установка уже выполняется." | ||||
|  | ||||
| msgid "Failed to start installation." | ||||
| msgstr "Не удалось запустить установку." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Processed {} installation..." | ||||
| msgstr "В процессе установки {}..." | ||||
|  | ||||
| msgid "Installation completed successfully." | ||||
| msgstr "Установка завершена успешно." | ||||
|  | ||||
| msgid "Installation failed." | ||||
| msgstr "Установка не удалась." | ||||
|  | ||||
| msgid "Installation error." | ||||
| msgstr "Ошибка установки." | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "Загрузка игр из Steam..." | ||||
|  | ||||
| @@ -384,14 +451,109 @@ msgstr "Игровая библиотека" | ||||
| msgid "Find Games ..." | ||||
| msgstr "Найти игры..." | ||||
|  | ||||
| msgid "Here you can configure automatic game installation..." | ||||
| msgstr "Здесь можно настроить автоматическую установку игр..." | ||||
| #, python-brace-format | ||||
| msgid "Added '{name}'" | ||||
| msgstr "'{name}' добавлен(а)" | ||||
|  | ||||
| msgid "List of available emulators and their configuration..." | ||||
| msgstr "Список доступных эмуляторов и их настройка..." | ||||
| msgid "Compatibility tool:" | ||||
| msgstr "Инструмент совместимости:" | ||||
|  | ||||
| msgid "Various Wine parameters and versions..." | ||||
| msgstr "Различные параметры и версии wine..." | ||||
| msgid "Prefix:" | ||||
| msgstr "Префикс:" | ||||
|  | ||||
| msgid "Wine Configuration" | ||||
| msgstr "Конфигурация Wine" | ||||
|  | ||||
| msgid "Registry Editor" | ||||
| msgstr "Редактор реестра" | ||||
|  | ||||
| msgid "Command Prompt" | ||||
| msgstr "Командная строка" | ||||
|  | ||||
| msgid "Uninstaller" | ||||
| msgstr "Удаление программ" | ||||
|  | ||||
| msgid "Create Prefix Backup" | ||||
| msgstr "Создать резервную копию префикса" | ||||
|  | ||||
| msgid "Load Prefix Backup" | ||||
| msgstr "Загрузить резервную копию префикса" | ||||
|  | ||||
| msgid "Delete Compatibility Tool" | ||||
| msgstr "Удалить Инструмент совместимости" | ||||
|  | ||||
| msgid "Delete Prefix" | ||||
| msgstr "Удалить Префикс" | ||||
|  | ||||
| msgid "Clear Prefix" | ||||
| msgstr "Очистить Префикс" | ||||
|  | ||||
| msgid "Launching tool..." | ||||
| msgstr "Запуск инструмента..." | ||||
|  | ||||
| msgid "Failed to start process." | ||||
| msgstr "Не удалось запустить процесс." | ||||
|  | ||||
| msgid "Confirm Clear" | ||||
| msgstr "Подтвердите очистку" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to clear prefix '{}'?" | ||||
| msgstr "Вы уверены, что хотите очистить префикс «{}»?" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' cleared successfully." | ||||
| msgstr "Префикс '{}' успешно удален." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "" | ||||
| "Prefix '{}' cleared with errors:\n" | ||||
| "{}" | ||||
| msgstr "" | ||||
| "Префикс '{}' очищен с ошибками:\n" | ||||
| "{}" | ||||
|  | ||||
| msgid "Failed to start backup process." | ||||
| msgstr "Не удалось запустить процесс резервного копирования." | ||||
|  | ||||
| msgid "Failed to start restore process." | ||||
| msgstr "Не удалось запустить процесс восстановления." | ||||
|  | ||||
| msgid "Prefix backup completed." | ||||
| msgstr "Резервное копирование префикса завершено." | ||||
|  | ||||
| msgid "Prefix backup failed." | ||||
| msgstr "Сбой резервного копирования префикса." | ||||
|  | ||||
| msgid "Prefix restore completed." | ||||
| msgstr "Восстановление префикса завершено." | ||||
|  | ||||
| msgid "Prefix restore failed." | ||||
| msgstr "Восстановление префикса не удалось." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete prefix '{}'?" | ||||
| msgstr "Вы уверены, что хотите удалить префикс «{}»?" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Prefix '{}' deleted." | ||||
| msgstr "Префикс «{}» удален." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete prefix: {}" | ||||
| msgstr "Не удалось удалить префикс: {}" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Are you sure you want to delete compatibility tool '{}'?" | ||||
| msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Compatibility tool '{}' deleted." | ||||
| msgstr "Инструмент совместимости «{}» удален." | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Failed to delete compatibility tool: {}" | ||||
| msgstr "Не удалось удалить инструмент совместимости: {}" | ||||
|  | ||||
| msgid "Main PortProton parameters..." | ||||
| msgstr "Основные параметры PortProton..." | ||||
| @@ -426,6 +588,9 @@ msgstr "все" | ||||
| msgid "Games Display Filter:" | ||||
| msgstr "Фильтр игр:" | ||||
|  | ||||
| msgid "Gamepad Type:" | ||||
| msgstr "Тип геймпада:" | ||||
|  | ||||
| msgid "Proxy URL" | ||||
| msgstr "Адрес прокси" | ||||
|  | ||||
| @@ -462,21 +627,6 @@ msgstr "Тактильная отдача на геймпаде" | ||||
| msgid "Gamepad haptic feedback:" | ||||
| msgstr "Тактильная отдача на геймпаде:" | ||||
|  | ||||
| msgid "Open Legendary Login" | ||||
| msgstr "Открыть браузер для входа в Legendary" | ||||
|  | ||||
| msgid "Legendary Authentication:" | ||||
| msgstr "Авторизация в Legendary:" | ||||
|  | ||||
| msgid "Enter Legendary Authorization Code" | ||||
| msgstr "Введите код авторизации Legendary" | ||||
|  | ||||
| msgid "Authorization Code:" | ||||
| msgstr "Код авторизации:" | ||||
|  | ||||
| msgid "Submit Code" | ||||
| msgstr "Отправить код" | ||||
|  | ||||
| msgid "Save Settings" | ||||
| msgstr "Сохранить настройки" | ||||
|  | ||||
| @@ -486,28 +636,6 @@ msgstr "Сбросить настройки" | ||||
| msgid "Clear Cache" | ||||
| msgstr "Очистить кэш" | ||||
|  | ||||
| msgid "Opened Legendary login page in browser" | ||||
| msgstr "Открытие страницы входа в Legendary в браузере" | ||||
|  | ||||
| msgid "Failed to open Legendary login page" | ||||
| msgstr "Не удалось открыть страницу входа в Legendary" | ||||
|  | ||||
| msgid "Please enter an authorization code" | ||||
| msgstr "Пожалуйста, введите код авторизации" | ||||
|  | ||||
| msgid "Successfully authenticated with Legendary" | ||||
| msgstr "Успешная аутентификация в Legendary" | ||||
|  | ||||
| #, python-brace-format | ||||
| msgid "Legendary authentication failed: {0}" | ||||
| msgstr "Не удалось выполнить аутентификацию Legendary: {0}" | ||||
|  | ||||
| msgid "Legendary executable not found" | ||||
| msgstr "Не найден исполняемый файл Legendary" | ||||
|  | ||||
| msgid "Unexpected error during authentication" | ||||
| msgstr "Неожиданная ошибка при аутентификации" | ||||
|  | ||||
| msgid "Confirm Reset" | ||||
| msgstr "Подтвердите удаление" | ||||
|  | ||||
| @@ -563,9 +691,6 @@ msgstr "Тема '{0}' применена успешно" | ||||
| msgid "Error applying theme '{0}'" | ||||
| msgstr "Ошибка при применение темы '{0}'" | ||||
|  | ||||
| msgid "Back" | ||||
| msgstr "Назад" | ||||
|  | ||||
| msgid "LAST LAUNCH" | ||||
| msgstr "Последний запуск" | ||||
|  | ||||
| @@ -669,3 +794,24 @@ msgstr "мин." | ||||
| msgid "sec." | ||||
| msgstr "сек." | ||||
|  | ||||
| msgid "Show" | ||||
| msgstr "Показать" | ||||
|  | ||||
| msgid "Favorites" | ||||
| msgstr "Избранное" | ||||
|  | ||||
| msgid "Recent Games" | ||||
| msgstr "Недавние" | ||||
|  | ||||
| msgid "Exit" | ||||
| msgstr "Выход" | ||||
|  | ||||
| msgid "Hide" | ||||
| msgstr "Скрыть" | ||||
|  | ||||
| msgid "No favorites" | ||||
| msgstr "Нет избранных" | ||||
|  | ||||
| msgid "No recent games" | ||||
| msgstr "Нет недавних игр" | ||||
|  | ||||
|   | ||||
| @@ -1,16 +1,34 @@ | ||||
| import logging | ||||
|  | ||||
| def setup_logger(): | ||||
| def setup_logger(level='NOTSET'): | ||||
|     """Настройка базовой конфигурации логирования.""" | ||||
|     logging.basicConfig( | ||||
|         level=logging.INFO, | ||||
|         format='[%(levelname)s] %(message)s', | ||||
|         handlers=[logging.StreamHandler()] | ||||
|     ) | ||||
|     # Clear existing handlers to prevent duplicates | ||||
|     root_logger = logging.getLogger() | ||||
|     for handler in root_logger.handlers[:]: | ||||
|         root_logger.removeHandler(handler) | ||||
|  | ||||
|     # Convert string level to logging level constant, map ALL to DEBUG | ||||
|     if level.upper() == 'ALL': | ||||
|         log_level = logging.DEBUG | ||||
|     else: | ||||
|         log_level = getattr(logging, level.upper(), logging.NOTSET) | ||||
|  | ||||
|     # Configure logging with null handler if level is NOTSET | ||||
|     if log_level == logging.NOTSET: | ||||
|         logging.basicConfig( | ||||
|             level=logging.NOTSET, | ||||
|             handlers=[logging.NullHandler()] | ||||
|         ) | ||||
|     else: | ||||
|         logging.basicConfig( | ||||
|             level=log_level, | ||||
|             format='[%(levelname)s] %(message)s', | ||||
|             handlers=[logging.StreamHandler()] | ||||
|         ) | ||||
|  | ||||
| def get_logger(name): | ||||
|     """Возвращает логгер для указанного модуля.""" | ||||
|     return logging.getLogger(name) | ||||
|  | ||||
| # Инициализация логгера при импорте модуля | ||||
| # Инициализация логгера при импорте модуля (без логов по умолчанию) | ||||
| setup_logger() | ||||
|   | ||||
| @@ -4,9 +4,12 @@ import orjson | ||||
| import requests | ||||
| import urllib.parse | ||||
| import time | ||||
| import glob | ||||
| import re | ||||
| from collections.abc import Callable | ||||
| from portprotonqt.downloader import Downloader | ||||
| from portprotonqt.logger import get_logger | ||||
| from portprotonqt.config_utils import get_portproton_location | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| CACHE_DURATION = 30 * 24 * 60 * 60  # 30 days in seconds | ||||
| @@ -52,6 +55,9 @@ class PortProtonAPI: | ||||
|         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") | ||||
|         os.makedirs(self.custom_data_dir, exist_ok=True) | ||||
|         self.portproton_location = get_portproton_location() | ||||
|         self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) | ||||
|         self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data") | ||||
|         self._topics_data = None | ||||
|  | ||||
|     def _get_game_dir(self, exe_name: str) -> str: | ||||
| @@ -68,40 +74,6 @@ class PortProtonAPI: | ||||
|             logger.debug(f"Failed to check file at {url}: {e}") | ||||
|             return False | ||||
|  | ||||
|     def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]: | ||||
|         game_dir = self._get_game_dir(exe_name) | ||||
|         results: dict[str, str | None] = {"cover": None, "metadata": None} | ||||
|         cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"] | ||||
|         cover_url_base = f"{self.base_url}/{exe_name}/cover" | ||||
|         metadata_url = f"{self.base_url}/{exe_name}/metadata.txt" | ||||
|  | ||||
|         for ext in cover_extensions: | ||||
|             cover_url = f"{cover_url_base}{ext}" | ||||
|             if self._check_file_exists(cover_url, timeout): | ||||
|                 local_cover_path = os.path.join(game_dir, f"cover{ext}") | ||||
|                 result = self.downloader.download(cover_url, local_cover_path, timeout=timeout) | ||||
|                 if result: | ||||
|                     results["cover"] = result | ||||
|                     logger.info(f"Downloaded cover for {exe_name} to {result}") | ||||
|                     break | ||||
|                 else: | ||||
|                     logger.error(f"Failed to download cover for {exe_name} from {cover_url}") | ||||
|             else: | ||||
|                 logger.debug(f"No cover found for {exe_name} with extension {ext}") | ||||
|  | ||||
|         if self._check_file_exists(metadata_url, timeout): | ||||
|             local_metadata_path = os.path.join(game_dir, "metadata.txt") | ||||
|             result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout) | ||||
|             if result: | ||||
|                 results["metadata"] = result | ||||
|                 logger.info(f"Downloaded metadata for {exe_name} to {result}") | ||||
|             else: | ||||
|                 logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}") | ||||
|         else: | ||||
|             logger.debug(f"No metadata found for {exe_name}") | ||||
|  | ||||
|         return results | ||||
|  | ||||
|     def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None: | ||||
|         game_dir = self._get_game_dir(exe_name) | ||||
|         cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"] | ||||
| @@ -163,6 +135,164 @@ class PortProtonAPI: | ||||
|             if callback: | ||||
|                 callback(results) | ||||
|  | ||||
|     def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None: | ||||
|         """Download only autoinstall cover image (PNG only, no metadata).""" | ||||
|         xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                                 os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") | ||||
|         user_game_folder = os.path.join(autoinstall_root, exe_name) | ||||
|  | ||||
|         if not os.path.isdir(user_game_folder): | ||||
|             try: | ||||
|                 os.mkdir(user_game_folder) | ||||
|             except FileExistsError: | ||||
|                 pass | ||||
|  | ||||
|         cover_url = f"{self.base_url}/{exe_name}/cover.png" | ||||
|         local_cover_path = os.path.join(user_game_folder, "cover.png") | ||||
|  | ||||
|         def on_cover_downloaded(local_path: str | None): | ||||
|             if local_path: | ||||
|                 logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}") | ||||
|             else: | ||||
|                 logger.debug(f"No autoinstall cover downloaded for {exe_name}") | ||||
|             if callback: | ||||
|                 callback(local_path) | ||||
|  | ||||
|         if self._check_file_exists(cover_url, timeout): | ||||
|             self.downloader.download_async( | ||||
|                 cover_url, | ||||
|                 local_cover_path, | ||||
|                 timeout=timeout, | ||||
|                 callback=on_cover_downloaded | ||||
|             ) | ||||
|         else: | ||||
|             logger.debug(f"No autoinstall cover found for {exe_name}") | ||||
|             if callback: | ||||
|                 callback(None) | ||||
|  | ||||
|     def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]: | ||||
|         """Extract display_name from # name comment and exe_name from autoinstall bash script.""" | ||||
|         try: | ||||
|             with open(file_path, encoding='utf-8') as f: | ||||
|                 content = f.read() | ||||
|  | ||||
|             # Skip emulators | ||||
|             if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE): | ||||
|                 return None, None | ||||
|  | ||||
|             display_name = None | ||||
|             exe_name = None | ||||
|  | ||||
|             # Extract display_name from "# name:" comment | ||||
|             name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE) | ||||
|             if name_match: | ||||
|                 display_name = name_match.group(1).strip() | ||||
|  | ||||
|             # --- pw_create_unique_exe --- | ||||
|             pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content) | ||||
|             if pw_match: | ||||
|                 arg = pw_match.group(1) | ||||
|                 if arg: | ||||
|                     exe_name = arg.strip() | ||||
|                     if not exe_name.lower().endswith(".exe"): | ||||
|                         exe_name += ".exe" | ||||
|                 else: | ||||
|                     export_match = re.search( | ||||
|                         r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']', | ||||
|                         content, re.IGNORECASE) | ||||
|                     if export_match: | ||||
|                         exe_name = f"{export_match.group(1).strip()}.exe" | ||||
|  | ||||
|             else: | ||||
|                 portwine_match = None | ||||
|                 for line in content.splitlines(): | ||||
|                     stripped = line.strip() | ||||
|                     if stripped.startswith("#"): | ||||
|                         continue | ||||
|                     if "portwine_exe" in stripped and "=" in stripped: | ||||
|                         portwine_match = stripped | ||||
|                         break | ||||
|  | ||||
|                 if portwine_match: | ||||
|                     exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ") | ||||
|                     exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr) | ||||
|                     if exe_candidates: | ||||
|                         exe_name = os.path.basename(exe_candidates[-1].strip()) | ||||
|  | ||||
|  | ||||
|             # Fallback | ||||
|             if not display_name and exe_name: | ||||
|                 display_name = exe_name | ||||
|  | ||||
|             return display_name, exe_name | ||||
|  | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to parse {file_path}: {e}") | ||||
|             return None, None | ||||
|  | ||||
|     def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None: | ||||
|         """Load auto-install games with user/builtin covers (no async download here).""" | ||||
|         games = [] | ||||
|         auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else "" | ||||
|         if not os.path.exists(auto_dir): | ||||
|             callback(games) | ||||
|             return | ||||
|  | ||||
|         scripts = sorted(glob.glob(os.path.join(auto_dir, "*"))) | ||||
|         if not scripts: | ||||
|             callback(games) | ||||
|             return | ||||
|  | ||||
|         xdg_data_home = os.getenv("XDG_DATA_HOME", | ||||
|                                 os.path.join(os.path.expanduser("~"), ".local", "share")) | ||||
|         base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") | ||||
|         os.makedirs(base_autoinstall_dir, exist_ok=True) | ||||
|  | ||||
|         for script_path in scripts: | ||||
|             display_name, exe_name = self.parse_autoinstall_script(script_path) | ||||
|             script_name = os.path.splitext(os.path.basename(script_path))[0] | ||||
|  | ||||
|             if not (display_name and exe_name): | ||||
|                 continue | ||||
|  | ||||
|             exe_name = os.path.splitext(exe_name)[0]  # Без .exe | ||||
|             user_game_folder = os.path.join(base_autoinstall_dir, exe_name) | ||||
|             os.makedirs(user_game_folder, exist_ok=True) | ||||
|  | ||||
|             # Поиск обложки | ||||
|             cover_path = "" | ||||
|             user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set() | ||||
|             for ext in [".jpg", ".png", ".jpeg", ".bmp"]: | ||||
|                 candidate = f"cover{ext}" | ||||
|                 if candidate in user_files: | ||||
|                     cover_path = os.path.join(user_game_folder, candidate) | ||||
|                     break | ||||
|  | ||||
|             if not cover_path: | ||||
|                 logger.debug(f"No local cover found for autoinstall {exe_name}") | ||||
|  | ||||
|             # Формируем кортеж игры (добавлен exe_name в конец) | ||||
|             game_tuple = ( | ||||
|                 display_name,  # name | ||||
|                 "",  # description | ||||
|                 cover_path,  # cover | ||||
|                 "",  # appid | ||||
|                 f"autoinstall:{script_name}",  # exec_line | ||||
|                 "",  # controller_support | ||||
|                 "Never",  # last_launch | ||||
|                 "0h 0m",  # formatted_playtime | ||||
|                 "",  # protondb_tier | ||||
|                 "",  # anticheat_status | ||||
|                 0,  # last_played | ||||
|                 0,  # playtime_seconds | ||||
|                 "autoinstall",  # game_source | ||||
|                 exe_name  # exe_name | ||||
|             ) | ||||
|             games.append(game_tuple) | ||||
|  | ||||
|         callback(games) | ||||
|  | ||||
|     def _load_topics_data(self): | ||||
|         """Load and cache linux_gaming_topics_min.json from the archive.""" | ||||
|         if self._topics_data is not None: | ||||
|   | ||||
							
								
								
									
										49
									
								
								portprotonqt/preloader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						| @@ -0,0 +1,49 @@ | ||||
| import time | ||||
|  | ||||
| from PySide6.QtCore import QRect | ||||
| from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient | ||||
| from PySide6.QtWidgets import QWidget | ||||
|  | ||||
| class Preloader(QWidget): | ||||
|     def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None): | ||||
|         super().__init__(parent) | ||||
|         self.setFixedSize(150, 150) | ||||
|         self._speed = speed | ||||
|         self._line_width = line_line_width | ||||
|         self._color1 = color | ||||
|         self._color2 = QColor(color.red(), color.green(), color.blue(), 0) | ||||
|         self._start_time = time.time() | ||||
|  | ||||
|     def showEvent(self, event): | ||||
|         self._start_time = time.time() | ||||
|  | ||||
|     def paintEvent(self, event): | ||||
|         rect = self._get_preloader_rect() | ||||
|         center = rect.center() | ||||
|         painter = QPainter(self) | ||||
|         painter.setRenderHint(QPainter.RenderHint.Antialiasing) | ||||
|         painter.setPen(self._get_pen()) | ||||
|         painter.translate(center) | ||||
|         painter.rotate(self._get_angle()) | ||||
|         painter.translate(-center) | ||||
|         painter.drawArc(rect, 0, 270 * 16) | ||||
|         self.update() | ||||
|  | ||||
|     def _get_pen(self) -> QPen: | ||||
|         gradient = QConicalGradient() | ||||
|         gradient.setCenter(self.rect().center()) | ||||
|         gradient.setColorAt(0, self._color1) | ||||
|         gradient.setColorAt(1, self._color2) | ||||
|         pen = QPen(QBrush(gradient), self._line_width) | ||||
|         pen.setCapStyle(Qt.PenCapStyle.RoundCap) | ||||
|         return pen | ||||
|  | ||||
|     def _get_angle(self) -> float: | ||||
|         duration = time.time() - self._start_time | ||||
|         return (self._speed * duration) % 360.0 | ||||
|  | ||||
|     def _get_preloader_rect(self) -> QRect: | ||||
|         size = self._line_width // 2 | ||||
|         rect = self.rect() | ||||
|         rect.adjust(size, size, -size, -size) | ||||
|         return rect | ||||
| @@ -18,6 +18,11 @@ from collections.abc import Callable | ||||
| import re | ||||
| import shutil | ||||
| import zlib | ||||
| import websocket | ||||
| import requests | ||||
| import random | ||||
| import base64 | ||||
| import glob | ||||
|  | ||||
| downloader = Downloader() | ||||
| logger = get_logger(__name__) | ||||
| @@ -40,14 +45,14 @@ def safe_vdf_load(path: str | Path) -> dict: | ||||
|  | ||||
| def decode_text(text: str) -> str: | ||||
|     """ | ||||
|     Декодирует HTML-сущности в строке. | ||||
|     Например, "&quot;" преобразуется в '"'. | ||||
|     Остальные символы и HTML-теги остаются без изменений. | ||||
|     Decodes HTML entities in a string. | ||||
|     For example, "&quot;" is converted to '"'. | ||||
|     Other characters and HTML tags remain unchanged. | ||||
|     """ | ||||
|     return html.unescape(text) | ||||
|  | ||||
| def get_cache_dir(): | ||||
|     """Возвращает путь к каталогу кэша, создаёт его при необходимости.""" | ||||
|     """Returns the path to the cache directory, creating it if necessary.""" | ||||
|     xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) | ||||
|     cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") | ||||
|     os.makedirs(cache_dir, exist_ok=True) | ||||
| @@ -60,7 +65,7 @@ STEAM_DATA_DIRS = ( | ||||
| ) | ||||
|  | ||||
| def get_steam_home(): | ||||
|     """Возвращает путь к директории Steam, используя список возможных директорий.""" | ||||
|     """Returns the path to the Steam directory using a list of possible directories.""" | ||||
|     for dir_path in STEAM_DATA_DIRS: | ||||
|         expanded_path = Path(os.path.expanduser(dir_path)) | ||||
|         if expanded_path.exists(): | ||||
| @@ -68,7 +73,7 @@ def get_steam_home(): | ||||
|     return None | ||||
|  | ||||
| def get_last_steam_user(steam_home: Path) -> dict | None: | ||||
|     """Возвращает данные последнего пользователя Steam из loginusers.vdf.""" | ||||
|     """Returns data for the last Steam user from loginusers.vdf.""" | ||||
|     loginusers_path = steam_home / "config/loginusers.vdf" | ||||
|     data = safe_vdf_load(loginusers_path) | ||||
|     if not data: | ||||
| @@ -79,20 +84,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None: | ||||
|             try: | ||||
|                 return {'SteamID': int(user_id)} | ||||
|             except ValueError: | ||||
|                 logger.error(f"Неверный формат SteamID: {user_id}") | ||||
|                 logger.error(f"Invalid SteamID format: {user_id}") | ||||
|                 return None | ||||
|     logger.info("Не найден пользователь с MostRecent=1") | ||||
|     logger.info("No user found with MostRecent=1") | ||||
|     return None | ||||
|  | ||||
| def convert_steam_id(steam_id: int) -> int: | ||||
|     """ | ||||
|     Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число. | ||||
|     Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения. | ||||
|     Converts a signed 32-bit integer to an unsigned 32-bit integer. | ||||
|     Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values. | ||||
|     """ | ||||
|     return steam_id & 0xFFFFFFFF | ||||
|  | ||||
| def get_steam_libs(steam_dir: Path) -> set[Path]: | ||||
|     """Возвращает набор директорий Steam libraryfolders.""" | ||||
|     """Returns a set of Steam library folders.""" | ||||
|     libs = set() | ||||
|     libs_vdf = steam_dir / "steamapps/libraryfolders.vdf" | ||||
|     data = safe_vdf_load(libs_vdf) | ||||
| @@ -108,7 +113,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]: | ||||
|     return libs | ||||
|  | ||||
| def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]: | ||||
|     """Возвращает данные о времени игры для последнего пользователя.""" | ||||
|     """Returns playtime data for the last user.""" | ||||
|     play_data: dict[int, tuple[int, int]] = {} | ||||
|     if steam_home is None: | ||||
|         steam_home = get_steam_home() | ||||
| @@ -128,14 +133,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in | ||||
|         return play_data | ||||
|  | ||||
|     if not last_user: | ||||
|         logger.info("Не удалось определить последнего пользователя Steam") | ||||
|         logger.info("Could not identify the last Steam user") | ||||
|         return play_data | ||||
|  | ||||
|     user_id = last_user['SteamID'] | ||||
|     unsigned_id = convert_steam_id(user_id) | ||||
|     user_dir = userdata_dir / str(unsigned_id) | ||||
|     if not user_dir.exists(): | ||||
|         logger.info(f"Директория пользователя {unsigned_id} не найдена") | ||||
|         logger.info(f"User directory {unsigned_id} not found") | ||||
|         return play_data | ||||
|  | ||||
|     localconfig = user_dir / "config/localconfig.vdf" | ||||
| @@ -149,11 +154,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in | ||||
|             playtime = int(info.get('Playtime', 0)) | ||||
|             play_data[appid] = (last_played, playtime) | ||||
|         except ValueError: | ||||
|             logger.warning(f"Некорректные данные playtime для app {appid_str}") | ||||
|             logger.warning(f"Invalid playtime data for app {appid_str}") | ||||
|     return play_data | ||||
|  | ||||
| def get_steam_installed_games() -> list[tuple[str, int, int, int]]: | ||||
|     """Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec).""" | ||||
|     """Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec).""" | ||||
|     games: list[tuple[str, int, int, int]] = [] | ||||
|     steam_home = get_steam_home() | ||||
|     if steam_home is None or not steam_home.exists(): | ||||
| @@ -182,13 +187,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]: | ||||
|  | ||||
| def normalize_name(s): | ||||
|     """ | ||||
|     Приведение строки к нормальному виду: | ||||
|       - перевод в нижний регистр, | ||||
|       - удаление символов ™ и ®, | ||||
|       - замена разделителей (-, :, ,) на пробел, | ||||
|       - удаление лишних пробелов, | ||||
|       - удаление суффиксов 'bin' или 'app' в конце строки, | ||||
|       - удаление ключевых слов типа 'ultimate', 'edition' и т.п. | ||||
|     Normalizes a string by: | ||||
|       - converting to lowercase, | ||||
|       - removing ™ and ® symbols, | ||||
|       - replacing separators (-, :, ,) with spaces, | ||||
|       - removing extra spaces, | ||||
|       - removing 'bin' or 'app' suffixes, | ||||
|       - removing keywords like 'ultimate', 'edition', etc. | ||||
|     """ | ||||
|     s = s.lower() | ||||
|     for ch in ["™", "®"]: | ||||
| @@ -206,14 +211,28 @@ def normalize_name(s): | ||||
|  | ||||
| def is_valid_candidate(candidate): | ||||
|     """ | ||||
|     Проверяет, содержит ли кандидат запрещённые подстроки: | ||||
|       - win32 | ||||
|       - win64 | ||||
|       - gamelauncher | ||||
|     Для проверки дополнительно используется строка без пробелов. | ||||
|     Возвращает True, если кандидат допустим, иначе False. | ||||
|     Determines whether a given candidate string is valid for use as a game name. | ||||
|  | ||||
|     The function performs the following checks: | ||||
|       1. Normalizes the candidate using `normalize_name()`. | ||||
|       2. Rejects the candidate if the normalized name is exactly "game" | ||||
|          (to avoid overly generic names). | ||||
|       3. Removes spaces and checks for forbidden substrings: | ||||
|          - "win32" | ||||
|          - "win64" | ||||
|          - "gamelauncher" | ||||
|          These are checked in the space-free version of the string. | ||||
|       4. Returns True only if none of the forbidden conditions are met. | ||||
|  | ||||
|     Args: | ||||
|         candidate (str): The candidate string to validate. | ||||
|  | ||||
|     Returns: | ||||
|         bool: True if the candidate is valid, False otherwise. | ||||
|     """ | ||||
|     normalized_candidate = normalize_name(candidate) | ||||
|     if normalized_candidate == "game": | ||||
|         return False | ||||
|     normalized_no_space = normalized_candidate.replace(" ", "") | ||||
|     forbidden = ["win32", "win64", "gamelauncher"] | ||||
|     for token in forbidden: | ||||
| @@ -223,7 +242,7 @@ def is_valid_candidate(candidate): | ||||
|  | ||||
| def filter_candidates(candidates): | ||||
|     """ | ||||
|     Фильтрует список кандидатов, отбрасывая недопустимые. | ||||
|     Filters a list of candidates, discarding invalid ones. | ||||
|     """ | ||||
|     valid = [] | ||||
|     dropped = [] | ||||
| @@ -233,18 +252,18 @@ def filter_candidates(candidates): | ||||
|         else: | ||||
|             dropped.append(cand) | ||||
|     if dropped: | ||||
|         logger.info("Отбрасываю кандидатов: %s", dropped) | ||||
|         logger.info("Discarding candidates: %s", dropped) | ||||
|     return valid | ||||
|  | ||||
| def remove_duplicates(candidates): | ||||
|     """ | ||||
|     Удаляет дубликаты из списка, сохраняя порядок. | ||||
|     Removes duplicates from a list while preserving order. | ||||
|     """ | ||||
|     return list(dict.fromkeys(candidates)) | ||||
|  | ||||
| @functools.lru_cache(maxsize=256) | ||||
| def get_exiftool_data(game_exe): | ||||
|     """Получает метаданные через exiftool""" | ||||
|     """Retrieves metadata using exiftool.""" | ||||
|     try: | ||||
|         proc = subprocess.run( | ||||
|             ["exiftool", "-j", game_exe], | ||||
| @@ -253,18 +272,28 @@ def get_exiftool_data(game_exe): | ||||
|             check=False | ||||
|         ) | ||||
|         if proc.returncode != 0: | ||||
|             logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}") | ||||
|             logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}") | ||||
|             return {} | ||||
|         meta_data_list = orjson.loads(proc.stdout.encode("utf-8")) | ||||
|         return meta_data_list[0] if meta_data_list else {} | ||||
|     except Exception as e: | ||||
|         logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}") | ||||
|         logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}") | ||||
|         return {} | ||||
|  | ||||
| def delete_cached_app_files(cache_dir: str, pattern: str): | ||||
|     """Deletes cached files matching the given pattern in the cache directory.""" | ||||
|     try: | ||||
|         for file_path in glob.glob(os.path.join(cache_dir, pattern)): | ||||
|             os.remove(file_path) | ||||
|             logger.info(f"Deleted cached file: {file_path}") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to delete cached files matching {pattern}: {e}") | ||||
|  | ||||
| def load_steam_apps_async(callback: Callable[[list], None]): | ||||
|     """ | ||||
|     Asynchronously loads the list of Steam applications, using cache if available. | ||||
|     Calls the callback with the list of apps. | ||||
|     Deletes cached app detail files when downloading a new steam_apps.json. | ||||
|     """ | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_tar = os.path.join(cache_dir, "games_appid.tar.xz") | ||||
| @@ -290,12 +319,14 @@ def load_steam_apps_async(callback: Callable[[list], None]): | ||||
|                 f.write(orjson.dumps(data)) | ||||
|             if os.path.exists(cache_tar): | ||||
|                 os.remove(cache_tar) | ||||
|                 logger.info("Archive %s deleted after extraction", cache_tar) | ||||
|             steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or [] | ||||
|                 logger.info("Deleted archive: %s", cache_tar) | ||||
|             # Delete all cached app detail files (steam_app_*.json) | ||||
|             delete_cached_app_files(cache_dir, "steam_app_*.json") | ||||
|             steam_apps = data if isinstance(data, list) else [] | ||||
|             logger.info("Loaded %d apps from archive", len(steam_apps)) | ||||
|             callback(steam_apps) | ||||
|         except Exception as e: | ||||
|             logger.error("Error extracting Steam apps archive: %s", e) | ||||
|             logger.error("Failed to extract Steam apps archive: %s", e) | ||||
|             callback([]) | ||||
|  | ||||
|     if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): | ||||
| @@ -303,26 +334,43 @@ def load_steam_apps_async(callback: Callable[[list], None]): | ||||
|         try: | ||||
|             with open(cache_json, "rb") as f: | ||||
|                 data = orjson.loads(f.read()) | ||||
|             steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or [] | ||||
|             # Validate JSON structure | ||||
|             if not isinstance(data, list): | ||||
|                 logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json) | ||||
|                 raise ValueError("Invalid JSON structure") | ||||
|             # Validate each app entry | ||||
|             for app in data: | ||||
|                 if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app: | ||||
|                     logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json) | ||||
|                     raise ValueError("Invalid app entry structure") | ||||
|             steam_apps = data | ||||
|             logger.info("Loaded %d apps from cache", len(steam_apps)) | ||||
|             callback(steam_apps) | ||||
|         except Exception as e: | ||||
|             logger.error("Error reading cached JSON: %s", e) | ||||
|             callback([]) | ||||
|             logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e) | ||||
|             # Attempt to re-download if cache is invalid or corrupted | ||||
|             app_list_url = ( | ||||
|                 "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" | ||||
|             ) | ||||
|             # Delete cached app detail files before re-downloading | ||||
|             delete_cached_app_files(cache_dir, "steam_app_*.json") | ||||
|             downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) | ||||
|     else: | ||||
|         app_list_url = ( | ||||
|             "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" | ||||
|         ) | ||||
|         # Delete cached app detail files before downloading | ||||
|         delete_cached_app_files(cache_dir, "steam_app_*.json") | ||||
|         downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) | ||||
|  | ||||
| def build_index(steam_apps): | ||||
|     """ | ||||
|     Строит индекс приложений по полю normalized_name. | ||||
|     Builds an index of applications by normalized_name field. | ||||
|     """ | ||||
|     steam_apps_index = {} | ||||
|     if not steam_apps: | ||||
|         return steam_apps_index | ||||
|     logger.info("Построение индекса Steam приложений:") | ||||
|     logger.info("Building Steam apps index") | ||||
|     for app in steam_apps: | ||||
|         normalized = app["normalized_name"] | ||||
|         steam_apps_index[normalized] = app | ||||
| @@ -330,25 +378,24 @@ def build_index(steam_apps): | ||||
|  | ||||
| def search_app(candidate, steam_apps_index): | ||||
|     """ | ||||
|     Ищет приложение по кандидату: сначала пытается точное совпадение, затем ищет подстроку. | ||||
|     Searches for an application by candidate: tries exact match first, then substring match. | ||||
|     """ | ||||
|     candidate_norm = normalize_name(candidate) | ||||
|     logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm) | ||||
|     logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm) | ||||
|     if candidate_norm in steam_apps_index: | ||||
|         logger.info("    Найдено точное совпадение: '%s'", candidate_norm) | ||||
|         logger.info("Found exact match: '%s'", candidate_norm) | ||||
|         return steam_apps_index[candidate_norm] | ||||
|     for name_norm, app in steam_apps_index.items(): | ||||
|         if candidate_norm in name_norm: | ||||
|             ratio = len(candidate_norm) / len(name_norm) | ||||
|             if ratio > 0.8: | ||||
|                 logger.info("    Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)", | ||||
|                             candidate_norm, name_norm, ratio) | ||||
|                 logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio) | ||||
|                 return app | ||||
|     logger.info("    Приложение для кандидата '%s' не найдено", candidate_norm) | ||||
|     logger.info("No app found for candidate '%s'", candidate_norm) | ||||
|     return None | ||||
|  | ||||
| def load_app_details(app_id): | ||||
|     """Загружает кэшированные данные для игры по appid, если они не устарели.""" | ||||
|     """Loads cached game data by appid if not outdated.""" | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") | ||||
|     if os.path.exists(cache_file): | ||||
| @@ -358,7 +405,7 @@ def load_app_details(app_id): | ||||
|     return None | ||||
|  | ||||
| def save_app_details(app_id, data): | ||||
|     """Сохраняет данные по appid в файл кэша.""" | ||||
|     """Saves appid data to a cache file.""" | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") | ||||
|     with open(cache_file, "wb") as f: | ||||
| @@ -401,7 +448,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]): | ||||
|             save_app_details(app_id, app_data) | ||||
|             callback(app_data) | ||||
|         except Exception as e: | ||||
|             logger.error("Error processing Steam app info for appid %s: %s", app_id, e) | ||||
|             logger.error("Failed to process Steam app info for appid %s: %s", app_id, e) | ||||
|             callback(None) | ||||
|  | ||||
|     downloader.download_async(url, cache_file, timeout=5, callback=process_response) | ||||
| @@ -410,6 +457,7 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]): | ||||
|     """ | ||||
|     Asynchronously loads the list of WeAntiCheatYet data, using cache if available. | ||||
|     Calls the callback with the list of anti-cheat data. | ||||
|     Deletes cached anti-cheat files when downloading a new anticheat_games.json. | ||||
|     """ | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz") | ||||
| @@ -435,12 +483,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]): | ||||
|                 f.write(orjson.dumps(data)) | ||||
|             if os.path.exists(cache_tar): | ||||
|                 os.remove(cache_tar) | ||||
|                 logger.info("Archive %s deleted after extraction", cache_tar) | ||||
|                 logger.info("Deleted archive: %s", cache_tar) | ||||
|             anti_cheat_data = data or [] | ||||
|             logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data)) | ||||
|             callback(anti_cheat_data) | ||||
|         except Exception as e: | ||||
|             logger.error("Error extracting WeAntiCheatYet archive: %s", e) | ||||
|             logger.error("Failed to extract WeAntiCheatYet archive: %s", e) | ||||
|             callback([]) | ||||
|  | ||||
|     if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): | ||||
| @@ -448,12 +496,25 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]): | ||||
|         try: | ||||
|             with open(cache_json, "rb") as f: | ||||
|                 data = orjson.loads(f.read()) | ||||
|             anti_cheat_data = data or [] | ||||
|             # Validate JSON structure | ||||
|             if not isinstance(data, list): | ||||
|                 logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json) | ||||
|                 raise ValueError("Invalid JSON structure") | ||||
|             # Validate each anti-cheat entry | ||||
|             for entry in data: | ||||
|                 if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry: | ||||
|                     logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json) | ||||
|                     raise ValueError("Invalid anti-cheat entry structure") | ||||
|             anti_cheat_data = data | ||||
|             logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data)) | ||||
|             callback(anti_cheat_data) | ||||
|         except Exception as e: | ||||
|             logger.error("Error reading cached WeAntiCheatYet JSON: %s", e) | ||||
|             callback([]) | ||||
|             logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e) | ||||
|             # Attempt to re-download if cache is invalid or corrupted | ||||
|             app_list_url = ( | ||||
|                 "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz" | ||||
|             ) | ||||
|             downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) | ||||
|     else: | ||||
|         app_list_url = ( | ||||
|             "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz" | ||||
| @@ -462,12 +523,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]): | ||||
|  | ||||
| def build_weanticheatyet_index(anti_cheat_data): | ||||
|     """ | ||||
|     Строит индекс античит-данных по полю normalized_name. | ||||
|     Builds an index of anti-cheat data by normalized_name field. | ||||
|     """ | ||||
|     anti_cheat_index = {} | ||||
|     if not anti_cheat_data: | ||||
|         return anti_cheat_index | ||||
|     logger.info("Построение индекса WeAntiCheatYet данных:") | ||||
|     logger.info("Building WeAntiCheatYet data index") | ||||
|     for entry in anti_cheat_data: | ||||
|         normalized = entry["normalized_name"] | ||||
|         anti_cheat_index[normalized] = entry | ||||
| @@ -475,20 +536,19 @@ def build_weanticheatyet_index(anti_cheat_data): | ||||
|  | ||||
| def search_anticheat_status(candidate, anti_cheat_index): | ||||
|     candidate_norm = normalize_name(candidate) | ||||
|     logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm) | ||||
|     logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm) | ||||
|     if candidate_norm in anti_cheat_index: | ||||
|         status = anti_cheat_index[candidate_norm]["status"] | ||||
|         logger.info("    Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status) | ||||
|         logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status) | ||||
|         return status | ||||
|     for name_norm, entry in anti_cheat_index.items(): | ||||
|         if candidate_norm in name_norm: | ||||
|             ratio = len(candidate_norm) / len(name_norm) | ||||
|             if ratio > 0.8: | ||||
|                 status = entry["status"] | ||||
|                 logger.info("    Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'", | ||||
|                             candidate_norm, name_norm, ratio, status) | ||||
|                 logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status) | ||||
|                 return status | ||||
|     logger.info("    Античит-статус для кандидата '%s' не найден", candidate_norm) | ||||
|     logger.info("No anti-cheat status found for candidate '%s'", candidate_norm) | ||||
|     return "" | ||||
|  | ||||
| def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]): | ||||
| @@ -504,7 +564,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No | ||||
|     load_weanticheatyet_data_async(on_anticheat_data) | ||||
|  | ||||
| def load_protondb_status(appid): | ||||
|     """Загружает закешированные данные ProtonDB для игры по appid, если они не устарели.""" | ||||
|     """Loads cached ProtonDB data for a game by appid if not outdated.""" | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") | ||||
|     if os.path.exists(cache_file): | ||||
| @@ -513,18 +573,18 @@ def load_protondb_status(appid): | ||||
|                 with open(cache_file, "rb") as f: | ||||
|                     return orjson.loads(f.read()) | ||||
|             except Exception as e: | ||||
|                 logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e) | ||||
|                 logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e) | ||||
|     return None | ||||
|  | ||||
| def save_protondb_status(appid, data): | ||||
|     """Сохраняет данные ProtonDB для игры по appid в файл кэша.""" | ||||
|     """Saves ProtonDB data for a game by appid to a cache file.""" | ||||
|     cache_dir = get_cache_dir() | ||||
|     cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") | ||||
|     try: | ||||
|         with open(cache_file, "wb") as f: | ||||
|             f.write(orjson.dumps(data)) | ||||
|     except Exception as e: | ||||
|         logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e) | ||||
|         logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e) | ||||
|  | ||||
| def get_protondb_tier_async(appid: int, callback: Callable[[str], None]): | ||||
|     """ | ||||
| @@ -612,7 +672,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla | ||||
|                         if game_exe.lower().endswith('.exe'): | ||||
|                             break | ||||
|             except Exception as e: | ||||
|                 logger.error("Error processing bat file %s: %s", game_exe, e) | ||||
|                 logger.error("Failed to process bat file %s: %s", game_exe, e) | ||||
|         else: | ||||
|             logger.error("Bat file not found: %s", game_exe) | ||||
|  | ||||
| @@ -745,6 +805,126 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None] | ||||
|  | ||||
|     load_steam_apps_async(on_steam_apps) | ||||
|  | ||||
| def enable_steam_cef() -> tuple[bool, str]: | ||||
|     """ | ||||
|     Checks and enables Steam CEF remote debugging if necessary. | ||||
|  | ||||
|     Creates a .cef-enable-remote-debugging file in the Steam directory. | ||||
|     Steam must be restarted after the file is first created. | ||||
|  | ||||
|     Returns a tuple: | ||||
|     - (True, "already_enabled") if already enabled. | ||||
|     - (True, "restart_needed") if just enabled and Steam restart is needed. | ||||
|     - (False, "steam_not_found") if Steam directory is not found. | ||||
|     """ | ||||
|     steam_home = get_steam_home() | ||||
|     if not steam_home: | ||||
|         return (False, "steam_not_found") | ||||
|  | ||||
|     cef_flag_file = steam_home / ".cef-enable-remote-debugging" | ||||
|     logger.info(f"Checking CEF flag: {cef_flag_file}") | ||||
|  | ||||
|     if cef_flag_file.exists(): | ||||
|         logger.info("CEF Remote Debugging is already enabled") | ||||
|         return (True, "already_enabled") | ||||
|     else: | ||||
|         try: | ||||
|             os.makedirs(cef_flag_file.parent, exist_ok=True) | ||||
|             cef_flag_file.touch() | ||||
|             logger.info("Enabled CEF Remote Debugging. Steam restart required") | ||||
|             return (True, "restart_needed") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}") | ||||
|             return (False, str(e)) | ||||
|  | ||||
| def call_steam_api(js_cmd: str, *args) -> dict | None: | ||||
|     """ | ||||
|     Executes a JavaScript function in the Steam context via CEF Remote Debugging. | ||||
|  | ||||
|     Args: | ||||
|         js_cmd: Name of the JS function to call (e.g., 'createShortcut'). | ||||
|         *args: Arguments to pass to the JS function. | ||||
|  | ||||
|     Returns: | ||||
|         Dictionary with the result or None if an error occurs. | ||||
|     """ | ||||
|     status, message = enable_steam_cef() | ||||
|     if not (status is True and message == "already_enabled"): | ||||
|         if message == "restart_needed": | ||||
|             logger.warning("Steam CEF API is available but requires Steam restart for full activation") | ||||
|         elif message == "steam_not_found": | ||||
|             logger.error("Could not find Steam directory to check CEF API") | ||||
|         else: | ||||
|             logger.error(f"Steam CEF API is unavailable or not ready: {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 not found. Is Steam running with -cef-enable-remote-debugging?") | ||||
|             return None | ||||
|     except Exception as e: | ||||
|         logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {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"JavaScript execution error in Steam: {response_data['error']['message']}") | ||||
|             return None | ||||
|         result = response_data.get('result', {}).get('result', {}) | ||||
|         if result.get('type') == 'object' and result.get('subtype') == 'error': | ||||
|             logger.error(f"JavaScript execution error in Steam: {result.get('description')}") | ||||
|             return None | ||||
|         return result.get('value') | ||||
|     except Exception as e: | ||||
|         logger.error(f"WebSocket interaction error with Steam: {e}") | ||||
|         return None | ||||
|  | ||||
| def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]: | ||||
|     """ | ||||
|     Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag, | ||||
| @@ -819,24 +999,24 @@ export START_FROM_STEAM=1 | ||||
|         else: | ||||
|             success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True) | ||||
|             if not success or not os.path.exists(generated_icon_path): | ||||
|                 logger.warning(f"generate_thumbnail failed to create icon for {exe_path}") | ||||
|                 logger.warning(f"Failed to generate thumbnail for {exe_path}") | ||||
|                 icon_path = "" | ||||
|             else: | ||||
|                 logger.info(f"Generated thumbnail: {generated_icon_path}") | ||||
|         icon_path = generated_icon_path | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error generating thumbnail for {exe_path}: {e}") | ||||
|         logger.error(f"Failed to generate thumbnail for {exe_path}: {e}") | ||||
|         icon_path = "" | ||||
|  | ||||
|     steam_home = get_steam_home() | ||||
|     if not steam_home: | ||||
|         logger.error("Steam home directory not found") | ||||
|         return (False, "Steam directory not found.") | ||||
|         return (False, "Steam directory not found") | ||||
|  | ||||
|     last_user = get_last_steam_user(steam_home) | ||||
|     if not last_user or 'SteamID' not in last_user: | ||||
|         logger.error("Failed to retrieve Steam user ID") | ||||
|         return (False, "Failed to get Steam user ID.") | ||||
|         return (False, "Failed to get Steam user ID") | ||||
|  | ||||
|     userdata_dir = steam_home / "userdata" | ||||
|     user_id = last_user['SteamID'] | ||||
| @@ -846,45 +1026,42 @@ export START_FROM_STEAM=1 | ||||
|     grid_dir = user_dir / "config" / "grid" | ||||
|     os.makedirs(grid_dir, exist_ok=True) | ||||
|  | ||||
|     backup_path = f"{steam_shortcuts_path}.backup" | ||||
|     if os.path.exists(steam_shortcuts_path): | ||||
|         try: | ||||
|             shutil.copy2(steam_shortcuts_path, backup_path) | ||||
|             logger.info(f"Created backup of shortcuts.vdf at {backup_path}") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|             return (False, f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|     appid = None | ||||
|     was_api_used = False | ||||
|  | ||||
|     unique_string = f"{script_path}{game_name}" | ||||
|     baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff | ||||
|     appid = baseid | 0x80000000 | ||||
|     if appid > 0x7FFFFFFF: | ||||
|         aidvdf = appid - 0x100000000 | ||||
|     logger.info("Attempting to add shortcut via Steam CEF API") | ||||
|     api_response = call_steam_api( | ||||
|         "createShortcut", | ||||
|         game_name, | ||||
|         script_path, | ||||
|         str(Path(script_path).parent), | ||||
|         icon_path, | ||||
|         "" | ||||
|     ) | ||||
|  | ||||
|     if api_response and isinstance(api_response, dict) and 'id' in api_response: | ||||
|         appid = api_response['id'] | ||||
|         was_api_used = True | ||||
|         logger.info(f"Shortcut successfully added via API. AppID: {appid}") | ||||
|     else: | ||||
|         aidvdf = appid | ||||
|         logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf") | ||||
|         backup_path = f"{steam_shortcuts_path}.backup" | ||||
|         if os.path.exists(steam_shortcuts_path): | ||||
|             try: | ||||
|                 shutil.copy2(steam_shortcuts_path, backup_path) | ||||
|                 logger.info(f"Created backup of shortcuts.vdf at {backup_path}") | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|                 return (False, f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|  | ||||
|     steam_appid = None | ||||
|     downloaded_count = 0 | ||||
|     total_covers = 4  # количество обложек | ||||
|         unique_string = f"{script_path}{game_name}" | ||||
|         baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff | ||||
|         appid = baseid | 0x80000000 | ||||
|         if appid > 0x7FFFFFFF: | ||||
|             aidvdf = appid - 0x100000000 | ||||
|         else: | ||||
|             aidvdf = appid | ||||
|  | ||||
|     download_lock = threading.Lock() | ||||
|  | ||||
|     def on_cover_download(cover_file: str, cover_type: str): | ||||
|         nonlocal downloaded_count | ||||
|         try: | ||||
|             if cover_file and os.path.exists(cover_file): | ||||
|                 logger.info(f"Downloaded cover {cover_type} to {cover_file}") | ||||
|             else: | ||||
|                 logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}") | ||||
|         with download_lock: | ||||
|             downloaded_count += 1 | ||||
|             if downloaded_count == total_covers: | ||||
|                 finalize_shortcut() | ||||
|  | ||||
|     def finalize_shortcut(): | ||||
|         tags_dict = {'0': 'PortProton'} | ||||
|         shortcut = { | ||||
|             "appid": aidvdf, | ||||
|             "AppName": game_name, | ||||
| @@ -899,7 +1076,7 @@ export START_FROM_STEAM=1 | ||||
|             "Devkit": 0, | ||||
|             "DevkitGameID": "", | ||||
|             "LastPlayTime": 0, | ||||
|             "tags": tags_dict | ||||
|             "tags": {'0': 'PortProton'} | ||||
|         } | ||||
|         logger.info(f"Shortcut entry to be written: {shortcut}") | ||||
|  | ||||
| @@ -929,6 +1106,7 @@ export START_FROM_STEAM=1 | ||||
|  | ||||
|             with open(steam_shortcuts_path, 'wb') as f: | ||||
|                 vdf.binary_dump({"shortcuts": shortcuts}, f) | ||||
|             logger.info(f"Game '{game_name}' successfully added to Steam with covers") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to update shortcuts.vdf: {e}") | ||||
|             if os.path.exists(backup_path): | ||||
| @@ -937,34 +1115,54 @@ export START_FROM_STEAM=1 | ||||
|                     logger.info("Restored shortcuts.vdf from backup due to update failure") | ||||
|                 except Exception as restore_err: | ||||
|                     logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}") | ||||
|             return (False, f"Failed to update shortcuts.vdf: {e}") | ||||
|             appid = None | ||||
|  | ||||
|         logger.info(f"Game '{game_name}' successfully added to Steam with covers") | ||||
|         return (True, f"Game '{game_name}' added to Steam with covers") | ||||
|     if not appid: | ||||
|         return (False, "Failed to create shortcut using any method") | ||||
|  | ||||
|     steam_appid = None | ||||
|  | ||||
|     def on_game_info(game_info: dict): | ||||
|         nonlocal steam_appid | ||||
|         steam_appid = game_info.get("appid") | ||||
|         if not steam_appid or not isinstance(steam_appid, int): | ||||
|             logger.info("No valid Steam appid found, skipping cover download") | ||||
|             return finalize_shortcut() | ||||
|             return | ||||
|         logger.info(f"Found Steam AppID {steam_appid} for cover download") | ||||
|  | ||||
|         # Обложки и имена, соответствующие bash-скрипту и твоим размерам | ||||
|         cover_types = [ | ||||
|             (".jpg", "header.jpg"),              # базовый, сохранится как AppId.jpg | ||||
|             ("p.jpg", "library_600x900_2x.jpg"), # сохранится как AppIdp.jpg | ||||
|             ("_hero.jpg", "library_hero.jpg"),   # AppId_hero.jpg | ||||
|             ("_logo.png", "logo.png")            # AppId_logo.png | ||||
|             ("p.jpg", "library_600x900_2x.jpg"), | ||||
|             ("_hero.jpg", "library_hero.jpg"), | ||||
|             ("_logo.png", "logo.png"), | ||||
|             (".jpg", "header.jpg") | ||||
|         ] | ||||
|  | ||||
|         for suffix, cover_type in cover_types: | ||||
|         def on_cover_download(result_path: str | None, steam_name: str, index: int): | ||||
|             try: | ||||
|                 if result_path and os.path.exists(result_path): | ||||
|                     logger.info(f"Downloaded cover {steam_name} to {result_path}") | ||||
|                     if was_api_used: | ||||
|                         try: | ||||
|                             with open(result_path, 'rb') as f: | ||||
|                                 img_b64 = base64.b64encode(f.read()).decode('utf-8') | ||||
|                             logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}") | ||||
|                             ext = Path(steam_name).suffix.lstrip('.') | ||||
|                             call_steam_api("setGrid", appid, index, ext, img_b64) | ||||
|                         except Exception as e: | ||||
|                             logger.error(f"Failed to apply cover '{steam_name}' via API: {e}") | ||||
|                 else: | ||||
|                     logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}") | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}") | ||||
|  | ||||
|         for i, (suffix, steam_name) in enumerate(cover_types): | ||||
|             cover_file = os.path.join(grid_dir, f"{appid}{suffix}") | ||||
|             cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}" | ||||
|             cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}" | ||||
|             downloader.download_async( | ||||
|                 cover_url, | ||||
|                 cover_file, | ||||
|                 timeout=5, | ||||
|                 callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype) | ||||
|                 callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index) | ||||
|             ) | ||||
|  | ||||
|     get_steam_game_info_async(game_name, exec_line, on_game_info) | ||||
| @@ -996,13 +1194,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]: | ||||
|     steam_home = get_steam_home() | ||||
|     if not steam_home: | ||||
|         logger.error("Steam home directory not found") | ||||
|         return (False, "Steam directory not found.") | ||||
|         return (False, "Steam directory not found") | ||||
|  | ||||
|     # Get current Steam user ID | ||||
|     last_user = get_last_steam_user(steam_home) | ||||
|     if not last_user or 'SteamID' not in last_user: | ||||
|         logger.error("Failed to retrieve Steam user ID") | ||||
|         return (False, "Failed to get Steam user ID.") | ||||
|         return (False, "Failed to get Steam user ID") | ||||
|     userdata_dir = steam_home / "userdata" | ||||
|     user_id = last_user['SteamID'] | ||||
|     unsigned_id = convert_steam_id(user_id) | ||||
| @@ -1017,19 +1215,7 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]: | ||||
|         logger.info(f"shortcuts.vdf not found at {steam_shortcuts_path}") | ||||
|         return (False, f"Game '{game_name}' not found in Steam") | ||||
|  | ||||
|     # Generate appid for identifying cover files | ||||
|     unique_string = f"{script_path}{game_name}" | ||||
|     baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff | ||||
|     appid = baseid | 0x80000000 | ||||
|  | ||||
|     # Create backup of shortcuts.vdf | ||||
|     backup_path = f"{steam_shortcuts_path}.backup" | ||||
|     try: | ||||
|         shutil.copy2(steam_shortcuts_path, backup_path) | ||||
|         logger.info(f"Created backup of shortcuts.vdf at {backup_path}") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|         return (False, f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|     appid = None | ||||
|  | ||||
|     # Load and modify shortcuts.vdf | ||||
|     try: | ||||
| @@ -1043,37 +1229,51 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]: | ||||
|         return (False, f"Failed to load shortcuts.vdf: {load_err}") | ||||
|  | ||||
|     shortcuts = shortcuts_data.get("shortcuts", {}) | ||||
|     found = False | ||||
|     new_shortcuts = {} | ||||
|     index = 0 | ||||
|  | ||||
|     # Filter out the matching shortcut | ||||
|     for _key, entry in shortcuts.items(): | ||||
|         if entry.get("AppName") == game_name and entry.get("Exe") == f'"{script_path}"': | ||||
|             found = True | ||||
|             appid = convert_steam_id(int(entry.get("appid"))) | ||||
|             logger.info(f"Found matching shortcut for '{game_name}' to remove") | ||||
|             continue | ||||
|         new_shortcuts[str(index)] = entry | ||||
|         index += 1 | ||||
|  | ||||
|     if not found: | ||||
|     if not appid: | ||||
|         logger.info(f"Game '{game_name}' not found in Steam shortcuts") | ||||
|         return (False, f"Game '{game_name}' not found in Steam") | ||||
|  | ||||
|     # Save updated shortcuts.vdf | ||||
|     try: | ||||
|         with open(steam_shortcuts_path, 'wb') as f: | ||||
|             vdf.binary_dump({"shortcuts": new_shortcuts}, f) | ||||
|         logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'") | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to update shortcuts.vdf: {e}") | ||||
|         if os.path.exists(backup_path): | ||||
|             try: | ||||
|                 shutil.copy2(backup_path, steam_shortcuts_path) | ||||
|                 logger.info("Restored shortcuts.vdf from backup due to update failure") | ||||
|             except Exception as restore_err: | ||||
|                 logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}") | ||||
|         return (False, f"Failed to update shortcuts.vdf: {e}") | ||||
|     api_response = call_steam_api("removeShortcut", appid) | ||||
|     if api_response is not None: # API responded, even if response is empty | ||||
|         logger.info(f"Shortcut for AppID {appid} successfully removed via API") | ||||
|     else: | ||||
|         logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf") | ||||
|  | ||||
|         # Create backup of shortcuts.vdf | ||||
|         backup_path = f"{steam_shortcuts_path}.backup" | ||||
|         try: | ||||
|             shutil.copy2(steam_shortcuts_path, backup_path) | ||||
|             logger.info(f"Created backup of shortcuts.vdf at {backup_path}") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|             return (False, f"Failed to create backup of shortcuts.vdf: {e}") | ||||
|  | ||||
|         # Save updated shortcuts.vdf | ||||
|         try: | ||||
|             with open(steam_shortcuts_path, 'wb') as f: | ||||
|                 vdf.binary_dump({"shortcuts": new_shortcuts}, f) | ||||
|             logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'") | ||||
|         except Exception as e: | ||||
|             logger.error(f"Failed to update shortcuts.vdf: {e}") | ||||
|             if os.path.exists(backup_path): | ||||
|                 try: | ||||
|                     shutil.copy2(backup_path, steam_shortcuts_path) | ||||
|                     logger.info("Restored shortcuts.vdf from backup due to update failure") | ||||
|                 except Exception as restore_err: | ||||
|                     logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}") | ||||
|             return (False, f"Failed to update shortcuts.vdf: {e}") | ||||
|  | ||||
|     # Delete cover files | ||||
|     cover_files = [ | ||||
| @@ -1128,5 +1328,5 @@ def is_game_in_steam(game_name: str) -> bool: | ||||
|             if entry.get("AppName") == game_name: | ||||
|                 return True | ||||
|     except Exception as e: | ||||
|         logger.error(f"Error checking if game {game_name} is in Steam: {e}") | ||||
|         logger.error(f"Failed to check if game {game_name} is in Steam: {e}") | ||||
|     return False | ||||
|   | ||||
| @@ -1,9 +1,8 @@ | ||||
| import importlib.util | ||||
| import os | ||||
| import ast | ||||
| from portprotonqt.logger import get_logger | ||||
| from PySide6.QtSvg import QSvgRenderer | ||||
| from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter | ||||
|  | ||||
| from PySide6.QtGui import QIcon, QFontDatabase, QPixmap | ||||
| from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo | ||||
|  | ||||
| logger = get_logger(__name__) | ||||
| @@ -14,6 +13,59 @@ THEMES_DIRS = [ | ||||
|     os.path.join(xdg_data_home, "PortProtonQt", "themes"), | ||||
|     os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") | ||||
| ] | ||||
| _loaded_theme = None | ||||
|  | ||||
| # Запрещенные модули и функции | ||||
| FORBIDDEN_MODULES = { | ||||
|     "os", | ||||
|     "subprocess", | ||||
|     "shutil", | ||||
|     "sys", | ||||
|     "socket", | ||||
|     "ctypes", | ||||
|     "pathlib", | ||||
|     "glob", | ||||
| } | ||||
| FORBIDDEN_FUNCTIONS = { | ||||
|     "exec", | ||||
|     "eval", | ||||
|     "open", | ||||
|     "__import__", | ||||
| } | ||||
|  | ||||
| def check_theme_safety(theme_file: str) -> bool: | ||||
|     """ | ||||
|     Проверяет файл темы на наличие запрещённых модулей и функций. | ||||
|     Возвращает True, если файл безопасен, иначе False. | ||||
|     """ | ||||
|     has_errors = False | ||||
|     try: | ||||
|         with open(theme_file) as f: | ||||
|             content = f.read() | ||||
|  | ||||
|             # Проверка на опасные импорты и функции | ||||
|             try: | ||||
|                 tree = ast.parse(content) | ||||
|                 for node in ast.walk(tree): | ||||
|                     # Проверка импортов | ||||
|                     if isinstance(node, ast.Import | ast.ImportFrom): | ||||
|                         for name in node.names: | ||||
|                             if name.name in FORBIDDEN_MODULES: | ||||
|                                 logger.error(f"Forbidden module '{name.name}' found in file {theme_file}") | ||||
|                                 has_errors = True | ||||
|                     # Проверка вызовов функций | ||||
|                     if isinstance(node, ast.Call): | ||||
|                         if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS: | ||||
|                             logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}") | ||||
|                             has_errors = True | ||||
|             except SyntaxError as e: | ||||
|                 logger.error(f"Syntax error in file {theme_file}: {e}") | ||||
|                 has_errors = True | ||||
|     except Exception as e: | ||||
|         logger.error(f"Failed to check theme safety for {theme_file}: {e}") | ||||
|         has_errors = True | ||||
|  | ||||
|     return not has_errors | ||||
|  | ||||
| def list_themes(): | ||||
|     """ | ||||
| @@ -49,9 +101,13 @@ def load_theme_screenshots(theme_name): | ||||
|  | ||||
| def load_theme_fonts(theme_name): | ||||
|     """ | ||||
|     Загружает все шрифты выбранной темы. | ||||
|     :param theme_name: Имя темы. | ||||
|     Загружает все шрифты выбранной темы, если они ещё не были загружены. | ||||
|     """ | ||||
|     global _loaded_theme | ||||
|     if _loaded_theme == theme_name: | ||||
|         logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping") | ||||
|         return | ||||
|  | ||||
|     QFontDatabase.removeAllApplicationFonts() | ||||
|     fonts_folder = None | ||||
|     if theme_name == "standart": | ||||
| @@ -66,7 +122,7 @@ def load_theme_fonts(theme_name): | ||||
|                 break | ||||
|  | ||||
|     if not fonts_folder or not os.path.exists(fonts_folder): | ||||
|         logger.error(f"Папка fonts не найдена для темы '{theme_name}'") | ||||
|         logger.error(f"Fonts folder not found for theme '{theme_name}'") | ||||
|         return | ||||
|  | ||||
|     for filename in os.listdir(fonts_folder): | ||||
| @@ -75,29 +131,11 @@ def load_theme_fonts(theme_name): | ||||
|             font_id = QFontDatabase.addApplicationFont(font_path) | ||||
|             if font_id != -1: | ||||
|                 families = QFontDatabase.applicationFontFamilies(font_id) | ||||
|                 logger.info(f"Шрифт {filename} успешно загружен: {families}") | ||||
|                 logger.info(f"Font {filename} successfully loaded: {families}") | ||||
|             else: | ||||
|                 logger.error(f"Ошибка загрузки шрифта: {filename}") | ||||
|                 logger.error(f"Error loading font: {filename}") | ||||
|  | ||||
| def load_logo(): | ||||
|     logo_path = None | ||||
|  | ||||
|     base_dir = os.path.dirname(os.path.abspath(__file__)) | ||||
|     logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg") | ||||
|  | ||||
|     file_extension = os.path.splitext(logo_path)[1].lower() | ||||
|  | ||||
|     if file_extension == ".svg": | ||||
|         renderer = QSvgRenderer(logo_path) | ||||
|         if not renderer.isValid(): | ||||
|             logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}") | ||||
|             return None | ||||
|         pixmap = QPixmap(128, 128) | ||||
|         pixmap.fill(QColor(0, 0, 0, 0)) | ||||
|         painter = QPainter(pixmap) | ||||
|         renderer.render(painter) | ||||
|         painter.end() | ||||
|         return pixmap | ||||
|     _loaded_theme = theme_name | ||||
|  | ||||
| class ThemeWrapper: | ||||
|     """ | ||||
| @@ -109,69 +147,83 @@ class ThemeWrapper: | ||||
|         self.custom_theme = custom_theme | ||||
|         self.metainfo = metainfo or {} | ||||
|         self.screenshots = load_theme_screenshots(self.metainfo.get("name", "")) | ||||
|         self._default_theme = None  # Lazy-loaded default theme | ||||
|  | ||||
|     def __getattr__(self, name): | ||||
|         if hasattr(self.custom_theme, name): | ||||
|             return getattr(self.custom_theme, name) | ||||
|         import portprotonqt.themes.standart.styles as default_styles | ||||
|         return getattr(default_styles, name) | ||||
|         if self._default_theme is None: | ||||
|             self._default_theme = load_theme("standart")  # Dynamically load standard theme | ||||
|         return getattr(self._default_theme, name) | ||||
|  | ||||
| def load_theme(theme_name): | ||||
|     """ | ||||
|     Динамически загружает модуль стилей выбранной темы и метаинформацию. | ||||
|     Если выбрана стандартная тема, импортируется оригинальный styles.py. | ||||
|     Все темы, включая стандартную, проходят проверку безопасности. | ||||
|     Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты. | ||||
|     """ | ||||
|     if theme_name == "standart": | ||||
|         import portprotonqt.themes.standart.styles as default_styles | ||||
|         return default_styles | ||||
|  | ||||
|     for themes_dir in THEMES_DIRS: | ||||
|         theme_folder = os.path.join(themes_dir, theme_name) | ||||
|         styles_file = os.path.join(theme_folder, "styles.py") | ||||
|         if os.path.exists(styles_file): | ||||
|             # Проверяем безопасность темы перед загрузкой | ||||
|             if not check_theme_safety(styles_file): | ||||
|                 logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'") | ||||
|                 raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions") | ||||
|  | ||||
|             spec = importlib.util.spec_from_file_location("theme_styles", styles_file) | ||||
|             if spec is None or spec.loader is None: | ||||
|                 continue | ||||
|             custom_theme = importlib.util.module_from_spec(spec) | ||||
|             spec.loader.exec_module(custom_theme) | ||||
|             if theme_name == "standart": | ||||
|                 return custom_theme | ||||
|             meta = load_theme_metainfo(theme_name) | ||||
|             wrapper = ThemeWrapper(custom_theme, metainfo=meta) | ||||
|             wrapper.screenshots = load_theme_screenshots(theme_name) | ||||
|             return wrapper | ||||
|     raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'") | ||||
|     raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'") | ||||
|  | ||||
| class ThemeManager: | ||||
|     """ | ||||
|     Класс для управления темами приложения. | ||||
|  | ||||
|     Позволяет получить список доступных тем, загрузить и применить выбранную тему. | ||||
|     Реализует паттерн Singleton для единого экземпляра. | ||||
|     """ | ||||
|     def __init__(self): | ||||
|         self.current_theme_name = None | ||||
|         self.current_theme_module = None | ||||
|     _instance = None | ||||
|  | ||||
|     def get_available_themes(self): | ||||
|     def __new__(cls): | ||||
|         if cls._instance is None: | ||||
|             cls._instance = super().__new__(cls) | ||||
|             cls._instance.current_theme_name = None | ||||
|             cls._instance.current_theme_module = None | ||||
|         return cls._instance | ||||
|  | ||||
|     def get_available_themes(self) -> list: | ||||
|         """Возвращает список доступных тем.""" | ||||
|         return list_themes() | ||||
|  | ||||
|     def get_theme_logo(self): | ||||
|         """Возвращает логотип для текущей или указанной темы.""" | ||||
|         return load_logo() | ||||
|     def apply_theme(self, theme_name: str): | ||||
|         """ | ||||
|         Применяет указанную тему, если она ещё не применена. | ||||
|         Возвращает модуль темы или обёртку. | ||||
|         """ | ||||
|         if self.current_theme_name == theme_name and self.current_theme_module is not None: | ||||
|             logger.debug(f"Theme '{theme_name}' is already applied, skipping") | ||||
|             return self.current_theme_module | ||||
|  | ||||
|         try: | ||||
|             theme_module = load_theme(theme_name) | ||||
|         except FileNotFoundError: | ||||
|             logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'") | ||||
|             theme_module = load_theme("standart") | ||||
|             theme_name = "standart" | ||||
|             save_theme_to_config("standart") | ||||
|  | ||||
|     def apply_theme(self, theme_name): | ||||
|         """ | ||||
|         Применяет выбранную тему: загружает модуль стилей, шрифты и логотип. | ||||
|         Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации. | ||||
|         :param theme_name: Имя темы. | ||||
|         :return: Загруженный модуль темы (или обёртка). | ||||
|         """ | ||||
|         theme_module = load_theme(theme_name) | ||||
|         load_theme_fonts(theme_name) | ||||
|         self.current_theme_name = theme_name | ||||
|         self.current_theme_module = theme_module | ||||
|         save_theme_to_config(theme_name) | ||||
|         logger.info(f"Тема '{theme_name}' успешно применена") | ||||
|         logger.info(f"Theme '{theme_name}' successfully applied") | ||||
|         return theme_module | ||||
|  | ||||
|     def get_icon(self, icon_name, theme_name=None, as_path=False): | ||||
| @@ -226,7 +278,7 @@ class ThemeManager: | ||||
|  | ||||
|         # Если иконка всё равно не найдена | ||||
|         if not icon_path or not os.path.exists(icon_path): | ||||
|             logger.error(f"Предупреждение: иконка '{icon_name}' не найдена") | ||||
|             logger.error(f"Warning: icon '{icon_name}' not found") | ||||
|             return QIcon() if not as_path else None | ||||
|  | ||||
|         if as_path: | ||||
|   | ||||
| @@ -1 +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 |