Compare commits
	
		
			1 Commits
		
	
	
		
			renovate/a
			...
			77d4287f12
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						77d4287f12
	
				 | 
					
					
						
@@ -12,27 +12,17 @@ jobs:
 | 
			
		||||
    name: Build AppImage
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
            sudo apt update
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
 | 
			
		||||
 | 
			
		||||
      - name: Upgrade pip toolchain
 | 
			
		||||
      - name: Install tools
 | 
			
		||||
        run: |
 | 
			
		||||
          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
 | 
			
		||||
            pip3 install git+https://github.com/Boria138/appimage-builder.git
 | 
			
		||||
            pip3 install uv
 | 
			
		||||
 | 
			
		||||
      - name: Build AppImage
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -52,7 +42,7 @@ jobs:
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        fedora_version: [41, 42, 43, rawhide]
 | 
			
		||||
        fedora_version: [41, 42, rawhide]
 | 
			
		||||
 | 
			
		||||
    container:
 | 
			
		||||
      image: fedora:${{ matrix.fedora_version }}
 | 
			
		||||
@@ -73,7 +63,7 @@ jobs:
 | 
			
		||||
          echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
 | 
			
		||||
 | 
			
		||||
      - name: Checkout repo
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Copy fedora.spec
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -94,7 +84,7 @@ jobs:
 | 
			
		||||
    name: Build Arch Package
 | 
			
		||||
    runs-on: ubuntu-22.04
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
			
		||||
      image: archlinux:base-devel
 | 
			
		||||
      volumes:
 | 
			
		||||
        - /usr:/usr-host
 | 
			
		||||
        - /opt:/opt-host
 | 
			
		||||
@@ -134,7 +124,7 @@ jobs:
 | 
			
		||||
          su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
 | 
			
		||||
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Upload Arch package
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-upload-artifact@v4
 | 
			
		||||
 
 | 
			
		||||
@@ -8,7 +8,7 @@ on:
 | 
			
		||||
 | 
			
		||||
env:
 | 
			
		||||
  # Common version, will be used for tagging the release
 | 
			
		||||
  VERSION: 0.1.8
 | 
			
		||||
  VERSION: 0.1.4
 | 
			
		||||
  PKGDEST: "/tmp/portprotonqt"
 | 
			
		||||
  PACKAGE: "portprotonqt"
 | 
			
		||||
  GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
 | 
			
		||||
@@ -23,22 +23,12 @@ jobs:
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
            sudo apt update
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
 | 
			
		||||
 | 
			
		||||
      - name: Upgrade pip toolchain
 | 
			
		||||
      - name: Install tools
 | 
			
		||||
        run: |
 | 
			
		||||
          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
 | 
			
		||||
            pip3 install git+https://github.com/Boria138/appimage-builder.git
 | 
			
		||||
            pip3 install uv
 | 
			
		||||
 | 
			
		||||
      - name: Build AppImage
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -109,7 +99,7 @@ jobs:
 | 
			
		||||
 | 
			
		||||
    strategy:
 | 
			
		||||
      matrix:
 | 
			
		||||
        fedora_version: [41, 42, 43, rawhide]
 | 
			
		||||
        fedora_version: [41, 42, rawhide]
 | 
			
		||||
 | 
			
		||||
    container:
 | 
			
		||||
      image: fedora:${{ matrix.fedora_version }}
 | 
			
		||||
@@ -180,12 +170,10 @@ 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 }}
 | 
			
		||||
          tag_name: v${{ env.VERSION }}
 | 
			
		||||
          prerelease: true
 | 
			
		||||
          files: release/**/*
 | 
			
		||||
          sha256sum: false
 | 
			
		||||
          sha256sum: true
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
name: Check Translations (disabled until yaspeller is fixed)
 | 
			
		||||
name: Check Translations
 | 
			
		||||
run-name: Check spelling in translation files
 | 
			
		||||
on:
 | 
			
		||||
  push:
 | 
			
		||||
@@ -12,14 +12,13 @@ on:
 | 
			
		||||
 | 
			
		||||
jobs:
 | 
			
		||||
  check-translations:
 | 
			
		||||
    if: false
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Set up Python
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
        with:
 | 
			
		||||
          fetch-depth: 0
 | 
			
		||||
 | 
			
		||||
@@ -63,7 +63,7 @@ jobs:
 | 
			
		||||
    needs: changes
 | 
			
		||||
    if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -115,7 +115,7 @@ jobs:
 | 
			
		||||
          echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
 | 
			
		||||
 | 
			
		||||
      - name: Checkout repo
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Copy fedora-git.spec
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -138,7 +138,7 @@ jobs:
 | 
			
		||||
    needs: changes
 | 
			
		||||
    if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
 | 
			
		||||
    container:
 | 
			
		||||
      image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
 | 
			
		||||
      image: archlinux:base-devel
 | 
			
		||||
      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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Upload Arch package
 | 
			
		||||
        uses: https://gitea.com/actions/gitea-upload-artifact@v4
 | 
			
		||||
 
 | 
			
		||||
@@ -20,17 +20,17 @@ jobs:
 | 
			
		||||
    name: Check code
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20
 | 
			
		||||
 | 
			
		||||
      - name: Install uv manually
 | 
			
		||||
        run: |
 | 
			
		||||
          curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
			
		||||
          source $HOME/.local/bin/env
 | 
			
		||||
          echo "$HOME/.cargo/bin" >> $GITHUB_PATH
 | 
			
		||||
          uv --version
 | 
			
		||||
 | 
			
		||||
      - name: Sync dependencies into venv
 | 
			
		||||
 
 | 
			
		||||
@@ -11,10 +11,10 @@ jobs:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - name: Checkout
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Set up Python
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/setup-python@v5
 | 
			
		||||
        with:
 | 
			
		||||
          python-version-file: "pyproject.toml"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -8,20 +8,19 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  renovate:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20
 | 
			
		||||
 | 
			
		||||
      - name: Install uv manually
 | 
			
		||||
        run: |
 | 
			
		||||
          curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
			
		||||
          . $HOME/.local/bin/env
 | 
			
		||||
          uv --version
 | 
			
		||||
      - name: Install uv
 | 
			
		||||
        uses: https://github.com/astral-sh/setup-uv@v6
 | 
			
		||||
        with:
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
 | 
			
		||||
      - name: Download external renovate config
 | 
			
		||||
        run: |
 | 
			
		||||
@@ -35,4 +34,3 @@ jobs:
 | 
			
		||||
          RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
 | 
			
		||||
          LOG_LEVEL: "debug"
 | 
			
		||||
          RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
 | 
			
		||||
          RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}
 | 
			
		||||
 
 | 
			
		||||
@@ -11,12 +11,12 @@ repos:
 | 
			
		||||
      - id: check-yaml
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/uv-pre-commit
 | 
			
		||||
    rev: 0.8.22
 | 
			
		||||
    rev: 0.8.9
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: uv-lock
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    rev: v0.14.0
 | 
			
		||||
    rev: v0.12.8
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff-check
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										100
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@@ -3,101 +3,12 @@
 | 
			
		||||
Все заметные изменения в этом проекте фиксируются в этом файле.
 | 
			
		||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
 | 
			
		||||
 | 
			
		||||
## [0.1.8] - 2025-10-18
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
 | 
			
		||||
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
 | 
			
		||||
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
 | 
			
		||||
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- При завершении автоустановки приложение больше не перезапускается
 | 
			
		||||
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
 | 
			
		||||
- Обновлены и дополнены скриншоты темы
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Исправлено наложение карточек при смене фильтра игр
 | 
			
		||||
- Исправлена невозможность запуска приложения без подключёного геймпада
 | 
			
		||||
- Исправлена невозможность установки компонентов Winetricks через геймпад
 | 
			
		||||
- Ресиверы и виртуальные устройства больше не считаются за геймпад
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [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
 | 
			
		||||
## [Unreleased]
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
 | 
			
		||||
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
 | 
			
		||||
- Анимация при закрытии карточки игры (подробности см. в документации).
 | 
			
		||||
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
 | 
			
		||||
- Система быстрого доступа (избранного) в диалоге выбора файлов.
 | 
			
		||||
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
 | 
			
		||||
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
 | 
			
		||||
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
 | 
			
		||||
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
 | 
			
		||||
- Пункт "Выход" в трей.
 | 
			
		||||
- Пункт "Темы" в трей для быстрого переключения тем.
 | 
			
		||||
- Двойной клик по иконке трея для показа/скрытия главного окна.
 | 
			
		||||
- Запуск через трей показывает модальное окно для слежки за процессом запуска
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
 | 
			
		||||
@@ -107,9 +18,7 @@
 | 
			
		||||
- Временно удалена светлая тема.
 | 
			
		||||
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
 | 
			
		||||
- Обновлены все зависимости (затрагивает только AppImage).
 | 
			
		||||
- Приложение теперь не закрывается полностью, а сворачивается в трей.
 | 
			
		||||
- Карточки теперь все находятся друг под другом, а не в разнабой
 | 
			
		||||
- Изменено соотношение сторон карточек
 | 
			
		||||
- Удалён отдельный трей, так как у PortProton есть собственный.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
 | 
			
		||||
@@ -118,10 +27,7 @@
 | 
			
		||||
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
 | 
			
		||||
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
 | 
			
		||||
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
 | 
			
		||||
- Заголовок окна диалога выбора файлов теперь можно перевести.
 | 
			
		||||
- Трей теперь можно перевести.
 | 
			
		||||
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
 | 
			
		||||
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
 | 
			
		||||
- Переведен заголовок окна диалога выбора файлов.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Alex Smith
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
<div align="center">
 | 
			
		||||
  <img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
 | 
			
		||||
  <img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64">
 | 
			
		||||
  <h1 align="center">PortProtonQt</h1>
 | 
			
		||||
  <p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
 | 
			
		||||
</div>
 | 
			
		||||
@@ -54,6 +54,7 @@ PortProtonQt использует код и зависимости от след
 | 
			
		||||
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
 | 
			
		||||
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
 | 
			
		||||
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
 | 
			
		||||
 | 
			
		||||
Полный текст лицензий см. в файле [LICENSE](LICENSE).
 | 
			
		||||
 | 
			
		||||
> [!WARNING]
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										15
									
								
								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] Получать обложки для игр из CDN Steam
 | 
			
		||||
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
 | 
			
		||||
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
 | 
			
		||||
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
 | 
			
		||||
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
			
		||||
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
 | 
			
		||||
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
 | 
			
		||||
- [X] Избавиться от вызовов yad
 | 
			
		||||
- [X] Реализовать собственный системный трей вместо использования трея PortProton
 | 
			
		||||
- [X] Добавить экранную клавиатуру в поиск
 | 
			
		||||
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
 | 
			
		||||
- [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] Добавить переводы в переопределения
 | 
			
		||||
@@ -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,6 +62,7 @@
 | 
			
		||||
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
 | 
			
		||||
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
 | 
			
		||||
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
 | 
			
		||||
- [X] Добавить подсказки к управлению с геймпада
 | 
			
		||||
- [ ] Доделать светлую тему
 | 
			
		||||
- [ ] Добавить подсказки к управлению с геймпада
 | 
			
		||||
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
 | 
			
		||||
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,16 @@
 | 
			
		||||
version: 1
 | 
			
		||||
script:
 | 
			
		||||
  # 1) чистим старый AppDir
 | 
			
		||||
  - rm -rf AppDir || true
 | 
			
		||||
  # 2) создаём структуру каталога
 | 
			
		||||
  - mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
 | 
			
		||||
  # 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
 | 
			
		||||
  - uv venv
 | 
			
		||||
  - uv pip install --no-cache-dir ../
 | 
			
		||||
  # 4) копируем всё из .venv в AppDir
 | 
			
		||||
  - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
 | 
			
		||||
  - cp -r share AppDir/usr
 | 
			
		||||
  # 5) чистим от ненужных модулей и бинарников
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
 | 
			
		||||
@@ -14,6 +19,7 @@ script:
 | 
			
		||||
AppDir:
 | 
			
		||||
  path: ./AppDir
 | 
			
		||||
  after_bundle:
 | 
			
		||||
    # Документация, справка, примеры
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/man || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/doc || true
 | 
			
		||||
    - rm -rf $TARGET_APPDIR/usr/share/doc-base || true
 | 
			
		||||
@@ -29,14 +35,17 @@ 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.8
 | 
			
		||||
    version: 0.1.4
 | 
			
		||||
    exec: usr/bin/python3
 | 
			
		||||
    exec_args: "-m portprotonqt.app $@"
 | 
			
		||||
  apt:
 | 
			
		||||
@@ -54,18 +63,16 @@ AppDir:
 | 
			
		||||
      - libxcb-cursor0
 | 
			
		||||
      - libimage-exiftool-perl
 | 
			
		||||
      - xdg-utils
 | 
			
		||||
      - cabextract
 | 
			
		||||
      - curl
 | 
			
		||||
      - 7zip
 | 
			
		||||
      - unzip
 | 
			
		||||
      - unrar
 | 
			
		||||
    exclude:
 | 
			
		||||
      # Документация и man-страницы
 | 
			
		||||
      - "*-doc"
 | 
			
		||||
      - "*-man"
 | 
			
		||||
      - manpages
 | 
			
		||||
      - mandb
 | 
			
		||||
      # Статические библиотеки
 | 
			
		||||
      - "*-dev"
 | 
			
		||||
      - "*-static"
 | 
			
		||||
      # Дебаг-символы
 | 
			
		||||
      - "*-dbg"
 | 
			
		||||
      - "*-dbgsym"
 | 
			
		||||
  runtime:
 | 
			
		||||
@@ -76,4 +83,3 @@ AppDir:
 | 
			
		||||
AppImage:
 | 
			
		||||
  sign-key: None
 | 
			
		||||
  arch: x86_64
 | 
			
		||||
  comp: zstd
 | 
			
		||||
 
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
pkgname=portprotonqt
 | 
			
		||||
pkgver=0.1.8
 | 
			
		||||
pkgver=0.1.4
 | 
			
		||||
pkgrel=1
 | 
			
		||||
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
 | 
			
		||||
arch=('any')
 | 
			
		||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
 | 
			
		||||
license=('GPL-3.0')
 | 
			
		||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
 | 
			
		||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
 | 
			
		||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
 | 
			
		||||
sha256sums=('SKIP')
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ arch=('any')
 | 
			
		||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
 | 
			
		||||
license=('GPL-3.0')
 | 
			
		||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
 | 
			
		||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
 | 
			
		||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
 | 
			
		||||
sha256sums=('SKIP')
 | 
			
		||||
 
 | 
			
		||||
@@ -46,11 +46,6 @@ 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.8
 | 
			
		||||
%global pypi_version 0.1.4
 | 
			
		||||
%global oname PortProtonQt
 | 
			
		||||
%global _python_no_extras_requires 1
 | 
			
		||||
 | 
			
		||||
@@ -43,11 +43,6 @@ 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,30 +1,19 @@
 | 
			
		||||
