Compare commits
	
		
			56 Commits
		
	
	
		
			v0.1.4
			...
			18312502ca
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						
						
							
						
						18312502ca
	
				 | 
					
					
						|||
| 
						
						
							
						
						5b257d3b62
	
				 | 
					
					
						|||
| 
						
						
							
						
						4dcf1dbe6d
	
				 | 
					
					
						|||
| 
						
						
							
						
						8d6fe4aa65
	
				 | 
					
					
						|||
| 
						
						
							
						
						022eb3f1e9
	
				 | 
					
					
						|||
| 
						
						
							
						
						11b847ed05
	
				 | 
					
					
						|||
| 
						
						
							
						
						1e4e0127a4
	
				 | 
					
					
						|||
| 
						
						
							
						
						c045aa7a56
	
				 | 
					
					
						|||
| 
						
						
							
						
						f18e7bae6b
	
				 | 
					
					
						|||
| 
						
						
							
						
						dcf8904037
	
				 | 
					
					
						|||
| 
						
						
							
						
						f9d24e385d
	
				 | 
					
					
						|||
| 
						
						
							
						
						09028931be
	
				 | 
					
					
						|||
| 
						
						
							
						
						0294c90c54
	
				 | 
					
					
						|||
| 
						
						
							
						
						17dfef2d27
	
				 | 
					
					
						|||
| 
						 | 
					
						
						
							
						
						f0690f8811
	
				 | 
					
					
						||
| 
						
						
							
						
						ac20447ba3
	
				 | 
					
					
						|||
| 
						
						
							
						
						ba143c15a8
	
				 | 
					
					
						|||
| 
						
						
							
						
						13068f3959
	
				 | 
					
					
						|||
| 
						 | 
					c8360d08ca | ||
| 
						
						
							
						
						b070ff1fca
	
				 | 
					
					
						|||
| 
						
						
							
						
						b5a2f41bdf
	
				 | 
					
					
						|||
| 
						
						
							
						
						9a37f31841
	
				 | 
					
					
						|||
| 
						
						
							
						
						aeed0112cd
	
				 | 
					
					
						|||
| 
						
						
							
						
						027ae68d4d
	
				 | 
					
					
						|||
| 
						
						
							
						
						37d41fef8d
	
				 | 
					
					
						|||
| 
						
						
							
						
						e37422fc95
	
				 | 
					
					
						|||
| 
						
						
							
						
						d7951e8587
	
				 | 
					
					
						|||
| 
						
						
							
						
						556533785a
	
				 | 
					
					
						|||
| 
						
						
							
						
						a13aca4d84
	
				 | 
					
					
						|||
| 
						
						
							
						
						35736e1723
	
				 | 
					
					
						|||
| 
						 | 
					
						
						
							
						
						24a7c2e657
	
				 | 
					
					
						||
| 
						 | 
					
						
						
							
						
						279f7ec36b
	
				 | 
					
					
						||
| 
						
						
							
						
						41f6943998
	
				 | 
					
					
						|||
| 
						
						
							
						
						3bf10dc4cd
	
				 | 
					
					
						|||
| 
						
						
							
						
						33b96d3185
	
				 | 
					
					
						|||
| 
						
						
							
						
						3573b8e373
	
				 | 
					
					
						|||
| 
						
						
							
						
						582ddd2218
	
				 | 
					
					
						|||
| 
						
						
							
						
						2753e53a4d
	
				 | 
					
					
						|||
| 
						
						
							
						
						46973f35e1
	
				 | 
					
					
						|||
| 
						
						
							
						
						8e34c92385
	
				 | 
					
					
						|||
| 
						
						
							
						
						d50b63bca7
	
				 | 
					
					
						|||
| 
						
						
							
						
						6966253e9b
	
				 | 
					
					
						|||
| 
						
						
							
						
						13f3af7a42
	
				 | 
					
					
						|||
| 
						
						
							
						
						c7bed80570
	
				 | 
					
					
						|||
| 
						
						
							
						
						6fde7c18db
	
				 | 
					
					
						|||
| 
						
						
							
						
						37782d4375
	
				 | 
					
					
						|||
| 
						
						
							
						
						0a8a7c538c
	
				 | 
					
					
						|||
| 
						 | 
					9cc4b8c51d | ||
| 
						
						
							
						
						397dede2be
	
				 | 
					
					
						|||
| 
						
						
							
						
						6a66f37ba1
	
				 | 
					
					
						|||
| 
						
						
							
						
						4db1cce32c
	
				 | 
					
					
						|||
| 
						
						
							
						
						edaeca4f11
	
				 | 
					
					
						|||
| 
						
						
							
						
						11d44f091d
	
				 | 
					
					
						|||
| 
						
						
							
						
						09d9c6510a
	
				 | 
					
					
						|||
| 
						
						
							
						
						272be51bb0
	
				 | 
					
					
						|||
| 
						
						
							
						
						63933172f9
	
				 | 
					
					
						
@@ -17,11 +17,11 @@ jobs:
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
            sudo apt update
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
 | 
			
		||||
 | 
			
		||||
      - name: Install tools
 | 
			
		||||
        run: |
 | 
			
		||||
            pip3 install git+https://github.com/Frederic98/appimage-builder.git
 | 
			
		||||
            pip3 install git+https://github.com/Boria138/appimage-builder.git
 | 
			
		||||
            pip3 install uv
 | 
			
		||||
 | 
			
		||||
      - name: Build AppImage
 | 
			
		||||
 
 | 
			
		||||
@@ -23,11 +23,11 @@ jobs:
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
            sudo apt update
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
 | 
			
		||||
 | 
			
		||||
      - name: Install tools
 | 
			
		||||
        run: |
 | 
			
		||||
            pip3 install git+https://github.com/Frederic98/appimage-builder.git
 | 
			
		||||
            pip3 install git+https://github.com/Boria138/appimage-builder.git
 | 
			
		||||
            pip3 install uv
 | 
			
		||||
 | 
			
		||||
      - name: Build AppImage
 | 
			
		||||
@@ -159,6 +159,7 @@ jobs:
 | 
			
		||||
          mkdir -p extracted
 | 
			
		||||
          find release/ -name '*.zip' -exec unzip -o {} -d extracted/ \;
 | 
			
		||||
          find extracted/ -type f -exec mv {} release/ \;
 | 
			
		||||
          find release/ -name '*.zip' -delete
 | 
			
		||||
          rm -rf extracted/
 | 
			
		||||
 | 
			
		||||
      - name: Extract changelog for version
 | 
			
		||||
 
 | 
			
		||||
@@ -68,11 +68,11 @@ jobs:
 | 
			
		||||
      - name: Install required dependencies
 | 
			
		||||
        run: |
 | 
			
		||||
            sudo apt update
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git
 | 
			
		||||
            sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync zstd git
 | 
			
		||||
 | 
			
		||||
      - name: Install tools
 | 
			
		||||
        run: |
 | 
			
		||||
            pip3 install git+https://github.com/Frederic98/appimage-builder.git
 | 
			
		||||
            pip3 install git+https://github.com/Boria138/appimage-builder.git
 | 
			
		||||
            pip3 install uv
 | 
			
		||||
 | 
			
		||||
      - name: Build AppImage
 | 
			
		||||
 
 | 
			
		||||
@@ -22,10 +22,16 @@ jobs:
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
 | 
			
		||||
      - name: Install uv
 | 
			
		||||
        uses: https://github.com/astral-sh/setup-uv@v6
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
          node-version: 20
 | 
			
		||||
 | 
			
		||||
      - name: Install uv manually
 | 
			
		||||
        run: |
 | 
			
		||||
          curl -LsSf https://astral.sh/uv/install.sh | sh
 | 
			
		||||
          source $HOME/.local/bin/env
 | 
			
		||||
          uv --version
 | 
			
		||||
 | 
			
		||||
      - name: Sync dependencies into venv
 | 
			
		||||
        run: uv sync --all-extras --dev
 | 
			
		||||
 
 | 
			
		||||
@@ -8,11 +8,29 @@ on:
 | 
			
		||||
jobs:
 | 
			
		||||
  renovate:
 | 
			
		||||
    runs-on: ubuntu-latest
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:41.1.4
 | 
			
		||||
    container: ghcr.io/renovatebot/renovate:latest
 | 
			
		||||
    steps:
 | 
			
		||||
      - uses: https://gitea.com/actions/checkout@v4
 | 
			
		||||
      - run: renovate
 | 
			
		||||
 | 
			
		||||
      - name: Set up Node.js
 | 
			
		||||
        uses: https://gitea.com/actions/setup-node@v4
 | 
			
		||||
        with:
 | 
			
		||||
          node-version: 20
 | 
			
		||||
 | 
			
		||||
      - name: Install uv
 | 
			
		||||
        uses: https://github.com/astral-sh/setup-uv@v6
 | 
			
		||||
        with:
 | 
			
		||||
          enable-cache: true
 | 
			
		||||
 | 
			
		||||
      - name: Download external renovate config
 | 
			
		||||
        run: |
 | 
			
		||||
          mkdir -p /tmp/renovate-config
 | 
			
		||||
          curl -fsSL "https://git.linux-gaming.ru/Linux-Gaming/renovate-config/raw/branch/main/config.js" \
 | 
			
		||||
            -o /tmp/renovate-config/config.js
 | 
			
		||||
 | 
			
		||||
      - name: Run Renovate
 | 
			
		||||
        run: renovate
 | 
			
		||||
        env:
 | 
			
		||||
          RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js"
 | 
			
		||||
          RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
 | 
			
		||||
          LOG_LEVEL: "debug"
 | 
			
		||||
          RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
 | 
			
		||||
 
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
 | 
			
		||||
repos:
 | 
			
		||||
  - repo: https://github.com/pre-commit/pre-commit-hooks
 | 
			
		||||
    rev: v5.0.0
 | 
			
		||||
    rev: v6.0.0
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: trailing-whitespace
 | 
			
		||||
      - id: end-of-file-fixer
 | 
			
		||||
@@ -11,15 +11,14 @@ repos:
 | 
			
		||||
      - id: check-yaml
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/uv-pre-commit
 | 
			
		||||
    rev: 0.6.14
 | 
			
		||||
    rev: 0.8.9
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: uv-lock
 | 
			
		||||
 | 
			
		||||
  - repo: https://github.com/astral-sh/ruff-pre-commit
 | 
			
		||||
    rev: v0.11.5
 | 
			
		||||
    rev: v0.12.8
 | 
			
		||||
    hooks:
 | 
			
		||||
      - id: ruff
 | 
			
		||||
        args: [--fix]
 | 
			
		||||
      - id: ruff-check
 | 
			
		||||
 | 
			
		||||
  - repo: local
 | 
			
		||||
    hooks:
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										229
									
								
								CHANGELOG.md
									
									
									
									
									
								
							
							
						
						@@ -3,27 +3,58 @@
 | 
			
		||||
Все заметные изменения в этом проекте фиксируются в этом файле.
 | 
			
		||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
 | 
			
		||||
 | 
			
		||||
## [Unreleased]
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Больше типов анимаций при открытии карточки игры (подробности см. в документации).
 | 
			
		||||
- Анимация при закрытии карточки игры (подробности см. в документации).
 | 
			
		||||
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
 | 
			
		||||
- Контекстное меню при открытии теперь сразу фокусируется на первом элементе.
 | 
			
		||||
- Анимации теперь можно настраивать через темы (подробности см. в документации).
 | 
			
		||||
- Общие JSON-файлы (`steam_apps` и `anticheat_games`) теперь перекачиваются, если они повреждены.
 | 
			
		||||
- Временно удалена светлая тема.
 | 
			
		||||
- Добавление и удаление игр из Steam больше не требует перезапуска клиента.
 | 
			
		||||
- Обновлены все зависимости (затрагивает только AppImage).
 | 
			
		||||
- Удалён отдельный трей, так как у PortProton есть собственный.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
 | 
			
		||||
- Скриншоты тем больше не теряют качество при масштабе, отличном от 100%.
 | 
			
		||||
- Данные от HLTB теперь не отображаются в карточке, если нет информации о времени прохождения.
 | 
			
		||||
- Диалог добавления игры больше не добавляет игру, если `exe` не существует.
 | 
			
		||||
- Вкладки больше не переключаются стрелками, если фокус в поле ввода.
 | 
			
		||||
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
 | 
			
		||||
- Переведен заголовок окна диалога выбора файлов.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Alex Smith
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## [0.1.4] - 2025-07-21
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Переводы в переопределениях (за подробностями в документацию)
 | 
			
		||||
- Обложки и описания для всех автоинсталлов
 | 
			
		||||
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры
 | 
			
		||||
- Интеграция с howlongtobeat.com
 | 
			
		||||
- Переводы в переопределениях (подробности см. в документации).
 | 
			
		||||
- Обложки и описания для всех автоинсталлов.
 | 
			
		||||
- Возможность указать ссылку для скачивания обложки в диалоге добавления игры.
 | 
			
		||||
- Интеграция с howlongtobeat.com.
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Оптимизированны обложки автоинсталлов
 | 
			
		||||
- Папка custom_data исключена из сборки модуля для уменьшение его размера
 | 
			
		||||
- Бейдж PortProton теперь открывает PortProtonDB
 | 
			
		||||
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в gamescope сессии
 | 
			
		||||
- Удалён аргумент `--session` так как тестирование gamescope сессии завершено
 | 
			
		||||
- В контекстном меню игр без exe файла теперь отображается только пункт "Удалить из PortProton"
 | 
			
		||||
- Оптимизированы обложки автоинсталлов.
 | 
			
		||||
- Папка `custom_data` исключена из сборки модуля для уменьшения его размера.
 | 
			
		||||
- Бейдж PortProton теперь открывает PortProtonDB.
 | 
			
		||||
- Отключено переключение полноэкранного режима через F11 или кнопку Select на геймпаде в Gamescope-сессии.
 | 
			
		||||
- Удалён аргумент `--session`, так как тестирование Gamescope-сессии завершено.
 | 
			
		||||
- В контекстном меню игр без exe-файла теперь отображается только пункт «Удалить из PortProton».
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Запрос к GitHub API при загрузке legendary теперь не игнорирует настройки прокси
 | 
			
		||||
- Путь к portprotonqt-session-select в оверлее
 | 
			
		||||
- Работа exiftool в AppImage
 | 
			
		||||
- Открытие контекстного меню у игр без exe
 | 
			
		||||
- Запрос к GitHub API при загрузке legendary теперь учитывает настройки прокси.
 | 
			
		||||
- Путь к `portprotonqt-session-select` в оверлее.
 | 
			
		||||
- Работа `exiftool` в AppImage.
 | 
			
		||||
- Открытие контекстного меню у игр без exe-файла.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
@@ -33,32 +64,32 @@
 | 
			
		||||
## [0.1.3] - 2025-07-05
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Аргумент `--session` для запуска приложения в gamescope (Исключительно в целях тестирования)
 | 
			
		||||
- Начальная поддержка EGS (Без EOS, скачивания игр и запуска игр из сторонних магазинов)
 | 
			
		||||
- Автодополнение bash для комманды portprotonqt
 | 
			
		||||
- Поддержка геймпадов в диалоге выбора игры
 | 
			
		||||
- Быстрый запуск и остановка игры через контекстное меню
 | 
			
		||||
- Иконки в контекстом меню
 | 
			
		||||
- Обложки для части автоинсталлов
 | 
			
		||||
- Аргумент `--session` для запуска приложения в Gamescope (исключительно в целях тестирования).
 | 
			
		||||
- Начальная поддержка EGS (без EOS, скачивания и запуска игр из сторонних магазинов).
 | 
			
		||||
- Автодополнение bash для команды `portprotonqt`.
 | 
			
		||||
- Поддержка геймпадов в диалоге выбора игры.
 | 
			
		||||
- Быстрый запуск и остановка игры через контекстное меню.
 | 
			
		||||
- Иконки в контекстном меню.
 | 
			
		||||
- Обложки для части автоинсталлов.
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Удалены сборки для Fedora 40
 | 
			
		||||
- Перенесены параметры анимации GameCard в `styles.py` с подробной документацией для поддержки кастомизации тем.
 | 
			
		||||
- Статус выделения и наведения на карточки теперь взаимоисключают друг друга
 | 
			
		||||
- Все desktop файлы создаются с коментарием "Запустить игру {название} через PortProton"
 | 
			
		||||
- Заполнители в переводах теперь стали более осмысленными
 | 
			
		||||
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope
 | 
			
		||||
- Текст бейджей теперь обрезается через ... если не помещается
 | 
			
		||||
- Удалены сборки для Fedora 40.
 | 
			
		||||
- Параметры анимации GameCard перенесены в `styles.py` с подробной документацией для кастомизации тем.
 | 
			
		||||
- Статусы выделения и наведения на карточки теперь взаимоисключающие.
 | 
			
		||||
- Все desktop-файлы создаются с комментарием «Запустить игру {название} через PortProton».
 | 
			
		||||
- Заполнители в переводах стали более осмысленными.
 | 
			
		||||
- Изменена компоновка диалога добавления игры для лучшего отображения в Gamescope.
 | 
			
		||||
- Текст бейджей теперь обрезается троеточием, если не помещается.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Дублирование обводки выделения карточек при быстром перемешении мыши
 | 
			
		||||
- Завершение приложения при закритие окна
 | 
			
		||||
- Использование системной палитры в темах
 | 
			
		||||
- Ошибки темы в нативном пакете
 | 
			
		||||
- Ошибки темы в Gamescope
 | 
			
		||||
- Размер иконок для desktop файлов теперь 128x128
 | 
			
		||||
- Пустая область при обновлении сетки игр
 | 
			
		||||
- Запуск игры при открытом оверлее
 | 
			
		||||
- Дублирование обводки карточек при быстром перемещении мыши.
 | 
			
		||||
- Завершение приложения при закрытии окна.
 | 
			
		||||
- Использование системной палитры в темах.
 | 
			
		||||
- Ошибки тем в нативном пакете.
 | 
			
		||||
- Ошибки тем в Gamescope.
 | 
			
		||||
- Размер иконок для desktop-файлов теперь 128x128.
 | 
			
		||||
- Пустая область при обновлении сетки игр.
 | 
			
		||||
- Запуск игры при открытом оверлее.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Dervart
 | 
			
		||||
@@ -69,63 +100,63 @@
 | 
			
		||||
## [0.1.2] - 2025-06-15
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Кнопки сброса настроек и очистки кэша
 | 
			
		||||
- Бейдж PortProton
 | 
			
		||||
- Зависимость от `xdg-utils`
 | 
			
		||||
- Интеграция статуса WeAntiCheatYet в карточку
 | 
			
		||||
- Переключение полноэкршанного режима через F11 или кнопку Select на геймпаде
 | 
			
		||||
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде
 | 
			
		||||
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде
 | 
			
		||||
- Закрытие окна приложения по комбинации клавиш Ctrl+Q
 | 
			
		||||
- Сохранение и восстановление размера окна при перезапуске
 | 
			
		||||
- Переключатель полноэкранного режима приложения
 | 
			
		||||
- Пункт в контекстном меню «Открыть папку игры»
 | 
			
		||||
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam»
 | 
			
		||||
- Пункты в контекстном меню «Добавить в Избранное» и «Удалить из Избранного»
 | 
			
		||||
- Метод сортировки «Сначала избранное»
 | 
			
		||||
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена)
 | 
			
		||||
- Поддержка управления геймпадом в `QMenu` и `QComboBox`
 | 
			
		||||
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме
 | 
			
		||||
- Оверлей на кнопку  Insert или кнопку Xbox/PS на геймпаде для закрытия приложения, выключения, перезагрузки и перехода в спящий режим или переключения между сессиями
 | 
			
		||||
- [Gamescope сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt)
 | 
			
		||||
- Пресеты управления для DualShock 4 и DualSense
 | 
			
		||||
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию выключена)
 | 
			
		||||
- Переводы пунктов настроек
 | 
			
		||||
- Кнопки сброса настроек и очистки кэша.
 | 
			
		||||
- Бейдж PortProton.
 | 
			
		||||
- Зависимость от `xdg-utils`.
 | 
			
		||||
- Интеграция статуса WeAntiCheatYet в карточку.
 | 
			
		||||
- Переключение полноэкранного режима через F11 или кнопку Select на геймпаде.
 | 
			
		||||
- Выбор состояния `QCheckBox` через Enter или кнопку A на геймпаде.
 | 
			
		||||
- Закрытие диалога добавления игры через ESC или кнопку B на геймпаде.
 | 
			
		||||
- Закрытие приложения комбинацией клавиш Ctrl+Q.
 | 
			
		||||
- Сохранение и восстановление размера окна при перезапуске.
 | 
			
		||||
- Переключатель полноэкранного режима приложения.
 | 
			
		||||
- Пункт в контекстном меню «Открыть папку игры».
 | 
			
		||||
- Пункты в контекстном меню «Добавить в Steam» и «Удалить из Steam».
 | 
			
		||||
- Пункты в контекстном меню «Добавить в избранное» и «Удалить из избранного».
 | 
			
		||||
- Метод сортировки «Сначала избранное».
 | 
			
		||||
- Настройка автоматического перехода в полноэкранный режим при подключении геймпада (по умолчанию отключена).
 | 
			
		||||
- Поддержка управления геймпадом в `QMenu` и `QComboBox`.
 | 
			
		||||
- Аргумент `--fullscreen` для запуска приложения в полноэкранном режиме.
 | 
			
		||||
- Оверлей на кнопку Insert или Xbox/PS-кнопку на геймпаде для закрытия приложения, выключения, перезагрузки, перехода в спящий режим или переключения между сессиями.
 | 
			
		||||
- [Gamescope-сессия](https://git.linux-gaming.ru/Boria138/gamescope-session-portprotonqt).
 | 
			
		||||
- Пресеты управления для DualShock 4 и DualSense.
 | 
			
		||||
- Настройка тактильной отдачи на геймпаде при запуске игры (по умолчанию отключена).
 | 
			
		||||
- Переводы пунктов настроек.
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Обновлены все иконки
 | 
			
		||||
- Переименована функция `_get_steam_home` в `get_steam_home`
 | 
			
		||||
- Переименован `steam_game` в `game_source`
 | 
			
		||||
- Логика контекстного меню вынесена в `ContextMenuManager`
 | 
			
		||||
- Бейдж Steam теперь открывает Steam Community
 | 
			
		||||
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
 | 
			
		||||
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна
 | 
			
		||||
- Бейджи с карточек теперь отображаются также на странице с деталями, а не только в библиотеке
 | 
			
		||||
- Установлена ширина бейджа в две трети ширины карточки
 | 
			
		||||
- Бейджи источников (`Steam`, `EGS`, `PortProton`) теперь отображаются только при активном фильтре `all` или `favorites`
 | 
			
		||||
- Карточки теперь фокусируются в направлении движения стрелок или D-pad:
 | 
			
		||||
- Поддерживается удержание D-pad для непрерывного переключения карточек
 | 
			
		||||
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности
 | 
			
		||||
- D-pad больше не переключает вкладки (только кнопки RB/LB)
 | 
			
		||||
- Кнопка добавления игры больше не фокусируется
 | 
			
		||||
- Диалог добавления игры теперь открывается только в библиотеке
 | 
			
		||||
- Удалены все упоминания PortProtonQT из кода и заменены на PortProtonQt
 | 
			
		||||
- Размер карточек теперь меняется только при отпускании слайдера
 | 
			
		||||
- Слайдер теперь управляется через тригеры на геймпаде
 | 
			
		||||
- Диалог добавления игры теперь открывается на X, а не на Y
 | 
			
		||||
- Обновлены все иконки.
 | 
			
		||||
- Функция `_get_steam_home` переименована в `get_steam_home`.
 | 
			
		||||
- `steam_game` переименован в `game_source`.
 | 
			
		||||
- Логика контекстного меню вынесена в `ContextMenuManager`.
 | 
			
		||||
- Бейдж Steam теперь открывает Steam Community.
 | 
			
		||||
- Лицензия изменена с MIT на GPL-3.0 для совместимости с кодом legendary.
 | 
			
		||||
- Оптимизирована генерация карточек для плавной работы при поиске и изменении размера окна.
 | 
			
		||||
- Бейджи с карточек теперь отображаются и на странице с деталями, а не только в библиотеке.
 | 
			
		||||
- Установлена ширина бейджа в 2/3 ширины карточки.
 | 
			
		||||
- Бейджи источников (`Steam`, `EGS`, `PortProton`) отображаются только при активном фильтре `all` или `favorites`.
 | 
			
		||||
- Карточки теперь фокусируются в направлении движения стрелок или D-pad.
 | 
			
		||||
- Поддерживается удержание D-pad для непрерывного переключения карточек.
 | 
			
		||||
- Объединён обработчик управления стрелками клавиатуры и D-pad для консистентности.
 | 
			
		||||
- D-pad больше не переключает вкладки (только кнопки RB/LB).
 | 
			
		||||
- Кнопка добавления игры больше не получает фокус.
 | 
			
		||||
- Диалог добавления игры открывается только в библиотеке.
 | 
			
		||||
- Все упоминания PortProtonQT заменены на PortProtonQt.
 | 
			
		||||
- Размер карточек меняется только при отпускании слайдера.
 | 
			
		||||
- Слайдер теперь управляется триггерами на геймпаде.
 | 
			
		||||
- Диалог добавления игры теперь открывается на X, а не на Y.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Возврат к теме «standard» при выборе несуществующей темы
 | 
			
		||||
- Корректное открытие контекстного меню
 | 
			
		||||
- Запуск приложения при отсутствии `exiftool`
 | 
			
		||||
- Предотвращено бесконечное обращение к `get_portproton_location`
 | 
			
		||||
- Обновлены ссылки на документацию в README
 | 
			
		||||
- Устранён traceback при отсутствии обложек (placeholder)
 | 
			
		||||
- Устранены утечки памяти при загрузке обложек
 | 
			
		||||
- Исправлены ошибки при подключении геймпада
 | 
			
		||||
- Предотвращено многократное открытие диалога добавления игры через геймпад
 | 
			
		||||
- Корректная обработка событий геймпада во время игры
 | 
			
		||||
- Убийсво всех процессов "зомби" при закрытии программы
 | 
			
		||||
- Возврат к теме «standard» при выборе несуществующей темы.
 | 
			
		||||
- Корректное открытие контекстного меню.
 | 
			
		||||
- Запуск приложения при отсутствии `exiftool`.
 | 
			
		||||
- Предотвращено бесконечное обращение к `get_portproton_location`.
 | 
			
		||||
- Обновлены ссылки на документацию в README.
 | 
			
		||||
- Исправлено падение при отсутствии обложек (placeholder).
 | 
			
		||||
- Устранены утечки памяти при загрузке обложек.
 | 
			
		||||
- Исправлены ошибки при подключении геймпада.
 | 
			
		||||
- Предотвращено многократное открытие диалога добавления игры через геймпад.
 | 
			
		||||
- Корректная обработка событий геймпада во время игры.
 | 
			
		||||
- Убийство всех процессов-зомби при закрытии программы.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
@@ -136,20 +167,20 @@
 | 
			
		||||
## [0.1.1] – 2025-05-17
 | 
			
		||||
 | 
			
		||||
### Added
 | 
			
		||||
- Алфавитная сортировка библиотеки
 | 
			
		||||
- Проверка переводов через yaspeller
 | 
			
		||||
- Сборка Fedora-пакета
 | 
			
		||||
- Сборка AppImage
 | 
			
		||||
- Алфавитная сортировка библиотеки.
 | 
			
		||||
- Проверка переводов через yaspeller.
 | 
			
		||||
- Сборка Fedora-пакета.
 | 
			
		||||
- Сборка AppImage.
 | 
			
		||||
 | 
			
		||||
### Changed
 | 
			
		||||
- Удалён жёстко заданный размер окна
 | 
			
		||||
- Использован `icoextract` как Python-модуль
 | 
			
		||||
- Удалён жёстко заданный размер окна.
 | 
			
		||||
- Использован `icoextract` как Python-модуль.
 | 
			
		||||
 | 
			
		||||
### Fixed
 | 
			
		||||
- Скрытие статус-бара
 | 
			
		||||
- Чтение списка Steam-игр
 | 
			
		||||
- Зависание GUI
 | 
			
		||||
- Сбой при повреждённом Steam
 | 
			
		||||
- Скрытие статус-бара.
 | 
			
		||||
- Чтение списка Steam-игр.
 | 
			
		||||
- Зависание GUI.
 | 
			
		||||
- Сбой при повреждённом Steam.
 | 
			
		||||
 | 
			
		||||
### Contributors
 | 
			
		||||
- @Vector_null
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										2
									
								
								TODO.md
									
									
									
									
									
								
							
							
						
						@@ -41,7 +41,7 @@
 | 
			
		||||
- [X] Получать slug через GraphQL [запрос](https://launcher.store.epicgames.com/graphql)
 | 
			
		||||
- [X] Добавить на карточку бейдж, указывающий, что игра из Steam
 | 
			
		||||
- [X] Добавить поддержку версий Steam для Flatpak и Snap
 | 
			
		||||
- [ ] Реализовать добавление игры как сторонней в Steam без перезапуска
 | 
			
		||||
- [X] Реализовать добавление игры как сторонней в Steam без перезапуска
 | 
			
		||||
- [X] Отображать данные о самом последнем пользователе Steam, а не первом попавшемся
 | 
			
		||||
- [X] Исправить склонения в детальном выводе времени, например, не «3 часов назад», а «3 часа назад»
 | 
			
		||||
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
 | 
			
		||||
 
 | 
			
		||||
@@ -13,9 +13,9 @@ script:
 | 
			
		||||
  # 5) чистим от ненужных модулей и бинарников
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*}
 | 
			
		||||
  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
 | 
			
		||||
  - shopt -s extglob
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*)
 | 
			
		||||
  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
 | 
			
		||||
AppDir:
 | 
			
		||||
  path: ./AppDir
 | 
			
		||||
  after_bundle:
 | 
			
		||||
@@ -82,5 +82,4 @@ AppDir:
 | 
			
		||||
      PERLLIB: '${APPDIR}/usr/share/perl5:${APPDIR}/usr/lib/x86_64-linux-gnu/perl/5.34:${APPDIR}/usr/share/perl/5.34'
 | 
			
		||||
AppImage:
 | 
			
		||||
  sign-key: None
 | 
			
		||||
  comp: xz
 | 
			
		||||
  arch: x86_64
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ arch=('any')
 | 
			
		||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
 | 
			
		||||
license=('GPL-3.0')
 | 
			
		||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
 | 
			
		||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
 | 
			
		||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
 | 
			
		||||
sha256sums=('SKIP')
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ arch=('any')
 | 
			
		||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
 | 
			
		||||
license=('GPL-3.0')
 | 
			
		||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4')
 | 
			
		||||
    'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
 | 
			
		||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
 | 
			
		||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
 | 
			
		||||
sha256sums=('SKIP')
 | 
			
		||||
 
 | 
			
		||||
@@ -33,6 +33,7 @@ Requires:       python3-babel
 | 
			
		||||
Requires:       python3-evdev
 | 
			
		||||
Requires:       python3-icoextract
 | 
			
		||||
Requires:       python3-numpy
 | 
			
		||||
Requires:       python3-websocket-client
 | 
			
		||||
Requires:       python3-orjson
 | 
			
		||||
Requires:       python3-psutil
 | 
			
		||||
Requires:       python3-pyside6
 | 
			
		||||
 
 | 
			
		||||
@@ -30,6 +30,7 @@ Requires:       python3-babel
 | 
			
		||||
Requires:       python3-evdev
 | 
			
		||||
Requires:       python3-icoextract
 | 
			
		||||
Requires:       python3-numpy
 | 
			
		||||
Requires:       python3-websocket-client
 | 
			
		||||
Requires:       python3-orjson
 | 
			
		||||
Requires:       python3-psutil
 | 
			
		||||
Requires:       python3-pyside6
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +0,0 @@
 | 
			
		||||
module.exports = {
 | 
			
		||||
    "endpoint": "https://git.linux-gaming.ru/api/v1",
 | 
			
		||||
    "gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
 | 
			
		||||
    "platform": "gitea",
 | 
			
		||||
    "onboardingConfigFileName": "renovate.json",
 | 
			
		||||
    "autodiscover": true,
 | 
			
		||||
    "optimizeForDisabled": true,
 | 
			
		||||
};
 | 
			
		||||
@@ -765,7 +765,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "lost ark",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "archeage unchained",
 | 
			
		||||
@@ -4426,5 +4426,61 @@
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "carx street",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "warcos 2",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "karos classic",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dead island riptide",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "lineage",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "day of dragons",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "sonic rumble",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "black stigma",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "umamusume pretty derby",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "dirt rally",
 | 
			
		||||
    "status": "Supported"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "minifighter",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "hide & hold out h2o",
 | 
			
		||||
    "status": "Running"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "f1 25",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "ghost of tsushima director's cut",
 | 
			
		||||
    "status": "Denied"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_name": "sword of justice",
 | 
			
		||||
    "status": "Broken"
 | 
			
		||||
  }
 | 
			
		||||
]
 | 
			
		||||
@@ -1,12 +1,56 @@
 | 
			
		||||