_portprotonqt_completions() {
 | 
			
		||||
    local cur prev opts
 | 
			
		||||
    COMPREPLY=()
 | 
			
		||||
    cur="${COMP_WORDS[COMP_CWORD]}"
 | 
			
		||||
    prev="${COMP_WORDS[COMP_CWORD-1]}"
 | 
			
		||||
_portprotonqt() {
 | 
			
		||||
    local cur prev
 | 
			
		||||
    _init_completion || 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
 | 
			
		||||
            ;;
 | 
			
		||||
        *)
 | 
			
		||||
    case $prev in
 | 
			
		||||
        --help|-h)
 | 
			
		||||
            return
 | 
			
		||||
            ;;
 | 
			
		||||
    esac
 | 
			
		||||
 | 
			
		||||
    # Complete options
 | 
			
		||||
    if [[ ${cur} == -* ]]; then
 | 
			
		||||
        COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
 | 
			
		||||
    if [[ "$cur" == -* ]]; then
 | 
			
		||||
        COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
 | 
			
		||||
        return 0
 | 
			
		||||
    fi
 | 
			
		||||
 | 
			
		||||
    return 0
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
complete -F _portprotonqt_completions portprotonqt
 | 
			
		||||
complete -F _portprotonqt portprotonqt
 | 
			
		||||
 
 | 
			
		||||
@@ -217,7 +217,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "watch_dogs 2",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "zero hour",
 | 
			
		||||
@@ -1777,7 +1777,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "supervive",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "splitgate 2",
 | 
			
		||||
@@ -4472,7 +4472,7 @@
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "battlefield 6",
 | 
			
		||||
    "normalized_name": "f1 25",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
@@ -4482,65 +4482,5 @@
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "sword of justice",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blade & soul neo",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "the finals (cn)",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "tom clancy's rainbow six siege x",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dragonheir silent gods",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "the quinfall",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "redmatch 2",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blade & soul heroes",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "blue archive",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "midnight murder club",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dungeon done",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "project wraith",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "solo leveling arise",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "freedom wars",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "open fortress",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "no more room in hell 2",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
							
								
								
									
										25868
									
								
								data/games_appid.json
									
									
									
									
									
								
							
							
						
						@@ -1,140 +1,4 @@
 | 
			
		||||