[
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "return alive",
 | 
			
		||||
    "slug": "return-alive"
 | 
			
		||||
    "normalized_title": "no sleep for kaname date from ai the somnium files",
 | 
			
		||||
    "slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dead island 2",
 | 
			
		||||
    "slug": "dead-island-2"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "dead island",
 | 
			
		||||
    "slug": "dead-island-definitive-edition"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "wuchang fallen feathers",
 | 
			
		||||
    "slug": "wuchang-fallen-feathers"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "mindseye",
 | 
			
		||||
    "slug": "mindseye"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "alan wake",
 | 
			
		||||
    "slug": "alan-wake"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "s.t.a.l.k.e.r. anomaly g.a.m.m.a",
 | 
			
		||||
    "slug": "s-t-a-l-k-e-r-anomaly-g-a-m-m-a"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "fifa 18",
 | 
			
		||||
    "slug": "fifa-18"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "eriksholm the stolen dream",
 | 
			
		||||
    "slug": "eriksholm-the-stolen-dream"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "caravan sandwitch",
 | 
			
		||||
    "slug": "caravan-sandwitch"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "expeditions a mudrunner game",
 | 
			
		||||
    "slug": "expeditions-a-mudrunner-game"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "#drive rally",
 | 
			
		||||
    "slug": "drive-rally"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "return alive",
 | 
			
		||||
    "slug": "return-alive"
 | 
			
		||||
  },
 | 
			
		||||
  {
 | 
			
		||||
    "normalized_title": "recore",
 | 
			
		||||
    "slug": "recore-definitive-edition"
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										378
									
								
								dev-scripts/appimage_clean.py
									
									
									
									
									
										Executable file
									
								
							
							
						
						@@ -0,0 +1,378 @@
 | 
			
		||||
#!/usr/bin/env python3
 | 
			
		||||
"""
 | 
			
		||||
PySide6 Dependencies Analyzer with ldd support
 | 
			
		||||
Анализирует зависимости PySide6 модулей используя ldd для определения
 | 
			
		||||
реальных зависимостей скомпилированных библиотек.
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import ast
 | 
			
		||||
import os
 | 
			
		||||
import sys
 | 
			
		||||
import subprocess
 | 
			
		||||
import re
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from typing import Set, Dict, List
 | 
			
		||||
import argparse
 | 
			
		||||
import json
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class PySide6DependencyAnalyzer:
 | 
			
		||||
    def __init__(self):
 | 
			
		||||
        # Системные библиотеки, которые нужно всегда оставлять
 | 
			
		||||
        self.system_libs = {
 | 
			
		||||
            'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
 | 
			
		||||
            'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus'
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        self.real_dependencies = {}
 | 
			
		||||
        self.used_modules_code = set()
 | 
			
		||||
        self.used_modules_ldd = set()
 | 
			
		||||
        self.all_required_modules = set()
 | 
			
		||||
 | 
			
		||||
    def find_python_files(self, directory: Path) -> List[Path]:
 | 
			
		||||
        """Находит все Python файлы в директории"""
 | 
			
		||||
        python_files = []
 | 
			
		||||
        for root, dirs, files in os.walk(directory):
 | 
			
		||||
            dirs[:] = [d for d in dirs if d not in {'.venv', '__pycache__', '.git'}]
 | 
			
		||||
 | 
			
		||||
            for file in files:
 | 
			
		||||
                if file.endswith('.py'):
 | 
			
		||||
                    python_files.append(Path(root) / file)
 | 
			
		||||
        return python_files
 | 
			
		||||
 | 
			
		||||
    def find_pyside6_libs(self, base_path: Path) -> Dict[str, Path]:
 | 
			
		||||
        """Находит все PySide6 библиотеки (.so файлы)"""
 | 
			
		||||
        libs = {}
 | 
			
		||||
 | 
			
		||||
        # Поиск в единственной локации
 | 
			
		||||
        search_path = Path("../.venv/lib/python3.10/site-packages/PySide6")
 | 
			
		||||
        print(f"Поиск PySide6 библиотек в: {search_path}")
 | 
			
		||||
 | 
			
		||||
        if search_path.exists():
 | 
			
		||||
            # Ищем .so файлы модулей
 | 
			
		||||
            for so_file in search_path.glob("Qt*.*.so"):
 | 
			
		||||
                module_name = so_file.stem.split('.')[0]  # QtCore.abi3.so -> QtCore
 | 
			
		||||
                if module_name.startswith('Qt'):
 | 
			
		||||
                    libs[module_name] = so_file
 | 
			
		||||
 | 
			
		||||
            # Также ищем в подпапках
 | 
			
		||||
            for subdir in search_path.iterdir():
 | 
			
		||||
                if subdir.is_dir() and subdir.name.startswith('Qt'):
 | 
			
		||||
                    for so_file in subdir.glob("*.so*"):
 | 
			
		||||
                        if 'Qt' in so_file.name:
 | 
			
		||||
                            libs[subdir.name] = so_file
 | 
			
		||||
                            break
 | 
			
		||||
 | 
			
		||||
        return libs
 | 
			
		||||
 | 
			
		||||
    def analyze_ldd_dependencies(self, lib_path: Path) -> Set[str]:
 | 
			
		||||
        """Анализирует зависимости библиотеки с помощью ldd"""
 | 
			
		||||
        qt_deps = set()
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            result = subprocess.run(['ldd', str(lib_path)],
 | 
			
		||||
                                  capture_output=True, text=True, check=True)
 | 
			
		||||
 | 
			
		||||
            # Парсим вывод ldd и ищем Qt библиотеки
 | 
			
		||||
            for line in result.stdout.split('\n'):
 | 
			
		||||
                # Ищем строки вида: libQt6Core.so.6 => /path/to/lib
 | 
			
		||||
                match = re.search(r'libQt6(\w+)\.so', line)
 | 
			
		||||
                if match:
 | 
			
		||||
                    qt_module = f"Qt{match.group(1)}"
 | 
			
		||||
                    qt_deps.add(qt_module)
 | 
			
		||||
 | 
			
		||||
        except (subprocess.CalledProcessError, FileNotFoundError) as e:
 | 
			
		||||
            print(f"Предупреждение: не удалось выполнить ldd для {lib_path}: {e}")
 | 
			
		||||
 | 
			
		||||
        return qt_deps
 | 
			
		||||
 | 
			
		||||
    def build_real_dependency_graph(self, pyside_libs: Dict[str, Path]) -> Dict[str, Set[str]]:
 | 
			
		||||
        """Строит граф зависимостей на основе ldd анализа"""
 | 
			
		||||
        dependencies = {}
 | 
			
		||||
 | 
			
		||||
        print("Анализ реальных зависимостей с помощью ldd...")
 | 
			
		||||
        for module, lib_path in pyside_libs.items():
 | 
			
		||||
            print(f"  Анализируется {module}...")
 | 
			
		||||
            deps = self.analyze_ldd_dependencies(lib_path)
 | 
			
		||||
            dependencies[module] = deps
 | 
			
		||||
 | 
			
		||||
            if deps:
 | 
			
		||||
                print(f"    Зависимости: {', '.join(sorted(deps))}")
 | 
			
		||||
 | 
			
		||||
        return dependencies
 | 
			
		||||
 | 
			
		||||
    def analyze_file_imports(self, file_path: Path) -> Set[str]:
 | 
			
		||||
        """Анализирует один Python файл и возвращает используемые PySide6 модули"""
 | 
			
		||||
        modules = set()
 | 
			
		||||
        try:
 | 
			
		||||
            with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
 | 
			
		||||
                content = f.read()
 | 
			
		||||
 | 
			
		||||
            tree = ast.parse(content)
 | 
			
		||||
 | 
			
		||||
            for node in ast.walk(tree):
 | 
			
		||||
                if isinstance(node, ast.Import):
 | 
			
		||||
                    for alias in node.names:
 | 
			
		||||
                        if alias.name.startswith('PySide6.'):
 | 
			
		||||
                            module = alias.name.split('.', 2)[1]
 | 
			
		||||
                            if module.startswith('Qt'):
 | 
			
		||||
                                modules.add(module)
 | 
			
		||||
 | 
			
		||||
                elif isinstance(node, ast.ImportFrom):
 | 
			
		||||
                    if node.module and node.module.startswith('PySide6.'):
 | 
			
		||||
                        module = node.module.split('.', 2)[1]
 | 
			
		||||
                        if module.startswith('Qt'):
 | 
			
		||||
                            modules.add(module)
 | 
			
		||||
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            print(f"Ошибка при анализе {file_path}: {e}")
 | 
			
		||||
 | 
			
		||||
        return modules
 | 
			
		||||
 | 
			
		||||
    def get_all_dependencies(self, modules: Set[str], dependency_graph: Dict[str, Set[str]]) -> Set[str]:
 | 
			
		||||
        """Получает все зависимости для набора модулей, используя граф зависимостей из ldd"""
 | 
			
		||||
        all_deps = set(modules)
 | 
			
		||||
 | 
			
		||||
        if not dependency_graph:
 | 
			
		||||
            return all_deps
 | 
			
		||||
 | 
			
		||||
        # Повторяем до тех пор, пока не найдем все транзитивные зависимости
 | 
			
		||||
        changed = True
 | 
			
		||||
        iteration = 0
 | 
			
		||||
        while changed and iteration < 10:  # Защита от бесконечного цикла
 | 
			
		||||
            changed = False
 | 
			
		||||
            current_deps = set(all_deps)
 | 
			
		||||
 | 
			
		||||
            for module in current_deps:
 | 
			
		||||
                if module in dependency_graph:
 | 
			
		||||
                    new_deps = dependency_graph[module] - all_deps
 | 
			
		||||
                    if new_deps:
 | 
			
		||||
                        all_deps.update(new_deps)
 | 
			
		||||
                        changed = True
 | 
			
		||||
 | 
			
		||||
            iteration += 1
 | 
			
		||||
 | 
			
		||||
        return all_deps
 | 
			
		||||
 | 
			
		||||
    def analyze_project(self, project_path: Path, appdir_path: Path = None) -> Dict:
 | 
			
		||||
        """Анализирует весь проект"""
 | 
			
		||||
        python_files = self.find_python_files(project_path)
 | 
			
		||||
        print(f"Найдено {len(python_files)} Python файлов")
 | 
			
		||||
 | 
			
		||||
        # Анализ статических импортов
 | 
			
		||||
        used_modules_code = set()
 | 
			
		||||
        file_modules = {}
 | 
			
		||||
 | 
			
		||||
        for file_path in python_files:
 | 
			
		||||
            modules = self.analyze_file_imports(file_path)
 | 
			
		||||
            if modules:
 | 
			
		||||
                file_modules[str(file_path.relative_to(project_path))] = list(modules)
 | 
			
		||||
                used_modules_code.update(modules)
 | 
			
		||||
 | 
			
		||||
        print(f"Найдено {len(used_modules_code)} модулей в коде: {', '.join(sorted(used_modules_code))}")
 | 
			
		||||
 | 
			
		||||
        # Поиск PySide6 библиотек
 | 
			
		||||
        search_base = appdir_path if appdir_path else project_path
 | 
			
		||||
        pyside_libs = self.find_pyside6_libs(search_base)
 | 
			
		||||
 | 
			
		||||
        if not pyside_libs:
 | 
			
		||||
            print("ОШИБКА: PySide6 библиотеки не найдены! Анализ невозможен.")
 | 
			
		||||
            return {
 | 
			
		||||
                'error': 'PySide6 библиотеки не найдены',
 | 
			
		||||
                'analysis_method': 'failed',
 | 
			
		||||
                'found_libraries': 0,
 | 
			
		||||
                'directly_used_code': sorted(used_modules_code),
 | 
			
		||||
                'all_required': [],
 | 
			
		||||
                'removable': [],
 | 
			
		||||
                'available_modules': [],
 | 
			
		||||
                'file_usage': file_modules
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        print(f"Найдено {len(pyside_libs)} PySide6 библиотек")
 | 
			
		||||
 | 
			
		||||
        # Анализ реальных зависимостей с ldd
 | 
			
		||||
        real_dependencies = self.build_real_dependency_graph(pyside_libs)
 | 
			
		||||
 | 
			
		||||
        # Определяем модули, которые реально используются через ldd
 | 
			
		||||
        used_modules_ldd = set()
 | 
			
		||||
        for module in used_modules_code:
 | 
			
		||||
            if module in real_dependencies:
 | 
			
		||||
                used_modules_ldd.update(real_dependencies[module])
 | 
			
		||||
                used_modules_ldd.add(module)
 | 
			
		||||
 | 
			
		||||
        print(f"Реальные зависимости через ldd: {', '.join(sorted(used_modules_ldd))}")
 | 
			
		||||
 | 
			
		||||
        # Объединяем результаты анализа кода и ldd
 | 
			
		||||
        all_used_modules = used_modules_code | used_modules_ldd
 | 
			
		||||
 | 
			
		||||
        # Получаем все необходимые модули включая зависимости
 | 
			
		||||
        all_required = self.get_all_dependencies(all_used_modules, real_dependencies)
 | 
			
		||||
 | 
			
		||||
        # Все доступные PySide6 модули
 | 
			
		||||
        available_modules = set(pyside_libs.keys())
 | 
			
		||||
 | 
			
		||||
        # Модули, которые можно удалить
 | 
			
		||||
        removable = available_modules - all_required
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            'analysis_method': 'ldd + static analysis',
 | 
			
		||||
            'found_libraries': len(pyside_libs),
 | 
			
		||||
            'directly_used_code': sorted(used_modules_code),
 | 
			
		||||
            'directly_used_ldd': sorted(used_modules_ldd),
 | 
			
		||||
            'all_required': sorted(all_required),
 | 
			
		||||
            'removable': sorted(removable),
 | 
			
		||||
            'available_modules': sorted(available_modules),
 | 
			
		||||
            'file_usage': file_modules,
 | 
			
		||||
            'real_dependencies': {k: sorted(v) for k, v in real_dependencies.items()},
 | 
			
		||||
            'library_paths': {k: str(v) for k, v in pyside_libs.items()},
 | 
			
		||||
            'analysis_summary': {
 | 
			
		||||
                'total_modules': len(available_modules),
 | 
			
		||||
                'required_modules': len(all_required),
 | 
			
		||||
                'removable_modules': len(removable),
 | 
			
		||||
                'space_saving_potential': f"{len(removable)/len(available_modules)*100:.1f}%" if available_modules else "0%"
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    def generate_appimage_recipe(self, removable_modules: List[str], template_path: Path) -> str:
 | 
			
		||||
        """Генерирует обновленный AppImage рецепт с командами очистки"""
 | 
			
		||||
 | 
			
		||||
        # Читаем существующий рецепт
 | 
			
		||||
        try:
 | 
			
		||||
            with open(template_path, 'r', encoding='utf-8') as f:
 | 
			
		||||
                recipe_content = f.read()
 | 
			
		||||
        except FileNotFoundError:
 | 
			
		||||
            print(f"Шаблон рецепта не найден: {template_path}")
 | 
			
		||||
            return ""
 | 
			
		||||
 | 
			
		||||
        # Генерируем новые команды очистки
 | 
			
		||||
        cleanup_lines = []
 | 
			
		||||
 | 
			
		||||
        # QML удаляем только если не используется
 | 
			
		||||
        qml_modules = {'QtQml', 'QtQuick', 'QtQuickWidgets'}
 | 
			
		||||
        if qml_modules.issubset(set(removable_modules)):
 | 
			
		||||
            cleanup_lines.append("  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/")
 | 
			
		||||
 | 
			
		||||
        # Инструменты разработки (всегда удаляем)
 | 
			
		||||
        cleanup_lines.append("  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}")
 | 
			
		||||
 | 
			
		||||
        # Модули для удаления
 | 
			
		||||
        if removable_modules:
 | 
			
		||||
            modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)])
 | 
			
		||||
            cleanup_lines.append(f"  - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
 | 
			
		||||
 | 
			
		||||
        # Генерируем команду для удаления нативных библиотек с сохранением нужных
 | 
			
		||||
        required_libs = set()
 | 
			
		||||
        for module in sorted(set(self.all_required_modules)):
 | 
			
		||||
            required_libs.add(f"libQt6{module.replace('Qt', '')}*")
 | 
			
		||||
 | 
			
		||||
        # Добавляем системные библиотеки
 | 
			
		||||
        for lib in self.system_libs:
 | 
			
		||||
            required_libs.add(f"{lib}*")
 | 
			
		||||
 | 
			
		||||
        keep_pattern = '|'.join(sorted(required_libs))
 | 
			
		||||
 | 
			
		||||
        cleanup_lines.extend([
 | 
			
		||||
            "  - shopt -s extglob",
 | 
			
		||||
            f"  - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
 | 
			
		||||
        ])
 | 
			
		||||
 | 
			
		||||
        # Заменяем блок очистки в рецепте
 | 
			
		||||
        import re
 | 
			
		||||
 | 
			
		||||
        # Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir:
 | 
			
		||||
        pattern = r'(  # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n  # [0-9]+\)|$)'
 | 
			
		||||
 | 
			
		||||
        new_cleanup_block = "  # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines)
 | 
			
		||||
 | 
			
		||||
        updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL)
 | 
			
		||||
 | 
			
		||||
        return updated_recipe
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def main():
 | 
			
		||||
    parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
 | 
			
		||||
    parser.add_argument('project_path', help='Путь к проекту для анализа')
 | 
			
		||||
    parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
 | 
			
		||||
    parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
 | 
			
		||||
    parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
 | 
			
		||||
 | 
			
		||||
    args = parser.parse_args()
 | 
			
		||||
 | 
			
		||||
    project_path = Path(args.project_path)
 | 
			
		||||
    if not project_path.exists():
 | 
			
		||||
        print(f"Ошибка: путь {project_path} не существует")
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    appdir_path = Path(args.appdir) if args.appdir else None
 | 
			
		||||
    if appdir_path and not appdir_path.exists():
 | 
			
		||||
        print(f"Предупреждение: AppDir путь {appdir_path} не существует")
 | 
			
		||||
        appdir_path = None
 | 
			
		||||
 | 
			
		||||
    analyzer = PySide6DependencyAnalyzer()
 | 
			
		||||
    results = analyzer.analyze_project(project_path, appdir_path)
 | 
			
		||||
 | 
			
		||||
    # Сохраняем в анализатор для генерации команд
 | 
			
		||||
    analyzer.all_required_modules = set(results.get('all_required', []))
 | 
			
		||||
 | 
			
		||||
    # Выводим результаты
 | 
			
		||||
    print("\n" + "="*60)
 | 
			
		||||
    print("АНАЛИЗ ЗАВИСИМОСТЕЙ PYSIDE6 (ldd analysis)")
 | 
			
		||||
    print("="*60)
 | 
			
		||||
 | 
			
		||||
    if 'error' in results:
 | 
			
		||||
        print(f"\nОШИБКА: {results['error']}")
 | 
			
		||||
        sys.exit(1)
 | 
			
		||||
 | 
			
		||||
    print(f"\nМетод анализа: {results['analysis_method']}")
 | 
			
		||||
    print(f"Найдено библиотек: {results['found_libraries']}")
 | 
			
		||||
 | 
			
		||||
    if results['directly_used_code']:
 | 
			
		||||
        print(f"\nИспользуемые модули в коде ({len(results['directly_used_code'])}):")
 | 
			
		||||
        for module in results['directly_used_code']:
 | 
			
		||||
            print(f"  • {module}")
 | 
			
		||||
 | 
			
		||||
    if results['directly_used_ldd']:
 | 
			
		||||
        print(f"\nРеальные зависимости через ldd ({len(results['directly_used_ldd'])}):")
 | 
			
		||||
        for module in results['directly_used_ldd']:
 | 
			
		||||
            print(f"  • {module}")
 | 
			
		||||
 | 
			
		||||
    print(f"\nВсе необходимые модули ({len(results['all_required'])}):")
 | 
			
		||||
    for module in results['all_required']:
 | 
			
		||||
        print(f"  • {module}")
 | 
			
		||||
 | 
			
		||||
    print(f"\nМодули, которые можно удалить ({len(results['removable'])}):")
 | 
			
		||||
    for module in results['removable']:
 | 
			
		||||
        print(f"  • {module}")
 | 
			
		||||
 | 
			
		||||
    print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
 | 
			
		||||
 | 
			
		||||
    if args.verbose and results['real_dependencies']:
 | 
			
		||||
        Devlin(f"\nРеальные зависимости (ldd):")
 | 
			
		||||
        for module, deps in results['real_dependencies'].items():
 | 
			
		||||
            if deps:
 | 
			
		||||
                print(f"  {module} → {', '.join(deps)}")
 | 
			
		||||
 | 
			
		||||
    # Обновляем AppImage рецепт
 | 
			
		||||
    recipe_path = Path("../build-aux/AppImageBuilder.yml")
 | 
			
		||||
    if recipe_path.exists():
 | 
			
		||||
        updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
 | 
			
		||||
        if updated_recipe:
 | 
			
		||||
            with open(recipe_path, 'w', encoding='utf-8') as f:
 | 
			
		||||
                f.write(updated_recipe)
 | 
			
		||||
            print(f"\nAppImage рецепт обновлен: {recipe_path}")
 | 
			
		||||
        else:
 | 
			
		||||
            print(f"\nОШИБКА: не удалось обновить рецепт")
 | 
			
		||||
    else:
 | 
			
		||||
        print(f"\nПредупреждение: рецепт AppImage не найден в {recipe_path}")
 | 
			
		||||
 | 
			
		||||
    # Сохраняем результаты в JSON
 | 
			
		||||
    if args.output:
 | 
			
		||||
        with open(args.output, 'w', encoding='utf-8') as f:
 | 
			
		||||
            json.dump(results, f, ensure_ascii=False, indent=2)
 | 
			
		||||
        print(f"Результаты сохранены в: {args.output}")
 | 
			
		||||
 | 
			
		||||
    print("\n" + "="*60)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    main()
 | 
			
		||||
@@ -3,10 +3,11 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Contents
 | 
			
		||||
- [Overview](#overview)
 | 
			
		||||
- [Adding a New Translation](#adding-a-new-translation)
 | 
			
		||||
- [Updating Existing Translations](#updating-existing-translations)
 | 
			
		||||
- [Compiling Translations](#compiling-translations)
 | 
			
		||||
- [Overview](#-overview)
 | 
			
		||||
- [Adding a New Translation](#-adding-a-new-translation)
 | 
			
		||||
- [Updating Existing Translations](#-updating-existing-translations)
 | 
			
		||||
- [Compiling Translations](#-compiling-translations)
 | 
			
		||||
- [Spell Check](#-spell-check)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -20,9 +21,9 @@ Current translation status:
 | 
			
		||||
 | 
			
		||||
| Locale | Progress | Translated |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 197 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 197 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 of 197 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 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 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,10 +3,11 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Содержание
 | 
			
		||||
- [Обзор](#обзор)
 | 
			
		||||
- [Добавление нового перевода](#добавление-нового-перевода)
 | 
			
		||||
- [Обновление существующих переводов](#обновление-существующих-переводов)
 | 
			
		||||
- [Компиляция переводов](#компиляция-переводов)
 | 
			
		||||
- [Обзор](#-обзор)
 | 
			
		||||
- [Добавление нового перевода](#-добавление-нового-перевода)
 | 
			
		||||
- [Обновление существующих переводов](#-обновление-существующих-переводов)
 | 
			
		||||
- [Компиляция переводов](#-компиляция-переводов)
 | 
			
		||||
- [Проверка орфографии](#-проверка-орфографии)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -20,9 +21,9 @@
 | 
			
		||||
 | 
			
		||||
| Локаль | Прогресс | Переведено |
 | 
			
		||||
| :----- | -------: | ---------: |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 197 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 197 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 197 из 197 |
 | 
			
		||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
 | 
			
		||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
 | 
			
		||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 из 195 |
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,15 +3,10 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Contents
 | 
			
		||||
- [Overview](#overview)
 | 
			
		||||
- [How It Works](#how-it-works)
 | 
			
		||||
  - [Data Priorities](#data-priorities)
 | 
			
		||||
  - [File Structure](#file-structure)
 | 
			
		||||
- [For Users](#for-users)
 | 
			
		||||
  - [Creating User Overrides](#creating-user-overrides)
 | 
			
		||||
  - [Example](#example)
 | 
			
		||||
- [For Developers](#for-developers)
 | 
			
		||||
  - [Adding Built-In Overrides](#adding-built-in-overrides)
 | 
			
		||||
- [Overview](#-overview)
 | 
			
		||||
- [How It Works](#-how-it-works)
 | 
			
		||||
- [For Users](#-for-users)
 | 
			
		||||
- [For Developers](#-for-developers)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,15 +3,10 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Содержание
 | 
			
		||||
- [Обзор](#обзор)
 | 
			
		||||
- [Как это работает](#как-это-работает)
 | 
			
		||||
  - [Приоритеты данных](#приоритеты-данных)
 | 
			
		||||
  - [Структура файлов](#структура-файлов)
 | 
			
		||||
- [Для пользователей](#для-пользователей)
 | 
			
		||||
  - [Создание пользовательских переопределений](#создание-пользовательских-переопределений)
 | 
			
		||||
  - [Пример](#пример)
 | 
			
		||||
- [Для разработчиков](#для-разработчиков)
 | 
			
		||||
  - [Добавление встроенных переопределений](#добавление-встроенных-переопределений)
 | 
			
		||||
- [Обзор](#-обзор)
 | 
			
		||||
- [Как это работает](#-как-это-работает)
 | 
			
		||||
- [Для пользователей](#-для-пользователей)
 | 
			
		||||
- [Для разработчиков](#-для-разработчиков)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,13 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Contents
 | 
			
		||||
- [Overview](#overview)
 | 
			
		||||
- [Creating the Theme Folder](#creating-the-theme-folder)
 | 
			
		||||
- [Style File](#style-file)
 | 
			
		||||
- [Metadata](#metadata)
 | 
			
		||||
- [Screenshots](#screenshots)
 | 
			
		||||
- [Fonts and Icons](#fonts-and-icons)
 | 
			
		||||
- [Overview](#-overview)
 | 
			
		||||
- [Creating the Theme Folder](#-creating-the-theme-folder)
 | 
			
		||||
- [Style File](#-style-file-stylespy)
 | 
			
		||||
- [Animation configuration](#-animation-configuration)
 | 
			
		||||
- [Metadata](#-metadata-metainfoini)
 | 
			
		||||
- [Screenshots](#-screenshots)
 | 
			
		||||
- [Fonts and Icons](#-fonts-and-icons-optional)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +46,114 @@ def custom_button_style(color1, color2):
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 🎥 Animation configuration
 | 
			
		||||
 | 
			
		||||
The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game cards:
 | 
			
		||||
 | 
			
		||||
```python
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Type of animation when entering and exiting the detail page
 | 
			
		||||
    # Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
 | 
			
		||||
    "detail_page_animation_type": "fade",
 | 
			
		||||
 | 
			
		||||
    # Border width of the card in idle state (no hover or focus).
 | 
			
		||||
    # Affects the thickness of the border when the card is not highlighted.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Border width on hover.
 | 
			
		||||
    # Increases the border thickness when the cursor is over the card.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "hover_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Border width on focus (e.g., selected via keyboard).
 | 
			
		||||
    # Increases the border thickness when the card is focused.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Minimum border width during pulsing animation.
 | 
			
		||||
    # Sets the minimum border thickness during the "breathing" animation.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Maximum border width during pulsing animation.
 | 
			
		||||
    # Sets the maximum border thickness during pulsing.
 | 
			
		||||
    # Value in pixels.
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Duration of the border thickness animation (e.g., on hover or focus).
 | 
			
		||||
    # Affects the speed of transition between different border widths.
 | 
			
		||||
    # Value in milliseconds.
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Duration of one pulsing animation cycle.
 | 
			
		||||
    # Defines how fast the border "pulses" between min and max values.
 | 
			
		||||
    # Value in milliseconds.
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Duration of the gradient rotation animation.
 | 
			
		||||
    # Affects how fast the gradient border rotates around the card.
 | 
			
		||||
    # Value in milliseconds.
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Starting angle of the gradient (in degrees).
 | 
			
		||||
    # Defines the initial rotation point of the gradient when the animation starts.
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Ending angle of the gradient (in degrees).
 | 
			
		||||
    # Defines the end rotation point of the gradient.
 | 
			
		||||
    # A value of 0 means a full 360-degree rotation.
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for border expansion animation (on hover/focus).
 | 
			
		||||
    # Affects the "feel" of the animation (e.g., smooth acceleration or deceleration).
 | 
			
		||||
    # Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad").
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for border contraction animation (on mouse leave/focus loss).
 | 
			
		||||
    # Affects the "feel" of returning to the original border width.
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Gradient colors for the animated border.
 | 
			
		||||
    # A list of dictionaries where each defines a position (0.0–1.0) and color in hex format.
 | 
			
		||||
    # Affects the appearance of the border on hover or focus.
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Start color (cyan)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # 33% color (orange)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # 66% color (purple)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # End color (back to cyan)
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Duration of the fade animation when entering the detail page
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Duration of the slide animation when entering the detail page
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Duration of the bounce animation when entering the detail page
 | 
			
		||||
    "detail_page_bounce_duration": 400,
 | 
			
		||||
 | 
			
		||||
    # Duration of the fade animation when exiting the detail page
 | 
			
		||||
    "detail_page_fade_duration_exit": 350,
 | 
			
		||||
 | 
			
		||||
    # Duration of the slide animation when exiting the detail page
 | 
			
		||||
    "detail_page_slide_duration_exit": 500,
 | 
			
		||||
 | 
			
		||||
    # Duration of the bounce animation when exiting the detail page
 | 
			
		||||
    "detail_page_bounce_duration_exit": 400,
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for animation when entering the detail page
 | 
			
		||||
    # Applies to slide and bounce animations
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Easing curve type for animation when exiting the detail page
 | 
			
		||||
    # Applies to slide and bounce animations
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📝 Metadata (`metainfo.ini`)
 | 
			
		||||
 | 
			
		||||
```ini
 | 
			
		||||
 
 | 
			
		||||
@@ -3,12 +3,13 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📋 Содержание
 | 
			
		||||
- [Обзор](#обзор)
 | 
			
		||||
- [Создание папки темы](#создание-папки-темы)
 | 
			
		||||
- [Файл стилей](#файл-стилей)
 | 
			
		||||
- [Метаинформация](#метаинформация)
 | 
			
		||||
- [Скриншоты](#скриншоты)
 | 
			
		||||
- [Шрифты и иконки](#шрифты-и-иконки)
 | 
			
		||||
- [Обзор](#-обзор)
 | 
			
		||||
- [Создание папки темы](#-создание-папки-темы)
 | 
			
		||||
- [Файл стилей](#-файл-стилей-stylespy)
 | 
			
		||||
- [Конфигурация анимации](#-конфигурация-анимации)
 | 
			
		||||
- [Метаинформация](#-метаинформация-metainfoini)
 | 
			
		||||
- [Скриншоты](#-скриншоты)
 | 
			
		||||
- [Шрифты и иконки](#-шрифты-и-иконки-опционально)
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
@@ -45,6 +46,114 @@ def custom_button_style(color1, color2):
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 🎥 Конфигурация анимации
 | 
			
		||||
 | 
			
		||||
Словарь `GAME_CARD_ANIMATION` управляет всеми параметрами анимации для карточек игр:
 | 
			
		||||
 | 
			
		||||
```python
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Тип анимации при входе и выходе на детальную страницу
 | 
			
		||||
    # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
 | 
			
		||||
    "detail_page_animation_type": "fade",
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
 | 
			
		||||
    # Влияет на толщину рамки вокруг карточки, когда она не выделена.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при наведении курсора.
 | 
			
		||||
    # Увеличивает толщину рамки, когда курсор находится над карточкой.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "hover_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при фокусе (например, при выборе с клавиатуры).
 | 
			
		||||
    # Увеличивает толщину рамки, когда карточка в фокусе.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Минимальная ширина обводки во время пульсирующей анимации.
 | 
			
		||||
    # Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Максимальная ширина обводки во время пульсирующей анимации.
 | 
			
		||||
    # Определяет максимальную толщину рамки при пульсации.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
 | 
			
		||||
    # Влияет на скорость перехода от одной ширины обводки к другой.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Длительность одного цикла пульсирующей анимации.
 | 
			
		||||
    # Определяет, как быстро рамка "пульсирует" между min и max значениями.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации вращения градиента.
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Начальный угол градиента (в градусах).
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации.
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Конечный угол градиента (в градусах).
 | 
			
		||||
    # Определяет конечную точку вращения градиента.
 | 
			
		||||
    # Значение 0 означает полный поворот на 360 градусов.
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
 | 
			
		||||
    # Влияет на "чувство" анимации (например, плавное ускорение или замедление).
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
 | 
			
		||||
    # Влияет на "чувство" возврата к исходной ширине обводки.
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Цвета градиента для анимированной обводки.
 | 
			
		||||
    # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
 | 
			
		||||
    # Влияет на внешний вид обводки при наведении или фокусе.
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Начальный цвет (циан)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # Конечный цвет (возвращение к циану)
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при входе на детальную страницу
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при входе на детальную страницу
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при входе на детальную страницу
 | 
			
		||||
    "detail_page_bounce_duration": 400,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при выходе из детальной страницы
 | 
			
		||||
    "detail_page_fade_duration_exit": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при выходе из детальной страницы
 | 
			
		||||
    "detail_page_slide_duration_exit": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при выходе из детальной страницы
 | 
			
		||||
    "detail_page_bounce_duration_exit": 400,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при входе на детальную страницу
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при выходе из детальной страницы
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
```
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 📝 Метаинформация (`metainfo.ini`)
 | 
			
		||||
 | 
			
		||||
```ini
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										328
									
								
								portprotonqt/animations.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,328 @@
 | 
			
		||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstractAnimation, QParallelAnimationGroup, QRect, Qt, QPoint
 | 
			
		||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
 | 
			
		||||
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
class SafeOpacityEffect(QGraphicsOpacityEffect):
 | 
			
		||||
    def __init__(self, parent=None, disable_at_full=True):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        self.disable_at_full = disable_at_full
 | 
			
		||||
 | 
			
		||||
    def setOpacity(self, opacity: float):
 | 
			
		||||
        opacity = max(0.0, min(1.0, opacity))
 | 
			
		||||
        super().setOpacity(opacity)
 | 
			
		||||
        if opacity < 1.0:
 | 
			
		||||
            self.setEnabled(True)
 | 
			
		||||
        elif self.disable_at_full:
 | 
			
		||||
            self.setEnabled(False)
 | 
			
		||||
 | 
			
		||||
class GameCardAnimations:
 | 
			
		||||
    def __init__(self, game_card, theme=None):
 | 
			
		||||
        self.game_card = game_card
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
        self.thickness_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self.gradient_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self.pulse_anim: QPropertyAnimation | None = None
 | 
			
		||||
        self._isPulseAnimationConnected = False
 | 
			
		||||
 | 
			
		||||
    def setup_animations(self):
 | 
			
		||||
        """Initialize animation properties."""
 | 
			
		||||
        self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
 | 
			
		||||
        self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
 | 
			
		||||
 | 
			
		||||
    def start_pulse_animation(self):
 | 
			
		||||
        """Start pulse animation for border width when hovered or focused."""
 | 
			
		||||
        if not (self.game_card._hovered or self.game_card._focused):
 | 
			
		||||
            return
 | 
			
		||||
        if self.pulse_anim:
 | 
			
		||||
            self.pulse_anim.stop()
 | 
			
		||||
        self.pulse_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
 | 
			
		||||
        self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
 | 
			
		||||
        self.pulse_anim.setLoopCount(0)
 | 
			
		||||
        self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
 | 
			
		||||
        self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
 | 
			
		||||
        self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
 | 
			
		||||
        self.pulse_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_enter_event(self):
 | 
			
		||||
        """Handle mouse enter event animations."""
 | 
			
		||||
        self.game_card._hovered = True
 | 
			
		||||
        self.game_card.hoverChanged.emit(self.game_card.name, True)
 | 
			
		||||
        self.game_card.setFocus(Qt.FocusReason.MouseFocusReason)
 | 
			
		||||
 | 
			
		||||
        if not self.thickness_anim:
 | 
			
		||||
            self.setup_animations()
 | 
			
		||||
 | 
			
		||||
        if self.thickness_anim:
 | 
			
		||||
            self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
                self.thickness_anim.finished.disconnect(self.start_pulse_animation)
 | 
			
		||||
                self._isPulseAnimationConnected = False
 | 
			
		||||
            self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
 | 
			
		||||
            self.thickness_anim.setStartValue(self.game_card._borderWidth)
 | 
			
		||||
            self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
 | 
			
		||||
            self.thickness_anim.finished.connect(self.start_pulse_animation)
 | 
			
		||||
            self._isPulseAnimationConnected = True
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
        if self.gradient_anim:
 | 
			
		||||
            self.gradient_anim.stop()
 | 
			
		||||
        self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
        self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
        self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
        self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
        self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
        self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_leave_event(self):
 | 
			
		||||
        """Handle mouse leave event animations."""
 | 
			
		||||
        self.game_card._hovered = False
 | 
			
		||||
        self.game_card.hoverChanged.emit(self.game_card.name, False)
 | 
			
		||||
        if not self.game_card._focused:
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
                if self._isPulseAnimationConnected:
 | 
			
		||||
                    self.thickness_anim.finished.disconnect(self.start_pulse_animation)
 | 
			
		||||
                    self._isPulseAnimationConnected = False
 | 
			
		||||
                self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
 | 
			
		||||
                self.thickness_anim.setStartValue(self.game_card._borderWidth)
 | 
			
		||||
                self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
 | 
			
		||||
                self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_focus_in_event(self):
 | 
			
		||||
        """Handle focus in event animations."""
 | 
			
		||||
        if not self.game_card._hovered:
 | 
			
		||||
            self.game_card._focused = True
 | 
			
		||||
            self.game_card.focusChanged.emit(self.game_card.name, True)
 | 
			
		||||
 | 
			
		||||
            if not self.thickness_anim:
 | 
			
		||||
                self.setup_animations()
 | 
			
		||||
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
                if self._isPulseAnimationConnected:
 | 
			
		||||
                    self.thickness_anim.finished.disconnect(self.start_pulse_animation)
 | 
			
		||||
                    self._isPulseAnimationConnected = False
 | 
			
		||||
                self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
 | 
			
		||||
                self.thickness_anim.setStartValue(self.game_card._borderWidth)
 | 
			
		||||
                self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
 | 
			
		||||
                self.thickness_anim.finished.connect(self.start_pulse_animation)
 | 
			
		||||
                self._isPulseAnimationConnected = True
 | 
			
		||||
                self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
            self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
 | 
			
		||||
            self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
            self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
            self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
            self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
            self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
    def handle_focus_out_event(self):
 | 
			
		||||
        """Handle focus out event animations."""
 | 
			
		||||
        self.game_card._focused = False
 | 
			
		||||
        self.game_card.focusChanged.emit(self.game_card.name, False)
 | 
			
		||||
        if not self.game_card._hovered:
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
                if self._isPulseAnimationConnected:
 | 
			
		||||
                    self.thickness_anim.finished.disconnect(self.start_pulse_animation)
 | 
			
		||||
                    self._isPulseAnimationConnected = False
 | 
			
		||||
                self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
 | 
			
		||||
                self.thickness_anim.setStartValue(self.game_card._borderWidth)
 | 
			
		||||
                self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
 | 
			
		||||
                self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
    def paint_border(self, painter: QPainter):
 | 
			
		||||
        if not painter.isActive():
 | 
			
		||||
            logger.warning("Painter is not active; skipping border paint")
 | 
			
		||||
            return
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
        pen = QPen()
 | 
			
		||||
        pen.setWidth(self.game_card._borderWidth)
 | 
			
		||||
        if self.game_card._hovered or self.game_card._focused:
 | 
			
		||||
            center = self.game_card.rect().center()
 | 
			
		||||
            gradient = QConicalGradient(center, self.game_card._gradientAngle)
 | 
			
		||||
            for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
 | 
			
		||||
                gradient.setColorAt(stop["position"], QColor(stop["color"]))
 | 
			
		||||
            pen.setBrush(QBrush(gradient))
 | 
			
		||||
        else:
 | 
			
		||||
            pen.setColor(QColor(0, 0, 0, 0))
 | 
			
		||||
        painter.setPen(pen)
 | 
			
		||||
        radius = 18
 | 
			
		||||
        bw = round(self.game_card._borderWidth / 2)
 | 
			
		||||
        rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
 | 
			
		||||
        if rect.isEmpty():
 | 
			
		||||
            return  # Avoid drawing invalid rect
 | 
			
		||||
        painter.drawRoundedRect(rect, radius, radius)
 | 
			
		||||
 | 
			
		||||
class DetailPageAnimations:
 | 
			
		||||
    def __init__(self, main_window, theme=None):
 | 
			
		||||
        self.main_window = main_window
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
        self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
 | 
			
		||||
 | 
			
		||||
    def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
 | 
			
		||||
        """Animate the detail page based on theme settings."""
 | 
			
		||||
        animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
 | 
			
		||||
        duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
 | 
			
		||||
 | 
			
		||||
        if animation_type == "fade":
 | 
			
		||||
            original_effect = detail_page.graphicsEffect()
 | 
			
		||||
            opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
 | 
			
		||||
            opacity_effect.setOpacity(0.0)
 | 
			
		||||
            detail_page.setGraphicsEffect(opacity_effect)
 | 
			
		||||
            animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
 | 
			
		||||
            animation.setDuration(duration)
 | 
			
		||||
            animation.setStartValue(0.0)
 | 
			
		||||
            animation.setEndValue(0.999)
 | 
			
		||||
            animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
            self.animations[detail_page] = animation
 | 
			
		||||
            def restore_effect():
 | 
			
		||||
                try:
 | 
			
		||||
                    detail_page.setGraphicsEffect(original_effect) # type: ignore
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    logger.debug("Original effect already deleted")
 | 
			
		||||
            animation.finished.connect(restore_effect)
 | 
			
		||||
            animation.finished.connect(load_image_and_restore_effect)
 | 
			
		||||
            animation.finished.connect(opacity_effect.deleteLater)
 | 
			
		||||
        elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
 | 
			
		||||
            duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
 | 
			
		||||
            easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
 | 
			
		||||
            start_pos = {
 | 
			
		||||
                "slide_left": QPoint(self.main_window.width(), 0),
 | 
			
		||||
                "slide_right": QPoint(-self.main_window.width(), 0),
 | 
			
		||||
                "slide_up": QPoint(0, self.main_window.height()),
 | 
			
		||||
                "slide_down": QPoint(0, -self.main_window.height())
 | 
			
		||||
            }[animation_type]
 | 
			
		||||
            detail_page.move(start_pos)
 | 
			
		||||
            animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
 | 
			
		||||
            animation.setDuration(duration)
 | 
			
		||||
            animation.setStartValue(start_pos)
 | 
			
		||||
            animation.setEndValue(self.main_window.stackedWidget.rect().topLeft())
 | 
			
		||||
            animation.setEasingCurve(easing_curve)
 | 
			
		||||
            animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
            self.animations[detail_page] = animation
 | 
			
		||||
            animation.finished.connect(cleanup_animation)
 | 
			
		||||
            animation.finished.connect(load_image_and_restore_effect)
 | 
			
		||||
        elif animation_type == "bounce":
 | 
			
		||||
            duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration", 400)
 | 
			
		||||
            easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
 | 
			
		||||
            detail_page.setWindowOpacity(0.0)
 | 
			
		||||
            opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
 | 
			
		||||
            opacity_anim.setDuration(duration)
 | 
			
		||||
            opacity_anim.setStartValue(0.0)
 | 
			
		||||
            opacity_anim.setEndValue(1.0)
 | 
			
		||||
            initial_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
 | 
			
		||||
                                detail_page.width() // 2, detail_page.height() // 2)
 | 
			
		||||
            final_rect = detail_page.geometry()
 | 
			
		||||
            geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
 | 
			
		||||
            geometry_anim.setDuration(duration)
 | 
			
		||||
            geometry_anim.setStartValue(initial_rect)
 | 
			
		||||
            geometry_anim.setEndValue(final_rect)
 | 
			
		||||
            geometry_anim.setEasingCurve(easing_curve)
 | 
			
		||||
            group_anim = QParallelAnimationGroup()
 | 
			
		||||
            group_anim.addAnimation(opacity_anim)
 | 
			
		||||
            group_anim.addAnimation(geometry_anim)
 | 
			
		||||
            group_anim.finished.connect(load_image_and_restore_effect)
 | 
			
		||||
            group_anim.finished.connect(cleanup_animation)
 | 
			
		||||
            group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
            self.animations[detail_page] = group_anim
 | 
			
		||||
 | 
			
		||||
    def animate_detail_page_exit(self, detail_page: QWidget, cleanup_callback: Callable):
 | 
			
		||||
        """Animate the detail page exit based on theme settings."""
 | 
			
		||||
        try:
 | 
			
		||||
            animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
 | 
			
		||||
 | 
			
		||||
            # Safely stop and remove any existing animation
 | 
			
		||||
            if detail_page in self.animations:
 | 
			
		||||
                try:
 | 
			
		||||
                    animation = self.animations[detail_page]
 | 
			
		||||
                    if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
 | 
			
		||||
                        animation.stop()
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    logger.debug("Animation already deleted for page")
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(f"Error stopping existing animation: {e}", exc_info=True)
 | 
			
		||||
                finally:
 | 
			
		||||
                    self.animations.pop(detail_page, None)
 | 
			
		||||
 | 
			
		||||
            # Define animation based on type
 | 
			
		||||
            if animation_type == "fade":
 | 
			
		||||
                duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
 | 
			
		||||
                original_effect = detail_page.graphicsEffect()
 | 
			
		||||
                opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
 | 
			
		||||
                opacity_effect.setOpacity(0.999)
 | 
			
		||||
                detail_page.setGraphicsEffect(opacity_effect)
 | 
			
		||||
                animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
 | 
			
		||||
                animation.setDuration(duration)
 | 
			
		||||
                animation.setStartValue(0.999)
 | 
			
		||||
                animation.setEndValue(0.0)
 | 
			
		||||
                animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
                self.animations[detail_page] = animation
 | 
			
		||||
                def restore_and_cleanup():
 | 
			
		||||
                    try:
 | 
			
		||||
                        detail_page.setGraphicsEffect(original_effect) # type: ignore
 | 
			
		||||
                    except RuntimeError:
 | 
			
		||||
                        logger.debug("Original effect already deleted")
 | 
			
		||||
                    cleanup_callback()
 | 
			
		||||
                animation.finished.connect(restore_and_cleanup)
 | 
			
		||||
                animation.finished.connect(opacity_effect.deleteLater)  # Clean up effect
 | 
			
		||||
            elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
 | 
			
		||||
                duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
 | 
			
		||||
                easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
 | 
			
		||||
                end_pos = {
 | 
			
		||||
                    "slide_left": QPoint(-self.main_window.width(), 0),  # Exit to left (opposite of entry)
 | 
			
		||||
                    "slide_right": QPoint(self.main_window.width(), 0),  # Exit to right
 | 
			
		||||
                    "slide_up": QPoint(0, self.main_window.height()),    # Exit downward
 | 
			
		||||
                    "slide_down": QPoint(0, -self.main_window.height())  # Exit upward
 | 
			
		||||
                }[animation_type]
 | 
			
		||||
                animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
 | 
			
		||||
                animation.setDuration(duration)
 | 
			
		||||
                animation.setStartValue(detail_page.pos())
 | 
			
		||||
                animation.setEndValue(end_pos)
 | 
			
		||||
                animation.setEasingCurve(easing_curve)
 | 
			
		||||
                animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
                self.animations[detail_page] = animation
 | 
			
		||||
                animation.finished.connect(cleanup_callback)
 | 
			
		||||
            elif animation_type == "bounce":
 | 
			
		||||
                duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_bounce_duration_exit", 400)
 | 
			
		||||
                easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
 | 
			
		||||
                opacity_anim = QPropertyAnimation(detail_page, QByteArray(b"windowOpacity"))
 | 
			
		||||
                opacity_anim.setDuration(duration)
 | 
			
		||||
                opacity_anim.setStartValue(1.0)
 | 
			
		||||
                opacity_anim.setEndValue(0.0)
 | 
			
		||||
                final_rect = QRect(detail_page.x() + detail_page.width() // 4, detail_page.y() + detail_page.height() // 4,
 | 
			
		||||
                                  detail_page.width() // 2, detail_page.height() // 2)
 | 
			
		||||
                geometry_anim = QPropertyAnimation(detail_page, QByteArray(b"geometry"))
 | 
			
		||||
                geometry_anim.setDuration(duration)
 | 
			
		||||
                geometry_anim.setStartValue(detail_page.geometry())
 | 
			
		||||
                geometry_anim.setEndValue(final_rect)
 | 
			
		||||
                geometry_anim.setEasingCurve(easing_curve)
 | 
			
		||||
                group_anim = QParallelAnimationGroup()
 | 
			
		||||
                group_anim.addAnimation(opacity_anim)
 | 
			
		||||
                group_anim.addAnimation(geometry_anim)
 | 
			
		||||
                group_anim.finished.connect(cleanup_callback)
 | 
			
		||||
                group_anim.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
                self.animations[detail_page] = group_anim
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
 | 
			
		||||
            self.animations.pop(detail_page, None)
 | 
			
		||||
            cleanup_callback()  # Fallback to cleanup if animation setup fails
 | 
			
		||||
@@ -3,8 +3,7 @@ from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
 | 
			
		||||
from PySide6.QtWidgets import QApplication
 | 
			
		||||
from PySide6.QtGui import QIcon
 | 
			
		||||
from portprotonqt.main_window import MainWindow
 | 
			
		||||
from portprotonqt.tray import SystemTray
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config, save_fullscreen_config
 | 
			
		||||
from portprotonqt.config_utils import save_fullscreen_config
 | 
			
		||||
from portprotonqt.logger import get_logger
 | 
			
		||||
from portprotonqt.cli import parse_args
 | 
			
		||||
 | 
			
		||||
@@ -38,35 +37,13 @@ def main():
 | 
			
		||||
        save_fullscreen_config(True)
 | 
			
		||||
        window.showFullScreen()
 | 
			
		||||
 | 
			
		||||
    current_theme_name = read_theme_from_config()
 | 
			
		||||
    tray = SystemTray(app, current_theme_name)
 | 
			
		||||
    tray.show_action.triggered.connect(window.show)
 | 
			
		||||
    tray.hide_action.triggered.connect(window.hide)
 | 
			
		||||
 | 
			
		||||
    def recreate_tray():
 | 
			
		||||
        nonlocal tray
 | 
			
		||||
        if tray:
 | 
			
		||||
            logger.debug("Recreating system tray")
 | 
			
		||||
            tray.cleanup()
 | 
			
		||||
            tray = None
 | 
			
		||||
        current_theme = read_theme_from_config()
 | 
			
		||||
        tray = SystemTray(app, current_theme)
 | 
			
		||||
        # Ensure window is not None before connecting signals
 | 
			
		||||
        if window:
 | 
			
		||||
            tray.show_action.triggered.connect(window.show)
 | 
			
		||||
            tray.hide_action.triggered.connect(window.hide)
 | 
			
		||||
 | 
			
		||||
    def cleanup_on_exit():
 | 
			
		||||
        nonlocal tray, window
 | 
			
		||||
        nonlocal window
 | 
			
		||||
        app.aboutToQuit.disconnect()
 | 
			
		||||
        if tray:
 | 
			
		||||
            tray.cleanup()
 | 
			
		||||
            tray = None
 | 
			
		||||
        if window:
 | 
			
		||||
            window.close()
 | 
			
		||||
        app.quit()
 | 
			
		||||
 | 
			
		||||
    window.settings_saved.connect(recreate_tray)
 | 
			
		||||
    app.aboutToQuit.connect(cleanup_on_exit)
 | 
			
		||||
 | 
			
		||||
    window.show()
 | 
			
		||||
 
 | 
			
		||||
@@ -280,7 +280,12 @@ class ContextMenuManager:
 | 
			
		||||
                )
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        menu.exec(game_card.mapToGlobal(pos))
 | 
			
		||||
        # Устанавливаем фокус на первый элемент меню
 | 
			
		||||
        actions = menu.actions()
 | 
			
		||||
        if actions:
 | 
			
		||||
            menu.setActiveAction(actions[0])
 | 
			
		||||
 | 
			
		||||
            menu.exec(game_card.mapToGlobal(pos))
 | 
			
		||||
 | 
			
		||||
    def _launch_game(self, game_card):
 | 
			
		||||
        """
 | 
			
		||||
 
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 447 KiB  | 
@@ -1,3 +0,0 @@
 | 
			
		||||
name=Pulse Online
 | 
			
		||||
description_ru=Многопользовательская онлайн-игра в жанре MMORPG, действие которой происходит в научно-фантастическом мире с уникальной боевой системой и глубоким крафтом. Игроки могут исследовать обширные локации, выполнять квесты, сражаться с противниками и взаимодействовать с другими участниками игры.
 | 
			
		||||
description_en=A multiplayer online game in the MMORPG genre set in a sci-fi world with a unique combat system and deep crafting mechanics. Players can explore vast locations, complete quests, battle enemies, and interact with other participants in the game.
 | 
			
		||||
@@ -150,7 +150,7 @@ class FileExplorer(QDialog):
 | 
			
		||||
 | 
			
		||||
    def setup_ui(self):
 | 
			
		||||
        """Настройка интерфейса"""
 | 
			
		||||
        self.setWindowTitle("File Explorer")
 | 
			
		||||
        self.setWindowTitle(_("File Explorer"))
 | 
			
		||||
        self.setGeometry(100, 100, 600, 600)
 | 
			
		||||
 | 
			
		||||
        self.main_layout = QVBoxLayout()
 | 
			
		||||
@@ -677,7 +677,10 @@ class AddGameDialog(QDialog):
 | 
			
		||||
        exe_path = self.exeEdit.text().strip()
 | 
			
		||||
        name = self.nameEdit.text().strip()
 | 
			
		||||
 | 
			
		||||
        if not exe_path or not name:
 | 
			
		||||
        if not exe_path or not os.path.isfile(exe_path):
 | 
			
		||||
            return None, None
 | 
			
		||||
 | 
			
		||||
        if not name:
 | 
			
		||||
            return None, None
 | 
			
		||||
 | 
			
		||||
        portproton_path = get_portproton_location()
 | 
			
		||||
 
 | 
			
		||||
@@ -144,14 +144,21 @@ class Downloader(QObject):
 | 
			
		||||
                logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
 | 
			
		||||
                return None
 | 
			
		||||
            if url in self._cache:
 | 
			
		||||
                return self._cache[url]
 | 
			
		||||
                cached_path = self._cache[url]
 | 
			
		||||
                if os.path.exists(cached_path):
 | 
			
		||||
                    if os.path.abspath(cached_path) == os.path.abspath(local_path):
 | 
			
		||||
                        return cached_path
 | 
			
		||||
                else:
 | 
			
		||||
                    del self._cache[url]
 | 
			
		||||
        url_lock = self._get_url_lock(url)
 | 
			
		||||
        with url_lock:
 | 
			
		||||
            with self._global_lock:
 | 
			
		||||
                if url in self._last_error:
 | 
			
		||||
                    return None
 | 
			
		||||
                if url in self._cache:
 | 
			
		||||
                    return self._cache[url]
 | 
			
		||||
                    cached_path = self._cache[url]
 | 
			
		||||
                    if os.path.exists(cached_path) and os.path.abspath(cached_path) == os.path.abspath(local_path):
 | 
			
		||||
                        return cached_path
 | 
			
		||||
            result = download_with_cache(url, local_path, timeout, self)
 | 
			
		||||
            with self._global_lock:
 | 
			
		||||
                if result:
 | 
			
		||||
 
 | 
			
		||||
@@ -16,13 +16,14 @@ from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_la
 | 
			
		||||
from portprotonqt.config_utils import get_portproton_location
 | 
			
		||||
from portprotonqt.steam_api import (
 | 
			
		||||
    get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
 | 
			
		||||
    search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail
 | 
			
		||||
    search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
 | 
			
		||||
)
 | 
			
		||||
import vdf
 | 
			
		||||
import shutil
 | 
			
		||||
import zlib
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from PySide6.QtGui import QPixmap
 | 
			
		||||
import base64
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
downloader = Downloader()
 | 
			
		||||
@@ -66,7 +67,8 @@ def get_cache_dir() -> Path:
 | 
			
		||||
 | 
			
		||||
def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None:
 | 
			
		||||
    """
 | 
			
		||||
    Removes an EGS game from Steam by modifying shortcuts.vdf and deleting the launch script.
 | 
			
		||||
    Removes an EGS game from Steam using CEF API or by modifying shortcuts.vdf and deleting the launch script.
 | 
			
		||||
    Also deletes associated cover files in the Steam grid directory.
 | 
			
		||||
    Calls the callback with (success, message).
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
@@ -74,6 +76,7 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
 | 
			
		||||
        portproton_dir: Path to the PortProton directory.
 | 
			
		||||
        callback: Callback function to handle the result (success, message).
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    if not portproton_dir:
 | 
			
		||||
        logger.error("PortProton directory not found")
 | 
			
		||||
        callback((False, "PortProton directory not found"))
 | 
			
		||||
@@ -101,51 +104,89 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
 | 
			
		||||
    unsigned_id = convert_steam_id(user_id)
 | 
			
		||||
    user_dir = os.path.join(userdata_dir, str(unsigned_id))
 | 
			
		||||
    steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    grid_dir = os.path.join(user_dir, "config", "grid")
 | 
			
		||||
 | 
			
		||||
    if not os.path.exists(steam_shortcuts_path):
 | 
			
		||||
        logger.error("Steam shortcuts file not found")
 | 
			
		||||
        callback((False, "Steam shortcuts file not found"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Find appid for the shortcut
 | 
			
		||||
    try:
 | 
			
		||||
        with open(steam_shortcuts_path, 'rb') as f:
 | 
			
		||||
            shortcuts_data = vdf.binary_load(f)
 | 
			
		||||
        shortcuts = shortcuts_data.get("shortcuts", {})
 | 
			
		||||
        appid = None
 | 
			
		||||
        for _key, entry in shortcuts.items():
 | 
			
		||||
            if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
 | 
			
		||||
                appid = convert_steam_id(int(entry.get("appid")))
 | 
			
		||||
                logger.info(f"Found matching shortcut for '{game_name}' with AppID {appid}")
 | 
			
		||||
                break
 | 
			
		||||
        if not appid:
 | 
			
		||||
            logger.info(f"Game '{game_name}' not found in Steam shortcuts")
 | 
			
		||||
            callback((False, f"Game '{game_name}' not found in Steam"))
 | 
			
		||||
            return
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to load shortcuts.vdf: {e}")
 | 
			
		||||
        callback((False, f"Failed to load shortcuts.vdf: {e}"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Try CEF API first
 | 
			
		||||
    logger.info(f"Attempting to remove EGS game '{game_name}' via Steam CEF API with AppID {appid}")
 | 
			
		||||
    api_response = call_steam_api("removeShortcut", appid)
 | 
			
		||||
    if api_response is not None:  # API responded, even if empty
 | 
			
		||||
        logger.info(f"Shortcut for AppID {appid} successfully removed via CEF API")
 | 
			
		||||
 | 
			
		||||
        # Delete cover files
 | 
			
		||||
        cover_files = [
 | 
			
		||||
            os.path.join(grid_dir, f"{appid}.jpg"),
 | 
			
		||||
            os.path.join(grid_dir, f"{appid}p.jpg"),
 | 
			
		||||
            os.path.join(grid_dir, f"{appid}_hero.jpg"),
 | 
			
		||||
            os.path.join(grid_dir, f"{appid}_logo.png")
 | 
			
		||||
        ]
 | 
			
		||||
        for cover_file in cover_files:
 | 
			
		||||
            if os.path.exists(cover_file):
 | 
			
		||||
                try:
 | 
			
		||||
                    os.remove(cover_file)
 | 
			
		||||
                    logger.info(f"Deleted cover file: {cover_file}")
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(f"Failed to delete cover file {cover_file}: {e}")
 | 
			
		||||
 | 
			
		||||
        # Delete launch script
 | 
			
		||||
        if os.path.exists(script_path):
 | 
			
		||||
            try:
 | 
			
		||||
                os.remove(script_path)
 | 
			
		||||
                logger.info(f"Removed EGS script: {script_path}")
 | 
			
		||||
            except OSError as e:
 | 
			
		||||
                logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
 | 
			
		||||
 | 
			
		||||
        callback((True, f"Game '{game_name}' was removed from Steam. Please restart Steam for changes to take effect."))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Fallback to VDF modification
 | 
			
		||||
    logger.warning("CEF API failed for EGS game removal; falling back to VDF modification")
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    try:
 | 
			
		||||
        shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
        logger.info("Created backup of shortcuts.vdf at %s", backup_path)
 | 
			
		||||
        logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
        callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        with open(steam_shortcuts_path, 'rb') as f:
 | 
			
		||||
            shortcuts_data = vdf.binary_load(f)
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to load shortcuts.vdf: {e}")
 | 
			
		||||
        callback((False, f"Failed to load shortcuts.vdf: {e}"))
 | 
			
		||||
        return
 | 
			
		||||
        new_shortcuts = {}
 | 
			
		||||
        index = 0
 | 
			
		||||
        for _key, entry in shortcuts.items():
 | 
			
		||||
            if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
 | 
			
		||||
                logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
 | 
			
		||||
                continue
 | 
			
		||||
            new_shortcuts[str(index)] = entry
 | 
			
		||||
            index += 1
 | 
			
		||||
 | 
			
		||||
    shortcuts = shortcuts_data.get("shortcuts", {})
 | 
			
		||||
    modified = False
 | 
			
		||||
    new_shortcuts = {}
 | 
			
		||||
    index = 0
 | 
			
		||||
 | 
			
		||||
    for _key, entry in shortcuts.items():
 | 
			
		||||
        if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
 | 
			
		||||
            modified = True
 | 
			
		||||
            logger.info("Removing EGS game '%s' from Steam shortcuts", game_name)
 | 
			
		||||
            continue
 | 
			
		||||
        new_shortcuts[str(index)] = entry
 | 
			
		||||
        index += 1
 | 
			
		||||
 | 
			
		||||
    if not modified:
 | 
			
		||||
        logger.error("Game '%s' not found in Steam shortcuts", game_name)
 | 
			
		||||
        callback((False, f"Game '{game_name}' not found in Steam shortcuts"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
            vdf.binary_dump({"shortcuts": new_shortcuts}, f)
 | 
			
		||||
        logger.info("Updated shortcuts.vdf, removed '%s'", game_name)
 | 
			
		||||
        logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
        if os.path.exists(backup_path):
 | 
			
		||||
@@ -157,10 +198,26 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
 | 
			
		||||
        callback((False, f"Failed to update shortcuts.vdf: {e}"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    # Delete cover files
 | 
			
		||||
    cover_files = [
 | 
			
		||||
        os.path.join(grid_dir, f"{appid}.jpg"),
 | 
			
		||||
        os.path.join(grid_dir, f"{appid}p.jpg"),
 | 
			
		||||
        os.path.join(grid_dir, f"{appid}_hero.jpg"),
 | 
			
		||||
        os.path.join(grid_dir, f"{appid}_logo.png")
 | 
			
		||||
    ]
 | 
			
		||||
    for cover_file in cover_files:
 | 
			
		||||
        if os.path.exists(cover_file):
 | 
			
		||||
            try:
 | 
			
		||||
                os.remove(cover_file)
 | 
			
		||||
                logger.info(f"Deleted cover file: {cover_file}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to delete cover file {cover_file}: {e}")
 | 
			
		||||
 | 
			
		||||
    # Delete launch script
 | 
			
		||||
    if os.path.exists(script_path):
 | 
			
		||||
        try:
 | 
			
		||||
            os.remove(script_path)
 | 
			
		||||
            logger.info("Removed EGS script: %s", script_path)
 | 
			
		||||
            logger.info(f"Removed EGS script: {script_path}")
 | 
			
		||||
        except OSError as e:
 | 
			
		||||
            logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
 | 
			
		||||
 | 
			
		||||
@@ -168,11 +225,17 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
 | 
			
		||||
 | 
			
		||||
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
 | 
			
		||||
    """
 | 
			
		||||
    Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag.
 | 
			
		||||
    Asynchronously adds an EGS game to Steam via CEF API or shortcuts.vdf with PortProton tag.
 | 
			
		||||
    Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
 | 
			
		||||
    Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
 | 
			
		||||
    Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
 | 
			
		||||
    Calls the callback with (success, message).
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        app_name: The Legendary app_name (unique identifier for the game).
 | 
			
		||||
        game_title: The display name of the game.
 | 
			
		||||
        legendary_path: Path to the Legendary CLI executable.
 | 
			
		||||
        callback: Callback function to handle the result (success, message).
 | 
			
		||||
    """
 | 
			
		||||
    if not app_name or not app_name.strip() or not game_title or not game_title.strip():
 | 
			
		||||
        logger.error("Invalid app_name or game_title: empty or whitespace")
 | 
			
		||||
@@ -267,47 +330,47 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
    grid_dir = user_dir / "config" / "grid"
 | 
			
		||||
    os.makedirs(grid_dir, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
    # Backup shortcuts.vdf
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
            logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
            callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
 | 
			
		||||
            return
 | 
			
		||||
    # Try CEF API first
 | 
			
		||||
    logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API")
 | 
			
		||||
    api_response = call_steam_api(
 | 
			
		||||
        "createShortcut",
 | 
			
		||||
        game_title,
 | 
			
		||||
        script_path,
 | 
			
		||||
        str(Path(script_path).parent),
 | 
			
		||||
        icon_path,
 | 
			
		||||
        ""
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    # Generate unique appid
 | 
			
		||||
    unique_string = f"{script_path}{game_title}"
 | 
			
		||||
    baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
    appid = baseid | 0x80000000
 | 
			
		||||
    if appid > 0x7FFFFFFF:
 | 
			
		||||
        aidvdf = appid - 0x100000000
 | 
			
		||||
    appid = None
 | 
			
		||||
    was_api_used = False
 | 
			
		||||
 | 
			
		||||
    if api_response and isinstance(api_response, dict) and 'id' in api_response:
 | 
			
		||||
        appid = api_response['id']
 | 
			
		||||
        was_api_used = True
 | 
			
		||||
        logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}")
 | 
			
		||||
    else:
 | 
			
		||||
        aidvdf = appid
 | 
			
		||||
        logger.warning("CEF API failed for EGS game addition; falling back to VDF modification")
 | 
			
		||||
        # Backup shortcuts.vdf
 | 
			
		||||
        backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
        if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
            try:
 | 
			
		||||
                shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
                logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
                callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
    downloaded_count = 0
 | 
			
		||||
    total_covers = 4
 | 
			
		||||
    download_lock = threading.Lock()
 | 
			
		||||
        # Generate unique appid
 | 
			
		||||
        unique_string = f"{script_path}{game_title}"
 | 
			
		||||
        baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
        appid = baseid | 0x80000000
 | 
			
		||||
        if appid > 0x7FFFFFFF:
 | 
			
		||||
            aidvdf = appid - 0x100000000
 | 
			
		||||
        else:
 | 
			
		||||
            aidvdf = appid
 | 
			
		||||
 | 
			
		||||
    def on_cover_download(cover_file: str, cover_type: str):
 | 
			
		||||
        nonlocal downloaded_count
 | 
			
		||||
        try:
 | 
			
		||||
            if cover_file and os.path.exists(cover_file):
 | 
			
		||||
                logger.info(f"Downloaded cover {cover_type} to {cover_file}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
 | 
			
		||||
        with download_lock:
 | 
			
		||||
            downloaded_count += 1
 | 
			
		||||
            if downloaded_count == total_covers:
 | 
			
		||||
                finalize_shortcut()
 | 
			
		||||
 | 
			
		||||
    def finalize_shortcut():
 | 
			
		||||
        tags_dict = {'0': 'PortProton'}
 | 
			
		||||
        # Create shortcut entry
 | 
			
		||||
        shortcut = {
 | 
			
		||||
            "appid": aidvdf,
 | 
			
		||||
            "AppName": game_title,
 | 
			
		||||
@@ -322,7 +385,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
            "Devkit": 0,
 | 
			
		||||
            "DevkitGameID": "",
 | 
			
		||||
            "LastPlayTime": 0,
 | 
			
		||||
            "tags": tags_dict
 | 
			
		||||
            "tags": {'0': 'PortProton'}
 | 
			
		||||
        }
 | 
			
		||||
        logger.info(f"Shortcut entry for EGS game: {shortcut}")
 | 
			
		||||
 | 
			
		||||
@@ -353,6 +416,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
 | 
			
		||||
            with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
                vdf.binary_dump({"shortcuts": shortcuts}, f)
 | 
			
		||||
            logger.info(f"EGS game '{game_title}' added to Steam via VDF")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
            if os.path.exists(backup_path):
 | 
			
		||||
@@ -364,8 +428,42 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
            callback((False, f"Failed to update shortcuts.vdf: {e}"))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        logger.info(f"EGS game '{game_title}' added to Steam")
 | 
			
		||||
        callback((True, f"Game '{game_title}' added to Steam with covers"))
 | 
			
		||||
    if not appid:
 | 
			
		||||
        callback((False, "Failed to create shortcut via any method"))
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
    downloaded_count = 0
 | 
			
		||||
    total_covers = 4
 | 
			
		||||
    download_lock = threading.Lock()
 | 
			
		||||
 | 
			
		||||
    def on_cover_download(cover_file: str | None, cover_type: str, index: int):
 | 
			
		||||
        nonlocal downloaded_count
 | 
			
		||||
        try:
 | 
			
		||||
            if cover_file is None or not os.path.exists(cover_file):
 | 
			
		||||
                logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
 | 
			
		||||
                with download_lock:
 | 
			
		||||
                    downloaded_count += 1
 | 
			
		||||
                    if downloaded_count == total_covers:
 | 
			
		||||
                        callback((True, f"Game '{game_title}' added to Steam with covers"))
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
            logger.info(f"Downloaded cover {cover_type} to {cover_file}")
 | 
			
		||||
            if was_api_used:
 | 
			
		||||
                try:
 | 
			
		||||
                    with open(cover_file, 'rb') as f:
 | 
			
		||||
                        img_b64 = base64.b64encode(f.read()).decode('utf-8')
 | 
			
		||||
                    logger.info(f"Applying cover type '{cover_type}' via API for AppID {appid}")
 | 
			
		||||
                    ext = Path(cover_type).suffix.lstrip('.')
 | 
			
		||||
                    call_steam_api("setGrid", appid, index, ext, img_b64)
 | 
			
		||||
                except Exception as e:
 | 
			
		||||
                    logger.error(f"Error applying cover '{cover_type}' via API: {e}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
 | 
			
		||||
        with download_lock:
 | 
			
		||||
            downloaded_count += 1
 | 
			
		||||
            if downloaded_count == total_covers:
 | 
			
		||||
                callback((True, f"Game '{game_title}' added to Steam with covers"))
 | 
			
		||||
 | 
			
		||||
    def on_steam_apps(steam_data: tuple[list, dict]):
 | 
			
		||||
        nonlocal steam_appid
 | 
			
		||||
@@ -375,24 +473,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
 | 
			
		||||
 | 
			
		||||
        if not steam_appid:
 | 
			
		||||
            logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
 | 
			
		||||
            finalize_shortcut()
 | 
			
		||||
            callback((True, f"Game '{game_title}' added to Steam"))
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        cover_types = [
 | 
			
		||||
            (".jpg", "header.jpg"),
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg"),
 | 
			
		||||
            ("_hero.jpg", "library_hero.jpg"),
 | 
			
		||||
            ("_logo.png", "logo.png")
 | 
			
		||||
            (".jpg", "header.jpg", 0),
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg", 1),
 | 
			
		||||
            ("_hero.jpg", "library_hero.jpg", 2),
 | 
			
		||||
            ("_logo.png", "logo.png", 3)
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for suffix, cover_type in cover_types:
 | 
			
		||||
        for suffix, cover_type, index in cover_types:
 | 
			
		||||
            cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
 | 
			
		||||
            cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
 | 
			
		||||
            downloader.download_async(
 | 
			
		||||
                cover_url,
 | 
			
		||||
                cover_file,
 | 
			
		||||
                timeout=5,
 | 
			
		||||
                callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
 | 
			
		||||
                callback=lambda result, ctype=cover_type, idx=index: on_cover_download(result, ctype, idx)
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    get_steam_apps_and_index_async(on_steam_apps)
 | 
			
		||||
@@ -747,6 +845,11 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
 | 
			
		||||
    games: list[tuple] = []
 | 
			
		||||
    cache_dir.mkdir(parents=True, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
    user_json_path = cache_dir / "user.json"
 | 
			
		||||
    if not user_json_path.exists():
 | 
			
		||||
        callback(games)
 | 
			
		||||
        return
 | 
			
		||||
 | 
			
		||||
    def process_games(installed_games: list | None):
 | 
			
		||||
        if installed_games is None:
 | 
			
		||||
            logger.info("No installed Epic Games Store games found")
 | 
			
		||||
@@ -855,12 +958,12 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
 | 
			
		||||
                                        app_name,
 | 
			
		||||
                                        f"legendary:launch:{app_name}",
 | 
			
		||||
                                        "",
 | 
			
		||||
                                        last_launch,  # Время последнего запуска
 | 
			
		||||
                                        formatted_playtime,  # Форматированное время игры
 | 
			
		||||
                                        protondb_tier,  # ProtonDB tier
 | 
			
		||||
                                        last_launch,
 | 
			
		||||
                                        formatted_playtime,
 | 
			
		||||
                                        protondb_tier,
 | 
			
		||||
                                        status or "",
 | 
			
		||||
                                        last_launch_timestamp,  # Временная метка последнего запуска
 | 
			
		||||
                                        playtime_seconds,  # Время игры в секундах
 | 
			
		||||
                                        last_launch_timestamp,
 | 
			
		||||
                                        playtime_seconds,
 | 
			
		||||
                                        "epic"
 | 
			
		||||
                                    )
 | 
			
		||||
                                    pending_images -= 1
 | 
			
		||||
@@ -880,7 +983,7 @@ def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tu
 | 
			
		||||
                    get_protondb_tier_async(steam_appid, on_protondb_tier)
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.debug(f"No Steam app found for EGS game {title}")
 | 
			
		||||
                    on_protondb_tier("")  # Proceed with empty ProtonDB tier
 | 
			
		||||
                    on_protondb_tier("")
 | 
			
		||||
 | 
			
		||||
            get_steam_apps_and_index_async(on_steam_apps)
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices
 | 
			
		||||
from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl
 | 
			
		||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
 | 
			
		||||
from PySide6.QtCore import Signal, Property, Qt, QUrl
 | 
			
		||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
@@ -11,9 +11,11 @@ from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
from portprotonqt.custom_widgets import ClickableLabel
 | 
			
		||||
from portprotonqt.portproton_api import PortProtonAPI
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
from portprotonqt.animations import GameCardAnimations
 | 
			
		||||
import weakref
 | 
			
		||||
from typing import cast
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GameCard(QFrame):
 | 
			
		||||
    borderWidthChanged = Signal()
 | 
			
		||||
    gradientAngleChanged = Signal()
 | 
			
		||||
@@ -78,13 +80,8 @@ class GameCard(QFrame):
 | 
			
		||||
        self._focused = False
 | 
			
		||||
 | 
			
		||||
        # Анимации
 | 
			
		||||
        self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
 | 
			
		||||
        self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
 | 
			
		||||
        self.gradient_anim = None
 | 
			
		||||
        self.pulse_anim = None
 | 
			
		||||
 | 
			
		||||
        # Флаг для отслеживания подключения слота startPulseAnimation
 | 
			
		||||
        self._isPulseAnimationConnected = False
 | 
			
		||||
        self.animations = GameCardAnimations(self, self.theme)
 | 
			
		||||
        self.animations.setup_animations()
 | 
			
		||||
 | 
			
		||||
        # Тень
 | 
			
		||||
        shadow = QGraphicsDropShadowEffect(self)
 | 
			
		||||
@@ -191,7 +188,7 @@ class GameCard(QFrame):
 | 
			
		||||
        self.egsLabel.setVisible(self.egs_visible)
 | 
			
		||||
 | 
			
		||||
        # PortProton бейдж
 | 
			
		||||
        portproton_icon = self.theme_manager.get_icon("ppqt-tray")
 | 
			
		||||
        portproton_icon = self.theme_manager.get_icon("portproton")
 | 
			
		||||
        self.portprotonLabel = ClickableLabel(
 | 
			
		||||
            "PortProton",
 | 
			
		||||
            icon=portproton_icon,
 | 
			
		||||
@@ -455,133 +452,22 @@ class GameCard(QFrame):
 | 
			
		||||
 | 
			
		||||
    def paintEvent(self, event):
 | 
			
		||||
        super().paintEvent(event)
 | 
			
		||||
        painter = QPainter(self)
 | 
			
		||||
        painter.setRenderHint(QPainter.RenderHint.Antialiasing)
 | 
			
		||||
 | 
			
		||||
        pen = QPen()
 | 
			
		||||
        pen.setWidth(self._borderWidth)
 | 
			
		||||
        if self._hovered or self._focused:
 | 
			
		||||
            center = self.rect().center()
 | 
			
		||||
            gradient = QConicalGradient(center, self._gradientAngle)
 | 
			
		||||
            for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
 | 
			
		||||
                gradient.setColorAt(stop["position"], QColor(stop["color"]))
 | 
			
		||||
            pen.setBrush(QBrush(gradient))
 | 
			
		||||
        else:
 | 
			
		||||
            pen.setColor(QColor(0, 0, 0, 0))
 | 
			
		||||
 | 
			
		||||
        painter.setPen(pen)
 | 
			
		||||
        radius = 18
 | 
			
		||||
        bw = round(self._borderWidth / 2)
 | 
			
		||||
        rect = self.rect().adjusted(bw, bw, -bw, -bw)
 | 
			
		||||
        painter.drawRoundedRect(rect, radius, radius)
 | 
			
		||||
 | 
			
		||||
    def startPulseAnimation(self):
 | 
			
		||||
        if not (self._hovered or self._focused):
 | 
			
		||||
            return
 | 
			
		||||
        if self.pulse_anim:
 | 
			
		||||
            self.pulse_anim.stop()
 | 
			
		||||
        self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
 | 
			
		||||
        self.pulse_anim.setDuration(self.theme.GAME_CARD_ANIMATION["pulse_anim_duration"])
 | 
			
		||||
        self.pulse_anim.setLoopCount(0)
 | 
			
		||||
        self.pulse_anim.setKeyValueAt(0, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
 | 
			
		||||
        self.pulse_anim.setKeyValueAt(0.5, self.theme.GAME_CARD_ANIMATION["pulse_max_border_width"])
 | 
			
		||||
        self.pulse_anim.setKeyValueAt(1, self.theme.GAME_CARD_ANIMATION["pulse_min_border_width"])
 | 
			
		||||
        self.pulse_anim.start()
 | 
			
		||||
        self.animations.paint_border(QPainter(self))
 | 
			
		||||
 | 
			
		||||
    def enterEvent(self, event):
 | 
			
		||||
        self._hovered = True
 | 
			
		||||
        self.hoverChanged.emit(self.name, True)
 | 
			
		||||
        self.setFocus(Qt.FocusReason.MouseFocusReason)
 | 
			
		||||
 | 
			
		||||
        self.thickness_anim.stop()
 | 
			
		||||
        if self._isPulseAnimationConnected:
 | 
			
		||||
            self.thickness_anim.finished.disconnect(self.startPulseAnimation)
 | 
			
		||||
            self._isPulseAnimationConnected = False
 | 
			
		||||
        self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
 | 
			
		||||
        self.thickness_anim.setStartValue(self._borderWidth)
 | 
			
		||||
        self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_border_width"])
 | 
			
		||||
        self.thickness_anim.finished.connect(self.startPulseAnimation)
 | 
			
		||||
        self._isPulseAnimationConnected = True
 | 
			
		||||
        self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
        if self.gradient_anim:
 | 
			
		||||
            self.gradient_anim.stop()
 | 
			
		||||
        self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
 | 
			
		||||
        self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
        self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
        self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
        self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
        self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
        self.animations.handle_enter_event()
 | 
			
		||||
        super().enterEvent(event)
 | 
			
		||||
 | 
			
		||||
    def leaveEvent(self, event):
 | 
			
		||||
        self._hovered = False
 | 
			
		||||
        self.hoverChanged.emit(self.name, False)
 | 
			
		||||
        if not self._focused:
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
                self.thickness_anim.finished.disconnect(self.startPulseAnimation)
 | 
			
		||||
                self._isPulseAnimationConnected = False
 | 
			
		||||
            self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
 | 
			
		||||
            self.thickness_anim.setStartValue(self._borderWidth)
 | 
			
		||||
            self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
        self.animations.handle_leave_event()
 | 
			
		||||
        super().leaveEvent(event)
 | 
			
		||||
 | 
			
		||||
    def focusInEvent(self, event):
 | 
			
		||||
        if not self._hovered:
 | 
			
		||||
            self._focused = True
 | 
			
		||||
            self.focusChanged.emit(self.name, True)
 | 
			
		||||
 | 
			
		||||
            self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
                self.thickness_anim.finished.disconnect(self.startPulseAnimation)
 | 
			
		||||
                self._isPulseAnimationConnected = False
 | 
			
		||||
            self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve"]]))
 | 
			
		||||
            self.thickness_anim.setStartValue(self._borderWidth)
 | 
			
		||||
            self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_border_width"])
 | 
			
		||||
            self.thickness_anim.finished.connect(self.startPulseAnimation)
 | 
			
		||||
            self._isPulseAnimationConnected = True
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
            self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
 | 
			
		||||
            self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
 | 
			
		||||
            self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
 | 
			
		||||
            self.gradient_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"])
 | 
			
		||||
            self.gradient_anim.setLoopCount(-1)
 | 
			
		||||
            self.gradient_anim.start()
 | 
			
		||||
 | 
			
		||||
        self.animations.handle_focus_in_event()
 | 
			
		||||
        super().focusInEvent(event)
 | 
			
		||||
 | 
			
		||||
    def focusOutEvent(self, event):
 | 
			
		||||
        self._focused = False
 | 
			
		||||
        self.focusChanged.emit(self.name, False)
 | 
			
		||||
        if not self._hovered:
 | 
			
		||||
            if self.gradient_anim:
 | 
			
		||||
                self.gradient_anim.stop()
 | 
			
		||||
                self.gradient_anim = None
 | 
			
		||||
            if self.pulse_anim:
 | 
			
		||||
                self.pulse_anim.stop()
 | 
			
		||||
                self.pulse_anim = None
 | 
			
		||||
            if self.thickness_anim:
 | 
			
		||||
                self.thickness_anim.stop()
 | 
			
		||||
            if self._isPulseAnimationConnected:
 | 
			
		||||
                self.thickness_anim.finished.disconnect(self.startPulseAnimation)
 | 
			
		||||
                self._isPulseAnimationConnected = False
 | 
			
		||||
            self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["thickness_easing_curve_out"]]))
 | 
			
		||||
            self.thickness_anim.setStartValue(self._borderWidth)
 | 
			
		||||
            self.thickness_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_border_width"])
 | 
			
		||||
            self.thickness_anim.start()
 | 
			
		||||
        self.animations.handle_focus_out_event()
 | 
			
		||||
        super().focusOutEvent(event)
 | 
			
		||||
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
 
 | 
			
		||||
@@ -219,9 +219,11 @@ class ResultParser:
 | 
			
		||||
            ("comp_plus", "main_extra"),
 | 
			
		||||
            ("comp_100", "completionist")
 | 
			
		||||
        ]
 | 
			
		||||
        all_zero = all(game_data.get(json_field, 0) == 0 for json_field, _ in time_fields)
 | 
			
		||||
        for json_field, attr_name in time_fields:
 | 
			
		||||
            if json_field in game_data:
 | 
			
		||||
                time_hours = round(game_data[json_field] / 3600, 2)
 | 
			
		||||
                time_seconds = game_data[json_field]
 | 
			
		||||
                time_hours = None if all_zero else round(time_seconds / 3600, 2)
 | 
			
		||||
                setattr(game, attr_name, time_hours)
 | 
			
		||||
        game.similarity = self._calculate_similarity(game)
 | 
			
		||||
        return game
 | 
			
		||||
 
 | 
			
		||||
@@ -21,6 +21,13 @@ image_load_queue = Queue()
 | 
			
		||||
image_executor = ThreadPoolExecutor(max_workers=4)
 | 
			
		||||
queue_lock = threading.Lock()
 | 
			
		||||
 | 
			
		||||
def get_device_pixel_ratio() -> float:
 | 
			
		||||
    """
 | 
			
		||||
    Retrieves the device pixel ratio from QApplication, with a fallback of 1.0 if not available.
 | 
			
		||||
    """
 | 
			
		||||
    app = QApplication.instance()
 | 
			
		||||
    return app.devicePixelRatio() if isinstance(app, QApplication) else 1.0
 | 
			
		||||
 | 
			
		||||
def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""):
 | 
			
		||||
    """
 | 
			
		||||
    Асинхронно загружает обложку через очередь задач.
 | 
			
		||||
@@ -164,7 +171,6 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
        :param theme: Объект темы для стилизации (если None, используется default_styles)
 | 
			
		||||
        """
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
        # Удаление диалога после закрытия
 | 
			
		||||
        self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
 | 
			
		||||
        self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
 | 
			
		||||
        self.setFocus()
 | 
			
		||||
@@ -173,14 +179,12 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
        self.current_index = current_index
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
 | 
			
		||||
        # Убираем стандартные элементы управления окна
 | 
			
		||||
        self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
 | 
			
		||||
        self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
 | 
			
		||||
 | 
			
		||||
        self.init_ui()
 | 
			
		||||
        self.update_display()
 | 
			
		||||
 | 
			
		||||
        # Фильтруем события для закрытия диалога по клику
 | 
			
		||||
        self.imageLabel.installEventFilter(self)
 | 
			
		||||
        self.captionLabel.installEventFilter(self)
 | 
			
		||||
 | 
			
		||||
@@ -190,32 +194,28 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
        self.mainLayout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        self.mainLayout.setSpacing(0)
 | 
			
		||||
 | 
			
		||||
        # Контейнер для изображения и стрелок
 | 
			
		||||
        self.imageContainer = QWidget()
 | 
			
		||||
        self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
 | 
			
		||||
        self.imageContainerLayout = QHBoxLayout(self.imageContainer)
 | 
			
		||||
        self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
 | 
			
		||||
        self.imageContainerLayout.setSpacing(0)
 | 
			
		||||
 | 
			
		||||
        # Левая стрелка
 | 
			
		||||
        self.prevButton = QToolButton()
 | 
			
		||||
        self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
 | 
			
		||||
        self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE)
 | 
			
		||||
        self.prevButton.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
 | 
			
		||||
        self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.prevButton.setFixedSize(40, 40)
 | 
			
		||||
        self.prevButton.clicked.connect(self.show_prev)
 | 
			
		||||
        self.imageContainerLayout.addWidget(self.prevButton)
 | 
			
		||||
 | 
			
		||||
        # Метка для изображения
 | 
			
		||||
        self.imageLabel = QLabel()
 | 
			
		||||
        self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
 | 
			
		||||
        self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
 | 
			
		||||
 | 
			
		||||
        # Правая стрелка
 | 
			
		||||
        self.nextButton = QToolButton()
 | 
			
		||||
        self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
 | 
			
		||||
        self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE)
 | 
			
		||||
        self.nextButton.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
 | 
			
		||||
        self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.nextButton.setFixedSize(40, 40)
 | 
			
		||||
        self.nextButton.clicked.connect(self.show_next)
 | 
			
		||||
@@ -223,16 +223,14 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
 | 
			
		||||
        self.mainLayout.addWidget(self.imageContainer)
 | 
			
		||||
 | 
			
		||||
        # Небольшой отступ между изображением и подписью
 | 
			
		||||
        spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
 | 
			
		||||
        self.mainLayout.addItem(spacer)
 | 
			
		||||
 | 
			
		||||
        # Подпись
 | 
			
		||||
        self.captionLabel = QLabel()
 | 
			
		||||
        self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
 | 
			
		||||
        self.captionLabel.setFixedHeight(40)
 | 
			
		||||
        self.captionLabel.setWordWrap(True)
 | 
			
		||||
        self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE)
 | 
			
		||||
        self.captionLabel.setStyleSheet(getattr(self.theme, "CAPTION_LABEL_STYLE", ""))
 | 
			
		||||
        self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.mainLayout.addWidget(self.captionLabel)
 | 
			
		||||
 | 
			
		||||
@@ -241,28 +239,37 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
        if not self.images:
 | 
			
		||||
            return
 | 
			
		||||
 | 
			
		||||
        # Очищаем старое содержимое
 | 
			
		||||
        self.imageLabel.clear()
 | 
			
		||||
        self.captionLabel.clear()
 | 
			
		||||
        QApplication.processEvents()
 | 
			
		||||
 | 
			
		||||
        pixmap, caption = self.images[self.current_index]
 | 
			
		||||
        # Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера
 | 
			
		||||
        # Учитываем devicePixelRatio для масштабирования высокого качества
 | 
			
		||||
        device_pixel_ratio = get_device_pixel_ratio()
 | 
			
		||||
        target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
 | 
			
		||||
        target_height = int(self.FIXED_HEIGHT * device_pixel_ratio)
 | 
			
		||||
 | 
			
		||||
        # Масштабируем изображение из оригинального pixmap
 | 
			
		||||
        scaled_pixmap = pixmap.scaled(
 | 
			
		||||
            self.FIXED_WIDTH - 80,  # учитываем ширину стрелок
 | 
			
		||||
            self.FIXED_HEIGHT,
 | 
			
		||||
            target_width,
 | 
			
		||||
            target_height,
 | 
			
		||||
            Qt.AspectRatioMode.KeepAspectRatio,
 | 
			
		||||
            Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
        )
 | 
			
		||||
        scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
 | 
			
		||||
        self.imageLabel.setPixmap(scaled_pixmap)
 | 
			
		||||
        self.captionLabel.setText(caption)
 | 
			
		||||
        self.setWindowTitle(caption)
 | 
			
		||||
 | 
			
		||||
        # Принудительная перерисовка виджетов
 | 
			
		||||
        self.imageLabel.repaint()
 | 
			
		||||
        self.captionLabel.repaint()
 | 
			
		||||
        self.repaint()
 | 
			
		||||
 | 
			
		||||
    def resizeEvent(self, event):
 | 
			
		||||
        """Обновляет изображение при изменении размера окна."""
 | 
			
		||||
        super().resizeEvent(event)
 | 
			
		||||
        self.update_display()  # Перерисовываем изображение с учетом нового размера
 | 
			
		||||
 | 
			
		||||
    def show_prev(self):
 | 
			
		||||
        """Показывает предыдущее изображение."""
 | 
			
		||||
        if self.images:
 | 
			
		||||
@@ -292,7 +299,6 @@ class FullscreenDialog(QDialog):
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
        """Закрывает диалог при клике на пустую область."""
 | 
			
		||||
        pos = event.pos()
 | 
			
		||||
        # Проверяем, находится ли клик вне imageContainer и captionLabel
 | 
			
		||||
        if not (self.imageContainer.geometry().contains(pos) or
 | 
			
		||||
                self.captionLabel.geometry().contains(pos)):
 | 
			
		||||
            self.close()
 | 
			
		||||
@@ -305,15 +311,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
 | 
			
		||||
        """
 | 
			
		||||
        :param pixmap: QPixmap для отображения в карусели
 | 
			
		||||
        :param pixmap: QPixmap для отображения в карусели (оригинальное, высокое разрешение)
 | 
			
		||||
        :param caption: Подпись к изображению
 | 
			
		||||
        :param images_list: Список всех изображений (кортежей (QPixmap, caption)),
 | 
			
		||||
                            чтобы в диалоге можно было перелистывать.
 | 
			
		||||
                            Если не передан, будет использован только текущее изображение.
 | 
			
		||||
        :param index: Индекс текущего изображения в images_list.
 | 
			
		||||
        :param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками.
 | 
			
		||||
        :param images_list: Список всех изображений (кортежей (QPixmap, caption))
 | 
			
		||||
        :param index: Индекс текущего изображения в images_list
 | 
			
		||||
        :param carousel: Ссылка на родительскую карусель (ImageCarousel)
 | 
			
		||||
        """
 | 
			
		||||
        super().__init__(pixmap)
 | 
			
		||||
        super().__init__()
 | 
			
		||||
        self.original_pixmap = pixmap  # Store original high-resolution pixmap
 | 
			
		||||
        self.caption = caption
 | 
			
		||||
        self.images_list = images_list if images_list is not None else [(pixmap, caption)]
 | 
			
		||||
        self.index = index
 | 
			
		||||
@@ -323,6 +328,20 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
 | 
			
		||||
        self._click_start_position = None
 | 
			
		||||
        self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
 | 
			
		||||
        self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
 | 
			
		||||
        self.update_pixmap()  # Set initial pixmap
 | 
			
		||||
 | 
			
		||||
    def update_pixmap(self, height=300):
 | 
			
		||||
        """Update the displayed pixmap by scaling from the original high-resolution pixmap."""
 | 
			
		||||
        if self.original_pixmap.isNull():
 | 
			
		||||
            return
 | 
			
		||||
        # Scale pixmap to desired height, considering device pixel ratio
 | 
			
		||||
        device_pixel_ratio = get_device_pixel_ratio()
 | 
			
		||||
        scaled_pixmap = self.original_pixmap.scaledToHeight(
 | 
			
		||||
            int(height * device_pixel_ratio),
 | 
			
		||||
            Qt.TransformationMode.SmoothTransformation
 | 
			
		||||
        )
 | 
			
		||||
        scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
 | 
			
		||||
        self.setPixmap(scaled_pixmap)
 | 
			
		||||
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
        if event.button() == Qt.MouseButton.LeftButton:
 | 
			
		||||
@@ -339,17 +358,14 @@ class ClickablePixmapItem(QGraphicsPixmapItem):
 | 
			
		||||
        event.accept()
 | 
			
		||||
 | 
			
		||||
    def show_fullscreen(self):
 | 
			
		||||
        # Скрываем стрелки карусели перед открытием FullscreenDialog
 | 
			
		||||
        if self.carousel:
 | 
			
		||||
            self.carousel.prevArrow.hide()
 | 
			
		||||
            self.carousel.nextArrow.hide()
 | 
			
		||||
        dialog = FullscreenDialog(self.images_list, current_index=self.index)
 | 
			
		||||
        dialog.exec()
 | 
			
		||||
        # После закрытия диалога обновляем видимость стрелок
 | 
			
		||||
        if self.carousel:
 | 
			
		||||
            self.carousel.update_arrows_visibility()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ImageCarousel(QGraphicsView):
 | 
			
		||||
    """
 | 
			
		||||
    Карусель изображений с адаптивностью, возможностью увеличения по клику
 | 
			
		||||
@@ -357,19 +373,16 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
    """
 | 
			
		||||
    def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
 | 
			
		||||
        super().__init__(parent)
 | 
			
		||||
 | 
			
		||||
        # Аннотируем тип scene как QGraphicsScene
 | 
			
		||||
        self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
 | 
			
		||||
        self.setScene(self.carousel_scene)
 | 
			
		||||
 | 
			
		||||
        self.images = images  # Список кортежей: (QPixmap, caption)
 | 
			
		||||
        self.image_items = []
 | 
			
		||||
        self._animation = None
 | 
			
		||||
        self.theme = theme if theme else default_styles
 | 
			
		||||
        self.max_height = 300  # Default height for images
 | 
			
		||||
        self.init_ui()
 | 
			
		||||
        self.create_arrows()
 | 
			
		||||
 | 
			
		||||
        # Переменные для поддержки перетаскивания
 | 
			
		||||
        self._drag_active = False
 | 
			
		||||
        self._drag_start_position = None
 | 
			
		||||
        self._scroll_start_value = None
 | 
			
		||||
@@ -380,30 +393,38 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
 | 
			
		||||
        self.setFrameShape(QFrame.Shape.NoFrame)
 | 
			
		||||
 | 
			
		||||
        x_offset = 10  # Отступ между изображениями
 | 
			
		||||
        max_height = 300  # Фиксированная высота изображений
 | 
			
		||||
        self.update_scene()
 | 
			
		||||
 | 
			
		||||
    def update_scene(self):
 | 
			
		||||
        """Update the scene with scaled images based on current size and scale."""
 | 
			
		||||
        self.carousel_scene.clear()
 | 
			
		||||
        self.image_items.clear()
 | 
			
		||||
 | 
			
		||||
        x_offset = 10
 | 
			
		||||
        x = 0
 | 
			
		||||
        device_pixel_ratio = get_device_pixel_ratio()
 | 
			
		||||
 | 
			
		||||
        for i, (pixmap, caption) in enumerate(self.images):
 | 
			
		||||
            item = ClickablePixmapItem(
 | 
			
		||||
                pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation),
 | 
			
		||||
                pixmap,  # Pass original pixmap
 | 
			
		||||
                caption,
 | 
			
		||||
                images_list=self.images,
 | 
			
		||||
                index=i,
 | 
			
		||||
                carousel=self  # Передаем ссылку на карусель
 | 
			
		||||
                carousel=self
 | 
			
		||||
            )
 | 
			
		||||
            item.update_pixmap(self.max_height)  # Scale to current height
 | 
			
		||||
            item.setPos(x, 0)
 | 
			
		||||
            self.carousel_scene.addItem(item)
 | 
			
		||||
            self.image_items.append(item)
 | 
			
		||||
            x += item.pixmap().width() + x_offset
 | 
			
		||||
            x += item.pixmap().width() / device_pixel_ratio + x_offset
 | 
			
		||||
 | 
			
		||||
        self.setSceneRect(0, 0, x, max_height)
 | 
			
		||||
        self.setSceneRect(0, 0, x, self.max_height)
 | 
			
		||||
 | 
			
		||||
    def create_arrows(self):
 | 
			
		||||
        """Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
 | 
			
		||||
        self.prevArrow = QToolButton(self)
 | 
			
		||||
        self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
 | 
			
		||||
        self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore
 | 
			
		||||
        self.prevArrow.setStyleSheet(getattr(self.theme, "PREV_BUTTON_STYLE", ""))
 | 
			
		||||
        self.prevArrow.setFixedSize(40, 40)
 | 
			
		||||
        self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.prevArrow.setAutoRepeat(True)
 | 
			
		||||
@@ -414,7 +435,7 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
 | 
			
		||||
        self.nextArrow = QToolButton(self)
 | 
			
		||||
        self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
 | 
			
		||||
        self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore
 | 
			
		||||
        self.nextArrow.setStyleSheet(getattr(self.theme, "NEXT_BUTTON_STYLE", ""))
 | 
			
		||||
        self.nextArrow.setFixedSize(40, 40)
 | 
			
		||||
        self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
 | 
			
		||||
        self.nextArrow.setAutoRepeat(True)
 | 
			
		||||
@@ -423,14 +444,9 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        self.nextArrow.clicked.connect(self.scroll_right)
 | 
			
		||||
        self.nextArrow.raise_()
 | 
			
		||||
 | 
			
		||||
        # Проверяем видимость стрелок при создании
 | 
			
		||||
        self.update_arrows_visibility()
 | 
			
		||||
 | 
			
		||||
    def update_arrows_visibility(self):
 | 
			
		||||
        """
 | 
			
		||||
        Показывает стрелки, если контент шире видимой области.
 | 
			
		||||
        Иначе скрывает их.
 | 
			
		||||
        """
 | 
			
		||||
        if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
 | 
			
		||||
            if self.horizontalScrollBar().maximum() == 0:
 | 
			
		||||
                self.prevArrow.hide()
 | 
			
		||||
@@ -444,7 +460,8 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        margin = 10
 | 
			
		||||
        self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
 | 
			
		||||
        self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
 | 
			
		||||
                              (self.height() - self.nextArrow.height()) // 2)
 | 
			
		||||
                            (self.height() - self.nextArrow.height()) // 2)
 | 
			
		||||
        self.update_scene()  # Re-scale images on resize
 | 
			
		||||
        self.update_arrows_visibility()
 | 
			
		||||
 | 
			
		||||
    def animate_scroll(self, end_value):
 | 
			
		||||
@@ -469,19 +486,15 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
        self.animate_scroll(new_value)
 | 
			
		||||
 | 
			
		||||
    def update_images(self, new_images):
 | 
			
		||||
        self.carousel_scene.clear()
 | 
			
		||||
        self.images = new_images
 | 
			
		||||
        self.image_items.clear()
 | 
			
		||||
        self.init_ui()
 | 
			
		||||
        self.update_scene()
 | 
			
		||||
        self.update_arrows_visibility()
 | 
			
		||||
 | 
			
		||||
    # Обработка событий мыши для перетаскивания
 | 
			
		||||
    def mousePressEvent(self, event):
 | 
			
		||||
        if event.button() == Qt.MouseButton.LeftButton:
 | 
			
		||||
            self._drag_active = True
 | 
			
		||||
            self._drag_start_position = event.pos()
 | 
			
		||||
            self._scroll_start_value = self.horizontalScrollBar().value()
 | 
			
		||||
            # Скрываем стрелки при начале перетаскивания
 | 
			
		||||
            if hasattr(self, "prevArrow"):
 | 
			
		||||
                self.prevArrow.hide()
 | 
			
		||||
            if hasattr(self, "nextArrow"):
 | 
			
		||||
@@ -497,6 +510,5 @@ class ImageCarousel(QGraphicsView):
 | 
			
		||||
 | 
			
		||||
    def mouseReleaseEvent(self, event):
 | 
			
		||||
        self._drag_active = False
 | 
			
		||||
        # Показываем стрелки после завершения перетаскивания (с проверкой видимости)
 | 
			
		||||
        self.update_arrows_visibility()
 | 
			
		||||
        super().mouseReleaseEvent(event)
 | 
			
		||||
 
 | 
			
		||||
@@ -42,17 +42,17 @@ class MainWindowProtocol(Protocol):
 | 
			
		||||
# https://github.com/torvalds/linux/blob/master/drivers/hid/hid-playstation.c
 | 
			
		||||
# https://github.com/torvalds/linux/blob/master/drivers/input/joystick/xpad.c
 | 
			
		||||
BUTTONS = {
 | 
			
		||||
    'confirm':   {ecodes.BTN_SOUTH},               # A (Xbox) / Cross (PS)
 | 
			
		||||
    'back':      {ecodes.BTN_EAST},                # B (Xbox) / Circle (PS)
 | 
			
		||||
    'add_game':  {ecodes.BTN_NORTH},               # X (Xbox) / Triangle (PS)
 | 
			
		||||
    'prev_dir':  {ecodes.BTN_WEST},                # Y (Xbox) / Square (PS)
 | 
			
		||||
    'prev_tab':  {ecodes.BTN_TL},                  # LB (Xbox) / L1 (PS)
 | 
			
		||||
    'next_tab':  {ecodes.BTN_TR},                  # RB (Xbox) / R1 (PS)
 | 
			
		||||
    'context_menu': {ecodes.BTN_START},            # Start (Xbox) / Options (PS)
 | 
			
		||||
    'menu':      {ecodes.BTN_SELECT},              # Select (Xbox) / Share (PS)
 | 
			
		||||
    'guide':     {ecodes.BTN_MODE},                # Xbox Button / PS Button
 | 
			
		||||
    'increase_size': {ecodes.ABS_RZ},              # RT (Xbox) / R2 (PS)
 | 
			
		||||
    'decrease_size': {ecodes.ABS_Z},               # LT (Xbox) / L2 (PS)
 | 
			
		||||
    'confirm':       {ecodes.BTN_SOUTH},           # A (Xbox) / Cross (PS)
 | 
			
		||||
    'back':          {ecodes.BTN_EAST},            # B (Xbox) / Circle (PS)
 | 
			
		||||
    'add_game':      {ecodes.BTN_NORTH},           # X (Xbox) / Triangle (PS)
 | 
			
		||||
    'prev_dir':      {ecodes.BTN_WEST},            # Y (Xbox) / Square (PS)
 | 
			
		||||
    'prev_tab':      {ecodes.BTN_TL},              # LB (Xbox) / L1 (PS)
 | 
			
		||||
    'next_tab':      {ecodes.BTN_TR},              # RB (Xbox) / R1 (PS)
 | 
			
		||||
    'context_menu':  {ecodes.BTN_START},           # Start (Xbox) / Options (PS)
 | 
			
		||||
    'menu':          {ecodes.BTN_SELECT},          # Select (Xbox) / Share (PS)
 | 
			
		||||
    'guide':         {ecodes.BTN_MODE},            # Xbox Button / PS Button
 | 
			
		||||
    'increase_size': {ecodes.BTN_TR2},             # RT (Xbox) / R2 (PS)
 | 
			
		||||
    'decrease_size': {ecodes.BTN_TL2},             # LT (Xbox) / L2 (PS)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class InputManager(QObject):
 | 
			
		||||
@@ -754,6 +754,14 @@ class InputManager(QObject):
 | 
			
		||||
 | 
			
		||||
        # Handle key press events
 | 
			
		||||
        if event.type() == QEvent.Type.KeyPress:
 | 
			
		||||
            # Handle QLineEdit cursor movement with Left/Right arrows
 | 
			
		||||
            if isinstance(focused, QLineEdit) and key in (Qt.Key.Key_Left, Qt.Key.Key_Right):
 | 
			
		||||
                if key == Qt.Key.Key_Left:
 | 
			
		||||
                    focused.cursorBackward(False, 1)  # Move cursor left by one character
 | 
			
		||||
                elif key == Qt.Key.Key_Right:
 | 
			
		||||
                    focused.cursorForward(False, 1)  # Move cursor right by one character
 | 
			
		||||
                return True  # Consume the event to prevent further processing
 | 
			
		||||
 | 
			
		||||
            # Open system overlay with Insert
 | 
			
		||||
            if key == Qt.Key.Key_Insert:
 | 
			
		||||
                if not popup and not isinstance(active_win, QDialog):
 | 
			
		||||
@@ -765,6 +773,11 @@ class InputManager(QObject):
 | 
			
		||||
                app.quit()
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
            # Handle Backspace for FileExplorer navigation (move to parent directory)
 | 
			
		||||
            if key == Qt.Key.Key_Backspace and self.file_explorer:
 | 
			
		||||
                self.file_explorer.previous_dir()
 | 
			
		||||
                return True
 | 
			
		||||
 | 
			
		||||
            # Close AddGameDialog with Escape
 | 
			
		||||
            if key == Qt.Key.Key_Escape and isinstance(popup, QDialog):
 | 
			
		||||
                popup.reject()
 | 
			
		||||
@@ -783,8 +796,8 @@ class InputManager(QObject):
 | 
			
		||||
                        active_win.show_next()
 | 
			
		||||
                    return True  # Consume event to prevent tab switching
 | 
			
		||||
 | 
			
		||||
            # Handle tab switching with Left/Right arrow keys when not in GameCard focus
 | 
			
		||||
            if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and (not isinstance(focused, GameCard) or focused is None):
 | 
			
		||||
            # Handle tab switching with Left/Right arrow keys when not in GameCard focus or QLineEdit
 | 
			
		||||
            if key in (Qt.Key.Key_Left, Qt.Key.Key_Right) and (not isinstance(focused, GameCard | QLineEdit) or focused is None):
 | 
			
		||||
                idx = self._parent.stackedWidget.currentIndex()
 | 
			
		||||
                total = len(self._parent.tabButtons)
 | 
			
		||||
                if key == Qt.Key.Key_Left:
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-07-14 13:16+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-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"
 | 
			
		||||
@@ -26,6 +26,9 @@ msgstr ""
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -65,9 +68,6 @@ msgstr ""
 | 
			
		||||
msgid "Edit Shortcut"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Stopped '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
@@ -170,18 +170,6 @@ msgstr ""
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Invalid executable command: {exec_line}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found: {path}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse executable: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Deletion"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -260,6 +248,9 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-07-14 13:16+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-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"
 | 
			
		||||
@@ -26,6 +26,9 @@ msgstr ""
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -65,9 +68,6 @@ msgstr ""
 | 
			
		||||
msgid "Edit Shortcut"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Stopped '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
@@ -170,18 +170,6 @@ msgstr ""
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Invalid executable command: {exec_line}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found: {path}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse executable: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Deletion"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -260,6 +248,9 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-07-14 13:16+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-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"
 | 
			
		||||
@@ -24,6 +24,9 @@ msgstr ""
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -63,9 +66,6 @@ msgstr ""
 | 
			
		||||
msgid "Edit Shortcut"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Stopped '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
@@ -168,18 +168,6 @@ msgstr ""
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Invalid executable command: {exec_line}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found: {path}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse executable: {error}"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Deletion"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
@@ -258,6 +246,9 @@ msgstr ""
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr ""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -9,8 +9,8 @@ msgid ""
 | 
			
		||||
msgstr ""
 | 
			
		||||
"Project-Id-Version: PROJECT VERSION\n"
 | 
			
		||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
 | 
			
		||||
"POT-Creation-Date: 2025-07-14 13:16+0500\n"
 | 
			
		||||
"PO-Revision-Date: 2025-07-14 13:16+0500\n"
 | 
			
		||||
"POT-Creation-Date: 2025-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"
 | 
			
		||||
@@ -27,6 +27,9 @@ msgstr "Ошибка"
 | 
			
		||||
msgid "PortProton is not found"
 | 
			
		||||
msgstr "PortProton не найден"
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr "Удалить из PortProton"
 | 
			
		||||
 | 
			
		||||
msgid "Stop Game"
 | 
			
		||||
msgstr "Остановить игру"
 | 
			
		||||
 | 
			
		||||
@@ -66,9 +69,6 @@ msgstr "Добавить в меню"
 | 
			
		||||
msgid "Edit Shortcut"
 | 
			
		||||
msgstr "Редактировать"
 | 
			
		||||
 | 
			
		||||
msgid "Delete from PortProton"
 | 
			
		||||
msgstr "Удалить из PortProton"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Stopped '{game_name}'"
 | 
			
		||||
msgstr "Остановлен(а) '{game_name}'"
 | 
			
		||||
@@ -173,18 +173,6 @@ msgstr "Не удалось прочитать файл .desktop: {error}"
 | 
			
		||||
msgid "No .desktop file found for '{game_name}'"
 | 
			
		||||
msgstr "Файл .desktop для '{game_name}' не найден"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Invalid executable command: {exec_line}"
 | 
			
		||||
msgstr "Недопустимая исполняемая команда: {exec_line}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Executable not found: {path}"
 | 
			
		||||
msgstr "Исполняемый файл не найден: {path}"
 | 
			
		||||
 | 
			
		||||
#, python-brace-format
 | 
			
		||||
msgid "Failed to parse executable: {error}"
 | 
			
		||||
msgstr "Не удалось разобрать исполняемый файл: {error}"
 | 
			
		||||
 | 
			
		||||
msgid "Confirm Deletion"
 | 
			
		||||
msgstr "Подтвердите удаление"
 | 
			
		||||
 | 
			
		||||
@@ -267,6 +255,9 @@ msgstr "Удалить"
 | 
			
		||||
msgid "Select All"
 | 
			
		||||
msgstr "Выбрать всё"
 | 
			
		||||
 | 
			
		||||
msgid "File Explorer"
 | 
			
		||||
msgstr "Проводник"
 | 
			
		||||
 | 
			
		||||
msgid "Select"
 | 
			
		||||
msgstr "Выбрать"
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -10,6 +10,7 @@ import psutil
 | 
			
		||||
 | 
			
		||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer
 | 
			
		||||
from portprotonqt.game_card import GameCard
 | 
			
		||||
from portprotonqt.animations import DetailPageAnimations
 | 
			
		||||
from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel
 | 
			
		||||
from portprotonqt.portproton_api import PortProtonAPI
 | 
			
		||||
from portprotonqt.input_manager import InputManager
 | 
			
		||||
@@ -35,20 +36,18 @@ from portprotonqt.howlongtobeat_api import HowLongToBeat
 | 
			
		||||
from portprotonqt.downloader import Downloader
 | 
			
		||||
 | 
			
		||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
 | 
			
		||||
                               QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QGraphicsEffect, QGraphicsOpacityEffect, QApplication, QPushButton, QProgressBar, QCheckBox)
 | 
			
		||||
                               QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
 | 
			
		||||
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
 | 
			
		||||
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
 | 
			
		||||
from PySide6.QtCore import Qt, QAbstractAnimation, QPropertyAnimation, QByteArray, QUrl, Signal, QTimer, Slot
 | 
			
		||||
from typing import cast
 | 
			
		||||
from collections.abc import Callable
 | 
			
		||||
from concurrent.futures import ThreadPoolExecutor
 | 
			
		||||
from datetime import datetime
 | 
			
		||||
from PySide6.QtWidgets import QSizePolicy
 | 
			
		||||
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
 | 
			
		||||
class MainWindow(QMainWindow):
 | 
			
		||||
    """Main window of PortProtonQt."""
 | 
			
		||||
    settings_saved = Signal()
 | 
			
		||||
    games_loaded = Signal(list)
 | 
			
		||||
    update_progress = Signal(int)  # Signal to update progress bar
 | 
			
		||||
    update_status_message = Signal(str, int)  # Signal to update status message
 | 
			
		||||
@@ -210,6 +209,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.restore_state()
 | 
			
		||||
 | 
			
		||||
        self.input_manager = InputManager(self)
 | 
			
		||||
        self.detail_animations = DetailPageAnimations(self, self.theme)
 | 
			
		||||
        QTimer.singleShot(0, self.loadGames)
 | 
			
		||||
 | 
			
		||||
        if read_fullscreen_config():
 | 
			
		||||
@@ -698,6 +698,15 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
    def resizeEvent(self, event):
 | 
			
		||||
        super().resizeEvent(event)
 | 
			
		||||
        if hasattr(self, '_animations') and self._animations:
 | 
			
		||||
            for widget, animation in list(self._animations.items()):
 | 
			
		||||
                try:
 | 
			
		||||
                    if animation.state() == QAbstractAnimation.State.Running:
 | 
			
		||||
                        animation.stop()
 | 
			
		||||
                        widget.setWindowOpacity(1.0)
 | 
			
		||||
                        del self._animations[widget]
 | 
			
		||||
                except RuntimeError:
 | 
			
		||||
                    del self._animations[widget]
 | 
			
		||||
        if not hasattr(self, '_last_width'):
 | 
			
		||||
            self._last_width = self.width()
 | 
			
		||||
        if abs(self.width() - self._last_width) > 10:
 | 
			
		||||
@@ -1321,7 +1330,6 @@ class MainWindow(QMainWindow):
 | 
			
		||||
 | 
			
		||||
        self.settingsDebounceTimer.start()
 | 
			
		||||
 | 
			
		||||
        self.settings_saved.emit()
 | 
			
		||||
 | 
			
		||||
        # Управление полноэкранным режимом
 | 
			
		||||
        gamepad_connected = self.input_manager.find_gamepad() is not None
 | 
			
		||||
@@ -1521,23 +1529,44 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self._detail_page_active = True
 | 
			
		||||
        self._current_detail_page = detailPage
 | 
			
		||||
 | 
			
		||||
        if cover_path:
 | 
			
		||||
            def on_pixmap_ready(pixmap):
 | 
			
		||||
                rounded = round_corners(pixmap, 10)
 | 
			
		||||
                imageLabel.setPixmap(rounded)
 | 
			
		||||
        # Функция загрузки изображения и обновления стилей
 | 
			
		||||
        def load_image_and_restore_effect():
 | 
			
		||||
            if not detailPage or detailPage.isHidden():
 | 
			
		||||
                logger.warning("Detail page is None or hidden, skipping image load")
 | 
			
		||||
                return
 | 
			
		||||
 | 
			
		||||
                def on_palette_ready(palette):
 | 
			
		||||
                    dark_palette = [self.darkenColor(color, factor=200) for color in palette]
 | 
			
		||||
                    stops = ",\n".join(
 | 
			
		||||
                        [f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
 | 
			
		||||
                    )
 | 
			
		||||
                    detailPage.setStyleSheet(self.theme.detail_page_style(stops))
 | 
			
		||||
            detailPage.setWindowOpacity(1.0)
 | 
			
		||||
 | 
			
		||||
                self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
 | 
			
		||||
            if cover_path:
 | 
			
		||||
                def on_pixmap_ready(pixmap):
 | 
			
		||||
                    if not detailPage or detailPage.isHidden():
 | 
			
		||||
                        logger.warning("Detail page is None or hidden, skipping pixmap update")
 | 
			
		||||
                        return
 | 
			
		||||
                    rounded = round_corners(pixmap, 10)
 | 
			
		||||
                    imageLabel.setPixmap(rounded)
 | 
			
		||||
                    logger.debug("Pixmap set for imageLabel")
 | 
			
		||||
 | 
			
		||||
            load_pixmap_async(cover_path, 300, 400, on_pixmap_ready)
 | 
			
		||||
        else:
 | 
			
		||||
            detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
 | 
			
		||||
                    def on_palette_ready(palette):
 | 
			
		||||
                        if not detailPage or detailPage.isHidden():
 | 
			
		||||
                            logger.warning("Detail page is None or hidden, skipping palette update")
 | 
			
		||||
                            return
 | 
			
		||||
                        dark_palette = [self.darkenColor(color, factor=200) for color in palette]
 | 
			
		||||
                        stops = ",\n".join(
 | 
			
		||||
                            [f"stop:{i/(len(dark_palette)-1):.2f} {dark_palette[i].name()}" for i in range(len(dark_palette))]
 | 
			
		||||
                        )
 | 
			
		||||
                        detailPage.setStyleSheet(self.theme.detail_page_style(stops))
 | 
			
		||||
                        detailPage.update()
 | 
			
		||||
                        logger.debug("Stylesheet updated with palette")
 | 
			
		||||
 | 
			
		||||
                    self.getColorPalette_async(cover_path, num_colors=5, callback=on_palette_ready)
 | 
			
		||||
                load_pixmap_async(cover_path, 300, 400, on_pixmap_ready)
 | 
			
		||||
            else:
 | 
			
		||||
                detailPage.setStyleSheet(self.theme.DETAIL_PAGE_NO_COVER_STYLE)
 | 
			
		||||
                detailPage.update()
 | 
			
		||||
 | 
			
		||||
        def cleanup_animation():
 | 
			
		||||
            if detailPage in self._animations:
 | 
			
		||||
                del self._animations[detailPage]
 | 
			
		||||
 | 
			
		||||
        mainLayout = QVBoxLayout(detailPage)
 | 
			
		||||
        mainLayout.setContentsMargins(30, 30, 30, 30)
 | 
			
		||||
@@ -1645,7 +1674,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        egsLabel.setVisible(egs_visible)
 | 
			
		||||
 | 
			
		||||
        # PortProton badge
 | 
			
		||||
        portproton_icon = self.theme_manager.get_icon("ppqt-tray")
 | 
			
		||||
        portproton_icon = self.theme_manager.get_icon("portproton")
 | 
			
		||||
        portprotonLabel = ClickableLabel(
 | 
			
		||||
            "PortProton",
 | 
			
		||||
            icon=portproton_icon,
 | 
			
		||||
@@ -1880,17 +1909,7 @@ class MainWindow(QMainWindow):
 | 
			
		||||
        self.current_play_button = playButton
 | 
			
		||||
 | 
			
		||||
        # Анимация
 | 
			
		||||
        opacityEffect = QGraphicsOpacityEffect(detailPage)
 | 
			
		||||
        detailPage.setGraphicsEffect(opacityEffect)
 | 
			
		||||
        animation = QPropertyAnimation(opacityEffect, QByteArray(b"opacity"))
 | 
			
		||||
        animation.setDuration(800)
 | 
			
		||||
        animation.setStartValue(0)
 | 
			
		||||
        animation.setEndValue(1)
 | 
			
		||||
        animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
 | 
			
		||||
        self._animations[detailPage] = animation
 | 
			
		||||
        animation.finished.connect(
 | 
			
		||||
            lambda: detailPage.setGraphicsEffect(cast(QGraphicsEffect, None))
 | 
			
		||||
        )
 | 
			
		||||
        self.detail_animations.animate_detail_page(detailPage, load_image_and_restore_effect, cleanup_animation)
 | 
			
		||||
 | 
			
		||||
    def toggleFavoriteInDetailPage(self, game_name, label):
 | 
			
		||||
        favorites = read_favorites()
 | 
			
		||||
@@ -1946,16 +1965,42 @@ class MainWindow(QMainWindow):
 | 
			
		||||
            parent = parent.parent()
 | 
			
		||||
 | 
			
		||||
    def goBackDetailPage(self, page: QWidget | None) -> None:
 | 
			
		||||
        if page is None or page != self.stackedWidget.currentWidget():
 | 
			
		||||
        if page is None or page != self.stackedWidget.currentWidget() or getattr(self, '_exit_animation_in_progress', False):
 | 
			
		||||
            return
 | 
			
		||||
        self._exit_animation_in_progress = True
 | 
			
		||||
        self._detail_page_active = False
 | 
			
		||||
        self._current_detail_page = None
 | 
			
		||||
        self.stackedWidget.setCurrentIndex(0)
 | 
			
		||||
        self.stackedWidget.removeWidget(page)
 | 
			
		||||
        page.deleteLater()
 | 
			
		||||
        self.currentDetailPage = None
 | 
			
		||||
        self.current_exec_line = None
 | 
			
		||||
        self.current_play_button = None
 | 
			
		||||
 | 
			
		||||
        def cleanup():
 | 
			
		||||
            """Helper function to clean up after animation."""
 | 
			
		||||
            try:
 | 
			
		||||
                if page in self._animations:
 | 
			
		||||
                    animation = self._animations[page]
 | 
			
		||||
                    try:
 | 
			
		||||
                        if animation.state() == QAbstractAnimation.State.Running:
 | 
			
		||||
                            animation.stop()
 | 
			
		||||
                    except RuntimeError:
 | 
			
		||||
                        pass  # Animation already deleted
 | 
			
		||||
                    finally:
 | 
			
		||||
                        del self._animations[page]
 | 
			
		||||
                self.stackedWidget.setCurrentIndex(0)
 | 
			
		||||
                self.stackedWidget.removeWidget(page)
 | 
			
		||||
                page.deleteLater()
 | 
			
		||||
                self.currentDetailPage = None
 | 
			
		||||
                self.current_exec_line = None
 | 
			
		||||
                self.current_play_button = None
 | 
			
		||||
                self._exit_animation_in_progress = False
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Error in cleanup: {e}", exc_info=True)
 | 
			
		||||
                self._exit_animation_in_progress = False
 | 
			
		||||
 | 
			
		||||
        # Start exit animation
 | 
			
		||||
        try:
 | 
			
		||||
            self.detail_animations.animate_detail_page_exit(page, cleanup)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error starting exit animation: {e}", exc_info=True)
 | 
			
		||||
            self._exit_animation_in_progress = False
 | 
			
		||||
            cleanup()  # Fallback to cleanup if animation fails
 | 
			
		||||
 | 
			
		||||
    def is_target_exe_running(self):
 | 
			
		||||
        """Проверяет, запущен ли процесс с именем self.target_exe через psutil."""
 | 
			
		||||
 
 | 
			
		||||
@@ -18,6 +18,10 @@ from collections.abc import Callable
 | 
			
		||||
import re
 | 
			
		||||
import shutil
 | 
			
		||||
import zlib
 | 
			
		||||
import websocket
 | 
			
		||||
import requests
 | 
			
		||||
import random
 | 
			
		||||
import base64
 | 
			
		||||
 | 
			
		||||
downloader = Downloader()
 | 
			
		||||
logger = get_logger(__name__)
 | 
			
		||||
@@ -291,7 +295,7 @@ def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
            if os.path.exists(cache_tar):
 | 
			
		||||
                os.remove(cache_tar)
 | 
			
		||||
                logger.info("Archive %s deleted after extraction", cache_tar)
 | 
			
		||||
            steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or []
 | 
			
		||||
            steam_apps = data if isinstance(data, list) else []
 | 
			
		||||
            logger.info("Loaded %d apps from archive", len(steam_apps))
 | 
			
		||||
            callback(steam_apps)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
@@ -303,12 +307,25 @@ def load_steam_apps_async(callback: Callable[[list], None]):
 | 
			
		||||
        try:
 | 
			
		||||
            with open(cache_json, "rb") as f:
 | 
			
		||||
                data = orjson.loads(f.read())
 | 
			
		||||
            steam_apps = data.get("applist", {}).get("apps", []) if isinstance(data, dict) else data or []
 | 
			
		||||
            # Validate JSON structure
 | 
			
		||||
            if not isinstance(data, list):
 | 
			
		||||
                logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
 | 
			
		||||
                raise ValueError("Invalid JSON structure")
 | 
			
		||||
            # Validate each app entry
 | 
			
		||||
            for app in data:
 | 
			
		||||
                if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
 | 
			
		||||
                    logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json)
 | 
			
		||||
                    raise ValueError("Invalid app entry structure")
 | 
			
		||||
            steam_apps = data
 | 
			
		||||
            logger.info("Loaded %d apps from cache", len(steam_apps))
 | 
			
		||||
            callback(steam_apps)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error reading cached JSON: %s", e)
 | 
			
		||||
            callback([])
 | 
			
		||||
            logger.error("Error reading or validating cached JSON %s: %s", cache_json, e)
 | 
			
		||||
            # Attempt to re-download if cache is invalid or corrupted
 | 
			
		||||
            app_list_url = (
 | 
			
		||||
                "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
 | 
			
		||||
            )
 | 
			
		||||
            downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
    else:
 | 
			
		||||
        app_list_url = (
 | 
			
		||||
            "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
 | 
			
		||||
@@ -448,12 +465,25 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
 | 
			
		||||
        try:
 | 
			
		||||
            with open(cache_json, "rb") as f:
 | 
			
		||||
                data = orjson.loads(f.read())
 | 
			
		||||
            anti_cheat_data = data or []
 | 
			
		||||
            # Validate JSON structure
 | 
			
		||||
            if not isinstance(data, list):
 | 
			
		||||
                logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
 | 
			
		||||
                raise ValueError("Invalid JSON structure")
 | 
			
		||||
            # Validate each anti-cheat entry
 | 
			
		||||
            for entry in data:
 | 
			
		||||
                if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
 | 
			
		||||
                    logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json)
 | 
			
		||||
                    raise ValueError("Invalid anti-cheat entry structure")
 | 
			
		||||
            anti_cheat_data = data
 | 
			
		||||
            logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
 | 
			
		||||
            callback(anti_cheat_data)
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error("Error reading cached WeAntiCheatYet JSON: %s", e)
 | 
			
		||||
            callback([])
 | 
			
		||||
            logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e)
 | 
			
		||||
            # Attempt to re-download if cache is invalid or corrupted
 | 
			
		||||
            app_list_url = (
 | 
			
		||||
                "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
 | 
			
		||||
            )
 | 
			
		||||
            downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
 | 
			
		||||
    else:
 | 
			
		||||
        app_list_url = (
 | 
			
		||||
            "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
 | 
			
		||||
@@ -745,6 +775,126 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
 | 
			
		||||
 | 
			
		||||
    load_steam_apps_async(on_steam_apps)
 | 
			
		||||
 | 
			
		||||
def enable_steam_cef() -> tuple[bool, str]:
 | 
			
		||||
    """
 | 
			
		||||
    Проверяет и при необходимости активирует режим удаленной отладки Steam CEF.
 | 
			
		||||
 | 
			
		||||
    Создает файл .cef-enable-remote-debugging в директории Steam.
 | 
			
		||||
    Steam необходимо перезапустить после первого создания этого файла.
 | 
			
		||||
 | 
			
		||||
    Возвращает кортеж:
 | 
			
		||||
    - (True, "already_enabled") если уже было активно.
 | 
			
		||||
    - (True, "restart_needed") если было только что активировано и нужен перезапуск Steam.
 | 
			
		||||
    - (False, "steam_not_found") если директория Steam не найдена.
 | 
			
		||||
    """
 | 
			
		||||
    steam_home = get_steam_home()
 | 
			
		||||
    if not steam_home:
 | 
			
		||||
        return (False, "steam_not_found")
 | 
			
		||||
 | 
			
		||||
    cef_flag_file = steam_home / ".cef-enable-remote-debugging"
 | 
			
		||||
    logger.info(f"Проверка CEF флага: {cef_flag_file}")
 | 
			
		||||
 | 
			
		||||
    if cef_flag_file.exists():
 | 
			
		||||
        logger.info("CEF Remote Debugging уже активирован.")
 | 
			
		||||
        return (True, "already_enabled")
 | 
			
		||||
    else:
 | 
			
		||||
        try:
 | 
			
		||||
            os.makedirs(cef_flag_file.parent, exist_ok=True)
 | 
			
		||||
            cef_flag_file.touch()
 | 
			
		||||
            logger.info("CEF Remote Debugging активирован. Steam необходимо перезапустить.")
 | 
			
		||||
            return (True, "restart_needed")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Не удалось создать CEF флаг {cef_flag_file}: {e}")
 | 
			
		||||
            return (False, str(e))
 | 
			
		||||
 | 
			
		||||
def call_steam_api(js_cmd: str, *args) -> dict | None:
 | 
			
		||||
    """
 | 
			
		||||
    Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging.
 | 
			
		||||
 | 
			
		||||
    Args:
 | 
			
		||||
        js_cmd: Имя JS функции для вызова (напр. 'createShortcut').
 | 
			
		||||
        *args: Аргументы для передачи в JS функцию.
 | 
			
		||||
 | 
			
		||||
    Returns:
 | 
			
		||||
        Словарь с результатом выполнения или None в случае ошибки.
 | 
			
		||||
    """
 | 
			
		||||
    status, message = enable_steam_cef()
 | 
			
		||||
    if not (status is True and message == "already_enabled"):
 | 
			
		||||
        if message == "restart_needed":
 | 
			
		||||
            logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.")
 | 
			
		||||
        elif message == "steam_not_found":
 | 
			
		||||
            logger.error("Не удалось найти директорию Steam для проверки CEF API.")
 | 
			
		||||
        else:
 | 
			
		||||
            logger.error(f"Steam CEF API недоступен или не готов: {message}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    steam_debug_url = "http://localhost:8080/json"
 | 
			
		||||
 | 
			
		||||
    try:
 | 
			
		||||
        response = requests.get(steam_debug_url, timeout=2)
 | 
			
		||||
        response.raise_for_status()
 | 
			
		||||
        contexts = response.json()
 | 
			
		||||
        ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
 | 
			
		||||
        if not ws_url:
 | 
			
		||||
            logger.warning("Не удалось найти SharedJSContext. Steam запущен с флагом -cef-enable-remote-debugging?")
 | 
			
		||||
            return None
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
    js_code = """
 | 
			
		||||
        async function createShortcut(name, exe, dir, icon, args) {
 | 
			
		||||
            const id = await SteamClient.Apps.AddShortcut(name, exe, dir, args);
 | 
			
		||||
            console.log("Shortcut created with ID:", id);
 | 
			
		||||
            await SteamClient.Apps.SetShortcutName(id, name);
 | 
			
		||||
            if (icon)
 | 
			
		||||
                await SteamClient.Apps.SetShortcutIcon(id, icon);
 | 
			
		||||
            if (args)
 | 
			
		||||
                await SteamClient.Apps.SetAppLaunchOptions(id, args);
 | 
			
		||||
            return { id };
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        async function setGrid(id, i, ext, image) {
 | 
			
		||||
            await SteamClient.Apps.SetCustomArtworkForApp(id, image, ext, i);
 | 
			
		||||
            return true;
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        async function removeShortcut(id) {
 | 
			
		||||
            await SteamClient.Apps.RemoveShortcut(+id);
 | 
			
		||||
            return true;
 | 
			
		||||
        };
 | 
			
		||||
    """
 | 
			
		||||
    try:
 | 
			
		||||
        ws = websocket.create_connection(ws_url, timeout=5)
 | 
			
		||||
        js_args = ", ".join(orjson.dumps(arg).decode('utf-8') for arg in args)
 | 
			
		||||
        expression = f"{js_code} {js_cmd}({js_args});"
 | 
			
		||||
        payload = {
 | 
			
		||||
            "id": random.randint(0, 32767),
 | 
			
		||||
            "method": "Runtime.evaluate",
 | 
			
		||||
            "params": {
 | 
			
		||||
                "expression": expression,
 | 
			
		||||
                "awaitPromise": True,
 | 
			
		||||
                "returnByValue": True
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        ws.send(orjson.dumps(payload))
 | 
			
		||||
        response_str = ws.recv()
 | 
			
		||||
        ws.close()
 | 
			
		||||
 | 
			
		||||
        response_data = orjson.loads(response_str)
 | 
			
		||||
        if "error" in response_data:
 | 
			
		||||
            logger.error(f"Ошибка выполнения JS в Steam: {response_data['error']['message']}")
 | 
			
		||||
            return None
 | 
			
		||||
        result = response_data.get('result', {}).get('result', {})
 | 
			
		||||
        if result.get('type') == 'object' and result.get('subtype') == 'error':
 | 
			
		||||
            logger.error(f"Ошибка выполнения JS в Steam: {result.get('description')}")
 | 
			
		||||
            return None
 | 
			
		||||
        return result.get('value')
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}")
 | 
			
		||||
        return None
 | 
			
		||||
 | 
			
		||||
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
 | 
			
		||||
    """
 | 
			
		||||
    Add a non-Steam game to Steam via shortcuts.vdf with PortProton tag,
 | 
			
		||||
@@ -846,45 +996,42 @@ export START_FROM_STEAM=1
 | 
			
		||||
    grid_dir = user_dir / "config" / "grid"
 | 
			
		||||
    os.makedirs(grid_dir, exist_ok=True)
 | 
			
		||||
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
            logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
            return (False, f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
    appid = None
 | 
			
		||||
    was_api_used = False
 | 
			
		||||
 | 
			
		||||
    unique_string = f"{script_path}{game_name}"
 | 
			
		||||
    baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
    appid = baseid | 0x80000000
 | 
			
		||||
    if appid > 0x7FFFFFFF:
 | 
			
		||||
        aidvdf = appid - 0x100000000
 | 
			
		||||
    logger.info("Попытка добавления ярлыка через Steam CEF API...")
 | 
			
		||||
    api_response = call_steam_api(
 | 
			
		||||
        "createShortcut",
 | 
			
		||||
        game_name,
 | 
			
		||||
        script_path,
 | 
			
		||||
        str(Path(script_path).parent),
 | 
			
		||||
        icon_path,
 | 
			
		||||
        ""
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    if api_response and isinstance(api_response, dict) and 'id' in api_response:
 | 
			
		||||
        appid = api_response['id']
 | 
			
		||||
        was_api_used = True
 | 
			
		||||
        logger.info(f"Ярлык успешно добавлен через API. AppID: {appid}")
 | 
			
		||||
    else:
 | 
			
		||||
        aidvdf = appid
 | 
			
		||||
        logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).")
 | 
			
		||||
        backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
        if os.path.exists(steam_shortcuts_path):
 | 
			
		||||
            try:
 | 
			
		||||
                shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
                logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
                return (False, f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
    downloaded_count = 0
 | 
			
		||||
    total_covers = 4  # количество обложек
 | 
			
		||||
        unique_string = f"{script_path}{game_name}"
 | 
			
		||||
        baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
        appid = baseid | 0x80000000
 | 
			
		||||
        if appid > 0x7FFFFFFF:
 | 
			
		||||
            aidvdf = appid - 0x100000000
 | 
			
		||||
        else:
 | 
			
		||||
            aidvdf = appid
 | 
			
		||||
 | 
			
		||||
    download_lock = threading.Lock()
 | 
			
		||||
 | 
			
		||||
    def on_cover_download(cover_file: str, cover_type: str):
 | 
			
		||||
        nonlocal downloaded_count
 | 
			
		||||
        try:
 | 
			
		||||
            if cover_file and os.path.exists(cover_file):
 | 
			
		||||
                logger.info(f"Downloaded cover {cover_type} to {cover_file}")
 | 
			
		||||
            else:
 | 
			
		||||
                logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
 | 
			
		||||
        with download_lock:
 | 
			
		||||
            downloaded_count += 1
 | 
			
		||||
            if downloaded_count == total_covers:
 | 
			
		||||
                finalize_shortcut()
 | 
			
		||||
 | 
			
		||||
    def finalize_shortcut():
 | 
			
		||||
        tags_dict = {'0': 'PortProton'}
 | 
			
		||||
        shortcut = {
 | 
			
		||||
            "appid": aidvdf,
 | 
			
		||||
            "AppName": game_name,
 | 
			
		||||
@@ -899,7 +1046,7 @@ export START_FROM_STEAM=1
 | 
			
		||||
            "Devkit": 0,
 | 
			
		||||
            "DevkitGameID": "",
 | 
			
		||||
            "LastPlayTime": 0,
 | 
			
		||||
            "tags": tags_dict
 | 
			
		||||
            "tags": {'0': 'PortProton'}
 | 
			
		||||
        }
 | 
			
		||||
        logger.info(f"Shortcut entry to be written: {shortcut}")
 | 
			
		||||
 | 
			
		||||
@@ -929,6 +1076,7 @@ export START_FROM_STEAM=1
 | 
			
		||||
 | 
			
		||||
            with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
                vdf.binary_dump({"shortcuts": shortcuts}, f)
 | 
			
		||||
            logger.info(f"Game '{game_name}' successfully added to Steam with covers")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
            if os.path.exists(backup_path):
 | 
			
		||||
@@ -937,34 +1085,54 @@ export START_FROM_STEAM=1
 | 
			
		||||
                    logger.info("Restored shortcuts.vdf from backup due to update failure")
 | 
			
		||||
                except Exception as restore_err:
 | 
			
		||||
                    logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
 | 
			
		||||
            return (False, f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
            appid = None
 | 
			
		||||
 | 
			
		||||
        logger.info(f"Game '{game_name}' successfully added to Steam with covers")
 | 
			
		||||
        return (True, f"Game '{game_name}' added to Steam with covers")
 | 
			
		||||
    if not appid:
 | 
			
		||||
        return (False, "Не удалось создать ярлык ни одним из способов.")
 | 
			
		||||
 | 
			
		||||
    steam_appid = None
 | 
			
		||||
 | 
			
		||||
    def on_game_info(game_info: dict):
 | 
			
		||||
        nonlocal steam_appid
 | 
			
		||||
        steam_appid = game_info.get("appid")
 | 
			
		||||
        if not steam_appid or not isinstance(steam_appid, int):
 | 
			
		||||
            logger.info("No valid Steam appid found, skipping cover download")
 | 
			
		||||
            return finalize_shortcut()
 | 
			
		||||
            return
 | 
			
		||||
        logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.")
 | 
			
		||||
 | 
			
		||||
        # Обложки и имена, соответствующие bash-скрипту и твоим размерам
 | 
			
		||||
        cover_types = [
 | 
			
		||||
            (".jpg", "header.jpg"),              # базовый, сохранится как AppId.jpg
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg"), # сохранится как AppIdp.jpg
 | 
			
		||||
            ("_hero.jpg", "library_hero.jpg"),   # AppId_hero.jpg
 | 
			
		||||
            ("_logo.png", "logo.png")            # AppId_logo.png
 | 
			
		||||
            ("p.jpg", "library_600x900_2x.jpg"),
 | 
			
		||||
            ("_hero.jpg", "library_hero.jpg"),
 | 
			
		||||
            ("_logo.png", "logo.png"),
 | 
			
		||||
            (".jpg", "header.jpg")
 | 
			
		||||
        ]
 | 
			
		||||
 | 
			
		||||
        for suffix, cover_type in cover_types:
 | 
			
		||||
        def on_cover_download(result_path: str | None, steam_name: str, index: int):
 | 
			
		||||
            try:
 | 
			
		||||
                if result_path and os.path.exists(result_path):
 | 
			
		||||
                    logger.info(f"Downloaded cover {steam_name} to {result_path}")
 | 
			
		||||
                    if was_api_used:
 | 
			
		||||
                        try:
 | 
			
		||||
                            with open(result_path, 'rb') as f:
 | 
			
		||||
                                img_b64 = base64.b64encode(f.read()).decode('utf-8')
 | 
			
		||||
                            logger.info(f"Применение обложки типа '{steam_name}' через API для AppID {appid}")
 | 
			
		||||
                            ext = Path(steam_name).suffix.lstrip('.')
 | 
			
		||||
                            call_steam_api("setGrid", appid, index, ext, img_b64)
 | 
			
		||||
                        except Exception as e:
 | 
			
		||||
                            logger.error(f"Ошибка при применении обложки '{steam_name}' через API: {e}")
 | 
			
		||||
                else:
 | 
			
		||||
                    logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
 | 
			
		||||
            except Exception as e:
 | 
			
		||||
                logger.error(f"Error processing cover {steam_name} for appid {steam_appid}: {e}")
 | 
			
		||||
 | 
			
		||||
        for i, (suffix, steam_name) in enumerate(cover_types):
 | 
			
		||||
            cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
 | 
			
		||||
            cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
 | 
			
		||||
            cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{steam_name}"
 | 
			
		||||
            downloader.download_async(
 | 
			
		||||
                cover_url,
 | 
			
		||||
                cover_file,
 | 
			
		||||
                timeout=5,
 | 
			
		||||
                callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype)
 | 
			
		||||
                callback=lambda result, index=i, name=steam_name: on_cover_download(result, name, index)
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    get_steam_game_info_async(game_name, exec_line, on_game_info)
 | 
			
		||||
@@ -1017,19 +1185,7 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
 | 
			
		||||
        logger.info(f"shortcuts.vdf not found at {steam_shortcuts_path}")
 | 
			
		||||
        return (False, f"Game '{game_name}' not found in Steam")
 | 
			
		||||
 | 
			
		||||
    # Generate appid for identifying cover files
 | 
			
		||||
    unique_string = f"{script_path}{game_name}"
 | 
			
		||||
    baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
 | 
			
		||||
    appid = baseid | 0x80000000
 | 
			
		||||
 | 
			
		||||
    # Create backup of shortcuts.vdf
 | 
			
		||||
    backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
    try:
 | 
			
		||||
        shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
        logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
        return (False, f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
    appid = None
 | 
			
		||||
 | 
			
		||||
    # Load and modify shortcuts.vdf
 | 
			
		||||
    try:
 | 
			
		||||
@@ -1043,37 +1199,51 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
 | 
			
		||||
        return (False, f"Failed to load shortcuts.vdf: {load_err}")
 | 
			
		||||
 | 
			
		||||
    shortcuts = shortcuts_data.get("shortcuts", {})
 | 
			
		||||
    found = False
 | 
			
		||||
    new_shortcuts = {}
 | 
			
		||||
    index = 0
 | 
			
		||||
 | 
			
		||||
    # Filter out the matching shortcut
 | 
			
		||||
    for _key, entry in shortcuts.items():
 | 
			
		||||
        if entry.get("AppName") == game_name and entry.get("Exe") == f'"{script_path}"':
 | 
			
		||||
            found = True
 | 
			
		||||
            appid = convert_steam_id(int(entry.get("appid")))
 | 
			
		||||
            logger.info(f"Found matching shortcut for '{game_name}' to remove")
 | 
			
		||||
            continue
 | 
			
		||||
        new_shortcuts[str(index)] = entry
 | 
			
		||||
        index += 1
 | 
			
		||||
 | 
			
		||||
    if not found:
 | 
			
		||||
    if not appid:
 | 
			
		||||
        logger.info(f"Game '{game_name}' not found in Steam shortcuts")
 | 
			
		||||
        return (False, f"Game '{game_name}' not found in Steam")
 | 
			
		||||
 | 
			
		||||
    # Save updated shortcuts.vdf
 | 
			
		||||
    try:
 | 
			
		||||
        with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
            vdf.binary_dump({"shortcuts": new_shortcuts}, f)
 | 
			
		||||
        logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'")
 | 
			
		||||
    except Exception as e:
 | 
			
		||||
        logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
        if os.path.exists(backup_path):
 | 
			
		||||
            try:
 | 
			
		||||
                shutil.copy2(backup_path, steam_shortcuts_path)
 | 
			
		||||
                logger.info("Restored shortcuts.vdf from backup due to update failure")
 | 
			
		||||
            except Exception as restore_err:
 | 
			
		||||
                logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
 | 
			
		||||
        return (False, f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
    api_response = call_steam_api("removeShortcut", appid)
 | 
			
		||||
    if api_response is not None: # API ответил, даже если ответ пустой
 | 
			
		||||
        logger.info(f"Ярлык для AppID {appid} успешно удален через API.")
 | 
			
		||||
    else:
 | 
			
		||||
        logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).")
 | 
			
		||||
 | 
			
		||||
        # Create backup of shortcuts.vdf
 | 
			
		||||
        backup_path = f"{steam_shortcuts_path}.backup"
 | 
			
		||||
        try:
 | 
			
		||||
            shutil.copy2(steam_shortcuts_path, backup_path)
 | 
			
		||||
            logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
            return (False, f"Failed to create backup of shortcuts.vdf: {e}")
 | 
			
		||||
 | 
			
		||||
        # Save updated shortcuts.vdf
 | 
			
		||||
        try:
 | 
			
		||||
            with open(steam_shortcuts_path, 'wb') as f:
 | 
			
		||||
                vdf.binary_dump({"shortcuts": new_shortcuts}, f)
 | 
			
		||||
            logger.info(f"Successfully updated shortcuts.vdf, removed '{game_name}'")
 | 
			
		||||
        except Exception as e:
 | 
			
		||||
            logger.error(f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
            if os.path.exists(backup_path):
 | 
			
		||||
                try:
 | 
			
		||||
                    shutil.copy2(backup_path, steam_shortcuts_path)
 | 
			
		||||
                    logger.info("Restored shortcuts.vdf from backup due to update failure")
 | 
			
		||||
                except Exception as restore_err:
 | 
			
		||||
                    logger.error(f"Failed to restore shortcuts.vdf from backup: {restore_err}")
 | 
			
		||||
            return (False, f"Failed to update shortcuts.vdf: {e}")
 | 
			
		||||
 | 
			
		||||
    # Delete cover files
 | 
			
		||||
    cover_files = [
 | 
			
		||||
 
 | 
			
		||||
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 734 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 213 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 622 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 164 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 570 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 367 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 2.3 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 392 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 660 B  | 
| 
		 Before Width: | Height: | Size: 7.9 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.7 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 1.3 KiB  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 208 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#fff"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 165 B  | 
@@ -1 +0,0 @@
 | 
			
		||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848t-1.5848 3.8596q-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 717 B  | 
| 
		 Before Width: | Height: | Size: 1.1 KiB  | 
| 
		 Before Width: | Height: | Size: 1.6 MiB  | 
| 
		 Before Width: | Height: | Size: 475 KiB  | 
| 
		 Before Width: | Height: | Size: 151 KiB  | 
@@ -1,5 +0,0 @@
 | 
			
		||||
[Metainfo]
 | 
			
		||||
author = BlackSnaker
 | 
			
		||||
author_link =
 | 
			
		||||
description = Стандартная тема PortProtonQt (светлый вариант)
 | 
			
		||||
name = Light
 | 
			
		||||
@@ -1,699 +0,0 @@
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
 | 
			
		||||
theme_manager = ThemeManager()
 | 
			
		||||
current_theme_name = read_theme_from_config()
 | 
			
		||||
 | 
			
		||||
# КОНСТАНТЫ
 | 
			
		||||
favoriteLabelSize = 48, 48
 | 
			
		||||
pixmapsScaledSize = 60, 60
 | 
			
		||||
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
 | 
			
		||||
    # Влияет на толщину рамки вокруг карточки, когда она не выделена.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "default_border_width": 2,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при наведении курсора.
 | 
			
		||||
    # Увеличивает толщину рамки, когда курсор находится над карточкой.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "hover_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки при фокусе (например, при выборе с клавиатуры).
 | 
			
		||||
    # Увеличивает толщину рамки, когда карточка в фокусе.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "focus_border_width": 12,
 | 
			
		||||
 | 
			
		||||
    # Минимальная ширина обводки во время пульсирующей анимации.
 | 
			
		||||
    # Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "pulse_min_border_width": 8,
 | 
			
		||||
 | 
			
		||||
    # Максимальная ширина обводки во время пульсирующей анимации.
 | 
			
		||||
    # Определяет максимальную толщину рамки при пульсации.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
    "pulse_max_border_width": 10,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
 | 
			
		||||
    # Влияет на скорость перехода от одной ширины обводки к другой.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "thickness_anim_duration": 300,
 | 
			
		||||
 | 
			
		||||
    # Длительность одного цикла пульсирующей анимации.
 | 
			
		||||
    # Определяет, как быстро рамка "пульсирует" между min и max значениями.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "pulse_anim_duration": 800,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации вращения градиента.
 | 
			
		||||
    # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
 | 
			
		||||
    # Значение в миллисекундах.
 | 
			
		||||
    "gradient_anim_duration": 3000,
 | 
			
		||||
 | 
			
		||||
    # Начальный угол градиента (в градусах).
 | 
			
		||||
    # Определяет начальную точку вращения градиента при старте анимации.
 | 
			
		||||
    "gradient_start_angle": 360,
 | 
			
		||||
 | 
			
		||||
    # Конечный угол градиента (в градусах).
 | 
			
		||||
    # Определяет конечную точку вращения градиента.
 | 
			
		||||
    # Значение 0 означает полный поворот на 360 градусов.
 | 
			
		||||
    "gradient_end_angle": 0,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
 | 
			
		||||
    # Влияет на "чувство" анимации (например, плавное ускорение или замедление).
 | 
			
		||||
    # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
 | 
			
		||||
    "thickness_easing_curve": "OutBack",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
 | 
			
		||||
    # Влияет на "чувство" возврата к исходной ширине обводки.
 | 
			
		||||
    "thickness_easing_curve_out": "InBack",
 | 
			
		||||
 | 
			
		||||
    # Цвета градиента для анимированной обводки.
 | 
			
		||||
    # Список словарей, где каждый словарь задает позицию (0.0–1.0) и цвет в формате hex.
 | 
			
		||||
    # Влияет на внешний вид обводки при наведении или фокусе.
 | 
			
		||||
    "gradient_colors": [
 | 
			
		||||
        {"position": 0, "color": "#00fff5"},    # Начальный цвет (циан)
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # Конечный цвет (возвращение к циану)
 | 
			
		||||
    ]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
 | 
			
		||||
MAIN_WINDOW_HEADER_STYLE = """
 | 
			
		||||
    QFrame {
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        border: 10px solid rgba(255, 255, 255, 0.10);
 | 
			
		||||
        border-bottom: 0px solid rgba(255, 255, 255, 0.15);
 | 
			
		||||
        border-top-left-radius: 30px;
 | 
			
		||||
        border-top-right-radius: 30px;
 | 
			
		||||
        border: none;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
 | 
			
		||||
TITLE_LABEL_STYLE = """
 | 
			
		||||
    QLabel {
 | 
			
		||||
        font-family: 'RASKHAL';
 | 
			
		||||
        font-size: 38px;
 | 
			
		||||
        margin: 0 0 0 0;
 | 
			
		||||
        color: #007AFF;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
 | 
			
		||||
NAV_WIDGET_STYLE = """
 | 
			
		||||
    QWidget {
 | 
			
		||||
        background: #ffffff;
 | 
			
		||||
        border-bottom: 0px solid rgba(0, 0, 0, 0.10);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
 | 
			
		||||
NAV_BUTTON_STYLE = """
 | 
			
		||||
    NavLabel {
 | 
			
		||||
        background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
 | 
			
		||||
            stop:0 rgba(242, 242, 242, 0.5),
 | 
			
		||||
            stop:1 rgba(232, 232, 232, 0.5));
 | 
			
		||||
        padding: 10px 10px;
 | 
			
		||||
        margin: 10px 0 10px 10px;
 | 
			
		||||
        color: #333333;
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        text-transform: uppercase;
 | 
			
		||||
        border: 1px solid rgba(179, 179, 179, 0.4);
 | 
			
		||||
        border-radius: 15px;
 | 
			
		||||
    }
 | 
			
		||||
    NavLabel[checked = true] {
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
        color: #002244;
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
        border-radius: 15px;
 | 
			
		||||
    }
 | 
			
		||||
    NavLabel:hover {
 | 
			
		||||
        background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
 | 
			
		||||
            stop:0 rgba(0,122,255,0.12),
 | 
			
		||||
            stop:1 rgba(0,122,255,0.08));
 | 
			
		||||
        color: #002244;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
 | 
			
		||||
MAIN_WINDOW_STYLE = """
 | 
			
		||||
    QMainWindow {
 | 
			
		||||
        background: none;
 | 
			
		||||
    }
 | 
			
		||||
    QLabel {
 | 
			
		||||
        color: #333333;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ПОЛЯ ПОИСКА
 | 
			
		||||
SEARCH_EDIT_STYLE = """
 | 
			
		||||
    QLineEdit {
 | 
			
		||||
        background-color: rgba(30, 30, 30, 0.50);
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
        border-radius: 10px;
 | 
			
		||||
        padding: 7px 14px;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
    }
 | 
			
		||||
    QLineEdit:focus {
 | 
			
		||||
        border: 1px solid rgba(0,122,255,0.25);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
 | 
			
		||||
SCROLL_AREA_STYLE = """
 | 
			
		||||
    QWidget {
 | 
			
		||||
        background: transparent;
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar:vertical {
 | 
			
		||||
        width: 10px;
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        background: rgba(20, 20, 20, 0.30);
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar::handle:vertical {
 | 
			
		||||
        background: rgba(255, 255, 255, 0.7);
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar::add-line:vertical {
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        background: none;
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar::sub-line:vertical {
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        background: none;
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        width: 3px;
 | 
			
		||||
        height: 3px;
 | 
			
		||||
        background: none;
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar:horizontal {
 | 
			
		||||
        height: 10px;
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        background: rgba(20, 20, 20, 0.30);
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar::handle:horizontal {
 | 
			
		||||
        background: #bebebe;
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar::add-line:horizontal {
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        background: none;
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar::sub-line:horizontal {
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        background: none;
 | 
			
		||||
    }
 | 
			
		||||
    QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        width: 3px;
 | 
			
		||||
        height: 3px;
 | 
			
		||||
        background: none;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# SLIDER_SIZE_STYLE
 | 
			
		||||
SLIDER_SIZE_STYLE= """
 | 
			
		||||
    QWidget {
 | 
			
		||||
        background: transparent;
 | 
			
		||||
        height: 25px;
 | 
			
		||||
    }
 | 
			
		||||
    QSlider::groove:horizontal {
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        border-radius: 3px;
 | 
			
		||||
        height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
 | 
			
		||||
        background: rgba(20, 20, 20, 0.30);
 | 
			
		||||
        margin: 6px 0;
 | 
			
		||||
    }
 | 
			
		||||
    QSlider::handle:horizontal {
 | 
			
		||||
        background: #bebebe;
 | 
			
		||||
        border: 0px solid;
 | 
			
		||||
        width: 18px;
 | 
			
		||||
        height: 18px;
 | 
			
		||||
        margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
 | 
			
		||||
        border-radius: 9px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
 | 
			
		||||
LIST_WIDGET_STYLE = """
 | 
			
		||||
    QWidget {
 | 
			
		||||
        background: none;
 | 
			
		||||
        border: 0px solid rgba(255, 255, 255, 0.10);
 | 
			
		||||
        border-radius: 25px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
 | 
			
		||||
INSTALLED_TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627;"
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
 | 
			
		||||
ACTION_BUTTON_STYLE = """
 | 
			
		||||
    QPushButton {
 | 
			
		||||
        background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
 | 
			
		||||
            stop:0 rgba(242, 242, 242, 0.5),
 | 
			
		||||
            stop:1 rgba(232, 232, 232, 0.5));
 | 
			
		||||
        border: 1px solid rgba(179, 179, 179, 0.4);
 | 
			
		||||
        border-radius: 10px;
 | 
			
		||||
        color: #232627;
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        padding: 8px 16px;
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:hover {
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:pressed {
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
 | 
			
		||||
TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627; background-color: none;"
 | 
			
		||||
CONTENT_STYLE = """
 | 
			
		||||
    QLabel {
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        color: #232627;
 | 
			
		||||
        background-color: none;
 | 
			
		||||
        border-bottom: 1px solid rgba(165, 165, 165, 0.7);
 | 
			
		||||
        padding-bottom: 15px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
 | 
			
		||||
# LIBRARY_WIDGET_STYLE
 | 
			
		||||
LIBRARY_WIDGET_STYLE= """
 | 
			
		||||
    QWidget {
 | 
			
		||||
        background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
 | 
			
		||||
        border-radius: 0px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# CONTAINER_STYLE
 | 
			
		||||
CONTAINER_STYLE= """
 | 
			
		||||
    QWidget {
 | 
			
		||||
        background-color: none;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# OTHER_PAGES_WIDGET_STYLE
 | 
			
		||||
OTHER_PAGES_WIDGET_STYLE= """
 | 
			
		||||
    QWidget {
 | 
			
		||||
        background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
 | 
			
		||||
        border-radius: 0px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# CAROUSEL_WIDGET_STYLE
 | 
			
		||||
CAROUSEL_WIDGET_STYLE= """
 | 
			
		||||
    QWidget {
 | 
			
		||||
        background: qlineargradient(spread:pad, x1:0.099, y1:0.119, x2:0.917, y2:0.936149, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(217, 193, 255, 255));
 | 
			
		||||
        border-radius: 0px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
 | 
			
		||||
DETAIL_PAGE_NO_COVER_STYLE = "background: rgba(20,20,20,0.95); border-radius: 15px;"
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
 | 
			
		||||
ADDGAME_BACK_BUTTON_STYLE = """
 | 
			
		||||
    QPushButton {
 | 
			
		||||
        background: rgba(20, 20, 20, 0.40);
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
        border-radius: 10px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        padding: 4px 16px;
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:hover {
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:pressed {
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
 | 
			
		||||
DETAIL_CONTENT_FRAME_STYLE = """
 | 
			
		||||
    QFrame {
 | 
			
		||||
        background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
 | 
			
		||||
            stop:0 rgba(20, 20, 20, 0.40),
 | 
			
		||||
            stop:1 rgba(20, 20, 20, 0.35));
 | 
			
		||||
        border: 0px solid rgba(255, 255, 255, 0.10);
 | 
			
		||||
        border-radius: 15px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ФРЕЙМ ПОД ОБЛОЖКОЙ
 | 
			
		||||
COVER_FRAME_STYLE = """
 | 
			
		||||
    QFrame {
 | 
			
		||||
        background: rgba(30, 30, 30, 0.80);
 | 
			
		||||
        border-radius: 15px;
 | 
			
		||||
        border: 0px solid rgba(255, 255, 255, 0.15);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
 | 
			
		||||
COVER_LABEL_STYLE = "border-radius: 100px;"
 | 
			
		||||
 | 
			
		||||
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
 | 
			
		||||
DETAILS_WIDGET_STYLE = "background: rgba(20,20,20,0.40); border-radius: 15px; padding: 10px;"
 | 
			
		||||
 | 
			
		||||
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
 | 
			
		||||
DETAIL_PAGE_TITLE_STYLE = "font-family: 'Orbitron'; font-size: 32px; color: #007AFF;"
 | 
			
		||||
 | 
			
		||||
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
 | 
			
		||||
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
 | 
			
		||||
 | 
			
		||||
# ТЕКСТ ОПИСАНИЯ
 | 
			
		||||
DETAIL_PAGE_DESC_STYLE = "font-family: 'Poppins'; font-size: 16px; color: #ffffff; line-height: 1.5;"
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ КНОПКИ "ИГРАТЬ"
 | 
			
		||||
PLAY_BUTTON_STYLE = """
 | 
			
		||||
    QPushButton {
 | 
			
		||||
        background: rgba(20, 20, 20, 0.40);
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
        border-radius: 10px;
 | 
			
		||||
        font-size: 18px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
        font-family: 'Orbitron';
 | 
			
		||||
        padding: 8px 16px;
 | 
			
		||||
        min-width: 120px;
 | 
			
		||||
        min-height: 40px;
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:hover {
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:pressed {
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ КНОПКИ "ОБЗОР..." В ДИАЛОГЕ "ДОБАВИТЬ ИГРУ"
 | 
			
		||||
DIALOG_BROWSE_BUTTON_STYLE = """
 | 
			
		||||
    QPushButton {
 | 
			
		||||
        background: rgba(20, 20, 20, 0.40);
 | 
			
		||||
        border: 0px solid rgba(255, 255, 255, 0.20);
 | 
			
		||||
        border-radius: 15px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        padding: 5px 10px;
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:hover {
 | 
			
		||||
        background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
 | 
			
		||||
            stop:0 rgba(0,122,255,0.20),
 | 
			
		||||
            stop:1 rgba(0,122,255,0.15));
 | 
			
		||||
    }
 | 
			
		||||
    QPushButton:pressed {
 | 
			
		||||
        background: rgba(20, 20, 20, 0.60);
 | 
			
		||||
        border: 0px solid rgba(255, 255, 255, 0.25);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
 | 
			
		||||
GAME_CARD_WINDOW_STYLE = """
 | 
			
		||||
    QFrame {
 | 
			
		||||
        border-radius: 20px;
 | 
			
		||||
        background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
 | 
			
		||||
            stop:0 rgba(255, 255, 255, 0.3),
 | 
			
		||||
            stop:1 rgba(249, 249, 249, 0.3));
 | 
			
		||||
        border: 0px solid rgba(255, 255, 255, 0.4);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
 | 
			
		||||
GAME_CARD_NAME_LABEL_STYLE = """
 | 
			
		||||
    QLabel {
 | 
			
		||||
        color: #333333;
 | 
			
		||||
        font-family: 'Orbitron';
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
        background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
 | 
			
		||||
            stop:0 rgba(242, 242, 242, 0.5),
 | 
			
		||||
            stop:1 rgba(232, 232, 232, 0.5));
 | 
			
		||||
        border-radius: 20px;
 | 
			
		||||
        padding: 7px;
 | 
			
		||||
        qproperty-wordWrap: true;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
 | 
			
		||||
LAST_LAUNCH_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
 | 
			
		||||
LAST_LAUNCH_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
 | 
			
		||||
PLAY_TIME_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
 | 
			
		||||
PLAY_TIME_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
 | 
			
		||||
GAMEPAD_SUPPORT_VALUE_STYLE = """
 | 
			
		||||
    font-family: 'Poppins'; font-size: 12px; color: #00ff00;
 | 
			
		||||
    font-weight: bold; background: rgba(0, 0, 0, 0.3);
 | 
			
		||||
    border-radius: 5px; padding: 4px 8px;
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
 | 
			
		||||
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
 | 
			
		||||
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
 | 
			
		||||
CAPTION_LABEL_STYLE="color: white; font-size: 16px;"
 | 
			
		||||
 | 
			
		||||
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
 | 
			
		||||
def get_protondb_badge_style(tier):
 | 
			
		||||
    tier = tier.lower()
 | 
			
		||||
    tier_colors = {
 | 
			
		||||
        "platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
 | 
			
		||||
        "gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
 | 
			
		||||
        "silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
 | 
			
		||||
        "bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
 | 
			
		||||
        "borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
 | 
			
		||||
        "pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
 | 
			
		||||
    }
 | 
			
		||||
    colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
 | 
			
		||||
    return f"""
 | 
			
		||||
        qproperty-alignment: AlignCenter;
 | 
			
		||||
        background-color: {colors["background"]};
 | 
			
		||||
        color: {colors["color"]};
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
def get_anticheat_badge_style(status):
 | 
			
		||||
    status = status.lower()
 | 
			
		||||
    status_colors = {
 | 
			
		||||
        "supported": {"background": "rgba(102, 168, 15, 0.7)", "color": "black"},
 | 
			
		||||
        "running": {"background": "rgba(25, 113, 194, 0.7)", "color": "black"},
 | 
			
		||||
        "planned": {"background": "rgba(156, 54, 181, 0.7)", "color": "black"},
 | 
			
		||||
        "broken": {"background": "rgba(232, 89, 12, 0.7)", "color": "black"},
 | 
			
		||||
        "denied": {"background": "rgba(224, 49, 49, 0.7)", "color": "black"}
 | 
			
		||||
    }
 | 
			
		||||
    colors = status_colors.get(status, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
 | 
			
		||||
    return f"""
 | 
			
		||||
        qproperty-alignment: AlignCenter;
 | 
			
		||||
        background-color: {colors["background"]};
 | 
			
		||||
        color: {colors["color"]};
 | 
			
		||||
        border-radius: 5px;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        font-weight: bold;
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
# СТИЛИ БЕЙДЖА STEAM
 | 
			
		||||
STEAM_BADGE_STYLE= """
 | 
			
		||||
    qproperty-alignment: AlignCenter;
 | 
			
		||||
    background: rgba(0, 0, 0, 0.5);
 | 
			
		||||
    color: white;
 | 
			
		||||
    border-radius: 5px;
 | 
			
		||||
    font-family: 'Poppins';
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# Favorite Star
 | 
			
		||||
FAVORITE_LABEL_STYLE = "color: gold; font-size: 32px; background: transparent; border: none;"
 | 
			
		||||
 | 
			
		||||
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
 | 
			
		||||
MESSAGE_BOX_STYLE = """
 | 
			
		||||
    QMessageBox {
 | 
			
		||||
        background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
 | 
			
		||||
            stop:0 rgba(40, 40, 40, 0.95),
 | 
			
		||||
            stop:1 rgba(25, 25, 25, 0.95));
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.15);
 | 
			
		||||
        border-radius: 12px;
 | 
			
		||||
    }
 | 
			
		||||
    QMessageBox QLabel {
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
    }
 | 
			
		||||
    QMessageBox QPushButton {
 | 
			
		||||
        background: rgba(30, 30, 30, 0.6);
 | 
			
		||||
        border: 1px solid rgba(165, 165, 165, 0.7);
 | 
			
		||||
        border-radius: 8px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        padding: 8px 20px;
 | 
			
		||||
        min-width: 80px;
 | 
			
		||||
    }
 | 
			
		||||
    QMessageBox QPushButton:hover {
 | 
			
		||||
        background: #09bec8;
 | 
			
		||||
        border-color: rgba(255, 255, 255, 0.3);
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
 | 
			
		||||
# PARAMS_TITLE_STYLE
 | 
			
		||||
PARAMS_TITLE_STYLE = "color: #232627; font-family: 'Poppins'; font-size: 16px; padding: 10px; background: transparent;"
 | 
			
		||||
 | 
			
		||||
PROXY_INPUT_STYLE = """
 | 
			
		||||
    QLineEdit {
 | 
			
		||||
        background: rgba(20, 20, 20, 0.40);
 | 
			
		||||
        border: 0px solid rgba(165, 165, 165, 0.7);
 | 
			
		||||
        border-radius: 10px;
 | 
			
		||||
        height: 34px;
 | 
			
		||||
        padding-left: 12px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
    }
 | 
			
		||||
    QLineEdit:focus {
 | 
			
		||||
        border: 1px solid rgba(0,122,255,0.25);
 | 
			
		||||
    }
 | 
			
		||||
    QMenu {
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
        padding: 5px 10px;
 | 
			
		||||
        background: #c7c7c7;
 | 
			
		||||
    }
 | 
			
		||||
    QMenu::item {
 | 
			
		||||
        padding: 0px 10px;
 | 
			
		||||
        border: 10px solid transparent; /* reserve space for selection border */
 | 
			
		||||
    }
 | 
			
		||||
    QMenu::item:selected {
 | 
			
		||||
        background: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
        border-radius: 10px;
 | 
			
		||||
    }
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
SETTINGS_COMBO_STYLE = f"""
 | 
			
		||||
    QComboBox {{
 | 
			
		||||
        background: rgba(20, 20, 20, 0.40);
 | 
			
		||||
        border: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
        border-radius: 10px;
 | 
			
		||||
        height: 34px;
 | 
			
		||||
        padding-left: 12px;
 | 
			
		||||
        color: #ffffff;
 | 
			
		||||
        font-family: 'Poppins';
 | 
			
		||||
        font-size: 16px;
 | 
			
		||||
        min-width: 120px;
 | 
			
		||||
        combobox-popup: 0;
 | 
			
		||||
    }}
 | 
			
		||||
    QComboBox:on {{
 | 
			
		||||
        background: rgba(20, 20, 20, 0.40);
 | 
			
		||||
        border: 1px solid rgba(165, 165, 165, 0.7);
 | 
			
		||||
        border-top-left-radius: 10px;
 | 
			
		||||
        border-top-right-radius: 10px;
 | 
			
		||||
        border-bottom-left-radius: 0px;
 | 
			
		||||
        border-bottom-right-radius: 0px;
 | 
			
		||||
    }}
 | 
			
		||||
    QComboBox:hover {{
 | 
			
		||||
        border: 1px solid rgba(165, 165, 165, 0.7);
 | 
			
		||||
    }}
 | 
			
		||||
    QComboBox::drop-down {{
 | 
			
		||||
        subcontrol-origin: padding;
 | 
			
		||||
        subcontrol-position: center right;
 | 
			
		||||
        border-left: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
        padding: 12px;
 | 
			
		||||
        height: 12px;
 | 
			
		||||
        width: 12px;
 | 
			
		||||
    }}
 | 
			
		||||
    QComboBox::down-arrow {{
 | 
			
		||||
        image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
 | 
			
		||||
        padding: 12px;
 | 
			
		||||
        height: 12px;
 | 
			
		||||
        width: 12px;
 | 
			
		||||
    }}
 | 
			
		||||
    QComboBox::down-arrow:on {{
 | 
			
		||||
        image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
 | 
			
		||||
        padding: 12px;
 | 
			
		||||
        height: 12px;
 | 
			
		||||
        width: 12px;
 | 
			
		||||
    }}
 | 
			
		||||
    QComboBox QAbstractItemView {{
 | 
			
		||||
        outline: none;
 | 
			
		||||
        border: 1px solid rgba(165, 165, 165, 0.7);
 | 
			
		||||
        border-top-style: none;
 | 
			
		||||
    }}
 | 
			
		||||
    QListView {{
 | 
			
		||||
        background: #ffffff;
 | 
			
		||||
    }}
 | 
			
		||||
    QListView::item {{
 | 
			
		||||
        padding: 7px 7px 7px 12px;
 | 
			
		||||
        border-radius: 0px;
 | 
			
		||||
        color: #232627;
 | 
			
		||||
    }}
 | 
			
		||||
    QListView::item:hover {{
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
    }}
 | 
			
		||||
    QListView::item:selected {{
 | 
			
		||||
        background: rgba(0,122,255,0.25);
 | 
			
		||||
    }}
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
class FileExplorerStyles:
 | 
			
		||||
    WINDOW_STYLE = """
 | 
			
		||||
        QDialog {
 | 
			
		||||
            background-color: #2d2d2d;
 | 
			
		||||
            color: #ffffff;
 | 
			
		||||
            font-family: "Arial";
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
        }
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    PATH_LABEL_STYLE = """
 | 
			
		||||
        QLabel {
 | 
			
		||||
            color: #3daee9;
 | 
			
		||||
            font-size: 16px;
 | 
			
		||||
            padding: 5px;
 | 
			
		||||
        }
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    LIST_STYLE = """
 | 
			
		||||
        QListWidget {
 | 
			
		||||
            font-size: 16px;
 | 
			
		||||
            background-color: #353535;
 | 
			
		||||
            color: #eee;
 | 
			
		||||
            border: 1px solid #444;
 | 
			
		||||
            border-radius: 4px;
 | 
			
		||||
        }
 | 
			
		||||
        QListWidget::item {
 | 
			
		||||
            padding: 8px;
 | 
			
		||||
            border-bottom: 1px solid #444;
 | 
			
		||||
        }
 | 
			
		||||
        QListWidget::item:selected {
 | 
			
		||||
            background-color: #3daee9;
 | 
			
		||||
            color: white;
 | 
			
		||||
            border-radius: 2px;
 | 
			
		||||
        }
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    BUTTON_STYLE = """
 | 
			
		||||
        QPushButton {
 | 
			
		||||
            background-color: #3daee9;
 | 
			
		||||
            color: white;
 | 
			
		||||
            border: none;
 | 
			
		||||
            padding: 8px 16px;
 | 
			
		||||
            border-radius: 4px;
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
        }
 | 
			
		||||
        QPushButton:hover {
 | 
			
		||||
            background-color: #2c9fd8;
 | 
			
		||||
        }
 | 
			
		||||
        QPushButton:pressed {
 | 
			
		||||
            background-color: #1a8fc7;
 | 
			
		||||
        }
 | 
			
		||||
    """
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB  | 
@@ -27,6 +27,10 @@ color_g = "rgba(0, 0, 0, 0)"
 | 
			
		||||
color_h = "transparent"
 | 
			
		||||
 | 
			
		||||
GAME_CARD_ANIMATION = {
 | 
			
		||||
    # Тип анимации при входе и выходе на детальную страницу
 | 
			
		||||
    # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
 | 
			
		||||
    "detail_page_animation_type": "fade",
 | 
			
		||||
 | 
			
		||||
    # Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
 | 
			
		||||
    # Влияет на толщину рамки вокруг карточки, когда она не выделена.
 | 
			
		||||
    # Значение в пикселях.
 | 
			
		||||
@@ -93,7 +97,33 @@ GAME_CARD_ANIMATION = {
 | 
			
		||||
        {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
 | 
			
		||||
        {"position": 0.66, "color": "#9B59B6"}, # Цвет на 66% (пурпурный)
 | 
			
		||||
        {"position": 1, "color": "#00fff5"}     # Конечный цвет (возвращение к циану)
 | 
			
		||||
    ]
 | 
			
		||||
    ],
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при входе на детальную страницу
 | 
			
		||||
    "detail_page_fade_duration": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при входе на детальную страницу
 | 
			
		||||
    "detail_page_slide_duration": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при входе на детальную страницу
 | 
			
		||||
    "detail_page_bounce_duration": 400,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации fade при выходе из детальной страницы
 | 
			
		||||
    "detail_page_fade_duration_exit": 350,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации slide при выходе из детальной страницы
 | 
			
		||||
    "detail_page_slide_duration_exit": 500,
 | 
			
		||||
 | 
			
		||||
    # Длительность анимации bounce при выходе из детальной страницы
 | 
			
		||||
    "detail_page_bounce_duration_exit": 400,
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при входе на детальную страницу
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve": "OutCubic",
 | 
			
		||||
 | 
			
		||||
    # Тип кривой сглаживания для анимации при выходе из детальной страницы
 | 
			
		||||
    # Применяется к slide и bounce анимациям
 | 
			
		||||
    "detail_page_easing_curve_exit": "InCubic"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
CONTEXT_MENU_STYLE = f"""
 | 
			
		||||
 
 | 
			
		||||
@@ -1,49 +0,0 @@
 | 
			
		||||
from PySide6.QtGui import QAction, QIcon
 | 
			
		||||
from PySide6.QtWidgets import QSystemTrayIcon, QMenu
 | 
			
		||||
from portprotonqt.theme_manager import ThemeManager
 | 
			
		||||
from typing import cast
 | 
			
		||||
import portprotonqt.themes.standart.styles as default_styles
 | 
			
		||||
from portprotonqt.config_utils import read_theme_from_config
 | 
			
		||||
 | 
			
		||||
class SystemTray:
 | 
			
		||||
    def __init__(self, app, theme=None):
 | 
			
		||||
        self.app = app
 | 
			
		||||
        self.theme_manager = ThemeManager()
 | 
			
		||||
        self.theme = theme if theme is not None else default_styles
 | 
			
		||||
        self.current_theme_name = read_theme_from_config()
 | 
			
		||||
        self.tray = QSystemTrayIcon()
 | 
			
		||||
        self.tray.setIcon(cast(QIcon, self.theme_manager.get_icon("ppqt-tray", self.current_theme_name)))
 | 
			
		||||
        self.tray.setToolTip("PortProtonQt")
 | 
			
		||||
        self.tray.setVisible(True)
 | 
			
		||||
 | 
			
		||||
        # Создаём меню
 | 
			
		||||
        self.menu = QMenu()
 | 
			
		||||
 | 
			
		||||
        self.hide_action = QAction("Скрыть окно")
 | 
			
		||||
        self.menu.addAction(self.hide_action)
 | 
			
		||||
 | 
			
		||||
        self.show_action = QAction("Показать окно")
 | 
			
		||||
        self.menu.addAction(self.show_action)
 | 
			
		||||
 | 
			
		||||
        self.quit_action = QAction("Выход")
 | 
			
		||||
        self.quit_action.triggered.connect(app.quit)
 | 
			
		||||
        self.menu.addAction(self.quit_action)
 | 
			
		||||
 | 
			
		||||
        self.tray.setContextMenu(self.menu)
 | 
			
		||||
 | 
			
		||||
    def hide_tray(self):
 | 
			
		||||
        """Скрыть иконку трея"""
 | 
			
		||||
        if self.tray:
 | 
			
		||||
            self.tray.setVisible(False)
 | 
			
		||||
            if self.menu:
 | 
			
		||||
                self.menu.deleteLater()
 | 
			
		||||
                self.menu = None
 | 
			
		||||
 | 
			
		||||
    def cleanup(self):
 | 
			
		||||
        """Очистка ресурсов трея"""
 | 
			
		||||
        if self.tray:
 | 
			
		||||
            self.tray.setVisible(False)
 | 
			
		||||
            self.tray = None
 | 
			
		||||
        if self.menu:
 | 
			
		||||
            self.menu.deleteLater()
 | 
			
		||||
            self.menu = None
 | 
			
		||||
@@ -28,17 +28,18 @@ requires-python = ">=3.10"
 | 
			
		||||
dependencies = [
 | 
			
		||||
    "babel>=2.17.0",
 | 
			
		||||
    "beautifulsoup4>=4.13.4",
 | 
			
		||||
    "evdev>=1.9.1",
 | 
			
		||||
    "icoextract>=0.1.6",
 | 
			
		||||
    "evdev>=1.9.2",
 | 
			
		||||
    "icoextract>=0.2.0",
 | 
			
		||||
    "numpy>=2.2.4",
 | 
			
		||||
    "orjson>=3.10.16",
 | 
			
		||||
    "pillow>=11.2.1",
 | 
			
		||||
    "orjson>=3.11.2",
 | 
			
		||||
    "pillow>=11.3.0",
 | 
			
		||||
    "psutil>=7.0.0",
 | 
			
		||||
    "pyside6>=6.9.0",
 | 
			
		||||
    "pyside6>=6.9.1",
 | 
			
		||||
    "pyudev>=0.24.3",
 | 
			
		||||
    "requests>=2.32.3",
 | 
			
		||||
    "requests>=2.32.4",
 | 
			
		||||
    "tqdm>=4.67.1",
 | 
			
		||||
    "vdf>=3.4",
 | 
			
		||||
    "websocket-client>=1.8.0",
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[project.scripts]
 | 
			
		||||
@@ -102,7 +103,7 @@ ignore = [
 | 
			
		||||
 | 
			
		||||
[dependency-groups]
 | 
			
		||||
dev = [
 | 
			
		||||
    "pre-commit>=4.2.0",
 | 
			
		||||
    "pre-commit>=4.3.0",
 | 
			
		||||
    "pyaspeller>=2.0.2",
 | 
			
		||||
    "pyright>=1.1.400",
 | 
			
		||||
    "pyright>=1.1.403",
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||
@@ -15,12 +15,23 @@
 | 
			
		||||
      "enabled": false
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchFileNames": [".gitea/workflows/**.yaml", ".gitea/workflows/**.yml"],
 | 
			
		||||
      "matchFileNames": [".python-version"],
 | 
			
		||||
      "enabled": false
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchFileNames": [".python-version"],
 | 
			
		||||
      "matchManagers": ["github-actions", "pre-commit"],
 | 
			
		||||
      "enabled": false
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchManagers": ["pep621"],
 | 
			
		||||
      "rangeStrategy": "bump",
 | 
			
		||||
      "versioning": "pep440",
 | 
			
		||||
      "groupName": "Python dependencies"
 | 
			
		||||
    },
 | 
			
		||||
    {
 | 
			
		||||
      "matchPackageNames": ["numpy", "setuptools"],
 | 
			
		||||
      "enabled": false,
 | 
			
		||||
      "description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
 | 
			
		||||
    }
 | 
			
		||||
  ]
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										337
									
								
								uv.lock
									
									
									
										generated
									
									
									
								
							
							
						
						@@ -304,68 +304,79 @@ wheels = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "orjson"
 | 
			
		||||
version = "3.10.18"
 | 
			
		||||
version = "3.11.2"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/81/0b/fea456a3ffe74e70ba30e01ec183a9b26bec4d497f61dcfce1b601059c60/orjson-3.10.18.tar.gz", hash = "sha256:e8da3947d92123eda795b68228cafe2724815621fe35e8e320a9e9593a4bcd53", size = 5422810 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/df/1d/5e0ae38788bdf0721326695e65fdf41405ed535f633eb0df0f06f57552fa/orjson-3.11.2.tar.gz", hash = "sha256:91bdcf5e69a8fd8e8bdb3de32b31ff01d2bd60c1e8d5fe7d5afabdcf19920309", size = 5470739 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/27/16/2ceb9fb7bc2b11b1e4a3ea27794256e93dee2309ebe297fd131a778cd150/orjson-3.10.18-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a45e5d68066b408e4bc383b6e4ef05e717c65219a9e1390abc6155a520cac402", size = 248927 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3d/e1/d3c0a2bba5b9906badd121da449295062b289236c39c3a7801f92c4682b0/orjson-3.10.18-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be3b9b143e8b9db05368b13b04c84d37544ec85bb97237b3a923f076265ec89c", size = 136995 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d7/51/698dd65e94f153ee5ecb2586c89702c9e9d12f165a63e74eb9ea1299f4e1/orjson-3.10.18-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9b0aa09745e2c9b3bf779b096fa71d1cc2d801a604ef6dd79c8b1bfef52b2f92", size = 132893 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b3/e5/155ce5a2c43a85e790fcf8b985400138ce5369f24ee6770378ee6b691036/orjson-3.10.18-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53a245c104d2792e65c8d225158f2b8262749ffe64bc7755b00024757d957a13", size = 137017 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/46/bb/6141ec3beac3125c0b07375aee01b5124989907d61c72c7636136e4bd03e/orjson-3.10.18-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f9495ab2611b7f8a0a8a505bcb0f0cbdb5469caafe17b0e404c3c746f9900469", size = 138290 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/77/36/6961eca0b66b7809d33c4ca58c6bd4c23a1b914fb23aba2fa2883f791434/orjson-3.10.18-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:73be1cbcebadeabdbc468f82b087df435843c809cd079a565fb16f0f3b23238f", size = 142828 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/8b/2f/0c646d5fd689d3be94f4d83fa9435a6c4322c9b8533edbb3cd4bc8c5f69a/orjson-3.10.18-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe8936ee2679e38903df158037a2f1c108129dee218975122e37847fb1d4ac68", size = 132806 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ea/af/65907b40c74ef4c3674ef2bcfa311c695eb934710459841b3c2da212215c/orjson-3.10.18-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7115fcbc8525c74e4c2b608129bef740198e9a120ae46184dac7683191042056", size = 135005 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c7/d1/68bd20ac6a32cd1f1b10d23e7cc58ee1e730e80624e3031d77067d7150fc/orjson-3.10.18-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:771474ad34c66bc4d1c01f645f150048030694ea5b2709b87d3bda273ffe505d", size = 413418 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/31/31/c701ec0bcc3e80e5cb6e319c628ef7b768aaa24b0f3b4c599df2eaacfa24/orjson-3.10.18-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:7c14047dbbea52886dd87169f21939af5d55143dad22d10db6a7514f058156a8", size = 153288 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d9/31/5e1aa99a10893a43cfc58009f9da840990cc8a9ebb75aa452210ba18587e/orjson-3.10.18-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:641481b73baec8db14fdf58f8967e52dc8bda1f2aba3aa5f5c1b07ed6df50b7f", size = 137181 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/bf/8c/daba0ac1b8690011d9242a0f37235f7d17df6d0ad941021048523b76674e/orjson-3.10.18-cp310-cp310-win32.whl", hash = "sha256:607eb3ae0909d47280c1fc657c4284c34b785bae371d007595633f4b1a2bbe06", size = 142694 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/16/62/8b687724143286b63e1d0fab3ad4214d54566d80b0ba9d67c26aaf28a2f8/orjson-3.10.18-cp310-cp310-win_amd64.whl", hash = "sha256:8770432524ce0eca50b7efc2a9a5f486ee0113a5fbb4231526d414e6254eba92", size = 134600 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/97/c7/c54a948ce9a4278794f669a353551ce7db4ffb656c69a6e1f2264d563e50/orjson-3.10.18-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:e0a183ac3b8e40471e8d843105da6fbe7c070faab023be3b08188ee3f85719b8", size = 248929 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/9e/60/a9c674ef1dd8ab22b5b10f9300e7e70444d4e3cda4b8258d6c2488c32143/orjson-3.10.18-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:5ef7c164d9174362f85238d0cd4afdeeb89d9e523e4651add6a5d458d6f7d42d", size = 133364 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c1/4e/f7d1bdd983082216e414e6d7ef897b0c2957f99c545826c06f371d52337e/orjson-3.10.18-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd14c5d99cdc7bf93f22b12ec3b294931518aa019e2a147e8aa2f31fd3240f7", size = 136995 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/17/89/46b9181ba0ea251c9243b0c8ce29ff7c9796fa943806a9c8b02592fce8ea/orjson-3.10.18-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7b672502323b6cd133c4af6b79e3bea36bad2d16bca6c1f645903fce83909a7a", size = 132894 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ca/dd/7bce6fcc5b8c21aef59ba3c67f2166f0a1a9b0317dcca4a9d5bd7934ecfd/orjson-3.10.18-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:51f8c63be6e070ec894c629186b1c0fe798662b8687f3d9fdfa5e401c6bd7679", size = 137016 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1c/4a/b8aea1c83af805dcd31c1f03c95aabb3e19a016b2a4645dd822c5686e94d/orjson-3.10.18-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f9478ade5313d724e0495d167083c6f3be0dd2f1c9c8a38db9a9e912cdaf947", size = 138290 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/36/d6/7eb05c85d987b688707f45dcf83c91abc2251e0dd9fb4f7be96514f838b1/orjson-3.10.18-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:187aefa562300a9d382b4b4eb9694806e5848b0cedf52037bb5c228c61bb66d4", size = 142829 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d2/78/ddd3ee7873f2b5f90f016bc04062713d567435c53ecc8783aab3a4d34915/orjson-3.10.18-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9da552683bc9da222379c7a01779bddd0ad39dd699dd6300abaf43eadee38334", size = 132805 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/8c/09/c8e047f73d2c5d21ead9c180203e111cddeffc0848d5f0f974e346e21c8e/orjson-3.10.18-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e450885f7b47a0231979d9c49b567ed1c4e9f69240804621be87c40bc9d3cf17", size = 135008 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/0c/4b/dccbf5055ef8fb6eda542ab271955fc1f9bf0b941a058490293f8811122b/orjson-3.10.18-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5e3c9cc2ba324187cd06287ca24f65528f16dfc80add48dc99fa6c836bb3137e", size = 413419 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/8a/f3/1eac0c5e2d6d6790bd2025ebfbefcbd37f0d097103d76f9b3f9302af5a17/orjson-3.10.18-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:50ce016233ac4bfd843ac5471e232b865271d7d9d44cf9d33773bcd883ce442b", size = 153292 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1f/b4/ef0abf64c8f1fabf98791819ab502c2c8c1dc48b786646533a93637d8999/orjson-3.10.18-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b3ceff74a8f7ffde0b2785ca749fc4e80e4315c0fd887561144059fb1c138aa7", size = 137182 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a9/a3/6ea878e7b4a0dc5c888d0370d7752dcb23f402747d10e2257478d69b5e63/orjson-3.10.18-cp311-cp311-win32.whl", hash = "sha256:fdba703c722bd868c04702cac4cb8c6b8ff137af2623bc0ddb3b3e6a2c8996c1", size = 142695 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/79/2a/4048700a3233d562f0e90d5572a849baa18ae4e5ce4c3ba6247e4ece57b0/orjson-3.10.18-cp311-cp311-win_amd64.whl", hash = "sha256:c28082933c71ff4bc6ccc82a454a2bffcef6e1d7379756ca567c772e4fb3278a", size = 134603 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/03/45/10d934535a4993d27e1c84f1810e79ccf8b1b7418cef12151a22fe9bb1e1/orjson-3.10.18-cp311-cp311-win_arm64.whl", hash = "sha256:a6c7c391beaedd3fa63206e5c2b7b554196f14debf1ec9deb54b5d279b1b46f5", size = 131400 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/21/1a/67236da0916c1a192d5f4ccbe10ec495367a726996ceb7614eaa687112f2/orjson-3.10.18-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:50c15557afb7f6d63bc6d6348e0337a880a04eaa9cd7c9d569bcb4e760a24753", size = 249184 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b3/bc/c7f1db3b1d094dc0c6c83ed16b161a16c214aaa77f311118a93f647b32dc/orjson-3.10.18-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:356b076f1662c9813d5fa56db7d63ccceef4c271b1fb3dd522aca291375fcf17", size = 133279 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/af/84/664657cd14cc11f0d81e80e64766c7ba5c9b7fc1ec304117878cc1b4659c/orjson-3.10.18-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:559eb40a70a7494cd5beab2d73657262a74a2c59aff2068fdba8f0424ec5b39d", size = 136799 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/9a/bb/f50039c5bb05a7ab024ed43ba25d0319e8722a0ac3babb0807e543349978/orjson-3.10.18-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3c29eb9a81e2fbc6fd7ddcfba3e101ba92eaff455b8d602bf7511088bbc0eae", size = 132791 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/93/8c/ee74709fc072c3ee219784173ddfe46f699598a1723d9d49cbc78d66df65/orjson-3.10.18-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6612787e5b0756a171c7d81ba245ef63a3533a637c335aa7fcb8e665f4a0966f", size = 137059 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6a/37/e6d3109ee004296c80426b5a62b47bcadd96a3deab7443e56507823588c5/orjson-3.10.18-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7ac6bd7be0dcab5b702c9d43d25e70eb456dfd2e119d512447468f6405b4a69c", size = 138359 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/4f/5d/387dafae0e4691857c62bd02839a3bf3fa648eebd26185adfac58d09f207/orjson-3.10.18-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f72f100cee8dde70100406d5c1abba515a7df926d4ed81e20a9730c062fe9ad", size = 142853 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/27/6f/875e8e282105350b9a5341c0222a13419758545ae32ad6e0fcf5f64d76aa/orjson-3.10.18-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9dca85398d6d093dd41dc0983cbf54ab8e6afd1c547b6b8a311643917fbf4e0c", size = 133131 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/48/b2/73a1f0b4790dcb1e5a45f058f4f5dcadc8a85d90137b50d6bbc6afd0ae50/orjson-3.10.18-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:22748de2a07fcc8781a70edb887abf801bb6142e6236123ff93d12d92db3d406", size = 134834 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/56/f5/7ed133a5525add9c14dbdf17d011dd82206ca6840811d32ac52a35935d19/orjson-3.10.18-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:3a83c9954a4107b9acd10291b7f12a6b29e35e8d43a414799906ea10e75438e6", size = 413368 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/11/7c/439654221ed9c3324bbac7bdf94cf06a971206b7b62327f11a52544e4982/orjson-3.10.18-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:303565c67a6c7b1f194c94632a4a39918e067bd6176a48bec697393865ce4f06", size = 153359 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/48/e7/d58074fa0cc9dd29a8fa2a6c8d5deebdfd82c6cfef72b0e4277c4017563a/orjson-3.10.18-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:86314fdb5053a2f5a5d881f03fca0219bfdf832912aa88d18676a5175c6916b5", size = 137466 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/57/4d/fe17581cf81fb70dfcef44e966aa4003360e4194d15a3f38cbffe873333a/orjson-3.10.18-cp312-cp312-win32.whl", hash = "sha256:187ec33bbec58c76dbd4066340067d9ece6e10067bb0cc074a21ae3300caa84e", size = 142683 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e6/22/469f62d25ab5f0f3aee256ea732e72dc3aab6d73bac777bd6277955bceef/orjson-3.10.18-cp312-cp312-win_amd64.whl", hash = "sha256:f9f94cf6d3f9cd720d641f8399e390e7411487e493962213390d1ae45c7814fc", size = 134754 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/10/b0/1040c447fac5b91bc1e9c004b69ee50abb0c1ffd0d24406e1350c58a7fcb/orjson-3.10.18-cp312-cp312-win_arm64.whl", hash = "sha256:3d600be83fe4514944500fa8c2a0a77099025ec6482e8087d7659e891f23058a", size = 131218 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/04/f0/8aedb6574b68096f3be8f74c0b56d36fd94bcf47e6c7ed47a7bd1474aaa8/orjson-3.10.18-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:69c34b9441b863175cc6a01f2935de994025e773f814412030f269da4f7be147", size = 249087 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/bc/f7/7118f965541aeac6844fcb18d6988e111ac0d349c9b80cda53583e758908/orjson-3.10.18-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:1ebeda919725f9dbdb269f59bc94f861afbe2a27dce5608cdba2d92772364d1c", size = 133273 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fb/d9/839637cc06eaf528dd8127b36004247bf56e064501f68df9ee6fd56a88ee/orjson-3.10.18-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5adf5f4eed520a4959d29ea80192fa626ab9a20b2ea13f8f6dc58644f6927103", size = 136779 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/2b/6d/f226ecfef31a1f0e7d6bf9a31a0bbaf384c7cbe3fce49cc9c2acc51f902a/orjson-3.10.18-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7592bb48a214e18cd670974f289520f12b7aed1fa0b2e2616b8ed9e069e08595", size = 132811 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/73/2d/371513d04143c85b681cf8f3bce743656eb5b640cb1f461dad750ac4b4d4/orjson-3.10.18-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f872bef9f042734110642b7a11937440797ace8c87527de25e0c53558b579ccc", size = 137018 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/69/cb/a4d37a30507b7a59bdc484e4a3253c8141bf756d4e13fcc1da760a0b00cb/orjson-3.10.18-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0315317601149c244cb3ecef246ef5861a64824ccbcb8018d32c66a60a84ffbc", size = 138368 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1e/ae/cd10883c48d912d216d541eb3db8b2433415fde67f620afe6f311f5cd2ca/orjson-3.10.18-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e0da26957e77e9e55a6c2ce2e7182a36a6f6b180ab7189315cb0995ec362e049", size = 142840 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6d/4c/2bda09855c6b5f2c055034c9eda1529967b042ff8d81a05005115c4e6772/orjson-3.10.18-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bb70d489bc79b7519e5803e2cc4c72343c9dc1154258adf2f8925d0b60da7c58", size = 133135 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/13/4a/35971fd809a8896731930a80dfff0b8ff48eeb5d8b57bb4d0d525160017f/orjson-3.10.18-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e9e86a6af31b92299b00736c89caf63816f70a4001e750bda179e15564d7a034", size = 134810 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/99/70/0fa9e6310cda98365629182486ff37a1c6578e34c33992df271a476ea1cd/orjson-3.10.18-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c382a5c0b5931a5fc5405053d36c1ce3fd561694738626c77ae0b1dfc0242ca1", size = 413491 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/32/cb/990a0e88498babddb74fb97855ae4fbd22a82960e9b06eab5775cac435da/orjson-3.10.18-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8e4b2ae732431127171b875cb2668f883e1234711d3c147ffd69fe5be51a8012", size = 153277 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/92/44/473248c3305bf782a384ed50dd8bc2d3cde1543d107138fd99b707480ca1/orjson-3.10.18-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d808e34ddb24fc29a4d4041dcfafbae13e129c93509b847b14432717d94b44f", size = 137367 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ad/fd/7f1d3edd4ffcd944a6a40e9f88af2197b619c931ac4d3cfba4798d4d3815/orjson-3.10.18-cp313-cp313-win32.whl", hash = "sha256:ad8eacbb5d904d5591f27dee4031e2c1db43d559edb8f91778efd642d70e6bea", size = 142687 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/4b/03/c75c6ad46be41c16f4cfe0352a2d1450546f3c09ad2c9d341110cd87b025/orjson-3.10.18-cp313-cp313-win_amd64.whl", hash = "sha256:aed411bcb68bf62e85588f2a7e03a6082cc42e5a2796e06e72a962d7c6310b52", size = 134794 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c2/28/f53038a5a72cc4fd0b56c1eafb4ef64aec9685460d5ac34de98ca78b6e29/orjson-3.10.18-cp313-cp313-win_arm64.whl", hash = "sha256:f54c1385a0e6aba2f15a40d703b858bedad36ded0491e55d35d905b2c34a4cc3", size = 131186 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a1/7b/7aebe925c6b1c46c8606a960fe1d6b681fccd4aaf3f37cd647c3309d6582/orjson-3.11.2-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:d6b8a78c33496230a60dc9487118c284c15ebdf6724386057239641e1eb69761", size = 226896 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/7d/39/c952c9b0d51063e808117dd1e53668a2e4325cc63cfe7df453d853ee8680/orjson-3.11.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc04036eeae11ad4180d1f7b5faddb5dab1dee49ecd147cd431523869514873b", size = 111845 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f5/dc/90b7f29be38745eeacc30903b693f29fcc1097db0c2a19a71ffb3e9f2a5f/orjson-3.11.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9c04325839c5754c253ff301cee8aaed7442d974860a44447bb3be785c411c27", size = 116395 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/10/c2/fe84ba63164c22932b8d59b8810e2e58590105293a259e6dd1bfaf3422c9/orjson-3.11.2-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:32769e04cd7fdc4a59854376211145a1bbbc0aea5e9d6c9755d3d3c301d7c0df", size = 118768 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a9/ce/d9748ec69b1a4c29b8e2bab8233e8c41c583c69f515b373f1fb00247d8c9/orjson-3.11.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff285d14917ea1408a821786e3677c5261fa6095277410409c694b8e7720ae0", size = 120887 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c1/66/b90fac8e4a76e83f981912d7f9524d402b31f6c1b8bff3e498aa321c326c/orjson-3.11.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2662f908114864b63ff75ffe6ffacf996418dd6cc25e02a72ad4bda81b1ec45a", size = 123650 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/33/81/56143898d1689c7f915ac67703efb97e8f2f8d5805ce8c2c3fd0f2bb6e3d/orjson-3.11.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ab463cf5d08ad6623a4dac1badd20e88a5eb4b840050c4812c782e3149fe2334", size = 121287 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/80/de/f9c6d00c127be766a3739d0d85b52a7c941e437d8dd4d573e03e98d0f89c/orjson-3.11.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:64414241bde943cbf3c00d45fcb5223dca6d9210148ba984aae6b5d63294502b", size = 119637 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/67/4c/ab70c7627022d395c1b4eb5badf6196b7144e82b46a3a17ed2354f9e592d/orjson-3.11.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:7773e71c0ae8c9660192ff144a3d69df89725325e3d0b6a6bb2c50e5ebaf9b84", size = 392478 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/77/91/d890b873b69311db4fae2624c5603c437df9c857fb061e97706dac550a77/orjson-3.11.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:652ca14e283b13ece35bf3a86503c25592f294dbcfc5bb91b20a9c9a62a3d4be", size = 134343 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/47/16/1aa248541b4830274a079c4aeb2aa5d1ff17c3f013b1d0d8d16d0848f3de/orjson-3.11.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:26e99e98df8990ecfe3772bbdd7361f602149715c2cbc82e61af89bfad9528a4", size = 123887 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/95/e4/7419833c55ac8b5f385d00c02685a260da1f391e900fc5c3e0b797e0d506/orjson-3.11.2-cp310-cp310-win32.whl", hash = "sha256:5814313b3e75a2be7fe6c7958201c16c4560e21a813dbad25920752cecd6ad66", size = 124560 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/74/f8/27ca7ef3e194c462af32ce1883187f5ec483650c559166f0de59c4c2c5f0/orjson-3.11.2-cp310-cp310-win_amd64.whl", hash = "sha256:dc471ce2225ab4c42ca672f70600d46a8b8e28e8d4e536088c1ccdb1d22b35ce", size = 119700 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/78/7d/e295df1ac9920cbb19fb4c1afa800e86f175cb657143aa422337270a4782/orjson-3.11.2-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:888b64ef7eaeeff63f773881929434a5834a6a140a63ad45183d59287f07fc6a", size = 226502 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/65/21/ffb0f10ea04caf418fb4e7ad1fda4b9ab3179df9d7a33b69420f191aadd5/orjson-3.11.2-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:83387cc8b26c9fa0ae34d1ea8861a7ae6cff8fb3e346ab53e987d085315a728e", size = 115999 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/90/d5/8da1e252ac3353d92e6f754ee0c85027c8a2cda90b6899da2be0df3ef83d/orjson-3.11.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7e35f003692c216d7ee901b6b916b5734d6fc4180fcaa44c52081f974c08e17", size = 111563 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/4f/81/baabc32e52c570b0e4e1044b1bd2ccbec965e0de3ba2c13082255efa2006/orjson-3.11.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4a0a4c29ae90b11d0c00bcc31533854d89f77bde2649ec602f512a7e16e00640", size = 116222 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/8d/b7/da2ad55ad80b49b560dce894c961477d0e76811ee6e614b301de9f2f8728/orjson-3.11.2-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:585d712b1880f68370108bc5534a257b561672d1592fae54938738fe7f6f1e33", size = 118594 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/61/be/014f7eab51449f3c894aa9bbda2707b5340c85650cb7d0db4ec9ae280501/orjson-3.11.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d08e342a7143f8a7c11f1c4033efe81acbd3c98c68ba1b26b96080396019701f", size = 120700 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cf/ae/c217903a30c51341868e2d8c318c59a8413baa35af54d7845071c8ccd6fe/orjson-3.11.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:29c0f84fc50398773a702732c87cd622737bf11c0721e6db3041ac7802a686fb", size = 123433 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/57/c2/b3c346f78b1ff2da310dd300cb0f5d32167f872b4d3bb1ad122c889d97b0/orjson-3.11.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:140f84e3c8d4c142575898c91e3981000afebf0333df753a90b3435d349a5fe5", size = 121061 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/00/c8/c97798f6010327ffc75ad21dd6bca11ea2067d1910777e798c2849f1c68f/orjson-3.11.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96304a2b7235e0f3f2d9363ddccdbfb027d27338722fe469fe656832a017602e", size = 119410 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/37/fd/df720f7c0e35694617b7f95598b11a2cb0374661d8389703bea17217da53/orjson-3.11.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d7612bb227d5d9582f1f50a60bd55c64618fc22c4a32825d233a4f2771a428a", size = 392294 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ba/52/0120d18f60ab0fe47531d520372b528a45c9a25dcab500f450374421881c/orjson-3.11.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:a134587d18fe493befc2defffef2a8d27cfcada5696cb7234de54a21903ae89a", size = 134134 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ec/10/1f967671966598366de42f07e92b0fc694ffc66eafa4b74131aeca84915f/orjson-3.11.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0b84455e60c4bc12c1e4cbaa5cfc1acdc7775a9da9cec040e17232f4b05458bd", size = 123745 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/43/eb/76081238671461cfd0f47e0c24f408ffa66184237d56ef18c33e86abb612/orjson-3.11.2-cp311-cp311-win32.whl", hash = "sha256:f0660efeac223f0731a70884e6914a5f04d613b5ae500744c43f7bf7b78f00f9", size = 124393 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/26/76/cc598c1811ba9ba935171267b02e377fc9177489efce525d478a2999d9cc/orjson-3.11.2-cp311-cp311-win_amd64.whl", hash = "sha256:955811c8405251d9e09cbe8606ad8fdef49a451bcf5520095a5ed38c669223d8", size = 119561 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d8/17/c48011750f0489006f7617b0a3cebc8230f36d11a34e7e9aca2085f07792/orjson-3.11.2-cp311-cp311-win_arm64.whl", hash = "sha256:2e4d423a6f838552e3a6d9ec734b729f61f88b1124fd697eab82805ea1a2a97d", size = 114186 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/40/02/46054ebe7996a8adee9640dcad7d39d76c2000dc0377efa38e55dc5cbf78/orjson-3.11.2-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:901d80d349d8452162b3aa1afb82cec5bee79a10550660bc21311cc61a4c5486", size = 226528 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e2/c6/6b6f0b4d8aea1137436546b990f71be2cd8bd870aa2f5aa14dba0fcc95dc/orjson-3.11.2-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:cf3bd3967a360e87ee14ed82cb258b7f18c710dacf3822fb0042a14313a673a1", size = 115931 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ae/05/4205cc97c30e82a293dd0d149b1a89b138ebe76afeca66fc129fa2aa4e6a/orjson-3.11.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26693dde66910078229a943e80eeb99fdce6cd2c26277dc80ead9f3ab97d2131", size = 111382 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/50/c7/b8a951a93caa821f9272a7c917115d825ae2e4e8768f5ddf37968ec9de01/orjson-3.11.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4ad4c8acb50a28211c33fc7ef85ddf5cb18d4636a5205fd3fa2dce0411a0e30c", size = 116271 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/17/03/1006c7f8782d5327439e26d9b0ec66500ea7b679d4bbb6b891d2834ab3ee/orjson-3.11.2-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:994181e7f1725bb5f2d481d7d228738e0743b16bf319ca85c29369c65913df14", size = 119086 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/44/61/57d22bc31f36a93878a6f772aea76b2184102c6993dea897656a66d18c74/orjson-3.11.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dbb79a0476393c07656b69c8e763c3cc925fa8e1d9e9b7d1f626901bb5025448", size = 120724 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/78/a9/4550e96b4c490c83aea697d5347b8f7eb188152cd7b5a38001055ca5b379/orjson-3.11.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:191ed27a1dddb305083d8716af413d7219f40ec1d4c9b0e977453b4db0d6fb6c", size = 123577 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3a/86/09b8cb3ebd513d708ef0c92d36ac3eebda814c65c72137b0a82d6d688fc4/orjson-3.11.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0afb89f16f07220183fd00f5f297328ed0a68d8722ad1b0c8dcd95b12bc82804", size = 121195 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/37/68/7b40b39ac2c1c644d4644e706d0de6c9999764341cd85f2a9393cb387661/orjson-3.11.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6ab6e6b4e93b1573a026b6ec16fca9541354dd58e514b62c558b58554ae04307", size = 119234 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/40/7c/bb6e7267cd80c19023d44d8cbc4ea4ed5429fcd4a7eb9950f50305697a28/orjson-3.11.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:9cb23527efb61fb75527df55d20ee47989c4ee34e01a9c98ee9ede232abf6219", size = 392250 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/64/f2/6730ace05583dbca7c1b406d59f4266e48cd0d360566e71482420fb849fc/orjson-3.11.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a4dd1268e4035af21b8a09e4adf2e61f87ee7bf63b86d7bb0a237ac03fad5b45", size = 134572 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/96/0f/7d3e03a30d5aac0432882b539a65b8c02cb6dd4221ddb893babf09c424cc/orjson-3.11.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ff8b155b145eaf5a9d94d2c476fbe18d6021de93cf36c2ae2c8c5b775763f14e", size = 123869 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/45/80/1513265eba6d4a960f078f4b1d2bff94a571ab2d28c6f9835e03dfc65cc6/orjson-3.11.2-cp312-cp312-win32.whl", hash = "sha256:ae3bb10279d57872f9aba68c9931aa71ed3b295fa880f25e68da79e79453f46e", size = 124430 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fb/61/eadf057b68a332351eeb3d89a4cc538d14f31cd8b5ec1b31a280426ccca2/orjson-3.11.2-cp312-cp312-win_amd64.whl", hash = "sha256:d026e1967239ec11a2559b4146a61d13914504b396f74510a1c4d6b19dfd8732", size = 119598 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6b/3f/7f4b783402143d965ab7e9a2fc116fdb887fe53bdce7d3523271cd106098/orjson-3.11.2-cp312-cp312-win_arm64.whl", hash = "sha256:59f8d5ad08602711af9589375be98477d70e1d102645430b5a7985fdbf613b36", size = 114052 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c2/f3/0dd6b4750eb556ae4e2c6a9cb3e219ec642e9c6d95f8ebe5dc9020c67204/orjson-3.11.2-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a079fdba7062ab396380eeedb589afb81dc6683f07f528a03b6f7aae420a0219", size = 226419 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/44/d5/e67f36277f78f2af8a4690e0c54da6b34169812f807fd1b4bfc4dbcf9558/orjson-3.11.2-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:6a5f62ebbc530bb8bb4b1ead103647b395ba523559149b91a6c545f7cd4110ad", size = 115803 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/24/37/ff8bc86e0dacc48f07c2b6e20852f230bf4435611bab65e3feae2b61f0ae/orjson-3.11.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7df6c7b8b0931feb3420b72838c3e2ba98c228f7aa60d461bc050cf4ca5f7b2", size = 111337 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b9/25/37d4d3e8079ea9784ea1625029988e7f4594ce50d4738b0c1e2bf4a9e201/orjson-3.11.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6f59dfea7da1fced6e782bb3699718088b1036cb361f36c6e4dd843c5111aefe", size = 116222 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b7/32/a63fd9c07fce3b4193dcc1afced5dd4b0f3a24e27556604e9482b32189c9/orjson-3.11.2-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edf49146520fef308c31aa4c45b9925fd9c7584645caca7c0c4217d7900214ae", size = 119020 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b4/b6/400792b8adc3079a6b5d649264a3224d6342436d9fac9a0ed4abc9dc4596/orjson-3.11.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:50995bbeb5d41a32ad15e023305807f561ac5dcd9bd41a12c8d8d1d2c83e44e6", size = 120721 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/40/f3/31ab8f8c699eb9e65af8907889a0b7fef74c1d2b23832719a35da7bb0c58/orjson-3.11.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cc42960515076eb639b705f105712b658c525863d89a1704d984b929b0577d1", size = 123574 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/bd/a6/ce4287c412dff81878f38d06d2c80845709c60012ca8daf861cb064b4574/orjson-3.11.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c56777cab2a7b2a8ea687fedafb84b3d7fdafae382165c31a2adf88634c432fa", size = 121225 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/69/b0/7a881b2aef4fed0287d2a4fbb029d01ed84fa52b4a68da82bdee5e50598e/orjson-3.11.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07349e88025b9b5c783077bf7a9f401ffbfb07fd20e86ec6fc5b7432c28c2c5e", size = 119201 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cf/98/a325726b37f7512ed6338e5e65035c3c6505f4e628b09a5daf0419f054ea/orjson-3.11.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:45841fbb79c96441a8c58aa29ffef570c5df9af91f0f7a9572e5505e12412f15", size = 392193 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cb/4f/a7194f98b0ce1d28190e0c4caa6d091a3fc8d0107ad2209f75c8ba398984/orjson-3.11.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13d8d8db6cd8d89d4d4e0f4161acbbb373a4d2a4929e862d1d2119de4aa324ac", size = 134548 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e8/5e/b84caa2986c3f472dc56343ddb0167797a708a8d5c3be043e1e2677b55df/orjson-3.11.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51da1ee2178ed09c00d09c1b953e45846bbc16b6420965eb7a913ba209f606d8", size = 123798 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/9c/5b/e398449080ce6b4c8fcadad57e51fa16f65768e1b142ba90b23ac5d10801/orjson-3.11.2-cp313-cp313-win32.whl", hash = "sha256:51dc033df2e4a4c91c0ba4f43247de99b3cbf42ee7a42ee2b2b2f76c8b2f2cb5", size = 124402 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b3/66/429e4608e124debfc4790bfc37131f6958e59510ba3b542d5fc163be8e5f/orjson-3.11.2-cp313-cp313-win_amd64.whl", hash = "sha256:29d91d74942b7436f29b5d1ed9bcfc3f6ef2d4f7c4997616509004679936650d", size = 119498 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/7b/04/f8b5f317cce7ad3580a9ad12d7e2df0714dfa8a83328ecddd367af802f5b/orjson-3.11.2-cp313-cp313-win_arm64.whl", hash = "sha256:4ca4fb5ac21cd1e48028d4f708b1bb13e39c42d45614befd2ead004a8bba8535", size = 114051 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/74/83/2c363022b26c3c25b3708051a19d12f3374739bb81323f05b284392080c0/orjson-3.11.2-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:3dcba7101ea6a8d4ef060746c0f2e7aa8e2453a1012083e1ecce9726d7554cb7", size = 226406 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b0/a7/aa3c973de0b33fc93b4bd71691665ffdfeae589ea9d0625584ab10a7d0f5/orjson-3.11.2-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:15d17bdb76a142e1f55d91913e012e6e6769659daa6bfef3ef93f11083137e81", size = 115788 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ef/f2/e45f233dfd09fdbb052ec46352363dca3906618e1a2b264959c18f809d0b/orjson-3.11.2-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:53c9e81768c69d4b66b8876ec3c8e431c6e13477186d0db1089d82622bccd19f", size = 111318 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3e/23/cf5a73c4da6987204cbbf93167f353ff0c5013f7c5e5ef845d4663a366da/orjson-3.11.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:d4f13af59a7b84c1ca6b8a7ab70d608f61f7c44f9740cd42409e6ae7b6c8d8b7", size = 121231 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/40/1d/47468a398ae68a60cc21e599144e786e035bb12829cb587299ecebc088f1/orjson-3.11.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bde64aa469b5ee46cc960ed241fae3721d6a8801dacb2ca3466547a2535951e4", size = 119204 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/4d/d9/f99433d89b288b5bc8836bffb32a643f805e673cf840ef8bab6e73ced0d1/orjson-3.11.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:b5ca86300aeb383c8fa759566aca065878d3d98c3389d769b43f0a2e84d52c5f", size = 392237 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d4/dc/1b9d80d40cebef603325623405136a29fb7d08c877a728c0943dd066c29a/orjson-3.11.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:24e32a558ebed73a6a71c8f1cbc163a7dd5132da5270ff3d8eeb727f4b6d1bc7", size = 134578 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/45/b3/72e7a4c5b6485ef4e83ef6aba7f1dd041002bad3eb5d1d106ca5b0fc02c6/orjson-3.11.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e36319a5d15b97e4344110517450396845cc6789aed712b1fbf83c1bd95792f6", size = 123799 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c8/3e/a3d76b392e7acf9b34dc277171aad85efd6accc75089bb35b4c614990ea9/orjson-3.11.2-cp314-cp314-win32.whl", hash = "sha256:40193ada63fab25e35703454d65b6afc71dbc65f20041cb46c6d91709141ef7f", size = 124461 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fb/e3/75c6a596ff8df9e4a5894813ff56695f0a218e6ea99420b4a645c4f7795d/orjson-3.11.2-cp314-cp314-win_amd64.whl", hash = "sha256:7c8ac5f6b682d3494217085cf04dadae66efee45349ad4ee2a1da3c97e2305a8", size = 119494 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/5b/3d/9e74742fc261c5ca473c96bb3344d03995869e1dc6402772c60afb97736a/orjson-3.11.2-cp314-cp314-win_arm64.whl", hash = "sha256:21cf261e8e79284242e4cb1e5924df16ae28255184aafeff19be1405f6d33f67", size = 114046 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@@ -379,79 +390,104 @@ wheels = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "pillow"
 | 
			
		||||
version = "11.2.1"
 | 
			
		||||
version = "11.3.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/0d/8b/b158ad57ed44d3cc54db8d68ad7c0a58b8fc0e4c7a3f995f9d62d5b464a1/pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047", size = 3198442 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b1/f8/bb5d956142f86c2d6cc36704943fa761f2d2e4c48b7436fd0a85c20f1713/pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95", size = 3030553 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/22/7f/0e413bb3e2aa797b9ca2c5c38cb2e2e45d88654e5b12da91ad446964cfae/pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61", size = 4405503 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f3/b4/cc647f4d13f3eb837d3065824aa58b9bcf10821f029dc79955ee43f793bd/pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1", size = 4490648 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c2/6f/240b772a3b35cdd7384166461567aa6713799b4e78d180c555bd284844ea/pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c", size = 4508937 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f3/5e/7ca9c815ade5fdca18853db86d812f2f188212792780208bdb37a0a6aef4/pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d", size = 4599802 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/02/81/c3d9d38ce0c4878a77245d4cf2c46d45a4ad0f93000227910a46caff52f3/pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97", size = 4576717 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/42/49/52b719b89ac7da3185b8d29c94d0e6aec8140059e3d8adcaa46da3751180/pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579", size = 4654874 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/5b/0b/ede75063ba6023798267023dc0d0401f13695d228194d2242d5a7ba2f964/pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d", size = 2331717 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ed/3c/9831da3edea527c2ed9a09f31a2c04e77cd705847f13b69ca60269eec370/pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad", size = 2676204 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/01/97/1f66ff8a1503d8cbfc5bae4dc99d54c6ec1e22ad2b946241365320caabc2/pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2", size = 2414767 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/68/08/3fbf4b98924c73037a8e8b4c2c774784805e0fb4ebca6c5bb60795c40125/pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70", size = 3198450 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/84/92/6505b1af3d2849d5e714fc75ba9e69b7255c05ee42383a35a4d58f576b16/pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf", size = 3030550 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3c/8c/ac2f99d2a70ff966bc7eb13dacacfaab57c0549b2ffb351b6537c7840b12/pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7", size = 4415018 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1f/e3/0a58b5d838687f40891fff9cbaf8669f90c96b64dc8f91f87894413856c6/pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8", size = 4498006 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/21/f5/6ba14718135f08fbfa33308efe027dd02b781d3f1d5c471444a395933aac/pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600", size = 4517773 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/20/f2/805ad600fc59ebe4f1ba6129cd3a75fb0da126975c8579b8f57abeb61e80/pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788", size = 4607069 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/71/6b/4ef8a288b4bb2e0180cba13ca0a519fa27aa982875882392b65131401099/pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e", size = 4583460 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/62/ae/f29c705a09cbc9e2a456590816e5c234382ae5d32584f451c3eb41a62062/pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e", size = 4661304 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6e/1a/c8217b6f2f73794a5e219fbad087701f412337ae6dbb956db37d69a9bc43/pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6", size = 2331809 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e2/72/25a8f40170dc262e86e90f37cb72cb3de5e307f75bf4b02535a61afcd519/pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193", size = 2676338 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/06/9e/76825e39efee61efea258b479391ca77d64dbd9e5804e4ad0fa453b4ba55/pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7", size = 2414918 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c7/40/052610b15a1b8961f52537cc8326ca6a881408bc2bdad0d852edeb6ed33b/pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f", size = 3190185 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e5/7e/b86dbd35a5f938632093dc40d1682874c33dcfe832558fc80ca56bfcb774/pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b", size = 3030306 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a4/5c/467a161f9ed53e5eab51a42923c33051bf8d1a2af4626ac04f5166e58e0c/pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d", size = 4416121 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/62/73/972b7742e38ae0e2ac76ab137ca6005dcf877480da0d9d61d93b613065b4/pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4", size = 4501707 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e4/3a/427e4cb0b9e177efbc1a84798ed20498c4f233abde003c06d2650a6d60cb/pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d", size = 4522921 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fe/7c/d8b1330458e4d2f3f45d9508796d7caf0c0d3764c00c823d10f6f1a3b76d/pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4", size = 4612523 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b3/2f/65738384e0b1acf451de5a573d8153fe84103772d139e1e0bdf1596be2ea/pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443", size = 4587836 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6a/c5/e795c9f2ddf3debb2dedd0df889f2fe4b053308bb59a3cc02a0cd144d641/pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c", size = 4669390 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/96/ae/ca0099a3995976a9fce2f423166f7bff9b12244afdc7520f6ed38911539a/pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3", size = 2332309 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/7c/18/24bff2ad716257fc03da964c5e8f05d9790a779a8895d6566e493ccf0189/pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941", size = 2676768 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/da/bb/e8d656c9543276517ee40184aaa39dcb41e683bca121022f9323ae11b39d/pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb", size = 2415087 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/36/9c/447528ee3776e7ab8897fe33697a7ff3f0475bb490c5ac1456a03dc57956/pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28", size = 3190098 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b5/09/29d5cd052f7566a63e5b506fac9c60526e9ecc553825551333e1e18a4858/pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830", size = 3030166 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/71/5d/446ee132ad35e7600652133f9c2840b4799bbd8e4adba881284860da0a36/pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0", size = 4408674 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/69/5f/cbe509c0ddf91cc3a03bbacf40e5c2339c4912d16458fcb797bb47bcb269/pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1", size = 4496005 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f9/b3/dd4338d8fb8a5f312021f2977fb8198a1184893f9b00b02b75d565c33b51/pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f", size = 4518707 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/13/eb/2552ecebc0b887f539111c2cd241f538b8ff5891b8903dfe672e997529be/pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155", size = 4610008 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/72/d1/924ce51bea494cb6e7959522d69d7b1c7e74f6821d84c63c3dc430cbbf3b/pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14", size = 4585420 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/43/ab/8f81312d255d713b99ca37479a4cb4b0f48195e530cdc1611990eb8fd04b/pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b", size = 4667655 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/94/86/8f2e9d2dc3d308dfd137a07fe1cc478df0a23d42a6c4093b087e738e4827/pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2", size = 2332329 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6d/ec/1179083b8d6067a613e4d595359b5fdea65d0a3b7ad623fee906e1b3c4d2/pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691", size = 2676388 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/23/f1/2fc1e1e294de897df39fa8622d829b8828ddad938b0eaea256d65b84dd72/pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c", size = 2414950 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c4/3e/c328c48b3f0ead7bab765a84b4977acb29f101d10e4ef57a5e3400447c03/pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22", size = 3192759 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/18/0e/1c68532d833fc8b9f404d3a642991441d9058eccd5606eab31617f29b6d4/pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7", size = 3033284 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b7/cb/6faf3fb1e7705fd2db74e070f3bf6f88693601b0ed8e81049a8266de4754/pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16", size = 4445826 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/07/94/8be03d50b70ca47fb434a358919d6a8d6580f282bbb7af7e4aa40103461d/pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b", size = 4527329 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fd/a4/bfe78777076dc405e3bd2080bc32da5ab3945b5a25dc5d8acaa9de64a162/pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406", size = 4549049 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/65/4d/eaf9068dc687c24979e977ce5677e253624bd8b616b286f543f0c1b91662/pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91", size = 4635408 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1d/26/0fd443365d9c63bc79feb219f97d935cd4b93af28353cba78d8e77b61719/pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751", size = 4614863 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/49/65/dca4d2506be482c2c6641cacdba5c602bc76d8ceb618fd37de855653a419/pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9", size = 4692938 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b3/92/1ca0c3f09233bd7decf8f7105a1c4e3162fb9142128c74adad0fb361b7eb/pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd", size = 2335774 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a5/ac/77525347cb43b83ae905ffe257bbe2cc6fd23acb9796639a1f56aa59d191/pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e", size = 2681895 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/67/32/32dc030cfa91ca0fc52baebbba2e009bb001122a1daa8b6a79ad830b38d3/pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681", size = 2417234 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/33/49/c8c21e4255b4f4a2c0c68ac18125d7f5460b109acc6dfdef1a24f9b960ef/pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156", size = 3181727 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6d/f1/f7255c0838f8c1ef6d55b625cfb286835c17e8136ce4351c5577d02c443b/pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772", size = 2999833 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e2/57/9968114457bd131063da98d87790d080366218f64fa2943b65ac6739abb3/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363", size = 3437472 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b2/1b/e35d8a158e21372ecc48aac9c453518cfe23907bb82f950d6e1c72811eb0/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0", size = 3459976 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/26/da/2c11d03b765efff0ccc473f1c4186dc2770110464f2177efaed9cf6fae01/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01", size = 3527133 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/79/1a/4e85bd7cadf78412c2a3069249a09c32ef3323650fd3005c97cca7aa21df/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193", size = 3571555 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/69/03/239939915216de1e95e0ce2334bf17a7870ae185eb390fab6d706aadbfc0/pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013", size = 2674713 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a4/ad/2613c04633c7257d9481ab21d6b5364b59fc5d75faafd7cb8693523945a3/pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed", size = 3181734 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a4/fd/dcdda4471ed667de57bb5405bb42d751e6cfdd4011a12c248b455c778e03/pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c", size = 2999841 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ac/89/8a2536e95e77432833f0db6fd72a8d310c8e4272a04461fb833eb021bf94/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd", size = 3437470 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/9d/8f/abd47b73c60712f88e9eda32baced7bfc3e9bd6a7619bb64b93acff28c3e/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076", size = 3460013 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f6/20/5c0a0aa83b213b7a07ec01e71a3d6ea2cf4ad1d2c686cc0168173b6089e7/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b", size = 3527165 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/58/0e/2abab98a72202d91146abc839e10c14f7cf36166f12838ea0c4db3ca6ecb/pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f", size = 3571586 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/21/2c/5e05f58658cf49b6667762cca03d6e7d85cededde2caf2ab37b81f80e574/pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044", size = 2674751 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/4c/5d/45a3553a253ac8763f3561371432a90bdbe6000fbdcf1397ffe502aa206c/pillow-11.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b9c17fd4ace828b3003dfd1e30bff24863e0eb59b535e8f80194d9cc7ecf860", size = 5316554 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/7c/c8/67c12ab069ef586a25a4a79ced553586748fad100c77c0ce59bb4983ac98/pillow-11.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:65dc69160114cdd0ca0f35cb434633c75e8e7fad4cf855177a05bf38678f73ad", size = 4686548 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/2f/bd/6741ebd56263390b382ae4c5de02979af7f8bd9807346d068700dd6d5cf9/pillow-11.3.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7107195ddc914f656c7fc8e4a5e1c25f32e9236ea3ea860f257b0436011fddd0", size = 5859742 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ca/0b/c412a9e27e1e6a829e6ab6c2dca52dd563efbedf4c9c6aa453d9a9b77359/pillow-11.3.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc3e831b563b3114baac7ec2ee86819eb03caa1a2cef0b481a5675b59c4fe23b", size = 7633087 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/59/9d/9b7076aaf30f5dd17e5e5589b2d2f5a5d7e30ff67a171eb686e4eecc2adf/pillow-11.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1f182ebd2303acf8c380a54f615ec883322593320a9b00438eb842c1f37ae50", size = 5963350 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f0/16/1a6bf01fb622fb9cf5c91683823f073f053005c849b1f52ed613afcf8dae/pillow-11.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4445fa62e15936a028672fd48c4c11a66d641d2c05726c7ec1f8ba6a572036ae", size = 6631840 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/7b/e6/6ff7077077eb47fde78739e7d570bdcd7c10495666b6afcd23ab56b19a43/pillow-11.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71f511f6b3b91dd543282477be45a033e4845a40278fa8dcdbfdb07109bf18f9", size = 6074005 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c3/3a/b13f36832ea6d279a697231658199e0a03cd87ef12048016bdcc84131601/pillow-11.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:040a5b691b0713e1f6cbe222e0f4f74cd233421e105850ae3b3c0ceda520f42e", size = 6708372 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6c/e4/61b2e1a7528740efbc70b3d581f33937e38e98ef3d50b05007267a55bcb2/pillow-11.3.0-cp310-cp310-win32.whl", hash = "sha256:89bd777bc6624fe4115e9fac3352c79ed60f3bb18651420635f26e643e3dd1f6", size = 6277090 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a9/d3/60c781c83a785d6afbd6a326ed4d759d141de43aa7365725cbcd65ce5e54/pillow-11.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:19d2ff547c75b8e3ff46f4d9ef969a06c30ab2d4263a9e287733aa8b2429ce8f", size = 6985988 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/9f/28/4f4a0203165eefb3763939c6789ba31013a2e90adffb456610f30f613850/pillow-11.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:819931d25e57b513242859ce1876c58c59dc31587847bf74cfe06b2e0cb22d2f", size = 2422899 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/40/fe/1bc9b3ee13f68487a99ac9529968035cca2f0a51ec36892060edcc51d06a/pillow-11.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdae223722da47b024b867c1ea0be64e0df702c5e0a60e27daad39bf960dd1e4", size = 5278800 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/2c/32/7e2ac19b5713657384cec55f89065fb306b06af008cfd87e572035b27119/pillow-11.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:921bd305b10e82b4d1f5e802b6850677f965d8394203d182f078873851dada69", size = 4686296 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/8e/1e/b9e12bbe6e4c2220effebc09ea0923a07a6da1e1f1bfbc8d7d29a01ce32b/pillow-11.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb76541cba2f958032d79d143b98a3a6b3ea87f0959bbe256c0b5e416599fd5d", size = 5871726 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/8d/33/e9200d2bd7ba00dc3ddb78df1198a6e80d7669cce6c2bdbeb2530a74ec58/pillow-11.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67172f2944ebba3d4a7b54f2e95c786a3a50c21b88456329314caaa28cda70f6", size = 7644652 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/41/f1/6f2427a26fc683e00d985bc391bdd76d8dd4e92fac33d841127eb8fb2313/pillow-11.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f07ed9f56a3b9b5f49d3661dc9607484e85c67e27f3e8be2c7d28ca032fec7", size = 5977787 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e4/c9/06dd4a38974e24f932ff5f98ea3c546ce3f8c995d3f0985f8e5ba48bba19/pillow-11.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:676b2815362456b5b3216b4fd5bd89d362100dc6f4945154ff172e206a22c024", size = 6645236 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/40/e7/848f69fb79843b3d91241bad658e9c14f39a32f71a301bcd1d139416d1be/pillow-11.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e184b2f26ff146363dd07bde8b711833d7b0202e27d13540bfe2e35a323a809", size = 6086950 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/0b/1a/7cff92e695a2a29ac1958c2a0fe4c0b2393b60aac13b04a4fe2735cad52d/pillow-11.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6be31e3fc9a621e071bc17bb7de63b85cbe0bfae91bb0363c893cbe67247780d", size = 6723358 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/26/7d/73699ad77895f69edff76b0f332acc3d497f22f5d75e5360f78cbcaff248/pillow-11.3.0-cp312-cp312-win32.whl", hash = "sha256:7b161756381f0918e05e7cb8a371fff367e807770f8fe92ecb20d905d0e1c149", size = 6275079 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/8c/ce/e7dfc873bdd9828f3b6e5c2bbb74e47a98ec23cc5c74fc4e54462f0d9204/pillow-11.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a6444696fce635783440b7f7a9fc24b3ad10a9ea3f0ab66c5905be1c19ccf17d", size = 6986324 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/16/8f/b13447d1bf0b1f7467ce7d86f6e6edf66c0ad7cf44cf5c87a37f9bed9936/pillow-11.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:2aceea54f957dd4448264f9bf40875da0415c83eb85f55069d89c0ed436e3542", size = 2423067 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/6f/8b/209bd6b62ce8367f47e68a218bffac88888fdf2c9fcf1ecadc6c3ec1ebc7/pillow-11.3.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:3cee80663f29e3843b68199b9d6f4f54bd1d4a6b59bdd91bceefc51238bcb967", size = 5270556 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/2e/e6/231a0b76070c2cfd9e260a7a5b504fb72da0a95279410fa7afd99d9751d6/pillow-11.3.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:b5f56c3f344f2ccaf0dd875d3e180f631dc60a51b314295a3e681fe8cf851fbe", size = 4654625 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/13/f4/10cf94fda33cb12765f2397fc285fa6d8eb9c29de7f3185165b702fc7386/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e67d793d180c9df62f1f40aee3accca4829d3794c95098887edc18af4b8b780c", size = 4874207 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/72/c9/583821097dc691880c92892e8e2d41fe0a5a3d6021f4963371d2f6d57250/pillow-11.3.0-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d000f46e2917c705e9fb93a3606ee4a819d1e3aa7a9b442f6444f07e77cf5e25", size = 6583939 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/3b/8e/5c9d410f9217b12320efc7c413e72693f48468979a013ad17fd690397b9a/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527b37216b6ac3a12d7838dc3bd75208ec57c1c6d11ef01902266a5a0c14fc27", size = 4957166 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/62/bb/78347dbe13219991877ffb3a91bf09da8317fbfcd4b5f9140aeae020ad71/pillow-11.3.0-pp310-pypy310_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be5463ac478b623b9dd3937afd7fb7ab3d79dd290a28e2b6df292dc75063eb8a", size = 5581482 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/d9/28/1000353d5e61498aaeaaf7f1e4b49ddb05f2c6575f9d4f9f914a3538b6e1/pillow-11.3.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:8dc70ca24c110503e16918a658b869019126ecfe03109b754c402daff12b3d9f", size = 6984596 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@@ -482,6 +518,7 @@ dependencies = [
 | 
			
		||||
    { name = "requests" },
 | 
			
		||||
    { name = "tqdm" },
 | 
			
		||||
    { name = "vdf" },
 | 
			
		||||
    { name = "websocket-client" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.dev-dependencies]
 | 
			
		||||
@@ -495,29 +532,30 @@ dev = [
 | 
			
		||||
requires-dist = [
 | 
			
		||||
    { name = "babel", specifier = ">=2.17.0" },
 | 
			
		||||
    { name = "beautifulsoup4", specifier = ">=4.13.4" },
 | 
			
		||||
    { name = "evdev", specifier = ">=1.9.1" },
 | 
			
		||||
    { name = "icoextract", specifier = ">=0.1.6" },
 | 
			
		||||
    { name = "evdev", specifier = ">=1.9.2" },
 | 
			
		||||
    { name = "icoextract", specifier = ">=0.2.0" },
 | 
			
		||||
    { name = "numpy", specifier = ">=2.2.4" },
 | 
			
		||||
    { name = "orjson", specifier = ">=3.10.16" },
 | 
			
		||||
    { name = "pillow", specifier = ">=11.2.1" },
 | 
			
		||||
    { name = "orjson", specifier = ">=3.11.2" },
 | 
			
		||||
    { name = "pillow", specifier = ">=11.3.0" },
 | 
			
		||||
    { name = "psutil", specifier = ">=7.0.0" },
 | 
			
		||||
    { name = "pyside6", specifier = ">=6.9.0" },
 | 
			
		||||
    { name = "pyside6", specifier = ">=6.9.1" },
 | 
			
		||||
    { name = "pyudev", specifier = ">=0.24.3" },
 | 
			
		||||
    { name = "requests", specifier = ">=2.32.3" },
 | 
			
		||||
    { name = "requests", specifier = ">=2.32.4" },
 | 
			
		||||
    { name = "tqdm", specifier = ">=4.67.1" },
 | 
			
		||||
    { name = "vdf", specifier = ">=3.4" },
 | 
			
		||||
    { name = "websocket-client", specifier = ">=1.8.0" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[package.metadata.requires-dev]
 | 
			
		||||
dev = [
 | 
			
		||||
    { name = "pre-commit", specifier = ">=4.2.0" },
 | 
			
		||||
    { name = "pre-commit", specifier = ">=4.3.0" },
 | 
			
		||||
    { name = "pyaspeller", specifier = ">=2.0.2" },
 | 
			
		||||
    { name = "pyright", specifier = ">=1.1.400" },
 | 
			
		||||
    { name = "pyright", specifier = ">=1.1.403" },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "pre-commit"
 | 
			
		||||
version = "4.2.0"
 | 
			
		||||
version = "4.3.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "cfgv" },
 | 
			
		||||
@@ -526,9 +564,9 @@ dependencies = [
 | 
			
		||||
    { name = "pyyaml" },
 | 
			
		||||
    { name = "virtualenv" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/29/7cf5bbc236333876e4b41f56e06857a87937ce4bf91e117a6991a2dbb02a/pre_commit-4.3.0.tar.gz", hash = "sha256:499fe450cc9d42e9d58e606262795ecb64dd05438943c62b66f6a8673da30b16", size = 193792 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/5b/a5/987a405322d78a73b66e39e4a90e4ef156fd7141bf71df987e50717c321b/pre_commit-4.3.0-py2.py3-none-any.whl", hash = "sha256:2b0747ad7e6e967169136edffee14c16e148a778a54e4f967921aa1ebf2308d8", size = 220965 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@@ -560,15 +598,15 @@ wheels = [
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "pyright"
 | 
			
		||||
version = "1.1.402"
 | 
			
		||||
version = "1.1.403"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
dependencies = [
 | 
			
		||||
    { name = "nodeenv" },
 | 
			
		||||
    { name = "typing-extensions" },
 | 
			
		||||
]
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207 }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004 },
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
@@ -760,3 +798,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 },
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
[[package]]
 | 
			
		||||
name = "websocket-client"
 | 
			
		||||
version = "1.8.0"
 | 
			
		||||
source = { registry = "https://pypi.org/simple" }
 | 
			
		||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/30/fba0d96b4b5fbf5948ed3f4681f7da2f9f64512e1d303f94b4cc174c24a5/websocket_client-1.8.0.tar.gz", hash = "sha256:3239df9f44da632f96012472805d40a23281a991027ce11d2f45a6f24ac4c3da", size = 54648 }
 | 
			
		||||
wheels = [
 | 
			
		||||
    { url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826 },
 | 
			
		||||
]
 | 
			
		||||
 
 | 
			
		||||