[
 | 
			
		||||
  {
 | 
			
		||||
    "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"
 | 
			
		||||
@@ -287,6 +151,10 @@
 | 
			
		||||
    "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"
 | 
			
		||||
@@ -367,6 +235,10 @@
 | 
			
		||||
    "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,6 +17,4 @@ Generated-By:
 | 
			
		||||
start.sh
 | 
			
		||||
EGS
 | 
			
		||||
Stop Game
 | 
			
		||||
Fullscreen
 | 
			
		||||
Fulscreen
 | 
			
		||||
\t
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,6 @@
 | 
			
		||||
 | 
			
		||||
import argparse
 | 
			
		||||
import re
 | 
			
		||||
import subprocess
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from datetime import date
 | 
			
		||||
 | 
			
		||||
@@ -135,12 +134,6 @@ 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,9 +3,8 @@
 | 
			
		||||
import sys
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
import re
 | 
			
		||||
import ast
 | 
			
		||||
 | 
			
		||||
# Запрещенные QSS-свойства
 | 
			
		||||
# Запрещенные свойства
 | 
			
		||||
FORBIDDEN_PROPERTIES = {
 | 
			
		||||
    "box-shadow",
 | 
			
		||||
    "backdrop-filter",
 | 
			
		||||
@@ -13,55 +12,15 @@ 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}' in file {qss_file}")
 | 
			
		||||
                    print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}")
 | 
			
		||||
                    has_errors = True
 | 
			
		||||
 | 
			
		||||
            # Проверка на опасные импорты и функции
 | 
			
		||||
            try:
 | 
			
		||||
                tree = ast.parse(content)
 | 
			
		||||
                for node in ast.walk(tree):
 | 
			
		||||
                    # Проверка импортов
 | 
			
		||||
                    if isinstance(node, (ast.Import, ast.ImportFrom)):
 | 
			
		||||
                        for name in node.names:
 | 
			
		||||
                            if name.name in FORBIDDEN_MODULES:
 | 
			
		||||
                                print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
 | 
			
		||||
                                has_errors = True
 | 
			
		||||
                    # Проверка вызовов функций
 | 
			
		||||
                    if isinstance(node, ast.Call):
 | 
			
		||||
                        if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
 | 
			
		||||
                            print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
 | 
			
		||||
                            has_errors = True
 | 
			
		||||
            except SyntaxError as e:
 | 
			
		||||
                print(f"ERROR: Syntax error in file {qss_file}: {e}")
 | 
			
		||||
                has_errors = True
 | 
			
		||||
 | 
			
		||||
    return has_errors
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@ Current translation status:
 | 
			
		||||
 | 
			
		||||
| Locale | Progress | Translated |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 195 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 195 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 of 195 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -21,9 +21,9 @@
 | 
			
		||||
 | 
			
		||||
| Локаль | Прогресс | Переведено |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 из 195 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -52,151 +52,102 @@ The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game
 | 
			
		||||
 | 
			
		||||
```python
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Type of animation when entering or exiting the detail page
 | 
			
		||||
    # Type of animation when entering and 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
 | 
			
		||||
    # Border width of the card in idle state (no hover or focus).
 | 
			
		||||
    # Affects the thickness of the border when the card is not highlighted.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Border width on hover
 | 
			
		||||
    # Increases the border thickness when the cursor is over the card
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    # 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
 | 
			
		||||
    # Border width on focus (e.g., selected via keyboard).
 | 
			
		||||
    # Increases the border thickness when the card is focused.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Minimum border width during pulsing animation
 | 
			
		||||
    # Determines the minimum border thickness during the "breathing" animation
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    # Minimum border width during pulsing animation.
 | 
			
		||||
    # Sets the minimum border thickness during the "breathing" animation.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Maximum border width during pulsing animation
 | 
			
		||||
    # Determines the maximum border thickness during pulsing
 | 
			
		||||
    # Value in pixels
 | 
			
		||||
    # Maximum border width during pulsing animation.
 | 
			
		||||
    # Sets the maximum border thickness during pulsing.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Duration of the border thickness animation (e.g., on hover or focus)
 | 
			
		||||
    # Affects the speed of transition from one border width to another
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the border thickness animation (e.g., on hover or focus).
 | 
			
		||||
    # Affects the speed of transition between different border widths.
 | 
			
		||||
    # Value in milliseconds.
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Duration of one pulsing animation cycle
 | 
			
		||||
    # Determines how fast the border "pulses" between min and max values
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of one pulsing animation cycle.
 | 
			
		||||
    # Defines how fast the border "pulses" between min and max values.
 | 
			
		||||
    # Value in milliseconds.
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Duration of the gradient rotation animation
 | 
			
		||||
    # Affects how fast the gradient border rotates around the card
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # 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
 | 
			
		||||
    # Starting angle of the gradient (in degrees).
 | 
			
		||||
    # Defines the initial rotation point of the gradient when the animation starts.
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Ending angle of the gradient (in degrees)
 | 
			
		||||
    # Determines the final rotation point of the gradient
 | 
			
		||||
    # Value 0 means a full 360° rotation
 | 
			
		||||
    # Ending angle of the gradient (in degrees).
 | 
			
		||||
    # Defines the end rotation point of the gradient.
 | 
			
		||||
    # A value of 0 means a full 360-degree rotation.
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # 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")
 | 
			
		||||
    # Easing curve type for border expansion animation (on hover/focus).
 | 
			
		||||
    # Affects the "feel" of the animation (e.g., smooth acceleration or deceleration).
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad").
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for border thickness decrease animation (on hover/focus exit)
 | 
			
		||||
    # Affects the "feel" of returning to the default border width
 | 
			
		||||
    # Easing curve type for border contraction animation (on mouse leave/focus loss).
 | 
			
		||||
    # Affects the "feel" of returning to the original border width.
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # 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 for the animated border.
 | 
			
		||||
    # A list of dictionaries where each defines a position (0.0–1.0) and color in hex format.
 | 
			
		||||
    # Affects the appearance of the border on hover or focus.
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # 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)
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Start color (cyan)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # 33% color (orange)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # 66% color (purple)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # End color (back to cyan)
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Duration of fade animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page appearance with fade animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the fade animation when entering the detail page
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Duration of slide animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page sliding animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the slide animation when entering the detail page
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Duration of bounce animation when entering the detail page
 | 
			
		||||
    # Affects the speed of page "bounce" animation
 | 
			
		||||
    # Value in milliseconds
 | 
			
		||||
    # Duration of the bounce animation when entering the detail page
 | 
			
		||||
    "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
 | 
			
		||||
    # Duration of the fade animation when exiting the detail page
 | 
			
		||||
    "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
 | 
			
		||||
    # Duration of the slide animation when exiting the detail page
 | 
			
		||||
    "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
 | 
			
		||||
    # Duration of the bounce animation when exiting the detail page
 | 
			
		||||
    "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
 | 
			
		||||
    # Easing curve type for animation when entering the detail page
 | 
			
		||||
    # Applies to slide and bounce animations
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for animations when exiting the detail page
 | 
			
		||||
    # Applied to slide and bounce animations; affects the "feel" of movement
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type
 | 
			
		||||
    # Easing curve type for animation when exiting the detail page
 | 
			
		||||
    # Applies to slide and bounce animations
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 
 | 
			
		||||
@@ -54,104 +54,69 @@ def custom_button_style(color1, color2):
 | 
			
		||||
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 значениями
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    # Длительность одного цикла пульсирующей анимации.
 | 
			
		||||
    # Определяет, как быстро рамка "пульсирует" между min и max значениями.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации вращения градиента
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки
 | 
			
		||||
    # Значение в миллисекундах
 | 
			
		||||
    # Длительность анимации вращения градиента.
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Начальный угол градиента (в градусах)
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации
 | 
			
		||||
    # Начальный угол градиента (в градусах).
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации.
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Конечный угол градиента (в градусах)
 | 
			
		||||
    # Определяет конечную точку вращения градиента
 | 
			
		||||
    # Значение 0 означает полный поворот на 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")
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
 | 
			
		||||
    # Влияет на "чувство" анимации (например, плавное ускорение или замедление).
 | 
			
		||||
    # Возможные значения: строки, соответствующие 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"
 | 
			
		||||
    # Цвета градиента для анимированной обводки.
 | 
			
		||||
    # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
 | 
			
		||||
    # Влияет на внешний вид обводки при наведении или фокусе.
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Начальный цвет (циан)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
 | 
			
		||||
@@ -160,43 +125,29 @@ GAME_CARD_ANIMATION = {
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации 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
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при выходе из детальной страницы
 | 
			
		||||
    # Применяется к slide и bounce анимациям, влияет на "чувство" движения
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 
 | 
			
		||||
@@ -2,9 +2,8 @@ from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstra
 | 
			
		||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
 | 
			
		||||
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
@@ -24,27 +23,17 @@ class SafeOpacityEffect(QGraphicsOpacityEffect):
 | 
			
		||||
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.theme = theme if theme is not None else default_styles
 | 
			
		||||
        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."""
 | 
			
		||||
        """Initialize animation properties."""
 | 
			
		||||
        self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
 | 
			
		||||
        self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
 | 
			
		||||
 | 
			
		||||
        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):
 | 
			
		||||
@@ -68,8 +57,6 @@ class GameCardAnimations:
 | 
			
		||||
        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:
 | 
			
		||||
@@ -82,44 +69,23 @@ class GameCardAnimations:
 | 
			
		||||
            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()
 | 
			
		||||
        if self.gradient_anim:
 | 
			
		||||
            self.gradient_anim.stop()
 | 
			
		||||
        self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
        self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
        self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
        self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
        self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
        self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_leave_event(self):
 | 
			
		||||
        """Handle mouse leave event animations."""
 | 
			
		||||
        self.game_card._hovered = False
 | 
			
		||||
        self.game_card.hoverChanged.emit(self.game_card.name, False)
 | 
			
		||||
        if not self.game_card._focused:
 | 
			
		||||
            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.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
@@ -142,8 +108,6 @@ class GameCardAnimations:
 | 
			
		||||
            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:
 | 
			
		||||
@@ -156,44 +120,23 @@ class GameCardAnimations:
 | 
			
		||||
                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()
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
            self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
            self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
            self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
            self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
            self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
            self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_focus_out_event(self):
 | 
			
		||||
        """Handle focus out event animations."""
 | 
			
		||||
        self.game_card._focused = False
 | 
			
		||||
        self.game_card.focusChanged.emit(self.game_card.name, False)
 | 
			
		||||
        if not self.game_card._hovered:
 | 
			
		||||
            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.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
@@ -209,13 +152,12 @@ class GameCardAnimations:
 | 
			
		||||
 | 
			
		||||
    def paint_border(self, painter: QPainter):
 | 
			
		||||
        if not painter.isActive():
 | 
			
		||||
            logger.debug("Painter is not active; skipping border paint")
 | 
			
		||||
            logger.warning("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":
 | 
			
		||||
        if self.game_card._hovered or self.game_card._focused:
 | 
			
		||||
            center = self.game_card.rect().center()
 | 
			
		||||
            gradient = QConicalGradient(center, self.game_card._gradientAngle)
 | 
			
		||||
            for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
 | 
			
		||||
@@ -224,18 +166,17 @@ class GameCardAnimations:
 | 
			
		||||
        else:
 | 
			
		||||
            pen.setColor(QColor(0, 0, 0, 0))
 | 
			
		||||
        painter.setPen(pen)
 | 
			
		||||
        radius = 18 * self.game_card._scale
 | 
			
		||||
        radius = 18
 | 
			
		||||
        bw = round(self.game_card._borderWidth / 2)
 | 
			
		||||
        rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
 | 
			
		||||
        if rect.isEmpty():
 | 
			
		||||
            return
 | 
			
		||||
            return  # Avoid drawing invalid rect
 | 
			
		||||
        painter.drawRoundedRect(rect, radius, radius)
 | 
			
		||||
 | 
			
		||||
class DetailPageAnimations:
 | 
			
		||||
    def __init__(self, main_window, theme=None):
 | 
			
		||||
        self.main_window = main_window
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
        self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
 | 
			
		||||
 | 
			
		||||
    def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
 | 
			
		||||
@@ -258,7 +199,7 @@ class DetailPageAnimations:
 | 
			
		||||
                try:
 | 
			
		||||
                    detail_page.setGraphicsEffect(original_effect) # type: ignore
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    logger.warning("Original effect already deleted")
 | 
			
		||||
                    logger.debug("Original effect already deleted")
 | 
			
		||||
            animation.finished.connect(restore_effect)
 | 
			
		||||
            animation.finished.connect(load_image_and_restore_effect)
 | 
			
		||||
            animation.finished.connect(opacity_effect.deleteLater)
 | 
			
		||||
@@ -317,7 +258,7 @@ class DetailPageAnimations:
 | 
			
		||||
                    if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
 | 
			
		||||
                        animation.stop()
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    logger.warning("Animation already deleted for page")
 | 
			
		||||
                    logger.debug("Animation already deleted for page")
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(f"Error stopping existing animation: {e}", exc_info=True)
 | 
			
		||||
                finally:
 | 
			
		||||
@@ -343,15 +284,15 @@ class DetailPageAnimations:
 | 
			
		||||
                        logger.debug("Original effect already deleted")
 | 
			
		||||
                    cleanup_callback()
 | 
			
		||||
                animation.finished.connect(restore_and_cleanup)
 | 
			
		||||
                animation.finished.connect(opacity_effect.deleteLater)
 | 
			
		||||
                animation.finished.connect(opacity_effect.deleteLater)  # Clean up effect
 | 
			
		||||
            elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
 | 
			
		||||
                duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
 | 
			
		||||
                easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
 | 
			
		||||
                end_pos = {
 | 
			
		||||
                    "slide_left": QPoint(-self.main_window.width(), 0),
 | 
			
		||||
                    "slide_right": QPoint(self.main_window.width(), 0),
 | 
			
		||||
                    "slide_up": QPoint(0, self.main_window.height()),
 | 
			
		||||
                    "slide_down": QPoint(0, -self.main_window.height())
 | 
			
		||||
                    "slide_left": QPoint(-self.main_window.width(), 0),  # Exit to left (opposite of entry)
 | 
			
		||||
                    "slide_right": QPoint(self.main_window.width(), 0),  # Exit to right
 | 
			
		||||
                    "slide_up": QPoint(0, self.main_window.height()),    # Exit downward
 | 
			
		||||
                    "slide_down": QPoint(0, -self.main_window.height())  # Exit upward
 | 
			
		||||
                }[animation_type]
 | 
			
		||||
                animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
 | 
			
		||||
                animation.setDuration(duration)
 | 
			
		||||
@@ -384,4 +325,4 @@ class DetailPageAnimations:
 | 
			
		||||
        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()
 | 
			
		||||
            cleanup_callback()  # Fallback to cleanup if animation setup fails
 | 
			
		||||
 
 | 
			
		||||
@@ -1,65 +1,36 @@
 | 
			
		||||
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.config_utils import save_fullscreen_config, get_portproton_location
 | 
			
		||||
from portprotonqt.logger import get_logger, setup_logger
 | 
			
		||||
from portprotonqt.config_utils import save_fullscreen_config
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.cli import parse_args
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
 | 
			
		||||
__app_name__ = "PortProtonQt"
 | 
			
		||||
__app_version__ = "0.1.8"
 | 
			
		||||
 | 
			
		||||
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__
 | 
			
		||||
__app_version__ = "0.1.4"
 | 
			
		||||
 | 
			
		||||
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.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
 | 
			
		||||
        logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
 | 
			
		||||
 | 
			
		||||
    version = get_version()
 | 
			
		||||
    window = MainWindow(app_name=__app_name__, version=version)
 | 
			
		||||
    args = parse_args()
 | 
			
		||||
 | 
			
		||||
    window = MainWindow()
 | 
			
		||||
 | 
			
		||||
    if args.fullscreen:
 | 
			
		||||
        logger.info("Launching in fullscreen mode due to --fullscreen flag")
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +1,16 @@
 | 
			
		||||
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="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 для всех сообщений, по умолчанию: без логов)"
 | 
			
		||||
        help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
 | 
			
		||||
    )
 | 
			
		||||
    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,32 +18,17 @@ 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 = {}
 | 
			
		||||
@@ -59,17 +44,29 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None:
 | 
			
		||||
        return "standart"
 | 
			
		||||
    Читает из конфигурационного файла тему из секции [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"
 | 
			
		||||
    return cp.get("Appearance", "theme", fallback="standart")
 | 
			
		||||
 | 
			
		||||
def save_theme_to_config(theme_name):
 | 
			
		||||
    """Saves the selected theme name to the [Appearance] section of the configuration file."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет имя выбранной темы в секции [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)
 | 
			
		||||
    if "Appearance" not in cp:
 | 
			
		||||
        cp["Appearance"] = {}
 | 
			
		||||
    cp["Appearance"]["theme"] = theme_name
 | 
			
		||||
@@ -77,18 +74,34 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    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()
 | 
			
		||||
    Читает настройки времени из секции [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"
 | 
			
		||||
 | 
			
		||||
def save_time_config(detail_level):
 | 
			
		||||
    """Saves the time detail level to the [Time] section of the configuration file."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку уровня детализации времени в секции [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)
 | 
			
		||||
    if "Time" not in cp:
 | 
			
		||||
        cp["Time"] = {}
 | 
			
		||||
    cp["Time"]["detail_level"] = detail_level
 | 
			
		||||
@@ -96,42 +109,48 @@ 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():
 | 
			
		||||
    """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.
 | 
			
		||||
    """
 | 
			
		||||
    Возвращает путь к директории PortProton.
 | 
			
		||||
    Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
 | 
			
		||||
    наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
 | 
			
		||||
    используется директория по умолчанию.
 | 
			
		||||
    """
 | 
			
		||||
    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 path from configuration: {location}")
 | 
			
		||||
                logger.info(f"Путь PortProton из конфигурации: {location}")
 | 
			
		||||
                return _portproton_location
 | 
			
		||||
            logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
 | 
			
		||||
            logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
 | 
			
		||||
        except (OSError, PermissionError) as e:
 | 
			
		||||
            logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
 | 
			
		||||
            logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
 | 
			
		||||
 | 
			
		||||
    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"Using flatpak PortProton directory: {default_dir}")
 | 
			
		||||
        logger.info(f"Используется директория flatpak PortProton: {default_dir}")
 | 
			
		||||
        return _portproton_location
 | 
			
		||||
 | 
			
		||||
    logger.warning("PortProton configuration and flatpak directory not found")
 | 
			
		||||
    logger.warning("Конфигурация и директория flatpak PortProton не найдены")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def parse_desktop_entry(file_path):
 | 
			
		||||
    """Reads and parses a .desktop file using configparser.
 | 
			
		||||
    Returns None if the [Desktop Entry] section is missing.
 | 
			
		||||
    """
 | 
			
		||||
    Читает и парсит .desktop файл с помощью configparser.
 | 
			
		||||
    Если секция [Desktop Entry] отсутствует, возвращается None.
 | 
			
		||||
    """
 | 
			
		||||
    cp = configparser.ConfigParser(interpolation=None)
 | 
			
		||||
    cp.read(file_path, encoding="utf-8")
 | 
			
		||||
@@ -140,8 +159,9 @@ def parse_desktop_entry(file_path):
 | 
			
		||||
    return cp["Desktop Entry"]
 | 
			
		||||
 | 
			
		||||
def load_theme_metainfo(theme_name):
 | 
			
		||||
    """Loads theme metadata from metainfo.ini in the theme's root directory.
 | 
			
		||||
    Expected fields: author, author_link, description, name.
 | 
			
		||||
    """
 | 
			
		||||
    Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
 | 
			
		||||
    Ожидаемые поля: author, author_link, description, name.
 | 
			
		||||
    """
 | 
			
		||||
    meta = {}
 | 
			
		||||
    for themes_dir in THEMES_DIRS:
 | 
			
		||||
@@ -159,57 +179,69 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
    Читает размер карточек (ширину) из секции [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
 | 
			
		||||
 | 
			
		||||
def save_card_size(card_width):
 | 
			
		||||
    """Saves the card size (width) to the [Cards] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет размер карточек (ширину) в секцию [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)
 | 
			
		||||
    if "Cards" not in cp:
 | 
			
		||||
        cp["Cards"] = {}
 | 
			
		||||
    cp["Cards"]["card_width"] = str(card_width)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
def read_auto_card_size():
 | 
			
		||||
    """Reads the card size (width) for Auto Install from the [Cards] section.
 | 
			
		||||
    Returns 250 if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
 | 
			
		||||
        save_auto_card_size(250)
 | 
			
		||||
        return 250
 | 
			
		||||
    return cp.getint("Cards", "auto_card_width", fallback=250)
 | 
			
		||||
 | 
			
		||||
def save_auto_card_size(card_width):
 | 
			
		||||
    """Saves the card size (width) for Auto Install to the [Cards] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Cards" not in cp:
 | 
			
		||||
        cp["Cards"] = {}
 | 
			
		||||
    cp["Cards"]["auto_card_width"] = str(card_width)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def read_sort_method():
 | 
			
		||||
    """Reads the sort method from the [Games] section.
 | 
			
		||||
    Returns 'last_launch' if the parameter is not set.
 | 
			
		||||
    """
 | 
			
		||||
    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()
 | 
			
		||||
    Читает метод сортировки из секции [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"
 | 
			
		||||
 | 
			
		||||
def save_sort_method(sort_method):
 | 
			
		||||
    """Saves the sort method to the [Games] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет метод сортировки в секцию [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)
 | 
			
		||||
    if "Games" not in cp:
 | 
			
		||||
        cp["Games"] = {}
 | 
			
		||||
    cp["Games"]["sort_method"] = sort_method
 | 
			
		||||
@@ -217,18 +249,34 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    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()
 | 
			
		||||
    Читает параметр 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"
 | 
			
		||||
 | 
			
		||||
def save_display_filter(filter_value):
 | 
			
		||||
    """Saves the display_filter parameter to the [Games] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет параметр 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)
 | 
			
		||||
    if "Games" not in cp:
 | 
			
		||||
        cp["Games"] = {}
 | 
			
		||||
    cp["Games"]["display_filter"] = filter_value
 | 
			
		||||
@@ -236,23 +284,37 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    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()]
 | 
			
		||||
    Читает список избранных игр из секции [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 []
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    Сохраняет список избранных игр в секцию [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)
 | 
			
		||||
    if "Favorites" not in cp:
 | 
			
		||||
        cp["Favorites"] = {}
 | 
			
		||||
    fav_str = ", ".join(favorites)
 | 
			
		||||
@@ -261,66 +323,76 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
    Читает настройку виброотдачи геймпада из секции [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
 | 
			
		||||
 | 
			
		||||
def save_rumble_config(rumble_enabled):
 | 
			
		||||
    """Saves the gamepad rumble setting to the [Gamepad] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку виброотдачи геймпада в секцию [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)
 | 
			
		||||
    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.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
    Проверяет наличие секции [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)
 | 
			
		||||
 | 
			
		||||
def read_proxy_config():
 | 
			
		||||
    """Reads proxy settings from the [Proxy] section.
 | 
			
		||||
    Returns an empty dict if proxy_url is not set or empty.
 | 
			
		||||
    """
 | 
			
		||||
    Читает настройки прокси из секции [Proxy] конфигурационного файла.
 | 
			
		||||
    Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
 | 
			
		||||
    """
 | 
			
		||||
    ensure_default_proxy_config()
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None:
 | 
			
		||||
    cp = configparser.ConfigParser()
 | 
			
		||||
    try:
 | 
			
		||||
        cp.read(CONFIG_FILE, encoding="utf-8")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error("Ошибка чтения конфигурационного файла: %s", e)
 | 
			
		||||
        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:
 | 
			
		||||
@@ -330,10 +402,16 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    Сохраняет настройки 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)
 | 
			
		||||
    if "Proxy" not in cp:
 | 
			
		||||
        cp["Proxy"] = {}
 | 
			
		||||
    cp["Proxy"]["proxy_url"] = proxy_url
 | 
			
		||||
@@ -343,18 +421,34 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
    Читает настройку полноэкранного режима приложения из секции [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
 | 
			
		||||
 | 
			
		||||
def save_fullscreen_config(fullscreen):
 | 
			
		||||
    """Saves the fullscreen mode setting to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку полноэкранного режима приложения в секцию [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)
 | 
			
		||||
    if "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["fullscreen"] = str(fullscreen)
 | 
			
		||||
@@ -362,19 +456,33 @@ 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.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
    Читает ширину и высоту окна из секции [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)
 | 
			
		||||
 | 
			
		||||
def save_window_geometry(width: int, height: int):
 | 
			
		||||
    """Saves the window width and height to the [MainWindow] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет ширину и высоту окна в секцию [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)
 | 
			
		||||
    if "MainWindow" not in cp:
 | 
			
		||||
        cp["MainWindow"] = {}
 | 
			
		||||
    cp["MainWindow"]["width"] = str(width)
 | 
			
		||||
@@ -383,86 +491,61 @@ 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("Configuration file %s deleted", CONFIG_FILE)
 | 
			
		||||
            logger.info("Конфигурационный файл %s удалён", CONFIG_FILE)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.warning(f"Failed to delete configuration file: {e}")
 | 
			
		||||
            logger.error("Ошибка при удалении конфигурационного файла: %s", e)
 | 
			
		||||
 | 
			
		||||
def clear_cache():
 | 
			
		||||
    """Clears the PortProtonQt cache by deleting the cache directory."""
 | 
			
		||||
    """
 | 
			
		||||
    Очищает кэш PortProtonQt, удаляя папку кэша.
 | 
			
		||||
    """
 | 
			
		||||
    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 cache deleted: %s", cache_dir)
 | 
			
		||||
            logger.info("Кэш PortProtonQt удалён: %s", cache_dir)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.warning(f"Failed to delete cache: {e}")
 | 
			
		||||
            logger.error("Ошибка при удалении кэша: %s", e)
 | 
			
		||||
 | 
			
		||||
def read_auto_fullscreen_gamepad():
 | 
			
		||||
    """Reads the auto-fullscreen setting for gamepad from the [Display] section.
 | 
			
		||||
    Returns False if the parameter is missing.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
    Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [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
 | 
			
		||||
 | 
			
		||||
def save_auto_fullscreen_gamepad(auto_fullscreen):
 | 
			
		||||
    """Saves the auto-fullscreen setting for gamepad to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    """
 | 
			
		||||
    Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [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)
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
def read_minimize_to_tray():
 | 
			
		||||
    """Reads the minimize-to-tray setting from the [Display] section.
 | 
			
		||||
    Returns True if the parameter is missing (default: minimize to tray).
 | 
			
		||||
    """
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE)
 | 
			
		||||
    if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
 | 
			
		||||
        save_minimize_to_tray(True)
 | 
			
		||||
        return True
 | 
			
		||||
    return cp.getboolean("Display", "minimize_to_tray", fallback=True)
 | 
			
		||||
 | 
			
		||||
def save_minimize_to_tray(minimize_to_tray):
 | 
			
		||||
    """Saves the minimize-to-tray setting to the [Display] section."""
 | 
			
		||||
    cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
 | 
			
		||||
    if "Display" not in cp:
 | 
			
		||||
        cp["Display"] = {}
 | 
			
		||||
    cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
 | 
			
		||||
    with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
 | 
			
		||||
        cp.write(configfile)
 | 
			
		||||
 
 | 
			
		||||
@@ -4,6 +4,7 @@ import glob
 | 
			
		||||
import shutil
 | 
			
		||||
import subprocess
 | 
			
		||||
import threading
 | 
			
		||||
import logging
 | 
			
		||||
import orjson
 | 
			
		||||
import psutil
 | 
			
		||||
import signal
 | 
			
		||||
@@ -11,14 +12,13 @@ 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, read_favorite_folders, save_favorite_folders
 | 
			
		||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
 | 
			
		||||
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 = get_logger(__name__)
 | 
			
		||||
logger = logging.getLogger(__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, game_library_manager):
 | 
			
		||||
    def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
 | 
			
		||||
        """
 | 
			
		||||
        Initialize the ContextMenuManager.
 | 
			
		||||
 | 
			
		||||
@@ -45,8 +45,7 @@ class ContextMenuManager:
 | 
			
		||||
        self.theme = theme
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.load_games = load_games_callback
 | 
			
		||||
        self.game_library_manager = game_library_manager
 | 
			
		||||
        self.update_game_grid = game_library_manager.update_game_grid
 | 
			
		||||
        self.update_game_grid = update_game_grid_callback
 | 
			
		||||
        self.legendary_path = os.path.join(
 | 
			
		||||
            os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
 | 
			
		||||
            "PortProtonQt", "legendary_cache", "legendary"
 | 
			
		||||
@@ -63,7 +62,7 @@ class ContextMenuManager:
 | 
			
		||||
                self.parent.statusBar().showMessage,
 | 
			
		||||
                Qt.ConnectionType.QueuedConnection
 | 
			
		||||
            )
 | 
			
		||||
            logger.debug("Connected show_status_message signal to status bar")
 | 
			
		||||
            logger.debug("Connected show_status_message signal to statusBar")
 | 
			
		||||
        self.signals.show_warning_dialog.connect(
 | 
			
		||||
            self._show_warning_dialog,
 | 
			
		||||
            Qt.ConnectionType.QueuedConnection
 | 
			
		||||
@@ -75,28 +74,28 @@ class ContextMenuManager:
 | 
			
		||||
 | 
			
		||||
    def _show_warning_dialog(self, title: str, message: str):
 | 
			
		||||
        """Show a warning dialog in the main thread."""
 | 
			
		||||
        logger.debug("Displaying warning dialog: %s - %s", title, message)
 | 
			
		||||
        logger.debug("Showing 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("Displaying info dialog: %s - %s", title, message)
 | 
			
		||||
        logger.debug("Showing 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("Displayed status message: %s", message)
 | 
			
		||||
            logger.debug("Direct status message: %s", message)
 | 
			
		||||
        else:
 | 
			
		||||
            logger.warning("Status bar unavailable for message: %s", message)
 | 
			
		||||
            logger.warning("Status bar not available 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 directory not found")
 | 
			
		||||
                _("PortProton is not found")
 | 
			
		||||
            )
 | 
			
		||||
            return False
 | 
			
		||||
        return True
 | 
			
		||||
@@ -120,7 +119,7 @@ class ContextMenuManager:
 | 
			
		||||
                installed_games = orjson.loads(f.read())
 | 
			
		||||
            return app_name in installed_games
 | 
			
		||||
        except (OSError, orjson.JSONDecodeError) as e:
 | 
			
		||||
            logger.error("Error reading installed.json: %s", e)
 | 
			
		||||
            logger.error("Failed to read installed.json: %s", e)
 | 
			
		||||
            return False
 | 
			
		||||
 | 
			
		||||
    def _is_game_running(self, game_card) -> bool:
 | 
			
		||||
@@ -151,84 +150,6 @@ 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.
 | 
			
		||||
@@ -237,6 +158,14 @@ 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)
 | 
			
		||||
 | 
			
		||||
@@ -246,7 +175,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(self._get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
                delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
                delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
 | 
			
		||||
                menu.exec(game_card.mapToGlobal(pos))
 | 
			
		||||
                return
 | 
			
		||||
@@ -255,7 +184,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(self._get_safe_icon(action_icon), action_text)
 | 
			
		||||
        launch_action = menu.addAction(get_safe_icon(action_icon), action_text)
 | 
			
		||||
        launch_action.triggered.connect(
 | 
			
		||||
            lambda: self._launch_game(game_card)
 | 
			
		||||
        )
 | 
			
		||||
@@ -264,11 +193,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(self._get_safe_icon(icon_name), text)
 | 
			
		||||
        favorite_action = menu.addAction(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(self._get_safe_icon("epic_games"), _("Import to Legendary"))
 | 
			
		||||
            import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary"))
 | 
			
		||||
            import_action.triggered.connect(
 | 
			
		||||
                lambda: self.import_to_legendary(game_card.name, game_card.appid)
 | 
			
		||||
            )
 | 
			
		||||
@@ -276,13 +205,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(self._get_safe_icon(icon_name), text)
 | 
			
		||||
                steam_action = menu.addAction(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(self._get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
                open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
                open_folder_action.triggered.connect(
 | 
			
		||||
                    lambda: self.open_egs_game_folder(game_card.appid)
 | 
			
		||||
                )
 | 
			
		||||
@@ -290,7 +219,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(self._get_safe_icon(icon_name), text)
 | 
			
		||||
                desktop_action = menu.addAction(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)
 | 
			
		||||
@@ -299,7 +228,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(
 | 
			
		||||
                    self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
 | 
			
		||||
                    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(
 | 
			
		||||
@@ -313,19 +242,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(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            desktop_action = menu.addAction(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(self._get_safe_icon("edit"), _("Edit Shortcut"))
 | 
			
		||||
            edit_action = menu.addAction(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(self._get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
            delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
 | 
			
		||||
            delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
 | 
			
		||||
            open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
            open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
 | 
			
		||||
            open_folder_action.triggered.connect(
 | 
			
		||||
                lambda: self.open_game_folder(game_card.name, game_card.exec_line)
 | 
			
		||||
            )
 | 
			
		||||
@@ -333,7 +262,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(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            menu_action = menu.addAction(get_safe_icon(icon_name), text)
 | 
			
		||||
            menu_action.triggered.connect(
 | 
			
		||||
                lambda: self.remove_from_menu(game_card.name)
 | 
			
		||||
                if os.path.exists(menu_path)
 | 
			
		||||
@@ -342,7 +271,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(self._get_safe_icon(icon_name), text)
 | 
			
		||||
            steam_action = menu.addAction(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)
 | 
			
		||||
@@ -351,7 +280,7 @@ class ContextMenuManager:
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        # Set focus to the first menu item
 | 
			
		||||
        # Устанавливаем фокус на первый элемент меню
 | 
			
		||||
        actions = menu.actions()
 | 
			
		||||
        if actions:
 | 
			
		||||
            menu.setActiveAction(actions[0])
 | 
			
		||||
@@ -493,7 +422,7 @@ class ContextMenuManager:
 | 
			
		||||
            )
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Use FileExplorer with directory_only=True
 | 
			
		||||
        # Используем FileExplorer с directory_only=True
 | 
			
		||||
        file_explorer = FileExplorer(
 | 
			
		||||
            parent=self.parent,
 | 
			
		||||
            theme=self.theme,
 | 
			
		||||
@@ -523,10 +452,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)
 | 
			
		||||
 | 
			
		||||
        # Center FileExplorer relative to the parent widget
 | 
			
		||||
        # Центрируем FileExplorer относительно родительского виджета
 | 
			
		||||
        parent_widget = self.parent
 | 
			
		||||
        if parent_widget:
 | 
			
		||||
            parent_geometry = parent_widget.geometry()
 | 
			
		||||
@@ -608,10 +537,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("Failed to generate thumbnail for EGS game: %s", exe_path)
 | 
			
		||||
                logger.error(f"Failed to generate thumbnail from exe: {exe_path}")
 | 
			
		||||
                icon_path = ""
 | 
			
		||||
        else:
 | 
			
		||||
            logger.error("No executable found for EGS game: %s", app_name)
 | 
			
		||||
            logger.error(f"No executable found for EGS game: {app_name}")
 | 
			
		||||
            icon_path = ""
 | 
			
		||||
 | 
			
		||||
        egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
 | 
			
		||||
@@ -751,7 +680,7 @@ Icon={icon_path}
 | 
			
		||||
                    if not exec_line:
 | 
			
		||||
                        self.signals.show_warning_dialog.emit(
 | 
			
		||||
                            _("Error"),
 | 
			
		||||
                            _("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name)
 | 
			
		||||
                            _("No executable command in .desktop file for '{game_name}'").format(game_name=game_name)
 | 
			
		||||
                        )
 | 
			
		||||
                        return None
 | 
			
		||||
                else:
 | 
			
		||||
@@ -763,7 +692,7 @@ Icon={icon_path}
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                self.signals.show_warning_dialog.emit(
 | 
			
		||||
                    _("Error"),
 | 
			
		||||
                    _("Error reading .desktop file: {error}").format(error=str(e))
 | 
			
		||||
                    _("Failed to read .desktop file: {error}").format(error=str(e))
 | 
			
		||||
                )
 | 
			
		||||
                return None
 | 
			
		||||
        else:
 | 
			
		||||
@@ -785,7 +714,7 @@ Icon={icon_path}
 | 
			
		||||
        try:
 | 
			
		||||
            entry_exec_split = shlex.split(exec_line)
 | 
			
		||||
            if not entry_exec_split:
 | 
			
		||||
                logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line)
 | 
			
		||||
                logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
 | 
			
		||||
                return None
 | 
			
		||||
            if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
 | 
			
		||||
                exe_path = entry_exec_split[2]
 | 
			
		||||
@@ -794,11 +723,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 game '%s': %s", game_name, exe_path or "None")
 | 
			
		||||
                logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
 | 
			
		||||
                return None
 | 
			
		||||
            return exe_path
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.debug("Error parsing executable for game '%s': %s", game_name, e)
 | 
			
		||||
            logger.debug("Failed to parse executable for '%s': %s", game_name, e)
 | 
			
		||||
            return None
 | 
			
		||||
 | 
			
		||||
    def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
 | 
			
		||||
@@ -860,16 +789,9 @@ Icon={icon_path}
 | 
			
		||||
                        _("Failed to delete custom data: {error}").format(error=str(e))
 | 
			
		||||
                    )
 | 
			
		||||
 | 
			
		||||
        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]))
 | 
			
		||||
        # Перезагрузка списка игр и обновление сетки
 | 
			
		||||
        self.load_games()
 | 
			
		||||
        self.update_game_grid()
 | 
			
		||||
 | 
			
		||||
    def add_to_menu(self, game_name, exec_line):
 | 
			
		||||
        """Copy the .desktop file to ~/.local/share/applications."""
 | 
			
		||||
@@ -944,7 +866,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("Failed to generate thumbnail for game: %s", exe_path)
 | 
			
		||||
                logger.error(f"Failed to generate thumbnail for {exe_path}")
 | 
			
		||||
 | 
			
		||||
        desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
 | 
			
		||||
        os.makedirs(desktop_dir, exist_ok=True)
 | 
			
		||||
@@ -1080,7 +1002,7 @@ Icon={icon_path}
 | 
			
		||||
        exe_path = self._parse_exe_path(exec_line, game_name)
 | 
			
		||||
        if not exe_path:
 | 
			
		||||
            return
 | 
			
		||||
        logger.debug("Adding game '%s' to Steam", game_name)
 | 
			
		||||
        logger.debug("Adding '%s' to Steam", game_name)
 | 
			
		||||
        try:
 | 
			
		||||
            success, message = add_to_steam(game_name, exec_line, cover_path)
 | 
			
		||||
            self.signals.show_info_dialog.emit(
 | 
			
		||||
@@ -1123,7 +1045,7 @@ Icon={icon_path}
 | 
			
		||||
            exe_path = self._parse_exe_path(exec_line, game_name)
 | 
			
		||||
            if not exe_path:
 | 
			
		||||
                return
 | 
			
		||||
            logger.debug("Removing game '%s' from Steam", game_name)
 | 
			
		||||
            logger.debug("Removing non-EGS 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: 315 KiB After Width: | Height: | Size: 315 KiB  | 
| 
		 Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB  | 
| 
		 Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 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,63 +5,30 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter
 | 
			
		||||
 | 
			
		||||
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
    """
 | 
			
		||||
    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).
 | 
			
		||||
    Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
 | 
			
		||||
    nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
 | 
			
		||||
    rect_width: доступная ширина контейнера.
 | 
			
		||||
    spacing: отступ между элементами.
 | 
			
		||||
    max_scale: максимальный коэффициент масштабирования (например, 1.2).
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
      result: Array (N, 4), where each row contains [x, y, new_width, new_height].
 | 
			
		||||
      total_height: Total height of all rows.
 | 
			
		||||
    Возвращает:
 | 
			
		||||
      result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
 | 
			
		||||
      total_height: итоговая высота всех рядов.
 | 
			
		||||
    """
 | 
			
		||||
    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]
 | 
			
		||||
            if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
 | 
			
		||||
            # Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
 | 
			
		||||
            if count > 0 and (sum_width + spacing + w) > rect_width:
 | 
			
		||||
                break
 | 
			
		||||
            sum_width += w
 | 
			
		||||
            count += 1
 | 
			
		||||
@@ -69,19 +36,13 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
 | 
			
		||||
            if h > row_max_height:
 | 
			
		||||
                row_max_height = h
 | 
			
		||||
            j += 1
 | 
			
		||||
 | 
			
		||||
        # Use global scale for all rows
 | 
			
		||||
        scale = global_scale
 | 
			
		||||
        scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
 | 
			
		||||
 | 
			
		||||
        # 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
 | 
			
		||||
 | 
			
		||||
        # Доступная ширина ряда с учетом обязательных отступов между элементами
 | 
			
		||||
        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
 | 
			
		||||
        for k in range(i, j):
 | 
			
		||||
            new_w = int(nat_sizes[k, 0] * scale)
 | 
			
		||||
            new_h = int(nat_sizes[k, 1] * scale)
 | 
			
		||||
@@ -90,7 +51,6 @@ 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
 | 
			
		||||
@@ -99,17 +59,18 @@ class FlowLayout(QLayout):
 | 
			
		||||
    def __init__(self, parent=None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.itemList = []
 | 
			
		||||
        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
 | 
			
		||||
        # Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
 | 
			
		||||
        self.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        self._spacing = 3  # отступ между карточками
 | 
			
		||||
        self._max_scale = 1.2  # максимальное увеличение карточек (например, на 20%)
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
@@ -126,21 +87,7 @@ class FlowLayout(QLayout):
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def heightForWidth(self, width):
 | 
			
		||||
        # Аналогично фильтруем видимые для тестового расчёта высоты
 | 
			
		||||
        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
 | 
			
		||||
        return self.doLayout(QRect(0, 0, width, 0), True)
 | 
			
		||||
 | 
			
		||||
    def setGeometry(self, rect):
 | 
			
		||||
        super().setGeometry(rect)
 | 
			
		||||
@@ -155,50 +102,32 @@ 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_total = len(self.itemList)
 | 
			
		||||
        if N_total == 0:
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        # Фильтруем только видимые элементы
 | 
			
		||||
        visible_items = []
 | 
			
		||||
        visible_indices = []  # Индексы в оригинальном itemList для установки геометрии
 | 
			
		||||
        nat_sizes = np.empty((0, 2), dtype=np.int32)
 | 
			
		||||
        for i, item in enumerate(self.itemList):
 | 
			
		||||
            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)
 | 
			
		||||
        N = len(self.itemList)
 | 
			
		||||
        if N == 0:
 | 
			
		||||
            # Если все скрыты, устанавливаем нулевые геометрии для всех
 | 
			
		||||
            if not testOnly:
 | 
			
		||||
                for item in self.itemList:
 | 
			
		||||
                    item.setGeometry(QRect())
 | 
			
		||||
            return 0
 | 
			
		||||
 | 
			
		||||
        # Собираем натуральные размеры всех элементов в массив NumPy
 | 
			
		||||
        nat_sizes = np.empty((N, 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()
 | 
			
		||||
 | 
			
		||||
        # Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
 | 
			
		||||
        geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
 | 
			
		||||
 | 
			
		||||
        if not testOnly:
 | 
			
		||||
            # Устанавливаем геометрии только для видимых
 | 
			
		||||
            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]
 | 
			
		||||
            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]
 | 
			
		||||
                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):
 | 
			
		||||
@@ -223,7 +152,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()
 | 
			
		||||
@@ -241,23 +170,28 @@ 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))
 | 
			
		||||
        font.setPointSize(max(8, font_size))  # Минимальный размер шрифта 8
 | 
			
		||||
        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:
 | 
			
		||||
@@ -266,11 +200,17 @@ 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)
 | 
			
		||||
@@ -340,6 +280,8 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
 | 
			
		||||
        self.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.setFlat(True)
 | 
			
		||||
 | 
			
		||||
        # Изначально выставляем минимальную ширину
 | 
			
		||||
        self.setMinimumWidth(50)
 | 
			
		||||
        self.adjustFontSize()
 | 
			
		||||
 | 
			
		||||
@@ -370,6 +312,7 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
        if not self._update_size:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Определяем доступную ширину внутри кнопки
 | 
			
		||||
        available_width = self.width()
 | 
			
		||||
        if self._icon:
 | 
			
		||||
            available_width -= self._icon_size
 | 
			
		||||
@@ -380,6 +323,7 @@ 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)
 | 
			
		||||
@@ -392,12 +336,14 @@ 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)
 | 
			
		||||
 | 
			
		||||
@@ -407,6 +353,7 @@ 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)
 | 
			
		||||
@@ -417,6 +364,7 @@ class AutoSizeButton(QPushButton):
 | 
			
		||||
            height = fm.height() + margins.top() + margins.bottom() + self._padding
 | 
			
		||||
            return QSize(width, height)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NavLabel(QLabel):
 | 
			
		||||
    clicked = Signal()
 | 
			
		||||
 | 
			
		||||
@@ -428,6 +376,7 @@ 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):
 | 
			
		||||
@@ -446,6 +395,7 @@ 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)
 | 
			
		||||
 
 | 
			
		||||
@@ -2,36 +2,39 @@ 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, read_theme_from_config
 | 
			
		||||
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
 | 
			
		||||
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
 | 
			
		||||
from portprotonqt.animations import GameCardAnimations
 | 
			
		||||
import weakref
 | 
			
		||||
from typing import cast
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameCard(QFrame):
 | 
			
		||||
    borderWidthChanged = Signal()
 | 
			
		||||
    gradientAngleChanged = Signal()
 | 
			
		||||
    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)
 | 
			
		||||
    # 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
 | 
			
		||||
    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
 | 
			
		||||
@@ -46,16 +49,14 @@ class GameCard(QFrame):
 | 
			
		||||
        self.game_source = game_source
 | 
			
		||||
        self.last_launch_ts = last_launch_ts
 | 
			
		||||
        self.playtime_seconds = playtime_seconds
 | 
			
		||||
        self.base_card_width = card_width
 | 
			
		||||
        self.base_pixmap = None
 | 
			
		||||
        self.base_font_size = None
 | 
			
		||||
        self.card_width = card_width
 | 
			
		||||
 | 
			
		||||
        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 self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
 | 
			
		||||
        self.display_filter = read_display_filter()
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
@@ -66,46 +67,75 @@ 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"))
 | 
			
		||||
 | 
			
		||||
        self.base_extra_margin = 20
 | 
			
		||||
        # Дополнительное пространство для анимации
 | 
			
		||||
        extra_margin = 20
 | 
			
		||||
        self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
 | 
			
		||||
        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.animations = GameCardAnimations(self, self.theme)
 | 
			
		||||
        self.animations.setup_animations()
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        self.coverWidget = QWidget()
 | 
			
		||||
        coverLayout = QStackedLayout(self.coverWidget)
 | 
			
		||||
        # Контейнер обложки
 | 
			
		||||
        coverWidget = QWidget()
 | 
			
		||||
        coverWidget.setFixedSize(card_width, int(card_width * 1.2))
 | 
			
		||||
        coverLayout = QStackedLayout(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)
 | 
			
		||||
 | 
			
		||||
        load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded)
 | 
			
		||||
        # создаём слабую ссылку на label
 | 
			
		||||
        label_ref = weakref.ref(self.coverLabel)
 | 
			
		||||
 | 
			
		||||
        self.favoriteLabel = ClickableLabel(self.coverWidget)
 | 
			
		||||
        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.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)
 | 
			
		||||
@@ -113,50 +143,67 @@ class GameCard(QFrame):
 | 
			
		||||
            self.protondbLabel = ClickableLabel(
 | 
			
		||||
                tier_text,
 | 
			
		||||
                icon=icon,
 | 
			
		||||
                parent=self.coverWidget,
 | 
			
		||||
                font_scale_factor=0.06
 | 
			
		||||
                parent=coverWidget,
 | 
			
		||||
                icon_size=icon_size,
 | 
			
		||||
                icon_space=icon_space,
 | 
			
		||||
                font_scale_factor=font_scale_factor
 | 
			
		||||
            )
 | 
			
		||||
            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=self.coverWidget)
 | 
			
		||||
            self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
 | 
			
		||||
            self.protondbLabel.setFixedWidth(badge_width)
 | 
			
		||||
            self.protondbLabel.setVisible(False)
 | 
			
		||||
 | 
			
		||||
        # Steam бейдж
 | 
			
		||||
        steam_icon = self.theme_manager.get_icon("steam")
 | 
			
		||||
        self.steamLabel = ClickableLabel(
 | 
			
		||||
            "Steam",
 | 
			
		||||
            icon=steam_icon,
 | 
			
		||||
            parent=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor
 | 
			
		||||
        )
 | 
			
		||||
        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=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06,
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor,
 | 
			
		||||
            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("portproton")
 | 
			
		||||
        self.portprotonLabel = ClickableLabel(
 | 
			
		||||
            "PortProton",
 | 
			
		||||
            icon=portproton_icon,
 | 
			
		||||
            parent=self.coverWidget,
 | 
			
		||||
            font_scale_factor=0.06
 | 
			
		||||
            parent=coverWidget,
 | 
			
		||||
            icon_size=icon_size,
 | 
			
		||||
            icon_space=icon_space,
 | 
			
		||||
            font_scale_factor=font_scale_factor
 | 
			
		||||
        )
 | 
			
		||||
        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)
 | 
			
		||||
@@ -164,57 +211,40 @@ class GameCard(QFrame):
 | 
			
		||||
            self.anticheatLabel = ClickableLabel(
 | 
			
		||||
                anticheat_text,
 | 
			
		||||
                icon=icon,
 | 
			
		||||
                parent=self.coverWidget,
 | 
			
		||||
                font_scale_factor=0.06
 | 
			
		||||
                parent=coverWidget,
 | 
			
		||||
                icon_size=icon_size,
 | 
			
		||||
                icon_space=icon_space,
 | 
			
		||||
                font_scale_factor=font_scale_factor
 | 
			
		||||
            )
 | 
			
		||||
            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=self.coverWidget)
 | 
			
		||||
            self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
 | 
			
		||||
            self.anticheatLabel.setFixedWidth(badge_width)
 | 
			
		||||
            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)
 | 
			
		||||
 | 
			
		||||
        self.layout_.addWidget(self.coverWidget)
 | 
			
		||||
        layout.addWidget(coverWidget)
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
        # Название игры
 | 
			
		||||
        nameLabel = QLabel(name)
 | 
			
		||||
        nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
 | 
			
		||||
        layout.addWidget(nameLabel)
 | 
			
		||||
 | 
			
		||||
        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)
 | 
			
		||||
    def _position_badges(self, card_width):
 | 
			
		||||
        """Позиционирует бейджи на основе ширины карточки."""
 | 
			
		||||
        right_margin = 8
 | 
			
		||||
        badge_spacing = int(card_width * 0.02)  # 2% от ширины карточки
 | 
			
		||||
        top_y = 10
 | 
			
		||||
        badge_y_positions = []
 | 
			
		||||
        badge_width = int(current_width * 2/3)
 | 
			
		||||
        badge_width = int(card_width * 2/3)
 | 
			
		||||
 | 
			
		||||
        badges = [
 | 
			
		||||
            (self.steam_visible, self.steamLabel),
 | 
			
		||||
@@ -226,99 +256,80 @@ class GameCard(QFrame):
 | 
			
		||||
 | 
			
		||||
        for is_visible, badge in badges:
 | 
			
		||||
            if is_visible:
 | 
			
		||||
                badge_x = current_width - badge_width - right_margin
 | 
			
		||||
                badge_x = card_width - badge_width - right_margin
 | 
			
		||||
                badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
 | 
			
		||||
                badge.move(int(badge_x), int(badge_y))
 | 
			
		||||
                badge.move(badge_x, 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_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)
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
        self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5))
 | 
			
		||||
        self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5))
 | 
			
		||||
        if self.coverLabel is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        self.update_cover_pixmap()
 | 
			
		||||
        coverWidget = self.coverLabel.parentWidget()
 | 
			
		||||
        if coverWidget is None:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        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))
 | 
			
		||||
        coverWidget.setFixedSize(new_width, int(new_width * 1.2))
 | 
			
		||||
        self.coverLabel.setFixedSize(new_width, int(new_width * 1.2))
 | 
			
		||||
 | 
			
		||||
        badge_width = int(scaled_width * 2/3)
 | 
			
		||||
        icon_size = int(scaled_width * 0.06)
 | 
			
		||||
        icon_space = int(scaled_width * 0.012)
 | 
			
		||||
        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)
 | 
			
		||||
        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(scaled_width)
 | 
			
		||||
                label.setCardWidth(new_width)  # Пересчитываем размер шрифта
 | 
			
		||||
 | 
			
		||||
        self._position_badges(scaled_width)
 | 
			
		||||
        # Перепозиционируем бейджи
 | 
			
		||||
        self._position_badges(new_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 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"))
 | 
			
		||||
        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"))
 | 
			
		||||
        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)
 | 
			
		||||
 | 
			
		||||
        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()
 | 
			
		||||
        # Перепозиционируем бейджи
 | 
			
		||||
        self._position_badges(self.card_width)
 | 
			
		||||
 | 
			
		||||
    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)
 | 
			
		||||
 | 
			
		||||
@@ -376,6 +387,7 @@ 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="):
 | 
			
		||||
@@ -435,19 +447,8 @@ 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)
 | 
			
		||||
@@ -486,7 +487,6 @@ 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(
 | 
			
		||||
 
 | 
			
		||||
@@ -1,470 +0,0 @@
 | 
			
		||||
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
 | 
			
		||||
    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 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)
 | 
			
		||||
        self.main_window.card_width = self.card_width
 | 
			
		||||
        self.main_window._last_card_width = 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 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 _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)
 | 
			
		||||
@@ -3,6 +3,7 @@ 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
 | 
			
		||||
@@ -176,8 +177,7 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
 | 
			
		||||
        self.images = images
 | 
			
		||||
        self.current_index = current_index
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
 | 
			
		||||
        self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
 | 
			
		||||
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
 | 
			
		||||
@@ -378,8 +378,7 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        self.images = images  # Список кортежей: (QPixmap, caption)
 | 
			
		||||
        self.image_items = []
 | 
			
		||||
        self._animation = None
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
        self.max_height = 300  # Default height for images
 | 
			
		||||
        self.init_ui()
 | 
			
		||||
        self.create_arrows()
 | 
			
		||||
 
 | 
			
		||||
@@ -1,73 +0,0 @@
 | 
			
		||||
# 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-10-16 14:54+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-08-23 20:35+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 directory not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
@@ -41,6 +35,12 @@ msgstr ""
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -155,7 +155,7 @@ msgid "Menu"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No executable command found in .desktop file for '{game_name}'"
 | 
			
		||||
msgid "No executable command in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Error reading .desktop file: {error}"
 | 
			
		||||
msgid "Failed to read .desktop file: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -191,10 +191,6 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -252,48 +248,16 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
@@ -332,39 +296,6 @@ 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 "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 ""
 | 
			
		||||
 | 
			
		||||
@@ -413,6 +344,9 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -422,34 +356,6 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -462,106 +368,13 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
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: {}"
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -597,9 +410,6 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -624,12 +434,6 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -642,6 +446,21 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -651,6 +470,28 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -704,6 +545,9 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -807,24 +651,3 @@ 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-10-16 14:54+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-08-23 20:35+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 directory not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
@@ -41,6 +35,12 @@ msgstr ""
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -155,7 +155,7 @@ msgid "Menu"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No executable command found in .desktop file for '{game_name}'"
 | 
			
		||||
msgid "No executable command in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Error reading .desktop file: {error}"
 | 
			
		||||
msgid "Failed to read .desktop file: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -191,10 +191,6 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -252,48 +248,16 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
@@ -332,39 +296,6 @@ 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 "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 ""
 | 
			
		||||
 | 
			
		||||
@@ -413,6 +344,9 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -422,34 +356,6 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -462,106 +368,13 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
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: {}"
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -597,9 +410,6 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -624,12 +434,6 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -642,6 +446,21 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -651,6 +470,28 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -704,6 +545,9 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -807,24 +651,3 @@ 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-10-16 14:54+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-08-23 20:35+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 directory not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
@@ -39,6 +33,12 @@ msgstr ""
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -153,7 +153,7 @@ msgid "Menu"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No executable command found in .desktop file for '{game_name}'"
 | 
			
		||||
msgid "No executable command in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -161,7 +161,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Error reading .desktop file: {error}"
 | 
			
		||||
msgid "Failed to read .desktop file: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
@@ -189,10 +189,6 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -250,48 +246,16 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
@@ -330,39 +294,6 @@ 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 "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 ""
 | 
			
		||||
 | 
			
		||||
@@ -411,6 +342,9 @@ msgstr ""
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -420,34 +354,6 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -460,106 +366,13 @@ msgstr ""
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
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: {}"
 | 
			
		||||
msgid "Various Wine parameters and versions..."
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
@@ -595,9 +408,6 @@ msgstr ""
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -622,12 +432,6 @@ msgstr ""
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -640,6 +444,21 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -649,6 +468,28 @@ 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 ""
 | 
			
		||||
 | 
			
		||||
@@ -702,6 +543,9 @@ msgstr ""
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -805,24 +649,3 @@ 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-10-16 14:54+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-10-16 14:54+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-08-23 20:35+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-08-23 20:35+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 directory not found"
 | 
			
		||||
msgstr "Не найден каталог PortProton"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr "Удалить из Избранного"
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr "Добавить в Избранное"
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr "PortProton не найден"
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr "Удалить из PortProton"
 | 
			
		||||
@@ -42,6 +36,12 @@ msgstr "Остановить игру"
 | 
			
		||||
msgid "Launch Game"
 | 
			
		||||
msgstr "Запустить игру"
 | 
			
		||||
 | 
			
		||||
msgid "Remove from Favorites"
 | 
			
		||||
msgstr "Удалить из Избранного"
 | 
			
		||||
 | 
			
		||||
msgid "Add to Favorites"
 | 
			
		||||
msgstr "Добавить в Избранное"
 | 
			
		||||
 | 
			
		||||
msgid "Import to Legendary"
 | 
			
		||||
msgstr "Импортировать игру"
 | 
			
		||||
 | 
			
		||||
@@ -158,16 +158,16 @@ msgid "Menu"
 | 
			
		||||
msgstr "Меню"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No executable command found in .desktop file for '{game_name}'"
 | 
			
		||||
msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'"
 | 
			
		||||
msgid "No executable command 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 "Error reading .desktop file: {error}"
 | 
			
		||||
msgstr "Ошибка при чтении файла .desktop: {error}"
 | 
			
		||||
msgid "Failed to read .desktop file: {error}"
 | 
			
		||||
msgstr "Не удалось прочитать файл .desktop: {error}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
@@ -196,10 +196,6 @@ 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 "Требуются название игры и путь к исполняемому файлу"
 | 
			
		||||
 | 
			
		||||
@@ -259,50 +255,18 @@ msgstr "Удалить"
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr "Выбрать всё"
 | 
			
		||||
 | 
			
		||||
msgid "Open"
 | 
			
		||||
msgstr "Открыть"
 | 
			
		||||
 | 
			
		||||
msgid "Select Dir"
 | 
			
		||||
msgstr "Выбрать папку"
 | 
			
		||||
 | 
			
		||||
msgid "Prev Dir"
 | 
			
		||||
msgstr "Предыдущий каталог"
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "Отмена"
 | 
			
		||||
 | 
			
		||||
msgid "Toggle"
 | 
			
		||||
msgstr "Переключить"
 | 
			
		||||
 | 
			
		||||
msgid "Install"
 | 
			
		||||
msgstr "Установить"
 | 
			
		||||
 | 
			
		||||
msgid "Force Install"
 | 
			
		||||
msgstr "Принудительно установить"
 | 
			
		||||
 | 
			
		||||
msgid "Prev Tab"
 | 
			
		||||
msgstr "Предыдущая вкладка"
 | 
			
		||||
 | 
			
		||||
msgid "Next Tab"
 | 
			
		||||
msgstr "Следующая вкладка"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Launching {0}"
 | 
			
		||||
msgstr "Идёт запуск {0}"
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr "Проводник"
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr "Выбрать"
 | 
			
		||||
 | 
			
		||||
msgid "Cancel"
 | 
			
		||||
msgstr "Отмена"
 | 
			
		||||
 | 
			
		||||
msgid "Path: "
 | 
			
		||||
msgstr "Путь: "
 | 
			
		||||
 | 
			
		||||
#, python-format
 | 
			
		||||
msgid "Access denied: %s"
 | 
			
		||||
msgstr "Доступ запрещён: %s"
 | 
			
		||||
 | 
			
		||||
msgid "Edit Game"
 | 
			
		||||
msgstr "Редактировать игру"
 | 
			
		||||
 | 
			
		||||
@@ -339,39 +303,6 @@ 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 "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..."
 | 
			
		||||
 | 
			
		||||
@@ -420,6 +351,9 @@ msgstr "Библиотека"
 | 
			
		||||
msgid "Auto Install"
 | 
			
		||||
msgstr "Автоустановка"
 | 
			
		||||
 | 
			
		||||
msgid "Emulators"
 | 
			
		||||
msgstr "Эмуляторы"
 | 
			
		||||
 | 
			
		||||
msgid "Wine Settings"
 | 
			
		||||
msgstr "Настройки wine"
 | 
			
		||||
 | 
			
		||||
@@ -429,34 +363,6 @@ 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..."
 | 
			
		||||
 | 
			
		||||
@@ -469,109 +375,14 @@ msgstr "Игровая библиотека"
 | 
			
		||||
msgid "Find Games ..."
 | 
			
		||||
msgstr "Найти игры..."
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Added '{name}'"
 | 
			
		||||
msgstr "'{name}' добавлен(а)"
 | 
			
		||||
msgid "Here you can configure automatic game installation..."
 | 
			
		||||
msgstr "Здесь можно настроить автоматическую установку игр..."
 | 
			
		||||
 | 
			
		||||
msgid "Compatibility tool:"
 | 
			
		||||
msgstr "Инструмент совместимости:"
 | 
			
		||||
msgid "List of available emulators and their configuration..."
 | 
			
		||||
msgstr "Список доступных эмуляторов и их настройка..."
 | 
			
		||||
 | 
			
		||||
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 "Various Wine parameters and versions..."
 | 
			
		||||
msgstr "Различные параметры и версии wine..."
 | 
			
		||||
 | 
			
		||||
msgid "Main PortProton parameters..."
 | 
			
		||||
msgstr "Основные параметры PortProton..."
 | 
			
		||||
@@ -606,9 +417,6 @@ msgstr "все"
 | 
			
		||||
msgid "Games Display Filter:"
 | 
			
		||||
msgstr "Фильтр игр:"
 | 
			
		||||
 | 
			
		||||
msgid "Gamepad Type:"
 | 
			
		||||
msgstr "Тип геймпада:"
 | 
			
		||||
 | 
			
		||||
msgid "Proxy URL"
 | 
			
		||||
msgstr "Адрес прокси"
 | 
			
		||||
 | 
			
		||||
@@ -633,12 +441,6 @@ msgstr "Запуск приложения в полноэкранном режи
 | 
			
		||||
msgid "Application Fullscreen Mode:"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения:"
 | 
			
		||||
 | 
			
		||||
msgid "Minimize to tray on close"
 | 
			
		||||
msgstr "Сворачивать в трей при закрытии"
 | 
			
		||||
 | 
			
		||||
msgid "Application Close Mode:"
 | 
			
		||||
msgstr "Режим закрытия приложения:"
 | 
			
		||||
 | 
			
		||||
msgid "Auto Fullscreen on Gamepad connected"
 | 
			
		||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
 | 
			
		||||
 | 
			
		||||
@@ -651,6 +453,21 @@ 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 "Сохранить настройки"
 | 
			
		||||
 | 
			
		||||
@@ -660,6 +477,28 @@ 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 "Подтвердите удаление"
 | 
			
		||||
 | 
			
		||||
@@ -715,6 +554,9 @@ msgstr "Тема '{0}' применена успешно"
 | 
			
		||||
msgid "Error applying theme '{0}'"
 | 
			
		||||
msgstr "Ошибка при применение темы '{0}'"
 | 
			
		||||
 | 
			
		||||
msgid "Back"
 | 
			
		||||
msgstr "Назад"
 | 
			
		||||
 | 
			
		||||
msgid "LAST LAUNCH"
 | 
			
		||||
msgstr "Последний запуск"
 | 
			
		||||
 | 
			
		||||
@@ -818,24 +660,3 @@ 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,34 +1,16 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
def setup_logger(level='NOTSET'):
 | 
			
		||||
def setup_logger():
 | 
			
		||||
    """Настройка базовой конфигурации логирования."""
 | 
			
		||||
    # 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()]
 | 
			
		||||
        )
 | 
			
		||||
    logging.basicConfig(
 | 
			
		||||
        level=logging.INFO,
 | 
			
		||||
        format='[%(levelname)s] %(message)s',
 | 
			
		||||
        handlers=[logging.StreamHandler()]
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
def get_logger(name):
 | 
			
		||||
    """Возвращает логгер для указанного модуля."""
 | 
			
		||||
    return logging.getLogger(name)
 | 
			
		||||
 | 
			
		||||
# Инициализация логгера при импорте модуля (без логов по умолчанию)
 | 
			
		||||
# Инициализация логгера при импорте модуля
 | 
			
		||||
setup_logger()
 | 
			
		||||
 
 | 
			
		||||
@@ -4,12 +4,9 @@ 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
 | 
			
		||||
@@ -55,9 +52,6 @@ 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:
 | 
			
		||||
@@ -74,6 +68,40 @@ 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"]
 | 
			
		||||
@@ -135,164 +163,6 @@ 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:
 | 
			
		||||
 
 | 
			
		||||
@@ -1,49 +0,0 @@
 | 
			
		||||
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
 | 
			
		||||
@@ -22,7 +22,6 @@ import websocket
 | 
			
		||||
import requests
 | 
			
		||||
import random
 | 
			
		||||
import base64
 | 
			
		||||
import glob
 | 
			
		||||
 | 
			
		||||
downloader = Downloader()
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -45,14 +44,14 @@ def safe_vdf_load(path: str | Path) -> dict:
 | 
			
		||||
 | 
			
		||||
def decode_text(text: str) -> str:
 | 
			
		||||
    """
 | 
			
		||||
    Decodes HTML entities in a string.
 | 
			
		||||
    For example, "&quot;" is converted to '"'.
 | 
			
		||||
    Other characters and HTML tags remain unchanged.
 | 
			
		||||
    Декодирует HTML-сущности в строке.
 | 
			
		||||
    Например, "&quot;" преобразуется в '"'.
 | 
			
		||||
    Остальные символы и HTML-теги остаются без изменений.
 | 
			
		||||
    """
 | 
			
		||||
    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)
 | 
			
		||||
@@ -65,7 +64,7 @@ STEAM_DATA_DIRS = (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
def get_steam_home():
 | 
			
		||||
    """Returns the path to the Steam directory using a list of possible directories."""
 | 
			
		||||
    """Возвращает путь к директории Steam, используя список возможных директорий."""
 | 
			
		||||
    for dir_path in STEAM_DATA_DIRS:
 | 
			
		||||
        expanded_path = Path(os.path.expanduser(dir_path))
 | 
			
		||||
        if expanded_path.exists():
 | 
			
		||||
@@ -73,7 +72,7 @@ def get_steam_home():
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def get_last_steam_user(steam_home: Path) -> dict | None:
 | 
			
		||||
    """Returns data for the last Steam user from loginusers.vdf."""
 | 
			
		||||
    """Возвращает данные последнего пользователя Steam из loginusers.vdf."""
 | 
			
		||||
    loginusers_path = steam_home / "config/loginusers.vdf"
 | 
			
		||||
    data = safe_vdf_load(loginusers_path)
 | 
			
		||||
    if not data:
 | 
			
		||||
@@ -84,20 +83,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
 | 
			
		||||
            try:
 | 
			
		||||
                return {'SteamID': int(user_id)}
 | 
			
		||||
            except ValueError:
 | 
			
		||||
                logger.error(f"Invalid SteamID format: {user_id}")
 | 
			
		||||
                logger.error(f"Неверный формат SteamID: {user_id}")
 | 
			
		||||
                return None
 | 
			
		||||
    logger.info("No user found with MostRecent=1")
 | 
			
		||||
    logger.info("Не найден пользователь с MostRecent=1")
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def convert_steam_id(steam_id: int) -> int:
 | 
			
		||||
    """
 | 
			
		||||
    Converts a signed 32-bit integer to an unsigned 32-bit integer.
 | 
			
		||||
    Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values.
 | 
			
		||||
    Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число.
 | 
			
		||||
    Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения.
 | 
			
		||||
    """
 | 
			
		||||
    return steam_id & 0xFFFFFFFF
 | 
			
		||||
 | 
			
		||||
def get_steam_libs(steam_dir: Path) -> set[Path]:
 | 
			
		||||
    """Returns a set of Steam library folders."""
 | 
			
		||||
    """Возвращает набор директорий Steam libraryfolders."""
 | 
			
		||||
    libs = set()
 | 
			
		||||
    libs_vdf = steam_dir / "steamapps/libraryfolders.vdf"
 | 
			
		||||
    data = safe_vdf_load(libs_vdf)
 | 
			
		||||
@@ -113,7 +112,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()
 | 
			
		||||
@@ -133,14 +132,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
 | 
			
		||||
        return play_data
 | 
			
		||||
 | 
			
		||||
    if not last_user:
 | 
			
		||||
        logger.info("Could not identify the last Steam user")
 | 
			
		||||
        logger.info("Не удалось определить последнего пользователя Steam")
 | 
			
		||||
        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"User directory {unsigned_id} not found")
 | 
			
		||||
        logger.info(f"Директория пользователя {unsigned_id} не найдена")
 | 
			
		||||
        return play_data
 | 
			
		||||
 | 
			
		||||
    localconfig = user_dir / "config/localconfig.vdf"
 | 
			
		||||
@@ -154,11 +153,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"Invalid playtime data for app {appid_str}")
 | 
			
		||||
            logger.warning(f"Некорректные данные playtime для app {appid_str}")
 | 
			
		||||
    return play_data
 | 
			
		||||
 | 
			
		||||
def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
 | 
			
		||||
    """Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec)."""
 | 
			
		||||
    """Возвращает список установленных Steam игр в формате (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():
 | 
			
		||||
@@ -187,13 +186,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
 | 
			
		||||
 | 
			
		||||
def normalize_name(s):
 | 
			
		||||
    """
 | 
			
		||||
    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.
 | 
			
		||||
    Приведение строки к нормальному виду:
 | 
			
		||||
      - перевод в нижний регистр,
 | 
			
		||||
      - удаление символов ™ и ®,
 | 
			
		||||
      - замена разделителей (-, :, ,) на пробел,
 | 
			
		||||
      - удаление лишних пробелов,
 | 
			
		||||
      - удаление суффиксов 'bin' или 'app' в конце строки,
 | 
			
		||||
      - удаление ключевых слов типа 'ultimate', 'edition' и т.п.
 | 
			
		||||
    """
 | 
			
		||||
    s = s.lower()
 | 
			
		||||
    for ch in ["™", "®"]:
 | 
			
		||||
@@ -211,28 +210,14 @@ def normalize_name(s):
 | 
			
		||||
 | 
			
		||||
def is_valid_candidate(candidate):
 | 
			
		||||
    """
 | 
			
		||||
    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.
 | 
			
		||||
    Проверяет, содержит ли кандидат запрещённые подстроки:
 | 
			
		||||
      - win32
 | 
			
		||||
      - win64
 | 
			
		||||
      - gamelauncher
 | 
			
		||||
    Для проверки дополнительно используется строка без пробелов.
 | 
			
		||||
    Возвращает True, если кандидат допустим, иначе False.
 | 
			
		||||
    """
 | 
			
		||||
    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:
 | 
			
		||||
@@ -242,7 +227,7 @@ def is_valid_candidate(candidate):
 | 
			
		||||
 | 
			
		||||
def filter_candidates(candidates):
 | 
			
		||||
    """
 | 
			
		||||
    Filters a list of candidates, discarding invalid ones.
 | 
			
		||||
    Фильтрует список кандидатов, отбрасывая недопустимые.
 | 
			
		||||
    """
 | 
			
		||||
    valid = []
 | 
			
		||||
    dropped = []
 | 
			
		||||
@@ -252,18 +237,18 @@ def filter_candidates(candidates):
 | 
			
		||||
        else:
 | 
			
		||||
            dropped.append(cand)
 | 
			
		||||
    if dropped:
 | 
			
		||||
        logger.info("Discarding candidates: %s", dropped)
 | 
			
		||||
        logger.info("Отбрасываю кандидатов: %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):
 | 
			
		||||
    """Retrieves metadata using exiftool."""
 | 
			
		||||
    """Получает метаданные через exiftool"""
 | 
			
		||||
    try:
 | 
			
		||||
        proc = subprocess.run(
 | 
			
		||||
            ["exiftool", "-j", game_exe],
 | 
			
		||||
@@ -272,28 +257,18 @@ def get_exiftool_data(game_exe):
 | 
			
		||||
            check=False
 | 
			
		||||
        )
 | 
			
		||||
        if proc.returncode != 0:
 | 
			
		||||
            logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}")
 | 
			
		||||
            logger.error(f"exiftool error 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"Unexpected error in get_exiftool_data for {game_exe}: {e}")
 | 
			
		||||
        logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
 | 
			
		||||
        return {}
 | 
			
		||||
 | 
			
		||||
def delete_cached_app_files(cache_dir: str, pattern: str):
 | 
			
		||||
    """Deletes cached files matching the given pattern in the cache directory."""
 | 
			
		||||
    try:
 | 
			
		||||
        for file_path in glob.glob(os.path.join(cache_dir, pattern)):
 | 
			
		||||
            os.remove(file_path)
 | 
			
		||||
            logger.info(f"Deleted cached file: {file_path}")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to delete cached files matching {pattern}: {e}")
 | 
			
		||||
 | 
			
		||||
def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously loads the list of Steam applications, using cache if available.
 | 
			
		||||
    Calls the callback with the list of apps.
 | 
			
		||||
    Deletes cached app detail files when downloading a new steam_apps.json.
 | 
			
		||||
    """
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_tar = os.path.join(cache_dir, "games_appid.tar.xz")
 | 
			
		||||
@@ -319,14 +294,12 @@ 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("Deleted archive: %s", cache_tar)
 | 
			
		||||
            # Delete all cached app detail files (steam_app_*.json)
 | 
			
		||||
            delete_cached_app_files(cache_dir, "steam_app_*.json")
 | 
			
		||||
                logger.info("Archive %s deleted after extraction", cache_tar)
 | 
			
		||||
            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("Failed to extract Steam apps archive: %s", e)
 | 
			
		||||
            logger.error("Error extracting Steam apps archive: %s", e)
 | 
			
		||||
            callback([])
 | 
			
		||||
 | 
			
		||||
    if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
 | 
			
		||||
@@ -336,41 +309,37 @@ def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
                data = orjson.loads(f.read())
 | 
			
		||||
            # Validate JSON structure
 | 
			
		||||
            if not isinstance(data, list):
 | 
			
		||||
                logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
 | 
			
		||||
                logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
 | 
			
		||||
                raise ValueError("Invalid JSON structure")
 | 
			
		||||
            # Validate each app entry
 | 
			
		||||
            for app in data:
 | 
			
		||||
                if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
 | 
			
		||||
                    logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json)
 | 
			
		||||
                    logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json)
 | 
			
		||||
                    raise ValueError("Invalid app entry structure")
 | 
			
		||||
            steam_apps = data
 | 
			
		||||
            logger.info("Loaded %d apps from cache", len(steam_apps))
 | 
			
		||||
            callback(steam_apps)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e)
 | 
			
		||||
            logger.error("Error reading or validating cached JSON %s: %s", cache_json, e)
 | 
			
		||||
            # Attempt to re-download if cache is invalid or corrupted
 | 
			
		||||
            app_list_url = (
 | 
			
		||||
                "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
 | 
			
		||||
            )
 | 
			
		||||
            # 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):
 | 
			
		||||
    """
 | 
			
		||||
    Builds an index of applications by normalized_name field.
 | 
			
		||||
    Строит индекс приложений по полю normalized_name.
 | 
			
		||||
    """
 | 
			
		||||
    steam_apps_index = {}
 | 
			
		||||
    if not steam_apps:
 | 
			
		||||
        return steam_apps_index
 | 
			
		||||
    logger.info("Building Steam apps index")
 | 
			
		||||
    logger.info("Построение индекса Steam приложений:")
 | 
			
		||||
    for app in steam_apps:
 | 
			
		||||
        normalized = app["normalized_name"]
 | 
			
		||||
        steam_apps_index[normalized] = app
 | 
			
		||||
@@ -378,24 +347,25 @@ 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("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm)
 | 
			
		||||
    logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm)
 | 
			
		||||
    if candidate_norm in steam_apps_index:
 | 
			
		||||
        logger.info("Found exact match: '%s'", candidate_norm)
 | 
			
		||||
        logger.info("    Найдено точное совпадение: '%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("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio)
 | 
			
		||||
                logger.info("    Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)",
 | 
			
		||||
                            candidate_norm, name_norm, ratio)
 | 
			
		||||
                return app
 | 
			
		||||
    logger.info("No app found for candidate '%s'", candidate_norm)
 | 
			
		||||
    logger.info("    Приложение для кандидата '%s' не найдено", candidate_norm)
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def load_app_details(app_id):
 | 
			
		||||
    """Loads cached game data by appid if not outdated."""
 | 
			
		||||
    """Загружает кэшированные данные для игры по appid, если они не устарели."""
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
 | 
			
		||||
    if os.path.exists(cache_file):
 | 
			
		||||
@@ -405,7 +375,7 @@ def load_app_details(app_id):
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def save_app_details(app_id, data):
 | 
			
		||||
    """Saves appid data to a cache file."""
 | 
			
		||||
    """Сохраняет данные по appid в файл кэша."""
 | 
			
		||||
    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:
 | 
			
		||||
@@ -448,7 +418,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("Failed to process Steam app info for appid %s: %s", app_id, e)
 | 
			
		||||
            logger.error("Error processing Steam app info for appid %s: %s", app_id, e)
 | 
			
		||||
            callback(None)
 | 
			
		||||
 | 
			
		||||
    downloader.download_async(url, cache_file, timeout=5, callback=process_response)
 | 
			
		||||
@@ -457,7 +427,6 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously loads the list of WeAntiCheatYet data, using cache if available.
 | 
			
		||||
    Calls the callback with the list of anti-cheat data.
 | 
			
		||||
    Deletes cached anti-cheat files when downloading a new anticheat_games.json.
 | 
			
		||||
    """
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz")
 | 
			
		||||
@@ -483,12 +452,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("Deleted archive: %s", cache_tar)
 | 
			
		||||
                logger.info("Archive %s deleted after extraction", 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("Failed to extract WeAntiCheatYet archive: %s", e)
 | 
			
		||||
            logger.error("Error extracting WeAntiCheatYet archive: %s", e)
 | 
			
		||||
            callback([])
 | 
			
		||||
 | 
			
		||||
    if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
 | 
			
		||||
@@ -498,18 +467,18 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
                data = orjson.loads(f.read())
 | 
			
		||||
            # Validate JSON structure
 | 
			
		||||
            if not isinstance(data, list):
 | 
			
		||||
                logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json)
 | 
			
		||||
                logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
 | 
			
		||||
                raise ValueError("Invalid JSON structure")
 | 
			
		||||
            # Validate each anti-cheat entry
 | 
			
		||||
            for entry in data:
 | 
			
		||||
                if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
 | 
			
		||||
                    logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json)
 | 
			
		||||
                    logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json)
 | 
			
		||||
                    raise ValueError("Invalid anti-cheat entry structure")
 | 
			
		||||
            anti_cheat_data = data
 | 
			
		||||
            logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
 | 
			
		||||
            callback(anti_cheat_data)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e)
 | 
			
		||||
            logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e)
 | 
			
		||||
            # Attempt to re-download if cache is invalid or corrupted
 | 
			
		||||
            app_list_url = (
 | 
			
		||||
                "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
 | 
			
		||||
@@ -523,12 +492,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
 | 
			
		||||
def build_weanticheatyet_index(anti_cheat_data):
 | 
			
		||||
    """
 | 
			
		||||
    Builds an index of anti-cheat data by normalized_name field.
 | 
			
		||||
    Строит индекс античит-данных по полю normalized_name.
 | 
			
		||||
    """
 | 
			
		||||
    anti_cheat_index = {}
 | 
			
		||||
    if not anti_cheat_data:
 | 
			
		||||
        return anti_cheat_index
 | 
			
		||||
    logger.info("Building WeAntiCheatYet data index")
 | 
			
		||||
    logger.info("Построение индекса WeAntiCheatYet данных:")
 | 
			
		||||
    for entry in anti_cheat_data:
 | 
			
		||||
        normalized = entry["normalized_name"]
 | 
			
		||||
        anti_cheat_index[normalized] = entry
 | 
			
		||||
@@ -536,19 +505,20 @@ def build_weanticheatyet_index(anti_cheat_data):
 | 
			
		||||
 | 
			
		||||
def search_anticheat_status(candidate, anti_cheat_index):
 | 
			
		||||
    candidate_norm = normalize_name(candidate)
 | 
			
		||||
    logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm)
 | 
			
		||||
    logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm)
 | 
			
		||||
    if candidate_norm in anti_cheat_index:
 | 
			
		||||
        status = anti_cheat_index[candidate_norm]["status"]
 | 
			
		||||
        logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status)
 | 
			
		||||
        logger.info("    Найдено точное совпадение: '%s', статус: '%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("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status)
 | 
			
		||||
                logger.info("    Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'",
 | 
			
		||||
                            candidate_norm, name_norm, ratio, status)
 | 
			
		||||
                return status
 | 
			
		||||
    logger.info("No anti-cheat status found for candidate '%s'", candidate_norm)
 | 
			
		||||
    logger.info("    Античит-статус для кандидата '%s' не найден", candidate_norm)
 | 
			
		||||
    return ""
 | 
			
		||||
 | 
			
		||||
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
 | 
			
		||||
@@ -564,7 +534,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):
 | 
			
		||||
    """Loads cached ProtonDB data for a game by appid if not outdated."""
 | 
			
		||||
    """Загружает закешированные данные ProtonDB для игры по appid, если они не устарели."""
 | 
			
		||||
    cache_dir = get_cache_dir()
 | 
			
		||||
    cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
 | 
			
		||||
    if os.path.exists(cache_file):
 | 
			
		||||
@@ -573,18 +543,18 @@ def load_protondb_status(appid):
 | 
			
		||||
                with open(cache_file, "rb") as f:
 | 
			
		||||
                    return orjson.loads(f.read())
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e)
 | 
			
		||||
                logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e)
 | 
			
		||||
    return None
 | 
			
		||||
 | 
			
		||||
def save_protondb_status(appid, data):
 | 
			
		||||
    """Saves ProtonDB data for a game by appid to a cache file."""
 | 
			
		||||
    """Сохраняет данные ProtonDB для игры по appid в файл кэша."""
 | 
			
		||||
    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("Failed to save ProtonDB cache for appid %s: %s", appid, e)
 | 
			
		||||
        logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e)
 | 
			
		||||
 | 
			
		||||
def get_protondb_tier_async(appid: int, callback: Callable[[str], None]):
 | 
			
		||||
    """
 | 
			
		||||
@@ -672,7 +642,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("Failed to process bat file %s: %s", game_exe, e)
 | 
			
		||||
                logger.error("Error processing bat file %s: %s", game_exe, e)
 | 
			
		||||
        else:
 | 
			
		||||
            logger.error("Bat file not found: %s", game_exe)
 | 
			
		||||
 | 
			
		||||
@@ -807,55 +777,55 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
 | 
			
		||||
 | 
			
		||||
def enable_steam_cef() -> tuple[bool, str]:
 | 
			
		||||
    """
 | 
			
		||||
    Checks and enables Steam CEF remote debugging if necessary.
 | 
			
		||||
    Проверяет и при необходимости активирует режим удаленной отладки Steam CEF.
 | 
			
		||||
 | 
			
		||||
    Creates a .cef-enable-remote-debugging file in the Steam directory.
 | 
			
		||||
    Steam must be restarted after the file is first created.
 | 
			
		||||
    Создает файл .cef-enable-remote-debugging в директории Steam.
 | 
			
		||||
    Steam необходимо перезапустить после первого создания этого файла.
 | 
			
		||||
 | 
			
		||||
    Returns a tuple:
 | 
			
		||||
    - (True, "already_enabled") if already enabled.
 | 
			
		||||
    - (True, "restart_needed") if just enabled and Steam restart is needed.
 | 
			
		||||
    - (False, "steam_not_found") if Steam directory is not found.
 | 
			
		||||
    Возвращает кортеж:
 | 
			
		||||
    - (True, "already_enabled") если уже было активно.
 | 
			
		||||
    - (True, "restart_needed") если было только что активировано и нужен перезапуск Steam.
 | 
			
		||||
    - (False, "steam_not_found") если директория Steam не найдена.
 | 
			
		||||
    """
 | 
			
		||||
    steam_home = get_steam_home()
 | 
			
		||||
    if not steam_home:
 | 
			
		||||
        return (False, "steam_not_found")
 | 
			
		||||
 | 
			
		||||
    cef_flag_file = steam_home / ".cef-enable-remote-debugging"
 | 
			
		||||
    logger.info(f"Checking CEF flag: {cef_flag_file}")
 | 
			
		||||
    logger.info(f"Проверка CEF флага: {cef_flag_file}")
 | 
			
		||||
 | 
			
		||||
    if cef_flag_file.exists():
 | 
			
		||||
        logger.info("CEF Remote Debugging is already enabled")
 | 
			
		||||
        logger.info("CEF Remote Debugging уже активирован.")
 | 
			
		||||
        return (True, "already_enabled")
 | 
			
		||||
    else:
 | 
			
		||||
        try:
 | 
			
		||||
            os.makedirs(cef_flag_file.parent, exist_ok=True)
 | 
			
		||||
            cef_flag_file.touch()
 | 
			
		||||
            logger.info("Enabled CEF Remote Debugging. Steam restart required")
 | 
			
		||||
            logger.info("CEF Remote Debugging активирован. Steam необходимо перезапустить.")
 | 
			
		||||
            return (True, "restart_needed")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}")
 | 
			
		||||
            logger.error(f"Не удалось создать CEF флаг {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.
 | 
			
		||||
    Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        js_cmd: Name of the JS function to call (e.g., 'createShortcut').
 | 
			
		||||
        *args: Arguments to pass to the JS function.
 | 
			
		||||
        js_cmd: Имя JS функции для вызова (напр. 'createShortcut').
 | 
			
		||||
        *args: Аргументы для передачи в JS функцию.
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        Dictionary with the result or None if an error occurs.
 | 
			
		||||
        Словарь с результатом выполнения или None в случае ошибки.
 | 
			
		||||
    """
 | 
			
		||||
    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")
 | 
			
		||||
            logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.")
 | 
			
		||||
        elif message == "steam_not_found":
 | 
			
		||||
            logger.error("Could not find Steam directory to check CEF API")
 | 
			
		||||
            logger.error("Не удалось найти директорию Steam для проверки CEF API.")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.error(f"Steam CEF API is unavailable or not ready: {message}")
 | 
			
		||||
            logger.error(f"Steam CEF API недоступен или не готов: {message}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    steam_debug_url = "http://localhost:8080/json"
 | 
			
		||||
@@ -866,10 +836,10 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
 | 
			
		||||
        contexts = response.json()
 | 
			
		||||
        ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
 | 
			
		||||
        if not ws_url:
 | 
			
		||||
            logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?")
 | 
			
		||||
            logger.warning("Не удалось найти SharedJSContext. Steam запущен с флагом -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}")
 | 
			
		||||
        logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    js_code = """
 | 
			
		||||
@@ -914,15 +884,15 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
 | 
			
		||||
 | 
			
		||||
        response_data = orjson.loads(response_str)
 | 
			
		||||
        if "error" in response_data:
 | 
			
		||||
            logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}")
 | 
			
		||||
            logger.error(f"Ошибка выполнения JS в Steam: {response_data['error']['message']}")
 | 
			
		||||
            return None
 | 
			
		||||
        result = response_data.get('result', {}).get('result', {})
 | 
			
		||||
        if result.get('type') == 'object' and result.get('subtype') == 'error':
 | 
			
		||||
            logger.error(f"JavaScript execution error in Steam: {result.get('description')}")
 | 
			
		||||
            logger.error(f"Ошибка выполнения JS в Steam: {result.get('description')}")
 | 
			
		||||
            return None
 | 
			
		||||
        return result.get('value')
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"WebSocket interaction error with Steam: {e}")
 | 
			
		||||
        logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
 | 
			
		||||
@@ -999,24 +969,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"Failed to generate thumbnail for {exe_path}")
 | 
			
		||||
                logger.warning(f"generate_thumbnail failed to create icon 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"Failed to generate thumbnail for {exe_path}: {e}")
 | 
			
		||||
        logger.error(f"Error generating 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']
 | 
			
		||||
@@ -1029,7 +999,7 @@ export START_FROM_STEAM=1
 | 
			
		||||
    appid = None
 | 
			
		||||
    was_api_used = False
 | 
			
		||||
 | 
			
		||||
    logger.info("Attempting to add shortcut via Steam CEF API")
 | 
			
		||||
    logger.info("Попытка добавления ярлыка через Steam CEF API...")
 | 
			
		||||
    api_response = call_steam_api(
 | 
			
		||||
        "createShortcut",
 | 
			
		||||
        game_name,
 | 
			
		||||
@@ -1042,9 +1012,9 @@ export START_FROM_STEAM=1
 | 
			
		||||
    if api_response and isinstance(api_response, dict) and 'id' in api_response:
 | 
			
		||||
        appid = api_response['id']
 | 
			
		||||
        was_api_used = True
 | 
			
		||||
        logger.info(f"Shortcut successfully added via API. AppID: {appid}")
 | 
			
		||||
        logger.info(f"Ярлык успешно добавлен через API. AppID: {appid}")
 | 
			
		||||
    else:
 | 
			
		||||
        logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf")
 | 
			
		||||
        logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).")
 | 
			
		||||
        backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
        if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
            try:
 | 
			
		||||
@@ -1118,7 +1088,7 @@ export START_FROM_STEAM=1
 | 
			
		||||
            appid = None
 | 
			
		||||
 | 
			
		||||
    if not appid:
 | 
			
		||||
        return (False, "Failed to create shortcut using any method")
 | 
			
		||||
        return (False, "Не удалось создать ярлык ни одним из способов.")
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
 | 
			
		||||
@@ -1128,7 +1098,7 @@ export START_FROM_STEAM=1
 | 
			
		||||
        if not steam_appid or not isinstance(steam_appid, int):
 | 
			
		||||
            logger.info("No valid Steam appid found, skipping cover download")
 | 
			
		||||
            return
 | 
			
		||||
        logger.info(f"Found Steam AppID {steam_appid} for cover download")
 | 
			
		||||
        logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.")
 | 
			
		||||
 | 
			
		||||
        cover_types = [
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg"),
 | 
			
		||||
@@ -1145,15 +1115,15 @@ export START_FROM_STEAM=1
 | 
			
		||||
                        try:
 | 
			
		||||
                            with open(result_path, 'rb') as f:
 | 
			
		||||
                                img_b64 = base64.b64encode(f.read()).decode('utf-8')
 | 
			
		||||
                            logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}")
 | 
			
		||||
                            logger.info(f"Применение обложки типа '{steam_name}' через API для AppID {appid}")
 | 
			
		||||
                            ext = Path(steam_name).suffix.lstrip('.')
 | 
			
		||||
                            call_steam_api("setGrid", appid, index, ext, img_b64)
 | 
			
		||||
                        except Exception as e:
 | 
			
		||||
                            logger.error(f"Failed to apply cover '{steam_name}' via API: {e}")
 | 
			
		||||
                            logger.error(f"Ошибка при применении обложки '{steam_name}' через API: {e}")
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}")
 | 
			
		||||
                logger.error(f"Error processing cover {steam_name} for appid {steam_appid}: {e}")
 | 
			
		||||
 | 
			
		||||
        for i, (suffix, steam_name) in enumerate(cover_types):
 | 
			
		||||
            cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
 | 
			
		||||
@@ -1194,13 +1164,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)
 | 
			
		||||
@@ -1246,10 +1216,10 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
 | 
			
		||||
        return (False, f"Game '{game_name}' not found in Steam")
 | 
			
		||||
 | 
			
		||||
    api_response = call_steam_api("removeShortcut", appid)
 | 
			
		||||
    if api_response is not None: # API responded, even if response is empty
 | 
			
		||||
        logger.info(f"Shortcut for AppID {appid} successfully removed via API")
 | 
			
		||||
    if api_response is not None: # API ответил, даже если ответ пустой
 | 
			
		||||
        logger.info(f"Ярлык для AppID {appid} успешно удален через API.")
 | 
			
		||||
    else:
 | 
			
		||||
        logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf")
 | 
			
		||||
        logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).")
 | 
			
		||||
 | 
			
		||||
        # Create backup of shortcuts.vdf
 | 
			
		||||
        backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
@@ -1328,5 +1298,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"Failed to check if game {game_name} is in Steam: {e}")
 | 
			
		||||
        logger.error(f"Error checking if game {game_name} is in Steam: {e}")
 | 
			
		||||
    return False
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,9 @@
 | 
			
		||||
import importlib.util
 | 
			
		||||
import os
 | 
			
		||||
import ast
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
 | 
			
		||||
from PySide6.QtSvg import QSvgRenderer
 | 
			
		||||
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
 | 
			
		||||
 | 
			
		||||
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -13,59 +14,6 @@ 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():
 | 
			
		||||
    """
 | 
			
		||||
@@ -101,13 +49,9 @@ 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":
 | 
			
		||||
@@ -122,7 +66,7 @@ def load_theme_fonts(theme_name):
 | 
			
		||||
                break
 | 
			
		||||
 | 
			
		||||
    if not fonts_folder or not os.path.exists(fonts_folder):
 | 
			
		||||
        logger.error(f"Fonts folder not found for theme '{theme_name}'")
 | 
			
		||||
        logger.error(f"Папка fonts не найдена для темы '{theme_name}'")
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    for filename in os.listdir(fonts_folder):
 | 
			
		||||
@@ -131,11 +75,29 @@ def load_theme_fonts(theme_name):
 | 
			
		||||
            font_id = QFontDatabase.addApplicationFont(font_path)
 | 
			
		||||
            if font_id != -1:
 | 
			
		||||
                families = QFontDatabase.applicationFontFamilies(font_id)
 | 
			
		||||
                logger.info(f"Font {filename} successfully loaded: {families}")
 | 
			
		||||
                logger.info(f"Шрифт {filename} успешно загружен: {families}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.error(f"Error loading font: {filename}")
 | 
			
		||||
                logger.error(f"Ошибка загрузки шрифта: {filename}")
 | 
			
		||||
 | 
			
		||||
    _loaded_theme = theme_name
 | 
			
		||||
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
 | 
			
		||||
 | 
			
		||||
class ThemeWrapper:
 | 
			
		||||
    """
 | 
			
		||||
@@ -147,83 +109,69 @@ 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)
 | 
			
		||||
        if self._default_theme is None:
 | 
			
		||||
            self._default_theme = load_theme("standart")  # Dynamically load standard theme
 | 
			
		||||
        return getattr(self._default_theme, name)
 | 
			
		||||
        import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
        return getattr(default_styles, 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"Styles file not found for theme '{theme_name}'")
 | 
			
		||||
    raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'")
 | 
			
		||||
 | 
			
		||||
class ThemeManager:
 | 
			
		||||
    """
 | 
			
		||||
    Класс для управления темами приложения.
 | 
			
		||||
    Реализует паттерн Singleton для единого экземпляра.
 | 
			
		||||
 | 
			
		||||
    Позволяет получить список доступных тем, загрузить и применить выбранную тему.
 | 
			
		||||
    """
 | 
			
		||||
    _instance = None
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        self.current_theme_name = None
 | 
			
		||||
        self.current_theme_module = None
 | 
			
		||||
 | 
			
		||||
    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:
 | 
			
		||||
    def get_available_themes(self):
 | 
			
		||||
        """Возвращает список доступных тем."""
 | 
			
		||||
        return list_themes()
 | 
			
		||||
 | 
			
		||||
    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 get_theme_logo(self):
 | 
			
		||||
        """Возвращает логотип для текущей или указанной темы."""
 | 
			
		||||
        return load_logo()
 | 
			
		||||
 | 
			
		||||
    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 '{theme_name}' successfully applied")
 | 
			
		||||
        logger.info(f"Тема '{theme_name}' успешно применена")
 | 
			
		||||
        return theme_module
 | 
			
		||||
 | 
			
		||||
    def get_icon(self, icon_name, theme_name=None, as_path=False):
 | 
			
		||||
@@ -278,7 +226,7 @@ class ThemeManager:
 | 
			
		||||
 | 
			
		||||
        # Если иконка всё равно не найдена
 | 
			
		||||
        if not icon_path or not os.path.exists(icon_path):
 | 
			
		||||
            logger.error(f"Warning: icon '{icon_name}' not found")
 | 
			
		||||
            logger.error(f"Предупреждение: иконка '{icon_name}' не найдена")
 | 
			
		||||
            return QIcon() if not as_path else None
 | 
			
		||||
 | 
			
		||||
        if as_path:
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 751 B  | 
@@ -1,48 +0,0 @@
 | 
			
		||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<svg
 | 
			
		||||
   width="48"
 | 
			
		||||
   height="48"
 | 
			
		||||
   version="1.1"
 | 
			
		||||
   viewBox="0 0 48 48"
 | 
			
		||||
   xml:space="preserve"
 | 
			
		||||
   id="svg2"
 | 
			
		||||
   sodipodi:docname="key_context.svg"
 | 
			
		||||
   inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
 | 
			
		||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
			
		||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
			
		||||
   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns:svg="http://www.w3.org/2000/svg"><defs
 | 
			
		||||
     id="defs2" /><sodipodi:namedview
 | 
			
		||||
     id="namedview2"
 | 
			
		||||
     pagecolor="#ffffff"
 | 
			
		||||
     bordercolor="#000000"
 | 
			
		||||
     borderopacity="0.25"
 | 
			
		||||
     inkscape:showpageshadow="2"
 | 
			
		||||
     inkscape:pageopacity="0.0"
 | 
			
		||||
     inkscape:pagecheckerboard="0"
 | 
			
		||||
     inkscape:deskcolor="#d1d1d1"
 | 
			
		||||
     inkscape:zoom="8.6915209"
 | 
			
		||||
     inkscape:cx="72.311855"
 | 
			
		||||
     inkscape:cy="22.780823"
 | 
			
		||||
     inkscape:window-width="2560"
 | 
			
		||||
     inkscape:window-height="1406"
 | 
			
		||||
     inkscape:window-x="0"
 | 
			
		||||
     inkscape:window-y="0"
 | 
			
		||||
     inkscape:window-maximized="1"
 | 
			
		||||
     inkscape:current-layer="svg2" /><path
 | 
			
		||||
     style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000"
 | 
			
		||||
     d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z"
 | 
			
		||||
     id="path10"
 | 
			
		||||
     sodipodi:nodetypes="csccscc" /><path
 | 
			
		||||
     fill="#000000"
 | 
			
		||||
     d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z"
 | 
			
		||||
     id="path2"
 | 
			
		||||
     style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g
 | 
			
		||||
     id="g15"
 | 
			
		||||
     transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path
 | 
			
		||||
       style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
 | 
			
		||||
       d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z"
 | 
			
		||||
       id="path14" /><path
 | 
			
		||||
       style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
 | 
			
		||||
       d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z"
 | 
			
		||||
       id="path15" /></g></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 3.3 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 726 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 823 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 857 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 865 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 864 B  | 
| 
		 Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.1 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 736 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 961 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1015 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.2 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.3 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.5 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 682 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 721 B  | 
| 
		 Before Width: | Height: | Size: 232 KiB  | 
| 
		 Before Width: | Height: | Size: 225 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Библиотека.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.4 MiB  | 
| 
		 Before Width: | Height: | Size: 70 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Карточка.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 562 KiB  | 
| 
		 After Width: | Height: | Size: 445 KiB  | 
| 
		 Before Width: | Height: | Size: 238 KiB  | 
| 
		 After Width: | Height: | Size: 1.4 MiB  | 
| 
		 Before Width: | Height: | Size: 61 KiB  | 
| 
		 Before Width: | Height: | Size: 38 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Настройки.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 106 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								portprotonqt/themes/standart/images/screenshots/Оверлей.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 1.1 MiB  | 
| 
		 Before Width: | Height: | Size: 93 KiB  | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/theme_logo.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 7.8 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 600 B  |