Compare commits
53
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5384104542
|
||
|
|
3b9e531eda
|
||
|
|
822b454bf4
|
||
|
|
77a65d9e81 | ||
|
|
eee8fb5f0e | ||
|
|
4d84d74d70 | ||
|
|
c9fea6e713
|
||
|
|
b4f39b003b
|
||
|
|
f7e44d72c8
|
||
|
|
747b85ce2f
|
||
|
|
b907b5400c
|
||
|
|
043031baec
|
||
|
|
17e7fa2690
|
||
|
|
be1e517c68
|
||
|
|
bcd089439e
|
||
|
|
cb1b984f04
|
||
|
|
22bf9b2451
|
||
|
|
c8ff25dd69
|
||
|
|
927531620a
|
||
|
|
bb14e1a346
|
||
|
|
2a5a45fabd
|
||
|
|
5e0e5b785e
|
||
|
|
90c4901e8e | ||
|
|
c1e76b8e8c
|
||
|
|
782c9cae6a | ||
|
|
725c8d371e
|
||
|
|
53d79c50d3
|
||
|
|
af8679aa08
|
||
|
|
96a2bd92e8
|
||
|
|
810d09b0ad | ||
|
|
359ac7d72e | ||
|
|
6135f640fd
|
||
|
|
58d85dcf9f
|
||
|
|
6b614ce0c5
|
||
|
|
7b563dff32
|
||
|
|
d04e637030
|
||
|
|
00a222073f
|
||
|
|
e09c87491f | ||
|
|
4f5fa36a0d | ||
|
|
6e70c92afc
|
||
|
|
ff568fad1c
|
||
|
|
254f56fe17 | ||
|
|
b0e8345517 | ||
|
|
897daf983b | ||
|
|
2effcf5505 | ||
|
|
5aaa7cbc7e
|
||
|
|
6a502711ad | ||
|
|
4889ad1f48
|
||
|
|
2d5336bc51
|
||
|
|
a7fe71558e
|
||
|
|
b9de7e40fe
|
||
|
|
9ad69007ec
|
||
|
|
6af8b31a90
|
No files matched your search
@@ -119,7 +119,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [43, 44, rawhide]
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
name: Check code
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
steps:
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
pacman -S --noconfirm --needed git nodejs npm mesa libarchive sdl3 pyside6 qt6-svg qt6-imageformats uv
|
||||
pacman -Scc --noconfirm
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Sync dependencies into venv
|
||||
run: uv sync --all-extras --dev
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
stats:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Python
|
||||
uses: https://git.linux-gaming.ru/actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
arch: ${{ steps.check.outputs.arch }}
|
||||
debian: ${{ steps.check.outputs.debian }}
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
with:
|
||||
fetch-depth: 0
|
||||
@@ -80,7 +80,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
options: --privileged --device /dev/fuse
|
||||
steps:
|
||||
- name: Prepare container
|
||||
@@ -89,10 +89,10 @@ jobs:
|
||||
pacman -Sy --noconfirm archlinux-keyring
|
||||
pacman -Syu --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm xorg-server-xvfb zsync patchelf
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
fedora_version: [43, 44, rawhide]
|
||||
fedora_version: [43, 44, 45, rawhide]
|
||||
|
||||
container:
|
||||
image: fedora:${{ matrix.fedora_version }}
|
||||
@@ -149,11 +149,11 @@ jobs:
|
||||
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -185,7 +185,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:212b1e518e94ee9c52be55e8a32da75fcf11e7b5610b80b49479e67880102406
|
||||
image: archlinux:base-devel@sha256:84cd9ef000b3cff245ec028e87965b84724f4bf1cc63fc2741ba927b88515ed6
|
||||
|
||||
steps:
|
||||
- name: Prepare container
|
||||
@@ -213,11 +213,11 @@ jobs:
|
||||
chown user -R ..
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
@@ -251,7 +251,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.debian == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: debian:trixie-20260623@sha256:d07d1b51c39f51188e60be9b64e6bf769fa94e187f092bc32b91305cfa34ba5a
|
||||
image: debian:trixie-20260824@sha256:f324c7ff54321e8d9c588493a20244965938ce0aa50bbd1022d38010e9ffc4b1
|
||||
options: --privileged
|
||||
|
||||
steps:
|
||||
@@ -263,11 +263,11 @@ jobs:
|
||||
pkgconf python3
|
||||
|
||||
- name: Checkout PR
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
- name: Checkout branch
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: ${{ env.BUILD_BRANCH }}
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:47421f2f3f82048e969b5195dd808d5ac2c5f69d43d192191c4a3dca390afe06
|
||||
steps:
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: https://git.linux-gaming.ru/actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: https://git.linux-gaming.ru/actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
|
||||
|
||||
@@ -78,6 +78,31 @@ Example:
|
||||
|
||||
---
|
||||
|
||||
## Commit Classification
|
||||
|
||||
Use Conventional Commit prefixes according to the primary purpose of the change:
|
||||
|
||||
| Prefix | Use for |
|
||||
|--------|---------|
|
||||
| `feat` | A new user-facing capability or supported workflow |
|
||||
| `fix` | A correction for a confirmed defect or regression in existing behavior |
|
||||
| `chore` | Maintenance, integration adjustments, filtering, metadata, or routine upkeep that is neither a feature nor a confirmed bug fix |
|
||||
| `refactor` | Internal restructuring with no intended behavior change |
|
||||
| `perf` | A measurable performance improvement |
|
||||
| `test` | Test-only changes |
|
||||
| `docs` | Documentation-only changes |
|
||||
| `build` | Build system or dependency changes |
|
||||
| `ci` | CI or automation configuration changes |
|
||||
| `style` | Formatting-only changes with no behavior change |
|
||||
| `revert` | Reverting an earlier commit |
|
||||
|
||||
- Classify by intent, not merely by whether code behavior changed.
|
||||
- Do not default to `fix` for every behavioral correction or edge-case filter.
|
||||
- Use `fix` only when the task identifies a bug/regression or the defect is clearly demonstrated.
|
||||
- If `feat`, `fix`, and `chore` are ambiguous, prefer `chore` unless the task clearly establishes a new capability or confirmed defect.
|
||||
|
||||
---
|
||||
|
||||
## Forbidden Patterns
|
||||
|
||||
```python
|
||||
|
||||
@@ -7,11 +7,44 @@
|
||||
|
||||
### Added
|
||||
|
||||
- Добавлена настройка принудительного применения английского языка
|
||||
- Добавлена интеграция Epic Games Store с авторизацией, библиотекой, установкой, импортом, восстановлением и удалением игр
|
||||
- Добавлена поддержка сборок Proton CachyOS WineLand
|
||||
- Добавлена кнопка возврата на рабочий стол в системной вкладке при указании команды в `PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT`
|
||||
- Добавлены режимы библиотеки `horizontal` и `horizontal_top` с циклической навигацией и плиткой открытия полной сетки в топ-режиме
|
||||
- Добавлен режим библиотеки `vertical` с табличным отображением названия, последнего запуска, проведённого времени и источника игры
|
||||
|
||||
### Changed
|
||||
|
||||
- Поиск в библиотеке теперь находит совпадения в любой части названия и показывает одноимённые игры из разных источников
|
||||
- Тема теперь применяется без перезапуска приложения
|
||||
- Добавлена сборка Fedora 45
|
||||
- Автоматическая загрузка PPDB с linux-gaming.ru теперь отключена по умолчанию
|
||||
- Кнопка «Открыть PPDB» переименована в «Редактировать PPDB», чтобы не путать открытие файла с его запуском
|
||||
- Обновлён перевод на Немецкий `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/de/`
|
||||
- Обновлён перевод на Португальский (Бразилия) `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/pt_BR/`
|
||||
- Обновлён перевод на Русский `https://translate.codeberg.org/projects/portprotonqt/portprotonqt/ru/`
|
||||
- В AppImage добавлен Bash для предотвращения конфликтов Vulkan со Steam Runtime при использовании системного GameMode
|
||||
- Удалена ассоциация PortProtonQt с исполняемыми файлами Linux
|
||||
- MIME-тип образов MDS заменён на используемый libMirage `application/x-mds`
|
||||
- Порог определения быстрого вылета Windows-программы увеличен с 5 до 7 секунд
|
||||
- Открытие карточек и тихий запуск унифицированы
|
||||
- `minazuki` добавлен в список конфликтующих с GameMode процессов
|
||||
|
||||
### Fixed
|
||||
|
||||
- Исправлено открытие карточек GOG через URI при старте приложения
|
||||
- Исправлен сброс состояния кнопки записи лога при переключении детальной страницы в компактный режим
|
||||
- Исправлено отсутствие вкладок Wi-Fi и Bluetooth при запуске до инициализации системных адаптеров
|
||||
- Исправлена ошибка, из-за которой не работал поиск по настройкам при отсутствии доступных параметров MangoHud или Gamescope
|
||||
- Фильтр установленных игр теперь переключается без повторной загрузки библиотеки
|
||||
- Некорректные изображения в кэше Steam теперь пропускаются при поиске обложек
|
||||
- Исправлено открытие `.ppdb` на разделах с битом исполнения у всех файлов
|
||||
|
||||
### Contributors
|
||||
- @rogerio-mattos
|
||||
- @Rubin13
|
||||
- @adem4ik
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<div align="center">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-6382-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/Downloads-10488-green?style=flat-square" alt="Total Downloads"></a>
|
||||
<p align="center">A modern and convenient interface for managing and launching games from PortProton, Steam, Epic Games Store, and GOG. It brings libraries together in one place and simplifies launching Windows games on Linux.</p>
|
||||
</div>
|
||||
|
||||
### Installation (devel)
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
<div align="center">
|
||||
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64">
|
||||
<h1 align="center">PortProtonQt</h1>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-6382-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
|
||||
<a href="https://git.linux-gaming.ru/Linux-Gaming/PortProtonQt/releases"><img src="https://img.shields.io/badge/%D0%97%D0%B0%D0%B3%D1%80%D1%83%D0%B7%D0%BA%D0%B8-10488-green?style=flat-square" alt="Всего загрузок"></a>
|
||||
<p align="center">Современный и удобный интерфейс для управления и запуска игр из PortProton, Steam, Epic Games Store и GOG. Объединяет библиотеки в одном месте и упрощает запуск Windows-игр на Linux.</p>
|
||||
</div>
|
||||
|
||||
### Установка (devel)
|
||||
|
||||
@@ -45,7 +45,9 @@ GAMEPAD_LIBRARY=$(find /usr/lib -type f \
|
||||
test -n "$GAMEPAD_LIBRARY"
|
||||
./quick-sharun \
|
||||
/usr/bin/portprotonqt* \
|
||||
/usr/bin/bash \
|
||||
/usr/bin/update-desktop-database \
|
||||
/usr/bin/update-mime-database \
|
||||
/usr/bin/vk_gpu_info \
|
||||
"$GAMEPAD_LIBRARY" \
|
||||
/usr/lib/libQt6Core.so* \
|
||||
@@ -55,6 +57,11 @@ test -n "$GAMEPAD_LIBRARY"
|
||||
/usr/lib/qt6/plugins/imageformats/libqwebp.so \
|
||||
/usr/lib/7zip/7z*
|
||||
|
||||
# quick-sharun removes MIME package XML files during dependency deployment
|
||||
mkdir -p ./AppDir/share/mime/packages
|
||||
cp /usr/share/mime/packages/ru.linux_gaming.PortProtonQt.xml \
|
||||
./AppDir/share/mime/packages
|
||||
|
||||
# DEPLOY_PYTHON copies the distro's complete Python installation. Remove
|
||||
# build/development content and PySide bindings not imported by PortProtonQt.
|
||||
PYTHON_DIR=$(find ./AppDir/lib -maxdepth 1 -type d -name 'python3.*' -print -quit)
|
||||
|
||||
@@ -11,4 +11,4 @@ Terminal=false
|
||||
Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;Utility;
|
||||
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-executable;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds-disk;application/x-nrg;
|
||||
MimeType=x-scheme-handler/portproton;x-scheme-handler/portprotonqt;application/x-portproton-prefix-backup;application/x-portproton-autoinstall;application/x-ms-dos-exec;application/x-ms-dos-executable;application/x-msdos-program;application/vnd.microsoft.portable-executable;application/x-ms-dos-batch;application/x-bat;application/x-msi;text/x-ms-regedit;application/x-dosexec;application/x-iso9660-image;application/x-cd-image;application/x-raw-disk-image;application/x-mdf;application/x-mds;application/x-nrg;
|
||||
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;
|
||||
NoDisplay=true
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -16,5 +16,5 @@ Icon=ru.linux_gaming.PortProtonQt
|
||||
StartupWMClass=ru.linux_gaming.PortProtonQt
|
||||
Categories=Game;
|
||||
NoDisplay=true
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-executable;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
MimeType=application/x-ms-dos-executable;application/x-msdos-program;application/x-ms-dos-exec;application/x-dosexec;application/vnd.microsoft.portable-executable;
|
||||
StartupNotify=true
|
||||
@@ -10,4 +10,10 @@
|
||||
<comment xml:lang="ru">Скрипт автоустановки PortProton</comment>
|
||||
<glob pattern="*.ppai"/>
|
||||
</mime-type>
|
||||
<mime-type type="application/x-portproton-ppdb">
|
||||
<comment>PortProton configuration</comment>
|
||||
<comment xml:lang="ru">Конфигурация PortProton</comment>
|
||||
<sub-class-of type="text/plain"/>
|
||||
<glob pattern="*.ppdb" weight="100"/>
|
||||
</mime-type>
|
||||
</mime-info>
|
||||
@@ -533,6 +533,21 @@ try_download () {
|
||||
return 0
|
||||
}
|
||||
|
||||
prepare_epic_wrapper () {
|
||||
local expected_hash="1feb21e21e19cbb34e881791c8f2557769f59231e46850915d49a6d0c1ce4583"
|
||||
export PW_EPIC_WRAPPER_EXE="${PORT_WINE_TMP_PATH}/plugins${PW_PLUGINS_VER}/epic/EpicGamesLauncher.exe"
|
||||
if [[ ! -f "$PW_EPIC_WRAPPER_EXE" ]] ; then
|
||||
create_new_dir "$(dirname "$PW_EPIC_WRAPPER_EXE")"
|
||||
try_download \
|
||||
"https://github.com/BananaWorks07/heroic-epic-integration/releases/download/v${PW_EPIC_WRAPPER_VER}/EpicGamesLauncher.exe" \
|
||||
"$PW_EPIC_WRAPPER_EXE" no_mirror || return 1
|
||||
fi
|
||||
if [[ "$(sha256sum "$PW_EPIC_WRAPPER_EXE" | awk '{print $1}')" != "$expected_hash" ]] ; then
|
||||
try_remove_file "$PW_EPIC_WRAPPER_EXE"
|
||||
fatal "Epic launcher wrapper checksum verification failed"
|
||||
fi
|
||||
}
|
||||
|
||||
var_winedlloverride_update () {
|
||||
export WINEDLLOVERRIDES="${1}${WINEDLLOVERRIDES:+;$WINEDLLOVERRIDES}"
|
||||
}
|
||||
@@ -2994,7 +3009,7 @@ start_portproton () {
|
||||
elif [[ "$PW_USE_GAMEMODE" = "1" ]] \
|
||||
&& [[ -n "$DBUS_SESSION_BUS_ADDRESS" ]]
|
||||
then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn ; then
|
||||
if pw_is_process_active ananicy ananicy-cpp scx scx_loader falcond sherrydmn minazuki ; then
|
||||
export GAMEMODERUN=0
|
||||
export PW_GAMEMODERUN_SLR=""
|
||||
local current_power_profile
|
||||
@@ -4155,7 +4170,12 @@ EOF
|
||||
else
|
||||
case "${PW_EXE_FILE,,}" in
|
||||
*.exe)
|
||||
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
|
||||
if [[ -n "${PW_EPIC_WRAPPER_EXE:-}" ]] ; then
|
||||
pw_run "${PW_VD_TMP[@]}" "${PW_EPIC_WRAPPER_EXE}" \
|
||||
"${PW_EXE_FILE}" "$@"
|
||||
else
|
||||
pw_run "${PW_VD_TMP[@]}" ${WINE_WIN_START} "${PW_EXE_FILE}"
|
||||
fi
|
||||
;;
|
||||
*.bat|*.cmd)
|
||||
PW_USE_TERMINAL=1
|
||||
|
||||
@@ -73,6 +73,9 @@ fi
|
||||
|
||||
export PW_START_PID="$$"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${PORT_SCRIPTS_PATH}/var"
|
||||
|
||||
read -r -a pw_full_command_line <<< "$0 $*"
|
||||
export pw_full_command_line
|
||||
export orig_IFS="$IFS"
|
||||
@@ -119,6 +122,9 @@ elif [[ "$1" =~ ^--(debug|launch|edit-db)$ && "${2,,}" =~ \.(exe|bat|cmd|msi|reg
|
||||
elif [[ -f "$OLDPWD/$2" ]] ; then
|
||||
PW_EXE_FILE="$(realpath -s "$OLDPWD/$2")"
|
||||
fi
|
||||
elif [[ "$1" == "--epic-wrapper" && "${2,,}" =~ \.exe$ ]] ; then
|
||||
prepare_epic_wrapper || fatal "Failed to prepare Epic launcher wrapper"
|
||||
PW_EXE_FILE="$(realpath -s "$2")"
|
||||
fi
|
||||
export PW_EXE_FILE
|
||||
|
||||
@@ -164,9 +170,6 @@ export PW_VULKAN_DIR="${PORT_WINE_TMP_PATH}/VULKAN"
|
||||
create_new_dir "${PW_VULKAN_DIR}"
|
||||
|
||||
cd "${PORT_SCRIPTS_PATH}" || fatal "Scripts directory not found: ${PORT_SCRIPTS_PATH}"
|
||||
|
||||
# shellcheck source=/dev/null
|
||||
source "${PORT_SCRIPTS_PATH}/var"
|
||||
# HACK: Avoid inheriting a system scripts cwd in pressure-vessel.
|
||||
cd "${XDG_DATA_HOME:-$HOME}" || fatal "Data directory not found: ${XDG_DATA_HOME:-$HOME}"
|
||||
|
||||
@@ -576,6 +579,10 @@ Usage examples:
|
||||
portwine_launch
|
||||
stop_portproton
|
||||
;;
|
||||
--epic-wrapper)
|
||||
portwine_launch "${@:3}"
|
||||
stop_portproton
|
||||
;;
|
||||
--stop)
|
||||
start_path="$(realpath "$0")"
|
||||
pgrep -f -- "${start_path}.*--autoinstall" | while read -r autoinstall_pid ; do
|
||||
|
||||
@@ -16,6 +16,7 @@ export VKD3D_SAREK_VER="sarek-2.6.0"
|
||||
export PW_LSFG_VK_VER="2.0.0-dev25-p11"
|
||||
export PW_OPTISCALER_VER="v03-0.9.0-pre8"
|
||||
export PW_SPECIALK_VER="SK_26_5_15"
|
||||
export PW_EPIC_WRAPPER_VER="0.4"
|
||||
export PW_GALLIUM_NINE_VER="0.10"
|
||||
export DOTPFX_VER="48v9"
|
||||
export DEFPFX_VER="v1"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [Detail Page Layout Mode](#detail-page-layout-mode)
|
||||
- [Detail Page Background Mode](#detail-page-background-mode)
|
||||
- [Background Configuration](#background-configuration)
|
||||
- [Library Background](#library-background)
|
||||
- [Preloader](#preloader)
|
||||
- [Source Corner (Ribbon)](#source-corner-ribbon)
|
||||
- [Terminal Color Schemes](#terminal-color-schemes)
|
||||
@@ -234,14 +235,36 @@ NAV_BUTTON_STYLE = f"""
|
||||
You can control the game library card layout directly from the theme via `styles.py`:
|
||||
|
||||
```python
|
||||
# "grid" (default) or "list"
|
||||
# "grid", "list", "vertical", "horizontal", or "horizontal_top"
|
||||
LIBRARY_LAYOUT_MODE = "grid"
|
||||
```
|
||||
|
||||
- `grid`: multi-column card grid (classic behavior).
|
||||
- `list`: horizontal row-style cards (launcher-style list).
|
||||
- `vertical`: a full-width table with game title, last launch, time spent, and
|
||||
library columns.
|
||||
- `horizontal`: one horizontally scrolling row containing every game.
|
||||
- `horizontal_top`: the first `horizontalTopGameLimit` games from the current
|
||||
filter and sort order, followed by a tile composed from the next games that
|
||||
opens the complete grid.
|
||||
|
||||
Card geometry is controlled by `GAME_CARD_LIST` and `GAME_CARD_GRID`:
|
||||
The `horizontal_top` preview tile is configured with theme-level constants:
|
||||
|
||||
```python
|
||||
horizontalTopGameLimit = 10 # Games shown before the tile.
|
||||
fullLibraryTileGameCount = 4 # Cover images shown in the tile.
|
||||
fullLibraryTileSize = (180, 180) # Tile width and height.
|
||||
fullLibraryTileColumns = 2 # Cover columns inside the tile.
|
||||
fullLibraryTileRows = 2 # Cover rows inside the tile.
|
||||
fullLibraryTileRadius = 10 # Tile corner radius in pixels.
|
||||
```
|
||||
|
||||
`fullLibraryTileColumns * fullLibraryTileRows` should be at least
|
||||
`fullLibraryTileGameCount`. The tile opens the complete library grid when
|
||||
activated.
|
||||
|
||||
Card geometry is controlled by `GAME_CARD_LIST`, `GAME_CARD_GRID`, and
|
||||
`GAME_CARD_HORIZONTAL`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
@@ -258,6 +281,7 @@ GAME_CARD_LIST = {
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
@@ -265,12 +289,43 @@ GAME_CARD_GRID = {
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_animation_type": "scale",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 0.56,
|
||||
"card_height_ratio": 0.78,
|
||||
"cover_radius": 15,
|
||||
"layout_margins": (20, 20, 20, 20),
|
||||
"layout_spacing": 20,
|
||||
}
|
||||
```
|
||||
|
||||
Values are logical pixels. `cover_radius` applies to static and animated covers;
|
||||
`border_radius` applies to the painted focus and hover border. Grid cover width
|
||||
continues to follow the library card-size setting.
|
||||
|
||||
Card animation keys can be overridden independently in `GAME_CARD_LIST`,
|
||||
`GAME_CARD_GRID`, or `GAME_CARD_HORIZONTAL`: `card_animation_type`, border
|
||||
widths, pulse settings, gradient angles and colors, fill/stripe/glow colors and
|
||||
opacity, scale values, animation durations, and easing curves. For example,
|
||||
`horizontal_top` can define its own `scale` type, scale values, duration, and
|
||||
easing while the complete grid uses separately configured `gradient` values.
|
||||
Keys omitted from a layout continue to use `GAME_CARD_ANIMATION` as fallback.
|
||||
|
||||
```python
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_animation_type": "scale",
|
||||
"default_scale": 1.0,
|
||||
"hover_scale": 1.055,
|
||||
"focus_scale": 1.075,
|
||||
"scale_anim_duration": 135,
|
||||
"scale_easing_curve": "OutCubic",
|
||||
"scale_easing_curve_out": "InOutCubic",
|
||||
}
|
||||
```
|
||||
|
||||
Card shadows use the shared `shadow_blur_radius` and `shadow_offset` theme
|
||||
constants in both grid and list layouts.
|
||||
|
||||
@@ -452,6 +507,53 @@ Existing themes may continue using `DETAIL_PAGE_GRADIENT`,
|
||||
`DETAIL_PAGE_GRADIENT_FY`, and `DETAIL_PAGE_WAVES`. Legacy values override the
|
||||
corresponding nested settings.
|
||||
|
||||
## Library Background
|
||||
|
||||
The library can use the cover of the currently hovered or focused card as its
|
||||
background. This feature is opt-in: if `LIBRARY_BACKGROUND` is not defined, the
|
||||
library keeps the theme's regular background unchanged.
|
||||
|
||||
`LIBRARY_BACKGROUND` selects the effect and can override detail-page
|
||||
background settings locally. Its `backgrounds` dictionary is merged with
|
||||
`DETAIL_PAGE_BACKGROUNDS`, so only the values that should differ need to be
|
||||
specified:
|
||||
|
||||
```python
|
||||
LIBRARY_BACKGROUND = {
|
||||
"margins": (0, 0, 0, 0),
|
||||
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
|
||||
"backgrounds": {
|
||||
"animation_interval_ms": 30,
|
||||
"animation_speed": 0.03,
|
||||
"blur": {
|
||||
"render_resolution": (1280, 720),
|
||||
"radius": 64,
|
||||
},
|
||||
"gradient": {
|
||||
"stops": None,
|
||||
"type": "linear",
|
||||
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4,
|
||||
"wave_amplitude_ratio": 0.06,
|
||||
"wave_frequency": 2.0,
|
||||
"layer_spacing_ratio": 0.04,
|
||||
"base_opacity": 0.45,
|
||||
"opacity_decay": 0.85,
|
||||
"animation_speed": 0.03,
|
||||
"animation_interval_ms": 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The `blur` settings control the intermediate image size and blur radius.
|
||||
`margins` are applied to the background layer layout. Animated modes use the
|
||||
same effect-specific dictionaries documented in `DETAIL_PAGE_BACKGROUNDS`;
|
||||
copy the corresponding dictionary there when overriding `aurora`, `leaf`,
|
||||
`metaballs`, `veins`, or `diagnostics`.
|
||||
|
||||
---
|
||||
|
||||
The `PRELOADER` dictionary controls the loading indicator style and animation. If not defined, the default spinner is used.
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
- [Режим отображения детальной страницы](#режим-отображения-детальной-страницы)
|
||||
- [Режим фона детальной страницы](#режим-фона-детальной-страницы)
|
||||
- [Настройка фона](#настройка-фона)
|
||||
- [Фон библиотеки](#фон-библиотеки)
|
||||
- [Прелоадер](#прелоадер)
|
||||
- [Уголок источника (Лента)](#уголок-источника-лента)
|
||||
- [Цветовые схемы терминала](#цветовые-схемы-терминала)
|
||||
@@ -234,14 +235,36 @@ NAV_BUTTON_STYLE = f"""
|
||||
Вы можете управлять типом отображения карточек библиотеки прямо из темы через `styles.py`:
|
||||
|
||||
```python
|
||||
# "grid" (по умолчанию) или "list"
|
||||
# "grid", "list", "vertical", "horizontal" или "horizontal_top"
|
||||
LIBRARY_LAYOUT_MODE = "grid"
|
||||
```
|
||||
|
||||
- `grid`: многоколоночная сетка карточек (классическое поведение).
|
||||
- `list`: горизонтальные карточки-строки (формат лаунчера).
|
||||
- `vertical`: полноширинная таблица с названием игры, последним запуском,
|
||||
временем в игре и библиотекой.
|
||||
- `horizontal`: одна горизонтально прокручиваемая лента со всеми играми,
|
||||
- `horizontal_top`: первые `horizontalTopGameLimit` игр из текущих результатов
|
||||
фильтрации и сортировки, затем плитка из следующих игр для открытия полной
|
||||
сетки.
|
||||
|
||||
Геометрия карточек управляется через `GAME_CARD_LIST` и `GAME_CARD_GRID`:
|
||||
Плитка перехода в режиме `horizontal_top` настраивается константами темы:
|
||||
|
||||
```python
|
||||
horizontalTopGameLimit = 10 # Игр показывается до плитки.
|
||||
fullLibraryTileGameCount = 4 # Обложек показывается в плитке.
|
||||
fullLibraryTileSize = (180, 180) # Ширина и высота плитки.
|
||||
fullLibraryTileColumns = 2 # Столбцов обложек внутри плитки.
|
||||
fullLibraryTileRows = 2 # Строк обложек внутри плитки.
|
||||
fullLibraryTileRadius = 10 # Радиус скругления плитки в пикселях.
|
||||
```
|
||||
|
||||
Произведение `fullLibraryTileColumns * fullLibraryTileRows` должно быть не
|
||||
меньше `fullLibraryTileGameCount`. При активации плитка открывает полную сетку
|
||||
библиотеки.
|
||||
|
||||
Геометрия карточек управляется через `GAME_CARD_LIST`, `GAME_CARD_GRID` и
|
||||
`GAME_CARD_HORIZONTAL`:
|
||||
|
||||
```python
|
||||
GAME_CARD_LIST = {
|
||||
@@ -258,6 +281,7 @@ GAME_CARD_LIST = {
|
||||
}
|
||||
|
||||
GAME_CARD_GRID = {
|
||||
"card_animation_type": "gradient",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 1.5,
|
||||
@@ -265,6 +289,17 @@ GAME_CARD_GRID = {
|
||||
"cover_radius": 15,
|
||||
"border_radius": 18,
|
||||
}
|
||||
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_animation_type": "scale",
|
||||
"extra_margin": 20,
|
||||
"spacing": 5,
|
||||
"cover_aspect_ratio": 0.56,
|
||||
"card_height_ratio": 0.78,
|
||||
"cover_radius": 15,
|
||||
"layout_margins": (20, 20, 20, 20),
|
||||
"layout_spacing": 20,
|
||||
}
|
||||
```
|
||||
|
||||
Значения задаются в логических пикселях. `cover_radius` применяется к
|
||||
@@ -272,6 +307,26 @@ GAME_CARD_GRID = {
|
||||
обводке при фокусе и наведении. Ширина обложки в сеточном режиме по-прежнему
|
||||
определяется настройкой размера карточек библиотеки.
|
||||
|
||||
Параметры анимации карточек можно отдельно переопределить в
|
||||
`GAME_CARD_LIST`, `GAME_CARD_GRID` или `GAME_CARD_HORIZONTAL`: тип анимации,
|
||||
ширину обводки, pulse, углы и цвета градиента, цвета и прозрачность
|
||||
fill/stripe/glow, масштаб, длительности и easing-кривые. Например,
|
||||
`horizontal_top` может задавать собственные тип `scale`, значения масштаба,
|
||||
длительность и easing, а полная сетка — отдельные параметры `gradient`.
|
||||
Отсутствующие в режиме параметры берутся из `GAME_CARD_ANIMATION`.
|
||||
|
||||
```python
|
||||
GAME_CARD_HORIZONTAL = {
|
||||
"card_animation_type": "scale",
|
||||
"default_scale": 1.0,
|
||||
"hover_scale": 1.055,
|
||||
"focus_scale": 1.075,
|
||||
"scale_anim_duration": 135,
|
||||
"scale_easing_curve": "OutCubic",
|
||||
"scale_easing_curve_out": "InOutCubic",
|
||||
}
|
||||
```
|
||||
|
||||
Тень карточек использует общие константы темы `shadow_blur_radius` и
|
||||
`shadow_offset` как в сеточном, так и в списочном режиме.
|
||||
|
||||
@@ -452,6 +507,54 @@ DETAIL_PAGE_BACKGROUNDS = {
|
||||
`DETAIL_PAGE_GRADIENT_FY` и `DETAIL_PAGE_WAVES`. Старые значения
|
||||
переопределяют соответствующие вложенные настройки.
|
||||
|
||||
## Фон библиотеки
|
||||
|
||||
Библиотека может использовать обложку карточки, на которой находится курсор
|
||||
или фокус, как фон. Функция включается только при наличии
|
||||
`LIBRARY_BACKGROUND`; если словарь не определён, обычный фон темы библиотеки
|
||||
остаётся без изменений.
|
||||
|
||||
`LIBRARY_BACKGROUND` выбирает эффект и может локально переопределять настройки
|
||||
фона детальной страницы. Словарь `backgrounds` объединяется с
|
||||
`DETAIL_PAGE_BACKGROUNDS`, поэтому достаточно указать только отличающиеся
|
||||
значения:
|
||||
|
||||
```python
|
||||
LIBRARY_BACKGROUND = {
|
||||
"margins": (0, 0, 0, 0),
|
||||
"mode": "waves", # gradient, blur, waves, aurora, metaballs, leaf, veins, diagnostics
|
||||
"backgrounds": {
|
||||
"animation_interval_ms": 30,
|
||||
"animation_speed": 0.03,
|
||||
"blur": {
|
||||
"render_resolution": (1280, 720),
|
||||
"radius": 64,
|
||||
},
|
||||
"gradient": {
|
||||
"stops": None,
|
||||
"type": "linear",
|
||||
"x1": 0, "y1": 0, "x2": 1, "y2": 1,
|
||||
},
|
||||
"waves": {
|
||||
"layer_count": 4,
|
||||
"wave_amplitude_ratio": 0.06,
|
||||
"wave_frequency": 2.0,
|
||||
"layer_spacing_ratio": 0.04,
|
||||
"base_opacity": 0.45,
|
||||
"opacity_decay": 0.85,
|
||||
"animation_speed": 0.03,
|
||||
"animation_interval_ms": 30,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Настройки `blur` задают размер промежуточного изображения и радиус размытия.
|
||||
`margins` применяются к слою фона. Анимированные режимы используют те же
|
||||
словаря эффектов, что и `DETAIL_PAGE_BACKGROUNDS`; для переопределения
|
||||
`aurora`, `leaf`, `metaballs`, `veins` или `diagnostics` скопируйте нужный
|
||||
словарь из описания выше.
|
||||
|
||||
---
|
||||
|
||||
Словарь `PRELOADER` управляет стилем и анимацией индикатора загрузки. Если не определён, используется стандартный спиннер.
|
||||
|
||||
@@ -30,7 +30,16 @@ class DetailBackgroundAnimations:
|
||||
if effect not in self.effects:
|
||||
self.remove(page)
|
||||
return False
|
||||
config = theme.DETAIL_PAGE_BACKGROUNDS
|
||||
config = getattr(theme, "DETAIL_PAGE_BACKGROUNDS", {})
|
||||
animated = not mode.startswith("static_")
|
||||
if not isinstance(config, dict) or not isinstance(config.get(effect), dict):
|
||||
self.remove(page)
|
||||
return False
|
||||
if animated and not all(
|
||||
key in config for key in ("animation_interval_ms", "animation_speed")
|
||||
):
|
||||
self.remove(page)
|
||||
return False
|
||||
self.remove(page)
|
||||
state = {
|
||||
"effect": effect,
|
||||
@@ -47,7 +56,7 @@ class DetailBackgroundAnimations:
|
||||
self._paint(page, state)
|
||||
|
||||
page.paintEvent = paint_event
|
||||
if not mode.startswith("static_"):
|
||||
if animated:
|
||||
self._start_timer(page, state)
|
||||
return True
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from math import radians, sin
|
||||
from typing import Any
|
||||
import warnings
|
||||
|
||||
from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, Qt
|
||||
@@ -22,6 +23,25 @@ class GameCardAnimations:
|
||||
self.pulse_anim: QPropertyAnimation | None = None
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
def _animation_type(self) -> str:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
return layout_config.get(
|
||||
"card_animation_type",
|
||||
self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient"),
|
||||
)
|
||||
|
||||
def _config_value(self, key: str) -> Any:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
if key in layout_config:
|
||||
return layout_config[key]
|
||||
return self.theme.GAME_CARD_ANIMATION[key]
|
||||
|
||||
def _optional_config_value(self, key: str, default: Any) -> Any:
|
||||
layout_config = getattr(self.game_card, "card_layout_cfg", {})
|
||||
if key in layout_config:
|
||||
return layout_config[key]
|
||||
return self.theme.GAME_CARD_ANIMATION.get(key, default)
|
||||
|
||||
def _disconnect_pulse_animation(self) -> None:
|
||||
if not self._isPulseAnimationConnected or self.thickness_anim is None:
|
||||
return
|
||||
@@ -49,20 +69,20 @@ class GameCardAnimations:
|
||||
def _restart_gradient_animation(self) -> None:
|
||||
self._stop_animation("gradient_anim")
|
||||
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.setDuration(self._config_value("gradient_anim_duration"))
|
||||
self.gradient_anim.setStartValue(self._config_value("gradient_start_angle"))
|
||||
self.gradient_anim.setEndValue(self._config_value("gradient_end_angle"))
|
||||
self.gradient_anim.setLoopCount(-1)
|
||||
self.gradient_anim.start()
|
||||
|
||||
def _easing_curve(self, easing_key: str) -> QEasingCurve:
|
||||
easing_type = QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION[easing_key]]
|
||||
easing_type = QEasingCurve.Type[self._config_value(easing_key)]
|
||||
return QEasingCurve(easing_type)
|
||||
|
||||
def _restart_scale_animation(self, end_value: float, easing_key: str) -> None:
|
||||
self._stop_animation("scale_anim")
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
|
||||
self.scale_anim.setEasingCurve(self._easing_curve(easing_key))
|
||||
self.scale_anim.setStartValue(self.game_card._scale)
|
||||
self.scale_anim.setEndValue(end_value)
|
||||
@@ -89,18 +109,47 @@ class GameCardAnimations:
|
||||
self._stop_animation("pulse_anim")
|
||||
self._isPulseAnimationConnected = False
|
||||
|
||||
def refresh_theme(self, theme: Any) -> None:
|
||||
"""Recreate animation state for a live theme change."""
|
||||
self.cleanup()
|
||||
self.theme = theme
|
||||
animation_type = self._animation_type()
|
||||
self.game_card._gradientAngle = self._config_value("gradient_start_angle")
|
||||
if self.game_card._hovered:
|
||||
self.game_card._borderWidth = self._config_value("hover_border_width")
|
||||
active_scale = self._config_value("hover_scale")
|
||||
elif self.game_card._focused:
|
||||
self.game_card._borderWidth = self._config_value("focus_border_width")
|
||||
active_scale = self._config_value("focus_scale")
|
||||
else:
|
||||
self.game_card._borderWidth = self._config_value("default_border_width")
|
||||
active_scale = self._config_value("default_scale")
|
||||
self.game_card._scale = (
|
||||
active_scale
|
||||
if animation_type in {"scale", "scale_fill"}
|
||||
else self._config_value("default_scale")
|
||||
)
|
||||
self.setup_animations()
|
||||
if (self.game_card._hovered or self.game_card._focused) and animation_type in {
|
||||
"gradient", "glow",
|
||||
}:
|
||||
self._restart_gradient_animation()
|
||||
if self.game_card._hovered or self.game_card._focused:
|
||||
self.start_pulse_animation()
|
||||
self.game_card.update()
|
||||
|
||||
def setup_animations(self):
|
||||
"""Initialize animation properties based on theme."""
|
||||
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
|
||||
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
|
||||
self.thickness_anim.setDuration(self._config_value("thickness_anim_duration"))
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
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.setDuration(self._config_value("gradient_anim_duration"))
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
|
||||
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
|
||||
self.scale_anim.setDuration(self._config_value("scale_anim_duration"))
|
||||
|
||||
def start_pulse_animation(self):
|
||||
"""Start pulse animation for border width when hovered or focused."""
|
||||
@@ -110,11 +159,11 @@ class GameCardAnimations:
|
||||
self._stop_animation("pulse_anim")
|
||||
|
||||
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.setDuration(self._config_value("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.setKeyValueAt(0, self._config_value("pulse_min_border_width"))
|
||||
self.pulse_anim.setKeyValueAt(0.5, self._config_value("pulse_max_border_width"))
|
||||
self.pulse_anim.setKeyValueAt(1, self._config_value("pulse_min_border_width"))
|
||||
self.pulse_anim.start()
|
||||
|
||||
def handle_enter_event(self):
|
||||
@@ -126,10 +175,10 @@ class GameCardAnimations:
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["hover_border_width"],
|
||||
self._config_value("hover_border_width"),
|
||||
"thickness_easing_curve",
|
||||
True,
|
||||
)
|
||||
@@ -137,21 +186,21 @@ class GameCardAnimations:
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._restart_gradient_animation()
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["hover_scale"], "scale_easing_curve")
|
||||
self._restart_scale_animation(self._config_value("hover_scale"), "scale_easing_curve")
|
||||
|
||||
def handle_leave_event(self):
|
||||
"""Handle mouse leave event animations."""
|
||||
self.game_card._hovered = False
|
||||
self.game_card.hoverChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._focused:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._stop_animation("gradient_anim")
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
|
||||
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
|
||||
self._stop_animation("pulse_anim")
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["default_border_width"],
|
||||
self._config_value("default_border_width"),
|
||||
"thickness_easing_curve_out",
|
||||
False,
|
||||
)
|
||||
@@ -165,10 +214,10 @@ class GameCardAnimations:
|
||||
if not self.thickness_anim:
|
||||
self.setup_animations()
|
||||
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["focus_border_width"],
|
||||
self._config_value("focus_border_width"),
|
||||
"thickness_easing_curve",
|
||||
True,
|
||||
)
|
||||
@@ -176,21 +225,21 @@ class GameCardAnimations:
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._restart_gradient_animation()
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["focus_scale"], "scale_easing_curve")
|
||||
self._restart_scale_animation(self._config_value("focus_scale"), "scale_easing_curve")
|
||||
|
||||
def handle_focus_out_event(self):
|
||||
"""Handle focus out event animations."""
|
||||
self.game_card._focused = False
|
||||
self.game_card.focusChanged.emit(self.game_card.name, False)
|
||||
if not self.game_card._hovered:
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if animation_type in {"gradient", "glow"}:
|
||||
self._stop_animation("gradient_anim")
|
||||
elif animation_type in {"scale", "scale_fill"}:
|
||||
self._restart_scale_animation(self.theme.GAME_CARD_ANIMATION["default_scale"], "scale_easing_curve_out")
|
||||
self._restart_scale_animation(self._config_value("default_scale"), "scale_easing_curve_out")
|
||||
self._stop_animation("pulse_anim")
|
||||
self._start_thickness_animation(
|
||||
self.theme.GAME_CARD_ANIMATION["default_border_width"],
|
||||
self._config_value("default_border_width"),
|
||||
"thickness_easing_curve_out",
|
||||
False,
|
||||
)
|
||||
@@ -203,39 +252,39 @@ class GameCardAnimations:
|
||||
pen = QPen()
|
||||
pen.setWidth(self.game_card._borderWidth)
|
||||
fill_brush = QBrush(Qt.BrushStyle.NoBrush)
|
||||
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
|
||||
animation_type = self._animation_type()
|
||||
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
|
||||
center = self.game_card.rect().center()
|
||||
gradient = QConicalGradient(center, self.game_card._gradientAngle)
|
||||
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
|
||||
for stop in self._config_value("gradient_colors"):
|
||||
gradient.setColorAt(stop["position"], QColor(stop["color"]))
|
||||
pen.setBrush(QBrush(gradient))
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type in {"fill", "scale_fill"}:
|
||||
fill_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
fill_color_value = self._optional_config_value(
|
||||
"fill_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
fill_alpha = int(self.theme.GAME_CARD_ANIMATION.get("fill_alpha", 90))
|
||||
fill_alpha = int(self._optional_config_value("fill_alpha", 90))
|
||||
fill_color = QColor(fill_color_value)
|
||||
fill_color.setAlpha(max(0, min(255, fill_alpha)))
|
||||
fill_brush = QBrush(fill_color)
|
||||
pen.setColor(QColor(0, 0, 0, 0))
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "stripe":
|
||||
stripe_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
stripe_color_value = self._optional_config_value(
|
||||
"stripe_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
stripe_alpha = int(self.theme.GAME_CARD_ANIMATION.get("stripe_alpha", 255))
|
||||
stripe_alpha = int(self._optional_config_value("stripe_alpha", 255))
|
||||
stripe_color = QColor(stripe_color_value)
|
||||
stripe_color.setAlpha(max(0, min(255, stripe_alpha)))
|
||||
pen.setColor(stripe_color)
|
||||
elif (self.game_card._hovered or self.game_card._focused) and animation_type == "glow":
|
||||
glow_color_value = self.theme.GAME_CARD_ANIMATION.get(
|
||||
glow_color_value = self._optional_config_value(
|
||||
"stripe_color",
|
||||
getattr(self.theme, "color_accent", self.theme.color_text),
|
||||
)
|
||||
glow_base_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_base_alpha", 120))
|
||||
glow_pulse_alpha = int(self.theme.GAME_CARD_ANIMATION.get("glow_pulse_alpha", 80))
|
||||
glow_base_alpha = int(self._optional_config_value("glow_base_alpha", 120))
|
||||
glow_pulse_alpha = int(self._optional_config_value("glow_pulse_alpha", 80))
|
||||
glow_wave = 0.5 + 0.5 * sin(radians(self.game_card._gradientAngle))
|
||||
glow_alpha = max(0, min(255, glow_base_alpha + int(glow_pulse_alpha * glow_wave)))
|
||||
glow_color = QColor(glow_color_value)
|
||||
|
||||
+109
-31
@@ -312,6 +312,7 @@ def main():
|
||||
autoinstall_path = None
|
||||
theme_store_id = None
|
||||
gog_launch_uri = None
|
||||
egs_launch_uri = None
|
||||
resolution_from_args = None
|
||||
if args.resolution:
|
||||
resolution_from_args = parse_resolution(args.resolution)
|
||||
@@ -325,14 +326,11 @@ def main():
|
||||
prefix_name, backup_dir = args.create_backup
|
||||
backup_request = (prefix_name, os.path.abspath(os.path.expanduser(backup_dir)))
|
||||
ipc_message = "backup:{}:{}".format(quote(prefix_name, safe=""), quote(backup_request[1], safe=""))
|
||||
elif is_silent_launch and not args.log and args.file_or_url and is_launch_file(args.file_or_url):
|
||||
run_silent_tray(app, start_sh, normalize_launch_path(args.file_or_url))
|
||||
sys.exit(app.exec())
|
||||
elif is_restore_prefix_request(args):
|
||||
restore_prefix_path = normalize_launch_path(args.file_or_url)
|
||||
ipc_message = f"restore:{quote(restore_prefix_path, safe='')}"
|
||||
elif args.file_or_url and is_launch_file(args.file_or_url):
|
||||
launch_action = "log" if args.log else "open"
|
||||
launch_action = "silent" if is_silent_launch and not args.log else "log" if args.log else "open"
|
||||
ipc_message = f"{launch_action}:{normalize_launch_path(args.file_or_url)}"
|
||||
elif args.file_or_url and is_autoinstall_file(args.file_or_url):
|
||||
autoinstall_path = normalize_launch_path(args.file_or_url)
|
||||
@@ -341,7 +339,18 @@ def main():
|
||||
app_id = args.file_or_url.removeprefix("gog://launch/")
|
||||
if app_id.isdigit():
|
||||
gog_launch_uri = args.file_or_url
|
||||
ipc_message = f"gog:{app_id}"
|
||||
if args.log:
|
||||
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
|
||||
else:
|
||||
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"gog:{app_id}"
|
||||
elif args.file_or_url and args.file_or_url.startswith("egs://launch/"):
|
||||
app_id = args.file_or_url.removeprefix("egs://launch/")
|
||||
if app_id and "/" not in app_id:
|
||||
egs_launch_uri = args.file_or_url
|
||||
if args.log:
|
||||
ipc_message = f"log:{quote(args.file_or_url, safe='')}"
|
||||
else:
|
||||
ipc_message = f"silent:{quote(args.file_or_url, safe='')}" if is_silent_launch else f"egs:{app_id}"
|
||||
elif args.file_or_url:
|
||||
theme_store_id = parse_portprotonqt_theme_url(args.file_or_url)
|
||||
if theme_store_id is not None:
|
||||
@@ -410,7 +419,7 @@ def main():
|
||||
logger.warning("Failed to persist PORT_DATA_PATH in PortProtonQt config")
|
||||
|
||||
# Check if we have a portproton:// URL or launch file to handle
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri:
|
||||
if args.file_or_url and not restore_prefix_path and not gog_launch_uri and not egs_launch_uri:
|
||||
if is_portproton_url(args.file_or_url):
|
||||
# Parse the portproton:// URL to get the full download URL
|
||||
download_url = parse_portproton_url(args.file_or_url)
|
||||
@@ -494,13 +503,59 @@ def main():
|
||||
logger.warning(f"Invalid resolution format: {args.resolution}, expected WIDTHxHEIGHT (e.g. 1920x1080)")
|
||||
|
||||
launch_path = exe_path or autoinstall_path
|
||||
silent_game_request = is_silent_launch and not args.log and bool(
|
||||
exe_path or gog_launch_uri or egs_launch_uri
|
||||
)
|
||||
window = MainWindow(app_name=__app_name__, version=version, launch_exe=launch_path, resolution=window_resolution, show_system_tab=args.ppqtos)
|
||||
window.silent_launch_mode = silent_game_request
|
||||
if silent_game_request:
|
||||
if exe_path:
|
||||
window.tray_manager.tray_icon.hide()
|
||||
else:
|
||||
window.tray_manager.set_minimal_mode()
|
||||
|
||||
def open_store_game_card(source: str, app_id: str, log_mode: bool = False) -> None:
|
||||
exec_line = f"{source}://launch/{app_id}"
|
||||
if log_mode:
|
||||
window._pending_log_exe = getattr(window, f"{source}_api").get_launch_target(app_id)
|
||||
|
||||
def open_card(games: list[tuple]) -> None:
|
||||
game = next((item for item in games if item[5] == exec_line), None)
|
||||
if game is None:
|
||||
return
|
||||
window.openGameDetailPage({
|
||||
"name": game[0], "description": game[1], "cover_path": game[2],
|
||||
"appid": game[3], "controller_support": game[4],
|
||||
"exec_line": game[5], "last_launch": game[6],
|
||||
"formatted_playtime": game[7], "protondb_tier": game[8],
|
||||
"anticheat_status": game[9], "playtime_seconds": game[11],
|
||||
"game_source": game[12], "anticheat_slug": game[13],
|
||||
"ppdb_id": game[14], "ppdb_rating": game[15],
|
||||
"protondb_appid": game[16],
|
||||
})
|
||||
|
||||
loader = getattr(window, f"_load_{source}_games_async")
|
||||
loader(open_card)
|
||||
|
||||
def handle_game_request(target: str, silent: bool, log_mode: bool = False) -> None:
|
||||
if is_launch_file(target):
|
||||
path = normalize_launch_path(target)
|
||||
if silent:
|
||||
run_silent_tray(app, start_sh, path)
|
||||
return
|
||||
window.handle_launch_exe(path, log_mode=log_mode)
|
||||
return
|
||||
source, app_id = target.split("://launch/", 1)
|
||||
if silent:
|
||||
window.toggleGame(target)
|
||||
return
|
||||
open_store_game_card(source, app_id, log_mode)
|
||||
|
||||
# Handle launch file if provided
|
||||
if exe_path:
|
||||
# Defer the call until after the window is shown
|
||||
def handle_launch_exe():
|
||||
window.handle_launch_exe(exe_path, log_mode=args.log)
|
||||
def handle_launch_exe() -> None:
|
||||
handle_game_request(exe_path, is_silent_launch and not args.log, args.log)
|
||||
QTimer.singleShot(0, handle_launch_exe)
|
||||
elif autoinstall_path:
|
||||
def handle_autoinstall():
|
||||
@@ -515,9 +570,13 @@ def main():
|
||||
window._perform_restore(restore_prefix_path)
|
||||
QTimer.singleShot(0, handle_restore_prefix)
|
||||
elif gog_launch_uri:
|
||||
def handle_gog_launch():
|
||||
window.toggleGame(gog_launch_uri)
|
||||
def handle_gog_launch() -> None:
|
||||
handle_game_request(gog_launch_uri, is_silent_launch, args.log)
|
||||
QTimer.singleShot(0, handle_gog_launch)
|
||||
elif egs_launch_uri:
|
||||
def handle_egs_launch() -> None:
|
||||
handle_game_request(egs_launch_uri, is_silent_launch, args.log)
|
||||
QTimer.singleShot(0, handle_egs_launch)
|
||||
|
||||
# --- Handle incoming connections ---
|
||||
def handle_new_connection():
|
||||
@@ -536,20 +595,24 @@ def main():
|
||||
msg.startswith("show")
|
||||
or msg.startswith("open:")
|
||||
or msg.startswith("log:")
|
||||
or msg.startswith("silent:")
|
||||
or msg.startswith("restore:")
|
||||
or msg.startswith("backup:")
|
||||
or msg.startswith("theme:")
|
||||
or msg.startswith("autoinstall:")
|
||||
or msg.startswith("gog:")
|
||||
or msg.startswith("egs:")
|
||||
):
|
||||
# Ensure the window is visible and not minimized
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
if not msg.startswith("silent:"):
|
||||
window.setWindowState(window.windowState() & ~Qt.WindowState.WindowMinimized)
|
||||
window.show()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
|
||||
# Ensure window is in active state for systems with strict focus policies
|
||||
window.setWindowState(window.windowState() | Qt.WindowState.WindowActive)
|
||||
# Ensure active state for strict focus policies
|
||||
window.setWindowState(
|
||||
window.windowState() | Qt.WindowState.WindowActive
|
||||
)
|
||||
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
@@ -561,12 +624,17 @@ def main():
|
||||
display_config.set_fullscreen(False)
|
||||
window.showNormal()
|
||||
|
||||
if msg.startswith("open:") or msg.startswith("log:"):
|
||||
if msg.startswith("silent:"):
|
||||
target = unquote(msg[7:].strip())
|
||||
handle_game_request(target, True)
|
||||
elif msg.startswith("open:") or msg.startswith("log:"):
|
||||
log_mode = msg.startswith("log:")
|
||||
launch_path = msg.split(":", 1)[1].strip()
|
||||
launch_path = unquote(msg.split(":", 1)[1].strip())
|
||||
if launch_path and is_launch_file(launch_path):
|
||||
logger.info("Opening launch file via IPC: %s", launch_path)
|
||||
window.handle_launch_exe(launch_path, log_mode=log_mode)
|
||||
handle_game_request(launch_path, False, log_mode)
|
||||
elif log_mode and launch_path.startswith(("gog://launch/", "egs://launch/")):
|
||||
handle_game_request(unquote(launch_path), False, True)
|
||||
else:
|
||||
logger.warning("Invalid launch file via IPC: %s", launch_path)
|
||||
elif msg.startswith("restore:"):
|
||||
@@ -579,9 +647,15 @@ def main():
|
||||
elif msg.startswith("gog:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id.isdigit():
|
||||
window.toggleGame(f"gog://launch/{app_id}")
|
||||
handle_game_request(f"gog://launch/{app_id}", False)
|
||||
else:
|
||||
logger.warning("Invalid GOG app id via IPC: %s", app_id)
|
||||
elif msg.startswith("egs:"):
|
||||
app_id = msg[4:].strip()
|
||||
if app_id and "/" not in app_id:
|
||||
handle_game_request(f"egs://launch/{app_id}", False)
|
||||
else:
|
||||
logger.warning("Invalid Epic app id via IPC: %s", app_id)
|
||||
elif msg.startswith("backup:"):
|
||||
parts = msg.split(":", 2)
|
||||
if len(parts) == 3:
|
||||
@@ -624,16 +698,20 @@ def main():
|
||||
and getattr(window.input_manager, "gamepad", None) is not None
|
||||
)
|
||||
launch_minimized = (
|
||||
display_config.get_start_minimized()
|
||||
and not args.fullscreen
|
||||
and not launch_auto_fullscreen
|
||||
and window_resolution is None
|
||||
and exe_path is None
|
||||
and backup_request is None
|
||||
and restore_prefix_path is None
|
||||
and autoinstall_path is None
|
||||
and theme_store_id is None
|
||||
and gog_launch_uri is None
|
||||
silent_game_request
|
||||
or (
|
||||
display_config.get_start_minimized()
|
||||
and not args.fullscreen
|
||||
and not launch_auto_fullscreen
|
||||
and window_resolution is None
|
||||
and exe_path is None
|
||||
and backup_request is None
|
||||
and restore_prefix_path is None
|
||||
and autoinstall_path is None
|
||||
and theme_store_id is None
|
||||
and gog_launch_uri is None
|
||||
and egs_launch_uri is None
|
||||
)
|
||||
)
|
||||
if launch_minimized:
|
||||
logger.info("Launching in tray")
|
||||
|
||||
@@ -15,11 +15,12 @@ from portprotonqt.logger import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
APP_ID = "ru.linux_gaming.PortProtonQt"
|
||||
MIME_PACKAGE_NAME = f"{APP_ID}.xml"
|
||||
DESKTOP_DATABASE_TIMEOUT = 10
|
||||
EXECUTABLE_MODE = 0o755
|
||||
WINDOWS_MIME_TYPES = (
|
||||
"application/x-ms-dos-executable;application/x-msdos-program;"
|
||||
"application/x-ms-dos-exec;application/x-executable;"
|
||||
"application/x-ms-dos-exec;"
|
||||
"application/x-dosexec;application/vnd.microsoft.portable-executable;"
|
||||
)
|
||||
INTEGRATION_MODES = ("log", "silent")
|
||||
@@ -141,6 +142,27 @@ def _get_appimage_metadata(appdir: Path) -> tuple[Path, Path]:
|
||||
return desktop, icon
|
||||
|
||||
|
||||
def _install_mime_package(appdir: Path, data_home: Path) -> None:
|
||||
source = _find_appimage_asset(
|
||||
appdir,
|
||||
(
|
||||
f"share/mime/packages/{MIME_PACKAGE_NAME}",
|
||||
f"usr/share/mime/packages/{MIME_PACKAGE_NAME}",
|
||||
),
|
||||
)
|
||||
if not source:
|
||||
raise FileNotFoundError("AppImage MIME metadata is incomplete")
|
||||
mime_dir = data_home / "mime"
|
||||
packages_dir = mime_dir / "packages"
|
||||
packages_dir.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(source, packages_dir / MIME_PACKAGE_NAME)
|
||||
subprocess.run(
|
||||
["update-mime-database", str(mime_dir)],
|
||||
check=True,
|
||||
timeout=DESKTOP_DATABASE_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
def integrate_appimage() -> Path:
|
||||
"""Install the running AppImage and its desktop handlers for this user."""
|
||||
source = Path(os.environ["APPIMAGE"]).expanduser()
|
||||
@@ -171,6 +193,7 @@ def integrate_appimage() -> Path:
|
||||
_remove_existing_integration(applications_dir, icon_dir)
|
||||
shutil.copy2(resolved_icon, icon)
|
||||
_install_desktop_files(desktop_source, applications_dir, destination)
|
||||
_install_mime_package(appdir, data_home)
|
||||
_set_default_mime_handlers()
|
||||
try:
|
||||
subprocess.run(
|
||||
|
||||
@@ -28,7 +28,7 @@ from portprotonqt.scripts_utils.graphics_detector import analyze_executable
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
CRASH_THRESHOLD_SECONDS = 5
|
||||
CRASH_THRESHOLD_SECONDS = 7
|
||||
COMPATIBILITY_ALWAYS_REPORT_ENV = "PORTPROTONQT_COMPATIBILITY_ALWAYS_REPORT"
|
||||
PE_MACHINE_AMD64 = 0x8664
|
||||
COMPATIBILITY_SCAN_BYTES = 4_194_304
|
||||
|
||||
@@ -546,8 +546,8 @@ def create_desktop_file(
|
||||
) -> tuple[str, str, str] | None:
|
||||
"""Create desktop entry content, destination path and icon path for a game."""
|
||||
portproton_path = get_portproton_location()
|
||||
is_gog_uri = re.fullmatch(r"gog://launch/\d+", exe_path) is not None
|
||||
if not is_gog_uri and not os.path.isfile(exe_path):
|
||||
is_store_uri = re.fullmatch(r"(?:gog|egs)://launch/[^/]+", exe_path) is not None
|
||||
if not is_store_uri and not os.path.isfile(exe_path):
|
||||
logger.error("Executable not found: %s", exe_path)
|
||||
return None
|
||||
if not portproton_path:
|
||||
@@ -560,7 +560,7 @@ def create_desktop_file(
|
||||
icon_name = _sanitize_icon_name(game_name)
|
||||
has_icon_source = bool(icon_source and os.path.isfile(icon_source))
|
||||
icon_path = (
|
||||
"applications-games" if is_gog_uri and not has_icon_source
|
||||
"applications-games" if is_store_uri and not has_icon_source
|
||||
else os.path.join(base_path, "img", f"{icon_name}.png")
|
||||
)
|
||||
desktop_path = os.path.join(portproton_path, f"{game_name}.desktop")
|
||||
|
||||
@@ -357,6 +357,22 @@ class UIConfig(BaseConfig):
|
||||
validate_bool(hide, "hide_control_hints")
|
||||
self._save_value("hide_control_hints", hide, "bool")
|
||||
|
||||
def get_force_english(self) -> bool:
|
||||
"""Get forced English language setting."""
|
||||
cp = self._read_config()
|
||||
if cp is None:
|
||||
return False
|
||||
try:
|
||||
return cp.getboolean(self._section, "force_english", fallback=False)
|
||||
except (configparser.Error, ValueError) as e:
|
||||
logger.warning("Error reading force_english: %s", e)
|
||||
return False
|
||||
|
||||
def set_force_english(self, enabled: bool) -> None:
|
||||
"""Set forced English language setting."""
|
||||
validate_bool(enabled, "force_english")
|
||||
self._save_value("force_english", enabled, "bool")
|
||||
|
||||
def get_badge_view_mode(self) -> str:
|
||||
"""Get badge view mode ('detailed' or 'compact')."""
|
||||
mode = self._get_str("badge_view_mode", "detailed")
|
||||
@@ -430,7 +446,7 @@ class UIConfig(BaseConfig):
|
||||
|
||||
def get_auto_download_ppdb(self) -> bool:
|
||||
"""Get PPDB auto-download setting."""
|
||||
return self._get_download_bool("auto_download_ppdb", True)
|
||||
return self._get_download_bool("auto_download_ppdb", False)
|
||||
|
||||
def set_auto_download_ppdb(self, enabled: bool) -> None:
|
||||
"""Set PPDB auto-download setting."""
|
||||
|
||||
@@ -471,12 +471,12 @@ class ContextMenuManager:
|
||||
return
|
||||
|
||||
# Normal menu for games with valid exe or from Steam
|
||||
is_uninstalled_gog = (
|
||||
game_card.game_source == "gog"
|
||||
and game_card.exec_line.startswith("gog://install/")
|
||||
is_uninstalled_store_game = (
|
||||
game_card.game_source in ("gog", "egs")
|
||||
and game_card.exec_line.startswith(("gog://install/", "egs://install/"))
|
||||
)
|
||||
is_running = self._is_game_running(game_card)
|
||||
if not is_uninstalled_gog:
|
||||
if not is_uninstalled_store_game:
|
||||
action_text = _("Stop Game") if is_running else _("Launch Game")
|
||||
action_icon = "stop" if is_running else "play"
|
||||
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text)
|
||||
@@ -545,6 +545,69 @@ class ContextMenuManager:
|
||||
lambda: self.parent._import_gog_game(game)
|
||||
)
|
||||
|
||||
if game_card.game_source == "egs":
|
||||
app_id = str(game_card.appid)
|
||||
if game_card.exec_line.startswith("egs://launch/"):
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
)
|
||||
desktop_path = self._get_shortcut_path(game_card.name, desktop_dir)
|
||||
desktop_exists = os.path.exists(desktop_path)
|
||||
desktop_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if desktop_exists else "desktop"),
|
||||
_("Remove from Desktop") if desktop_exists else _("Add to Desktop"),
|
||||
)
|
||||
desktop_action.triggered.connect(
|
||||
lambda: self.remove_from_desktop(game_card.name)
|
||||
if desktop_exists
|
||||
else self.add_to_desktop(game_card.name, game_card.exec_line)
|
||||
)
|
||||
menu_path = self._get_menu_shortcut_path(game_card.name)
|
||||
menu_exists = os.path.exists(menu_path)
|
||||
menu_action = menu.addAction(
|
||||
self._get_safe_icon("delete" if menu_exists else "menu"),
|
||||
_("Remove from Menu") if menu_exists else _("Add to Menu"),
|
||||
)
|
||||
menu_action.triggered.connect(
|
||||
lambda: self.remove_from_menu(game_card.name)
|
||||
if menu_exists
|
||||
else self.add_to_menu(game_card.name, game_card.exec_line)
|
||||
)
|
||||
update_action = menu.addAction(
|
||||
self._get_safe_icon("update"), _("Update")
|
||||
)
|
||||
update_action.triggered.connect(
|
||||
lambda: self.parent._update_egs_game(app_id)
|
||||
)
|
||||
repair_action = menu.addAction(
|
||||
self._get_safe_icon("update"), _("Repair")
|
||||
)
|
||||
repair_action.triggered.connect(
|
||||
lambda: self.parent._repair_egs_game(app_id)
|
||||
)
|
||||
eos_enabled = self.parent._is_egs_overlay_enabled(app_id)
|
||||
eos_action = menu.addAction(
|
||||
self._get_safe_icon("update"),
|
||||
_("Disable EOS Overlay")
|
||||
if eos_enabled else _("Enable EOS Overlay"),
|
||||
)
|
||||
eos_action.triggered.connect(
|
||||
lambda: self.parent._enable_egs_overlay(app_id)
|
||||
)
|
||||
delete_action = menu.addAction(
|
||||
self._get_safe_icon("delete"), _("Delete")
|
||||
)
|
||||
delete_action.triggered.connect(
|
||||
lambda: self.parent._delete_egs_game(app_id)
|
||||
)
|
||||
else:
|
||||
import_action = menu.addAction(
|
||||
self._get_safe_icon("folder"), _("Import")
|
||||
)
|
||||
import_action.triggered.connect(
|
||||
lambda: self.parent._import_egs_game(app_id)
|
||||
)
|
||||
|
||||
if game_card.game_source == "steam":
|
||||
desktop_dir = QStandardPaths.writableLocation(
|
||||
QStandardPaths.StandardLocation.DesktopLocation
|
||||
@@ -997,15 +1060,15 @@ class ContextMenuManager:
|
||||
"""Add the game desktop entry to the XDG applications directory."""
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_gog_uri and not os.path.exists(desktop_path):
|
||||
if not is_store_uri and not os.path.exists(desktop_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
if not is_gog_uri:
|
||||
if not is_store_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@@ -1013,9 +1076,9 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
if is_store_uri:
|
||||
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
@@ -1068,16 +1131,16 @@ class ContextMenuManager:
|
||||
|
||||
if not self._check_portproton():
|
||||
return
|
||||
is_gog_uri = str(exec_line).startswith("gog://launch/")
|
||||
is_store_uri = str(exec_line).startswith(("gog://launch/", "egs://launch/"))
|
||||
desktop_path = self._get_desktop_path(game_name)
|
||||
if not is_gog_uri and not os.path.exists(desktop_path):
|
||||
if not is_store_uri and not os.path.exists(desktop_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("No .desktop file found for '{game_name}'").format(game_name=game_name)
|
||||
)
|
||||
return
|
||||
# Ensure icon exists
|
||||
if not is_gog_uri:
|
||||
if not is_store_uri:
|
||||
exec_line = self._get_exec_line(game_name, exec_line)
|
||||
if not exec_line:
|
||||
return
|
||||
@@ -1085,9 +1148,9 @@ class ContextMenuManager:
|
||||
if not exec_line:
|
||||
return
|
||||
icon_source = None
|
||||
if is_gog_uri:
|
||||
app_id = str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = self.parent.gog_api.get_launch_target(app_id)
|
||||
if is_store_uri:
|
||||
source, app_id = exec_line.split("://", 1)[0], str(exec_line).rsplit("/", 1)[-1]
|
||||
icon_source = getattr(self.parent, f"{source}_api").get_launch_target(app_id)
|
||||
shortcut = create_desktop_file(exec_line, game_name, icon_source)
|
||||
if not shortcut:
|
||||
return
|
||||
|
||||
@@ -86,6 +86,9 @@ class DetailPageManager:
|
||||
self._gog_size_requests: dict[
|
||||
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
|
||||
] = {}
|
||||
self._egs_size_requests: dict[
|
||||
str, tuple[QProcess, list[list[Callable[[], QLabel | None]]]]
|
||||
] = {}
|
||||
|
||||
def openGameDetailPage(self, game_data: dict) -> None:
|
||||
"""Open detailed game information page."""
|
||||
@@ -310,6 +313,7 @@ class DetailPageManager:
|
||||
game_info_layout.addLayout(first_row)
|
||||
|
||||
self._setup_gog_size_data(game_data, game_info_layout)
|
||||
self._setup_egs_size_data(game_data, game_info_layout)
|
||||
|
||||
hltb_layout = QHBoxLayout()
|
||||
hltb_layout.setSpacing(10)
|
||||
@@ -365,6 +369,75 @@ class DetailPageManager:
|
||||
)
|
||||
process.start()
|
||||
|
||||
def _setup_egs_size_data(
|
||||
self, game_data: dict, game_info_layout: QVBoxLayout
|
||||
) -> None:
|
||||
exec_line = str(game_data.get("exec_line", ""))
|
||||
if str(game_data.get("game_source", "")).lower() != "egs":
|
||||
return
|
||||
if not exec_line.startswith("egs://install/"):
|
||||
return
|
||||
row = QHBoxLayout()
|
||||
values = []
|
||||
for title_text in (_("Download"), _("Size")):
|
||||
title = QLabel(title_text.upper())
|
||||
title.setStyleSheet(self.main_window.theme.LAST_LAUNCH_TITLE_STYLE)
|
||||
value = QLabel("—")
|
||||
value.setStyleSheet(self.main_window.theme.LAST_LAUNCH_VALUE_STYLE)
|
||||
row.addWidget(title)
|
||||
row.addWidget(value)
|
||||
row.addSpacing(30)
|
||||
values.append(weakref.ref(value))
|
||||
game_info_layout.addLayout(row)
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
api = self.main_window.egs_api
|
||||
cached = api.get_cached_download_sizes(app_id)
|
||||
if cached is not None:
|
||||
self._set_gog_size_values(values, cached)
|
||||
return
|
||||
request = self._egs_size_requests.get(app_id)
|
||||
if request is not None:
|
||||
request[1].append(values)
|
||||
return
|
||||
try:
|
||||
command = api.build_command(["info", app_id, "--json", "--platform", "Windows"])
|
||||
except (FileNotFoundError, OSError) as error:
|
||||
logger.warning("Failed to get EGS download size: %s", error)
|
||||
return
|
||||
process = QProcess(self.main_window)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
environment.insert("LEGENDARY_CONFIG_PATH", str(api.config_dir))
|
||||
process.setProcessEnvironment(environment)
|
||||
self._egs_size_requests[app_id] = (process, [values])
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._finish_egs_size_request(app_id, code)
|
||||
)
|
||||
process.start()
|
||||
|
||||
def _finish_egs_size_request(self, app_id: str, code: int) -> None:
|
||||
request = self._egs_size_requests.pop(app_id, None)
|
||||
if request is None:
|
||||
return
|
||||
process, value_groups = request
|
||||
if code != 0:
|
||||
logger.warning("legendary info exited with code %s", code)
|
||||
process.deleteLater()
|
||||
return
|
||||
try:
|
||||
sizes = self.main_window.egs_api.parse_download_sizes(
|
||||
bytes(process.readAllStandardOutput().data())
|
||||
)
|
||||
except (ValueError, TypeError) as error:
|
||||
logger.warning("Failed to parse EGS download size: %s", error)
|
||||
process.deleteLater()
|
||||
return
|
||||
self.main_window.egs_api.save_download_sizes(app_id, sizes)
|
||||
for value_refs in value_groups:
|
||||
self._set_gog_size_values(value_refs, sizes)
|
||||
process.deleteLater()
|
||||
|
||||
def _finish_gog_size_request(self, app_id: str, code: int) -> None:
|
||||
request = self._gog_size_requests.pop(app_id, None)
|
||||
if request is None:
|
||||
@@ -531,7 +604,9 @@ class DetailPageManager:
|
||||
|
||||
current_exe = self._get_current_exe(exec_line)
|
||||
play_button = self._create_play_button(exec_line, current_exe)
|
||||
if str(game_source).lower() == "gog" and exec_line.startswith("gog://install/"):
|
||||
source = str(game_source).lower()
|
||||
install_uri = exec_line.startswith(f"{source}://install/")
|
||||
if source in {"gog", "egs"} and install_uri:
|
||||
play_button.setText(_("Install"))
|
||||
play_button.setIcon(
|
||||
self.main_window.theme_manager.get_icon("update", as_path=True)
|
||||
@@ -570,7 +645,7 @@ class DetailPageManager:
|
||||
)
|
||||
buttons_layout.addWidget(open_folder_button)
|
||||
elif (
|
||||
str(game_source).lower() != "gog"
|
||||
str(game_source).lower() not in {"gog", "egs"}
|
||||
and not self._has_game_shortcut(game_name)
|
||||
):
|
||||
add_button = self._make_action_button(
|
||||
@@ -588,21 +663,26 @@ class DetailPageManager:
|
||||
if str(game_source).lower() == "portproton":
|
||||
self._add_portproton_buttons(buttons_layout, exec_line)
|
||||
elif (
|
||||
str(game_source).lower() == "gog"
|
||||
source in {"gog", "egs"}
|
||||
and appid
|
||||
and exec_line.startswith("gog://launch/")
|
||||
and exec_line.startswith(f"{source}://launch/")
|
||||
):
|
||||
gog_exe = self.main_window.gog_api.get_launch_target(str(appid))
|
||||
if not gog_exe:
|
||||
store_api = (
|
||||
self.main_window.gog_api if source == "gog"
|
||||
else self.main_window.egs_api
|
||||
)
|
||||
store_exe = store_api.get_launch_target(str(appid))
|
||||
if not store_exe:
|
||||
return buttons_layout
|
||||
self.main_window.gog_api.ensure_launch_parameters(str(appid))
|
||||
if source == "gog":
|
||||
store_api.ensure_launch_parameters(str(appid))
|
||||
settings_button = self._make_action_button(
|
||||
_("Settings"),
|
||||
self.main_window.theme_manager.get_icon("settings", as_path=True),
|
||||
)
|
||||
settings_button.clicked.connect(
|
||||
lambda: self.main_window.open_exe_settings(
|
||||
gog_exe, game_source="gog",
|
||||
store_exe, game_source=source,
|
||||
)
|
||||
)
|
||||
buttons_layout.addWidget(settings_button)
|
||||
@@ -610,7 +690,9 @@ class DetailPageManager:
|
||||
_("Create Log"),
|
||||
self.main_window.theme_manager.get_icon("edit", as_path=True),
|
||||
)
|
||||
log_button.clicked.connect(lambda: self.toggleDebugLog(gog_exe, log_button))
|
||||
log_button.clicked.connect(
|
||||
lambda: self.toggleDebugLog(store_exe, log_button)
|
||||
)
|
||||
buttons_layout.addWidget(log_button)
|
||||
self._debug_log_button = log_button
|
||||
folder_button = self._make_action_button(
|
||||
@@ -618,7 +700,7 @@ class DetailPageManager:
|
||||
self.main_window.theme_manager.get_icon("search", as_path=True),
|
||||
)
|
||||
folder_button.clicked.connect(
|
||||
lambda: self._open_executable_folder(gog_exe)
|
||||
lambda: self._open_executable_folder(store_exe)
|
||||
)
|
||||
buttons_layout.addWidget(folder_button)
|
||||
elif str(game_source).lower() == "steam" and appid:
|
||||
@@ -833,17 +915,32 @@ class DetailPageManager:
|
||||
return_tab_index = getattr(self, "_return_to_tab_index", 0)
|
||||
current_page = self._current_detail_page
|
||||
running_button = self.main_window.current_running_button
|
||||
debug_log_button = self._debug_log_button
|
||||
replace_running_button = (
|
||||
current_page is not None
|
||||
and running_button is not None
|
||||
and current_page.isAncestorOf(running_button)
|
||||
)
|
||||
replace_debug_log_button = (
|
||||
current_page is not None
|
||||
and debug_log_button is not None
|
||||
and current_page.isAncestorOf(debug_log_button)
|
||||
)
|
||||
self._remove_current_detail_page()
|
||||
if source_kind == "autoinstall":
|
||||
self.openAutoInstallDetailPage(source_data)
|
||||
else:
|
||||
self.openGameDetailPage(source_data)
|
||||
self._return_to_tab_index = return_tab_index
|
||||
if (
|
||||
replace_debug_log_button
|
||||
and self.debug_log_manager.is_running
|
||||
and self._debug_log_button is not None
|
||||
):
|
||||
self._debug_log_button.setText(_("Stop Log"))
|
||||
icon = self.main_window.theme_manager.get_icon("stop", as_path=True)
|
||||
if icon:
|
||||
self._debug_log_button.setIcon(icon)
|
||||
if replace_running_button and self._current_detail_page is not None:
|
||||
play_button = self._find_play_button(self._current_detail_page)
|
||||
if play_button is not None:
|
||||
|
||||
@@ -4,9 +4,9 @@ import os
|
||||
import re
|
||||
from weakref import WeakKeyDictionary
|
||||
from collections.abc import Callable
|
||||
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsOpacityEffect
|
||||
from PySide6.QtWidgets import QWidget, QApplication, QLabel, QGraphicsBlurEffect, QGraphicsOpacityEffect, QGraphicsPixmapItem, QGraphicsScene
|
||||
from PySide6.QtCore import QTimer, Qt, QObject, Signal, QPropertyAnimation, QByteArray
|
||||
from PySide6.QtGui import QPixmap
|
||||
from PySide6.QtGui import QBrush, QImage, QPainter, QPalette, QPixmap
|
||||
|
||||
from portprotonqt.image_utils import get_animated_cover_pixmap
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners, set_animated_cover
|
||||
@@ -85,7 +85,7 @@ def _set_animated_cover(
|
||||
detail_page, image_label = cover_widgets
|
||||
if not set_animated_cover(image_label, cover_path, cover_size[0], cover_size[1], 10):
|
||||
return False
|
||||
_setup_palette_stylesheet(detail_page, get_animated_cover_pixmap(image_label), main_window)
|
||||
setup_cover_background(detail_page, get_animated_cover_pixmap(image_label), main_window)
|
||||
_reveal_cover(image_label, main_window.theme)
|
||||
return True
|
||||
|
||||
@@ -99,7 +99,7 @@ def _on_pixmap_ready(pixmap: QPixmap, detail_page: QWidget, image_label: QLabel,
|
||||
image_label.setPixmap(rounded)
|
||||
_reveal_cover(image_label, main_window.theme)
|
||||
logger.debug("Pixmap set for imageLabel")
|
||||
_setup_palette_stylesheet(detail_page, pixmap, main_window)
|
||||
setup_cover_background(detail_page, pixmap, main_window)
|
||||
except RuntimeError:
|
||||
logger.warning("Detail page already deleted, skipping pixmap update")
|
||||
|
||||
@@ -138,25 +138,75 @@ def _reveal_cover(image_label: QLabel, theme) -> None:
|
||||
animation.start()
|
||||
|
||||
|
||||
def _setup_palette_stylesheet(detail_page: QWidget, pixmap: QPixmap | None, main_window) -> None:
|
||||
def setup_cover_background(
|
||||
detail_page: QWidget, pixmap: QPixmap | None, main_window, theme=None
|
||||
) -> None:
|
||||
"""Setup palette-based stylesheet after image load."""
|
||||
if pixmap is None:
|
||||
return
|
||||
active_theme = theme or main_window.theme
|
||||
if getattr(active_theme, "DETAIL_PAGE_BG_MODE", "gradient") == "blur":
|
||||
_apply_blurred_cover(detail_page, pixmap, active_theme)
|
||||
return
|
||||
|
||||
def on_palette_ready(palette: list) -> None:
|
||||
_on_palette_ready(palette, detail_page, main_window)
|
||||
_on_palette_ready(palette, detail_page, main_window, active_theme)
|
||||
|
||||
num_colors = getattr(main_window.theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
|
||||
num_colors = getattr(active_theme, 'DETAIL_PAGE_PALETTE_COLORS', 5)
|
||||
if num_colors < 2:
|
||||
num_colors = 2
|
||||
main_window.getColorPalette_from_pixmap(pixmap, num_colors=num_colors, callback=on_palette_ready)
|
||||
|
||||
|
||||
def _on_palette_ready(palette: list, detail_page: QWidget, main_window) -> None:
|
||||
def remove_cover_background(widget: QWidget) -> None:
|
||||
"""Remove a shared cover-driven background and stop its animation."""
|
||||
_remove_wave_background(widget)
|
||||
_detail_backgrounds.remove(widget)
|
||||
widget.setStyleSheet("")
|
||||
widget.setAutoFillBackground(False)
|
||||
if isinstance(widget, QLabel):
|
||||
widget.clear()
|
||||
|
||||
|
||||
def _on_palette_ready(
|
||||
palette: list, detail_page: QWidget, main_window, theme
|
||||
) -> None:
|
||||
"""Handle palette ready callback."""
|
||||
if not _is_detail_page_valid(detail_page):
|
||||
return
|
||||
_apply_palette_stylesheet(detail_page, palette, main_window)
|
||||
_apply_palette_stylesheet(detail_page, palette, main_window, theme)
|
||||
|
||||
|
||||
def _apply_blurred_cover(widget: QWidget, pixmap: QPixmap, theme) -> None:
|
||||
config = theme.DETAIL_PAGE_BACKGROUNDS.get("blur", {})
|
||||
width, height = config.get("render_resolution", (1280, 720))
|
||||
scaled = pixmap.scaled(
|
||||
width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
||||
Qt.TransformationMode.SmoothTransformation,
|
||||
)
|
||||
source = scaled.copy(
|
||||
(scaled.width() - width) // 2, (scaled.height() - height) // 2,
|
||||
width, height,
|
||||
)
|
||||
item = QGraphicsPixmapItem(source)
|
||||
effect = QGraphicsBlurEffect()
|
||||
effect.setBlurRadius(config.get("radius", 64))
|
||||
item.setGraphicsEffect(effect)
|
||||
scene = QGraphicsScene()
|
||||
scene.addItem(item)
|
||||
image = QImage(source.size(), QImage.Format.Format_ARGB32_Premultiplied)
|
||||
image.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(image)
|
||||
scene.render(painter)
|
||||
painter.end()
|
||||
background = QPixmap.fromImage(image).scaled(widget.size())
|
||||
if isinstance(widget, QLabel):
|
||||
widget.setPixmap(background)
|
||||
return
|
||||
palette = widget.palette()
|
||||
palette.setBrush(QPalette.ColorRole.Window, QBrush(background))
|
||||
widget.setPalette(palette)
|
||||
widget.setAutoFillBackground(True)
|
||||
|
||||
|
||||
def _build_palette_stops(dark_palette: list) -> str:
|
||||
@@ -228,14 +278,16 @@ def _resolve_gradient_config(theme: object) -> dict:
|
||||
return config
|
||||
|
||||
|
||||
def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window) -> None:
|
||||
def _apply_palette_stylesheet(
|
||||
detail_page: QWidget, palette: list, main_window, theme
|
||||
) -> None:
|
||||
"""Apply palette-based stylesheet to detail page."""
|
||||
try:
|
||||
dark_palette = [
|
||||
main_window.darkenColor(color, factor=200) for color in palette
|
||||
]
|
||||
gradient_config = _resolve_gradient_config(main_window.theme)
|
||||
stops = _resolve_gradient_stops(main_window.theme, dark_palette)
|
||||
gradient_config = _resolve_gradient_config(theme)
|
||||
stops = _resolve_gradient_stops(theme, dark_palette)
|
||||
gradient_type = gradient_config.get("type", "linear")
|
||||
|
||||
if gradient_type == 'radial':
|
||||
@@ -268,7 +320,7 @@ def _apply_palette_stylesheet(detail_page: QWidget, palette: list, main_window)
|
||||
"""
|
||||
|
||||
detail_page.setStyleSheet(stylesheet)
|
||||
_setup_wave_background(detail_page, dark_palette, main_window.theme)
|
||||
_setup_wave_background(detail_page, dark_palette, theme)
|
||||
detail_page.update()
|
||||
logger.debug("Stylesheet updated with palette")
|
||||
except RuntimeError:
|
||||
@@ -287,7 +339,7 @@ def _setup_wave_background(detail_page: QWidget, dark_palette: list, theme) -> N
|
||||
return
|
||||
_detail_backgrounds.remove(detail_page)
|
||||
|
||||
if bg_mode == "gradient":
|
||||
if bg_mode not in {"waves", "static_waves"}:
|
||||
_remove_wave_background(detail_page)
|
||||
return
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ def create_content_frame(
|
||||
) -> tuple[QFrame, QBoxLayout]:
|
||||
"""Create content frame."""
|
||||
content_frame = QFrame()
|
||||
content_frame.setProperty("theme_style_name", "DETAIL_CONTENT_FRAME_STYLE")
|
||||
content_frame.setStyleSheet(theme.DETAIL_CONTENT_FRAME_STYLE)
|
||||
content_frame_layout = QBoxLayout(QBoxLayout.Direction.LeftToRight, content_frame)
|
||||
content_frame_layout.setContentsMargins(20, 20, 20, 20)
|
||||
@@ -139,7 +140,7 @@ def _add_source_corner(
|
||||
) -> None:
|
||||
"""Add source corner badge to cover frame."""
|
||||
source = game_source.lower()
|
||||
if source not in ("steam", "gog", "portproton") or theme_manager is None:
|
||||
if source not in ("steam", "gog", "egs", "portproton") or theme_manager is None:
|
||||
return
|
||||
|
||||
icon_name = f"badge_{source}"
|
||||
@@ -182,6 +183,7 @@ def create_compact_detail_header(
|
||||
header_widget.setFixedHeight(cover_frame.height())
|
||||
|
||||
title_frame = QFrame(header_widget)
|
||||
title_frame.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
|
||||
title_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
|
||||
title_frame.setMaximumHeight(cover_frame.height())
|
||||
title_frame.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
|
||||
@@ -192,6 +194,7 @@ def create_compact_detail_header(
|
||||
title_layout.setSizeConstraint(QLayout.SizeConstraint.SetNoConstraint)
|
||||
title_label = QLabel(title)
|
||||
title_label.setWordWrap(True)
|
||||
title_label.setProperty("theme_style_name", "COMPACT_DETAIL_PAGE_TITLE_STYLE")
|
||||
title_label.setStyleSheet(theme.COMPACT_DETAIL_PAGE_TITLE_STYLE)
|
||||
title_layout.addWidget(title_label, alignment=Qt.AlignmentFlag.AlignVCenter)
|
||||
header_layout.addWidget(title_frame, stretch=1)
|
||||
@@ -201,6 +204,7 @@ def create_compact_detail_header(
|
||||
def create_compact_layout_panel(parent: QWidget, theme, content_layout: QLayout) -> QWidget:
|
||||
"""Create compact panel for an existing layout."""
|
||||
panel = QWidget(parent)
|
||||
panel.setProperty("theme_style_name", "COMPACT_PLAYTIME_WIDGET_STYLE")
|
||||
panel.setStyleSheet(theme.COMPACT_PLAYTIME_WIDGET_STYLE)
|
||||
panel.setLayout(content_layout)
|
||||
content_layout.setContentsMargins(
|
||||
@@ -218,6 +222,7 @@ def create_compact_description_panel(
|
||||
) -> QWidget:
|
||||
"""Create compact description block."""
|
||||
desc_widget = QWidget(parent)
|
||||
desc_widget.setProperty("theme_style_name", "COMPACT_DETAILS_WIDGET_STYLE")
|
||||
desc_widget.setStyleSheet(theme.COMPACT_DETAILS_WIDGET_STYLE)
|
||||
desc_layout = QVBoxLayout(desc_widget)
|
||||
desc_layout.setContentsMargins(
|
||||
@@ -397,6 +402,7 @@ def create_details_widget(
|
||||
) -> QWidget:
|
||||
"""Create details widget with title, description, and optional content."""
|
||||
details_widget = QWidget()
|
||||
details_widget.setProperty("theme_style_name", "DETAILS_WIDGET_STYLE")
|
||||
details_widget.setStyleSheet(main_window.theme.DETAILS_WIDGET_STYLE)
|
||||
|
||||
details_layout = QVBoxLayout(details_widget)
|
||||
@@ -439,6 +445,7 @@ def create_details_widget(
|
||||
def _add_details_header(layout: QVBoxLayout, theme, title: str) -> None:
|
||||
"""Add title and divider line to details layout."""
|
||||
title_label = QLabel(title)
|
||||
title_label.setProperty("theme_style_name", "DETAIL_PAGE_TITLE_STYLE")
|
||||
title_label.setStyleSheet(theme.DETAIL_PAGE_TITLE_STYLE)
|
||||
layout.addWidget(title_label)
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import urllib.parse
|
||||
@@ -36,6 +37,40 @@ WINE_TABLE_ROW_BATCH_SIZE = 8
|
||||
WINE_ARCHIVE_EXTENSIONS = ('.tar.xz', '.tar.gz')
|
||||
|
||||
|
||||
def sort_wine_entries(entries: list[dict], source_name: str) -> list[dict]:
|
||||
"""Sort Wine entries, alternating CachyOS and WineLand releases."""
|
||||
if source_name.lower() != 'proton_cachyos':
|
||||
return sorted(entries, key=version_sort_key)
|
||||
|
||||
release_groups = []
|
||||
for is_wineland in (False, True):
|
||||
family_entries = sorted(
|
||||
(
|
||||
entry for entry in entries
|
||||
if ('proton-cachyos-wineland-' in entry.get('name', '').lower())
|
||||
== is_wineland
|
||||
),
|
||||
key=version_sort_key,
|
||||
)
|
||||
groups = []
|
||||
for entry in family_entries:
|
||||
release_name = re.sub(
|
||||
r'-(?:x86_64|arm64|aarch64)(?:_v\d+|-wow64)?$',
|
||||
'', entry.get('name', '').lower(),
|
||||
)
|
||||
if not groups or groups[-1][0] != release_name:
|
||||
groups.append((release_name, []))
|
||||
groups[-1][1].append(entry)
|
||||
release_groups.append(groups)
|
||||
|
||||
sorted_entries = []
|
||||
for index in range(max(map(len, release_groups), default=0)):
|
||||
for groups in release_groups:
|
||||
if index < len(groups):
|
||||
sorted_entries.extend(groups[index][1])
|
||||
return sorted_entries
|
||||
|
||||
|
||||
class ProtonManager(DraggableDialog):
|
||||
"""Dialog for managing Proton/Wine versions."""
|
||||
|
||||
@@ -317,7 +352,7 @@ class ProtonManager(DraggableDialog):
|
||||
tabs = []
|
||||
for source_name, entries in metadata.items():
|
||||
filtered_entries = self.filter_entries_by_cpu_level(entries, source_name)
|
||||
tabs.append((source_name, sorted(filtered_entries, key=version_sort_key)))
|
||||
tabs.append((source_name, sort_wine_entries(filtered_entries, source_name)))
|
||||
tabs.sort(key=lambda item: (item[0] != 'proton_lg', item[0]))
|
||||
self.selected_assets.clear()
|
||||
self.tab_widget.clear()
|
||||
@@ -448,7 +483,7 @@ class ProtonManager(DraggableDialog):
|
||||
if url_filename:
|
||||
entry['filename'] = url_filename
|
||||
all_entries.append(entry)
|
||||
all_entries.sort(key=version_sort_key)
|
||||
all_entries = sort_wine_entries(all_entries, source_name)
|
||||
table = self._create_empty_wine_tab(source_name, len(all_entries))
|
||||
for row_index, entry in enumerate(all_entries):
|
||||
self.add_asset_row_from_json(table, row_index, entry, source_name)
|
||||
|
||||
@@ -443,7 +443,7 @@ class ExeSettingsDialog(
|
||||
button_layout = QHBoxLayout()
|
||||
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply", as_path=True))
|
||||
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel", as_path=True))
|
||||
self.open_ppdb_button = AutoSizeButton(_("Open PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
self.open_ppdb_button = AutoSizeButton(_("Edit PPDB"), icon=ThemeManager().get_icon("folder", as_path=True))
|
||||
self.clear_ppdb_button = AutoSizeButton(_("Clear PPDB"), icon=ThemeManager().get_icon("delete", as_path=True))
|
||||
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
|
||||
@@ -1027,6 +1027,8 @@ class GamescopeSettingsMixin:
|
||||
|
||||
def _filter_gamescope_settings(self, search_text):
|
||||
"""Filter Gamescope groups based on search text."""
|
||||
if not self.gamescope_category_groups:
|
||||
return
|
||||
gamescope_enabled = self.current_settings.get('PW_GAMESCOPE') == '1'
|
||||
for checkbox in self.gamescope_toggle_widgets.values():
|
||||
layout = checkbox.parentWidget().layout()
|
||||
|
||||
@@ -1232,6 +1232,8 @@ class MangoHudSettingsMixin:
|
||||
|
||||
def _filter_mangohud_settings(self, search_text):
|
||||
"""Filter MangoHud groups based on search text."""
|
||||
if not self.mangohud_category_groups:
|
||||
return
|
||||
config_visible = self.current_settings.get('PW_MANGOHUD') == '1' \
|
||||
and self.current_settings.get('PW_MANGOHUD_USER_CONF') != '1'
|
||||
for checkbox in self.mangohud_toggle_widgets.values():
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
"""Epic Games Store integration through Legendary."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
import requests
|
||||
|
||||
from portprotonqt.localization import get_store_content_languages
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
EGS_LOGIN_URL = "https://legendary.gl/epiclogin"
|
||||
LEGENDARY_RELEASE_URL = (
|
||||
"https://api.github.com/repos/Heroic-Games-Launcher/legendary/releases/latest"
|
||||
)
|
||||
LEGENDARY_TIMEOUT = 120
|
||||
EGS_METADATA_WORKERS = 4
|
||||
EGS_METADATA_TIMEOUT = 15
|
||||
EGS_DESCRIPTION_CACHE_VERSION = 2
|
||||
LEGENDARY_UPDATE_INTERVAL = 30 * 24 * 60 * 60
|
||||
EGS_API_USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 EpicGamesLauncher"
|
||||
)
|
||||
|
||||
|
||||
class EGSAPI:
|
||||
"""Provide EGS authentication, library and installation helpers."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
data_home = Path(os.getenv("XDG_DATA_HOME", Path.home() / ".local/share"))
|
||||
self.data_dir = data_home / "PortProtonQt" / "egs"
|
||||
self.config_dir = self.data_dir / "legendary"
|
||||
self.user_path = self.config_dir / "user.json"
|
||||
self.library_path = self.data_dir / "library.json"
|
||||
self.sizes_path = self.data_dir / "sizes.json"
|
||||
self.version_path = self.data_dir / "bin/legendary.version"
|
||||
self.games_dir = Path.home() / "Games"
|
||||
|
||||
def get_legendary_path(self) -> str | None:
|
||||
"""Return an available Legendary executable."""
|
||||
bundled = self.data_dir / "bin/legendary"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
return str(bundled)
|
||||
return shutil.which("legendary")
|
||||
|
||||
def ensure_legendary(self) -> str:
|
||||
"""Download the current official Heroic Legendary binary if needed."""
|
||||
existing = self.get_legendary_path()
|
||||
if existing:
|
||||
return existing
|
||||
asset, release_tag = self._get_latest_legendary_release()
|
||||
return self._download_legendary(asset, release_tag)
|
||||
|
||||
def update_legendary(self) -> str:
|
||||
"""Update the bundled Legendary binary when a newer release exists."""
|
||||
bundled = self.data_dir / "bin/legendary"
|
||||
if bundled.is_file() and os.access(bundled, os.X_OK):
|
||||
try:
|
||||
last_check = self.version_path.stat().st_mtime
|
||||
except OSError:
|
||||
last_check = 0
|
||||
if time.time() - last_check < LEGENDARY_UPDATE_INTERVAL:
|
||||
return str(bundled)
|
||||
asset, release_tag = self._get_latest_legendary_release()
|
||||
if (
|
||||
bundled.is_file()
|
||||
and self._get_legendary_release_tag() == release_tag
|
||||
):
|
||||
self.version_path.touch()
|
||||
return str(bundled)
|
||||
return self._download_legendary(asset, release_tag)
|
||||
|
||||
def _get_latest_legendary_release(self) -> tuple[dict, str]:
|
||||
"""Return the matching Legendary asset and release tag."""
|
||||
architecture = {"x86_64": "x86_64", "aarch64": "arm64"}.get(
|
||||
platform.machine()
|
||||
)
|
||||
if not architecture:
|
||||
raise OSError(f"Unsupported Legendary architecture: {platform.machine()}")
|
||||
response = requests.get(LEGENDARY_RELEASE_URL, timeout=15)
|
||||
response.raise_for_status()
|
||||
release = response.json()
|
||||
asset_name = f"legendary_linux_{architecture}"
|
||||
asset = next(
|
||||
(item for item in release.get("assets", []) if item.get("name") == asset_name),
|
||||
None,
|
||||
)
|
||||
if not asset:
|
||||
raise OSError(f"Legendary release asset not found: {asset_name}")
|
||||
release_tag = str(release.get("tag_name", ""))
|
||||
if not release_tag:
|
||||
raise OSError("Legendary release tag not found")
|
||||
return asset, release_tag
|
||||
|
||||
def _get_legendary_release_tag(self) -> str:
|
||||
try:
|
||||
return self.version_path.read_text(encoding="utf-8").strip()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
def _download_legendary(self, asset: dict, release_tag: str) -> str:
|
||||
target = self.data_dir / "bin/legendary"
|
||||
temporary = target.with_suffix(".part")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
with requests.get(asset["browser_download_url"], stream=True, timeout=30) as response:
|
||||
response.raise_for_status()
|
||||
with open(temporary, "wb") as output:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024):
|
||||
if chunk:
|
||||
output.write(chunk)
|
||||
digest.update(chunk)
|
||||
expected = str(asset.get("digest", "")).removeprefix("sha256:")
|
||||
if not expected or digest.hexdigest() != expected:
|
||||
raise OSError("Legendary checksum verification failed")
|
||||
temporary.chmod(0o755)
|
||||
temporary.replace(target)
|
||||
self.version_path.write_text(release_tag, encoding="utf-8")
|
||||
return str(target)
|
||||
except Exception:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def build_command(self, arguments: list[str]) -> list[str]:
|
||||
"""Build a Legendary command using private configuration paths."""
|
||||
executable = self.get_legendary_path()
|
||||
if not executable:
|
||||
raise FileNotFoundError("Legendary executable not found")
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.config_dir.chmod(0o700)
|
||||
command_arguments = list(arguments)
|
||||
if "-y" in command_arguments:
|
||||
command_arguments.remove("-y")
|
||||
command_arguments.insert(0, "-y")
|
||||
return [executable, *command_arguments]
|
||||
|
||||
def get_environment(self) -> dict[str, str]:
|
||||
"""Return the environment required by Legendary."""
|
||||
environment = os.environ.copy()
|
||||
environment["LEGENDARY_CONFIG_PATH"] = str(self.config_dir)
|
||||
return environment
|
||||
|
||||
def authenticate(self, code: str) -> tuple[bool, str]:
|
||||
"""Authenticate an Epic account with an authorization code."""
|
||||
self.ensure_legendary()
|
||||
result = subprocess.run(
|
||||
self.build_command(["auth", "--code", code]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
if result.returncode == 0 and self.user_path.is_file():
|
||||
self.user_path.chmod(0o600)
|
||||
return True, ""
|
||||
error = result.stderr.decode(errors="replace").strip()
|
||||
logger.error("Legendary authentication failed: %s", error)
|
||||
return False, error or f"Legendary exited with code {result.returncode}"
|
||||
|
||||
@staticmethod
|
||||
def extract_auth_code(text: str) -> str:
|
||||
"""Extract an Epic authorization code from copied text or JSON."""
|
||||
value = text.strip()
|
||||
try:
|
||||
data = orjson.loads(value)
|
||||
except orjson.JSONDecodeError:
|
||||
return value if "://" not in value else ""
|
||||
if not isinstance(data, dict):
|
||||
return ""
|
||||
return str(data.get("authorizationCode", "")).strip()
|
||||
|
||||
def logout(self) -> bool:
|
||||
"""Remove the Epic login and cached library."""
|
||||
try:
|
||||
if self.get_legendary_path() and self.user_path.is_file():
|
||||
subprocess.run(
|
||||
self.build_command(["auth", "--delete"]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
self.user_path.unlink(missing_ok=True)
|
||||
self.library_path.unlink(missing_ok=True)
|
||||
return True
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
logger.error("Failed to disconnect Epic account: %s", error)
|
||||
return False
|
||||
|
||||
def get_account_name(self) -> str:
|
||||
"""Return the Epic display name from Legendary state."""
|
||||
user = self._load_json(self.user_path, {})
|
||||
return str(user.get("displayName", "")) if isinstance(user, dict) else ""
|
||||
|
||||
def refresh_library(
|
||||
self, progress_callback: Callable[[int, int], None] | None = None
|
||||
) -> list[dict]:
|
||||
"""Refresh and cache the Epic library through Legendary."""
|
||||
self.update_legendary()
|
||||
result = subprocess.run(
|
||||
self.build_command(["list", "--json", "--third-party"]),
|
||||
env=self.get_environment(), capture_output=True, check=False,
|
||||
timeout=LEGENDARY_TIMEOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
error = result.stderr.decode(errors="replace").strip()
|
||||
raise OSError(error or "Failed to refresh Epic library")
|
||||
raw_games = orjson.loads(result.stdout)
|
||||
if not isinstance(raw_games, list):
|
||||
raise OSError("Legendary returned an invalid Epic library")
|
||||
games = []
|
||||
for game in raw_games:
|
||||
metadata = game.get("metadata", {})
|
||||
release_info = metadata.get("releaseInfo", [])
|
||||
mobile_only = bool(release_info) and all(
|
||||
info.get("platform")
|
||||
and all(platform in {"Android", "iOS"}
|
||||
for platform in info["platform"])
|
||||
for info in release_info
|
||||
)
|
||||
if not mobile_only:
|
||||
games.append(self._normalize_game(game))
|
||||
games = [game for game in games if game.get("app_id")]
|
||||
self._enrich_descriptions(games, progress_callback)
|
||||
self._save_json(self.library_path, games)
|
||||
return games
|
||||
|
||||
def load_library(self) -> list[dict]:
|
||||
"""Load the cached Epic library."""
|
||||
data = self._load_json(self.library_path, [])
|
||||
return data if isinstance(data, list) else []
|
||||
|
||||
def clear_library_cache(self) -> None:
|
||||
"""Remove cached Epic library metadata."""
|
||||
try:
|
||||
self.library_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to clear Epic library cache: %s", error)
|
||||
|
||||
def load_installed(self) -> dict[str, dict]:
|
||||
"""Load Legendary installed game records."""
|
||||
data = self._load_json(self.config_dir / "installed.json", {})
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
def is_game_installed(self, app_id: str) -> bool:
|
||||
"""Return whether Legendary records a usable installation."""
|
||||
return self.get_launch_target(app_id) is not None
|
||||
|
||||
@staticmethod
|
||||
def is_eos_overlay_enabled(prefix_path: Path) -> bool:
|
||||
"""Return whether the EOS overlay is enabled in a Wine prefix."""
|
||||
try:
|
||||
registry = (prefix_path / "user.reg").read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
for section in registry.casefold().split("\n["):
|
||||
if section.startswith(r"software\\epic games\\eos]"):
|
||||
if '"overlaypath"=' in section:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_launch_target(self, app_id: str) -> str | None:
|
||||
"""Return the installed game's Windows executable."""
|
||||
installed = self.load_installed().get(app_id, {})
|
||||
install_path = Path(str(installed.get("install_path", "")))
|
||||
executable = str(installed.get("executable", "")).replace("\\", "/")
|
||||
target = install_path.joinpath(*executable.split("/"))
|
||||
return str(target) if executable and target.is_file() else None
|
||||
|
||||
def get_cached_download_sizes(self, app_id: str) -> tuple[int, int] | None:
|
||||
"""Return cached EGS download and disk sizes."""
|
||||
cache = self._load_json(self.sizes_path, {})
|
||||
value = cache.get(app_id) if isinstance(cache, dict) else None
|
||||
if not isinstance(value, list) or len(value) != 2:
|
||||
return None
|
||||
sizes = int(value[0]), int(value[1])
|
||||
return None if sizes[0] > 0 and sizes[1] <= 0 else sizes
|
||||
|
||||
def save_download_sizes(self, app_id: str, sizes: tuple[int, int]) -> None:
|
||||
"""Cache EGS download and disk sizes."""
|
||||
cache = self._load_json(self.sizes_path, {})
|
||||
if not isinstance(cache, dict):
|
||||
cache = {}
|
||||
cache[app_id] = list(sizes)
|
||||
self._save_json(self.sizes_path, cache)
|
||||
|
||||
@staticmethod
|
||||
def parse_download_sizes(output: bytes) -> tuple[int, int]:
|
||||
"""Extract download and install sizes from Legendary JSON."""
|
||||
data = orjson.loads(output)
|
||||
values: dict[str, int] = {}
|
||||
|
||||
def visit(value: Any) -> None:
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
if key in {"download_size", "install_size", "disk_size"}:
|
||||
try:
|
||||
values[key] = max(values.get(key, 0), int(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
visit(item)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
visit(item)
|
||||
|
||||
visit(data)
|
||||
install_size = values.get("install_size") or values.get("disk_size", 0)
|
||||
return values.get("download_size", 0), install_size
|
||||
|
||||
def _enrich_descriptions(
|
||||
self, games: list[dict],
|
||||
progress_callback: Callable[[int, int], None] | None = None,
|
||||
) -> None:
|
||||
cached = {str(game.get("app_id")): game for game in self.load_library()}
|
||||
pending = []
|
||||
completed = 0
|
||||
total = len(games)
|
||||
preferred_locale = get_store_content_languages()[0]
|
||||
for game in games:
|
||||
old_game = cached.get(game["app_id"], {})
|
||||
old_description = str(old_game.get("description", ""))
|
||||
if (
|
||||
old_description
|
||||
and old_description != game["title"]
|
||||
and old_game.get("description_preference") == preferred_locale
|
||||
and old_game.get("description_cache_version")
|
||||
== EGS_DESCRIPTION_CACHE_VERSION
|
||||
):
|
||||
game["description"] = old_description
|
||||
game["description_locale"] = str(old_game.get("description_locale", ""))
|
||||
game["description_preference"] = preferred_locale
|
||||
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
|
||||
completed += 1
|
||||
if progress_callback:
|
||||
progress_callback(completed, total)
|
||||
else:
|
||||
pending.append(game)
|
||||
if not pending:
|
||||
return
|
||||
worker_count = min(EGS_METADATA_WORKERS, os.cpu_count() or 1)
|
||||
with ThreadPoolExecutor(max_workers=worker_count) as executor:
|
||||
descriptions = executor.map(self._get_store_description, pending)
|
||||
for game, result in zip(pending, descriptions, strict=True):
|
||||
description, description_locale = result
|
||||
if description:
|
||||
game["description"] = description
|
||||
game["description_locale"] = description_locale
|
||||
game["description_preference"] = preferred_locale
|
||||
game["description_cache_version"] = EGS_DESCRIPTION_CACHE_VERSION
|
||||
completed += 1
|
||||
if progress_callback:
|
||||
progress_callback(completed, total)
|
||||
|
||||
def _get_store_description(self, game: dict) -> tuple[str, str]:
|
||||
slug = self._get_product_slug(str(game.get("namespace", "")), game["title"])
|
||||
for language in get_store_content_languages():
|
||||
try:
|
||||
response = requests.get(
|
||||
"https://store-content.ak.epicgames.com/api/"
|
||||
f"{language}/content/products/{slug}",
|
||||
headers={"User-Agent": EGS_API_USER_AGENT},
|
||||
timeout=EGS_METADATA_TIMEOUT,
|
||||
)
|
||||
if response.status_code == requests.codes.not_found:
|
||||
continue
|
||||
response.raise_for_status()
|
||||
description = self._parse_store_description(response.json())
|
||||
if description:
|
||||
return description, language
|
||||
except requests.RequestException as error:
|
||||
logger.debug(
|
||||
"Epic description unavailable for %s: %s", game["app_id"], error
|
||||
)
|
||||
return "", ""
|
||||
return "", ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_store_description(data: Any) -> str:
|
||||
if not isinstance(data, dict) or not isinstance(data.get("pages"), list):
|
||||
return ""
|
||||
page = next(
|
||||
(item for item in data["pages"]
|
||||
if isinstance(item, dict) and item.get("type") == "productHome"),
|
||||
{},
|
||||
)
|
||||
about = page.get("data", {}).get("about", {})
|
||||
short_description = str(about.get("shortDescription") or "").strip()
|
||||
if short_description:
|
||||
return short_description
|
||||
description = str(about.get("description") or "").strip()
|
||||
return description.split("\n\n", 1)[0].strip()
|
||||
|
||||
def _get_product_slug(self, namespace: str, title: str) -> str:
|
||||
fallback = "-".join("".join(
|
||||
character if character.isascii() and character.isalnum() else " "
|
||||
for character in title.lower()
|
||||
).split())
|
||||
if not namespace:
|
||||
return fallback
|
||||
query = {
|
||||
"query": "query ProductSlug($namespace: String!) { Catalog { "
|
||||
"catalogNs(namespace: $namespace) { mappings(pageType: "
|
||||
"\"productHome\") { pageSlug pageType } } } }",
|
||||
"variables": {"namespace": namespace},
|
||||
}
|
||||
try:
|
||||
response = requests.post(
|
||||
"https://launcher.store.epicgames.com/graphql",
|
||||
json=query,
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": EGS_API_USER_AGENT,
|
||||
},
|
||||
timeout=EGS_METADATA_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
mappings = response.json()["data"]["Catalog"]["catalogNs"]["mappings"]
|
||||
return str(mappings[0].get("pageSlug") or fallback) if mappings else fallback
|
||||
except (KeyError, IndexError, TypeError, requests.RequestException) as error:
|
||||
logger.debug("Epic product slug unavailable for %s: %s", namespace, error)
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def _normalize_game(game: dict) -> dict:
|
||||
metadata = game.get("metadata", {})
|
||||
images = metadata.get("keyImages", []) if isinstance(metadata, dict) else []
|
||||
cover = next(
|
||||
(str(image.get("url", "")) for image in images
|
||||
if image.get("type") in ("DieselGameBoxTall", "OfferImageTall")),
|
||||
"",
|
||||
)
|
||||
return {
|
||||
"app_id": str(game.get("app_name") or game.get("appName") or ""),
|
||||
"title": str(game.get("app_title") or metadata.get("title") or ""),
|
||||
"description": str(metadata.get("description", "")),
|
||||
"cover": cover,
|
||||
"namespace": str(metadata.get("namespace", "")),
|
||||
"description_locale": "",
|
||||
"description_preference": "",
|
||||
"description_cache_version": 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _load_json(path: Path, default: Any) -> Any:
|
||||
try:
|
||||
return orjson.loads(path.read_bytes())
|
||||
except (OSError, orjson.JSONDecodeError):
|
||||
return default
|
||||
|
||||
@staticmethod
|
||||
def _save_json(path: Path, data: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(orjson.dumps(data, option=orjson.OPT_INDENT_2))
|
||||
+311
-127
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from typing import Any
|
||||
|
||||
from PySide6.QtGui import (
|
||||
QPainter,
|
||||
@@ -12,8 +13,12 @@ from PySide6.QtGui import (
|
||||
QIcon,
|
||||
QPen,
|
||||
QLinearGradient,
|
||||
QEnterEvent,
|
||||
QFocusEvent,
|
||||
QMouseEvent,
|
||||
QPaintEvent,
|
||||
)
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtCore import QEvent, Signal, Property, Qt, QUrl, QTimer, QSize
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
@@ -24,6 +29,7 @@ from PySide6.QtWidgets import (
|
||||
QWidget,
|
||||
QStackedLayout,
|
||||
QLabel,
|
||||
QSizePolicy,
|
||||
)
|
||||
from portprotonqt.image_utils import (
|
||||
cleanup_animated_cover,
|
||||
@@ -199,11 +205,123 @@ class SourceCorner(QWidget):
|
||||
self._visible = visible
|
||||
super().setVisible(visible)
|
||||
|
||||
def refresh_source_theme(self, config: dict, icon: str | QIcon | None) -> None:
|
||||
"""Refresh source-corner visuals for a live theme change."""
|
||||
self._cfg = config
|
||||
self._icon = icon
|
||||
self._color = QColor(config.get("ribbon_color", "#3f424d"))
|
||||
self._fold_color = QColor(config.get("ribbon_fold_color", "#00000096"))
|
||||
self.update()
|
||||
|
||||
class GameCard(QFrame):
|
||||
|
||||
class AnimatedCard(QFrame):
|
||||
animation_base_size: tuple[int, int]
|
||||
borderWidthChanged = Signal()
|
||||
gradientAngleChanged = Signal()
|
||||
scaleChanged = Signal()
|
||||
hoverChanged = Signal(str, bool)
|
||||
focusChanged = Signal(str, bool)
|
||||
clicked = Signal()
|
||||
|
||||
def setup_card_animations(self, theme: Any, layout_config: dict) -> None:
|
||||
self.theme = theme
|
||||
self.card_layout_cfg = layout_config
|
||||
self._borderWidth = layout_config.get(
|
||||
"default_border_width", theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||
)
|
||||
self._gradientAngle = layout_config.get(
|
||||
"gradient_start_angle", theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||
)
|
||||
self._scale = layout_config.get(
|
||||
"default_scale", theme.GAME_CARD_ANIMATION["default_scale"]
|
||||
)
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
self._hover_sound_pending = False
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setMouseTracking(True)
|
||||
self.animations = GameCardAnimations(self, theme)
|
||||
self.animations.setup_animations()
|
||||
|
||||
def update_scale(self) -> None:
|
||||
width, height = self.animation_base_size
|
||||
self.setFixedSize(int(width * self._scale), int(height * self._scale))
|
||||
|
||||
def getBorderWidth(self) -> int:
|
||||
return self._borderWidth
|
||||
|
||||
def setBorderWidth(self, value: int) -> None:
|
||||
self._borderWidth = value
|
||||
self.borderWidthChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getGradientAngle(self) -> float:
|
||||
return self._gradientAngle
|
||||
|
||||
def setGradientAngle(self, value: float) -> None:
|
||||
self._gradientAngle = value
|
||||
self.gradientAngleChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getScale(self) -> float:
|
||||
return self._scale
|
||||
|
||||
def setScale(self, value: float) -> None:
|
||||
self._scale = value
|
||||
self.update_scale()
|
||||
self.scaleChanged.emit()
|
||||
|
||||
borderWidth = Property(int, getBorderWidth, setBorderWidth, notify=borderWidthChanged)
|
||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, notify=gradientAngleChanged)
|
||||
scale = Property(float, getScale, setScale, notify=scaleChanged)
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
super().paintEvent(event)
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event: QEnterEvent) -> None:
|
||||
self.raise_()
|
||||
self._hover_sound_pending = True
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event: QEvent) -> None:
|
||||
self._hover_sound_pending = False
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event: QMouseEvent) -> None:
|
||||
if self._hover_sound_pending:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self._hover_sound_pending = False
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def focusInEvent(self, event: QFocusEvent) -> None:
|
||||
self.raise_()
|
||||
if QApplication.activeWindow() is not None and event.reason() not in (
|
||||
Qt.FocusReason.ActiveWindowFocusReason,
|
||||
Qt.FocusReason.MouseFocusReason,
|
||||
):
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event: QFocusEvent) -> None:
|
||||
self.animations.handle_focus_out_event()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def mouseReleaseEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton and self.rect().contains(event.pos()):
|
||||
self.clicked.emit()
|
||||
super().mouseReleaseEvent(event)
|
||||
|
||||
def click(self) -> None:
|
||||
self.clicked.emit()
|
||||
|
||||
|
||||
class GameCard(AnimatedCard):
|
||||
editShortcutRequested = Signal(str, str, str)
|
||||
deleteGameRequested = Signal(str, str)
|
||||
addToMenuRequested = Signal(str, str)
|
||||
@@ -213,8 +331,6 @@ class GameCard(QFrame):
|
||||
addToSteamRequested = Signal(str, str, str)
|
||||
removeFromSteamRequested = Signal(str, str)
|
||||
openGameFolderRequested = Signal(str, str)
|
||||
hoverChanged = Signal(str, bool)
|
||||
focusChanged = Signal(str, bool)
|
||||
|
||||
def __init__(
|
||||
self, name, description, cover_path, appid, controller_support, exec_line,
|
||||
@@ -257,33 +373,46 @@ class GameCard(QFrame):
|
||||
self.display_filter = game_config.get_display_filter()
|
||||
self.badge_view_mode = ui_config.get_badge_view_mode()
|
||||
self.current_theme_name = ui_config.get_theme()
|
||||
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.list_layout = self.layout_mode == "list"
|
||||
parent_mode = parent.property("library_layout_mode") if parent is not None else None
|
||||
self.layout_mode = str(
|
||||
parent_mode or getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
).lower()
|
||||
self.list_layout = self.layout_mode in {"list", "vertical"}
|
||||
self.vertical_layout = self.layout_mode == "vertical"
|
||||
self.horizontal_layout = self.layout_mode in {"horizontal", "horizontal_top"}
|
||||
self.economy_mode = ui_config.get_economy_mode()
|
||||
self.missing_executable_path = self._get_missing_executable_path()
|
||||
|
||||
self.steam_visible = str(game_source).lower() == "steam"
|
||||
self.gog_visible = str(game_source).lower() == "gog"
|
||||
self.egs_visible = str(game_source).lower() == "egs"
|
||||
self.portproton_visible = str(game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
|
||||
config_name = "GAME_CARD_LIST" if self.list_layout else "GAME_CARD_GRID"
|
||||
if self.vertical_layout:
|
||||
config_name = "GAME_CARD_VERTICAL"
|
||||
elif self.list_layout:
|
||||
config_name = "GAME_CARD_LIST"
|
||||
elif self.horizontal_layout:
|
||||
config_name = "GAME_CARD_HORIZONTAL"
|
||||
else:
|
||||
config_name = "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = getattr(self.theme, config_name, {})
|
||||
default_margin = 8 if self.list_layout else 20
|
||||
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
|
||||
|
||||
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
|
||||
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
|
||||
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
|
||||
self._hovered = False
|
||||
self._focused = False
|
||||
self._hover_sound_pending = False
|
||||
self.setMouseTracking(True)
|
||||
|
||||
self.animations = GameCardAnimations(self, self.theme)
|
||||
self.animations.setup_animations()
|
||||
card_style_name = (
|
||||
"GAME_CARD_VERTICAL_STYLE"
|
||||
if self.vertical_layout
|
||||
else "GAME_CARD_WINDOW_STYLE"
|
||||
)
|
||||
self.setProperty("theme_style_name", card_style_name)
|
||||
card_style = (
|
||||
self.theme.GAME_CARD_VERTICAL_STYLE
|
||||
if self.vertical_layout
|
||||
else self.theme.GAME_CARD_WINDOW_STYLE
|
||||
)
|
||||
self.setStyleSheet(card_style)
|
||||
self.setup_card_animations(self.theme, self.card_layout_cfg)
|
||||
|
||||
self.shadow = QGraphicsDropShadowEffect(self)
|
||||
self.shadow.setBlurRadius(self.theme.shadow_blur_radius)
|
||||
@@ -301,6 +430,7 @@ class GameCard(QFrame):
|
||||
|
||||
self.coverWidget = QWidget()
|
||||
if self.list_layout:
|
||||
self.coverWidget.setProperty("theme_style_name", "COVER_WIDGET_STYLE")
|
||||
self.coverWidget.setStyleSheet(self.theme.COVER_WIDGET_STYLE)
|
||||
coverLayout = QStackedLayout(self.coverWidget)
|
||||
coverLayout.setContentsMargins(0, 0, 0, 0)
|
||||
@@ -310,8 +440,8 @@ class GameCard(QFrame):
|
||||
self.coverLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
|
||||
if self.missing_executable_path or (
|
||||
str(self.game_source).lower() == "gog"
|
||||
and self.exec_line.startswith("gog://install/")
|
||||
str(self.game_source).lower() in ("gog", "egs")
|
||||
and self.exec_line.startswith(("gog://install/", "egs://install/"))
|
||||
):
|
||||
self.coverOpacity = QGraphicsOpacityEffect(self.coverLabel)
|
||||
self.coverOpacity.setOpacity(self.theme.missing_exe_cover_opacity)
|
||||
@@ -349,7 +479,7 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.steamLabel.setVisible(self.steam_visible and not self.vertical_layout)
|
||||
|
||||
gog_icon = self.theme_manager.get_icon("badge_gog", as_path=True)
|
||||
self.gogLabel = SourceCorner(
|
||||
@@ -357,7 +487,15 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible and not self.vertical_layout)
|
||||
|
||||
egs_icon = self.theme_manager.get_icon("badge_egs", as_path=True)
|
||||
self.egsLabel = SourceCorner(
|
||||
icon=egs_icon,
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.egsLabel.setVisible(self.egs_visible and not self.vertical_layout)
|
||||
|
||||
portproton_icon = self.theme_manager.get_icon("badge_portproton", as_path=True)
|
||||
self.portprotonLabel = SourceCorner(
|
||||
@@ -365,7 +503,9 @@ class GameCard(QFrame):
|
||||
config=corner_config,
|
||||
parent=self.coverWidget,
|
||||
)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and not self.vertical_layout
|
||||
)
|
||||
|
||||
if self.ppdb_visible:
|
||||
self.ppdbLabel = ClickableLabel(
|
||||
@@ -411,9 +551,34 @@ class GameCard(QFrame):
|
||||
self.nameLabel.setContentsMargins(0, 0, 10, 0)
|
||||
else:
|
||||
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
|
||||
self.layout_.addWidget(self.nameLabel)
|
||||
if self.list_layout:
|
||||
name_style = (
|
||||
self.theme.GAME_CARD_VERTICAL_NAME_STYLE
|
||||
if self.vertical_layout
|
||||
else self.theme.GAME_CARD_NAME_LABEL_STYLE
|
||||
)
|
||||
self.nameLabel.setStyleSheet(name_style)
|
||||
if self.vertical_layout:
|
||||
self.nameLabel.setProperty(
|
||||
"theme_style_name", "GAME_CARD_VERTICAL_NAME_STYLE"
|
||||
)
|
||||
if self.vertical_layout:
|
||||
stretches = self.theme.GAME_CARD_VERTICAL["column_stretches"]
|
||||
self.layout_.addWidget(self.nameLabel, stretches[0])
|
||||
for text, stretch in zip(
|
||||
(self.last_launch, self.formatted_playtime, str(self.game_source)),
|
||||
stretches[1:],
|
||||
strict=True,
|
||||
):
|
||||
label = QLabel(text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
label.setProperty(
|
||||
"theme_style_name", "GAME_CARD_COLUMN_LABEL_STYLE"
|
||||
)
|
||||
label.setStyleSheet(self.theme.GAME_CARD_COLUMN_LABEL_STYLE)
|
||||
self.layout_.addWidget(label, stretch)
|
||||
else:
|
||||
self.layout_.addWidget(self.nameLabel)
|
||||
if self.list_layout and not self.vertical_layout:
|
||||
self.layout_.addStretch()
|
||||
|
||||
font_size = self.nameLabel.font().pointSizeF()
|
||||
@@ -430,6 +595,76 @@ class GameCard(QFrame):
|
||||
layout.invalidate()
|
||||
parent.updateGeometry()
|
||||
|
||||
def refresh_theme(self, theme: Any) -> None:
|
||||
"""Refresh cached card visuals after a live theme change."""
|
||||
old_theme = self.theme
|
||||
old_layout_cfg = self.card_layout_cfg
|
||||
self.theme = theme
|
||||
if self.vertical_layout:
|
||||
config_name = "GAME_CARD_VERTICAL"
|
||||
elif self.list_layout:
|
||||
config_name = "GAME_CARD_LIST"
|
||||
elif self.horizontal_layout:
|
||||
config_name = "GAME_CARD_HORIZONTAL"
|
||||
else:
|
||||
config_name = "GAME_CARD_GRID"
|
||||
self.card_layout_cfg = getattr(theme, config_name, {})
|
||||
default_margin = 8 if self.list_layout else 20
|
||||
self.base_extra_margin = self.card_layout_cfg.get("extra_margin", default_margin)
|
||||
spacing = self.card_layout_cfg.get("spacing", 12 if self.list_layout else 5)
|
||||
self.layout_.setSpacing(spacing)
|
||||
margin = self.base_extra_margin // 2
|
||||
self.layout_.setContentsMargins(margin, margin, margin, margin)
|
||||
self.shadow.setBlurRadius(theme.shadow_blur_radius)
|
||||
self.shadow.setColor(QColor(theme.color_shadow_card))
|
||||
self.shadow.setOffset(*theme.shadow_offset)
|
||||
if hasattr(self, "coverOpacity"):
|
||||
self.coverOpacity.setOpacity(theme.missing_exe_cover_opacity)
|
||||
if is_valid_protondb_tier(self.protondb_tier):
|
||||
self.protondbLabel.setStyleSheet(
|
||||
theme.get_protondb_badge_style(self.protondb_tier)
|
||||
)
|
||||
if self.ppdb_id:
|
||||
ppdb_style = (
|
||||
theme.get_ppdb_badge_style(self.ppdb_rating)
|
||||
if self.ppdb_rating
|
||||
else theme.STEAM_BADGE_STYLE
|
||||
)
|
||||
self.ppdbLabel.setStyleSheet(ppdb_style)
|
||||
if self.getAntiCheatText(self.anticheat_status):
|
||||
self.anticheatLabel.setStyleSheet(
|
||||
theme.get_anticheat_badge_style(self.anticheat_status)
|
||||
)
|
||||
corner_config = theme.get_source_corner_config()
|
||||
for label, icon_name in (
|
||||
(self.steamLabel, "badge_steam"),
|
||||
(self.gogLabel, "badge_gog"),
|
||||
(self.egsLabel, "badge_egs"),
|
||||
(self.portprotonLabel, "badge_portproton"),
|
||||
):
|
||||
icon = self.theme_manager.get_icon(
|
||||
icon_name, self.current_theme_name, as_path=True
|
||||
)
|
||||
label.refresh_source_theme(corner_config, icon)
|
||||
if (
|
||||
old_theme.GAME_CARD_ANIMATION != theme.GAME_CARD_ANIMATION
|
||||
or old_layout_cfg != self.card_layout_cfg
|
||||
):
|
||||
self.animations.refresh_theme(theme)
|
||||
else:
|
||||
self.animations.theme = theme
|
||||
self.update_favorite_icon()
|
||||
layout_changed = (
|
||||
old_layout_cfg != self.card_layout_cfg
|
||||
or old_theme.COMPACT_CARD != theme.COMPACT_CARD
|
||||
or old_theme.favoriteLabelSize != theme.favoriteLabelSize
|
||||
or old_theme.favoriteLabelIconSize != theme.favoriteLabelIconSize
|
||||
)
|
||||
if layout_changed:
|
||||
self.update_scale()
|
||||
else:
|
||||
self.update()
|
||||
|
||||
def on_cover_loaded(self, pixmap):
|
||||
self.animated_cover_path = ""
|
||||
self.base_pixmap = pixmap
|
||||
@@ -442,7 +677,8 @@ class GameCard(QFrame):
|
||||
height = cover_size
|
||||
else:
|
||||
width = self.base_card_width
|
||||
height = int(self.base_card_width * 1.5)
|
||||
layout_config = getattr(self, "card_layout_cfg", {})
|
||||
height = int(self.base_card_width * layout_config.get("cover_aspect_ratio", 1.5))
|
||||
if self._set_animated_cover(cover_path, width, height):
|
||||
return
|
||||
fallback_exe, fallback_icon_path = self._get_exe_icon_fallback()
|
||||
@@ -585,9 +821,10 @@ class GameCard(QFrame):
|
||||
ribbon_size = int(current_width * ribbon_cfg.get("size_ratio", 0.28))
|
||||
ribbon_size = max(ribbon_size, int(ribbon_cfg.get("min_size", 54) * self._scale))
|
||||
source_ribbons = [
|
||||
(self.steam_visible, self.steamLabel),
|
||||
(self.gog_visible, self.gogLabel),
|
||||
(self.portproton_visible, self.portprotonLabel),
|
||||
(self.steam_visible and not self.vertical_layout, self.steamLabel),
|
||||
(self.gog_visible and not self.vertical_layout, self.gogLabel),
|
||||
(self.egs_visible and not self.vertical_layout, self.egsLabel),
|
||||
(self.portproton_visible and not self.vertical_layout, self.portprotonLabel),
|
||||
]
|
||||
|
||||
for is_visible, ribbon in source_ribbons:
|
||||
@@ -609,6 +846,7 @@ class GameCard(QFrame):
|
||||
self.protondbLabel.raise_()
|
||||
self.portprotonLabel.raise_()
|
||||
self.gogLabel.raise_()
|
||||
self.egsLabel.raise_()
|
||||
self.steamLabel.raise_()
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -631,7 +869,15 @@ class GameCard(QFrame):
|
||||
int(self.card_layout_cfg.get("cover_size", 56) * self._scale),
|
||||
)
|
||||
margin_left = self.card_layout_cfg.get("cover_left_margin", 10)
|
||||
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
|
||||
if self.vertical_layout:
|
||||
self.setMinimumWidth(0)
|
||||
self.setFixedHeight(row_height + scaled_extra)
|
||||
self.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding,
|
||||
QSizePolicy.Policy.Fixed,
|
||||
)
|
||||
else:
|
||||
self.setFixedSize(scaled_width + scaled_extra, row_height + scaled_extra)
|
||||
self.coverWidget.setFixedSize(icon_size + margin_left, icon_size)
|
||||
self.coverWidget.setContentsMargins(margin_left, 0, 0, 0)
|
||||
self.coverLabel.setFixedSize(icon_size, icon_size)
|
||||
@@ -747,17 +993,22 @@ class GameCard(QFrame):
|
||||
self.economy_mode = ui_config.get_economy_mode()
|
||||
self.steam_visible = str(self.game_source).lower() == "steam"
|
||||
self.gog_visible = str(self.game_source).lower() == "gog"
|
||||
self.egs_visible = str(self.game_source).lower() == "egs"
|
||||
self.portproton_visible = str(self.game_source).lower() == "portproton"
|
||||
self.ppdb_visible = bool(self.ppdb_id) and not self.economy_mode
|
||||
protondb_visible = is_valid_protondb_tier(self.protondb_tier) and not self.economy_mode
|
||||
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) and not self.economy_mode
|
||||
|
||||
hidden_badges = self.badge_view_mode == "hidden"
|
||||
show_source_ribbons = self.vertical_layout is not True
|
||||
|
||||
try:
|
||||
self.steamLabel.setVisible(self.steam_visible)
|
||||
self.gogLabel.setVisible(self.gog_visible)
|
||||
self.portprotonLabel.setVisible(self.portproton_visible)
|
||||
self.steamLabel.setVisible(self.steam_visible and show_source_ribbons)
|
||||
self.gogLabel.setVisible(self.gog_visible and show_source_ribbons)
|
||||
self.egsLabel.setVisible(self.egs_visible and show_source_ribbons)
|
||||
self.portprotonLabel.setVisible(
|
||||
self.portproton_visible and show_source_ribbons
|
||||
)
|
||||
self.ppdbLabel.setVisible(self.ppdb_visible and not hidden_badges)
|
||||
self.protondbLabel.setVisible(protondb_visible and not hidden_badges)
|
||||
self.anticheatLabel.setVisible(anticheat_visible and not hidden_badges)
|
||||
@@ -947,76 +1198,6 @@ class GameCard(QFrame):
|
||||
favorites_config.set_games(favorites)
|
||||
self.update_favorite_icon()
|
||||
|
||||
def getBorderWidth(self) -> int:
|
||||
return self._borderWidth
|
||||
|
||||
def setBorderWidth(self, value: int):
|
||||
if self._borderWidth != value:
|
||||
self._borderWidth = value
|
||||
self.borderWidthChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getGradientAngle(self) -> float:
|
||||
return self._gradientAngle
|
||||
|
||||
def setGradientAngle(self, value: float):
|
||||
if self._gradientAngle != value:
|
||||
self._gradientAngle = value
|
||||
self.gradientAngleChanged.emit()
|
||||
self.update()
|
||||
|
||||
def getScale(self) -> float:
|
||||
return self._scale
|
||||
|
||||
def setScale(self, value: float):
|
||||
if self._scale != value:
|
||||
self._scale = value
|
||||
self.update_scale()
|
||||
self.scaleChanged.emit()
|
||||
|
||||
borderWidth = Property(int, fget=getBorderWidth, fset=setBorderWidth, notify=borderWidthChanged)
|
||||
gradientAngle = Property(float, fget=getGradientAngle, fset=setGradientAngle, notify=gradientAngleChanged)
|
||||
scale = Property(float, fget=getScale, fset=setScale, notify=scaleChanged)
|
||||
|
||||
|
||||
def paintEvent(self, event):
|
||||
super().paintEvent(event)
|
||||
self.animations.paint_border(QPainter(self))
|
||||
|
||||
def enterEvent(self, event):
|
||||
self._hover_sound_pending = True
|
||||
self.animations.handle_enter_event()
|
||||
super().enterEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._hover_sound_pending = False
|
||||
self.animations.handle_leave_event()
|
||||
super().leaveEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
if self._hover_sound_pending:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self._hover_sound_pending = False
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def focusInEvent(self, event):
|
||||
if (
|
||||
QApplication.activeWindow() is not None
|
||||
and event.reason() not in (
|
||||
Qt.FocusReason.ActiveWindowFocusReason,
|
||||
Qt.FocusReason.MouseFocusReason,
|
||||
)
|
||||
):
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().play("navigate")
|
||||
self.animations.handle_focus_in_event()
|
||||
super().focusInEvent(event)
|
||||
|
||||
def focusOutEvent(self, event):
|
||||
self.animations.handle_focus_out_event()
|
||||
super().focusOutEvent(event)
|
||||
|
||||
def hideEvent(self, event: QHideEvent) -> None:
|
||||
self.set_animated_cover_paused(True)
|
||||
super().hideEvent(event)
|
||||
@@ -1025,28 +1206,31 @@ class GameCard(QFrame):
|
||||
self.set_animated_cover_paused(False)
|
||||
super().showEvent(event)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
def click(self) -> None:
|
||||
game_data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"cover_path": self.cover_path,
|
||||
"appid": self.appid,
|
||||
"controller_support": self.controller_support,
|
||||
"exec_line": self.exec_line,
|
||||
"last_launch": self.last_launch,
|
||||
"formatted_playtime": self.formatted_playtime,
|
||||
"playtime_seconds": self.playtime_seconds,
|
||||
"protondb_tier": self.protondb_tier,
|
||||
"game_source": self.game_source,
|
||||
"anticheat_status": self.anticheat_status,
|
||||
"anticheat_slug": self.anticheat_slug,
|
||||
"ppdb_id": self.ppdb_id,
|
||||
"ppdb_rating": self.ppdb_rating,
|
||||
"protondb_appid": self.protondb_appid,
|
||||
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
|
||||
}
|
||||
self.select_callback(game_data)
|
||||
|
||||
def mousePressEvent(self, event: QMouseEvent) -> None:
|
||||
if event.button() == Qt.MouseButton.LeftButton:
|
||||
game_data = {
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"cover_path": self.cover_path,
|
||||
"appid": self.appid,
|
||||
"controller_support": self.controller_support,
|
||||
"exec_line": self.exec_line,
|
||||
"last_launch": self.last_launch,
|
||||
"formatted_playtime": self.formatted_playtime,
|
||||
"playtime_seconds": self.playtime_seconds,
|
||||
"protondb_tier": self.protondb_tier,
|
||||
"game_source": self.game_source,
|
||||
"anticheat_status": self.anticheat_status,
|
||||
"anticheat_slug": self.anticheat_slug,
|
||||
"ppdb_id": self.ppdb_id,
|
||||
"ppdb_rating": self.ppdb_rating,
|
||||
"protondb_appid": self.protondb_appid,
|
||||
"autoinstall_exe_name": getattr(self, "autoinstall_exe_name", ""),
|
||||
}
|
||||
self.select_callback(game_data)
|
||||
self.click()
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def cleanup(self):
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from typing import Protocol
|
||||
from portprotonqt.game_card import GameCard
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Protocol
|
||||
from portprotonqt.game_card import AnimatedCard, GameCard
|
||||
from portprotonqt.search_utils import (
|
||||
SearchOptimizer,
|
||||
ThreadedSearch,
|
||||
@@ -7,15 +8,40 @@ from portprotonqt.search_utils import (
|
||||
search_index,
|
||||
)
|
||||
from PySide6.QtWidgets import QAbstractButton
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtGui import QRegion
|
||||
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QFrame, QScrollArea, QSlider, QScroller, QStackedWidget
|
||||
from PySide6.QtCore import QRect, QRectF, Qt, QTimer
|
||||
from PySide6.QtGui import QPaintEvent, QPainter, QPainterPath, QPixmap, QRegion
|
||||
from portprotonqt.custom_widgets import FlowLayout, AutoHideScrollArea
|
||||
from portprotonqt.config import favorites_config, game_config, ui_config
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.detail_pages.utils import remove_cover_background, setup_cover_background
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
|
||||
from collections import deque
|
||||
|
||||
|
||||
class FullLibraryTile(AnimatedCard):
|
||||
def __init__(self, theme: Any):
|
||||
super().__init__()
|
||||
self.name = ""
|
||||
self.animation_base_size = theme.fullLibraryTileSize
|
||||
self.tile_pixmap = QPixmap()
|
||||
self.setup_card_animations(theme, theme.GAME_CARD_HORIZONTAL)
|
||||
self.update_scale()
|
||||
|
||||
def set_tile_pixmap(self, pixmap: QPixmap) -> None:
|
||||
self.tile_pixmap = pixmap
|
||||
self.update()
|
||||
|
||||
def paintEvent(self, event: QPaintEvent) -> None:
|
||||
QFrame.paintEvent(self, event)
|
||||
painter = QPainter(self)
|
||||
painter.drawPixmap(self.rect(), self.tile_pixmap)
|
||||
painter.end()
|
||||
border_painter = QPainter(self)
|
||||
self.animations.paint_border(border_painter)
|
||||
border_painter.end()
|
||||
|
||||
class MainWindowProtocol(Protocol):
|
||||
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
|
||||
|
||||
@@ -48,8 +74,15 @@ class GameLibraryManager:
|
||||
self.card_width = ui_config.get_card_width()
|
||||
self.layout_mode = str(getattr(theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.gamesListWidget: QWidget | None = None
|
||||
self.gamesListLayout: FlowLayout | None = None
|
||||
self.gamesListLayout: FlowLayout | QHBoxLayout | QVBoxLayout | None = None
|
||||
self.gamesScrollArea: QScrollArea | None = None
|
||||
self.libraryHeaderWidget: QWidget | None = None
|
||||
self.libraryContentLayout: QVBoxLayout | None = None
|
||||
self.libraryBackgroundLabel: QLabel | None = None
|
||||
self.fullLibraryTile: FullLibraryTile | None = None
|
||||
self._full_library_tile_covers: list[str] = []
|
||||
self._full_library_tile_pixmaps: dict[int, QPixmap] = {}
|
||||
self.full_library_open = False
|
||||
self.sizeSlider: QSlider | None = None
|
||||
self._update_timer: QTimer | None = None
|
||||
self._incremental_add_timer: QTimer | None = None
|
||||
@@ -69,23 +102,58 @@ class GameLibraryManager:
|
||||
def create_games_library_widget(self):
|
||||
"""Creates the games library widget with search, grid, and slider."""
|
||||
self.gamesLibraryWidget = QWidget()
|
||||
self.gamesLibraryWidget.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
|
||||
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(self.gamesLibraryWidget)
|
||||
library_background = getattr(self.theme, "LIBRARY_BACKGROUND", None)
|
||||
if isinstance(library_background, dict):
|
||||
stack_layout = QGridLayout(self.gamesLibraryWidget)
|
||||
stack_layout.setContentsMargins(*library_background["margins"])
|
||||
self.libraryBackgroundLabel = QLabel()
|
||||
stack_layout.addWidget(self.libraryBackgroundLabel, 0, 0)
|
||||
content_widget = QWidget()
|
||||
content_widget.setStyleSheet(self.theme.TRANSPARENT_BACKGROUND_STYLE)
|
||||
layout = QVBoxLayout(content_widget)
|
||||
stack_layout.addWidget(content_widget, 0, 0)
|
||||
else:
|
||||
layout = QVBoxLayout(self.gamesLibraryWidget)
|
||||
self.libraryContentLayout = layout
|
||||
layout.setSpacing(15)
|
||||
|
||||
# Search widget
|
||||
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
|
||||
layout.addWidget(searchWidget)
|
||||
|
||||
if self.layout_mode == "vertical":
|
||||
self.libraryHeaderWidget = self._create_library_header()
|
||||
layout.addWidget(self.libraryHeaderWidget)
|
||||
|
||||
# Scroll area for game grid
|
||||
scrollArea = AutoHideScrollArea(theme=self.theme)
|
||||
scrollArea.setProperty("theme_style_name", "TRANSPARENT_BACKGROUND_STYLE")
|
||||
self.gamesScrollArea = scrollArea
|
||||
scrollArea.setWidgetResizable(True)
|
||||
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
|
||||
|
||||
self.gamesListWidget = QWidget()
|
||||
self.gamesListWidget.setProperty("library_layout_mode", self.layout_mode)
|
||||
self.gamesListWidget.setProperty("theme_style_name", "LIST_WIDGET_STYLE")
|
||||
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
|
||||
self.gamesListLayout = FlowLayout(self.gamesListWidget)
|
||||
if self.layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = self.theme.GAME_CARD_HORIZONTAL
|
||||
self.gamesListLayout = QHBoxLayout(self.gamesListWidget)
|
||||
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
|
||||
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
|
||||
scrollArea.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
scrollArea.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||
elif self.layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout(self.gamesListWidget)
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
else:
|
||||
self.gamesListLayout = FlowLayout(self.gamesListWidget)
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
|
||||
scrollArea.setWidget(self.gamesListWidget)
|
||||
@@ -106,7 +174,7 @@ class GameLibraryManager:
|
||||
self.main_window._register_gamepad_tooltip(self.sizeSlider, f"{self.card_width} px")
|
||||
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
|
||||
sliderLayout.addWidget(self.sizeSlider)
|
||||
if self.layout_mode == "list":
|
||||
if self.layout_mode in {"list", "vertical", "horizontal", "horizontal_top"}:
|
||||
self.sizeSlider.setVisible(False)
|
||||
self._set_card_width_from_slider()
|
||||
|
||||
@@ -123,30 +191,125 @@ class GameLibraryManager:
|
||||
|
||||
# Connect scroll event for lazy loading
|
||||
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
scrollArea.horizontalScrollBar().valueChanged.connect(self.load_visible_images)
|
||||
|
||||
return self.gamesLibraryWidget
|
||||
|
||||
def _create_library_header(self) -> QWidget:
|
||||
"""Create the shared header for vertical library columns."""
|
||||
config = self.theme.GAME_CARD_VERTICAL
|
||||
header = QWidget()
|
||||
header.setFixedHeight(config["header_height"])
|
||||
header.setProperty("theme_style_name", "LIBRARY_HEADER_STYLE")
|
||||
header.setStyleSheet(self.theme.LIBRARY_HEADER_STYLE)
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(*config["header_margins"])
|
||||
header_layout.setSpacing(config["header_spacing"])
|
||||
header_layout.addSpacing(config["header_cover_width"])
|
||||
labels = (_("Game Title"), _("LAST LAUNCH"), _("TIME SPENT"), _("Library"))
|
||||
for text, stretch in zip(labels, config["column_stretches"], strict=True):
|
||||
label = QLabel(text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
label.setProperty("theme_style_name", "LIBRARY_HEADER_LABEL_STYLE")
|
||||
label.setStyleSheet(self.theme.LIBRARY_HEADER_LABEL_STYLE)
|
||||
header_layout.addWidget(label, stretch)
|
||||
return header
|
||||
|
||||
def rebuild_library_layout(self, layout_mode: str) -> None:
|
||||
"""Replace the card layout when a theme changes its library mode."""
|
||||
if self.gamesListWidget is None or self.gamesListLayout is None:
|
||||
return
|
||||
self.clear_layout(self.gamesListLayout)
|
||||
self.fullLibraryTile = None
|
||||
old_layout = self.gamesListLayout
|
||||
if layout_mode in {"horizontal", "horizontal_top"}:
|
||||
layout_config = self.theme.GAME_CARD_HORIZONTAL
|
||||
self.gamesListLayout = QHBoxLayout()
|
||||
self.gamesListLayout.setContentsMargins(*layout_config["layout_margins"])
|
||||
self.gamesListLayout.setSpacing(layout_config["layout_spacing"])
|
||||
elif layout_mode == "vertical":
|
||||
layout_config = self.theme.GAME_CARD_VERTICAL
|
||||
self.gamesListLayout = QVBoxLayout()
|
||||
self.gamesListLayout.setContentsMargins(
|
||||
*layout_config.get("layout_margins", (0, 0, 0, 0))
|
||||
)
|
||||
self.gamesListLayout.setSpacing(layout_config.get("layout_spacing", 0))
|
||||
else:
|
||||
self.gamesListLayout = FlowLayout()
|
||||
QWidget().setLayout(old_layout)
|
||||
self.gamesListWidget.setLayout(self.gamesListLayout)
|
||||
if self.gamesScrollArea is not None:
|
||||
horizontal = layout_mode in {"horizontal", "horizontal_top"}
|
||||
vertical_policy = (
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
if horizontal
|
||||
else Qt.ScrollBarPolicy.ScrollBarAsNeeded
|
||||
)
|
||||
self.gamesScrollArea.setHorizontalScrollBarPolicy(
|
||||
Qt.ScrollBarPolicy.ScrollBarAlwaysOff
|
||||
)
|
||||
self.gamesScrollArea.setVerticalScrollBarPolicy(vertical_policy)
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
self.full_library_open = theme_mode == "horizontal_top" and layout_mode == "grid"
|
||||
if self.full_library_open and self.libraryBackgroundLabel is not None:
|
||||
remove_cover_background(self.libraryBackgroundLabel)
|
||||
self.layout_mode = layout_mode
|
||||
if (
|
||||
layout_mode == "vertical"
|
||||
and self.libraryHeaderWidget is None
|
||||
and self.libraryContentLayout is not None
|
||||
):
|
||||
self.libraryHeaderWidget = self._create_library_header()
|
||||
self.libraryContentLayout.insertWidget(1, self.libraryHeaderWidget)
|
||||
if self.libraryHeaderWidget is not None:
|
||||
self.libraryHeaderWidget.setVisible(layout_mode == "vertical")
|
||||
self.gamesListWidget.setProperty("library_layout_mode", layout_mode)
|
||||
if self.sizeSlider is not None:
|
||||
self.sizeSlider.setVisible(
|
||||
not self.full_library_open
|
||||
and layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
|
||||
)
|
||||
self._set_card_width_from_slider()
|
||||
self.main_window.card_width = self.card_width
|
||||
self.set_games(self.games, focus_first_card=False)
|
||||
|
||||
def open_full_library(self) -> None:
|
||||
"""Open the complete grid from the horizontal top mode."""
|
||||
if self.layout_mode == "horizontal_top":
|
||||
self.rebuild_library_layout("grid")
|
||||
|
||||
def close_full_library(self) -> bool:
|
||||
"""Return from the complete grid to the horizontal top mode."""
|
||||
if not self.full_library_open:
|
||||
return False
|
||||
self.full_library_open = False
|
||||
self.rebuild_library_layout("horizontal_top")
|
||||
return True
|
||||
|
||||
def on_slider_released(self):
|
||||
"""Handles slider release to update card size."""
|
||||
if self.layout_mode == "list":
|
||||
if self.full_library_open or self.layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
return
|
||||
if self.sizeSlider is None:
|
||||
return
|
||||
self._set_card_width_from_slider()
|
||||
if hasattr(self.main_window, "_gamepad_tooltip_map"):
|
||||
self.main_window._gamepad_tooltip_map[self.sizeSlider] = f"{self.card_width} px"
|
||||
if self.layout_mode != "list":
|
||||
ui_config.set_card_width(self.card_width)
|
||||
ui_config.set_card_width(self.card_width)
|
||||
self.main_window.card_width = self.card_width
|
||||
for card in self.game_card_cache.values():
|
||||
card.update_card_size(self.card_width)
|
||||
self.update_game_grid()
|
||||
|
||||
def _set_card_width_from_slider(self):
|
||||
"""Use max card width for list layout."""
|
||||
"""Use max card width for fixed-size layouts."""
|
||||
if self.sizeSlider is None:
|
||||
return
|
||||
if self.layout_mode == "list":
|
||||
if self.full_library_open or self.layout_mode in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
self.card_width = self.sizeSlider.maximum()
|
||||
else:
|
||||
self.card_width = self.sizeSlider.value()
|
||||
@@ -199,7 +362,7 @@ class GameLibraryManager:
|
||||
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
|
||||
try:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
self.main_window.current_hovered_card.animations.handle_leave_event()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = None
|
||||
@@ -210,10 +373,29 @@ class GameLibraryManager:
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_focused_card = card
|
||||
self._update_library_background(card)
|
||||
else:
|
||||
if self.main_window.current_focused_card == card:
|
||||
self.main_window.current_focused_card = None
|
||||
|
||||
def _update_library_background(self, card: GameCard) -> None:
|
||||
"""Render the configured library background from the active cover."""
|
||||
if self.full_library_open or self.libraryBackgroundLabel is None:
|
||||
return
|
||||
pixmap = card.coverLabel.pixmap()
|
||||
if pixmap is None or pixmap.isNull():
|
||||
return
|
||||
library_config = self.theme.LIBRARY_BACKGROUND
|
||||
backgrounds = dict(getattr(self.theme, "DETAIL_PAGE_BACKGROUNDS", {}))
|
||||
backgrounds.update(library_config.get("backgrounds", {}))
|
||||
background_theme = SimpleNamespace(
|
||||
DETAIL_PAGE_BG_MODE=library_config.get("mode", "gradient"),
|
||||
DETAIL_PAGE_BACKGROUNDS=backgrounds,
|
||||
)
|
||||
setup_cover_background(
|
||||
self.libraryBackgroundLabel, pixmap, self.main_window, background_theme
|
||||
)
|
||||
|
||||
def _collapse_library_filter_controls(self) -> None:
|
||||
if getattr(self.main_window, "_library_controls_hover_close_delayed", False):
|
||||
return
|
||||
@@ -251,10 +433,11 @@ class GameLibraryManager:
|
||||
try:
|
||||
if self.main_window.current_hovered_card:
|
||||
self.main_window.current_hovered_card._hovered = False
|
||||
self.main_window.current_hovered_card.leaveEvent(None)
|
||||
self.main_window.current_hovered_card.animations.handle_leave_event()
|
||||
except RuntimeError:
|
||||
pass # Card already deleted
|
||||
self.main_window.current_hovered_card = card
|
||||
self._update_library_background(card)
|
||||
else:
|
||||
if self.main_window.current_hovered_card == card:
|
||||
self.main_window.current_hovered_card = None
|
||||
@@ -275,9 +458,14 @@ class GameLibraryManager:
|
||||
"""Schedules a game grid update with debouncing."""
|
||||
if focus_first_card is not None:
|
||||
self._focus_first_card_after_update = focus_first_card
|
||||
self.layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
theme_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
if not self.full_library_open:
|
||||
self.layout_mode = theme_mode
|
||||
if self.sizeSlider is not None:
|
||||
self.sizeSlider.setVisible(self.layout_mode != "list")
|
||||
self.sizeSlider.setVisible(
|
||||
not self.full_library_open
|
||||
and self.layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
|
||||
)
|
||||
old_card_width = self.card_width
|
||||
self._set_card_width_from_slider()
|
||||
self.main_window.card_width = self.card_width
|
||||
@@ -292,6 +480,9 @@ class GameLibraryManager:
|
||||
# When filtering, we want to update with the current filtered_games
|
||||
# which has already been set by _perform_search
|
||||
pass
|
||||
if is_filter and self.layout_mode == "horizontal_top":
|
||||
self.dirty = True
|
||||
is_filter = False
|
||||
self.is_filtering = is_filter
|
||||
self._pending_update = True
|
||||
|
||||
@@ -386,6 +577,7 @@ class GameLibraryManager:
|
||||
|
||||
if added_new_card:
|
||||
self.load_visible_images()
|
||||
self._sync_full_library_tile()
|
||||
self.force_update_cards_library()
|
||||
self._cancel_incremental_add()
|
||||
self._schedule_focus_first_card()
|
||||
@@ -404,7 +596,9 @@ class GameLibraryManager:
|
||||
self._update_search_results(search_text)
|
||||
else:
|
||||
# Full update: sorting, removal/addition, reorganization
|
||||
games_list = self.filtered_games if self.filtered_games else self.games
|
||||
games_list = self.filtered_games
|
||||
if self.layout_mode != "horizontal_top" and not games_list:
|
||||
games_list = self.games
|
||||
favorites = favorites_config.get_games()
|
||||
sort_method = game_config.get_sort_method()
|
||||
|
||||
@@ -433,6 +627,13 @@ class GameLibraryManager:
|
||||
sorted_fav = sorted(fav_games, key=partition_sort_key)
|
||||
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
|
||||
sorted_games = sorted_fav + sorted_non_fav
|
||||
if self.layout_mode == "horizontal_top":
|
||||
limit = self.theme.horizontalTopGameLimit
|
||||
tile_games = sorted_games[limit:limit + self.theme.fullLibraryTileGameCount]
|
||||
self._full_library_tile_covers = [str(game[2] or "") for game in tile_games]
|
||||
sorted_games = sorted_games[:limit]
|
||||
else:
|
||||
self._full_library_tile_covers = []
|
||||
|
||||
# Build set of current game keys for faster lookup
|
||||
current_game_keys = {(game[0], game[5]) for game in sorted_games}
|
||||
@@ -525,8 +726,68 @@ class GameLibraryManager:
|
||||
self.force_update_cards_library()
|
||||
|
||||
self.is_filtering = False # Reset flag in any case
|
||||
if not self._incremental_add_queue:
|
||||
self._sync_full_library_tile()
|
||||
self._schedule_focus_first_card()
|
||||
|
||||
def _sync_full_library_tile(self) -> None:
|
||||
if self.fullLibraryTile is not None:
|
||||
self.fullLibraryTile.setParent(None)
|
||||
self.fullLibraryTile.deleteLater()
|
||||
self.fullLibraryTile = None
|
||||
if self.layout_mode != "horizontal_top" or self.gamesListLayout is None:
|
||||
return
|
||||
if len(self.filtered_games) <= self.theme.horizontalTopGameLimit:
|
||||
return
|
||||
tile = FullLibraryTile(self.theme)
|
||||
tile.setProperty("full_library_tile", True)
|
||||
tile.setStyleSheet(self.theme.FULL_LIBRARY_TILE_STYLE)
|
||||
tile.clicked.connect(self.open_full_library)
|
||||
self.gamesListLayout.addWidget(tile)
|
||||
self.fullLibraryTile = tile
|
||||
self._full_library_tile_pixmaps = {}
|
||||
cover_width, cover_height = self.theme.fullLibraryTileSize
|
||||
for index, cover_path in enumerate(self._full_library_tile_covers):
|
||||
load_pixmap_async(
|
||||
cover_path,
|
||||
cover_width,
|
||||
cover_height,
|
||||
lambda pixmap, item=index: self._set_full_library_tile_cover(
|
||||
tile, item, pixmap
|
||||
),
|
||||
)
|
||||
|
||||
def _set_full_library_tile_cover(
|
||||
self, tile: FullLibraryTile, index: int, pixmap: QPixmap
|
||||
) -> None:
|
||||
if tile is not self.fullLibraryTile:
|
||||
return
|
||||
self._full_library_tile_pixmaps[index] = pixmap
|
||||
width, height = self.theme.fullLibraryTileSize
|
||||
cell_width = width // self.theme.fullLibraryTileColumns
|
||||
cell_height = height // self.theme.fullLibraryTileRows
|
||||
canvas = QPixmap(width, height)
|
||||
canvas.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(canvas)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
clip = QPainterPath()
|
||||
clip.addRoundedRect(
|
||||
QRectF(canvas.rect()),
|
||||
self.theme.fullLibraryTileRadius,
|
||||
self.theme.fullLibraryTileRadius,
|
||||
)
|
||||
painter.setClipPath(clip)
|
||||
for item, cover in self._full_library_tile_pixmaps.items():
|
||||
cell = QRect(
|
||||
item % self.theme.fullLibraryTileColumns * cell_width,
|
||||
item // self.theme.fullLibraryTileColumns * cell_height,
|
||||
cell_width,
|
||||
cell_height,
|
||||
)
|
||||
painter.drawPixmap(cell, cover)
|
||||
painter.end()
|
||||
tile.set_tile_pixmap(canvas)
|
||||
|
||||
def _schedule_focus_first_card(self) -> None:
|
||||
if not self._focus_first_card_after_update:
|
||||
return
|
||||
|
||||
@@ -23,6 +23,7 @@ downloader = Downloader()
|
||||
logger = get_logger(__name__)
|
||||
COVER_IMAGE_EXTENSIONS = (".png", ".apng", ".jpg", ".jpeg", ".gif", ".webp", ".jxl", ".svg")
|
||||
DEFAULT_ANIMATION_DELAY_MS = 100
|
||||
REMOTE_COVER_DOWNLOAD_TIMEOUT = 15
|
||||
|
||||
# Global queue and thread pool for image loading
|
||||
image_load_queue = Queue()
|
||||
@@ -489,7 +490,11 @@ def load_pixmap_async(
|
||||
finish_with(pixmap)
|
||||
|
||||
finish_with(load_fallback_pixmap())
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
downloader.download_async(
|
||||
cover, local_path,
|
||||
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"URL processing error {cover}: {e}")
|
||||
@@ -524,7 +529,11 @@ def load_pixmap_async(
|
||||
|
||||
logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
|
||||
finish_with(load_fallback_pixmap())
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
downloader.download_async(
|
||||
cover, local_path,
|
||||
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
@@ -555,7 +564,11 @@ def load_pixmap_async(
|
||||
finish_with(pixmap)
|
||||
|
||||
finish_with(load_fallback_pixmap())
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
downloader.download_async(
|
||||
cover, local_path,
|
||||
timeout=REMOTE_COVER_DOWNLOAD_TIMEOUT,
|
||||
callback=on_downloaded,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error("Error processing remote image URL %s: %s", cover, str(e))
|
||||
@@ -606,6 +619,7 @@ def round_corners(pixmap, radius):
|
||||
painter.end()
|
||||
return rounded
|
||||
|
||||
|
||||
class FullscreenDialog(QDialog):
|
||||
"""
|
||||
Dialog for viewing images without standard controls.
|
||||
|
||||
@@ -7,7 +7,7 @@ from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea
|
||||
from PySide6.QtCore import Qt, QObject, Signal, Slot, QTimer, QThread
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_card import AnimatedCard
|
||||
from portprotonqt.config import display_config, window_config
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
@@ -286,9 +286,11 @@ class InputManager(
|
||||
if container is None:
|
||||
return
|
||||
game_cards = [
|
||||
card for card in container.findChildren(GameCard)
|
||||
card for card in container.findChildren(AnimatedCard)
|
||||
if card.isVisible() and card.isEnabled()
|
||||
]
|
||||
if self._navigate_horizontal_library(game_cards, code, value):
|
||||
return
|
||||
focused = QApplication.focusWidget()
|
||||
if game_cards and focused not in game_cards:
|
||||
self._focus_grid_card(game_cards[0])
|
||||
@@ -299,6 +301,33 @@ class InputManager(
|
||||
if focused in first_row:
|
||||
self._parent.tabButtons[tab_index].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
def _navigate_horizontal_library(
|
||||
self, cards: list[QWidget], code: int, value: int
|
||||
) -> bool:
|
||||
manager = getattr(self._parent, "game_library_manager", None)
|
||||
if getattr(manager, "layout_mode", "grid") not in {
|
||||
"horizontal", "horizontal_top"
|
||||
}:
|
||||
return False
|
||||
layout = getattr(manager, "gamesListLayout", None)
|
||||
if code != PAD_DPAD_X or value == 0 or layout is None:
|
||||
return False
|
||||
ordered_cards = []
|
||||
for index in range(layout.count()):
|
||||
item = layout.itemAt(index)
|
||||
widget = item.widget() if item is not None else None
|
||||
if widget in cards:
|
||||
ordered_cards.append(widget)
|
||||
focused = QApplication.focusWidget()
|
||||
if not ordered_cards:
|
||||
return True
|
||||
if focused not in ordered_cards:
|
||||
self._focus_grid_card(ordered_cards[0])
|
||||
return True
|
||||
target_index = (ordered_cards.index(focused) + value) % len(ordered_cards)
|
||||
self._focus_grid_card(ordered_cards[target_index])
|
||||
return True
|
||||
|
||||
def _get_card_grid_rows(self, cards: list[QWidget]) -> list[list[QWidget]]:
|
||||
"""Group cards into rows ordered by their visual position."""
|
||||
rows = {}
|
||||
|
||||
@@ -276,6 +276,10 @@ class ButtonInputMixin(InputMixin):
|
||||
|
||||
def _handle_standard_back(self) -> None:
|
||||
SoundManager().play("back")
|
||||
manager = getattr(self._parent, "game_library_manager", None)
|
||||
close_full_library = getattr(manager, "close_full_library", None)
|
||||
if callable(close_full_library) and close_full_library():
|
||||
return
|
||||
if self._is_theme_store_visible():
|
||||
store_stack = getattr(self._parent, "themeStoreStack", None)
|
||||
detail_page = getattr(self._parent, "themeStoreDetailPage", None)
|
||||
|
||||
@@ -15,7 +15,7 @@ from PySide6.QtWidgets import (
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, NavLabel
|
||||
from portprotonqt.dialogs import AddGameDialog
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_card import AnimatedCard
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
PAD_AXIS_LEFT_X,
|
||||
@@ -267,7 +267,7 @@ class DpadInputMixin(InputMixin):
|
||||
current_index = self._parent.stackedWidget.currentIndex()
|
||||
if code != PAD_DPAD_Y or value >= 0 or current_index not in (0, 1):
|
||||
return False
|
||||
if not isinstance(focused, GameCard):
|
||||
if not isinstance(focused, AnimatedCard):
|
||||
return False
|
||||
if current_index == 0:
|
||||
container = self._parent.gamesListWidget
|
||||
@@ -279,7 +279,7 @@ class DpadInputMixin(InputMixin):
|
||||
if container is None or focus_target is None:
|
||||
return False
|
||||
cards: list[QWidget] = [
|
||||
card for card in container.findChildren(GameCard)
|
||||
card for card in container.findChildren(AnimatedCard)
|
||||
if card.isVisible() and card.isEnabled()
|
||||
]
|
||||
if not cards or focused not in self._get_card_grid_rows(cards)[0]:
|
||||
@@ -354,7 +354,7 @@ class DpadInputMixin(InputMixin):
|
||||
isinstance(focused, QListView)
|
||||
and self._find_parent_combo(focused) is not None
|
||||
)
|
||||
if is_initial_press and not isinstance(focused, GameCard) and not combo_popup:
|
||||
if is_initial_press and not isinstance(focused, AnimatedCard) and not combo_popup:
|
||||
SoundManager().play("navigate")
|
||||
if self._handle_dialog_dpad(active, code, value):
|
||||
return
|
||||
|
||||
@@ -21,7 +21,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from portprotonqt.custom_widgets import AutoSizeButton
|
||||
from portprotonqt.game_card import GameCard
|
||||
from portprotonqt.game_card import AnimatedCard, GameCard
|
||||
from portprotonqt.image_utils import FullscreenDialog
|
||||
from portprotonqt.input_manager.constants import (
|
||||
BUTTONS,
|
||||
@@ -406,7 +406,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
active = QApplication.activeWindow()
|
||||
if self.file_explorer or isinstance(active, QMessageBox | QDialog):
|
||||
return False
|
||||
if isinstance(focused, GameCard | QLineEdit | QTableWidget | AutoSizeButton | QCheckBox):
|
||||
if isinstance(focused, AnimatedCard | QLineEdit | QTableWidget | AutoSizeButton | QCheckBox):
|
||||
return False
|
||||
return self._switch_visible_tab(-1 if key == KEY_LEFT else 1)
|
||||
|
||||
@@ -474,7 +474,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
def _focus_first_game_card(self, container: QWidget | None) -> bool:
|
||||
if container is None:
|
||||
return False
|
||||
for card in container.findChildren(GameCard):
|
||||
for card in container.findChildren(AnimatedCard):
|
||||
if card.isVisible() and card.isEnabled():
|
||||
card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
scroll_area = container.parentWidget()
|
||||
@@ -598,7 +598,7 @@ class KeyboardInputMixin(InputMixin):
|
||||
def _handle_input_back_key(self, focused: QWidget | None) -> bool:
|
||||
if isinstance(focused, QLineEdit) or self._focused_editable_combo(focused):
|
||||
return False
|
||||
self._parent.goBackDetailPage(self._parent.currentDetailPage)
|
||||
self._handle_standard_back()
|
||||
return True
|
||||
|
||||
def _handle_input_key_release(self, key: int) -> bool:
|
||||
|
||||
@@ -169,6 +169,15 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -434,6 +443,15 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -833,9 +851,6 @@ msgstr ""
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Later"
|
||||
msgstr ""
|
||||
|
||||
@@ -1122,7 +1137,7 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
@@ -1993,9 +2008,39 @@ msgstr ""
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
@@ -2129,6 +2174,9 @@ msgstr ""
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2294,6 +2342,9 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-18 23:41+0500\n"
|
||||
"PO-Revision-Date: 2026-08-16 16:24+0000\n"
|
||||
"PO-Revision-Date: 2026-09-02 20:25+0000\n"
|
||||
"Last-Translator: Rubin13 <rubin13@noreply.codeberg.org>\n"
|
||||
"Language-Team: German <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/de/>\n"
|
||||
@@ -174,6 +174,15 @@ msgstr "Löschen"
|
||||
msgid "Import"
|
||||
msgstr "Import"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Aktualisierung"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "EOS-Overlay deaktivieren"
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "EOS-Overlay aktivieren"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Spielordner öffnen"
|
||||
|
||||
@@ -445,6 +454,15 @@ msgstr "Abhängigkeiten installieren"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Notwendige 32-Bit-Software fehlen. Jetzt installieren?"
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Epic-Spieleordner auswählen"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "Eine weitere Epic-Operation läuft"
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Wine-Prefix nicht gefunden"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Spiel nicht gefunden."
|
||||
|
||||
@@ -904,9 +922,6 @@ msgstr "Update verfügbar"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Ein neues AppImage-Update ist verfügbar."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Aktualisierung"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Später"
|
||||
|
||||
@@ -1201,8 +1216,8 @@ msgstr "Wert"
|
||||
msgid "Description"
|
||||
msgstr "Beschreibung"
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgstr "PPDB öffnen"
|
||||
msgid "Edit PPDB"
|
||||
msgstr "PPDB bearbeiten"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "PPDB zurücksetzen"
|
||||
@@ -2090,9 +2105,39 @@ msgstr "GOG-Bibliothek wird aktualisiert… {0}/{1}"
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Aktualisierung der GOG-Bibliothek fehlgeschlagen: {0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "Abmeldung vom Epic-Konto fehlgeschlagen"
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "Kopiere den Autorisierungscode in die Zwischenablage"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr "Anmeldung bei Epic-Konto…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Epic-Anmeldung fehlgeschlagen: {0}"
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr "Epic-Konto verknüpft"
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr "Epic-Konto nicht verbunden"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Epic-Bibliothek wird aktualisiert… {0}/{1}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Aktualisierung der Epic-Bibliothek fehlgeschlagen: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Installationsordner auswählen"
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr "Ein weiterer Download läuft"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Eine andere GOG-Aktion läuft bereits"
|
||||
|
||||
@@ -2226,7 +2271,10 @@ msgid "Hide Auto-Install Tab"
|
||||
msgstr "Tab „Automatische Installation“ ausblenden"
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
msgstr "Steuerungshinweise ausblenden"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "Englische Sprache erzwingen"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Benutzeroberflächen-Sounds aktivieren"
|
||||
@@ -2397,6 +2445,9 @@ msgstr "Wiedergabegeräte"
|
||||
msgid "Output"
|
||||
msgstr "Ausgang"
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr "Zum Desktop zurückkehren"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Abmelden"
|
||||
|
||||
@@ -2464,9 +2515,8 @@ msgstr "Theme auswählen:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Theme anwenden"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Theme auswählen:"
|
||||
msgstr "Theme löschen"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Kein Link"
|
||||
@@ -2480,13 +2530,12 @@ msgstr "Autor:"
|
||||
msgid "Link:"
|
||||
msgstr "Link:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Theme auswählen:"
|
||||
msgstr "Theme '{0}' löschen?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Laden der Themes fehlgeschlagen"
|
||||
msgstr "Das Theme konnte nicht gelöscht werden."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefix:"
|
||||
|
||||
@@ -176,6 +176,15 @@ msgstr "Borrar"
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Actualizar"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir la carpeta del juego"
|
||||
|
||||
@@ -447,6 +456,17 @@ msgstr "Instalar las dependencias"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Faltan algunas dependencias de 32 bits. ¿Las instalas ahora?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Borrar la carpeta"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefijo no encontrado"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Juego no encontrado."
|
||||
|
||||
@@ -920,9 +940,6 @@ msgstr "Actualización disponible"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Hay una nueva actualización de AppImage disponible."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Actualizar"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Más tarde"
|
||||
|
||||
@@ -1213,7 +1230,7 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
@@ -2089,9 +2106,40 @@ msgstr ""
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Error al iniciar el juego: {0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Archivo: {0}"
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Error al iniciar el juego: {0}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Error al iniciar el juego: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "Descargando"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
@@ -2231,6 +2279,9 @@ msgstr ""
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2397,6 +2448,10 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "Añadir al escritorio"
|
||||
|
||||
msgid "Logout"
|
||||
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: 2026-08-21 21:05+0500\n"
|
||||
"POT-Creation-Date: 2026-09-01 17:22+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"
|
||||
@@ -165,6 +165,15 @@ msgstr ""
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr ""
|
||||
|
||||
@@ -430,6 +439,15 @@ msgstr ""
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr ""
|
||||
|
||||
@@ -829,9 +847,6 @@ msgstr ""
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr ""
|
||||
|
||||
msgid "Later"
|
||||
msgstr ""
|
||||
|
||||
@@ -1118,7 +1133,7 @@ msgstr ""
|
||||
msgid "Description"
|
||||
msgstr ""
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear PPDB"
|
||||
@@ -1989,9 +2004,39 @@ msgstr ""
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr ""
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
@@ -2125,6 +2170,9 @@ msgstr ""
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr ""
|
||||
|
||||
@@ -2291,6 +2339,9 @@ msgstr ""
|
||||
msgid "Output"
|
||||
msgstr ""
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr ""
|
||||
|
||||
msgid "Logout"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-06-04 10:32+0500\n"
|
||||
"PO-Revision-Date: 2026-08-20 21:25+0000\n"
|
||||
"PO-Revision-Date: 2026-08-28 22:26+0000\n"
|
||||
"Last-Translator: rogerio-mattos <rogerio-mattos@noreply.codeberg.org>\n"
|
||||
"Language-Team: Portuguese (Brazil) <https://translate.codeberg.org/projects/"
|
||||
"portprotonqt/portprotonqt/pt_BR/>\n"
|
||||
@@ -174,6 +174,17 @@ msgstr "Excluir"
|
||||
msgid "Import"
|
||||
msgstr "Importar"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Atualizar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "Ativar overlay mangoapp"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "Ativar overlay mangoapp"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Abrir Pasta do Jogo"
|
||||
|
||||
@@ -444,6 +455,18 @@ msgstr "Instalar dependências"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "As dependências de 32 bits estão faltando. Instalá-las agora?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Selecionar pasta do jogo da GOG"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "Outra operação da GOG já está em execução"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Prefixo não encontrado"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Jogo não encontrado."
|
||||
|
||||
@@ -905,9 +928,6 @@ msgstr "Atualização disponível"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Uma nova atualização do AppImage está disponível."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Atualizar"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Mais tarde"
|
||||
|
||||
@@ -1201,8 +1221,8 @@ msgstr "Valor"
|
||||
msgid "Description"
|
||||
msgstr "Descrição"
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgstr "Abrir PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr "Editar PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Limpar PPDB"
|
||||
@@ -2088,9 +2108,44 @@ msgstr "Atualizando biblioteca da GOG… {0}/{1}"
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "Falha ao sair da conta GOG"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "Copiar o link para a área de transferência"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Falha no login da GOG: {0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account connected"
|
||||
msgstr "Conta GOG conectada"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account not connected"
|
||||
msgstr "Conta GOG não conectada"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Atualizando biblioteca da GOG… {0}/{1}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Falha ao atualizar a biblioteca da GOG: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Selecionar pasta de instalação"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "Baixando agora"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Outra operação da GOG já está em execução"
|
||||
|
||||
@@ -2226,6 +2281,9 @@ msgstr "Ocultar Aba de Instalação Automática"
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Ocultar dicas de controle"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Ativar sons da interface"
|
||||
|
||||
@@ -2393,8 +2451,12 @@ msgstr "Dispositivos de reprodução"
|
||||
msgid "Output"
|
||||
msgstr "Saída"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "Adicionar à Área de Trabalho"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Sair"
|
||||
msgstr "Encerrar sessão"
|
||||
|
||||
msgid "Reboot"
|
||||
msgstr "Reiniciar"
|
||||
@@ -2460,9 +2522,8 @@ msgstr "Selecionar Tema:"
|
||||
msgid "Apply Theme"
|
||||
msgstr "Aplicar Tema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Delete Theme"
|
||||
msgstr "Selecionar Tema:"
|
||||
msgstr "Excluir tema"
|
||||
|
||||
msgid "No link"
|
||||
msgstr "Sem link"
|
||||
@@ -2476,13 +2537,12 @@ msgstr "Autor:"
|
||||
msgid "Link:"
|
||||
msgstr "Link:"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
#, python-brace-format
|
||||
msgid "Delete theme '{0}'?"
|
||||
msgstr "Selecionar Tema:"
|
||||
msgstr "Excluir o tema '{0}'?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Falha ao carregar temas"
|
||||
msgstr "Não foi possível excluir o tema."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Prefixo:"
|
||||
|
||||
@@ -9,8 +9,8 @@ msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: support@linux-gaming.ru\n"
|
||||
"POT-Creation-Date: 2026-03-22 13:50+0500\n"
|
||||
"PO-Revision-Date: 2026-08-24 10:25+0000\n"
|
||||
"Last-Translator: Boria138 <boria138@noreply.codeberg.org>\n"
|
||||
"PO-Revision-Date: 2026-09-05 17:10+0000\n"
|
||||
"Last-Translator: adem4ik <adem4ik@noreply.codeberg.org>\n"
|
||||
"Language-Team: Russian <https://translate.codeberg.org/projects/portprotonqt/"
|
||||
"portprotonqt/ru/>\n"
|
||||
"Language: ru\n"
|
||||
@@ -175,6 +175,15 @@ msgstr "Удалить"
|
||||
msgid "Import"
|
||||
msgstr "Импорт"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "Отключить наложение EOS"
|
||||
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "Включить наложение EOS"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "Открыть папку с игрой"
|
||||
|
||||
@@ -260,7 +269,7 @@ msgstr "Не удалось удалить старый файл .desktop: {erro
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Removed old .desktop file for '{game_name}'"
|
||||
msgstr "Удален старый файл .desktop для '{game_name}'"
|
||||
msgstr "Удалён старый файл .desktop для '{game_name}'"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to save .desktop file: {error}"
|
||||
@@ -443,6 +452,15 @@ msgstr "Установить зависимости"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "Отсутствуют 32-битные зависимости. Установить их сейчас?"
|
||||
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "Выберите папку игры Epic"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr "Другая операция Epic уже выполняется"
|
||||
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "Префикс Wine не найден"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "Игра не найдена."
|
||||
|
||||
@@ -558,7 +576,7 @@ msgid "Force DirectInput protocol instead of XInput"
|
||||
msgstr "Принудительно использовать протокол DirectInput вместо XInput"
|
||||
|
||||
msgid "Use GStreamer for in-game clips (WMF support)"
|
||||
msgstr "Использовать GStreamer для внутриигровых клипов (поддержка WMF)"
|
||||
msgstr "Использовать GStreamer для внутриигровых роликов (поддержка WMF)"
|
||||
|
||||
msgid "Use WINE shader caching"
|
||||
msgstr "Использовать кэширование шейдеров WINE"
|
||||
@@ -594,8 +612,8 @@ msgid ""
|
||||
"Prevent the system from going to sleep and disable the screensaver while "
|
||||
"the game is running"
|
||||
msgstr ""
|
||||
"Предотвратите переход системы в спящий режим и отключите заставку во "
|
||||
"время игры"
|
||||
"Предотвращать переход системы в спящий режим и отключать заставку во время "
|
||||
"игры"
|
||||
|
||||
msgid "Use container launch mode (recommended default)"
|
||||
msgstr "Использовать режим запуска в контейнере (рекомендуемый по умолчанию)"
|
||||
@@ -619,7 +637,7 @@ msgid "Stable"
|
||||
msgstr "Стабильный"
|
||||
|
||||
msgid "Vulkan Backend"
|
||||
msgstr "Vulkan рендеринг"
|
||||
msgstr "Бэкенд Vulkan"
|
||||
|
||||
msgid ""
|
||||
"Select the DirectX → Vulkan/OpenGL backend:\n"
|
||||
@@ -773,9 +791,9 @@ msgid ""
|
||||
" single node reduces memory-access latency and limits costly core-to-core"
|
||||
" switches."
|
||||
msgstr ""
|
||||
"Узел NUMA для аффинности CPU. В многоядерных системах CPU разделены на "
|
||||
"узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка игры к "
|
||||
"одному узлу уменьшает задержку доступа к памяти и ограничивает "
|
||||
"Узел NUMA для привязки процессора. В многоядерных системах процессоры "
|
||||
"разделены на узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка "
|
||||
"игры к одному узлу уменьшает задержку доступа к памяти и ограничивает "
|
||||
"дорогостоящие переключения между ядрами."
|
||||
|
||||
msgid "Sound Driver"
|
||||
@@ -905,9 +923,6 @@ msgstr "Доступно обновление"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "Доступно новое обновление AppImage."
|
||||
|
||||
msgid "Update"
|
||||
msgstr "Обновить"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "Позже"
|
||||
|
||||
@@ -958,7 +973,7 @@ msgid "No cover selected"
|
||||
msgstr "Обложка не выбрана"
|
||||
|
||||
msgid "Compatibility report"
|
||||
msgstr "Отчет о совместимости"
|
||||
msgstr "Отчёт о совместимости"
|
||||
|
||||
msgid "The program stopped. Compatibility details were collected automatically."
|
||||
msgstr ""
|
||||
@@ -1151,7 +1166,7 @@ msgstr "Загрузка: {0} ({1}%)"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Extracting: {0}"
|
||||
msgstr "Распаковка {0}"
|
||||
msgstr "Распаковка: {0}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ", ETA: {}s"
|
||||
@@ -1201,8 +1216,8 @@ msgstr "Значение"
|
||||
msgid "Description"
|
||||
msgstr "Описание"
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgstr "Открыть PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr "Редактировать PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "Очистить PPDB"
|
||||
@@ -1265,7 +1280,7 @@ msgid "Enable HDR output"
|
||||
msgstr "Включить вывод HDR"
|
||||
|
||||
msgid "Enable SDR→HDR inverse tone mapping"
|
||||
msgstr "Включить SDR→HDR инвертированное тональное отображение"
|
||||
msgstr "Включить обратное тональное отображение SDR→HDR"
|
||||
|
||||
msgid "Force HDR support"
|
||||
msgstr "Принудительная поддержка HDR"
|
||||
@@ -1283,10 +1298,10 @@ msgid "Enable adaptive sync"
|
||||
msgstr "Включить адаптивную синхронизацию"
|
||||
|
||||
msgid "Output width"
|
||||
msgstr "Выходная ширина"
|
||||
msgstr "Ширина вывода"
|
||||
|
||||
msgid "Output height"
|
||||
msgstr "Выходная высота"
|
||||
msgstr "Высота вывода"
|
||||
|
||||
msgid "Game width"
|
||||
msgstr "Ширина игры"
|
||||
@@ -1307,7 +1322,7 @@ msgid "Upscaler type"
|
||||
msgstr "Режим масштабирования"
|
||||
|
||||
msgid "Upscaler filter"
|
||||
msgstr "Режим интерполяции"
|
||||
msgstr "Фильтр масштабирования"
|
||||
|
||||
msgid "Upscaler sharpness (0-20)"
|
||||
msgstr "Улучшение резкости (0-20)"
|
||||
@@ -1804,7 +1819,7 @@ msgid "Local time"
|
||||
msgstr "Местное время"
|
||||
|
||||
msgid "Time without label"
|
||||
msgstr "Скрыть слово \"Time\" перед временем"
|
||||
msgstr "Скрывать слово \"Time\" перед временем"
|
||||
|
||||
msgid "Force trilinear filtering"
|
||||
msgstr "Включить трилинейную фильтрацию"
|
||||
@@ -1887,7 +1902,7 @@ msgid "Failed to read default MangoHud config."
|
||||
msgstr "Ошибка при чтении стандартной конфигурации MangoHud."
|
||||
|
||||
msgid "System MangoHud config has been written."
|
||||
msgstr "Конфигурация записана в системный конфиг."
|
||||
msgstr "Конфигурация MangoHud записана в системный конфиг."
|
||||
|
||||
msgid "Failed to write system MangoHud config."
|
||||
msgstr "Ошибка при записи конфигурации MangoHud."
|
||||
@@ -2085,9 +2100,39 @@ msgstr "Обновление библиотеки GOG… {0}/{1}"
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "Не удалось обновить библиотеку GOG: {0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr "Не удалось выйти из учётной записи Epic"
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr "Скопировать код авторизации в буфер обмена"
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr "Авторизация Epic…"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "Ошибка входа в Epic: {0}"
|
||||
|
||||
msgid "Epic account connected"
|
||||
msgstr "Учётная запись Epic подключена"
|
||||
|
||||
msgid "Epic account not connected"
|
||||
msgstr "Учётная запись Epic не подключена"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "Обновление библиотеки Epic… {0}/{1}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "Не удалось обновить библиотеку Epic: {0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr "Выберите папку установки"
|
||||
|
||||
msgid "Another download is running"
|
||||
msgstr "Другая загрузка уже выполняется"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr "Другая операция GOG уже выполняется"
|
||||
|
||||
@@ -2130,7 +2175,7 @@ msgid "Quick Launch"
|
||||
msgstr "Быстрый запуск"
|
||||
|
||||
msgid "Delete cards without executable"
|
||||
msgstr "Удалить карточки без исполняемого файла"
|
||||
msgstr "Удалить карточки без исполняемых файлов"
|
||||
|
||||
msgid "Library Settings"
|
||||
msgstr "Настройки Библиотеки"
|
||||
@@ -2201,7 +2246,7 @@ msgid "Gamepad Type:"
|
||||
msgstr "Тип геймпада:"
|
||||
|
||||
msgid "Launch Application in Fullscreen"
|
||||
msgstr "Запуск приложения в полноэкранном режиме"
|
||||
msgstr "Запускать приложение в полноэкранном режиме"
|
||||
|
||||
msgid "Auto Fullscreen on Gamepad connected"
|
||||
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
|
||||
@@ -2210,22 +2255,25 @@ msgid "Minimize to tray on close"
|
||||
msgstr "Сворачивать в область уведомления при закрытии"
|
||||
|
||||
msgid "Run at system startup"
|
||||
msgstr "Запускать при запуске системы"
|
||||
msgstr "Запускать при старте системы"
|
||||
|
||||
msgid "Start in tray"
|
||||
msgstr "Запускать свёрнутым"
|
||||
|
||||
msgid "Hide Auto-Install Tab"
|
||||
msgstr "Скрыть вкладку \"Автоустановка\""
|
||||
msgstr "Скрывать вкладку \"Автоустановка\""
|
||||
|
||||
msgid "Hide Control Hints"
|
||||
msgstr "Скрыть подсказки управления"
|
||||
msgstr "Скрывать подсказки управления"
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr "Принудительно использовать английский язык"
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "Включить звуки интерфейса"
|
||||
|
||||
msgid "Show compatibility report after game crash"
|
||||
msgstr "Показать отчет о совместимости после сбоя игры"
|
||||
msgstr "Показывать отчёт о совместимости после сбоя игры"
|
||||
|
||||
msgid "Disable runtime download"
|
||||
msgstr "Отключить скачивание контейнера"
|
||||
@@ -2387,6 +2435,9 @@ msgstr "Устройства воспроизведения"
|
||||
msgid "Output"
|
||||
msgstr "Вывод"
|
||||
|
||||
msgid "Return to desktop"
|
||||
msgstr "Вернуться на рабочий стол"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "Выйти"
|
||||
|
||||
@@ -2474,7 +2525,7 @@ msgid "Delete theme '{0}'?"
|
||||
msgstr "Удалить тему '{0}'?"
|
||||
|
||||
msgid "Failed to delete theme."
|
||||
msgstr "Не удалось удалить тему"
|
||||
msgstr "Не удалось удалить тему."
|
||||
|
||||
msgid "Prefix:"
|
||||
msgstr "Префикс:"
|
||||
|
||||
@@ -169,6 +169,17 @@ msgstr "删除"
|
||||
msgid "Import"
|
||||
msgstr ""
|
||||
|
||||
msgid "Update"
|
||||
msgstr "更新"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Disable EOS Overlay"
|
||||
msgstr "启用 mangoapp 叠加层"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Enable EOS Overlay"
|
||||
msgstr "启用 mangoapp 叠加层"
|
||||
|
||||
msgid "Open Game Folder"
|
||||
msgstr "打开游戏文件夹"
|
||||
|
||||
@@ -432,6 +443,17 @@ msgstr "安装依赖"
|
||||
msgid "32-bit dependencies are missing. Install them now?"
|
||||
msgstr "缺少 32 位依赖项。现在安装吗?"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Epic game folder"
|
||||
msgstr "删除文件夹"
|
||||
|
||||
msgid "Another Epic operation is running"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Wine prefix not found"
|
||||
msgstr "未找到前缀"
|
||||
|
||||
msgid "Game not found."
|
||||
msgstr "未找到游戏。"
|
||||
|
||||
@@ -862,9 +884,6 @@ msgstr "有可用更新"
|
||||
msgid "A new AppImage update is available."
|
||||
msgstr "有新的 AppImage 更新可用。"
|
||||
|
||||
msgid "Update"
|
||||
msgstr "更新"
|
||||
|
||||
msgid "Later"
|
||||
msgstr "稍后"
|
||||
|
||||
@@ -1157,8 +1176,8 @@ msgstr "值"
|
||||
msgid "Description"
|
||||
msgstr "描述"
|
||||
|
||||
msgid "Open PPDB"
|
||||
msgstr "打开 PPDB"
|
||||
msgid "Edit PPDB"
|
||||
msgstr "编辑 PPDB"
|
||||
|
||||
msgid "Clear PPDB"
|
||||
msgstr "清除 PPDB"
|
||||
@@ -2040,9 +2059,42 @@ msgstr ""
|
||||
msgid "Failed to refresh GOG library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Failed to log out of Epic account"
|
||||
msgstr ""
|
||||
|
||||
msgid "Copy the authorization code to clipboard"
|
||||
msgstr ""
|
||||
|
||||
msgid "Epic authorization…"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Epic login failed: {0}"
|
||||
msgstr "文件:{0}"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Epic account not connected"
|
||||
msgstr "已连接"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Refreshing Epic library… {0}/{1}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
#, fuzzy, python-brace-format
|
||||
msgid "Failed to refresh Epic library: {0}"
|
||||
msgstr "启动游戏失败:{0}"
|
||||
|
||||
msgid "Select installation folder"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Another download is running"
|
||||
msgstr "正在下载"
|
||||
|
||||
msgid "Another GOG operation is already running"
|
||||
msgstr ""
|
||||
|
||||
@@ -2185,6 +2237,9 @@ msgstr "隐藏自动安装标签页"
|
||||
msgid "Hide Control Hints"
|
||||
msgstr ""
|
||||
|
||||
msgid "Force English language"
|
||||
msgstr ""
|
||||
|
||||
msgid "Enable UI Sounds"
|
||||
msgstr "启用界面音效"
|
||||
|
||||
@@ -2351,6 +2406,10 @@ msgstr "播放设备"
|
||||
msgid "Output"
|
||||
msgstr "输出"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Return to desktop"
|
||||
msgstr "添加到桌面"
|
||||
|
||||
msgid "Logout"
|
||||
msgstr "注销"
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_translation_sources: dict[str, str] = {}
|
||||
|
||||
LOCALE_MAP = {
|
||||
'ru': 'russian',
|
||||
'en': 'english',
|
||||
@@ -36,6 +38,8 @@ LOCALE_MAP = {
|
||||
'el': 'greek',
|
||||
}
|
||||
|
||||
STORE_CONTENT_LOCALES = {'es': 'es-ES', 'pt': 'pt-BR', 'zh': 'zh-CN'}
|
||||
|
||||
# Try local locale directory first, fallback to system for development
|
||||
_local_localedir = Path(__file__).parent / "locales"
|
||||
_system_prefix = "/app" if os.getenv("FLATPAK_ID") else os.getenv("SHARUN_DIR", "/usr")
|
||||
@@ -52,10 +56,31 @@ except FileNotFoundError:
|
||||
localedir=_system_localedir,
|
||||
fallback=True,
|
||||
)
|
||||
_ = translate.gettext
|
||||
|
||||
|
||||
def _(message: str) -> str:
|
||||
from portprotonqt.config import ui_config
|
||||
|
||||
translated = translate.gettext(message)
|
||||
_translation_sources[message] = message
|
||||
_translation_sources[translated] = message
|
||||
return message if ui_config.get_force_english() else translated
|
||||
|
||||
|
||||
def retranslate(message: str) -> str:
|
||||
"""Translate text previously passed through gettext."""
|
||||
source = _translation_sources.get(message)
|
||||
if source is None:
|
||||
return message
|
||||
return _(source)
|
||||
|
||||
|
||||
def get_system_locale():
|
||||
"""Return system locale, e.g., 'ru_RU'. Returns 'en' if detection fails."""
|
||||
from portprotonqt.config import ui_config
|
||||
|
||||
if ui_config.get_force_english():
|
||||
return 'en'
|
||||
loc = locale.getdefaultlocale()[0]
|
||||
return loc if loc else 'en'
|
||||
|
||||
@@ -87,6 +112,23 @@ def get_metadata_language() -> str:
|
||||
|
||||
return 'en'
|
||||
|
||||
def get_store_content_languages() -> tuple[str, ...]:
|
||||
"""Return preferred store content locales with English fallback."""
|
||||
language = get_metadata_language().lower().split('_', 1)[0] or 'en'
|
||||
system_locale = get_system_locale().lower().replace('-', '_')
|
||||
if language == 'zh' and any(
|
||||
variant in system_locale for variant in ('hant', '_tw', '_hk', '_mo')
|
||||
):
|
||||
preferred = 'zh-TW'
|
||||
else:
|
||||
preferred = STORE_CONTENT_LOCALES.get(language, language)
|
||||
languages = [preferred]
|
||||
if language not in languages:
|
||||
languages.append(language)
|
||||
if 'en-US' not in languages:
|
||||
languages.append('en-US')
|
||||
return tuple(languages)
|
||||
|
||||
def read_metadata_translations(metadata_file, language_code):
|
||||
"""
|
||||
Read translations from metadata.txt for the specified language.
|
||||
|
||||
+412
-45
@@ -8,9 +8,15 @@ import subprocess
|
||||
import tempfile
|
||||
import re
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
from threading import Thread
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import psutil
|
||||
import orjson
|
||||
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.icon_extractor import generate_thumbnail, get_exe_icon_cache_path
|
||||
from portprotonqt.dialogs import FileExplorer, ExeSettingsDialog
|
||||
@@ -21,6 +27,7 @@ from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel
|
||||
from portprotonqt.detail_pages import DetailPageManager
|
||||
from portprotonqt.portproton_api import PortProtonAPI, remove_empty_custom_data_dirs
|
||||
from portprotonqt.gog_api import GOGAPI
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.debug_utils import get_prefix_name
|
||||
from portprotonqt.input_manager import InputManager, MainWindowProtocol
|
||||
from portprotonqt.context_menu_manager import ContextMenuManager
|
||||
@@ -32,7 +39,7 @@ from portprotonqt.image_utils import (
|
||||
set_all_animated_covers_suspended,
|
||||
)
|
||||
from portprotonqt.steam_api import get_steam_game_info_async, get_full_steam_game_info_async, get_cached_steam_game_info, get_steam_installed_games, fetch_sgdb_cover_async, get_steam_launch_commands
|
||||
from portprotonqt.theme_manager import ThemeManager
|
||||
from portprotonqt.theme_manager import SystemThemeWatcher, ThemeManager
|
||||
from portprotonqt.time_utils import save_last_launch, get_last_launch, get_playtime_for_exe, format_playtime, get_last_launch_timestamp, format_last_launch
|
||||
from portprotonqt.config import (
|
||||
get_portproton_location,
|
||||
@@ -67,7 +74,7 @@ from portprotonqt.sound_manager import SoundManager
|
||||
from portprotonqt.tabs.control_hints import MainWindowControlHintsMixin
|
||||
from portprotonqt.tabs.autoinstall_tab import MainWindowAutoInstallTabMixin
|
||||
from portprotonqt.tabs.library_tab import MainWindowLibraryTabMixin
|
||||
from portprotonqt.tabs.gog_tab import GOGLibraryWorker, MainWindowGOGTabMixin
|
||||
from portprotonqt.tabs.download_tab import GOGLibraryWorker, MainWindowDownloadTabMixin
|
||||
from portprotonqt.tabs.settings_tab import MainWindowSettingsTabMixin
|
||||
from portprotonqt.tabs.system_tab import MainWindowSystemTabMixin
|
||||
from portprotonqt.tabs.theme_tab import MainWindowThemeTabMixin
|
||||
@@ -76,12 +83,13 @@ from portprotonqt.tabs.workers import MainWindowWorkersMixin
|
||||
|
||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
|
||||
QMessageBox, QApplication, QPushButton, QCheckBox)
|
||||
from PySide6.QtCore import Qt, QEvent, QEventLoop, QUrl, Signal, QTimer, Slot, QProcess, QThread, QFileSystemWatcher, QObject
|
||||
from PySide6.QtCore import Qt, QEvent, QEventLoop, QUrl, Signal, QTimer, Slot, QProcess, QProcessEnvironment, QThread, QFileSystemWatcher, QObject
|
||||
from PySide6.QtGui import QColor, QDesktopServices, QHideEvent, QShowEvent, QGuiApplication
|
||||
from typing import cast
|
||||
from collections.abc import Callable
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
|
||||
STORE_LAUNCH_GRACE_SECONDS = 10
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from portprotonqt.appimage_updater import AppImageUpdateWorker
|
||||
@@ -140,11 +148,12 @@ if [ -n "$LIST" ]; then
|
||||
fi
|
||||
"""
|
||||
|
||||
|
||||
class MainWindow(
|
||||
MainWindowControlHintsMixin,
|
||||
MainWindowAutoInstallTabMixin,
|
||||
MainWindowLibraryTabMixin,
|
||||
MainWindowGOGTabMixin,
|
||||
MainWindowDownloadTabMixin,
|
||||
MainWindowSettingsTabMixin,
|
||||
MainWindowSystemTabMixin,
|
||||
MainWindowThemeTabMixin,
|
||||
@@ -375,6 +384,7 @@ class MainWindow(
|
||||
QTimer.singleShot(0, self.start_watching_directories) # Delay to ensure portproton_location is set
|
||||
self.downloader = Downloader(max_workers=4)
|
||||
self.gog_api = GOGAPI()
|
||||
self.egs_api = EGSAPI()
|
||||
self.portproton_api = PortProtonAPI(self.downloader)
|
||||
self.autoInstallCustomDataThread = None
|
||||
|
||||
@@ -396,6 +406,10 @@ class MainWindow(
|
||||
self.wine_download_seen = False
|
||||
self.wine_download_status = _("Downloading Wine…")
|
||||
self.game_launch_started = False
|
||||
self.game_process_exit_monotonic = None
|
||||
self.egs_launch_cancelled = False
|
||||
self.launcher_process_only = False
|
||||
self.silent_launch_mode = False
|
||||
|
||||
# Central widget and main layout
|
||||
centralWidget = QWidget()
|
||||
@@ -419,6 +433,7 @@ class MainWindow(
|
||||
|
||||
# 2. NAVIGATION (TAB BUTTONS)
|
||||
self.navWidget = QWidget()
|
||||
self.navWidget.setProperty("theme_style_name", "NAV_WIDGET_STYLE")
|
||||
self.navWidget.setStyleSheet(self.theme.NAV_WIDGET_STYLE)
|
||||
navLayout = QHBoxLayout(self.navWidget)
|
||||
navLayout.setContentsMargins(10, 0, 10, 0)
|
||||
@@ -510,6 +525,9 @@ class MainWindow(
|
||||
self.createGOGDownloadsTab()
|
||||
|
||||
self.controlHintsWidget = self.createControlHintsWidget()
|
||||
self.controlHintsWidget.setProperty(
|
||||
"theme_style_name", "HINT_BAR_STYLE"
|
||||
)
|
||||
self.controlHintsWidget.setStyleSheet(self.theme.HINT_BAR_STYLE)
|
||||
mainLayout.addWidget(self.controlHintsWidget)
|
||||
|
||||
@@ -524,6 +542,13 @@ class MainWindow(
|
||||
app = QApplication.instance()
|
||||
if isinstance(app, QApplication):
|
||||
app.applicationStateChanged.connect(self._on_application_state_changed)
|
||||
is_light = app.styleHints().colorScheme() == Qt.ColorScheme.Light
|
||||
self.system_theme_watcher = SystemThemeWatcher(is_light, self)
|
||||
self.system_theme_watcher.theme_changed.connect(self._on_system_theme_detected)
|
||||
app.styleHints().colorSchemeChanged.connect(
|
||||
self._on_qt_color_scheme_changed
|
||||
)
|
||||
self.system_theme_watcher.start()
|
||||
|
||||
auto_fullscreen_gamepad = (
|
||||
display_config.get_auto_fullscreen_gamepad()
|
||||
@@ -912,6 +937,11 @@ class MainWindow(
|
||||
def on_games_loaded(self, games: list[tuple]):
|
||||
self._loading_games = False
|
||||
self.games = games
|
||||
if not game_config.get_only_installed():
|
||||
display_filter = game_config.get_display_filter()
|
||||
library_cache = getattr(self, "_loaded_library_cache", {})
|
||||
library_cache[display_filter] = games
|
||||
self._loaded_library_cache = library_cache
|
||||
focus_first_card = not getattr(self, "_preserve_library_focus_after_load", False)
|
||||
self._preserve_library_focus_after_load = False
|
||||
self.game_library_manager.set_games(games, focus_first_card=focus_first_card)
|
||||
@@ -950,18 +980,21 @@ class MainWindow(
|
||||
if callable(source_loader):
|
||||
source_loader(lambda games: self.games_loaded.emit(games))
|
||||
elif display_filter == "favorites":
|
||||
def on_all_games_favorites(portproton_games, steam_games, gog_games):
|
||||
games = [game for game in portproton_games + steam_games + gog_games if game[0] in favorites]
|
||||
def on_all_games_favorites():
|
||||
games = [
|
||||
game for source in results.values() for game in source
|
||||
if game[0] in favorites
|
||||
]
|
||||
self.games_loaded.emit(games)
|
||||
|
||||
# Load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': []}
|
||||
completed = {'portproton': False, 'steam': False, 'gog': False}
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
|
||||
def check_completion():
|
||||
if all(completed.values()):
|
||||
QApplication.processEvents() # Keep UI responsive
|
||||
on_all_games_favorites(results['portproton'], results['steam'], results['gog'])
|
||||
on_all_games_favorites()
|
||||
|
||||
def portproton_callback(games):
|
||||
results['portproton'] = games
|
||||
@@ -980,23 +1013,30 @@ class MainWindow(
|
||||
completed['gog'] = True
|
||||
check_completion()
|
||||
|
||||
def egs_callback(games):
|
||||
results['egs'] = games
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
else:
|
||||
# For 'all' filter - load games from different sources in parallel to prevent blocking
|
||||
results = {'portproton': [], 'steam': [], 'gog': []}
|
||||
completed = {'portproton': False, 'steam': False, 'gog': False}
|
||||
results = {'portproton': [], 'steam': [], 'gog': [], 'egs': []}
|
||||
completed = dict.fromkeys(results, False)
|
||||
|
||||
def on_all_games():
|
||||
seen = set()
|
||||
games = []
|
||||
for game in results['portproton'] + results['steam'] + results['gog']:
|
||||
for source_games in results.values():
|
||||
for game in source_games:
|
||||
# Unique key: name + exec_line
|
||||
key = (game[0], game[5])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
games.append(game)
|
||||
key = (game[0], game[5])
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
games.append(game)
|
||||
QApplication.processEvents() # Keep UI responsive
|
||||
self.games_loaded.emit(games)
|
||||
|
||||
@@ -1022,10 +1062,16 @@ class MainWindow(
|
||||
completed['gog'] = True
|
||||
check_completion()
|
||||
|
||||
def egs_callback(games):
|
||||
results['egs'] = games
|
||||
completed['egs'] = True
|
||||
check_completion()
|
||||
|
||||
# Load all sources in parallel
|
||||
self._load_portproton_games_async(portproton_callback)
|
||||
self._load_steam_games_async(steam_callback)
|
||||
self._load_gog_games_async(gog_callback)
|
||||
self._load_egs_games_async(egs_callback)
|
||||
|
||||
# Run loading immediately to show status without delay
|
||||
start_loading()
|
||||
@@ -1093,6 +1139,54 @@ class MainWindow(
|
||||
lambda info, current_game=game: on_game_info(current_game, info),
|
||||
)
|
||||
|
||||
def _load_egs_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||
library = [game for game in self.egs_api.load_library() if game.get("app_id")]
|
||||
only_installed = game_config.get_only_installed()
|
||||
games = []
|
||||
if not library:
|
||||
callback(games)
|
||||
return
|
||||
from portprotonqt.time_utils import get_statistics_path
|
||||
statistics_file = get_statistics_path()
|
||||
processed_count = 0
|
||||
|
||||
def on_game_info(game: dict, steam_info: dict) -> None:
|
||||
nonlocal processed_count
|
||||
app_id = str(game["app_id"])
|
||||
installed = self.egs_api.is_game_installed(app_id)
|
||||
if not only_installed or installed:
|
||||
target = self.egs_api.get_launch_target(app_id) if installed else None
|
||||
description = str(game.get("description", ""))
|
||||
if not description or description == str(game.get("title", "")):
|
||||
description = str(steam_info.get("description", ""))
|
||||
playtime = get_playtime_for_exe(
|
||||
statistics_file, target, exact_path=True
|
||||
) if target else 0
|
||||
playtime = playtime or 0
|
||||
games.append((
|
||||
str(game.get("title", app_id)), description,
|
||||
str(game.get("cover", "")), app_id,
|
||||
steam_info.get("controller_support", ""),
|
||||
f"egs://{'launch' if installed else 'install'}/{app_id}",
|
||||
get_last_launch(f"egs-{app_id}") if target else _("Never"),
|
||||
format_playtime(playtime), steam_info.get("protondb_tier", ""),
|
||||
steam_info.get("anticheat_status", ""),
|
||||
get_last_launch_timestamp(f"egs-{app_id}") if target else 0,
|
||||
playtime, "egs", steam_info.get("anticheat_slug", ""),
|
||||
steam_info.get("ppdb_id", ""), steam_info.get("ppdb_rating", ""),
|
||||
steam_info.get("appid", ""),
|
||||
))
|
||||
processed_count += 1
|
||||
if processed_count == len(library):
|
||||
callback(games)
|
||||
|
||||
for game in library:
|
||||
get_steam_game_info_async(
|
||||
str(game.get("title", game["app_id"])),
|
||||
f"egs://launch/{game['app_id']}",
|
||||
lambda info, current_game=game: on_game_info(current_game, info),
|
||||
)
|
||||
|
||||
def _upgrade_legacy_gog_library(
|
||||
self, api: GOGAPI, library: list[dict], callback: Callable[[list], None]
|
||||
) -> bool:
|
||||
@@ -1582,6 +1676,14 @@ class MainWindow(
|
||||
):
|
||||
game_data = dict(game_data)
|
||||
game_data["exec_line"] = f"gog://launch/{app_id}"
|
||||
if str(game_data.get("game_source", "")).lower() == "egs":
|
||||
app_id = str(game_data.get("appid", ""))
|
||||
if (
|
||||
exec_line.startswith("egs://install/")
|
||||
and self.egs_api.is_game_installed(app_id)
|
||||
):
|
||||
game_data = dict(game_data)
|
||||
game_data["exec_line"] = f"egs://launch/{app_id}"
|
||||
logger.debug(
|
||||
"Opening detail page for %s from stacked index %d",
|
||||
game_data.get("name", ""),
|
||||
@@ -1590,6 +1692,9 @@ class MainWindow(
|
||||
self.detail_page_manager.openGameDetailPage(game_data)
|
||||
pending_log_exe = self._pending_log_exe
|
||||
detail_exe = extract_exec_target_path(game_data.get("exec_line", ""))
|
||||
if exec_line.startswith(("gog://launch/", "egs://launch/")):
|
||||
source, app_id = exec_line.split("://", 1)[0], exec_line.rsplit("/", 1)[-1]
|
||||
detail_exe = getattr(self, f"{source}_api").get_launch_target(app_id)
|
||||
if pending_log_exe and detail_exe:
|
||||
if os.path.abspath(detail_exe) == pending_log_exe:
|
||||
self._pending_log_exe = None
|
||||
@@ -1899,25 +2004,6 @@ class MainWindow(
|
||||
focused_widget.selectAll()
|
||||
elif isinstance(focused_widget, QCheckBox):
|
||||
focused_widget.setChecked(not focused_widget.isChecked())
|
||||
elif isinstance(focused_widget, GameCard):
|
||||
game_data = {
|
||||
"name": focused_widget.name,
|
||||
"description": focused_widget.description,
|
||||
"cover_path": focused_widget.cover_path,
|
||||
"appid": focused_widget.appid,
|
||||
"controller_support": focused_widget.controller_support,
|
||||
"exec_line": focused_widget.exec_line,
|
||||
"last_launch": focused_widget.last_launch,
|
||||
"formatted_playtime": focused_widget.formatted_playtime,
|
||||
"playtime_seconds": focused_widget.playtime_seconds,
|
||||
"protondb_tier": focused_widget.protondb_tier,
|
||||
"anticheat_status": focused_widget.anticheat_status,
|
||||
"game_source": focused_widget.game_source,
|
||||
"anticheat_slug": focused_widget.anticheat_slug,
|
||||
"ppdb_id": focused_widget.ppdb_id,
|
||||
"ppdb_rating": focused_widget.ppdb_rating,
|
||||
}
|
||||
focused_widget.select_callback(game_data)
|
||||
parent = focused_widget.parent()
|
||||
while parent:
|
||||
if isinstance(parent, FileExplorer):
|
||||
@@ -1939,12 +2025,21 @@ class MainWindow(
|
||||
else:
|
||||
# Game completed - reset flag, reset button and stop timer
|
||||
self._analyze_short_launch()
|
||||
if getattr(self, "launcher_process_only", False):
|
||||
self._terminate_game_processes()
|
||||
self.resetPlayButton()
|
||||
#self._uninhibit_screensaver()
|
||||
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
|
||||
self.checkProcessTimer.stop()
|
||||
self.checkProcessTimer.deleteLater()
|
||||
self.checkProcessTimer = None
|
||||
self._finish_silent_launch()
|
||||
|
||||
def _finish_silent_launch(self) -> None:
|
||||
if not getattr(self, "silent_launch_mode", False):
|
||||
return
|
||||
self.tray_manager.tray_icon.hide()
|
||||
QApplication.quit()
|
||||
|
||||
def _set_running_button_stop(self) -> None:
|
||||
"""Update current running button to stop state."""
|
||||
@@ -1981,6 +2076,7 @@ class MainWindow(
|
||||
if launch_started:
|
||||
if not self.game_launch_started:
|
||||
self.game_launch_monotonic = time.monotonic()
|
||||
self.game_process_exit_monotonic = None
|
||||
self.game_launch_started = True
|
||||
continue
|
||||
if status is not None:
|
||||
@@ -2021,6 +2117,7 @@ class MainWindow(
|
||||
self.game_start_exe = None
|
||||
self.game_start_exact_path = False
|
||||
self.game_launch_monotonic = None
|
||||
self.game_process_exit_monotonic = None
|
||||
self.game_stopped_by_user = False
|
||||
|
||||
self.target_exe = None
|
||||
@@ -2029,7 +2126,11 @@ class MainWindow(
|
||||
self.wine_download_percent = 0.0
|
||||
self.wine_download_status = _("Downloading Wine…")
|
||||
self.game_launch_started = False
|
||||
self.game_processes = [proc for proc in self.game_processes if proc.poll() is None]
|
||||
launcher_only = getattr(self, "launcher_process_only", False)
|
||||
self.launcher_process_only = False
|
||||
self.game_processes = [] if launcher_only else [
|
||||
proc for proc in self.game_processes if proc.poll() is None
|
||||
]
|
||||
if not getattr(self, "_animated_covers_suspended", False):
|
||||
self.input_manager.resume_gamepad_polling()
|
||||
self._refresh_portproton_shortcuts()
|
||||
@@ -2127,10 +2228,38 @@ class MainWindow(
|
||||
if exec_line.startswith("gog://launch/"):
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
return str(self.gog_api.get_launch_target(app_id) or "")
|
||||
if exec_line.startswith("egs://launch/"):
|
||||
app_id = exec_line.rsplit("/", 1)[-1]
|
||||
return str(self.egs_api.get_launch_target(app_id) or "")
|
||||
return extract_exec_target_path(exec_line) or ""
|
||||
|
||||
def _has_running_game_process(self) -> bool:
|
||||
return any(proc.poll() is None for proc in self.game_processes)
|
||||
process_alive = any(proc.poll() is None for proc in self.game_processes)
|
||||
if process_alive and not (
|
||||
getattr(self, "launcher_process_only", False)
|
||||
and self.game_launch_started
|
||||
):
|
||||
self.game_process_exit_monotonic = None
|
||||
return True
|
||||
target = str(self.target_exe or "").lower()
|
||||
if target:
|
||||
for process in psutil.process_iter(attrs=["name"]):
|
||||
try:
|
||||
if str(process.info.get("name") or "").lower() == target:
|
||||
self.game_process_exit_monotonic = None
|
||||
return True
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
||||
continue
|
||||
launch_started = getattr(self, "game_launch_monotonic", None)
|
||||
if launch_started is not None:
|
||||
now = time.monotonic()
|
||||
if getattr(self, "game_process_exit_monotonic", None) is None:
|
||||
self.game_process_exit_monotonic = now
|
||||
return now - launch_started < STORE_LAUNCH_GRACE_SECONDS
|
||||
started = getattr(self, "game_start_time", None)
|
||||
if started is None:
|
||||
return False
|
||||
return (datetime.now() - started).total_seconds() < STORE_LAUNCH_GRACE_SECONDS
|
||||
|
||||
def _start_launch_output_reader(self, process: subprocess.Popen[str]) -> None:
|
||||
"""Start background reader for PortProton launch output."""
|
||||
@@ -2151,6 +2280,49 @@ class MainWindow(
|
||||
if state is not None:
|
||||
self.launch_output_queue.put(state)
|
||||
|
||||
def _read_egs_launch_output(self, process: subprocess.Popen[str]) -> None:
|
||||
"""Resolve an EGS command before starting its controlled Wine process."""
|
||||
if process.stdout is None:
|
||||
return
|
||||
launch_data = None
|
||||
for line in process.stdout:
|
||||
text = line.strip()
|
||||
if text:
|
||||
logger.info("%s", text)
|
||||
if text.startswith("{"):
|
||||
try:
|
||||
launch_data = orjson.loads(text)
|
||||
except orjson.JSONDecodeError:
|
||||
logger.warning("Invalid Legendary launch response")
|
||||
process.wait()
|
||||
if process.returncode != 0 or launch_data is None or self.egs_launch_cancelled:
|
||||
return
|
||||
command = list(launch_data.get("launch_command", []))
|
||||
executable = os.path.join(
|
||||
launch_data.get("game_directory", ""),
|
||||
launch_data.get("game_executable", ""),
|
||||
)
|
||||
command.extend([executable, *launch_data.get("game_parameters", [])])
|
||||
command.extend(launch_data.get("egl_parameters", []))
|
||||
command.extend(launch_data.get("user_parameters", []))
|
||||
environment = os.environ.copy()
|
||||
environment.update(launch_data.get("environment", {}))
|
||||
try:
|
||||
game_process = subprocess.Popen(
|
||||
command, env=environment, shell=False, preexec_fn=os.setsid,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, errors="replace",
|
||||
)
|
||||
except OSError as error:
|
||||
logger.error("Failed to start resolved Epic game: %s", error)
|
||||
return
|
||||
if self.egs_launch_cancelled:
|
||||
os.killpg(os.getpgid(game_process.pid), signal.SIGTERM)
|
||||
game_process.kill()
|
||||
return
|
||||
self.game_processes = [game_process]
|
||||
self._start_launch_output_reader(game_process)
|
||||
|
||||
def _analyze_short_launch(self) -> None:
|
||||
if not ui_config.get_crash_reports_enabled():
|
||||
return
|
||||
@@ -2158,7 +2330,8 @@ class MainWindow(
|
||||
started = getattr(self, "game_launch_monotonic", None)
|
||||
if started is None:
|
||||
return
|
||||
duration = time.monotonic() - started
|
||||
ended = getattr(self, "game_process_exit_monotonic", None)
|
||||
duration = (ended or time.monotonic()) - started
|
||||
stopped_by_user = getattr(self, "game_stopped_by_user", False)
|
||||
exit_code = next(
|
||||
(process.poll() for process in self.game_processes if process.poll() is not None),
|
||||
@@ -2201,6 +2374,7 @@ class MainWindow(
|
||||
continue
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
|
||||
proc.kill()
|
||||
except ProcessLookupError:
|
||||
continue
|
||||
except OSError as e:
|
||||
@@ -2223,19 +2397,18 @@ class MainWindow(
|
||||
if button is not None:
|
||||
self.current_running_button = button
|
||||
self.game_stopped_by_user = True
|
||||
self.egs_launch_cancelled = True
|
||||
self._analyze_short_launch()
|
||||
|
||||
if not self._run_portproton_stop_command():
|
||||
return False
|
||||
|
||||
self._terminate_game_processes()
|
||||
self.game_processes = []
|
||||
stopped = self._run_portproton_stop_command()
|
||||
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None:
|
||||
self.checkProcessTimer.stop()
|
||||
self.checkProcessTimer.deleteLater()
|
||||
self.checkProcessTimer = None
|
||||
self.resetPlayButton()
|
||||
return True
|
||||
return stopped
|
||||
|
||||
def _check_missing_prefix_by_name_before_launch(self, prefix_name: str, env_vars: dict[str, str]) -> None:
|
||||
"""Check prefix presence and optionally disable default recommended libs."""
|
||||
@@ -2448,6 +2621,182 @@ class MainWindow(
|
||||
if game:
|
||||
self._install_gog_game(game)
|
||||
|
||||
def _handle_egs_game(self, exec_line: str, button=None) -> None:
|
||||
action, app_id = exec_line.removeprefix("egs://").split("/", 1)
|
||||
if action == "install" and self.egs_api.is_game_installed(app_id):
|
||||
action = "launch"
|
||||
if action == "launch":
|
||||
self._launch_egs_game(app_id, button)
|
||||
return
|
||||
self._install_egs_game(app_id)
|
||||
|
||||
def _install_egs_game(self, app_id: str) -> None:
|
||||
self._install_egs_download(app_id)
|
||||
|
||||
def _repair_egs_game(self, app_id: str) -> None:
|
||||
self._start_egs_operation(
|
||||
app_id, ["repair", app_id, "--skip-sdl", "-y"], _("Repair")
|
||||
)
|
||||
|
||||
def _update_egs_game(self, app_id: str) -> None:
|
||||
self._start_egs_operation(
|
||||
app_id, ["update", app_id, "--platform", "Windows", "--skip-sdl", "-y"],
|
||||
_("Update")
|
||||
)
|
||||
|
||||
def _delete_egs_game(self, app_id: str) -> None:
|
||||
self._start_egs_operation(
|
||||
app_id, ["uninstall", app_id, "-y"], _("Delete")
|
||||
)
|
||||
|
||||
def _import_egs_game(self, app_id: str) -> None:
|
||||
selected_paths: list[str] = []
|
||||
explorer = FileExplorer(
|
||||
self, theme=self.theme, initial_path=str(Path.home() / "Games"),
|
||||
directory_only=True,
|
||||
)
|
||||
explorer.setWindowTitle(_("Select Epic game folder"))
|
||||
explorer.file_signal.file_selected.connect(selected_paths.append)
|
||||
explorer.exec()
|
||||
if selected_paths:
|
||||
self._start_egs_operation(app_id, [
|
||||
"import", app_id, selected_paths[0], "--with-dlcs",
|
||||
"--platform", "Windows", "-y",
|
||||
], _("Import"))
|
||||
|
||||
def _start_egs_operation(
|
||||
self, app_id: str, arguments: list[str], action: str
|
||||
) -> None:
|
||||
if getattr(self, "egs_process", None) is not None:
|
||||
QMessageBox.warning(self, _("Error"), _("Another Epic operation is running"))
|
||||
return
|
||||
try:
|
||||
command = self.egs_api.build_command(arguments)
|
||||
except OSError as error:
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
game = next(
|
||||
(item for item in self.egs_api.load_library()
|
||||
if str(item.get("app_id", "")) == app_id),
|
||||
{"app_id": app_id, "title": app_id, "cover": ""},
|
||||
)
|
||||
self._start_egs_visible_operation(game, command, action)
|
||||
|
||||
def _egs_process_environment(self) -> QProcessEnvironment:
|
||||
environment = QProcessEnvironment.systemEnvironment()
|
||||
environment.insert("LEGENDARY_CONFIG_PATH", str(self.egs_api.config_dir))
|
||||
return environment
|
||||
|
||||
def _on_egs_operation_finished(
|
||||
self, app_id: str, action: str, exit_code: int
|
||||
) -> None:
|
||||
self._finish_egs_visible_operation(action, exit_code == 0)
|
||||
if exit_code != 0:
|
||||
logger.error(
|
||||
"Epic %s failed for %s with code %s", action, app_id, exit_code
|
||||
)
|
||||
return
|
||||
if action == "eos-install":
|
||||
self._enable_egs_overlay(app_id)
|
||||
return
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _enable_egs_overlay(self, app_id: str) -> None:
|
||||
overlay_path = self.egs_api.data_dir / "eos_overlay"
|
||||
install_record = self.egs_api.config_dir / "overlay_install.json"
|
||||
if not install_record.is_file():
|
||||
self._start_egs_operation(app_id, [
|
||||
"eos-overlay", "install", "--path", str(overlay_path), "-y",
|
||||
], "eos-install")
|
||||
return
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
prefix_name = get_prefix_name(target)
|
||||
if not self.portproton_location:
|
||||
QMessageBox.warning(self, _("Error"), _("PortProton directory not found"))
|
||||
return
|
||||
prefix_path = Path(self.portproton_location) / "data/prefixes" / prefix_name
|
||||
if not (prefix_path / "user.reg").is_file():
|
||||
QMessageBox.warning(self, _("Error"), _("Wine prefix not found"))
|
||||
return
|
||||
action = (
|
||||
"disable"
|
||||
if self.egs_api.is_eos_overlay_enabled(prefix_path)
|
||||
else "enable"
|
||||
)
|
||||
self._start_egs_operation(app_id, [
|
||||
"eos-overlay", action, "--prefix", str(prefix_path),
|
||||
], f"eos-{action}")
|
||||
|
||||
def _is_egs_overlay_enabled(self, app_id: str) -> bool:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
if not target or not self.portproton_location:
|
||||
return False
|
||||
prefix_name = get_prefix_name(target)
|
||||
prefix_path = Path(self.portproton_location) / "data/prefixes" / prefix_name
|
||||
return self.egs_api.is_eos_overlay_enabled(prefix_path)
|
||||
|
||||
def _launch_egs_game(self, app_id: str, button=None) -> None:
|
||||
target = self.egs_api.get_launch_target(app_id)
|
||||
if not target:
|
||||
self._finish_silent_launch()
|
||||
QMessageBox.warning(self, _("Error"), _("Game not found."))
|
||||
return
|
||||
if not self.start_sh:
|
||||
self._finish_silent_launch()
|
||||
QMessageBox.warning(self, _("Error"), _("PortProton start script not found"))
|
||||
return
|
||||
target_name = os.path.basename(target)
|
||||
if self.game_processes and self.target_exe == target_name:
|
||||
self.stop_running_game(button)
|
||||
return
|
||||
if self.game_processes:
|
||||
QMessageBox.warning(
|
||||
self, _("Error"), _("Cannot launch game while another game is running")
|
||||
)
|
||||
return
|
||||
try:
|
||||
wrapper = shlex.join([self.start_sh[0], "--epic-wrapper"])
|
||||
command = self.egs_api.build_command([
|
||||
"launch", app_id, "--json", "--wrapper", wrapper,
|
||||
"--no-wine", "--skip-version-check",
|
||||
])
|
||||
process = subprocess.Popen(
|
||||
command, env=self.egs_api.get_environment(), shell=False,
|
||||
preexec_fn=os.setsid, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, text=True, errors="replace",
|
||||
)
|
||||
except OSError as error:
|
||||
self._finish_silent_launch()
|
||||
logger.error("Failed to launch Epic game %s: %s", app_id, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
SoundManager().play("game_launch")
|
||||
self.egs_launch_cancelled = False
|
||||
self.launcher_process_only = True
|
||||
self.game_processes.append(process)
|
||||
self.target_exe = target_name
|
||||
self.current_running_button = button
|
||||
launch_time = datetime.now()
|
||||
self.game_start_time = launch_time
|
||||
self.game_start_exe = target
|
||||
self.game_start_exact_path = True
|
||||
save_last_launch(f"egs-{app_id}", launch_time)
|
||||
self._update_last_launch_after_start(target, launch_time)
|
||||
self.launch_output_queue = Queue()
|
||||
self.launch_output_thread = Thread(
|
||||
target=self._read_egs_launch_output,
|
||||
args=(process,),
|
||||
daemon=True,
|
||||
)
|
||||
self.launch_output_thread.start()
|
||||
self.input_manager.suspend_gamepad_polling()
|
||||
self.checkProcessTimer = QTimer(self)
|
||||
self.checkProcessTimer.timeout.connect(self.checkTargetExe)
|
||||
self.checkProcessTimer.start(500)
|
||||
if button is not None:
|
||||
button.setText(_("Stop"))
|
||||
button.setIcon(self.theme_manager.get_icon("stop", as_path=True))
|
||||
|
||||
def _launch_gog_game(
|
||||
self, app_id: str, button=None, play_sound: bool = True
|
||||
) -> None:
|
||||
@@ -2455,9 +2804,11 @@ class MainWindow(
|
||||
discovered_path = self.gog_api.get_installed_path(app_id)
|
||||
install_path = str(discovered_path) if discovered_path else ""
|
||||
if not install_path:
|
||||
self._finish_silent_launch()
|
||||
QMessageBox.warning(self, _("Error"), _("Game not found."))
|
||||
return
|
||||
if not self.start_sh:
|
||||
self._finish_silent_launch()
|
||||
QMessageBox.warning(self, _("Error"), _("PortProton start script not found"))
|
||||
return
|
||||
target = os.path.basename(self.gog_api.get_launch_target(app_id) or app_id)
|
||||
@@ -2482,12 +2833,14 @@ class MainWindow(
|
||||
stderr=subprocess.STDOUT, text=True, errors="replace",
|
||||
)
|
||||
except (OSError, ValueError) as error:
|
||||
self._finish_silent_launch()
|
||||
logger.error("Failed to launch GOG game %s: %s", app_id, error)
|
||||
QMessageBox.warning(self, _("Error"), str(error))
|
||||
return
|
||||
if play_sound:
|
||||
SoundManager().play("game_launch")
|
||||
self.game_processes.append(process)
|
||||
self.launcher_process_only = True
|
||||
self.target_exe = target
|
||||
self.current_running_button = button
|
||||
launch_target = self.gog_api.get_launch_target(app_id)
|
||||
@@ -2517,6 +2870,10 @@ class MainWindow(
|
||||
self._handle_gog_game(exec_line, button)
|
||||
return
|
||||
|
||||
if exec_line.startswith("egs://"):
|
||||
self._handle_egs_game(exec_line, button)
|
||||
return
|
||||
|
||||
# Handle PortProton games
|
||||
entry_exec_split = shlex.split(exec_line)
|
||||
if not entry_exec_split:
|
||||
@@ -2641,6 +2998,11 @@ class MainWindow(
|
||||
# Full application close
|
||||
event.accept()
|
||||
|
||||
watcher = getattr(self, "system_theme_watcher", None)
|
||||
if watcher is not None:
|
||||
watcher.requestInterruption()
|
||||
watcher.wait()
|
||||
|
||||
# Hide and remove tray icon
|
||||
if hasattr(self, "tray_manager"):
|
||||
self.tray_manager.shutdown()
|
||||
@@ -2648,9 +3010,14 @@ class MainWindow(
|
||||
# Save card sizes only for grid layouts.
|
||||
layout_mode = str(getattr(self.theme, "LIBRARY_LAYOUT_MODE", "grid")).lower()
|
||||
size_slider = getattr(self.game_library_manager, 'sizeSlider', None)
|
||||
if size_slider is None or layout_mode != "list":
|
||||
if size_slider is None or layout_mode not in {
|
||||
"list", "vertical", "horizontal", "horizontal_top"
|
||||
}:
|
||||
ui_config.set_card_width(self.card_width)
|
||||
if hasattr(self, 'auto_size_slider') and layout_mode != "list":
|
||||
if (
|
||||
hasattr(self, 'auto_size_slider')
|
||||
and layout_mode not in {"list", "vertical", "horizontal", "horizontal_top"}
|
||||
):
|
||||
ui_config.set_auto_card_width(self.auto_card_width)
|
||||
|
||||
# Save window sizes (if not in fullscreen mode)
|
||||
|
||||
@@ -13,6 +13,7 @@ install_data(
|
||||
'game_card.py',
|
||||
'game_library_manager.py',
|
||||
'gog_api.py',
|
||||
'egs_api.py',
|
||||
'howlongtobeat_api.py',
|
||||
'image_utils.py',
|
||||
'icon_extractor.py',
|
||||
@@ -152,7 +153,7 @@ install_data(
|
||||
['tabs/__init__.py',
|
||||
'tabs/autoinstall_tab.py',
|
||||
'tabs/control_hints.py',
|
||||
'tabs/gog_tab.py',
|
||||
'tabs/download_tab.py',
|
||||
'tabs/library_tab.py',
|
||||
'tabs/settings_tab.py',
|
||||
'tabs/system_tab.py',
|
||||
|
||||
@@ -40,6 +40,10 @@ def search_index(
|
||||
prefix_results = optimizer.prefix_search(search_text)
|
||||
return _unique_search_results([payload for _match_text, payload in prefix_results])
|
||||
|
||||
substring_results = optimizer.substring_search(search_text)
|
||||
if substring_results:
|
||||
return _unique_search_results([payload for _match_text, payload in substring_results])
|
||||
|
||||
exact_result = optimizer.exact_search(search_text)
|
||||
if exact_result:
|
||||
return [exact_result]
|
||||
@@ -179,6 +183,13 @@ class SearchOptimizer:
|
||||
with self._lock:
|
||||
return self.trie_index.search_prefix(prefix)
|
||||
|
||||
def substring_search(self, query: str) -> list[tuple[str, Any]]:
|
||||
"""Find all indexed names containing the query."""
|
||||
with self._lock:
|
||||
query = query.lower()
|
||||
items = self.fuzzy_index.items if self.fuzzy_index else []
|
||||
return [(key, value) for key, value in items if query in key]
|
||||
|
||||
def fuzzy_search(self, query: str, limit: int = 5, min_score: float = 60.0) -> list[tuple[str, Any, float]]:
|
||||
"""Perform fuzzy search."""
|
||||
if self.fuzzy_index:
|
||||
|
||||
@@ -625,7 +625,7 @@ def get_steam_game_info_async(
|
||||
from portprotonqt.steam_api.utils import filter_candidates, remove_duplicates
|
||||
|
||||
is_autoinstall = exec_line.startswith("autoinstall:")
|
||||
is_gog_uri = exec_line.startswith("gog://")
|
||||
is_store_uri = exec_line.startswith(("gog://", "egs://"))
|
||||
|
||||
if game_exe.lower().endswith(('.bat', '.cmd')):
|
||||
if os.path.exists(game_exe):
|
||||
@@ -647,7 +647,7 @@ def get_steam_game_info_async(
|
||||
else:
|
||||
logger.error("Command file not found: %s", game_exe)
|
||||
|
||||
if is_gog_uri:
|
||||
if is_store_uri:
|
||||
meta_data = {}
|
||||
elif not game_exe.lower().endswith('.exe'):
|
||||
logger.error("Invalid executable path: %s. Expected .exe", game_exe)
|
||||
@@ -671,15 +671,15 @@ def get_steam_game_info_async(
|
||||
candidates.append(file_description)
|
||||
if desktop_name:
|
||||
candidates.append(desktop_name)
|
||||
if exe_name and not is_gog_uri:
|
||||
if exe_name and not is_store_uri:
|
||||
candidates.append(exe_name)
|
||||
if folder_name and not is_gog_uri:
|
||||
if folder_name and not is_store_uri:
|
||||
candidates.append(folder_name)
|
||||
candidates = filter_candidates(candidates)
|
||||
candidates = remove_duplicates(candidates)
|
||||
candidates_ordered = sorted(candidates, key=lambda s: len(s.split()), reverse=True)
|
||||
has_custom_data = (
|
||||
is_autoinstall or is_gog_uri or _has_custom_data_for_exe(exe_name)
|
||||
is_autoinstall or is_store_uri or _has_custom_data_for_exe(exe_name)
|
||||
)
|
||||
|
||||
def on_steam_apps_and_index(
|
||||
|
||||
@@ -143,6 +143,9 @@ def _is_portrait_image(path: Path) -> bool:
|
||||
except (OSError, UnidentifiedImageError) as e:
|
||||
logger.debug("Failed to read Steam cover dimensions %s: %s", path, e)
|
||||
return False
|
||||
except ValueError as e:
|
||||
logger.debug("Rejected unsafe Steam cover metadata %s: %s", path, e)
|
||||
return False
|
||||
return height > width
|
||||
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ class MainWindowAutoInstallTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def createAutoInstallTab(self):
|
||||
autoInstallPage = QWidget()
|
||||
autoInstallPage.setProperty("theme_style_name", "LIBRARY_WIDGET_STYLE")
|
||||
autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
|
||||
autoInstallLayout = QVBoxLayout(autoInstallPage)
|
||||
autoInstallLayout.setSpacing(15)
|
||||
|
||||
@@ -234,6 +234,7 @@ class MainWindowControlHintsMixin:
|
||||
]
|
||||
|
||||
self.hintsLabels = []
|
||||
self.hintTextLabels = []
|
||||
self.gamepadHintTextLabels = {}
|
||||
self.gamepadHintDefaultTexts = {}
|
||||
self.gamepadHintContainers = {}
|
||||
@@ -266,7 +267,9 @@ class MainWindowControlHintsMixin:
|
||||
layout.addWidget(icon_label)
|
||||
|
||||
text_label = QLabel(action_text)
|
||||
text_label.setProperty("theme_style_name", "HINTS_LABEL_STYLE")
|
||||
text_label.setStyleSheet(self.theme.HINTS_LABEL_STYLE)
|
||||
self.hintTextLabels.append(text_label)
|
||||
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
layout.addWidget(text_label)
|
||||
|
||||
@@ -358,7 +361,9 @@ class MainWindowControlHintsMixin:
|
||||
icon.clicked.connect(lambda hint_action=action: self._triggerControlHintAction(hint_action))
|
||||
|
||||
text_label = QLabel(action_text)
|
||||
text_label.setProperty("theme_style_name", "HINTS_LABEL_STYLE")
|
||||
text_label.setStyleSheet(self.theme.HINTS_LABEL_STYLE)
|
||||
self.hintTextLabels.append(text_label)
|
||||
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
|
||||
layout.addWidget(text_label)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Shared download manager with GOG download support."""
|
||||
"""Store account actions and shared game download manager."""
|
||||
|
||||
import os
|
||||
import re
|
||||
@@ -27,6 +27,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
from portprotonqt.dialogs.file_explorer import FileExplorer
|
||||
from portprotonqt.egs_api import EGSAPI, EGS_LOGIN_URL
|
||||
from portprotonqt.gog_api import GOGAPI, GOG_LOGIN_URL
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.localization import _
|
||||
@@ -36,6 +37,14 @@ from portprotonqt.sound_manager import SoundManager
|
||||
logger = get_logger(__name__)
|
||||
GOG_CANCEL_KILL_TIMEOUT_MS = 3000
|
||||
GOG_LOGIN_TIMEOUT_MS = 300000
|
||||
EGS_LOGIN_TIMEOUT_MS = 300000
|
||||
MIB_PER_GIB = 1024.0
|
||||
|
||||
|
||||
def _format_download_size(size_mib: float) -> str:
|
||||
if size_mib >= MIB_PER_GIB:
|
||||
return f"{size_mib / MIB_PER_GIB:.2f} GiB"
|
||||
return f"{size_mib:.1f} MiB"
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
@@ -64,6 +73,43 @@ class GOGLibraryWorker(QThread):
|
||||
self.failed.emit(str(error))
|
||||
|
||||
|
||||
class EGSLibraryWorker(QThread):
|
||||
"""Refresh the Epic library outside the UI thread."""
|
||||
|
||||
loaded = Signal(list)
|
||||
failed = Signal(str)
|
||||
progress = Signal(int, int)
|
||||
|
||||
def __init__(self, api: EGSAPI) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.loaded.emit(self.api.refresh_library(self.progress.emit))
|
||||
except Exception as error:
|
||||
logger.exception("Failed to refresh Epic library")
|
||||
self.failed.emit(str(error))
|
||||
|
||||
|
||||
class EGSAuthWorker(QThread):
|
||||
"""Authenticate an Epic account outside the UI thread."""
|
||||
|
||||
authenticated = Signal(bool, str)
|
||||
|
||||
def __init__(self, api: EGSAPI, code: str) -> None:
|
||||
super().__init__()
|
||||
self.api = api
|
||||
self.code = code
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
self.authenticated.emit(*self.api.authenticate(self.code))
|
||||
except Exception as error:
|
||||
logger.exception("Failed to authenticate with Epic")
|
||||
self.authenticated.emit(False, str(error))
|
||||
|
||||
|
||||
class GOGAuthWorker(QThread):
|
||||
"""Exchange the GOG OAuth code outside the UI thread."""
|
||||
|
||||
@@ -126,7 +172,7 @@ class GOGSupportWorker(QThread):
|
||||
self.failed.emit(self.error)
|
||||
|
||||
|
||||
class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
class MainWindowDownloadTabMixin(_MainWindowTypingBase):
|
||||
"""Add account actions and the shared downloads page."""
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -134,11 +180,15 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def createGOGDownloadsTab(self) -> None:
|
||||
self.gog_process = None
|
||||
self.egs_process = None
|
||||
self.gog_download_queue = []
|
||||
self.gog_download_output = ""
|
||||
self.egs_download_output = ""
|
||||
self.egs_download_total = 0.0
|
||||
self.gog_support_workers = []
|
||||
self.downloadTableHeadings = {}
|
||||
page = QWidget()
|
||||
page.setProperty("theme_style_name", "OTHER_PAGES_WIDGET_STYLE")
|
||||
page.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(page)
|
||||
layout.setSpacing(self.theme.downloadsSectionSpacing)
|
||||
@@ -150,6 +200,7 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
completed_header = QHBoxLayout()
|
||||
completed_header.addWidget(QLabel(_("Completed")))
|
||||
clear_button = QPushButton(_("Clear List"))
|
||||
clear_button.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
clear_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
clear_button.clicked.connect(self._clear_completed_downloads)
|
||||
completed_header.addWidget(clear_button)
|
||||
@@ -193,6 +244,7 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
header.addWidget(self.downloadActiveTitle)
|
||||
header.addStretch()
|
||||
self.downloadCancelButton = QPushButton(_("Cancel"))
|
||||
self.downloadCancelButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.downloadCancelButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.downloadCancelButton.clicked.connect(self._cancel_gog_download)
|
||||
header.addWidget(self.downloadCancelButton)
|
||||
@@ -399,6 +451,128 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
def _on_gog_library_worker_finished(self) -> None:
|
||||
self.gog_library_worker = None
|
||||
|
||||
def _handle_egs_account_action(self) -> None:
|
||||
if not self.egs_api.user_path.is_file():
|
||||
self._start_egs_login()
|
||||
return
|
||||
if not self.egs_api.logout():
|
||||
self.egsAccountStatus.setText(_("Failed to log out of Epic account"))
|
||||
return
|
||||
self._update_egs_account_state()
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _start_egs_login(self) -> None:
|
||||
if getattr(self, "egs_auth_worker", None) is not None:
|
||||
return
|
||||
clipboard = QApplication.clipboard()
|
||||
if not getattr(self, "egs_auth_clipboard_connected", False):
|
||||
clipboard.dataChanged.connect(self._check_egs_login_clipboard)
|
||||
self.egs_auth_clipboard_connected = True
|
||||
self.egs_auth_timer = QTimer(self)
|
||||
self.egs_auth_timer.setSingleShot(True)
|
||||
self.egs_auth_timer.timeout.connect(self._cancel_egs_login)
|
||||
self.egs_auth_timer.start(EGS_LOGIN_TIMEOUT_MS)
|
||||
self.egsLoginButton.setEnabled(False)
|
||||
self.egsAccountStatus.setText(_("Copy the authorization code to clipboard"))
|
||||
if not QDesktopServices.openUrl(QUrl(EGS_LOGIN_URL)):
|
||||
self._cancel_egs_login()
|
||||
|
||||
def _check_egs_login_clipboard(self) -> None:
|
||||
code = self.egs_api.extract_auth_code(QApplication.clipboard().text())
|
||||
if len(code) < 16:
|
||||
return
|
||||
self._stop_egs_login_clipboard()
|
||||
self.egsAccountStatus.setText(_("Epic authorization…"))
|
||||
worker = EGSAuthWorker(self.egs_api, code)
|
||||
worker.authenticated.connect(self._on_egs_authenticated)
|
||||
worker.finished.connect(self._on_egs_auth_worker_finished)
|
||||
self.egs_auth_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _stop_egs_login_clipboard(self) -> None:
|
||||
timer = getattr(self, "egs_auth_timer", None)
|
||||
if timer is not None:
|
||||
timer.stop()
|
||||
timer.deleteLater()
|
||||
self.egs_auth_timer = None
|
||||
if getattr(self, "egs_auth_clipboard_connected", False):
|
||||
QApplication.clipboard().dataChanged.disconnect(
|
||||
self._check_egs_login_clipboard
|
||||
)
|
||||
self.egs_auth_clipboard_connected = False
|
||||
|
||||
def _cancel_egs_login(self) -> None:
|
||||
self._stop_egs_login_clipboard()
|
||||
self.egsLoginButton.setEnabled(True)
|
||||
self._update_egs_account_state()
|
||||
|
||||
def _on_egs_authenticated(self, authenticated: bool, error: str) -> None:
|
||||
if not authenticated:
|
||||
self.egsAccountStatus.setText(
|
||||
_("Epic login failed: {0}").format(error or _("Unknown error"))
|
||||
)
|
||||
return
|
||||
self._refresh_egs_library()
|
||||
|
||||
def _on_egs_auth_worker_finished(self) -> None:
|
||||
self.egsLoginButton.setEnabled(True)
|
||||
self.egs_auth_worker = None
|
||||
|
||||
def _update_egs_account_state(self) -> None:
|
||||
connected = self.egs_api.user_path.is_file()
|
||||
account_name = self.egs_api.get_account_name() if connected else ""
|
||||
self.egsAccountStatus.setText(
|
||||
account_name or (_("Epic account connected") if connected
|
||||
else _("Epic account not connected"))
|
||||
)
|
||||
self.egsLoginButton.setText(_("Log out") if connected else _("Open login page"))
|
||||
self.egsLoginButton.setEnabled(True)
|
||||
|
||||
def _refresh_egs_library(self) -> None:
|
||||
if not self.egs_api.user_path.is_file():
|
||||
return
|
||||
if getattr(self, "egs_library_worker", None) is not None:
|
||||
return
|
||||
worker = EGSLibraryWorker(self.egs_api)
|
||||
worker.loaded.connect(self._on_egs_library_loaded)
|
||||
worker.failed.connect(self._on_egs_library_failed)
|
||||
worker.progress.connect(self._on_egs_library_progress)
|
||||
worker.finished.connect(self._on_egs_library_worker_finished)
|
||||
self.egs_library_worker = worker
|
||||
worker.start()
|
||||
|
||||
def _on_egs_library_progress(self, completed: int, total: int) -> None:
|
||||
self.egsAccountStatus.setText(
|
||||
_("Refreshing Epic library… {0}/{1}").format(completed, total)
|
||||
)
|
||||
|
||||
def _on_egs_library_loaded(self, games: list) -> None:
|
||||
self._update_egs_account_state()
|
||||
manager = self.detail_page_manager
|
||||
source = manager._current_detail_source
|
||||
if source and source[0] == "game":
|
||||
source_data = source[1]
|
||||
if str(source_data.get("game_source", "")).lower() == "egs":
|
||||
app_id = str(source_data.get("appid", ""))
|
||||
game = next(
|
||||
(item for item in games if str(item.get("app_id", "")) == app_id),
|
||||
None,
|
||||
)
|
||||
if game:
|
||||
source_data["description"] = str(game.get("description", ""))
|
||||
source_data["cover_path"] = str(game.get("cover", ""))
|
||||
if manager._detail_page_active:
|
||||
manager._reopen_current_detail_page()
|
||||
self.loadGames(force_load=True)
|
||||
|
||||
def _on_egs_library_failed(self, error: str) -> None:
|
||||
self.egsAccountStatus.setText(
|
||||
_("Failed to refresh Epic library: {0}").format(error)
|
||||
)
|
||||
|
||||
def _on_egs_library_worker_finished(self) -> None:
|
||||
self.egs_library_worker = None
|
||||
|
||||
def _install_gog_game(self, game: dict) -> None:
|
||||
if self.gog_process is not None:
|
||||
self.gog_download_queue.append(game)
|
||||
@@ -446,6 +620,36 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
self._start_gog_download(game, install_path, command, _("Install"))
|
||||
|
||||
def _install_egs_download(self, app_id: str) -> None:
|
||||
if self.gog_process is not None or self.egs_process is not None:
|
||||
QMessageBox.warning(self, _("Error"), _("Another download is running"))
|
||||
return
|
||||
game = next(
|
||||
(item for item in self.egs_api.load_library()
|
||||
if str(item.get("app_id", "")) == app_id),
|
||||
{"app_id": app_id, "title": app_id, "cover": ""},
|
||||
)
|
||||
selected_paths: list[str] = []
|
||||
explorer = FileExplorer(
|
||||
self, theme=self.theme, initial_path=str(self.egs_api.games_dir),
|
||||
directory_only=True,
|
||||
)
|
||||
explorer.setWindowTitle(_("Select installation folder"))
|
||||
explorer.file_signal.file_selected.connect(selected_paths.append)
|
||||
explorer.exec()
|
||||
if not selected_paths:
|
||||
return
|
||||
install_path = Path(selected_paths[0])
|
||||
try:
|
||||
command = self.egs_api.build_command([
|
||||
"install", app_id, "--base-path", str(install_path),
|
||||
"--platform", "Windows", "--skip-sdl", "--skip-dlcs", "-y",
|
||||
])
|
||||
except OSError as error:
|
||||
self.egsAccountStatus.setText(str(error))
|
||||
return
|
||||
self._start_egs_download(game, install_path, command)
|
||||
|
||||
def _repair_gog_game(self, game: dict) -> None:
|
||||
if self.gog_process is not None:
|
||||
self.gogAccountStatus.setText(_("Another GOG operation is already running"))
|
||||
@@ -564,10 +768,78 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
self.switchTab(6)
|
||||
process.start()
|
||||
|
||||
def _start_egs_download(
|
||||
self, game: dict, install_path: Path, command: list[str]
|
||||
) -> None:
|
||||
app_id = str(game["app_id"])
|
||||
self.downloadActiveTitle.setText(str(game["title"]))
|
||||
self.downloadActiveDetails.setText(_("Starting"))
|
||||
cover_width, cover_height = self.theme.downloadsActiveCoverSize
|
||||
load_pixmap_async(
|
||||
str(game.get("cover", "")), cover_width, cover_height,
|
||||
self.downloadActiveCover.setPixmap,
|
||||
app_name=f"download-active-{app_id}",
|
||||
)
|
||||
self.downloadActiveHeading.setVisible(True)
|
||||
self.downloadActiveCard.setVisible(True)
|
||||
self._update_downloads_tab_visibility()
|
||||
process = QProcess(self)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._egs_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.readyReadStandardOutput.connect(self._read_egs_download_output)
|
||||
process.finished.connect(self._on_egs_download_finished)
|
||||
self.egs_process = process
|
||||
self.egs_active_game = game
|
||||
self.egs_active_install_path = install_path
|
||||
self.egs_download_output = ""
|
||||
self.egs_download_total = 0.0
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
self.downloadCancelButton.setEnabled(True)
|
||||
self.switchTab(6)
|
||||
process.start()
|
||||
|
||||
def _start_egs_visible_operation(
|
||||
self, game: dict, command: list[str], action: str
|
||||
) -> None:
|
||||
app_id = str(game["app_id"])
|
||||
display_action = "EOS Overlay" if action.startswith("eos-") else action
|
||||
self.downloadActiveTitle.setText(str(game["title"]))
|
||||
self.downloadActiveDetails.setText(display_action)
|
||||
cover_width, cover_height = self.theme.downloadsActiveCoverSize
|
||||
load_pixmap_async(
|
||||
str(game.get("cover", "")), cover_width, cover_height,
|
||||
self.downloadActiveCover.setPixmap,
|
||||
app_name=f"download-active-{app_id}",
|
||||
)
|
||||
self.downloadActiveHeading.setVisible(True)
|
||||
self.downloadActiveCard.setVisible(True)
|
||||
self._update_downloads_tab_visibility()
|
||||
process = QProcess(self)
|
||||
process.setProgram(command[0])
|
||||
process.setArguments(command[1:])
|
||||
process.setProcessEnvironment(self._egs_process_environment())
|
||||
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
|
||||
process.readyReadStandardOutput.connect(self._read_egs_download_output)
|
||||
process.finished.connect(
|
||||
lambda code, _status: self._on_egs_operation_finished(
|
||||
app_id, action, code
|
||||
)
|
||||
)
|
||||
self.egs_process = process
|
||||
self.egs_active_game = game
|
||||
self.egs_download_output = ""
|
||||
self.egs_download_total = 0.0
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
self.downloadCancelButton.setEnabled(True)
|
||||
self.switchTab(6)
|
||||
process.start()
|
||||
|
||||
def _cancel_gog_download(self) -> None:
|
||||
if self.gog_process is None:
|
||||
process = self.gog_process or self.egs_process
|
||||
if process is None:
|
||||
return
|
||||
process = self.gog_process
|
||||
self.downloadCancelButton.setEnabled(False)
|
||||
self.downloadActiveDetails.setText(_("Cancel"))
|
||||
process.terminate()
|
||||
@@ -577,18 +849,29 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
)
|
||||
|
||||
def _kill_gog_process(self, process: QProcess) -> None:
|
||||
if process.state() != QProcess.ProcessState.NotRunning:
|
||||
logger.warning("gogdl did not terminate after cancellation; killing it")
|
||||
process.kill()
|
||||
try:
|
||||
if process.state() != QProcess.ProcessState.NotRunning:
|
||||
logger.warning("gogdl did not terminate after cancellation; killing it")
|
||||
process.kill()
|
||||
except RuntimeError:
|
||||
# QProcess may be deleted by its finished handler before the timer fires.
|
||||
return
|
||||
|
||||
def _clear_egs_data_lock(self) -> None:
|
||||
lock_path = self.egs_api.config_dir / "installed.json.lock"
|
||||
try:
|
||||
lock_path.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
logger.warning("Failed to clear Legendary data lock: %s", error)
|
||||
|
||||
def _append_download_row(
|
||||
self, table: QTableWidget, game: dict, action: str
|
||||
self, table: QTableWidget, game: dict, action: str, store: str = "GOG"
|
||||
) -> tuple[int, QLabel]:
|
||||
row = table.rowCount()
|
||||
table.insertRow(row)
|
||||
game_cell, details_label = self._create_download_game_cell(game)
|
||||
table.setCellWidget(row, 0, game_cell)
|
||||
values = (datetime.now().strftime("%H:%M:%S"), action, "GOG")
|
||||
values = (datetime.now().strftime("%H:%M:%S"), action, store)
|
||||
for column, value in enumerate(values, 1):
|
||||
table.setItem(row, column, QTableWidgetItem(value))
|
||||
table.setRowHeight(row, self.theme.downloadsTableRowHeight)
|
||||
@@ -667,6 +950,64 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
if details:
|
||||
self._update_active_download_details(details)
|
||||
|
||||
def _read_egs_download_output(self) -> None:
|
||||
if self.egs_process is None:
|
||||
return
|
||||
output = bytes(
|
||||
self.egs_process.readAllStandardOutput().data()
|
||||
).decode(errors="replace")
|
||||
self.egs_download_output = (self.egs_download_output + output)[-4096:]
|
||||
total = re.findall(r"Download size:\s+([\d.]+)\s+MiB", output)
|
||||
if total:
|
||||
self.egs_download_total = float(total[-1])
|
||||
verification = re.findall(
|
||||
r"Verification progress:\s*\d+/\d+\s+\(([\d.]+)%\)"
|
||||
r"(?:\s+\[([\d.]+)\s+MiB/s\])?",
|
||||
self.egs_download_output,
|
||||
)
|
||||
if verification:
|
||||
percent, speed = verification[-1]
|
||||
self.downloadOverallProgress.setValue(min(100, int(float(percent))))
|
||||
details = [f"{percent}%"]
|
||||
if speed:
|
||||
details.append(f"{speed} MiB/s")
|
||||
self._update_active_download_details(details)
|
||||
downloaded = re.findall(
|
||||
r"Downloaded:\s+([\d.]+)\s+MiB", self.egs_download_output
|
||||
)
|
||||
if downloaded and self.egs_download_total > 0:
|
||||
progress = float(downloaded[-1]) / self.egs_download_total * 100
|
||||
self.downloadOverallProgress.setValue(min(100, int(progress)))
|
||||
download_speeds = re.findall(
|
||||
r"Download\t-\s+(\S+)\s+MiB", self.egs_download_output
|
||||
)
|
||||
disk_speeds = re.findall(
|
||||
r"Disk\t-\s+(\S+)\s+MiB", self.egs_download_output
|
||||
)
|
||||
eta = re.findall(r"ETA:\s+(\d{2}:\d{2}:\d{2})", self.egs_download_output)
|
||||
if download_speeds:
|
||||
self.downloadSpeedLabel.setText(
|
||||
_("Download: {0} MiB/s").format(download_speeds[-1])
|
||||
)
|
||||
if disk_speeds:
|
||||
self.diskSpeedLabel.setText(
|
||||
_("Disk: {0} MiB/s").format(disk_speeds[-1])
|
||||
)
|
||||
details = []
|
||||
if downloaded and self.egs_download_total > 0:
|
||||
details.append(
|
||||
f"{_format_download_size(float(downloaded[-1]))} / "
|
||||
f"{_format_download_size(self.egs_download_total)}"
|
||||
)
|
||||
elif downloaded:
|
||||
details.append(f"{downloaded[-1]} MiB")
|
||||
if eta:
|
||||
details.append(_("ETA: {0}").format(eta[-1]))
|
||||
if details:
|
||||
self._update_active_download_details(details)
|
||||
elif output.strip():
|
||||
self.downloadActiveDetails.setText(output.strip().splitlines()[-1])
|
||||
|
||||
def _update_active_download_details(self, details: list[str]) -> None:
|
||||
self.downloadActiveDetails.setText(" · ".join(details))
|
||||
|
||||
@@ -710,6 +1051,51 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
self._update_download_table_height(self.downloadQueuedTable)
|
||||
self._install_gog_game(next_game)
|
||||
|
||||
def _on_egs_download_finished(
|
||||
self, code: int, _status: QProcess.ExitStatus
|
||||
) -> None:
|
||||
game = self.egs_active_game
|
||||
app_id = str(game["app_id"])
|
||||
self.downloadActiveHeading.setVisible(False)
|
||||
self.downloadActiveCard.setVisible(False)
|
||||
completed_action = _("Installed") if code == 0 else _("Failed")
|
||||
_row, completed_details = self._append_download_row(
|
||||
self.downloadCompletedTable, game, completed_action, "Epic Games"
|
||||
)
|
||||
if code == 0:
|
||||
self._update_installed_egs_detail(app_id)
|
||||
self.loadGames(force_load=True)
|
||||
else:
|
||||
error = self._get_egs_download_error()
|
||||
completed_details.setText(error)
|
||||
logger.error("Epic download failed for %s: %s", app_id, error)
|
||||
process = self.egs_process
|
||||
self.egs_process = None
|
||||
self._clear_egs_data_lock()
|
||||
if process is not None:
|
||||
process.deleteLater()
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
self.downloadSpeedLabel.setText(_("Downloading: ") + "\u2014")
|
||||
self.diskSpeedLabel.setText(_("Disk: ") + "\u2014")
|
||||
|
||||
def _finish_egs_visible_operation(self, action: str, success: bool) -> None:
|
||||
game = self.egs_active_game
|
||||
display_action = "EOS Overlay" if action.startswith("eos-") else action
|
||||
self.downloadActiveHeading.setVisible(False)
|
||||
self.downloadActiveCard.setVisible(False)
|
||||
_row, details = self._append_download_row(
|
||||
self.downloadCompletedTable, game, display_action, "Epic Games"
|
||||
)
|
||||
details.setText(_("Completed") if success else self._get_egs_download_error())
|
||||
process = self.egs_process
|
||||
self.egs_process = None
|
||||
self._clear_egs_data_lock()
|
||||
if process is not None:
|
||||
process.deleteLater()
|
||||
self.downloadOverallProgress.setValue(0)
|
||||
self.downloadSpeedLabel.setText(_("Downloading: ") + "\u2014")
|
||||
self.diskSpeedLabel.setText(_("Disk: ") + "\u2014")
|
||||
|
||||
def _start_gog_support_setup(self, app_id: str, button=None) -> None:
|
||||
if getattr(self, "gog_support_workers", []):
|
||||
return
|
||||
@@ -789,6 +1175,14 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
useful = [line for line in lines if line and "[PROGRESS]" not in line]
|
||||
return useful[-1] if useful else _("Download process failed")
|
||||
|
||||
def _get_egs_download_error(self) -> str:
|
||||
lines = [line.strip() for line in self.egs_download_output.splitlines()]
|
||||
errors = [line for line in lines if "ERROR" in line.upper()]
|
||||
if errors:
|
||||
return errors[-1]
|
||||
useful = [line for line in lines if line]
|
||||
return useful[-1] if useful else _("Download process failed")
|
||||
|
||||
def _update_installed_gog_detail(self, app_id: str) -> None:
|
||||
install_uri = f"gog://install/{app_id}"
|
||||
if self.current_exec_line != install_uri:
|
||||
@@ -801,3 +1195,16 @@ class MainWindowGOGTabMixin(_MainWindowTypingBase):
|
||||
source[1]["exec_line"] = f"gog://launch/{app_id}"
|
||||
self.current_exec_line = source[1]["exec_line"]
|
||||
self.detail_page_manager._reopen_current_detail_page()
|
||||
|
||||
def _update_installed_egs_detail(self, app_id: str) -> None:
|
||||
install_uri = f"egs://install/{app_id}"
|
||||
if self.current_exec_line != install_uri:
|
||||
return
|
||||
if self.stackedWidget.currentWidget() is not self.currentDetailPage:
|
||||
return
|
||||
source = self.detail_page_manager._current_detail_source
|
||||
if source is None or source[0] != "game":
|
||||
return
|
||||
source[1]["exec_line"] = f"egs://launch/{app_id}"
|
||||
self.current_exec_line = source[1]["exec_line"]
|
||||
self.detail_page_manager._reopen_current_detail_page()
|
||||
@@ -109,9 +109,27 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
def _on_only_installed_changed(self, checked: bool) -> None:
|
||||
game_config.set_only_installed(checked)
|
||||
self.searchEdit.clear()
|
||||
self.games = []
|
||||
self._preserve_library_focus_after_load = True
|
||||
self.loadGames(force_load=True)
|
||||
display_filter = game_config.get_display_filter()
|
||||
cached_games = getattr(self, "_loaded_library_cache", {}).get(display_filter)
|
||||
if cached_games is None:
|
||||
self.games = []
|
||||
self.loadGames(force_load=True)
|
||||
return
|
||||
games = cached_games
|
||||
if checked:
|
||||
games = [
|
||||
game for game in games
|
||||
if not game[5].startswith(("gog://install/", "egs://install/"))
|
||||
]
|
||||
self.games = games
|
||||
self._preserve_library_focus_after_load = False
|
||||
self.game_library_manager.games = games
|
||||
self.game_library_manager.filtered_games = games
|
||||
self.game_library_manager._build_search_indices(games)
|
||||
self.game_library_manager.update_game_grid(
|
||||
is_filter=True, focus_first_card=False
|
||||
)
|
||||
|
||||
def _on_library_badge_view_changed(self, index: int) -> None:
|
||||
if index < 0 or index >= len(self.badge_view_keys):
|
||||
@@ -302,11 +320,12 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
display_filter = game_config.get_display_filter()
|
||||
only_installed = game_config.get_only_installed()
|
||||
self.filter_keys = ["all", "steam", "gog", "portproton", "favorites"]
|
||||
self.filter_keys = ["all", "steam", "gog", "egs", "portproton", "favorites"]
|
||||
self.filter_labels = [
|
||||
_("All"),
|
||||
"Steam",
|
||||
"GOG",
|
||||
"Epic Games",
|
||||
"PortProton",
|
||||
_("Favorites"),
|
||||
]
|
||||
@@ -389,6 +408,7 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
|
||||
# Mark that a refresh is in progress
|
||||
self._refresh_in_progress = True
|
||||
self._loaded_library_cache = {}
|
||||
|
||||
# Clear the search field to ensure all games are shown after refresh
|
||||
self.searchEdit.clear()
|
||||
@@ -430,8 +450,11 @@ class MainWindowLibraryTabMixin(_MainWindowTypingBase):
|
||||
if callable(source_refresh):
|
||||
source_refresh()
|
||||
|
||||
if not callable(source_loader) and self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if not callable(source_loader):
|
||||
if self.gog_api.auth_path.is_file():
|
||||
self._refresh_gog_library()
|
||||
if self.egs_api.user_path.is_file():
|
||||
self._refresh_egs_library()
|
||||
|
||||
# Reload games using the existing loadGames functionality
|
||||
# Use a small delay to allow UI to update before starting the refresh
|
||||
|
||||
@@ -1,18 +1,26 @@
|
||||
import os
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtCore import QObject, Qt, QTimer
|
||||
from PySide6.QtGui import QAction
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QFrame,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QMenu,
|
||||
QMessageBox,
|
||||
QScrollArea,
|
||||
QScroller,
|
||||
QSizePolicy,
|
||||
QTabBar,
|
||||
QTableWidget,
|
||||
QTreeWidget,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -38,7 +46,7 @@ from portprotonqt.config import (
|
||||
from portprotonqt.context_menu_manager import CustomLineEdit
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox, FlowLayout
|
||||
from portprotonqt.debug_utils import get_selectable_gpu_list
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.localization import _, retranslate
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.portproton_api import get_user_conf_setting, set_user_conf_setting
|
||||
from portprotonqt.qt_utils import get_system_dpi_for_wine
|
||||
@@ -66,6 +74,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
def createPortProtonTab(self):
|
||||
"""PortProton Settings tab."""
|
||||
self.portProtonWidget = QWidget()
|
||||
self.portProtonWidget.setProperty("theme_style_name", "OTHER_PAGES_WIDGET_STYLE")
|
||||
self.portProtonWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
self.portProtonWidget.setObjectName("otherPage")
|
||||
layout = QVBoxLayout(self.portProtonWidget)
|
||||
@@ -99,6 +108,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
def create_section(title_text, theme):
|
||||
section_frame = QFrame()
|
||||
section_frame.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
section_frame.setProperty("theme_style_name", "SETTINGS_FRAME_STYLE")
|
||||
section_frame.setStyleSheet(self.theme.SETTINGS_FRAME_STYLE)
|
||||
section_layout = QVBoxLayout(section_frame)
|
||||
section_layout.setContentsMargins(*theme.portProtonPageMargins)
|
||||
@@ -145,25 +155,43 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
selected_account = game_config.get_steam_account_id()
|
||||
selected_index = self.steamAccountCombo.findData(selected_account)
|
||||
self.steamAccountCombo.setCurrentIndex(max(selected_index, 0))
|
||||
steam_account_title = QLabel(_("Account") + " Steam:")
|
||||
steam_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(steam_account_title, self.steamAccountCombo)
|
||||
self.steamAccountTitle = QLabel(_("Account") + " Steam:")
|
||||
self.steamAccountTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(self.steamAccountTitle, self.steamAccountCombo)
|
||||
self.gogAccountStatus = QLabel()
|
||||
self.gogAccountStatus.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
self.gogAccountStatus.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.gogLoginButton = AutoSizeButton(_("Open login page"))
|
||||
self.gogLoginButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.gogLoginButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.gogLoginButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.gogLoginButton.clicked.connect(self._handle_gog_account_action)
|
||||
gog_buttons = QHBoxLayout()
|
||||
gog_buttons.addWidget(self.gogAccountStatus)
|
||||
gog_buttons.addWidget(self.gogLoginButton)
|
||||
gog_account_title = QLabel(_("Account") + " GOG:")
|
||||
gog_account_title.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(gog_account_title, gog_buttons)
|
||||
self.gogAccountTitle = QLabel(_("Account") + " GOG:")
|
||||
self.gogAccountTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(self.gogAccountTitle, gog_buttons)
|
||||
self._update_gog_account_state()
|
||||
self.egsAccountStatus = QLabel()
|
||||
self.egsAccountStatus.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
self.egsAccountStatus.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed
|
||||
)
|
||||
self.egsLoginButton = AutoSizeButton(_("Open login page"))
|
||||
self.egsLoginButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.egsLoginButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.egsLoginButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.egsLoginButton.clicked.connect(self._handle_egs_account_action)
|
||||
egs_buttons = QHBoxLayout()
|
||||
egs_buttons.addWidget(self.egsAccountStatus)
|
||||
egs_buttons.addWidget(self.egsLoginButton)
|
||||
self.egsAccountTitle = QLabel(_("Account") + " Epic Games:")
|
||||
self.egsAccountTitle.setStyleSheet(self.theme.SETTINGS_TITLE_STYLE)
|
||||
gogForm.addRow(self.egsAccountTitle, egs_buttons)
|
||||
self._update_egs_account_state()
|
||||
|
||||
self.timeDetailCombo = CustomComboBox(theme=self.theme)
|
||||
self.timeDetailCombo.view().window().setWindowFlags(
|
||||
@@ -221,7 +249,8 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
uiForm.addRow(self.trayMenuModeTitle, self.trayMenuModeCombo)
|
||||
self.setTabOrder(self.timeDetailCombo, self.steamAccountCombo)
|
||||
self.setTabOrder(self.steamAccountCombo, self.gogLoginButton)
|
||||
self.setTabOrder(self.gogLoginButton, self.trayMenuModeCombo)
|
||||
self.setTabOrder(self.gogLoginButton, self.egsLoginButton)
|
||||
self.setTabOrder(self.egsLoginButton, self.trayMenuModeCombo)
|
||||
|
||||
self.gamepad_type_keys = ["auto", "xbox", "playstation"]
|
||||
self.gamepad_type_labels = [_("Auto"), "Xbox", "PlayStation"]
|
||||
@@ -359,6 +388,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
hide_control_hints_layout.addStretch()
|
||||
uiForm.addRow(hide_control_hints_layout)
|
||||
|
||||
self.forceEnglishCheckBox = QCheckBox()
|
||||
self.forceEnglishCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.forceEnglishCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.forceEnglishTitle = QLabel(_("Force English language"))
|
||||
self.forceEnglishTitle.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self.forceEnglishTitle.setStyleSheet(self.theme.SETTINGS_TITLE_CHECKBOX_STYLE)
|
||||
self.forceEnglishTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.forceEnglishCheckBox.setChecked(ui_config.get_force_english())
|
||||
force_english_layout = QHBoxLayout()
|
||||
force_english_layout.setContentsMargins(0, 0, 0, 0)
|
||||
force_english_layout.addWidget(self.forceEnglishCheckBox)
|
||||
force_english_layout.addWidget(self.forceEnglishTitle)
|
||||
force_english_layout.addStretch()
|
||||
uiForm.addRow(force_english_layout)
|
||||
|
||||
self.soundsEnabledCheckBox = QCheckBox()
|
||||
self.soundsEnabledCheckBox.setStyleSheet(self.theme.CHECKBOX_STYLE)
|
||||
self.soundsEnabledCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -523,6 +567,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.integrateAppImageButton = AutoSizeButton(
|
||||
_("Integrate AppImage"), icon=self.theme_manager.get_icon("desktop", as_path=True)
|
||||
)
|
||||
self.integrateAppImageButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.integrateAppImageButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.integrateAppImageButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.integrateAppImageButton.clicked.connect(self.integrateAppImage)
|
||||
@@ -623,6 +668,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.proxyUrlEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.proxyUrlEdit.setPlaceholderText(_("Proxy URL"))
|
||||
self.proxyUrlEdit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.proxyUrlEdit.setProperty("theme_style_name", "LINE_EDIT_STYLE")
|
||||
self.proxyUrlEdit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
self.proxyUrlEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.proxyUrlTitle = QLabel(_("Proxy URL:"))
|
||||
@@ -637,6 +683,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.proxyUserEdit = CustomLineEdit(self, theme=self.theme)
|
||||
self.proxyUserEdit.setPlaceholderText(_("Proxy Username"))
|
||||
self.proxyUserEdit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.proxyUserEdit.setProperty("theme_style_name", "LINE_EDIT_STYLE")
|
||||
self.proxyUserEdit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
self.proxyUserEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.proxyUserTitle = QLabel(_("Proxy Username:"))
|
||||
@@ -649,6 +696,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
self.proxyPasswordEdit.setPlaceholderText(_("Proxy Password"))
|
||||
self.proxyPasswordEdit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
|
||||
self.proxyPasswordEdit.setEchoMode(QLineEdit.EchoMode.Password)
|
||||
self.proxyPasswordEdit.setProperty("theme_style_name", "LINE_EDIT_STYLE")
|
||||
self.proxyPasswordEdit.setStyleSheet(self.theme.LINE_EDIT_STYLE)
|
||||
self.proxyPasswordEdit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.proxyPasswordTitle = QLabel(_("Proxy Password:"))
|
||||
@@ -667,18 +715,21 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
buttonsLayout.setSpacing(self.theme.portProtonPageVerticalSpacing)
|
||||
|
||||
self.saveButton = AutoSizeButton(_("Save Settings"), icon=self.theme_manager.get_icon("save", as_path=True))
|
||||
self.saveButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.saveButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.saveButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.saveButton.clicked.connect(self.savePortProtonSettings)
|
||||
buttonsLayout.addWidget(self.saveButton)
|
||||
|
||||
self.resetSettingsButton = AutoSizeButton(_("Reset Settings"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.resetSettingsButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.resetSettingsButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.resetSettingsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.resetSettingsButton.clicked.connect(self.resetSettings)
|
||||
buttonsLayout.addWidget(self.resetSettingsButton)
|
||||
|
||||
self.migrateShortcutsButton = AutoSizeButton(_("Migrate legacy shortcuts"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.migrateShortcutsButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.migrateShortcutsButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.migrateShortcutsButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.migrateShortcutsButton.clicked.connect(self.migrateLegacyShortcuts)
|
||||
@@ -688,6 +739,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
buttonsLayout.addWidget(self.integrateAppImageButton)
|
||||
|
||||
self.clearCacheButton = AutoSizeButton(_("Clear Cache"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.clearCacheButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.clearCacheButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.clearCacheButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
self.clearCacheButton.clicked.connect(self.clearCache)
|
||||
@@ -747,6 +799,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
if reply == QMessageBox.StandardButton.Yes:
|
||||
cache_config.clear_cache()
|
||||
self.gog_api.clear_library_cache()
|
||||
self.egs_api.clear_library_cache()
|
||||
|
||||
def integrateAppImage(self) -> None:
|
||||
"""Install the running AppImage in the user application menu."""
|
||||
@@ -839,6 +892,7 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def savePortProtonSettings(self):
|
||||
previous_economy_mode = ui_config.get_economy_mode()
|
||||
previous_force_english = ui_config.get_force_english()
|
||||
game_config.set_steam_account_id(
|
||||
str(self.steamAccountCombo.currentData())
|
||||
)
|
||||
@@ -874,6 +928,10 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
|
||||
hide_control_hints = self.hideControlHintsCheckBox.isChecked()
|
||||
ui_config.set_hide_control_hints(hide_control_hints)
|
||||
force_english = self.forceEnglishCheckBox.isChecked()
|
||||
ui_config.set_force_english(force_english)
|
||||
if previous_force_english != force_english:
|
||||
self._retranslate_interface()
|
||||
|
||||
gamepad_type_idx = self.gamepadTypeCombo.currentIndex()
|
||||
gamepad_type = self.gamepad_type_keys[gamepad_type_idx]
|
||||
@@ -999,6 +1057,51 @@ class MainWindowSettingsTabMixin(_MainWindowTypingBase):
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().reload_config()
|
||||
|
||||
def _retranslate_interface(self) -> None:
|
||||
"""Apply the selected language to the existing interface."""
|
||||
objects = [self, *self.findChildren(QObject)]
|
||||
for obj in objects:
|
||||
if isinstance(obj, QAction):
|
||||
obj.setText(retranslate(obj.text()))
|
||||
if isinstance(obj, (QLabel, QAbstractButton)):
|
||||
obj.setText(retranslate(obj.text()))
|
||||
if isinstance(obj, (QGroupBox, QMenu)):
|
||||
obj.setTitle(retranslate(obj.title()))
|
||||
if isinstance(obj, QLineEdit):
|
||||
obj.setPlaceholderText(retranslate(obj.placeholderText()))
|
||||
if isinstance(obj, QComboBox):
|
||||
for index in range(obj.count()):
|
||||
obj.setItemText(index, retranslate(obj.itemText(index)))
|
||||
if isinstance(obj, QTabBar):
|
||||
for index in range(obj.count()):
|
||||
obj.setTabText(index, retranslate(obj.tabText(index)))
|
||||
if isinstance(obj, QTableWidget):
|
||||
for column in range(obj.columnCount()):
|
||||
item = obj.horizontalHeaderItem(column)
|
||||
if item is not None:
|
||||
item.setText(retranslate(item.text()))
|
||||
for row in range(obj.rowCount()):
|
||||
for column in range(obj.columnCount()):
|
||||
item = obj.item(row, column)
|
||||
if item is not None:
|
||||
item.setText(retranslate(item.text()))
|
||||
if isinstance(obj, QTreeWidget):
|
||||
for column in range(obj.columnCount()):
|
||||
item = obj.headerItem()
|
||||
item.setText(column, retranslate(item.text(column)))
|
||||
self.steamAccountTitle.setText(_("Account") + " Steam:")
|
||||
self.gogAccountTitle.setText(_("Account") + " GOG:")
|
||||
self.egsAccountTitle.setText(_("Account") + " Epic Games:")
|
||||
self.autoDownloadPPDBTitle.setText(
|
||||
_("Auto download PPDB from") + " linux-gaming.ru"
|
||||
)
|
||||
self.enableThemeStoreTitle.setText(
|
||||
_("Enable Theme Store from %(source)s (%(warning)s)") % {
|
||||
"source": "linux-gaming.ru",
|
||||
"warning": _("third-party themes may be unsafe"),
|
||||
}
|
||||
)
|
||||
|
||||
def _apply_gamepad_type_setting(self) -> None:
|
||||
"""Apply configured gamepad type to current input manager."""
|
||||
input_manager = getattr(self, "input_manager", None)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import shlex
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
@@ -45,6 +46,9 @@ from portprotonqt.system_manager import (
|
||||
logger = get_logger(__name__)
|
||||
|
||||
AUDIO_MAX_VOLUME = 150
|
||||
RETURN_TO_DESKTOP_ENV = "PORTPROTONQT_RETURN_TO_DESKTOP_SCRIPT"
|
||||
SYSTEM_DEVICE_RETRY_INTERVAL_MS = 2000
|
||||
SYSTEM_DEVICE_RETRY_LIMIT = 5
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -165,6 +169,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def createSystemTab(self) -> None:
|
||||
"""System settings tab."""
|
||||
self.systemWidget = QWidget()
|
||||
self.systemWidget.setProperty("theme_style_name", "OTHER_PAGES_WIDGET_STYLE")
|
||||
self.systemWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
self.systemWidget.setObjectName("otherPage")
|
||||
layout = QVBoxLayout(self.systemWidget)
|
||||
@@ -263,6 +268,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
for index, section_title in enumerate(section_titles):
|
||||
button = AutoSizeButton(section_title)
|
||||
button.setCheckable(True)
|
||||
button.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
button.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
button.setProperty("sound_event", False)
|
||||
@@ -322,6 +328,14 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.audioBusy = False
|
||||
self.audioVolumeUpdating = False
|
||||
self.audioFocusSinkName = ""
|
||||
self.networkRetryTimer = QTimer(self)
|
||||
self.networkRetryTimer.setInterval(SYSTEM_DEVICE_RETRY_INTERVAL_MS)
|
||||
self.networkRetryTimer.timeout.connect(self.loadSystemNetworks)
|
||||
self.networkRetryCount = 0
|
||||
self.bluetoothRetryTimer = QTimer(self)
|
||||
self.bluetoothRetryTimer.setInterval(SYSTEM_DEVICE_RETRY_INTERVAL_MS)
|
||||
self.bluetoothRetryTimer.timeout.connect(self.loadSystemBluetoothDevices)
|
||||
self.bluetoothRetryCount = 0
|
||||
|
||||
def _startSystemBackgroundTasks(self) -> None:
|
||||
self.system_tab_index = self.stackedWidget.addWidget(self.systemWidget)
|
||||
@@ -404,6 +418,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
|
||||
def _createSystemSection(self, title_text: str) -> tuple[QFrame, QVBoxLayout]:
|
||||
section_frame = QFrame()
|
||||
section_frame.setProperty("theme_style_name", "SETTINGS_FRAME_STYLE")
|
||||
section_frame.setStyleSheet(self.theme.SETTINGS_FRAME_STYLE)
|
||||
section_layout = QVBoxLayout(section_frame)
|
||||
section_layout.setContentsMargins(15, 15, 15, 15)
|
||||
@@ -468,6 +483,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
buttons_layout = QHBoxLayout(self.networkActionButtonsWidget)
|
||||
buttons_layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.systemRefreshButton = AutoSizeButton(_("Refresh"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.systemRefreshButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemRefreshButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemRefreshButton.clicked.connect(self.loadSystemNetworks)
|
||||
buttons_layout.addWidget(self.systemRefreshButton)
|
||||
@@ -514,6 +530,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.vpnDisconnectButton = AutoSizeButton(_("Disconnect"), icon=self.theme_manager.get_icon("stop", as_path=True))
|
||||
|
||||
self.vpnAddButton = AutoSizeButton(_("Add"), icon=self.theme_manager.get_icon("folder", as_path=True))
|
||||
self.vpnAddButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.vpnAddButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.vpnAddButton.clicked.connect(self.addVpnProfile)
|
||||
vpn_layout.addWidget(self.vpnAddButton)
|
||||
@@ -584,11 +601,13 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def _setupSystemBluetoothActionButtons(self, layout) -> None:
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.bluetoothRefreshButton = AutoSizeButton(_("Refresh"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.bluetoothRefreshButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.bluetoothRefreshButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.bluetoothRefreshButton.clicked.connect(self.loadSystemBluetoothDevices)
|
||||
buttons_layout.addWidget(self.bluetoothRefreshButton)
|
||||
|
||||
self.bluetoothScanButton = AutoSizeButton(_("Scan"), icon=self.theme_manager.get_icon("search", as_path=True))
|
||||
self.bluetoothScanButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.bluetoothScanButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.bluetoothScanButton.clicked.connect(self.scanSystemBluetoothDevices)
|
||||
buttons_layout.addWidget(self.bluetoothScanButton)
|
||||
@@ -638,6 +657,7 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def _setupSystemStorageActionButtons(self, layout) -> None:
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.storageRefreshButton = AutoSizeButton(_("Refresh"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.storageRefreshButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.storageRefreshButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.storageRefreshButton.clicked.connect(self.loadSystemStorageDevices)
|
||||
buttons_layout.addWidget(self.storageRefreshButton)
|
||||
@@ -710,11 +730,13 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def _setupSystemAudioActionButtons(self, layout) -> None:
|
||||
buttons_layout = QHBoxLayout()
|
||||
self.audioRefreshButton = AutoSizeButton(_("Refresh"), icon=self.theme_manager.get_icon("update", as_path=True))
|
||||
self.audioRefreshButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.audioRefreshButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.audioRefreshButton.clicked.connect(self.loadSystemAudioDevices)
|
||||
buttons_layout.addWidget(self.audioRefreshButton)
|
||||
|
||||
self.audioSetOutputButton = AutoSizeButton(_("Set output"), icon=self.theme_manager.get_icon("login", as_path=True))
|
||||
self.audioSetOutputButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.audioSetOutputButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.audioSetOutputButton.clicked.connect(self.setSelectedAudioSinkDefault)
|
||||
buttons_layout.addWidget(self.audioSetOutputButton)
|
||||
@@ -873,22 +895,36 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
def _setupSystemPowerActionButtons(self, layout) -> None:
|
||||
buttons_layout = QHBoxLayout()
|
||||
|
||||
return_command = os.getenv(RETURN_TO_DESKTOP_ENV, "").strip()
|
||||
if return_command:
|
||||
self.systemReturnToDesktopButton = AutoSizeButton(
|
||||
_("Return to desktop"), icon=self.theme_manager.get_icon("exit", as_path=True)
|
||||
)
|
||||
self.systemReturnToDesktopButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemReturnToDesktopButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemReturnToDesktopButton.clicked.connect(self.returnToDesktop)
|
||||
buttons_layout.addWidget(self.systemReturnToDesktopButton)
|
||||
|
||||
self.systemLogoutButton = AutoSizeButton(_("Logout"), icon=self.theme_manager.get_icon("exit", as_path=True))
|
||||
self.systemLogoutButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemLogoutButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemLogoutButton.clicked.connect(self.logoutSystem)
|
||||
buttons_layout.addWidget(self.systemLogoutButton)
|
||||
|
||||
self.systemRebootButton = AutoSizeButton(_("Reboot"), icon=self.theme_manager.get_icon("reboot", as_path=True))
|
||||
self.systemRebootButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemRebootButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemRebootButton.clicked.connect(self.rebootSystem)
|
||||
buttons_layout.addWidget(self.systemRebootButton)
|
||||
|
||||
self.systemShutdownButton = AutoSizeButton(_("Shutdown"), icon=self.theme_manager.get_icon("shutdown", as_path=True))
|
||||
self.systemShutdownButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemShutdownButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemShutdownButton.clicked.connect(self.shutdownSystem)
|
||||
buttons_layout.addWidget(self.systemShutdownButton)
|
||||
|
||||
self.systemSuspendButton = AutoSizeButton(_("Suspend"), icon=self.theme_manager.get_icon("suspend", as_path=True))
|
||||
self.systemSuspendButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.systemSuspendButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.systemSuspendButton.clicked.connect(self.suspendSystem)
|
||||
buttons_layout.addWidget(self.systemSuspendButton)
|
||||
@@ -903,6 +939,20 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
return
|
||||
logger.error("Failed to execute %s %s", command, action)
|
||||
|
||||
def returnToDesktop(self) -> None:
|
||||
command_text = os.getenv(RETURN_TO_DESKTOP_ENV, "").strip()
|
||||
try:
|
||||
command = shlex.split(command_text)
|
||||
except ValueError as error:
|
||||
logger.error("Invalid return-to-desktop command: %s", error)
|
||||
return
|
||||
if not command:
|
||||
logger.error("Cannot return to desktop: %s is empty", RETURN_TO_DESKTOP_ENV)
|
||||
return
|
||||
if QProcess.startDetached(command[0], command[1:]):
|
||||
return
|
||||
logger.error("Failed to execute return-to-desktop command: %s", command_text)
|
||||
|
||||
def rebootSystem(self) -> None:
|
||||
self._runSystemAction("reboot")
|
||||
|
||||
@@ -1197,6 +1247,14 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.vpnRows = payload.get("vpns", [])
|
||||
self.populateSystemNetworks(payload)
|
||||
self.setNetworkBusy(False)
|
||||
if payload.get("available"):
|
||||
self.networkRetryTimer.stop()
|
||||
self.networkRetryCount = 0
|
||||
elif operation == "load" and self.networkRetryCount < SYSTEM_DEVICE_RETRY_LIMIT:
|
||||
self.networkRetryCount += 1
|
||||
self.networkRetryTimer.start()
|
||||
elif operation == "load":
|
||||
self.networkRetryTimer.stop()
|
||||
if operation == "scan":
|
||||
logger.info("Network scan finished: wifi_networks=%d", len(self.networkRows))
|
||||
elif operation == "load":
|
||||
@@ -1206,9 +1264,14 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
len(self.vpnRows),
|
||||
)
|
||||
|
||||
def onNetworkOperationFailed(self, _operation: str, error_text: str) -> None:
|
||||
def onNetworkOperationFailed(self, operation: str, error_text: str) -> None:
|
||||
self.setNetworkBusy(False)
|
||||
logger.warning("Network operation failed: %s", error_text)
|
||||
if operation == "load" and self.networkRetryCount < SYSTEM_DEVICE_RETRY_LIMIT:
|
||||
self.networkRetryCount += 1
|
||||
self.networkRetryTimer.start()
|
||||
elif operation == "load":
|
||||
self.networkRetryTimer.stop()
|
||||
self.networkStatusLabel.setText("Error")
|
||||
self.wirelessEnabledCheckBox.blockSignals(True)
|
||||
self.wirelessEnabledCheckBox.setChecked(self.systemWirelessEnabled)
|
||||
@@ -1416,6 +1479,14 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.bluetoothRows = payload.get("devices", [])
|
||||
self.populateSystemBluetoothDevices(payload)
|
||||
self.setBluetoothBusy(False)
|
||||
if payload.get("available"):
|
||||
self.bluetoothRetryTimer.stop()
|
||||
self.bluetoothRetryCount = 0
|
||||
elif operation == "load" and self.bluetoothRetryCount < SYSTEM_DEVICE_RETRY_LIMIT:
|
||||
self.bluetoothRetryCount += 1
|
||||
self.bluetoothRetryTimer.start()
|
||||
elif operation == "load":
|
||||
self.bluetoothRetryTimer.stop()
|
||||
if operation != "load":
|
||||
QTimer.singleShot(800, self.loadSystemBluetoothDevices)
|
||||
if operation == "scan":
|
||||
@@ -1436,6 +1507,11 @@ class MainWindowSystemTabMixin(_MainWindowTypingBase):
|
||||
self.bluetoothRows = []
|
||||
self.bluetoothTable.setRowCount(0)
|
||||
self._setSystemSectionVisible(self.systemSectionBluetoothIndex, False)
|
||||
if self.bluetoothRetryCount < SYSTEM_DEVICE_RETRY_LIMIT:
|
||||
self.bluetoothRetryCount += 1
|
||||
self.bluetoothRetryTimer.start()
|
||||
else:
|
||||
self.bluetoothRetryTimer.stop()
|
||||
self.bluetoothEnabledCheckBox.blockSignals(True)
|
||||
self.bluetoothEnabledCheckBox.setChecked(self.systemBluetoothEnabled)
|
||||
self.bluetoothEnabledCheckBox.blockSignals(False)
|
||||
|
||||
@@ -219,6 +219,7 @@ class ThemeStoreMixin:
|
||||
def _create_theme_store_detail_header(self) -> QHBoxLayout:
|
||||
header = QHBoxLayout()
|
||||
self.themeStoreBackButton = AutoSizeButton(_("Back"))
|
||||
self.themeStoreBackButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.themeStoreBackButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreBackButton.setProperty("sound_event", "back")
|
||||
self.themeStoreBackButton.clicked.connect(self._show_theme_store_list)
|
||||
@@ -229,6 +230,7 @@ class ThemeStoreMixin:
|
||||
header.addWidget(self.themeStoreDetailTitle)
|
||||
header.addStretch(1)
|
||||
self.themeStoreDownloadButton = AutoSizeButton(_("Download"))
|
||||
self.themeStoreDownloadButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.themeStoreDownloadButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreDownloadButton.clicked.connect(self._download_current_store_theme)
|
||||
header.addWidget(self.themeStoreDownloadButton)
|
||||
@@ -266,10 +268,12 @@ class ThemeStoreMixin:
|
||||
def _add_theme_store_variant_buttons(self, scrollLayout: QVBoxLayout) -> None:
|
||||
variantLayout = QHBoxLayout()
|
||||
self.themeStoreDarkButton = AutoSizeButton(_("Dark"))
|
||||
self.themeStoreDarkButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.themeStoreDarkButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreDarkButton.clicked.connect(lambda: self._set_theme_store_preview_variant("dark"))
|
||||
variantLayout.addWidget(self.themeStoreDarkButton)
|
||||
self.themeStoreLightButton = AutoSizeButton(_("Light"))
|
||||
self.themeStoreLightButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.themeStoreLightButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreLightButton.clicked.connect(lambda: self._set_theme_store_preview_variant("light"))
|
||||
variantLayout.addWidget(self.themeStoreLightButton)
|
||||
@@ -578,6 +582,14 @@ class ThemeStoreMixin:
|
||||
def _set_theme_store_preview_variant(self, variant: str) -> None:
|
||||
self.themeStorePreviewVariant = variant
|
||||
active_style = getattr(self.theme, "ACTION_BUTTON_ACTIVE_STYLE", self.theme.ACTION_BUTTON_STYLE)
|
||||
self.themeStoreDarkButton.setProperty(
|
||||
"theme_style_name",
|
||||
"ACTION_BUTTON_ACTIVE_STYLE" if variant == "dark" else "ACTION_BUTTON_STYLE",
|
||||
)
|
||||
self.themeStoreLightButton.setProperty(
|
||||
"theme_style_name",
|
||||
"ACTION_BUTTON_ACTIVE_STYLE" if variant == "light" else "ACTION_BUTTON_STYLE",
|
||||
)
|
||||
self.themeStoreDarkButton.setStyleSheet(
|
||||
active_style if variant == "dark" else self.theme.ACTION_BUTTON_STYLE
|
||||
)
|
||||
@@ -670,6 +682,8 @@ class ThemeStoreMixin:
|
||||
self.themeStoreStatusLabel.show()
|
||||
self.themeStoreStatusLabel.setText(_("Failed to install theme"))
|
||||
return
|
||||
for installed_theme in theme_names:
|
||||
self.theme_manager.invalidate_theme(installed_theme)
|
||||
variant = getattr(self, "themeStorePreviewVariant", "dark")
|
||||
theme_name = ui_config.resolve_theme(theme_names[0], variant)
|
||||
if theme_name not in theme_names:
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from collections.abc import Callable
|
||||
|
||||
from PySide6.QtCore import Qt, QTimer
|
||||
from PySide6.QtCore import QTimer, Qt
|
||||
from PySide6.QtWidgets import (
|
||||
QAbstractButton,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
@@ -10,8 +14,11 @@ from PySide6.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from PySide6.QtGui import QAction
|
||||
from shiboken6 import isValid
|
||||
|
||||
from portprotonqt.config import load_theme_metainfo, ui_config, window_config
|
||||
from portprotonqt.config.ui import _is_system_light_theme
|
||||
from portprotonqt.custom_widgets import AutoSizeButton, CustomComboBox
|
||||
from portprotonqt.image_utils import ImageCarousel
|
||||
from portprotonqt.localization import _
|
||||
@@ -21,6 +28,7 @@ from portprotonqt.theme_manager import load_theme_screenshots
|
||||
from portprotonqt.tray_manager import restart_application_process
|
||||
|
||||
logger = get_logger(__name__)
|
||||
THEME_STYLE_BATCH_SIZE = 24
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from PySide6.QtWidgets import QMainWindow
|
||||
@@ -37,6 +45,10 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
def createThemeTab(self):
|
||||
"""Themes tab"""
|
||||
self.themeTabWidget = QWidget()
|
||||
self.themeTabWidget.setProperty(
|
||||
"theme_style_names",
|
||||
("OTHER_PAGES_WIDGET_STYLE", "THEME_TAB_FOCUS_STYLE"),
|
||||
)
|
||||
self.themeTabWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE + self.theme.THEME_TAB_FOCUS_STYLE)
|
||||
self.themeTabWidget.setObjectName("otherPage")
|
||||
mainLayout = QVBoxLayout(self.themeTabWidget)
|
||||
@@ -130,6 +142,7 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
self.themeInfoLayout.addWidget(self.themeMetainfoLabel)
|
||||
|
||||
self.applyButton = AutoSizeButton(_("Apply Theme"), icon=self.theme_manager.get_icon("apply", as_path=True))
|
||||
self.applyButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.applyButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.applyButton.setObjectName("themeApplyButton")
|
||||
self.applyButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -139,6 +152,7 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
_("Delete Theme"),
|
||||
icon=self.theme_manager.get_icon("delete", as_path=True),
|
||||
)
|
||||
self.deleteThemeButton.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
self.deleteThemeButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
self.deleteThemeButton.setObjectName("themeDeleteButton")
|
||||
self.deleteThemeButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
@@ -280,13 +294,301 @@ class MainWindowThemeTabMixin(ThemeStoreMixin, _MainWindowTypingBase):
|
||||
self.themesCombo.blockSignals(False)
|
||||
|
||||
def _apply_theme_and_restart(self, theme_name: str, variant: str) -> None:
|
||||
base_theme = ui_config.get_theme_bases([theme_name])[0]
|
||||
ui_config.set_theme(theme_name)
|
||||
ui_config.set_theme_variant(variant)
|
||||
self._apply_theme_live(theme_name)
|
||||
if variant == "auto":
|
||||
ui_config.set_theme(base_theme)
|
||||
ui_config.set_theme_variant(variant)
|
||||
|
||||
def _apply_theme_live(self, theme_name: str) -> None:
|
||||
old_theme = self.theme
|
||||
old_qicons, old_icon_paths = self.theme_manager.get_cached_icon_names(
|
||||
self.current_theme_name
|
||||
)
|
||||
theme_module = self.theme_manager.apply_theme(theme_name)
|
||||
if not theme_module:
|
||||
return
|
||||
ui_config.set_theme(theme_name)
|
||||
ui_config.set_theme_variant(variant)
|
||||
self._save_theme_tab_state()
|
||||
QTimer.singleShot(500, lambda: self.restart_application())
|
||||
widgets = [self, *self.findChildren(QWidget)]
|
||||
widget_styles = {widget.styleSheet() for widget in widgets}
|
||||
combined_styles = "".join(widget_styles)
|
||||
style_icon_paths = {
|
||||
path: icon_name
|
||||
for path, icon_name in old_icon_paths.items()
|
||||
if path in combined_styles
|
||||
}
|
||||
replacement_cache = getattr(self, "_theme_replacement_cache", {})
|
||||
replacement_key = (id(old_theme), id(theme_module))
|
||||
replacements = replacement_cache.get(replacement_key)
|
||||
if replacements is None:
|
||||
replacements = self._theme_style_replacements(old_theme, theme_module)
|
||||
replacement_cache[replacement_key] = replacements
|
||||
self._theme_replacement_cache = replacement_cache
|
||||
replacements = list(replacements)
|
||||
icon_replacements = self._theme_icon_path_replacements(
|
||||
theme_name, style_icon_paths
|
||||
)
|
||||
replacements.extend(icon_replacements)
|
||||
replace_style = self._theme_style_replacer(replacements, widget_styles)
|
||||
self.theme = theme_module
|
||||
self.current_theme_name = theme_name
|
||||
self._theme_change_in_progress = True
|
||||
self.setUpdatesEnabled(False)
|
||||
try:
|
||||
style_cache: dict[str, str] = {}
|
||||
style_updates = 0
|
||||
refresh_time = 0.0
|
||||
stylesheet_time = 0.0
|
||||
slow_updates: list[tuple[float, str]] = []
|
||||
deferred_updates: list[
|
||||
tuple[QWidget, str, Callable[[object], object] | None]
|
||||
] = []
|
||||
for widget in widgets:
|
||||
if hasattr(widget, "theme"):
|
||||
widget.theme = theme_module
|
||||
if hasattr(widget, "current_theme_name"):
|
||||
widget.current_theme_name = theme_name
|
||||
refresh_theme = getattr(widget, "refresh_theme", None)
|
||||
visible = widget is self or widget.isVisible()
|
||||
if callable(refresh_theme) and visible:
|
||||
refresh_started = time.perf_counter()
|
||||
refresh_theme(theme_module)
|
||||
refresh_time += time.perf_counter() - refresh_started
|
||||
style = widget.styleSheet()
|
||||
named_style = self._get_named_theme_style(widget, theme_module)
|
||||
if isinstance(named_style, str):
|
||||
new_style = named_style
|
||||
elif style in style_cache:
|
||||
new_style = style_cache[style]
|
||||
else:
|
||||
new_style = replace_style(style)
|
||||
style_cache[style] = new_style
|
||||
if not visible:
|
||||
deferred_updates.append((widget, new_style, refresh_theme))
|
||||
elif new_style != style:
|
||||
stylesheet_started = time.perf_counter()
|
||||
widget.setStyleSheet(new_style)
|
||||
update_time = time.perf_counter() - stylesheet_started
|
||||
stylesheet_time += update_time
|
||||
if update_time >= 0.005:
|
||||
widget_name = widget.objectName() or type(widget).__name__
|
||||
slow_updates.append((update_time, widget_name))
|
||||
style_updates += 1
|
||||
self._update_theme_components(theme_module, theme_name)
|
||||
if old_qicons or old_icon_paths:
|
||||
self._refresh_theme_icons(theme_name, old_qicons, old_icon_paths)
|
||||
self._refresh_theme_library_layout(old_theme, theme_module)
|
||||
self._refresh_open_detail_page()
|
||||
finally:
|
||||
self.setUpdatesEnabled(True)
|
||||
self.update()
|
||||
self._theme_change_in_progress = False
|
||||
generation = getattr(self, "_theme_update_generation", 0) + 1
|
||||
self._theme_update_generation = generation
|
||||
if deferred_updates:
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: self._apply_deferred_theme_updates(
|
||||
deferred_updates, theme_module, generation
|
||||
),
|
||||
)
|
||||
self.updateControlHints("force")
|
||||
|
||||
def _apply_deferred_theme_updates(
|
||||
self,
|
||||
updates: list[tuple[QWidget, str, Callable[[object], object] | None]],
|
||||
theme: object,
|
||||
generation: int,
|
||||
) -> None:
|
||||
if generation != getattr(self, "_theme_update_generation", 0):
|
||||
return
|
||||
batch = updates[:THEME_STYLE_BATCH_SIZE]
|
||||
del updates[:THEME_STYLE_BATCH_SIZE]
|
||||
for widget, new_style, refresh_theme in batch:
|
||||
if not isValid(widget):
|
||||
continue
|
||||
if callable(refresh_theme):
|
||||
refresh_theme(theme)
|
||||
if new_style != widget.styleSheet():
|
||||
widget.setStyleSheet(new_style)
|
||||
if updates:
|
||||
QTimer.singleShot(
|
||||
0,
|
||||
lambda: self._apply_deferred_theme_updates(
|
||||
updates, theme, generation
|
||||
),
|
||||
)
|
||||
|
||||
def _get_named_theme_style(self, widget: QWidget, theme: object) -> str | None:
|
||||
style_names = widget.property("theme_style_names")
|
||||
if not isinstance(style_names, (list, tuple)):
|
||||
style_name = widget.property("theme_style_name")
|
||||
style_names = (style_name,) if isinstance(style_name, str) else ()
|
||||
styles = [getattr(theme, name, None) for name in style_names]
|
||||
if not styles or not all(isinstance(style, str) for style in styles):
|
||||
return None
|
||||
return "".join(style for style in styles if isinstance(style, str))
|
||||
|
||||
def _refresh_theme_library_layout(
|
||||
self, old_theme: object, new_theme: object
|
||||
) -> None:
|
||||
old_mode = getattr(old_theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
new_mode = getattr(new_theme, "LIBRARY_LAYOUT_MODE", "grid")
|
||||
if old_mode == new_mode:
|
||||
return
|
||||
manager = getattr(self, "game_library_manager", None)
|
||||
layout = getattr(manager, "gamesListLayout", None)
|
||||
if manager is None or layout is None:
|
||||
return
|
||||
manager.rebuild_library_layout(str(new_mode).lower())
|
||||
|
||||
def _refresh_open_detail_page(self) -> None:
|
||||
manager = getattr(self, "detail_page_manager", None)
|
||||
if manager is None or not manager._can_rebuild_after_resize():
|
||||
return
|
||||
manager._reopen_current_detail_page()
|
||||
|
||||
def _theme_style_replacer(
|
||||
self, replacements: list[tuple[str, str]], styles: set[str] | None = None
|
||||
) -> Callable[[str], str]:
|
||||
replacement_map = {old: new for old, new in replacements if old}
|
||||
if not replacement_map:
|
||||
return lambda style: style
|
||||
partial_values = [
|
||||
old_value
|
||||
for old_value in replacement_map
|
||||
if styles is None
|
||||
or any(old_value in style and old_value != style for style in styles)
|
||||
]
|
||||
if not partial_values:
|
||||
return lambda style: replacement_map.get(style, style)
|
||||
partial_values.sort(key=len, reverse=True)
|
||||
pattern = re.compile("|".join(re.escape(value) for value in partial_values))
|
||||
|
||||
def replace_style(style: str) -> str:
|
||||
if not style:
|
||||
return style
|
||||
exact_style = replacement_map.get(style)
|
||||
if exact_style is not None:
|
||||
return exact_style
|
||||
return pattern.sub(lambda match: replacement_map[match.group(0)], style)
|
||||
|
||||
return replace_style
|
||||
|
||||
def _theme_icon_path_replacements(
|
||||
self, theme_name: str, old_paths: dict[str, str]
|
||||
) -> list[tuple[str, str]]:
|
||||
replacements = []
|
||||
for old_path, icon_name in old_paths.items():
|
||||
new_path = self.theme_manager.get_icon(icon_name, theme_name, as_path=True)
|
||||
if isinstance(new_path, str) and old_path != new_path:
|
||||
replacements.append((old_path, new_path))
|
||||
return replacements
|
||||
|
||||
def _update_theme_components(self, theme_module: object, theme_name: str) -> None:
|
||||
for component in vars(self).values():
|
||||
if hasattr(component, "theme"):
|
||||
component.theme = theme_module
|
||||
if hasattr(component, "current_theme_name"):
|
||||
component.current_theme_name = theme_name
|
||||
control_hints = getattr(self, "controlHintsWidget", None)
|
||||
hint_bar_style = getattr(theme_module, "HINT_BAR_STYLE", "")
|
||||
if isinstance(control_hints, QWidget):
|
||||
control_hints.setStyleSheet(hint_bar_style)
|
||||
hints_label_style = getattr(theme_module, "HINTS_LABEL_STYLE", "")
|
||||
for label in getattr(self, "hintTextLabels", ()):
|
||||
if isinstance(label, QWidget):
|
||||
label.setStyleSheet(hints_label_style)
|
||||
tray_manager = getattr(self, "tray_manager", None)
|
||||
if tray_manager is not None:
|
||||
tray_icon = self.theme_manager.get_icon("tray_portproton", theme_name)
|
||||
tray_manager.tray_icon.setIcon(tray_icon)
|
||||
|
||||
def _refresh_theme_icons(
|
||||
self, theme_name: str, qicons: dict[int, str], paths: dict[str, str]
|
||||
) -> None:
|
||||
for widget in self.findChildren(QWidget):
|
||||
control_hint_path = getattr(widget, "_icon_path", None)
|
||||
if isinstance(control_hint_path, str) and control_hint_path:
|
||||
icon_name = paths.get(control_hint_path)
|
||||
if icon_name:
|
||||
new_path = self.theme_manager.get_icon(
|
||||
icon_name, theme_name, as_path=True
|
||||
)
|
||||
set_paths = getattr(widget, "set_icon_paths", None)
|
||||
if isinstance(new_path, str) and callable(set_paths):
|
||||
set_paths((new_path,))
|
||||
icon_path = getattr(widget, "_icon", None)
|
||||
icon_name = paths.get(icon_path) if isinstance(icon_path, str) else None
|
||||
icon_setter = getattr(widget, "setIcon", None)
|
||||
if icon_name and callable(icon_setter):
|
||||
icon_setter(self.theme_manager.get_icon(icon_name, theme_name, as_path=True))
|
||||
if isinstance(widget, QAbstractButton):
|
||||
icon_name = qicons.get(widget.icon().cacheKey())
|
||||
if icon_name:
|
||||
widget.setIcon(self.theme_manager.get_icon(icon_name, theme_name))
|
||||
for action in self.findChildren(QAction):
|
||||
icon_name = qicons.get(action.icon().cacheKey())
|
||||
if icon_name:
|
||||
action.setIcon(self.theme_manager.get_icon(icon_name, theme_name))
|
||||
|
||||
def _theme_style_replacements(self, old_theme: object, new_theme: object) -> list[tuple[str, str]]:
|
||||
names = self._theme_attribute_names(old_theme) | self._theme_attribute_names(new_theme)
|
||||
candidates: dict[str, set[str]] = {}
|
||||
for name in sorted(names):
|
||||
if not name.endswith("_STYLE"):
|
||||
continue
|
||||
old_value = getattr(old_theme, name, None)
|
||||
new_value = getattr(new_theme, name, None)
|
||||
if isinstance(old_value, str) and isinstance(new_value, str) and old_value != new_value:
|
||||
candidates.setdefault(old_value, set()).add(new_value)
|
||||
replacements = [
|
||||
(old_value, next(iter(new_values)))
|
||||
for old_value, new_values in candidates.items()
|
||||
if len(new_values) == 1
|
||||
]
|
||||
return sorted(replacements, key=lambda pair: len(pair[0]), reverse=True)
|
||||
|
||||
def _theme_attribute_names(self, theme: object) -> set[str]:
|
||||
names = {name for name in vars(theme) if name.isupper()}
|
||||
custom_theme = getattr(theme, "custom_theme", None)
|
||||
if custom_theme is not None:
|
||||
names.update(name for name in vars(custom_theme) if name.isupper())
|
||||
generated = getattr(theme, "_generated_styles", None)
|
||||
if generated:
|
||||
names.update(name for name in generated if name.isupper())
|
||||
parent = getattr(theme, "_default_theme", None)
|
||||
if parent is not None:
|
||||
names.update(self._theme_attribute_names(parent))
|
||||
return names
|
||||
|
||||
def _on_system_theme_detected(self, is_light: bool) -> None:
|
||||
if ui_config.get_theme_variant() != "auto":
|
||||
return
|
||||
self._apply_system_theme_variant("light" if is_light else "dark")
|
||||
|
||||
def _on_qt_color_scheme_changed(self, color_scheme: Qt.ColorScheme) -> None:
|
||||
if getattr(self, "_theme_change_in_progress", False):
|
||||
return
|
||||
if ui_config.get_theme_variant() != "auto":
|
||||
return
|
||||
is_light = color_scheme == Qt.ColorScheme.Light
|
||||
if _is_system_light_theme() != is_light:
|
||||
return
|
||||
watcher = getattr(self, "system_theme_watcher", None)
|
||||
if watcher is not None:
|
||||
watcher._last_light = is_light
|
||||
self._on_system_theme_detected(is_light)
|
||||
|
||||
def _apply_system_theme_variant(self, variant: str) -> None:
|
||||
base_theme = ui_config.get_theme_base()
|
||||
theme_name = ui_config.resolve_theme(base_theme, variant)
|
||||
if theme_name == self.current_theme_name:
|
||||
return
|
||||
self._apply_theme_live(theme_name)
|
||||
ui_config.set_theme(base_theme)
|
||||
ui_config.set_theme_variant("auto")
|
||||
|
||||
def _save_theme_tab_state(self) -> None:
|
||||
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||
|
||||
@@ -45,6 +45,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
def createWineTab(self):
|
||||
"""Wine Settings tab."""
|
||||
self.wineWidget = QWidget()
|
||||
self.wineWidget.setProperty("theme_style_name", "OTHER_PAGES_WIDGET_STYLE")
|
||||
self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||
layout = QVBoxLayout(self.wineWidget)
|
||||
layout.setContentsMargins(10, 18, 10, 10)
|
||||
@@ -169,6 +170,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
row = i // 3
|
||||
col = i % 3
|
||||
btn = AutoSizeButton(tool_name, update_size=False)
|
||||
btn.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
if tool_cmd == "default":
|
||||
@@ -199,6 +201,7 @@ class MainWindowWineTabMixin(_MainWindowTypingBase):
|
||||
row = i // 2
|
||||
col = i % 2
|
||||
btn = AutoSizeButton(text, update_size=False)
|
||||
btn.setProperty("theme_style_name", "ACTION_BUTTON_STYLE")
|
||||
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||
if callback:
|
||||
|
||||
+149
-31
@@ -1,4 +1,5 @@
|
||||
import ast
|
||||
import asyncio
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import os
|
||||
@@ -6,17 +7,21 @@ import re
|
||||
import shutil
|
||||
import stat
|
||||
import xml.etree.ElementTree as ET
|
||||
from typing import Any, cast
|
||||
import orjson
|
||||
from dbus_fast import BusType
|
||||
from dbus_fast.aio import MessageBus
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.theme_security import (
|
||||
check_theme_directory_safety,
|
||||
is_safe_font_file,
|
||||
is_safe_image_file,
|
||||
)
|
||||
from PySide6.QtCore import QRectF, Qt
|
||||
from PySide6.QtCore import QRectF, Qt, QThread, Signal
|
||||
from PySide6.QtGui import QIcon, QFontDatabase, QPainter, QPixmap
|
||||
from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.config import CACHE_DIR, ui_config, load_theme_metainfo
|
||||
from portprotonqt.config.ui import _is_system_light_theme, _unwrap_variant
|
||||
from portprotonqt.qt_utils import get_device_pixel_ratio
|
||||
|
||||
# Icon caching for performance optimization
|
||||
@@ -71,6 +76,10 @@ SVG_KEEP_PAINT_VALUES = {"none", "transparent", "inherit", "initial", "unset", "
|
||||
SVG_NAMESPACE = "http://www.w3.org/2000/svg"
|
||||
SVG_CACHE_VERSION = 2
|
||||
NON_INHERITED_THEME_CONSTANTS = {"ICON_COLORS"}
|
||||
SYSTEM_THEME_POLL_INTERVAL_MS = 200
|
||||
SYSTEM_THEME_POLL_STEP_MS = 200
|
||||
PORTAL_APPEARANCE_NAMESPACE = "org.freedesktop.appearance"
|
||||
PORTAL_COLOR_SCHEME_KEY = "color-scheme"
|
||||
|
||||
ET.register_namespace("", SVG_NAMESPACE)
|
||||
|
||||
@@ -81,6 +90,7 @@ THEMES_DIRS = [
|
||||
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
|
||||
]
|
||||
_loaded_theme = None
|
||||
_loaded_font_signature = None
|
||||
|
||||
|
||||
def _read_dms_color_file(colors_file: str) -> bytes | None:
|
||||
@@ -548,20 +558,14 @@ def load_theme_fonts(theme_name):
|
||||
"""
|
||||
Load all fonts from selected theme if not already loaded.
|
||||
"""
|
||||
global _loaded_theme
|
||||
global _loaded_theme, _loaded_font_signature
|
||||
if _loaded_theme == theme_name:
|
||||
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
|
||||
return
|
||||
|
||||
def load_fonts_delayed():
|
||||
global _loaded_theme
|
||||
global _loaded_theme, _loaded_font_signature
|
||||
try:
|
||||
# Only remove fonts if this is a theme change (not initial load)
|
||||
current_loaded_theme = _loaded_theme # Capture the current value
|
||||
if current_loaded_theme is not None and current_loaded_theme != theme_name:
|
||||
# Run font removal in the GUI thread with delay
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
|
||||
import time
|
||||
import os
|
||||
start_time = time.time()
|
||||
@@ -591,7 +595,21 @@ def load_theme_fonts(theme_name):
|
||||
logger.warning("Skipping unsafe font file: %s", font_path)
|
||||
|
||||
# Limit number of fonts loaded to prevent too much blocking
|
||||
font_files = font_files[:10] # Only load first 10 fonts to prevent too much blocking
|
||||
font_files = sorted(font_files)[:10]
|
||||
font_signature_parts = []
|
||||
for filename in font_files:
|
||||
with open(os.path.join(fonts_folder, filename), "rb") as font_file:
|
||||
digest = hashlib.sha256(font_file.read()).hexdigest()
|
||||
font_signature_parts.append((filename, digest))
|
||||
font_signature = tuple(font_signature_parts)
|
||||
if _loaded_font_signature == font_signature:
|
||||
_loaded_theme = theme_name
|
||||
logger.debug("Fonts for theme '%s' already loaded, skipping", theme_name)
|
||||
return
|
||||
|
||||
# Remove fonts only when the selected files changed.
|
||||
if _loaded_theme is not None:
|
||||
QFontDatabase.removeAllApplicationFonts()
|
||||
|
||||
for filename in font_files:
|
||||
if time.time() - start_time > timeout:
|
||||
@@ -608,6 +626,7 @@ def load_theme_fonts(theme_name):
|
||||
|
||||
# Update the global variable in the main thread
|
||||
_loaded_theme = theme_name
|
||||
_loaded_font_signature = font_signature
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading fonts for theme '{theme_name}': {e}")
|
||||
|
||||
@@ -624,16 +643,21 @@ class ThemeWrapper:
|
||||
def __init__(self, custom_theme, metainfo=None, inherit_chain=None):
|
||||
self.custom_theme = custom_theme
|
||||
self.metainfo = metainfo or {}
|
||||
self.screenshots = load_theme_screenshots(
|
||||
self.metainfo.get("name")
|
||||
or custom_theme.__name__.split(".")[-1]
|
||||
)
|
||||
self._theme_name = custom_theme.__name__.split(".")[-1]
|
||||
self._screenshots = None
|
||||
parent_name = getattr(custom_theme, "THEME_INHERITS", "standart")
|
||||
self.parent_theme_name = _get_parent_theme_name(custom_theme.__name__.split(".")[-1], parent_name)
|
||||
self._inherit_chain = inherit_chain or []
|
||||
self._default_theme = None # Lazy-loaded default theme
|
||||
self._generated_styles = None # Lazy-generated standard styles with custom constants
|
||||
|
||||
@property
|
||||
def screenshots(self) -> list[tuple[QPixmap, str]]:
|
||||
"""Load theme preview screenshots on first access."""
|
||||
if self._screenshots is None:
|
||||
self._screenshots = load_theme_screenshots(self._theme_name)
|
||||
return self._screenshots
|
||||
|
||||
def __getattr__(self, name):
|
||||
if hasattr(self.custom_theme, name):
|
||||
return getattr(self.custom_theme, name)
|
||||
@@ -887,23 +911,94 @@ def load_theme(theme_name, inherit_chain=None):
|
||||
if theme_name == "standart":
|
||||
return custom_theme
|
||||
meta = load_theme_metainfo(theme_name)
|
||||
wrapper = ThemeWrapper(custom_theme, metainfo=meta, inherit_chain=next_inherit_chain)
|
||||
wrapper.screenshots = load_theme_screenshots(theme_name)
|
||||
return wrapper
|
||||
return ThemeWrapper(custom_theme, metainfo=meta, inherit_chain=next_inherit_chain)
|
||||
raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'")
|
||||
|
||||
class SystemThemeWatcher(QThread):
|
||||
"""Watch desktop color scheme without blocking the UI thread."""
|
||||
|
||||
theme_changed = Signal(bool)
|
||||
|
||||
def __init__(self, initial_light: bool, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._last_light = initial_light
|
||||
|
||||
def run(self) -> None:
|
||||
try:
|
||||
asyncio.run(self._watch_portal())
|
||||
return
|
||||
except Exception as e:
|
||||
logger.debug("Portal theme watcher unavailable: %s", e)
|
||||
self._poll_system_theme()
|
||||
|
||||
async def _watch_portal(self) -> None:
|
||||
bus = await MessageBus(bus_type=BusType.SESSION).connect()
|
||||
try:
|
||||
introspection = await bus.introspect(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
)
|
||||
proxy = bus.get_proxy_object(
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
introspection,
|
||||
)
|
||||
interface = cast(Any, proxy.get_interface("org.freedesktop.portal.Settings"))
|
||||
value = await interface.call_read(
|
||||
PORTAL_APPEARANCE_NAMESPACE,
|
||||
PORTAL_COLOR_SCHEME_KEY,
|
||||
)
|
||||
if int(_unwrap_variant(value)) == 0:
|
||||
raise RuntimeError("Portal does not provide a color scheme")
|
||||
self._emit_portal_theme(value)
|
||||
interface.on_setting_changed(self._on_portal_setting_changed)
|
||||
while not self.isInterruptionRequested():
|
||||
await asyncio.sleep(SYSTEM_THEME_POLL_STEP_MS / 1000)
|
||||
finally:
|
||||
bus.disconnect()
|
||||
|
||||
def _on_portal_setting_changed(self, namespace: str, key: str, value: object) -> None:
|
||||
if namespace == PORTAL_APPEARANCE_NAMESPACE and key == PORTAL_COLOR_SCHEME_KEY:
|
||||
self._emit_portal_theme(value)
|
||||
|
||||
def _emit_portal_theme(self, value: object) -> None:
|
||||
color_scheme = int(_unwrap_variant(value))
|
||||
if color_scheme not in (1, 2):
|
||||
return
|
||||
is_light = color_scheme == 2
|
||||
if is_light != self._last_light:
|
||||
self._last_light = is_light
|
||||
self.theme_changed.emit(is_light)
|
||||
|
||||
def _poll_system_theme(self) -> None:
|
||||
elapsed_ms = SYSTEM_THEME_POLL_INTERVAL_MS
|
||||
while not self.isInterruptionRequested():
|
||||
if elapsed_ms >= SYSTEM_THEME_POLL_INTERVAL_MS:
|
||||
is_light = _is_system_light_theme()
|
||||
if is_light != self._last_light:
|
||||
self._last_light = is_light
|
||||
self.theme_changed.emit(is_light)
|
||||
elapsed_ms = 0
|
||||
self.msleep(SYSTEM_THEME_POLL_STEP_MS)
|
||||
elapsed_ms += SYSTEM_THEME_POLL_STEP_MS
|
||||
|
||||
|
||||
class ThemeManager:
|
||||
"""
|
||||
Class for managing application themes.
|
||||
Implement Singleton pattern for single instance.
|
||||
"""
|
||||
_instance = None
|
||||
current_theme_name: str | None
|
||||
current_theme_module: Any | None
|
||||
_theme_module_cache: dict[str, Any]
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
cls._instance.current_theme_name = None
|
||||
cls._instance.current_theme_module = None
|
||||
cls._instance._theme_module_cache = {}
|
||||
return cls._instance
|
||||
|
||||
def get_available_themes(self) -> list:
|
||||
@@ -927,8 +1022,32 @@ class ThemeManager:
|
||||
except OSError as e:
|
||||
logger.warning("Failed to remove custom theme '%s': %s", theme_name, e)
|
||||
return False
|
||||
self.invalidate_theme(theme_name)
|
||||
return True
|
||||
|
||||
def invalidate_theme(self, theme_name: str) -> None:
|
||||
"""Discard a cached theme after its files change."""
|
||||
self._theme_module_cache.pop(theme_name, None)
|
||||
if self.current_theme_name == theme_name:
|
||||
self.current_theme_name = None
|
||||
self.current_theme_module = None
|
||||
|
||||
def get_cached_icon_names(self, theme_name: str) -> tuple[dict[int, str], dict[str, str]]:
|
||||
"""Return cached icon identifiers before a live theme change."""
|
||||
marker = f"_{theme_name}_"
|
||||
qicons = {}
|
||||
paths = {}
|
||||
for cache_key, icon in _icon_cache.items():
|
||||
marker_index = cache_key.rfind(marker)
|
||||
if marker_index < 0:
|
||||
continue
|
||||
icon_name = cache_key[:marker_index]
|
||||
if isinstance(icon, QIcon) and not icon.isNull():
|
||||
qicons[icon.cacheKey()] = icon_name
|
||||
elif isinstance(icon, str):
|
||||
paths[icon] = icon_name
|
||||
return qicons, paths
|
||||
|
||||
def apply_theme(self, theme_name: str):
|
||||
"""
|
||||
Apply selected theme if not already applied.
|
||||
@@ -937,22 +1056,21 @@ class ThemeManager:
|
||||
if self.current_theme_name == theme_name and self.current_theme_module is not None:
|
||||
return self.current_theme_module
|
||||
|
||||
try:
|
||||
theme_module = load_theme(theme_name)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
|
||||
theme_module = load_theme("standart")
|
||||
theme_name = "standart"
|
||||
ui_config.set_theme("standart")
|
||||
theme_module = self._theme_module_cache.get(theme_name)
|
||||
if theme_module is None:
|
||||
try:
|
||||
theme_module = load_theme(theme_name)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
|
||||
theme_name = "standart"
|
||||
theme_module = self._theme_module_cache.get(theme_name)
|
||||
if theme_module is None:
|
||||
theme_module = load_theme(theme_name)
|
||||
ui_config.set_theme(theme_name)
|
||||
self._theme_module_cache[theme_name] = theme_module
|
||||
|
||||
load_theme_fonts(theme_name)
|
||||
|
||||
# Clear icon cache when theme changes to rebuild it for the new theme
|
||||
if self.current_theme_name != theme_name:
|
||||
global _icon_dirs_cache
|
||||
if theme_name in _icon_dirs_cache:
|
||||
del _icon_dirs_cache[theme_name]
|
||||
|
||||
self.current_theme_name = theme_name
|
||||
self.current_theme_module = theme_module
|
||||
ui_config.set_theme(theme_name)
|
||||
@@ -960,7 +1078,7 @@ class ThemeManager:
|
||||
from portprotonqt.sound_manager import SoundManager
|
||||
SoundManager().set_sounds_dirs(_resolve_sound_dirs(theme_name))
|
||||
|
||||
logger.info(f"Theme '{theme_name}' successfully applied")
|
||||
logger.info("Theme '%s' successfully applied", theme_name)
|
||||
return theme_module
|
||||
|
||||
def _get_theme_icon_colors(self, theme: object) -> dict:
|
||||
|
||||
@@ -1487,6 +1487,7 @@ TAB_STYLE = f"""
|
||||
# HINT BAR STYLE
|
||||
HINT_BAR_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_transparent};
|
||||
max-height: 82px;
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -107,6 +107,7 @@ STATUS_BAR_STYLE = f"""
|
||||
# HINT BAR STYLE
|
||||
HINT_BAR_STYLE = f"""
|
||||
QWidget {{
|
||||
background: {color_transparent};
|
||||
max-height: 82px;
|
||||
}}
|
||||
"""
|
||||
|
||||
@@ -73,6 +73,7 @@ class TrayManager:
|
||||
self.tray_icon.setToolTip(self.app_name)
|
||||
|
||||
self.tray_menu = QMenu()
|
||||
self.minimal_mode = False
|
||||
|
||||
self.stop_game_action = QAction(_("Stop Game"), self.main_window)
|
||||
self.stop_game_action.setEnabled(False)
|
||||
@@ -96,8 +97,10 @@ class TrayManager:
|
||||
self.tray_menu.clear()
|
||||
|
||||
self.tray_menu.addAction(self.stop_game_action)
|
||||
self.tray_menu.addSeparator()
|
||||
self.update_stop_game_action()
|
||||
if self.minimal_mode:
|
||||
return
|
||||
self.tray_menu.addSeparator()
|
||||
|
||||
if display_config.get_tray_menu_mode() == "compact":
|
||||
self._populate_compact_menu()
|
||||
@@ -109,6 +112,11 @@ class TrayManager:
|
||||
exit_action.triggered.connect(self.force_exit)
|
||||
self.tray_menu.addAction(exit_action)
|
||||
|
||||
def set_minimal_mode(self) -> None:
|
||||
"""Show only the stop action in the tray menu."""
|
||||
self.minimal_mode = True
|
||||
self.refresh_tray_menu()
|
||||
|
||||
def _populate_detailed_menu(self) -> None:
|
||||
self._populate_tabs_flat()
|
||||
self.tray_menu.addSeparator()
|
||||
@@ -182,6 +190,9 @@ class TrayManager:
|
||||
def stop_game(self) -> None:
|
||||
if self.main_window.stop_running_game():
|
||||
self.update_stop_game_action()
|
||||
if self.minimal_mode:
|
||||
self.tray_icon.hide()
|
||||
QApplication.quit()
|
||||
return
|
||||
|
||||
QMessageBox.warning(self.main_window, _("Error"), _("Failed to stop game"))
|
||||
|
||||
@@ -89,6 +89,12 @@ class VirtualKeyboard(QFrame):
|
||||
|
||||
self.setStyleSheet(self.theme.VIRTUAL_KEYBOARD_STYLE)
|
||||
|
||||
def refresh_theme(self, theme: Any) -> None:
|
||||
"""Refresh keyboard styling and animation settings."""
|
||||
self.theme = theme
|
||||
self.keyboard_animations.theme = theme
|
||||
self.setStyleSheet(theme.VIRTUAL_KEYBOARD_STYLE)
|
||||
|
||||
def set_slide_animation_enabled(self, enabled: bool) -> None:
|
||||
"""Enable or disable slide animation for show/hide."""
|
||||
self.enable_slide_animation = enabled
|
||||
|
||||
+11
-11
@@ -26,17 +26,17 @@ dependencies = [
|
||||
"babel>=2.18.0",
|
||||
"evdev>=1.9.3",
|
||||
"libarchive-c>=5.3",
|
||||
"orjson>=3.11.8",
|
||||
"pillow>=12.2.0",
|
||||
"orjson>=3.12.0",
|
||||
"pillow>=12.3.0",
|
||||
"psutil>=7.2.2",
|
||||
"pyside6>=6.11.0",
|
||||
"pyside6>=6.11.2",
|
||||
"rapidfuzz>=3.14.5",
|
||||
"qrcode>=8.2",
|
||||
"requests>=2.33.1",
|
||||
"tqdm>=4.67.3",
|
||||
"requests>=2.34.2",
|
||||
"tqdm>=4.70.0",
|
||||
"vdf>=3.4",
|
||||
"websocket-client>=1.9.0",
|
||||
"dbus-fast>=5.0.4",
|
||||
"websocket-client>=1.9.1",
|
||||
"dbus-fast>=5.0.22",
|
||||
"pefile>=2024.8.26",
|
||||
]
|
||||
|
||||
@@ -51,10 +51,10 @@ package = true
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pre-commit>=4.5.1",
|
||||
"pyright>=1.1.408",
|
||||
"pytest>=8.0.0",
|
||||
"pytest-mock>=3.14.0",
|
||||
"pre-commit>=4.6.2",
|
||||
"pyright>=1.1.411",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-mock>=3.15.1",
|
||||
]
|
||||
|
||||
[tool.ruff.lint]
|
||||
|
||||
@@ -15,6 +15,14 @@ os.environ.setdefault("SDL_AUDIODRIVER", "dummy")
|
||||
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mute_ui_sounds(monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest) -> None:
|
||||
"""Prevent UI feedback sounds outside dedicated sound manager tests."""
|
||||
if request.path.name == "test_sound_manager.py":
|
||||
return
|
||||
monkeypatch.setattr("portprotonqt.sound_manager.SoundManager.play", lambda _self, _event: None)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_config_dir(tmp_path: Path) -> Generator[Path, None, None]:
|
||||
os.environ["XDG_CONFIG_HOME"] = str(tmp_path / "config")
|
||||
|
||||
@@ -28,6 +28,12 @@ def test_integrate_appimage_installs_handlers(
|
||||
f"Icon={appimage_integration.APP_ID}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
mime_source = appdir / "share/mime/packages"
|
||||
mime_source.mkdir(parents=True)
|
||||
(mime_source / appimage_integration.MIME_PACKAGE_NAME).write_text(
|
||||
"<mime-info/>",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("APPIMAGE", str(source))
|
||||
monkeypatch.setenv("APPDIR", str(appdir))
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
@@ -96,6 +102,7 @@ def test_integrate_appimage_installs_handlers(
|
||||
destination = appimage_integration.integrate_appimage()
|
||||
|
||||
assert destination == tmp_path / "AppImages" / "portprotonqt.appimage"
|
||||
assert "application/x-executable" not in appimage_integration.WINDOWS_MIME_TYPES
|
||||
assert destination.read_text(encoding="utf-8") == "appimage"
|
||||
assert not legacy_appimage.exists()
|
||||
assert not stale_png.exists()
|
||||
@@ -143,7 +150,13 @@ def test_integrate_appimage_installs_handlers(
|
||||
]
|
||||
for mime_type in appimage_integration.WINDOWS_MIME_TYPES.rstrip(";").split(";")
|
||||
]
|
||||
assert commands == mime_commands + [
|
||||
installed_mime = (
|
||||
tmp_path / "data/mime/packages" / appimage_integration.MIME_PACKAGE_NAME
|
||||
)
|
||||
assert installed_mime.read_text(encoding="utf-8") == "<mime-info/>"
|
||||
assert commands == [
|
||||
["update-mime-database", str(tmp_path / "data/mime")],
|
||||
] + mime_commands + [
|
||||
["update-desktop-database", str(applications)]
|
||||
]
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
from portprotonqt.config.base import BaseConfig
|
||||
from portprotonqt.config.game import GameConfig
|
||||
from portprotonqt.config.ui import UIConfig
|
||||
import portprotonqt.localization as localization
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -126,6 +127,31 @@ def test_control_hints_are_visible_by_default(tmp_path: Path):
|
||||
assert config.get_hide_control_hints() is True
|
||||
|
||||
|
||||
def test_force_english_is_disabled_by_default(tmp_path: Path):
|
||||
config = UIConfig(config_file=tmp_path / "test.conf")
|
||||
|
||||
assert config.get_force_english() is False
|
||||
config.set_force_english(True)
|
||||
assert config.get_force_english() is True
|
||||
|
||||
|
||||
def test_translation_can_switch_to_english_without_restart(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
force_english = False
|
||||
monkeypatch.setattr(localization.translate, "gettext", lambda message: f"ru:{message}")
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.config.ui_config.get_force_english",
|
||||
lambda: force_english,
|
||||
)
|
||||
monkeypatch.setattr(localization, "_translation_sources", {})
|
||||
|
||||
translated = localization._("Settings")
|
||||
force_english = True
|
||||
|
||||
assert localization.retranslate(translated) == "Settings"
|
||||
|
||||
|
||||
class TestUpdateAppVersion:
|
||||
def test_first_save_returns_true(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("portprotonqt.config.base.CONFIG_FILE", tmp_path / "test.conf")
|
||||
|
||||
@@ -25,7 +25,8 @@ def test_suspected_crash_requires_existing_windows_executable(tmp_path: Path) ->
|
||||
|
||||
assert compatibility.is_suspected_crash(1.0, False, str(executable)) is True
|
||||
assert compatibility.is_suspected_crash(1.0, True, str(executable)) is False
|
||||
assert compatibility.is_suspected_crash(6.0, False, str(executable)) is False
|
||||
assert compatibility.is_suspected_crash(6.0, False, str(executable)) is True
|
||||
assert compatibility.is_suspected_crash(8.0, False, str(executable)) is False
|
||||
|
||||
|
||||
def test_always_report_environment_bypasses_duration(
|
||||
|
||||
@@ -6,6 +6,7 @@ from portprotonqt.config.ui import (
|
||||
_is_gsettings_dark_theme,
|
||||
THEME_VARIANTS,
|
||||
)
|
||||
from portprotonqt.theme_manager import SystemThemeWatcher
|
||||
|
||||
|
||||
class TestIsGsettingsDarkTheme:
|
||||
@@ -176,3 +177,31 @@ class TestThemeVariants:
|
||||
assert config.get_theme() == "classic"
|
||||
|
||||
assert resolve.call_count == 2
|
||||
|
||||
|
||||
def test_system_theme_watcher_emits_change(monkeypatch):
|
||||
watcher = SystemThemeWatcher(initial_light=False)
|
||||
detected = []
|
||||
watcher.theme_changed.connect(detected.append)
|
||||
interruptions = iter((False, True))
|
||||
monkeypatch.setattr(watcher, "isInterruptionRequested", lambda: next(interruptions))
|
||||
monkeypatch.setattr(watcher, "msleep", lambda _milliseconds: None)
|
||||
monkeypatch.setattr("portprotonqt.theme_manager._is_system_light_theme", lambda: True)
|
||||
|
||||
watcher._poll_system_theme()
|
||||
|
||||
assert detected == [True]
|
||||
|
||||
|
||||
def test_system_theme_watcher_emits_portal_change() -> None:
|
||||
watcher = SystemThemeWatcher(initial_light=False)
|
||||
detected = []
|
||||
watcher.theme_changed.connect(detected.append)
|
||||
|
||||
watcher._on_portal_setting_changed(
|
||||
"org.freedesktop.appearance",
|
||||
"color-scheme",
|
||||
2,
|
||||
)
|
||||
|
||||
assert detected == [True]
|
||||
@@ -17,6 +17,7 @@ from portprotonqt.detail_pages.utils import (
|
||||
_setup_wave_background,
|
||||
_remove_wave_background,
|
||||
_wave_states,
|
||||
remove_cover_background,
|
||||
)
|
||||
|
||||
|
||||
@@ -212,6 +213,8 @@ def test_compact_layout_rebuild_preserves_running_button() -> None:
|
||||
)
|
||||
manager._current_detail_source = ("game", {"name": "Game"})
|
||||
manager._current_detail_page = old_page
|
||||
cast(Any, manager).debug_log_manager = SimpleNamespace(is_running=False)
|
||||
manager._debug_log_button = None
|
||||
manager._return_to_tab_index = 0
|
||||
manager._remove_current_detail_page = MagicMock()
|
||||
manager.openGameDetailPage = MagicMock(
|
||||
@@ -225,6 +228,36 @@ def test_compact_layout_rebuild_preserves_running_button() -> None:
|
||||
manager.main_window._set_running_button_stop.assert_called_once_with()
|
||||
|
||||
|
||||
def test_compact_layout_rebuild_preserves_debug_log_button(monkeypatch: MonkeyPatch) -> None:
|
||||
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
|
||||
manager = DetailPageManager.__new__(DetailPageManager)
|
||||
old_page = MagicMock(spec=QWidget)
|
||||
old_button = MagicMock()
|
||||
new_button = MagicMock()
|
||||
stop_icon = object()
|
||||
old_page.isAncestorOf.return_value = True
|
||||
manager.main_window = SimpleNamespace(
|
||||
current_running_button=None,
|
||||
theme_manager=SimpleNamespace(
|
||||
get_icon=MagicMock(return_value=stop_icon),
|
||||
),
|
||||
)
|
||||
cast(Any, manager).debug_log_manager = SimpleNamespace(is_running=True)
|
||||
manager._debug_log_button = old_button
|
||||
manager._current_detail_source = ("game", {"name": "Game"})
|
||||
manager._current_detail_page = old_page
|
||||
manager._return_to_tab_index = 0
|
||||
manager._remove_current_detail_page = MagicMock()
|
||||
manager.openGameDetailPage = MagicMock(
|
||||
side_effect=lambda _data: setattr(manager, "_debug_log_button", new_button)
|
||||
)
|
||||
|
||||
manager._reopen_current_detail_page()
|
||||
|
||||
new_button.setText.assert_called_once_with("Stop Log")
|
||||
new_button.setIcon.assert_called_once_with(stop_icon)
|
||||
|
||||
|
||||
def _make_palette(colors):
|
||||
"""Create mock palette with name() returning hex colors."""
|
||||
palette = []
|
||||
@@ -450,8 +483,8 @@ class TestDetailBackgroundAnimations:
|
||||
def test_supports_all_omikuji_backgrounds_in_static_mode(self):
|
||||
manager = DetailBackgroundAnimations()
|
||||
page = MagicMock()
|
||||
config = {}
|
||||
for effect in manager.effects:
|
||||
config = {effect: {}}
|
||||
theme = SimpleNamespace(
|
||||
DETAIL_PAGE_BG_MODE=f"static_{effect}",
|
||||
DETAIL_PAGE_BACKGROUNDS=config,
|
||||
@@ -473,6 +506,7 @@ class TestDetailBackgroundAnimations:
|
||||
DETAIL_PAGE_BACKGROUNDS={
|
||||
"animation_interval_ms": 40,
|
||||
"animation_speed": 0.02,
|
||||
"aurora": {},
|
||||
},
|
||||
)
|
||||
assert manager.setup(page, _make_palette(["#111"]), theme)
|
||||
@@ -480,6 +514,17 @@ class TestDetailBackgroundAnimations:
|
||||
timer.start.assert_called_once()
|
||||
assert manager._states[page]["timer"] is timer
|
||||
|
||||
def test_incomplete_effect_config_is_not_installed(self):
|
||||
manager = DetailBackgroundAnimations()
|
||||
page = MagicMock()
|
||||
theme = SimpleNamespace(
|
||||
DETAIL_PAGE_BG_MODE="leaf",
|
||||
DETAIL_PAGE_BACKGROUNDS={"animation_interval_ms": 30},
|
||||
)
|
||||
|
||||
assert not manager.setup(page, _make_palette(["#111"]), theme)
|
||||
assert page not in manager._states
|
||||
|
||||
def test_wave_mode_is_not_claimed(self):
|
||||
manager = DetailBackgroundAnimations()
|
||||
page = MagicMock()
|
||||
@@ -490,6 +535,22 @@ class TestDetailBackgroundAnimations:
|
||||
# === _remove_wave_background ===
|
||||
|
||||
|
||||
def test_remove_cover_background_stops_procedural_effect() -> None:
|
||||
page = MagicMock()
|
||||
timer = MagicMock()
|
||||
original_paint = MagicMock()
|
||||
detail_utils._detail_backgrounds._states[page] = {
|
||||
"timer": timer,
|
||||
"original_paint": original_paint,
|
||||
}
|
||||
|
||||
remove_cover_background(page)
|
||||
|
||||
timer.stop.assert_called_once()
|
||||
timer.deleteLater.assert_called_once()
|
||||
assert page.paintEvent is original_paint
|
||||
|
||||
|
||||
class TestRemoveWaveBackground:
|
||||
def test_no_state(self):
|
||||
page = MagicMock()
|
||||
@@ -574,6 +635,36 @@ def test_non_steam_without_shortcut_shows_add_not_edit(monkeypatch) -> None:
|
||||
assert add_count == 1
|
||||
|
||||
|
||||
def test_egs_install_detail_shows_install_button(monkeypatch) -> None:
|
||||
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
|
||||
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
|
||||
play_button = cast(Any, manager._create_play_button).return_value
|
||||
|
||||
manager._create_game_buttons_layout({
|
||||
"game_source": "egs",
|
||||
"exec_line": "egs://install/doom64",
|
||||
"appid": "doom64",
|
||||
"name": "DOOM 64",
|
||||
})
|
||||
|
||||
play_button.setText.assert_called_once_with("Install")
|
||||
|
||||
|
||||
def test_installed_egs_detail_shows_store_controls(monkeypatch) -> None:
|
||||
monkeypatch.setattr("portprotonqt.detail_pages._", lambda text: text)
|
||||
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
|
||||
manager.main_window.egs_api.get_launch_target.return_value = "/games/DOOM64.exe"
|
||||
|
||||
manager._create_game_buttons_layout({
|
||||
"game_source": "egs",
|
||||
"exec_line": "egs://launch/doom64",
|
||||
"appid": "doom64",
|
||||
"name": "DOOM 64",
|
||||
})
|
||||
|
||||
assert _get_added_buttons(manager) == ["Settings", "Create Log", "Open Folder"]
|
||||
|
||||
|
||||
def test_steam_with_appid_shows_edit_not_add(monkeypatch) -> None:
|
||||
manager = _make_manager_with_shortcut_mock(has_shortcut=False, monkeypatch=monkeypatch)
|
||||
manager._create_game_buttons_layout({
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
import orjson
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from portprotonqt.egs_api import EGSAPI
|
||||
from portprotonqt.localization import get_store_content_languages
|
||||
|
||||
|
||||
def test_build_command_uses_isolated_legendary_config(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
api = EGSAPI()
|
||||
api.config_dir = tmp_path / "legendary"
|
||||
monkeypatch.setattr(api, "get_legendary_path", lambda: "/bin/legendary")
|
||||
|
||||
assert api.build_command(["list", "--json"]) == [
|
||||
"/bin/legendary", "list", "--json",
|
||||
]
|
||||
assert api.get_environment()["LEGENDARY_CONFIG_PATH"] == str(api.config_dir)
|
||||
|
||||
|
||||
def test_build_command_places_confirmation_before_subcommand(
|
||||
tmp_path: Path, monkeypatch
|
||||
) -> None:
|
||||
api = EGSAPI()
|
||||
api.config_dir = tmp_path / "legendary"
|
||||
monkeypatch.setattr(api, "get_legendary_path", lambda: "/bin/legendary")
|
||||
|
||||
assert api.build_command(["install", "Game", "-y"]) == [
|
||||
"/bin/legendary", "-y", "install", "Game",
|
||||
]
|
||||
|
||||
|
||||
def test_update_legendary_skips_matching_release(tmp_path: Path, monkeypatch) -> None:
|
||||
api = EGSAPI()
|
||||
api.data_dir = tmp_path
|
||||
api.version_path = tmp_path / "bin/legendary.version"
|
||||
bundled = tmp_path / "bin/legendary"
|
||||
bundled.parent.mkdir()
|
||||
bundled.touch(mode=0o755)
|
||||
api.version_path.write_text("v1", encoding="utf-8")
|
||||
monkeypatch.setattr(api, "_get_latest_legendary_release", lambda: ({}, "v1"))
|
||||
|
||||
assert api.update_legendary() == str(bundled)
|
||||
|
||||
|
||||
def test_normalize_game_uses_epic_metadata() -> None:
|
||||
game = EGSAPI._normalize_game({
|
||||
"app_name": "TestGame",
|
||||
"app_title": "Test Game",
|
||||
"metadata": {
|
||||
"description": "Description",
|
||||
"namespace": "test-namespace",
|
||||
"keyImages": [{"type": "DieselGameBoxTall", "url": "cover.jpg"}],
|
||||
},
|
||||
})
|
||||
|
||||
assert game == {
|
||||
"app_id": "TestGame",
|
||||
"title": "Test Game",
|
||||
"description": "Description",
|
||||
"cover": "cover.jpg",
|
||||
"namespace": "test-namespace",
|
||||
"description_locale": "",
|
||||
"description_preference": "",
|
||||
"description_cache_version": 0,
|
||||
}
|
||||
|
||||
|
||||
def test_refresh_library_skips_mobile_only_games(
|
||||
tmp_path: Path, monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
api = EGSAPI()
|
||||
api.library_path = tmp_path / "library.json"
|
||||
games = [
|
||||
{
|
||||
"app_name": "MobileGame",
|
||||
"app_title": "Mobile Game",
|
||||
"metadata": {"releaseInfo": [{"platform": ["Android", "iOS"]}]},
|
||||
},
|
||||
{
|
||||
"app_name": "DesktopGame",
|
||||
"app_title": "Desktop Game",
|
||||
"metadata": {"releaseInfo": [{"platform": ["Windows"]}]},
|
||||
},
|
||||
]
|
||||
result = SimpleNamespace(returncode=0, stdout=orjson.dumps(games), stderr=b"")
|
||||
monkeypatch.setattr(api, "update_legendary", lambda: "/bin/legendary")
|
||||
monkeypatch.setattr(api, "build_command", lambda args: ["legendary", *args])
|
||||
monkeypatch.setattr("portprotonqt.egs_api.subprocess.run", lambda *args, **kwargs: result)
|
||||
monkeypatch.setattr(api, "_enrich_descriptions", lambda *_args: None)
|
||||
|
||||
library = api.refresh_library()
|
||||
|
||||
assert [game["app_id"] for game in library] == ["DesktopGame"]
|
||||
|
||||
|
||||
def test_store_description_uses_epic_product_page(monkeypatch: MonkeyPatch) -> None:
|
||||
api = EGSAPI()
|
||||
graphql_response = SimpleNamespace(
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {
|
||||
"data": {"Catalog": {"catalogNs": {
|
||||
"mappings": [{"pageSlug": "control"}],
|
||||
}}},
|
||||
},
|
||||
)
|
||||
store_response = SimpleNamespace(
|
||||
status_code=200,
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {"pages": [{
|
||||
"type": "productHome",
|
||||
"data": {"about": {"description": "Real description"}},
|
||||
}]},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.egs_api.requests.post",
|
||||
lambda *args, **kwargs: graphql_response,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.egs_api.requests.get",
|
||||
lambda *args, **kwargs: store_response,
|
||||
)
|
||||
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "en")
|
||||
|
||||
description = api._get_store_description({
|
||||
"app_id": "Calluna", "title": "Control", "namespace": "calluna",
|
||||
})
|
||||
|
||||
assert description == ("Real description", "en")
|
||||
|
||||
|
||||
def test_store_description_prefers_localized_short_text() -> None:
|
||||
data = {"pages": [{
|
||||
"type": "productHome",
|
||||
"data": {"about": {
|
||||
"shortDescription": "Краткое описание",
|
||||
"description": "Первый абзац\n\nВторой абзац",
|
||||
}},
|
||||
}]}
|
||||
|
||||
assert EGSAPI._parse_store_description(data) == "Краткое описание"
|
||||
|
||||
|
||||
def test_store_description_limits_fallback_to_first_paragraph() -> None:
|
||||
data = {"pages": [{
|
||||
"type": "productHome",
|
||||
"data": {"about": {"description": "Первый абзац\n\nВторой абзац"}},
|
||||
}]}
|
||||
|
||||
assert EGSAPI._parse_store_description(data) == "Первый абзац"
|
||||
|
||||
|
||||
def test_store_description_handles_missing_product_pages(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
api = EGSAPI()
|
||||
response = SimpleNamespace(
|
||||
status_code=200,
|
||||
raise_for_status=lambda: None,
|
||||
json=lambda: {"pages": None},
|
||||
)
|
||||
monkeypatch.setattr(api, "_get_product_slug", lambda _namespace, _title: "game")
|
||||
monkeypatch.setattr("portprotonqt.egs_api.requests.get", lambda *args, **kwargs: response)
|
||||
|
||||
description = api._get_store_description({
|
||||
"app_id": "Game", "title": "Game", "namespace": "game",
|
||||
})
|
||||
|
||||
assert description == ("", "")
|
||||
|
||||
|
||||
def test_content_languages_follow_epic_locale_conventions(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "pt")
|
||||
monkeypatch.setattr("portprotonqt.localization.get_system_locale", lambda: "pt_BR")
|
||||
|
||||
assert get_store_content_languages() == ("pt-BR", "pt", "en-US")
|
||||
|
||||
monkeypatch.setattr("portprotonqt.localization.get_metadata_language", lambda: "zh")
|
||||
monkeypatch.setattr("portprotonqt.localization.get_system_locale", lambda: "zh_Hant_TW")
|
||||
|
||||
assert get_store_content_languages() == ("zh-TW", "zh", "en-US")
|
||||
|
||||
|
||||
def test_extract_auth_code_accepts_legendary_response() -> None:
|
||||
response = '{"authorizationCode":"epic-authorization-code"}'
|
||||
|
||||
assert EGSAPI.extract_auth_code(response) == "epic-authorization-code"
|
||||
assert EGSAPI.extract_auth_code("direct-code") == "direct-code"
|
||||
assert EGSAPI.extract_auth_code("https://example.com") == ""
|
||||
|
||||
|
||||
def test_description_enrichment_reports_library_progress(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
api = EGSAPI()
|
||||
games = [
|
||||
{"app_id": "one", "title": "One", "namespace": "one"},
|
||||
{"app_id": "two", "title": "Two", "namespace": "two"},
|
||||
]
|
||||
progress = []
|
||||
monkeypatch.setattr(api, "load_library", lambda: [])
|
||||
monkeypatch.setattr(api, "_get_store_description", lambda _game: ("About", "en-US"))
|
||||
|
||||
api._enrich_descriptions(games, lambda completed, total: progress.append(
|
||||
(completed, total)
|
||||
))
|
||||
|
||||
assert progress == [(1, 2), (2, 2)]
|
||||
|
||||
|
||||
def test_get_launch_target_uses_legendary_install_record(tmp_path: Path) -> None:
|
||||
api = EGSAPI()
|
||||
api.config_dir = tmp_path / "legendary"
|
||||
game_dir = tmp_path / "game"
|
||||
executable = game_dir / "Binaries/Game.exe"
|
||||
executable.parent.mkdir(parents=True)
|
||||
executable.touch()
|
||||
api.config_dir.mkdir()
|
||||
(api.config_dir / "installed.json").write_bytes(orjson.dumps({
|
||||
"TestGame": {
|
||||
"install_path": str(game_dir),
|
||||
"executable": "Binaries\\Game.exe",
|
||||
},
|
||||
}))
|
||||
|
||||
assert api.get_launch_target("TestGame") == str(executable)
|
||||
|
||||
|
||||
def test_parse_download_sizes_reads_legendary_info() -> None:
|
||||
output = orjson.dumps({
|
||||
"manifest": {"download_size": 1024},
|
||||
"install": {"install_size": 2048},
|
||||
})
|
||||
|
||||
assert EGSAPI.parse_download_sizes(output) == (1024, 2048)
|
||||
|
||||
|
||||
def test_parse_download_sizes_uses_manifest_disk_size() -> None:
|
||||
output = orjson.dumps({"manifest": {"download_size": 1024, "disk_size": 4096}})
|
||||
|
||||
assert EGSAPI.parse_download_sizes(output) == (1024, 4096)
|
||||
|
||||
|
||||
def test_zero_cached_install_size_is_refetched(tmp_path: Path) -> None:
|
||||
api = EGSAPI()
|
||||
api.sizes_path = tmp_path / "sizes.json"
|
||||
api.sizes_path.write_bytes(orjson.dumps({"Game": [1024, 0]}))
|
||||
|
||||
assert api.get_cached_download_sizes("Game") is None
|
||||
|
||||
|
||||
def test_authenticate_passes_epic_code(tmp_path: Path, monkeypatch) -> None:
|
||||
api = EGSAPI()
|
||||
api.config_dir = tmp_path / "legendary"
|
||||
api.user_path = api.config_dir / "user.json"
|
||||
monkeypatch.setattr(api, "ensure_legendary", lambda: "/bin/legendary")
|
||||
monkeypatch.setattr(api, "build_command", lambda args: ["legendary", *args])
|
||||
|
||||
def run(command: list[str], **_kwargs: Any) -> SimpleNamespace:
|
||||
api.config_dir.mkdir(parents=True)
|
||||
api.user_path.write_text("{}", encoding="utf-8")
|
||||
assert command == ["legendary", "auth", "--code", "epic-code"]
|
||||
return SimpleNamespace(returncode=0, stderr=b"")
|
||||
|
||||
monkeypatch.setattr("portprotonqt.egs_api.subprocess.run", run)
|
||||
|
||||
assert api.authenticate("epic-code") == (True, "")
|
||||
@@ -3,9 +3,10 @@
|
||||
from inspect import signature
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from PySide6.QtCore import QEvent, QObject, QStringListModel, Qt
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QWidget
|
||||
from PySide6.QtWidgets import QApplication, QCheckBox, QComboBox, QDialog, QFrame, QHBoxLayout, QLineEdit, QListView, QMenu, QPushButton, QSlider, QStackedWidget, QTableWidget, QTableWidgetItem, QWidget
|
||||
from pytest import MonkeyPatch, raises
|
||||
|
||||
import portprotonqt.input_manager as input_manager
|
||||
@@ -15,6 +16,7 @@ import portprotonqt.input_manager.runtime as input_runtime
|
||||
import portprotonqt.native_gamepad as native_gamepad
|
||||
from portprotonqt.input_manager.constants import (
|
||||
GamepadType,
|
||||
KEY_LEFT,
|
||||
PAD_BUTTON_SOUTH,
|
||||
PAD_BUTTON_SELECT,
|
||||
SDL_GAMEPAD_TYPE_PS5,
|
||||
@@ -22,13 +24,26 @@ from portprotonqt.input_manager.constants import (
|
||||
SDL_GAMEPAD_BUTTON_DPAD_UP,
|
||||
)
|
||||
from portprotonqt.input_manager import InputManager, MainWindowProtocol, PAD_DPAD_X, PAD_DPAD_Y
|
||||
from portprotonqt.game_library_manager import FullLibraryTile
|
||||
from portprotonqt.native_gamepad import GamepadBackendError, SDLGamepad
|
||||
from portprotonqt.themes.standart.styles.constants import GAME_CARD_ANIMATION
|
||||
|
||||
FIRST_CARD_X = 0
|
||||
HIDDEN_CARD_X = 20
|
||||
NEXT_CARD_X = 40
|
||||
|
||||
|
||||
def _tile_theme() -> Any:
|
||||
return SimpleNamespace(
|
||||
fullLibraryTileSize=(180, 180),
|
||||
fullLibraryTileColumns=2,
|
||||
fullLibraryTileRows=2,
|
||||
fullLibraryTileRadius=10,
|
||||
GAME_CARD_HORIZONTAL={},
|
||||
GAME_CARD_ANIMATION=GAME_CARD_ANIMATION,
|
||||
)
|
||||
|
||||
|
||||
def test_dpad_first_repeat_uses_long_press_delay() -> None:
|
||||
parameters = signature(InputManager).parameters
|
||||
|
||||
@@ -192,8 +207,8 @@ def test_game_card_navigation_skips_hidden_cards(monkeypatch: MonkeyPatch) -> No
|
||||
next_card.show()
|
||||
app.processEvents()
|
||||
|
||||
monkeypatch.setattr(input_manager, "GameCard", DummyCard)
|
||||
monkeypatch.setattr(input_dpad, "GameCard", DummyCard)
|
||||
monkeypatch.setattr(input_manager, "AnimatedCard", DummyCard)
|
||||
monkeypatch.setattr(input_dpad, "AnimatedCard", DummyCard)
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(MainWindowProtocol, SimpleNamespace(tabButtons={0: QWidget()}))
|
||||
|
||||
@@ -236,6 +251,82 @@ def test_card_grid_navigation_uses_visual_rows() -> None:
|
||||
assert QApplication.focusWidget() is bottom_right
|
||||
|
||||
|
||||
def test_horizontal_library_navigation_wraps_at_both_ends() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
container = QWidget()
|
||||
layout = QHBoxLayout(container)
|
||||
cards: list[QWidget] = [QPushButton() for _index in range(3)]
|
||||
for card in cards:
|
||||
layout.addWidget(card)
|
||||
card.show()
|
||||
container.show()
|
||||
app.processEvents()
|
||||
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager._parent = cast(
|
||||
MainWindowProtocol,
|
||||
SimpleNamespace(
|
||||
game_library_manager=SimpleNamespace(
|
||||
layout_mode="horizontal",
|
||||
gamesListLayout=layout,
|
||||
)
|
||||
),
|
||||
)
|
||||
cards[-1].setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
|
||||
assert manager._navigate_horizontal_library(cards, PAD_DPAD_X, 1)
|
||||
assert QApplication.focusWidget() is cards[0]
|
||||
|
||||
assert manager._navigate_horizontal_library(cards, PAD_DPAD_X, -1)
|
||||
assert QApplication.focusWidget() is cards[-1]
|
||||
|
||||
tile = FullLibraryTile(_tile_theme())
|
||||
tile.setParent(container)
|
||||
layout.addWidget(tile)
|
||||
tile.show()
|
||||
manager._parent.game_library_manager.layout_mode = "horizontal_top"
|
||||
manager._parent.game_library_manager.fullLibraryTile = tile
|
||||
navigable_cards = [*cards, tile]
|
||||
assert manager._navigate_horizontal_library(navigable_cards, PAD_DPAD_X, 1)
|
||||
assert QApplication.focusWidget() is tile
|
||||
|
||||
assert manager._navigate_horizontal_library(navigable_cards, PAD_DPAD_X, -1)
|
||||
assert QApplication.focusWidget() is cards[-1]
|
||||
|
||||
assert manager._navigate_horizontal_library(navigable_cards, PAD_DPAD_X, 1)
|
||||
assert QApplication.focusWidget() is tile
|
||||
|
||||
assert manager._navigate_horizontal_library(navigable_cards, PAD_DPAD_X, 1)
|
||||
assert QApplication.focusWidget() is cards[0]
|
||||
|
||||
|
||||
def test_full_library_tile_arrow_does_not_switch_tabs() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
tile = FullLibraryTile(_tile_theme())
|
||||
tile.show()
|
||||
tile.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
app.processEvents()
|
||||
manager = InputManager.__new__(InputManager)
|
||||
manager.file_explorer = None
|
||||
switch_tab = MagicMock()
|
||||
manager._parent = cast(
|
||||
MainWindowProtocol,
|
||||
SimpleNamespace(switchTab=switch_tab),
|
||||
)
|
||||
|
||||
assert not manager._handle_input_tab_key(KEY_LEFT)
|
||||
switch_tab.assert_not_called()
|
||||
|
||||
|
||||
def test_backspace_uses_standard_back_navigation() -> None:
|
||||
manager = InputManager.__new__(InputManager)
|
||||
standard_back = MagicMock()
|
||||
cast(Any, manager)._handle_standard_back = standard_back
|
||||
|
||||
assert manager._handle_input_back_key(QWidget())
|
||||
standard_back.assert_called_once_with()
|
||||
|
||||
|
||||
def test_library_toolbar_navigation_includes_delete_missing_button() -> None:
|
||||
app = QApplication.instance() or QApplication([])
|
||||
toolbar = QWidget()
|
||||
@@ -782,8 +873,8 @@ def test_first_card_row_moves_focus_to_toolbar(monkeypatch: MonkeyPatch) -> None
|
||||
card.show()
|
||||
app.processEvents()
|
||||
card.setFocus(Qt.FocusReason.OtherFocusReason)
|
||||
monkeypatch.setattr(input_manager, "GameCard", DummyCard)
|
||||
monkeypatch.setattr(input_dpad, "GameCard", DummyCard)
|
||||
monkeypatch.setattr(input_manager, "AnimatedCard", DummyCard)
|
||||
monkeypatch.setattr(input_dpad, "AnimatedCard", DummyCard)
|
||||
parent = SimpleNamespace(
|
||||
stackedWidget=SimpleNamespace(currentIndex=lambda: 0),
|
||||
gamesListWidget=container,
|
||||
|
||||
+1076
-25
File diff suppressed because it is too large.
Load diff
@@ -6,11 +6,31 @@ from typing import Any, cast
|
||||
|
||||
from PySide6.QtCore import QMimeData, QUrl
|
||||
|
||||
from portprotonqt.dialogs.proton_manager import ProtonManager
|
||||
from portprotonqt.dialogs.proton_manager import ProtonManager, sort_wine_entries
|
||||
from portprotonqt.dialogs.wine_extractor import ExtractionThread
|
||||
from portprotonqt.tabs import library_tab
|
||||
|
||||
|
||||
def test_cachyos_releases_alternate_with_wineland() -> None:
|
||||
names = [
|
||||
"proton-cachyos-wineland-11.0-20260713.4-slr-x86_64",
|
||||
"proton-cachyos-wineland-11.0-20260713.4-slr-x86_64_v3",
|
||||
"proton-cachyos-wineland-11.0-20260713.3-slr-x86_64",
|
||||
"proton-cachyos-11.0-20260703-slr-x86_64",
|
||||
"proton-cachyos-11.0-20260703-slr-x86_64_v3",
|
||||
"proton-cachyos-11.0-20260702-slr-x86_64",
|
||||
]
|
||||
entries = [{"name": name} for name in names]
|
||||
|
||||
assert [entry["name"] for entry in sort_wine_entries(entries, "proton_cachyos")] == [
|
||||
"proton-cachyos-11.0-20260703-slr-x86_64",
|
||||
"proton-cachyos-11.0-20260703-slr-x86_64_v3",
|
||||
"proton-cachyos-wineland-11.0-20260713.4-slr-x86_64",
|
||||
"proton-cachyos-wineland-11.0-20260713.4-slr-x86_64_v3",
|
||||
"proton-cachyos-11.0-20260702-slr-x86_64",
|
||||
"proton-cachyos-wineland-11.0-20260713.3-slr-x86_64",
|
||||
]
|
||||
|
||||
def test_dropped_wine_archives_accepts_supported_local_files(tmp_path) -> None:
|
||||
wine_archive = tmp_path / "WINE_LG_11-10.tar.xz"
|
||||
wine_archive.touch()
|
||||
|
||||
@@ -31,6 +31,20 @@ from portprotonqt.dialogs.settings_vkbasalt import VkBasaltSettingsMixin
|
||||
_application = QApplication.instance() or QApplication([])
|
||||
|
||||
|
||||
def test_mangohud_search_ignores_unavailable_categories() -> None:
|
||||
settings = MangoHudSettingsMixin()
|
||||
settings.mangohud_category_groups = {}
|
||||
|
||||
settings._filter_mangohud_settings("fps")
|
||||
|
||||
|
||||
def test_gamescope_search_ignores_unavailable_categories() -> None:
|
||||
settings = GamescopeSettingsMixin()
|
||||
settings.gamescope_category_groups = {}
|
||||
|
||||
settings._filter_gamescope_settings("fullscreen")
|
||||
|
||||
|
||||
def test_vkbasalt_search_filters_and_moves_matching_shader_first() -> None:
|
||||
settings = VkBasaltSettingsMixin()
|
||||
settings.theme = SimpleNamespace(mangoHudSwitchesColumns=2)
|
||||
|
||||
@@ -289,6 +289,21 @@ class TestCreateDesktopFile:
|
||||
assert 'Exec=portprotonqt --silent "gog://launch/123"' in entry
|
||||
assert icon_path == "applications-games"
|
||||
|
||||
def test_egs_launch_uri_uses_common_desktop_entry(
|
||||
self, tmp_path: Path, monkeypatch: Any
|
||||
) -> None:
|
||||
_patch_location(monkeypatch, tmp_path)
|
||||
|
||||
result = create_desktop_file(
|
||||
"egs://launch/5b60142e120c4f2d88027595c21d4a04",
|
||||
game_name="DOOM 64",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
entry, _, icon_path = result
|
||||
assert 'Exec=portprotonqt --silent "egs://launch/5b60142e120c4f2d88027595c21d4a04"' in entry
|
||||
assert icon_path == "applications-games"
|
||||
|
||||
def test_gog_launch_uri_uses_executable_icon(
|
||||
self, tmp_path: Path, monkeypatch: Any
|
||||
) -> None:
|
||||
|
||||
@@ -10,6 +10,7 @@ from PySide6.QtSvg import QSvgRenderer
|
||||
from portprotonqt.theme_manager import (
|
||||
DMS_COLOR_ROLES,
|
||||
ThemeManager,
|
||||
ThemeWrapper,
|
||||
_get_parent_theme_name,
|
||||
_inject_ast_constants,
|
||||
_inject_parent_theme_constants,
|
||||
@@ -19,6 +20,42 @@ from portprotonqt.theme_manager import (
|
||||
)
|
||||
|
||||
|
||||
def test_theme_wrapper_loads_screenshots_lazily(monkeypatch) -> None:
|
||||
loaded_themes = []
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.theme_manager.load_theme_screenshots",
|
||||
lambda theme_name: loaded_themes.append(theme_name) or [],
|
||||
)
|
||||
wrapper = ThemeWrapper(types.ModuleType("preview_theme"))
|
||||
|
||||
assert loaded_themes == []
|
||||
assert wrapper.screenshots == []
|
||||
assert wrapper.screenshots == []
|
||||
assert loaded_themes == ["preview_theme"]
|
||||
|
||||
|
||||
def test_theme_manager_reuses_loaded_theme(monkeypatch) -> None:
|
||||
manager = ThemeManager()
|
||||
manager.current_theme_name = "previous"
|
||||
manager.current_theme_module = types.SimpleNamespace()
|
||||
manager._theme_module_cache = {}
|
||||
loaded_themes = []
|
||||
monkeypatch.setattr(
|
||||
"portprotonqt.theme_manager.load_theme",
|
||||
lambda name: loaded_themes.append(name) or types.SimpleNamespace(),
|
||||
)
|
||||
monkeypatch.setattr("portprotonqt.theme_manager.load_theme_fonts", lambda name: None)
|
||||
monkeypatch.setattr("portprotonqt.config.ui.UIConfig.set_theme", lambda self, name: None)
|
||||
monkeypatch.setattr("portprotonqt.sound_manager.SoundManager.set_sounds_dirs", lambda self, dirs: None)
|
||||
|
||||
first = manager.apply_theme("first")
|
||||
manager.apply_theme("second")
|
||||
repeated = manager.apply_theme("first")
|
||||
|
||||
assert repeated is first
|
||||
assert loaded_themes == ["first", "second"]
|
||||
|
||||
|
||||
# === load_dms_palette ===
|
||||
|
||||
|
||||
|
||||
@@ -122,6 +122,34 @@ def test_search_index_ignores_descriptions() -> None:
|
||||
assert search_index(optimizer, "копайте") == []
|
||||
|
||||
|
||||
def test_search_index_finds_all_names_containing_query() -> None:
|
||||
games = [("DOOM 64", ""), ("Akalabeth: World of Doom", "")]
|
||||
optimizer = SearchOptimizer()
|
||||
optimizer.build_indices(build_search_items(games, lambda item: item[0]))
|
||||
|
||||
assert search_index(optimizer, "doom") == games
|
||||
|
||||
|
||||
def test_search_index_keeps_same_name_from_different_sources() -> None:
|
||||
games = [("Control", "egs"), ("Control", "portproton")]
|
||||
optimizer = SearchOptimizer()
|
||||
optimizer.build_indices(build_search_items(games, lambda item: item[0]))
|
||||
|
||||
assert search_index(optimizer, "control") == games
|
||||
|
||||
|
||||
def test_is_portrait_image_rejects_oversized_png_metadata(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
cover = tmp_path / "logo.png"
|
||||
cover.touch()
|
||||
with patch(
|
||||
"portprotonqt.steam_api.utils.Image.open",
|
||||
side_effect=ValueError("Decompressed data too large"),
|
||||
):
|
||||
assert not _is_portrait_image(cover)
|
||||
|
||||
|
||||
def _write_appinfo(path: Path, apps: list[tuple[int, str, str]]) -> None:
|
||||
strings = ["appinfo", "common", "type", "oslist"]
|
||||
entries = bytearray()
|
||||
|
||||
@@ -145,53 +145,53 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dbus-fast"
|
||||
version = "5.0.4"
|
||||
version = "5.0.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b1/c7/490f9ca9da84d1d47e021585cfc6888519f5c12ae48db90cdb4ba631be93/dbus_fast-5.0.4.tar.gz", hash = "sha256:ef372607f967870780c2063e5d39037fdd27e253680aef76687a8dc6cd9413f3", size = 80559, upload-time = "2026-05-23T18:31:31.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/aa/db/b621610e50b1bc46ff63534d75239553c1bf33256de6096b58214fd9808a/dbus_fast-5.0.22.tar.gz", hash = "sha256:34dc67d7d21a12399828dd13e63b352750580beea54ea7c729e708f2d2905fef", size = 83224, upload-time = "2026-06-05T18:47:59.171Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/e7/35d25d8652ded1787c198d87a3771a24c71ca04d544365e77e0babd019ba/dbus_fast-5.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7339765555cc40586fa8ee25b5a9b51c6f397c420fd9d42833ee8aec42c86bc", size = 694169, upload-time = "2026-05-23T18:38:34.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/75/dfef781f2de8c5f97c4360cba5d8abbccf617628d2feae031642e003946d/dbus_fast-5.0.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8cffe3f31d9e0108c4f0cf59e104037630fddbcf410954d821259e0cd71acf8", size = 842006, upload-time = "2026-05-23T18:38:37.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/f8/39d2f6a72cd572ccf857ce7567db881113785655a5c25fd95cdf50d32121/dbus_fast-5.0.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f01e98f38215e1abbb320e0f6e0875c2ed3c17891eef3176fed2c0de8ffb7954", size = 885667, upload-time = "2026-05-23T18:38:38.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/20/73f8c0b2b17fab681680065a3a3853f992d26dbe00ab966241d114989aed/dbus_fast-5.0.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe8dd98f504af78f92ea4377839d1ca22dca9b1b7242e86a7655e866173b5df8", size = 884493, upload-time = "2026-05-23T18:38:41.528Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/75/d46e40d7308599a3c1df7a5b60f6e557f91cd2fbe56efc0ab2acf5a63236/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6f819880a12e31f489fa81beedea14c87dd14fcb92817e9b88d2527a8989ac9f", size = 848095, upload-time = "2026-05-23T18:38:43.468Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/b5/fa9f4399c78c237dbfdbe3e71eaccd1049e027fba1da312d3612533a96c2/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:bf8ff7ceb5cac93c0c6869f8dc2ce5a84200337503d66bf83a3b88a9346ea76e", size = 882021, upload-time = "2026-05-23T18:38:45.479Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/9d/321c26379acfbfb605a68550a404c829b3498f01563e86091e2408ab1c68/dbus_fast-5.0.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fe088a16d33054607021400043f3daeff566fc0cdd45e789d331d24e99f13e93", size = 891620, upload-time = "2026-05-23T18:38:47.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/17/9297c320ab6363cbb3c0f349565f78e7e1adc0e6fb356bc37dba16bcfd91/dbus_fast-5.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:20c2d83950f2a3b4536f51da0ad51dfa063ffc92dec084f80e9c7661582edeb9", size = 692498, upload-time = "2026-05-23T18:38:49.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/b6/c63d13b45bff8619e3479be45429459728870d697fe037367ef78bb208fd/dbus_fast-5.0.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aca56a03b2d46263c1a0b6483677dbf1fa84f6516db9e1c032b4d57ad757cfc2", size = 842099, upload-time = "2026-05-23T18:38:51.425Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/85/5f9162efc2c1e6f9a9ed6732d4e4778a62f851482fb847afc82726d97f6a/dbus_fast-5.0.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:864b659724fb011356d4a5439551e17e03fa8420e5e14b1ae7cf5baf541c61e4", size = 885967, upload-time = "2026-05-23T18:38:53.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c6/704494d8bf28e4b5126a43c21d600af13b3e075c1c53e122e4f95a832174/dbus_fast-5.0.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d00901d51ba4e5f2eb421f83fb6e4faf9466788fc7d7c4cfaf464bd97737bc52", size = 885377, upload-time = "2026-05-23T18:38:55.236Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/82/8dd56dad8a32050a74261ead6bf8536823f2bf35a95cd5e2d50b4e8e585e/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4d4a0f108555f94ce76c2d9be58a1a0d72f3881b83ce919e418ece575e24e88", size = 848562, upload-time = "2026-05-23T18:38:57.86Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/aa/b12b378005538f4355a8a376783fa7477a60adb111947a565edf12101675/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0a56f1b933d6f0333653b7968cb5b38ff27e42f50c60a35801f6d27a94cc1fb9", size = 882106, upload-time = "2026-05-23T18:38:59.994Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/12/61c28663229b2fa3a8833d74d086c8eb7bad57efa03d9b719a4a72b6f675/dbus_fast-5.0.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6e28e4d08361697afe0be0815d2e649bdf5bc96bf012db61b3e5c6aa991bd7c3", size = 892440, upload-time = "2026-05-23T18:39:02.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/d5/8a9dae63f6e68eafed65c2fc23aa1172bd840b93cb564f52035fe5bde409/dbus_fast-5.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:06fdd44811c938bf8d10bb8a5d1d09831e337eeff325b17ad7ef45640c2c88c2", size = 689936, upload-time = "2026-05-23T18:39:04.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/2a/67d7a011dfe5054bca33ff7faee6d60daae343e5d07e3b6f4182e5cbcecf/dbus_fast-5.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d51094e6a80a7bfa6e67c59826a5604c7824e166958e6a029d0020f98287c00e", size = 803359, upload-time = "2026-05-23T18:39:06.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/15/3ed2ab01ac5fd025378e6528ca4b3067333fcaa56f2f7338111fc879c702/dbus_fast-5.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1a3d8f0aa43d34f19588ccdfd12e332258ab6c234b419166fa938bbae8afd52", size = 855303, upload-time = "2026-05-23T18:39:08.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ca/88b86f5f065311eee071f30559538acd39b5dfb3b1298c4d7e599accbe8d/dbus_fast-5.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff5f30056c3a68944729b88686551b56b79ec50fe27657e4e172afef4856458", size = 836412, upload-time = "2026-05-23T18:39:09.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/74/13de0346943bed77e322479a6823293712d3525c953bb130476069a4f6da/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1582355810e2224736a008e277bab8780bd3e6a7bbf865dd9d78fe6cd4aab961", size = 810524, upload-time = "2026-05-23T18:39:12.109Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/58/515cafb4ecae652ac8d289f9e7e73fff26e3f4052751f014ccff0c761742/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:176ea1a9c736f65262d923f9f8303824f1d1029d80bbfbdefe37a80e8a50b242", size = 833967, upload-time = "2026-05-23T18:39:13.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/13/5f369f6f388a19b662785c8e156795dd8f4df08cb49a3e2220464b69d7ee/dbus_fast-5.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:17ff032d12dc4f3dbc3b29cd3756ce9094fd4cfc8f8984efa214072f680f70b1", size = 863709, upload-time = "2026-05-23T18:39:15.799Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/be/0e88b7c1263583fda6213ef3370e259d19735b2f7f6e8746b77b08fb0080/dbus_fast-5.0.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:00fe9b762c9974304e50f73abfd75210ae769207b78d8f3a97783d7281108200", size = 685726, upload-time = "2026-05-23T18:39:18.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/e5/609bbd7e8e59753236a06838b5d561498695c457f08883348a7b034399c0/dbus_fast-5.0.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:527edcecb06a6a1676c8e7eb0c167a234701715913c66431377d38da7acaa5b3", size = 800170, upload-time = "2026-05-23T18:39:19.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a1/9542cd0fbf86be24aa4c0865667cf8bf742df90c93b5063c3364d10f8dc3/dbus_fast-5.0.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:556ffba0fd56e9e872ed031dab579dab99c537c702e7c3d28f460c39e686ad8a", size = 850999, upload-time = "2026-05-23T18:39:21.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8e/aaf3ff0e66df530807af0381e7a9ec92a8ebd9783d54f00c1cbfaab35dc5/dbus_fast-5.0.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d7bb1465c2d06ddb6c38f9c7344c5a70beb09adfee8a00344322c7b2a90a5f4", size = 830440, upload-time = "2026-05-23T18:39:23.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/ca/1cbf6e7928134fa642857222c4a684f4ffd5cc0d16fca51416a659cdc9be/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ca2a8e94610705fae090aa1c318b810da9f9df47b990da9b6e06a8005a52fb91", size = 807074, upload-time = "2026-05-23T18:39:25.667Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/f2/a5f8e20fdef07a3afdf6e50fb98df5dc266c6ff43346dd66d71cde300dd8/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f4e47a01c1e196d0aac0654850911eade720e9e571f2e68ee363dfeb69699e4e", size = 830159, upload-time = "2026-05-23T18:39:27.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/d7/a2307c0b32c297728d325c05b9978caf7e9a1787d259d5172cfb90946b73/dbus_fast-5.0.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c3587f50a3089e5cc1a09b9a72cad964b88565f3d6dcfab3681981bca81a3fac", size = 859531, upload-time = "2026-05-23T18:39:29.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/33/1dabccf932cee9408f189306bd6efdfab8cc063fddebcb218fd36d991ce1/dbus_fast-5.0.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:aa519fd8536ea8d100347f15af8a9d1a2d209fa5d353dadb64dfa13ecf707b3c", size = 692866, upload-time = "2026-05-23T18:39:31.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/46/a56b19ef369e55fe723c8b33fcdd18b730d77442585bd325972d2c07780f/dbus_fast-5.0.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6139737eda772a9a5de13368862a15e486d61570be7d3a5e6bd64c7a23131d36", size = 814255, upload-time = "2026-05-23T18:39:33.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/f2/e0397cdfa0aefb5b4c44658bd6ba8ce532b997e81bff3d9714bbd36ee858/dbus_fast-5.0.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ce788791929923dbcb3eee889c61b8898eed41c77e334ac73bacd24d6e5c68a", size = 856800, upload-time = "2026-05-23T18:39:35.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/49/d11afde6602cefb2fdb43dd15a1f6b6f36a0e86cd046c99b4bafb071eb30/dbus_fast-5.0.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8e36da263ce03faf48c5f40b9cc33949a8bbb67db1ef09b7cdb89a8b807aaaf7", size = 836628, upload-time = "2026-05-23T18:39:37.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/d8/0d711a54e4523cad9a4b08753c7c49a32580fe90a2a0949109fa8cdccc1b/dbus_fast-5.0.4-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:2eeb8dc52aa377074133fd8cd15733e8de27c4fef62f9c66be90fe0f4822aaa3", size = 854900, upload-time = "2026-05-23T18:31:27.897Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/38/59d700c6540136740b102f66c94147dc707e917d8136e32fc7fcef649032/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d3d3bddcec3df4e213dce8de6e456e8bf4053a5e6dc9544fa13d2803c7ade3e8", size = 820912, upload-time = "2026-05-23T18:39:38.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/58/961d5d6d7c71347bfc6d6e6fdf4501b8773eab46bad02dc0f57d1005ab66/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e2c53a20bc6756d2d70b5a189b2adc76bb5d9856a1abd9564f160cd03f5d63bb", size = 837066, upload-time = "2026-05-23T18:39:40.754Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e6/49122a2b58ceca179627b2cae1c6f536336303f00cff2d6ad37c08ce0094/dbus_fast-5.0.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2b0f0d98fcabc838589eb05b29322d20378bd3c98999ace909ed63bed28a92e8", size = 864390, upload-time = "2026-05-23T18:39:43.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/9b/55aed4a5e2905c3767dfa92b052272fa3459e2a7c46b1a97ca968163c2e9/dbus_fast-5.0.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2142c9be443711c8e185de57ad19781f6ff231fa2f6ebbe9312734efd7a72762", size = 1356783, upload-time = "2026-05-23T18:39:45.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/8d/df4488525e22a8e843e12656f0718d760528f56709401af979c27a6c151c/dbus_fast-5.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a80a3114dc11fc463ef5b5562de6fa7fb5615acd488efa31a157dd83d510a247", size = 1545147, upload-time = "2026-05-23T18:39:46.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/75/c18803c88e85164ce60c28f59a7fdb7819901b5e5971bbc505bcd438d02d/dbus_fast-5.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:340d36759a18d8b67deeb6bbd60647e0294f92950778a02e753832840b93f899", size = 1620877, upload-time = "2026-05-23T18:39:48.703Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/5c/a8154e4ba68c822c96233302551c7280198fd56ad6709baacc804a51f7e8/dbus_fast-5.0.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbbd41b9a641fc47e92d923331750a28c3a5001947bc1271d2392247c8fba327", size = 829639, upload-time = "2026-05-23T18:39:50.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/6b/13ef6c4fddcd7beeb15e8425e8d90dd5f06d973e959397153d53e12ec385/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f00ef02cfdc2423b50756e32e90af8f1c3d63ad41f3af8a3d0f7cea9315e0005", size = 1560002, upload-time = "2026-05-23T18:39:52.571Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/54/010f9a9673ca7bf67a393486278a617b9930fead5c04563bf710bb6dc21d/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2ba075dc3c12af888ecae09226dbc2225c75b0e105666e1f0630f95d2ca6ab84", size = 831734, upload-time = "2026-05-23T18:39:54.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/ad/14b5ed792988db43b257b9c262e39b43d8b1fdb361b96da005a5cd3e5b48/dbus_fast-5.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:210ba9eef1f63d256111207f98c5e2264b946ff8db1bf3c82ab064c7d1fb2dae", size = 1639471, upload-time = "2026-05-23T18:39:57.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5c/fa3b060e06e0322ddd5c21a4f63e6c3e59a427a92db1553b930816d40e97/dbus_fast-5.0.22-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:027b358d55164f722d7432d6734506751c89b5cee8d516e7282eff3eaa763f05", size = 694683, upload-time = "2026-06-05T18:55:38.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/69/9235af850e2ee0cec23b4e863612492964a9c40b65f3002e4a9f32e1ffa2/dbus_fast-5.0.22-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8244561e503cffcd5e6b1ff6544a44aa0b65b6856f2f64d04345ebf82f8ac48", size = 840236, upload-time = "2026-06-05T18:55:41.007Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/d1/08ed17331dd23a8b91b80db9acca48301c4879352024dc7882e2c5893fa2/dbus_fast-5.0.22-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32869678240f233ece5a1137960c0337699dbb39acc02c155929f52543f03fb", size = 884309, upload-time = "2026-06-05T18:55:42.447Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/ed/02458af54460e55cf26fdf0a778aa9b3217ac8d37e6280aa68e19f176650/dbus_fast-5.0.22-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f74700b1b4fd58acb76f8790e2cd475084d0e59149e0cc035abd239435a339b7", size = 885179, upload-time = "2026-06-05T18:55:43.801Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/79/4958c807e59106558a4415212e6be019775f5be9775ffc79c6bc7248b388/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:246780dcda59d26dd2fb1d9a15991bc8483e9c241cee8713920a39a363ad4d39", size = 847693, upload-time = "2026-06-05T18:55:45.271Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/cc/a5322fbc172ee099f98186224cf7e3bf48fc8bcb08270951da8006b556ba/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:58e4d41f9fea22cfd6dacdf03d53b932bbe15a825af5abad3ca79b91493a15c3", size = 882773, upload-time = "2026-06-05T18:55:46.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/e7/e068899c1d9a034ed9d5fc8519b8e2247aa8c28a6a67229162db64163b59/dbus_fast-5.0.22-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d8ca39f3f2ab34f88bb343db87c7cf8b07eefa046b23d8c38b3387f150ac609e", size = 891573, upload-time = "2026-06-05T18:55:48.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/1d/f6020228675338b7184ce5491e4535fb64c7badc0197151b9776f345a1a1/dbus_fast-5.0.22-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f0926e4cf49989b4ec8233e8fd462eb35a640fcfe81bb75d91675dd47489022b", size = 693158, upload-time = "2026-06-05T18:55:49.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/84/dfb014de75a3a854dccaae1cce8f840e4312e3efc781768eedd60d25d9ef/dbus_fast-5.0.22-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:846f9a6602b4383f989201f7851459fb225a8912cd24b38e63894748545c3040", size = 838836, upload-time = "2026-06-05T18:55:51.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/c2/be41bcc678e97092d44ba22d09ce687f76c955b3367a7e6863377b1cfea5/dbus_fast-5.0.22-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:886b43446b6fdc3986befbbb88db1365b14e49dd0a7edf84c2c67ac66c7160a4", size = 883163, upload-time = "2026-06-05T18:55:53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/cf/336c08f88fdd813a39fc1603a10a15aec67115e08a895e7c9840df54d4d7/dbus_fast-5.0.22-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3a699fca957acc845ddb12b47f741dba23ce147fdb93583e0c7e7bad3e9b2355", size = 886852, upload-time = "2026-06-05T18:55:54.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/f1/7c1aa53f25252a2317f21ad6e8eaa125246d2585ea4611f3f10a6feaeeb1/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9a5b05fd4973862042e5bee2c5e8c5a15297e0b33a975bf25b44becf7bcb3618", size = 846497, upload-time = "2026-06-05T18:55:56.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/32/a981ef2305f1bf41e538e02fa0cd69614f9043fd00ca965bf3044416c79b/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6c4dae5292a7924ec062815c34b49043d8386cd22e165f9fb4012de00997cdf1", size = 882275, upload-time = "2026-06-05T18:55:57.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/a0/78800a172f4ca32e19a70a36e175f54831f33a497c9644f3a3fa4dce01ea/dbus_fast-5.0.22-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b7d90a52be79acbaef257f3a81d5b9b9dec40f1bad29429ac5c7802684fb9b84", size = 890566, upload-time = "2026-06-05T18:55:59.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/81/ffc155f700c45191673e7f7620a28cbbbf5f116ff74a99f765895baa6f9c/dbus_fast-5.0.22-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f72b77be63f7bb24cf42936ad10994d40f43fed691f857f7854b5882d6a5227c", size = 690171, upload-time = "2026-06-05T18:56:01.151Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/06/233b0bc13919474f70320bf389cb81ce02811956d6cf86c45e84679b63c3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f0bcad7f71d2304a68a5b0bc0d24c3fcc14710a2ffcf5f2a27521e3aece71ca", size = 799464, upload-time = "2026-06-05T18:56:02.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e9/77bc23a6f5aebfb8f2c34489795e8517aed7eca31738438e1a4c4a4891d3/dbus_fast-5.0.22-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ffcf16034f71a801bd2108aeffb6337d104c9459e8b1a218d16a917c8a2d2e9", size = 852687, upload-time = "2026-06-05T18:56:04.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/04/56769d0936d1273d1801ef574ec426ccb3f61f4b0a7a0eeb9eb2b8ccafa5/dbus_fast-5.0.22-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98de6d2c200d8182e1fd0bdde3206fa556b8fa14ebb752a044cd8daa87b4658c", size = 833814, upload-time = "2026-06-05T18:56:05.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/ca/964f0d39a3be03b12a98f39519d34ad95b74360c7e3adf4cd4907dc25fd6/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b013437b66dc22b8d9aca5e0b0d46bf1980208a143409469fe482d9684a2a717", size = 806891, upload-time = "2026-06-05T18:56:07.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/25/57fe6ab509ad9da2e190498fa9c37f868e38ac521d940a9edb9ba6b6c657/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:855f15b7f7805171da2b82de1c317d01cfbb9fb8ac61fcc1e8dec54d8c69fab7", size = 830867, upload-time = "2026-06-05T18:56:09.473Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2b/da036e9f4aeb776833139575fe0774544aa6cd13ba997eea7fbc4ab99852/dbus_fast-5.0.22-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7d1c42963235cfc015a2d2b8c5fe42b65387493b4ad4ce0ec122601c805e6742", size = 860651, upload-time = "2026-06-05T18:56:10.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/1d/ebd02ae707328286c24582ac9189e4ed9c344399bc3b6a4b74e9687088eb/dbus_fast-5.0.22-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:26e26b409e1e6edf5e2a4df8d192625fb38876b074fb5c7d0a5b15c5792e549d", size = 685921, upload-time = "2026-06-05T18:56:12.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/5a/fa81a6685c763ea488ad93228cb6e036adc9af6a560f4c31643691f4cfd8/dbus_fast-5.0.22-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de10ff3b3cb2acb1c09fe17158a470519000d37bb5ee5fd69c4075e81ce8dcf5", size = 798472, upload-time = "2026-06-05T18:56:14.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/34/6b272e6df60be1aa4d575aa30220175a52c002a649c951d9950bfa3a72d6/dbus_fast-5.0.22-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:979761985fe343c701f2b7575285d6e370123f7231d4656209ef7824bb686bbb", size = 850312, upload-time = "2026-06-05T18:56:16.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/5c/9045c3595ddcd4069e6b5d051df06bf11a2b022592f721c9acea1e0e4d22/dbus_fast-5.0.22-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fb73f1d8374253b7c17d69e902cf2ded1bfb089cb6ae67c10b4e0bdfe1b8fe08", size = 828366, upload-time = "2026-06-05T18:56:17.786Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/78/ca6881442b8fa29edbe6d99bec4b535b0b2e2f423075d015ff5b719c4e2c/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b67a02037eb58bcf9e445df60ea0d9d7346fd334abde3aa62e03c75823b53979", size = 806036, upload-time = "2026-06-05T18:56:19.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c6/458728eb1caa26171e6a8ae1d0d99bd29aaeac67ad7824bbd95d7f854a41/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:83940ea00d7ee2f0c5bcb5d19d7d05e7949e52d467616a0b735d72e7285402ec", size = 828353, upload-time = "2026-06-05T18:56:21.346Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/1d/830b1569264780210d44898e5b0d95cffe2830b952c2ee21ea481274cd81/dbus_fast-5.0.22-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:279d212e9fb262d595af2e4b5b9e951bc00c73a5c8eeb50f158caa13705b9c84", size = 857743, upload-time = "2026-06-05T18:56:22.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/b6/171f8e92775254e2f42afe9e7501a57ace47f32de2969cb694a375dc9dce/dbus_fast-5.0.22-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:511655d915692f55b8c5f5a535acf80cca9c6d1a35384db7fcfdabaae05dd837", size = 692986, upload-time = "2026-06-05T18:56:24.661Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/8c/4eefaabdf538882528164060ae83d9a34f1172b019c32c3254436834e9b1/dbus_fast-5.0.22-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:703e0f8f9af52e8e053394ee2b578042be0c3d8ea2b1488f9db8cb14393cc13f", size = 810835, upload-time = "2026-06-05T18:56:26.356Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/cf/fd327dbb40ee67a9331fb587bf78aff2ab1500b35979978a5cacb10d7f8c/dbus_fast-5.0.22-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb1d7e8e65561d0fd438004fd9e0f981c8a862912fed58dd4e29db1936c39d73", size = 855498, upload-time = "2026-06-05T18:56:28.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/33/1709ebc16a4d353ddc4fcd29252e2b9d93bded6422a45fd6df170e0911c1/dbus_fast-5.0.22-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:959fab6420897ab99410e67d6f9f9a7f6f4cedb6014700768f5e2d71dbff5dc6", size = 833510, upload-time = "2026-06-05T18:56:29.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/fd/89d7c34152900d986b9c78e39cc62aa73eefc22b57b3a8c946d945a85540/dbus_fast-5.0.22-cp314-cp314-manylinux_2_41_x86_64.whl", hash = "sha256:eb31c5ff339a7071b914617a69d5b7c6ba7d411da4b01a5f9b5b2fe51e9d1301", size = 853669, upload-time = "2026-06-05T18:47:56.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/a1/031cc4a89d947f1fe110f663f93dcce9230213b7accaf719790d813def04/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:856f0543c593f3480e93e67bcd1aa4ddc1d94a6076cfd3ad4e0f5e2b01b33dc3", size = 818486, upload-time = "2026-06-05T18:56:31.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/e2/de8b764fdb947314fb8c2e079b556510194fd100983776845e234a107cc9/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:96d231d128c1f46f263790335897195dde9dac2f38571782db8ae1d8647bd548", size = 833582, upload-time = "2026-06-05T18:56:33.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/1f/e5f0dd28d07c4b3f7bafd3357bfa424c8dace355a3dad921fec05db4634b/dbus_fast-5.0.22-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:595bd3ccfd8318cbafff79f33a15709fee3728724fd61d5fa220080d73b574cb", size = 862291, upload-time = "2026-06-05T18:56:35.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/dd/61086156a1c2d8ffd04d61a232debbaff8ca9fc1cf598476999e1f06164d/dbus_fast-5.0.22-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:69a077c296eaab8c30160e861b5514c33d99d67d41d17dbf02e89aae44543b11", size = 1353993, upload-time = "2026-06-05T18:56:36.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/69/5b54654f598ef98e8f94fd5a40929668b1f8fcd76e7fb50de0db73d329da/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04bac97d0cb754a4d13037d0132517f1df28192d6e0568a0bf6df06623062285", size = 1534804, upload-time = "2026-06-05T18:56:38.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/b7/c00d01699dc87ffc35f143226d3b296372840e2e2bc15101d35df7c74949/dbus_fast-5.0.22-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3eb57d592d84b0bb90e0c077db7ecb61562f49cc9b86a3ef08cbe17243e9cc4f", size = 1613316, upload-time = "2026-06-05T18:56:40.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/94/ea0db4c1aa6409cb16551b50aa8573e72f64407ca5281b042919ef81ca1c/dbus_fast-5.0.22-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:de4d235d1282ebb3ab65b6cddab84e914c045d92ceb381ddcbdbaf66bf1fb132", size = 822053, upload-time = "2026-06-05T18:56:42.519Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e4/a3bb52185b8a8c76bd8aaba3ff4fa8395eea19fbc142122b43dc377b275c/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:048f34299fbe82d7b87c56f47e8bd83f62339a4517685abc6671d603a55d2c89", size = 1549996, upload-time = "2026-06-05T18:56:44.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/2b/6e405ba92e87d78a689a387809d975f97f8c8748b98efccfacd2b4e1d9f5/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:92df9fb6d8adeb17b534621c2ee730295bbe1d0c2584d5c82b1db478e3f04e8f", size = 823004, upload-time = "2026-06-05T18:56:46.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/8f/77135ab8d690030cdb0ebeca879640b5945c4cbf5344ecbc507b4628da24/dbus_fast-5.0.22-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7be4271e38251f1ad726962dec60da887c8ed352d157352e4fc27f56aece5c5d", size = 1629160, upload-time = "2026-06-05T18:56:47.688Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -277,83 +277,74 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "orjson"
|
||||
version = "3.11.9"
|
||||
version = "3.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/0c/964746fcafbd16f8ff53219ad9f6b412b34f345c75f384ad434ceaadb538/orjson-3.11.9.tar.gz", hash = "sha256:4fef17e1f8722c11587a6ef18e35902450221da0028e65dbaaa543619e68e48f", size = 5599163, upload-time = "2026-05-06T15:11:08.309Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/742fb1f62b825f2c010697eaf4e828004bc2a81e7e806666989c132c7c42/orjson-3.12.0.tar.gz", hash = "sha256:d14203fb1aae2ad9b3d52f8a0e82aeb10197ef1c9bc61da7f358bd70b00123d5", size = 4142915, upload-time = "2026-08-14T16:13:30.607Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/5d/b95ca542a001135cc250a49370f282f578c8f4e46cc8617d73775297eea8/orjson-3.11.9-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:135869ef917b8704ea0a94e01620e0c05021c15c52036e4663baffe75e72f8ce", size = 228986, upload-time = "2026-05-06T15:09:14.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/01/be33fbff646e22f93398429ea645f20d2097aea1a6cdc1e6628e70125f83/orjson-3.11.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:115ab5f5f4a0f203cc2a5f0fb09aee503a3f771aa08392949ab5ca230c4fbdbd", size = 132558, upload-time = "2026-05-06T15:09:17.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/61/73d49333bba660a075daccca10970dc6409ce1cf42ae4046646a19468aad/orjson-3.11.9-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4da3c38a2083ca4aaf9c2a36776cce3e9328e6647b10d118948f3cfb4913ffe4", size = 128213, upload-time = "2026-05-06T15:09:18.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/7d/30e844b3dac3f74aed66b1f984daf9db3c98c0328c03d965a9e8dc06449e/orjson-3.11.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:53b50b0e14084b8f7e29c5ce84c5af0f1160169b30d8a6914231d97d2fe297d4", size = 135430, upload-time = "2026-05-06T15:09:20.257Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/64/bd815f5c610b3facc204f26ba94e87a9eb49b0d83de3d5fc1eee2402d91b/orjson-3.11.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:231742b4a11dad8d5380a435962c57e91b7c37b79be858f4ef1c0df1a259897e", size = 146178, upload-time = "2026-05-06T15:09:21.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/35/e744fd36c79b339d27beb06068b5a08a8882ef5418804d0ce545a31f718d/orjson-3.11.9-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:34fd2317602587321faab75ab76c623a0117e80841a6413654f04e47f339a8fb", size = 133068, upload-time = "2026-05-06T15:09:23.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/56/d54152b67b63a0b3e556cfc549d6ce84f74d7f425ddeadc6c8a74d913da7/orjson-3.11.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71f3db16e69b667b132e0f305a833d5497da302d801508cbb051ed9a9819da47", size = 134217, upload-time = "2026-05-06T15:09:24.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ee/66154baf69f71c7164a268a5e888908aec5a0819d13c81d5e2755a257758/orjson-3.11.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0b34789fa0da61cf7bef0546b09c738fb195331e017e477096d129e9105ab03d", size = 141917, upload-time = "2026-05-06T15:09:26.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/d3/c5824260ca8b9d7ba82648d042a3f8f4815d18c15bb98a1f30edd1bb2d83/orjson-3.11.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:87e4d4ab280b0c87424d47695bec2182caf8cfc17879ea78dab76680194abc13", size = 415356, upload-time = "2026-05-06T15:09:28.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/cb/509c2e816fe4df641d93dc92f6a89adc8df3ada8ebdee2bd44aba3264c3c/orjson-3.11.9-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ace6c58523302d3b97b6ac5c38a5298a54b473762b6be82726b4265c41029f92", size = 148112, upload-time = "2026-05-06T15:09:29.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/b5/3ceae56d2e4962979eedb023ba6a46a4bb65f333960379be0ca470686220/orjson-3.11.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:97d0d932803c1b164fde11cb542a9efcb1e0f63b184537cca65887147906ff48", size = 137112, upload-time = "2026-05-06T15:09:31.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7a/81fa3f2c7bef79b04cf2ab7838e5ac74b1f12511ceab979759b0275d6bb4/orjson-3.11.9-cp310-cp310-win32.whl", hash = "sha256:b3afcf569c15577a9fe64627292daa3e6b3a70f4fb77a5df246a87ec21681b94", size = 131706, upload-time = "2026-05-06T15:09:32.707Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/d8/b64600f9083c7f151ad39717a5877fccbeb0ef6d7efcb55f971ce00b6bee/orjson-3.11.9-cp310-cp310-win_amd64.whl", hash = "sha256:8697ab6a080a5c46edaad50e2bc5bd8c7ca5c66442d24104fa44ec74910a8244", size = 127282, upload-time = "2026-05-06T15:09:33.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/51/3fb9e65ae76ee97bd611869a503fa3fc0a6e81dd8b737cf3003f682df7ff/orjson-3.11.9-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:f01c4818b3fc9b0da8e096722a84318071eaa118df35f6ed2344da0e73a5444f", size = 228522, upload-time = "2026-05-06T15:09:35.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/fa/9d54b07cb3f3b0bfd57841478e42d7a0ece4a9f49f9907eecf5a45461687/orjson-3.11.9-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:3ebca4179031ee716ed076ffadc29428e900512f6fccee8614c9983157fcf19c", size = 128463, upload-time = "2026-05-06T15:09:37.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/b1/6ceafc2eefd0a553e3be77ce6c49d107e772485d9568629376171c50e634/orjson-3.11.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:48ee05097750de0ff69ed5b7bbcf0732182fd57a24043dcc2a1da780a5ead3a5", size = 132306, upload-time = "2026-05-06T15:09:38.299Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/76/f11311285324a40aab1e3031385c50b635a7cd0734fdaf60c7e89a696f60/orjson-3.11.9-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6082706765a95a6680d812e1daf1c0cfe8adec7831b3ff3b625693f3b461b1c", size = 127988, upload-time = "2026-05-06T15:09:39.597Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/85/0ef63bcf1337f44031ce9b91b1919563f62a37527b3ea4368bb15a22e5d7/orjson-3.11.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:277fefe9d76ee17eb14debf399e3533d4d63b5f677a4d3719eb763536af1f4bd", size = 135188, upload-time = "2026-05-06T15:09:40.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/94/b0d27090ea8a2095db3c2bd1b1c96f96f19bbb494d7fef33130e846e613d/orjson-3.11.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:03db380e3780fa0015ed776a90f20e8e20bb11dde13b216ce19e5718e3dfba62", size = 145937, upload-time = "2026-05-06T15:09:42.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/eb/75d50c29c05b8054013e221e598820a365c8e64065312e75e202ed880709/orjson-3.11.9-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33d7d766701847dc6729846362dc27895d2f2d2251264f9d10e7cb9878194877", size = 132758, upload-time = "2026-05-06T15:09:43.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/360686f39348aa88827cb6fbf7dc606fd41c831a35235e1abf1db8e3a9e6/orjson-3.11.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:147302878da387104b66bb4a8b0227d1d487e976ce41a8501916161072ed87b1", size = 133971, upload-time = "2026-05-06T15:09:45.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/30/3178eb16f3221aeef068b6f1f1ebe05f656ea5c6dffe9f6c917329fe17a3/orjson-3.11.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3513550321f8c8c811a7c3297b8a630e82dc08e4c10216d07703c997776236cd", size = 141685, upload-time = "2026-05-06T15:09:46.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f1/ff2f19ed0225f9680fafa42febca3570dd59444ebf190980738d376214c2/orjson-3.11.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:c5d001196b89fa9cf0a4ab79766cd835b991a166e4b621ba95089edc50c429ff", size = 415167, upload-time = "2026-05-06T15:09:48.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/61/863bddf0da6e9e586765414debd54b4e58db05f560902b6d00658cb88636/orjson-3.11.9-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:16969c9d369c98eb084889c6e4d2d39b77c7eb38ceccf8da2a9fff62ae908980", size = 147913, upload-time = "2026-05-06T15:09:49.733Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/8a/4081492586d75b073d60c5271a8d0f05a0955cabf1e34c8473f6fcd84235/orjson-3.11.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:63e0efbc991250c0b3143488fa57d95affcabbfc63c99c48d625dd37779aafe2", size = 136959, upload-time = "2026-05-06T15:09:51.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/bd/70b6ab193594d7abb875320c0a7c8335e846f28968c432c31042409c3c8d/orjson-3.11.9-cp311-cp311-win32.whl", hash = "sha256:14ed654580c1ed2bc217352ec82f91b047aef82951aa71c7f64e0dcb03c0e180", size = 131533, upload-time = "2026-05-06T15:09:52.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/17/1a1a228183d62d1b77e2c30d210f47dd4768b310ebe1607c63e3c0e3a71e/orjson-3.11.9-cp311-cp311-win_amd64.whl", hash = "sha256:57ea77fb70a448ce87d18fca050193202a3da5e54598f6501ca5476fb66cfe02", size = 127106, upload-time = "2026-05-06T15:09:54.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/95/285de5fa296d09681ee9c546cd4a8aeb773b701cf343dc125994f4d52953/orjson-3.11.9-cp311-cp311-win_arm64.whl", hash = "sha256:19b72ed11572a2ee51a67a903afbe5af504f84ed6f529c0fe44b0ab3fb5cc697", size = 126848, upload-time = "2026-05-06T15:09:55.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6d/11867a3ffa3a3608d84a4de51ef4dd0896d6b5cc9132fbe1daf593e677bc/orjson-3.11.9-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9ef6fe90aadef185c7b128859f40beb24720b4ecea95379fc9000931179c3a49", size = 228515, upload-time = "2026-05-06T15:09:57.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/75/05912954c8b288f34fcf5cd4b9b071cb4f6e77b9961e175e56ebb258089f/orjson-3.11.9-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:e5c9b8f28e726e97d97696c826bc7bea5d71cecd63576dba92924a32c1961291", size = 128409, upload-time = "2026-05-06T15:09:59.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/86/1c3a47df3bc8191ea9ac51603bbb872a95167a364320c269f2557911f406/orjson-3.11.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:26a473dbb4162108b27901492546f83c76fdcea3d0eadff00ae7a07e18dcce09", size = 132106, upload-time = "2026-05-06T15:10:00.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/cf/b33b5f3e695ae7d63feef9d915c37cc3b8f465493dcd4f8e0b4c697a2366/orjson-3.11.9-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:011382e2a60fda9d46f1cdee31068cfc52ffe952b587d683ec0463002802a0f4", size = 127864, upload-time = "2026-05-06T15:10:02.15Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/6a/6cf69385a58208024fcb8c014e2141b8ce838aba6492b589f8acfff97fab/orjson-3.11.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2d3dc759490128c5c1711a53eeaa8ee1d437fd0038ffd2b6008abf46db3f882", size = 135213, upload-time = "2026-05-06T15:10:03.515Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/f8/0b1bd3e8f2efcdd376af5c8cfd79eaf13f018080c0089c80ebd724e3c7fb/orjson-3.11.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d8ea516b3726d190e1b4297e6f4e7a8650347ae053868a18163b4dd3641d1fff", size = 145994, upload-time = "2026-05-06T15:10:05.083Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/59/dab79f61044c529d2c81aecdc589b1f833a1c8dec11ba3b1c2498a02ca7e/orjson-3.11.9-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:380cdce7ba24989af81d0a7013d0aaec5d0e2a21734c0e2681b1bc4f141957fe", size = 132744, upload-time = "2026-05-06T15:10:06.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/a4/82b7a2fe5d8a67a59ed831b24d59a3d46ea7d207b66e1602d376541d94a6/orjson-3.11.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:be4fa4f0af7fa18951f7ab3fc2148e223af211bf03f59e1c6034ec3f97f21d61", size = 134014, upload-time = "2026-05-06T15:10:08.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/c7/375e83a76851b73b2e39f3bcf0e5a19e2b89bad13e5bca97d0b293d27f24/orjson-3.11.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a8f5f8bc7ce7d59f08d9f99fa510c06496164a24cb5f3d34537dbd9ca30132e2", size = 141509, upload-time = "2026-05-06T15:10:09.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/7c/49d5d82a3d3097f641f094f552131f1e2723b0b8cb0fa2874ab65ecfffa6/orjson-3.11.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:4d7fde5501b944f83b3e665e1b31343ff6e154b15560a16b7130ea1e594a4206", size = 415127, upload-time = "2026-05-06T15:10:11.049Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/dc/7446c538590d55f455647e5f3c61fc33f7108714e7afcffa6a2a033f8350/orjson-3.11.9-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cde1a448023ba7d5bb4c01c5afb48894380b5e4956e0627266526587ef4e535f", size = 148025, upload-time = "2026-05-06T15:10:12.842Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/e5/4d2d8af06f788329b4f78f8cc3679bb395392fcaa1e4d8d3c33e85308fa4/orjson-3.11.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:71e63adb0e1f1ed5d9e168f50a91ceb93ae6420731d222dc7da5c69409aa47aa", size = 136943, upload-time = "2026-05-06T15:10:14.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/69/850264ccf6d80f6b174620d30a87f65c9b1490aba33fe6b62798e618cad3/orjson-3.11.9-cp312-cp312-win32.whl", hash = "sha256:2d057a602cdd19a0ad680417527c45b6961a095081c0f46fe0e03e304aac6470", size = 131606, upload-time = "2026-05-06T15:10:15.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/d5/973a43fc9c55e20f2051e9830997649f669be0cb3ca52192087c0143f118/orjson-3.11.9-cp312-cp312-win_amd64.whl", hash = "sha256:59e403b1cc5a676da8eaf31f6254801b7341b3e29efa85f92b48d272637e77be", size = 127101, upload-time = "2026-05-06T15:10:17.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/ae/495470f0e4a18f73fa10b7f6b84b464ec4cc5291c4e0c7c2a6c400bef006/orjson-3.11.9-cp312-cp312-win_arm64.whl", hash = "sha256:9af678d6488357948f1f84c6cd1c1d397c014e1ae2f98ae082a44eb48f602624", size = 126736, upload-time = "2026-05-06T15:10:18.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/33/93fcc25907235c344ae73122f8a4e01d2d393ef062b4af7d2e2487a32c37/orjson-3.11.9-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:4bab1b2d6141fe7b32ae71dac905666ece4f94936efbfb13d55bb7739a3a6021", size = 228458, upload-time = "2026-05-06T15:10:20.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/27/b1e6dadb3c080313c03fdd8067b85e6a0460c7d8d6a1c3984ef77b904e4d/orjson-3.11.9-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:844417969855fc7a41be124aafe83dc424592a7f77cd4501900c67307122b92c", size = 128368, upload-time = "2026-05-06T15:10:21.549Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/0f/c9ede0bf052f6b4051e64a7d4fa91b725cccf8321a6a786e86eb03519f00/orjson-3.11.9-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffe02797b5e9f3a9d8292ddcd289b474ad13e81ad83cd1891a240811f1d2cb81", size = 132070, upload-time = "2026-05-06T15:10:23.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/26/d398e28048dc18205bbe812f2c88cb9b40313db2470778e25964796458fe/orjson-3.11.9-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e4eed3b200023042814d2fc8a5d2e880f13b52e1ed2485e83da4f3962f7dc1a", size = 127892, upload-time = "2026-05-06T15:10:24.714Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/60/52b0054c4c700d5aa7fc5b7ca96917400d8f061307778578e67a10e25852/orjson-3.11.9-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8aff7da9952a5ad1cef8e68017724d96c7b9a66e99e91d6252e1b133d67a7b10", size = 135217, upload-time = "2026-05-06T15:10:26.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/97/1e3dc2b2a28b7b2528f403d2fc1d79ec5f39af3bc143ab65d3ec26426385/orjson-3.11.9-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4d4e98d6f3b8afed8bc8cd9718ec0cdf46661826beefb53fe8eafb37f2bf0362", size = 145980, upload-time = "2026-05-06T15:10:28.062Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/39/31fbfe7850f2de32dee7e7e5c09f26d403ab01e440ac96001c6b01ad3c99/orjson-3.11.9-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a81d52442a7c99b3662333235b3adf96a1715864658b35bb797212be7bddb97", size = 132738, upload-time = "2026-05-06T15:10:29.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/08/dca0082dd2a194acb93e5457e73455388e2e2ca464a2672449a9ddbb679d/orjson-3.11.9-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e39364e726a8fff737309aff059ff67d8a8c8d5b677be7bb49a8b3e84b7e218", size = 134033, upload-time = "2026-05-06T15:10:31.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/d4/5bdb0626801230139987385554c5d4c42255218ac906525bf4347f22cd95/orjson-3.11.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4fd66214623f1b17501df9f0543bef0b833979ab5b6ded1e1d123222866aa8c9", size = 141492, upload-time = "2026-05-06T15:10:32.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/88/a21fb53b3ede6703aede6dce4710ed4111e5b201cfa6bbff5e544f9d47d7/orjson-3.11.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:8ecc30f10465fa1e0ce13fd01d9e22c316e5053a719a8d915d4545a09a5ff677", size = 415087, upload-time = "2026-05-06T15:10:34.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/57/1b30daf70f0d8180e9a73cefbfbdd99e4bf19eb020466502b01fba7e0e50/orjson-3.11.9-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:97db4c94a7db398a5bd636273324f0b3fd58b350bbbac8bb380ceb825a9b40f4", size = 148031, upload-time = "2026-05-06T15:10:36.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/83/45fbb6d962e260807f99441db9613cee868ceda4baceda59b3720a563f97/orjson-3.11.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9f78cf8fec5bd627f4082b8dfeac7871b43d7f3274904492a43dab39f18a19a0", size = 136915, upload-time = "2026-05-06T15:10:38.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/cc/2d10025f9056d376e4127ec05a5808b218d46f035fdc08178a5411b34250/orjson-3.11.9-cp313-cp313-win32.whl", hash = "sha256:d4087e5c0209a0a8efe4de3303c234b9c44d1174161dcd851e8eea07c7560b32", size = 131613, upload-time = "2026-05-06T15:10:39.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/bd/2775ff28bfe883b9aa1ff348300542eb2ef1ee18d8ae0e3a49846817a865/orjson-3.11.9-cp313-cp313-win_amd64.whl", hash = "sha256:051b102c93b4f634e89f3866b07b9a9a98915ada541f4ec30f177067b2694979", size = 127086, upload-time = "2026-05-06T15:10:41.262Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/2b/d26799e580939e32a7da9a39531bc9e58e15ca32ffaa6a8cb3e9bb0d22cd/orjson-3.11.9-cp313-cp313-win_arm64.whl", hash = "sha256:cce9127885941bd28f080cecf1f1d288336b7e0d812c345b08be88b572796254", size = 126696, upload-time = "2026-05-06T15:10:42.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/eb/5da01e356015aee6ecfa1187ced87aef51364e306f5e695dd52719bf0e78/orjson-3.11.9-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:b6ef1979adc4bc243523f1a2ba91418030a8e29b0a99cbe7e0e2d6807d4dce6e", size = 228465, upload-time = "2026-05-06T15:10:44.097Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/62/3e0e0c14c957133bcd855395c62b55ed4e3b0af23ffea11b032cb1dcbdb1/orjson-3.11.9-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:f36b7f32c7c0db4a719f1fc5824db4a9c6f8bd1a354debb91faf26ebf3a4c71e", size = 128364, upload-time = "2026-05-06T15:10:45.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/5a/07d8aa117211a8ed7630bda80c8c0b14d04e0f8dcf99bcf49656e4a710eb/orjson-3.11.9-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08f4d8ebb44925c794e535b2bebc507cebf32209df81de22ae285fb0d8d66de0", size = 132063, upload-time = "2026-05-06T15:10:47.267Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/ec/4acaf21483e18aa945be74a474c74b434f284b549f275a0a39b9f98956e9/orjson-3.11.9-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6cc7923789694fd58f001cbcac7e47abc13af4d560ebbfcf3b41a8b1a0748124", size = 122356, upload-time = "2026-05-06T15:10:48.765Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d8/5f0555e7638801323b7a75850f92e7dfa891bc84fe27a1ba4449170d1200/orjson-3.11.9-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea5c46eb2d3af39e806b986f4b09d5c2706a1f5afde3cbf7544ce6616127173c", size = 129592, upload-time = "2026-05-06T15:10:50.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/30/ed9860412a3603ceb3c5955bfd72d28b9d0e7ba6ed81add14f83d7114236/orjson-3.11.9-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5d89a2ed90731df3be64bab0aa44f78bff39fdc9d71c291f4a8023aa46425b7", size = 140491, upload-time = "2026-05-06T15:10:51.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/17/adc514dea7ac7c505527febf884934b815d34f0c7b8693c1a8b39c5c4a57/orjson-3.11.9-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:25e4aed0312d292c09f61af25bba34e0b2c88546041472b09088c39a4d828af1", size = 127309, upload-time = "2026-05-06T15:10:53.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/3e/c0b690253f0b82d86e99949af13533363acfb5432ecb5d53dd5b3bce9c34/orjson-3.11.9-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aaea64f3f467d22e70eeed68bdccb3bc4f83f650446c4a03c59f2cba28a108db", size = 134030, upload-time = "2026-05-06T15:10:54.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/7a/bc82a0bb25e9faaf92dc4d9ef002732efc09737706af83e346788641d4a7/orjson-3.11.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a028425d1b440c5d92a6be1e1a020739dfe67ea87d96c6dbe828c1b30041728b", size = 141482, upload-time = "2026-05-06T15:10:56.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/55/e69188b939f77d5d32a9833745ace31ea5ccae3ab613a1ec185d3cd2c4fb/orjson-3.11.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5b192c6cf397e4455b11523c5cf2b18ed084c1bbd61b6c0926344d2129481972", size = 415178, upload-time = "2026-05-06T15:10:58.446Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/1a/b8a5a7ac527e80b9cb11d51e3f6689b709279183264b9ec5c7bc680bb8b5/orjson-3.11.9-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea407d4ccf5891d667d045fecae97a7a1e5e87b3b97f97ae1803c2e741130be0", size = 148089, upload-time = "2026-05-06T15:11:00.441Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/4e/00503f64204bf859b37213a63927028f30fb6268cd8677fb0a5ad48155e1/orjson-3.11.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5f63aaf97afd9f6dec5b1a68e1b8da12bfccb4cb9a9a65c3e0b6c847849e7586", size = 136921, upload-time = "2026-05-06T15:11:02.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/ba/a23b82a0a8d0ed7bed4e5f5035aae751cad4ff6a1e8d2ecd14d8860f5929/orjson-3.11.9-cp314-cp314-win32.whl", hash = "sha256:e30ab17845bb9fa54ccf67fa4f9f5282652d54faa6d17452f47d0f369d038673", size = 131638, upload-time = "2026-05-06T15:11:03.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c3/0c6798456bade745c75c452342dabacce5798196483e77e643be1f53877d/orjson-3.11.9-cp314-cp314-win_amd64.whl", hash = "sha256:32ef5f4283a3be81913947d19608eacb7c6608026851123790cd9cc8982af34b", size = 127078, upload-time = "2026-05-06T15:11:05.123Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/21/5a3f1e8913103b703a436a5664238e5b965ec392b555fe68943ea3691e6b/orjson-3.11.9-cp314-cp314-win_arm64.whl", hash = "sha256:eebdbdeef0094e4f5aefa20dcd4eb2368ab5e7a3b4edea27f1e7b2892e009cf9", size = 126687, upload-time = "2026-05-06T15:11:06.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/35/819eeb4fa8ee676d38fdbb8213a76fd496f7dbbfdfafa89d34e02b22dfac/orjson-3.12.0-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:747843254519dd43b93eee3153a19e5a509334320c4d2f823ec879232db5c796", size = 224133, upload-time = "2026-08-14T16:12:00.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ab/d9221d4a2b085b073fcddc91728d490f20b9cf010c62c2f42371ab997695/orjson-3.12.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:7c2ad193c8004254f34b499f3bd2c80f043d10754aff2b38f93da574f4883f98", size = 113669, upload-time = "2026-08-14T16:12:02.126Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/12/644cbbcabb26df61d9ef0c66e6f2bf8b687cc7b66137597f2858951f1952/orjson-3.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:bc7a872f03522d90e0429e6c0c5cd23084f767bedcb4c58048eec19294613344", size = 130410, upload-time = "2026-08-14T16:12:03.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6d/e3a8c34d687895aecd8b267a01c46106eb98d8424a83bfa7bacb723854f6/orjson-3.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18a87929f31d94a77f7dc93cf527e91f39ce7fe7813d588a4de2507efd32a387", size = 131101, upload-time = "2026-08-14T16:12:04.918Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/20/930824c07685c22af23f26818ed3853b0270488a412b6ab757904b7f787b/orjson-3.12.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e9683ee9ea0659da64f36574ef675b8a86330c34c19ea75db1fb93c3ff99e0ef", size = 131479, upload-time = "2026-08-14T16:12:06.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/a6/22e863bbbe8917aa292e33e0db597000f9a07eb5e6f52efed623fa16bae1/orjson-3.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:103b5db66aa53c1f9e88c2524be4f383e831ba7dfd5f9f5af6336a177c622f11", size = 135865, upload-time = "2026-08-14T16:12:07.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/a0/ceb5008914a65e9a19a46a09d94bc67a74d120209fdfa772750023ceb377/orjson-3.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bd57d79aefa3f84eec851d6de7a366795b9345cfaf17f82b4820430a7a5fa241", size = 127843, upload-time = "2026-08-14T16:12:08.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/3d/61c6b3b84c250cb09cb7229701ff77e4d763773ad7f577d0b6abf2892664/orjson-3.12.0-cp310-cp310-win32.whl", hash = "sha256:3dbce9b6b3074b31a5d5dd322a9c4e5b16f206091ece4194c2e36952847a105e", size = 128293, upload-time = "2026-08-14T16:12:09.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/0e/ea0f4a563253b6363195a4f704123c6bfbf156641bd3be5a75de81c5e917/orjson-3.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:3bb17a06f9bd15237b3216c044209fe92597379124018cfc196fbb846cde64df", size = 122216, upload-time = "2026-08-14T16:12:11.261Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/1a/a7075a8e8b0d3f5097d17ac3099017104b6b7b42012041147995d5b2da05/orjson-3.12.0-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:a94f0f0c6fcbb2b5bd9734c57a489c7584a732bbdf04a39e8c83b861e9d03e92", size = 223409, upload-time = "2026-08-14T16:12:12.654Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/34/c2eb3b2900e5597db7841a4c6416ac2d90081bd956b02d4dd1833fa2b96b/orjson-3.12.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:a696529ec96a90d9a5f9570207efe403c8b08f8e4aa2783ee3403511e2fdfa10", size = 124015, upload-time = "2026-08-14T16:12:14.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/df/b49081766a75b6a37b3d33bdc0a39e492abab8441dd25e3e1998e7b83fcb/orjson-3.12.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:e4ac5059baab4b3acbd99485de019ff8cda0fdf34b61fa74f7197a53db78bfe8", size = 113471, upload-time = "2026-08-14T16:12:15.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/d4/58ea28eeef95c2a27358ed927380a621162cf20bd740bbccf9c3f09a200a/orjson-3.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:8e29957429c35bbb5a185a119c523aa2428b7bbf1a293724c7b9375ed8f892a3", size = 129998, upload-time = "2026-08-14T16:12:17.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/f4/1e82aa2efc9916422d804697876ce433c907a1abd7c7e5c6d3d48565e5f9/orjson-3.12.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dce0166feb0a737ab84f598c9a338cbc0b764a036617aa686194f53c7eba0c3e", size = 130891, upload-time = "2026-08-14T16:12:18.762Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e1/15169e9d22b59a406264f99d6db387c0b0b12b6357a8a0169917c2a713eb/orjson-3.12.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9caf3d09f47c3c70c4451ada20ef9bc4a4cdffa26f49862cf0a253b329aae2d5", size = 131285, upload-time = "2026-08-14T16:12:20.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/3a/763dbd426290d044ec3e615a05e70adb6d8b6f95bf17dc355c0081a5e8b6/orjson-3.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b9dca132b1fda5565088e65a6b6e742285e0aeceb6fae549fa8863e16c7d3998", size = 135707, upload-time = "2026-08-14T16:12:21.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/d1/3b2038ed168d22e14182ed715d6963f9c073a83a2ba43cfe918a4fc43c64/orjson-3.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a791f793b287bbc135b8e87c34e35c8bfc693e2a8a620fab1ae682b925f9a32e", size = 127669, upload-time = "2026-08-14T16:12:22.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/ae/b84b3d3e65f5629ada0edcb1d2bccc55d7c5f89d8b981537ecdc3d6f31ec/orjson-3.12.0-cp311-cp311-win32.whl", hash = "sha256:31ed278a36304390adc3eec5d7f6fd593a7c3e99e5a06cd07866396c4b1b4710", size = 128043, upload-time = "2026-08-14T16:12:24.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/24/2ed0e6f51ea3d0af45d807233a851175af75bec83ef5fd0d6a2601904ec0/orjson-3.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:fb2539159dfe8d371914f354360fa50e4a577cc89222a3828b9650a5e5040252", size = 122084, upload-time = "2026-08-14T16:12:25.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/dd/95d25fcfbc9471799ef6bb01c552d64ee5cde93ee40ba2f423dd3442c708/orjson-3.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:61318b6de893c7a9d9f3e5ecbadccbfc26a7eb417ccc7bbf0771de3b4d72f868", size = 127035, upload-time = "2026-08-14T16:12:27.201Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/4a/295da39c651c2faac8bd351a2a346f0fdedd9d50b847ee9dfc27d2207ef6/orjson-3.12.0-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:aa3e43a6846e91d7bde3d5a9c66090fcd8744f569a9b6cffc5e1ca38f6a461c0", size = 223427, upload-time = "2026-08-14T16:12:28.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/98/758cf90fbeaaafb7f8141bfac75a432099959f3a2f5db93a412e876415d8/orjson-3.12.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:11edb4660a6680abee9788a3a9072208a2c96538cc1322bd79542065229d8e54", size = 123725, upload-time = "2026-08-14T16:12:30.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/b5/5b934d251f8651f7e41df180ad0c57a6e1cabe15c7bd331638413a50ebc9/orjson-3.12.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2d3a9da945a4d96ae758fdaaca56742e6b73b6fd554c5d8876f252a6dad70b83", size = 113375, upload-time = "2026-08-14T16:12:31.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/d2/37efb5b12a176ce3ced29f4144f20da57d02757f78ce549637dc1b4e1fc8/orjson-3.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:92ffc09e07233a6ab6d4e067f7841edcbcc134cb4812155cf171ea5255a421d7", size = 129983, upload-time = "2026-08-14T16:12:32.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/22/0644b87c73f13e0092df8f35a1fe280d991e5e90072087411e0dd7e44e0c/orjson-3.12.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf44e374aadde77b1f6109f1030be51433eb61984379852766b6f4e187db7b1e", size = 130629, upload-time = "2026-08-14T16:12:34.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/57/80b986ebfecd9c6a177ddf1c2319717f0cd8feffb2b78946595a18a2fc88/orjson-3.12.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1192a7021b6d071aaf909864f6e924d6a2675ca360485b972b8401749311750b", size = 131245, upload-time = "2026-08-14T16:12:35.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/80/3d/75c5ac5a69161f44492a68fbdde66f4cc4ce48cd5e1fb05918e46f0c8848/orjson-3.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:53c0c474a9d9aff9aebfc0c88de1f28f843d940e6e3a80729abdf6a20274356f", size = 135397, upload-time = "2026-08-14T16:12:37.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/93/4d71f2df314a97ff0d27a4559bf5888fc8406e3c6dec90e92291e3511215/orjson-3.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:532ff8cd4bd59a327a953a7dcde922c7fc25b85e29721bb8633265430d3a3873", size = 127693, upload-time = "2026-08-14T16:12:38.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/1d/0dbc6be5adfd1730491072fb60beb6bcdf5d7b2596ee41b7fc2e298bfc09/orjson-3.12.0-cp312-cp312-win32.whl", hash = "sha256:a6cf4b18e7de173f209f2084ffbd736dd72389a396326ee80a7022168be232e5", size = 128000, upload-time = "2026-08-14T16:12:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/c9/97b1ce0112ebf5e949c775ed5b1755e562233179f3584579673cc24d6378/orjson-3.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:010811c1b69773450a01cef97727a67b223242f350b77d4ca000e59a9ef2155a", size = 122106, upload-time = "2026-08-14T16:12:41.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/6a/facd8b312e4a0d3a7fa978c7e15821f74a336adf1d65529faec33b48e18b/orjson-3.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:ad29eece0c601737f2a60edc2752a84e7a0785df3efb62e3012834700a5afe0d", size = 126869, upload-time = "2026-08-14T16:12:42.651Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/cb/d7b78218a987eb8a8ce4eeae0286b1bb679333eb631ea0eeaf6371680bfc/orjson-3.12.0-cp313-cp313-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9a36ec60f1796f9a3f13e3b98390295e17a1c7c10155b448d264098bf9ee5900", size = 223397, upload-time = "2026-08-14T16:12:44.003Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/4a/bc87c45e7ec639d35ebefd62618e01939531ac8e171426606a01bda05914/orjson-3.12.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:ad0422b92d5195443a39f80c3bcf731cc2e00f153bd32063a47b73b057bd0f03", size = 123662, upload-time = "2026-08-14T16:12:45.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/ee/c9a4ff3f2dbedbbe9e635d0fa72c8866adede09b6335ef9644f53752f0d8/orjson-3.12.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:5a0fdbc216388f653d3752ff310e710f59253bd4ed6a2bfb3f4f06b84714bbd8", size = 113374, upload-time = "2026-08-14T16:12:46.755Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/09/3f330a026a796c8b4c97a6f429652a5e912e7065039bf96ed25e42aa7b25/orjson-3.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2eb5c56e534127b2b8fa38d2363c8b1b8190367ee0d1d16c041517d880843b94", size = 130029, upload-time = "2026-08-14T16:12:48.06Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/40/094cc53126a3d22f76cdf83b6ea67338bed01d774037621a785aa8e6e5ea/orjson-3.12.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:784106539f4b9d4b930e0b4eb8d45168507dae001945e71b4675a367f1e5e806", size = 130528, upload-time = "2026-08-14T16:12:49.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/74/89bb236deb9565f99434b13052bb40ddfcce4adf3afbfa3132ee7e421468/orjson-3.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1c680706fc8396d95e7c4c1f9482563f552137aef91b57237a3ad5aaf64629df", size = 131075, upload-time = "2026-08-14T16:12:50.692Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/ac/1176360d762c01b5bd34acd56fc098e936c491363d8b6b397ad4aa475547/orjson-3.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83445adc40cba26d6d621185a45128ce455b766af368cad2ab64b970603a7978", size = 135321, upload-time = "2026-08-14T16:12:52.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/02/bbd881c8b9276d50b998de38b4e97de8ace1aac940b0ee545aedbf65ed00/orjson-3.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:644d005bc82f917337a95ce270c9f6f92f9834c2bed7b1477572f8db00784222", size = 127472, upload-time = "2026-08-14T16:12:53.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/02/a0934d7503e6dcbedd6afac3e7f3f8597fd09389949ad94d0f7540e9dbca/orjson-3.12.0-cp313-cp313-win32.whl", hash = "sha256:d8e78d3d93705e3d27cc17cdb209e44d7a8ea203010cac6ce9c7ffc1ae1996f1", size = 128000, upload-time = "2026-08-14T16:12:55.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/87/69f98f8d40faff103a965a5fbb83f08241b01beaf92badb5413fbc9358cc/orjson-3.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:b85931be5b6763c31283805c9bdaae1ca03ad9f6f12a15f1cbf6745b907932c2", size = 121841, upload-time = "2026-08-14T16:12:56.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/07/b83046a4e3cadcc0987d0f160696107c4af706a619b56e4ad01940cadadf/orjson-3.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:6a31348d7dfa64cd9c78bd1f510ff44c48fe64d71094e6b90e364dba3b55949e", size = 126765, upload-time = "2026-08-14T16:12:57.806Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/9d/3931253e6f3148abf2cbe14830367042a4806b362ea520df2303db188fb9/orjson-3.12.0-cp314-cp314-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:9e6fee342a48760e854d743e7a81534d8e2925a6f46e09f750cf56b50fd1de5d", size = 223391, upload-time = "2026-08-14T16:12:59.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/b4a4f1e305367245877b967a0bad70fcf001d77c54ac4339a120b66fdae4/orjson-3.12.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:8c3bb86dd10f39b3fbf434b7d5dc7cac77d6fc8ac572ae30a10731ede2c4b647", size = 123659, upload-time = "2026-08-14T16:13:00.548Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/f3/6782c6fa85e2702bc66be183c3b421486167dcf266ee4dc1403fe3824870/orjson-3.12.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.whl", hash = "sha256:2bb3ce43203936072dd8b4917b01d3aecfc02329bfb42510cb7cfb24708adc9c", size = 113337, upload-time = "2026-08-14T16:13:02.009Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/79/b32ab64bacda9d0fa4942ef483bd03cabf0eaf2be819ca9fb7ff610c559d/orjson-3.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:6a2a79c89984dc719817d388c8709e0efc2a2795a934eaa746b4882eb6045adc", size = 130112, upload-time = "2026-08-14T16:13:03.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/49/6e6142999ca01509219be5e5a9c338a3e5ea011f63e91ff473fbbf3734ed/orjson-3.12.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f06dd838d1e07d9b1de0932ec0485ec92c4d5f5d1ad4817a656268c3e88be1e1", size = 130520, upload-time = "2026-08-14T16:13:04.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d0/3745af0a4cc9867784f29722929cec4d10bd1c877cd754b01ba6d96eb21a/orjson-3.12.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c6b11be792c3d2c6a4be2af4ebf97a68d0bf5f580aca6e86a418a354f6cc846a", size = 131053, upload-time = "2026-08-14T16:13:06.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/6fe5a22fa478fffb190e65c338c84df5c311ef597b363150a17cc57063c0/orjson-3.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:477ecaf6b9f88f873341b91fcc736119ca81b5e002a9f7f308ff5b4f2ce2a70e", size = 135321, upload-time = "2026-08-14T16:13:07.544Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/41/b1b0ec30289646a81a76e2dbaae2686b96fcccb7cb0323dc1dd78cbc7875/orjson-3.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f3c0683136acdc29afdf88a5bc2f7d3d0e34087788d1d63c0144b805a87a196f", size = 127485, upload-time = "2026-08-14T16:13:08.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/2b/277404bdcc21c93b112b963655b76443ebfe828f8a3ff1de7d90f8850eb3/orjson-3.12.0-cp314-cp314-win32.whl", hash = "sha256:d39f3f5c3927e2dc0913fe5bbc1a2f6b1b9d1bba1de6358340d0ad0d0c00ca92", size = 128048, upload-time = "2026-08-14T16:13:10.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/2b/395b36fa2b4ce7af70b651d715e88f80d884b2c2b14a6b53e84d554fb5f0/orjson-3.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:0b1ac5bf6609b2716c7954011c5fef6254922df029f45d032ee4ebf5d363cbed", size = 121858, upload-time = "2026-08-14T16:13:11.634Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/a3/833e895ff452859eebe75093d26691fe9108f1a7a6a08435d7a5780ea652/orjson-3.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:50fae885cb073eac7556353ff3df93312b0d5137b0a5056b2bb63f97ed9a93c7", size = 126749, upload-time = "2026-08-14T16:13:13.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/64/99c8947ece10c17176af9aae85c4948f1d109da77440ec14d87239efaf73/orjson-3.12.0-cp315-cp315-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:01efac2074fffb4cb1ea3fab7861e9d0f2a26913854a972f5ac760525dbdaf6e", size = 223398, upload-time = "2026-08-14T16:13:14.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/30/cf983fe09f2731420fda097a9f7ef4343f47fa216c228961ad8f6da44f3d/orjson-3.12.0-cp315-cp315-macosx_15_0_arm64.whl", hash = "sha256:ed4ca42bd55955aa34deedcfdfd0e0c31abf51143aae158ae2bc3520b626e517", size = 123655, upload-time = "2026-08-14T16:13:16.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/50/9cb8ae73fa4749dbbc20f617004213b5ff01c20aaeec34c3f31124f2c1d8/orjson-3.12.0-cp315-cp315-manylinux_2_39_aarch64.whl", hash = "sha256:40f92192227505acca4e2533ce565f8e6b9535f7d0d09b0968452f18b7376b38", size = 130515, upload-time = "2026-08-14T16:13:17.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/0a/adb6ce1a5b5fbf9cb1790f9961bb668a0dd5429aadaf6cee044724681795/orjson-3.12.0-cp315-cp315-manylinux_2_39_armv7l.whl", hash = "sha256:33efefcf5d88eaf400b47e2eba02f91f319bb9951be61ca500b7d536d3f2079d", size = 113327, upload-time = "2026-08-14T16:13:18.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/5c/d17f61581d8dbdde7048f87a330fa24915edec38db4d72b381fec14fbb56/orjson-3.12.0-cp315-cp315-manylinux_2_39_i686.whl", hash = "sha256:8e386b0bc0ddd7cd2056f884b5a0af33592bd01ac66a7ca4b42a65a7e7774a13", size = 130105, upload-time = "2026-08-14T16:13:20.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/b7/938befcf33bee4704a92ecec6a2731224c539d939bf9429fd39396d28931/orjson-3.12.0-cp315-cp315-manylinux_2_39_x86_64.whl", hash = "sha256:58c58e1de0006ffb580368d6793c36c7b0b021db066479cf281bf5061e732328", size = 131049, upload-time = "2026-08-14T16:13:21.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/15/cfa2021d64d5aa8bb5c9f604ef375e00ec8b657651b5dd650b1b7ad13df1/orjson-3.12.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:08231552159be266a7269555bd9f7c016aee7d9ad6dab06eb58796c5ccb7101c", size = 135320, upload-time = "2026-08-14T16:13:23.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/50/3e75dfe357c1e8f9e287c7a5740260ef15bd23a5299eae8d0835dcad5375/orjson-3.12.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:a15f9a891bce5f5cc5d210e3ad8614d4d1b489a56448c099d6d2a7168b2d954a", size = 127488, upload-time = "2026-08-14T16:13:24.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/a6/79aed402eb3ab284dc5b4791a7ad62c5875127de01b8e3f04bd92d551298/orjson-3.12.0-cp315-cp315-win32.whl", hash = "sha256:03091c8a64db4be38746597ceea68f33c238e27acd9bfe99fb59420224ae7a55", size = 128048, upload-time = "2026-08-14T16:13:26.217Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/f7/2723e264aab7248c1ed6ecaad8e5d0cb866c0cffde75442102ffa7491aba/orjson-3.12.0-cp315-cp315-win_amd64.whl", hash = "sha256:2b7bcefb9f40fa242fa6b06377232c048e655747790829609168c01162f60578", size = 121860, upload-time = "2026-08-14T16:13:27.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/56/630c9113ec8996778f1f0304b364b091b9a9db5fef5fdc17cca622f5ea24/orjson-3.12.0-cp315-cp315-win_arm64.whl", hash = "sha256:859fc4196855890150bb08e649b30d2c93b249b3e3edd0d3bb2231abf8aa8adc", size = 126754, upload-time = "2026-08-14T16:13:28.962Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -376,100 +367,96 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.2.0"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -523,33 +510,33 @@ dev = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "babel", specifier = ">=2.18.0" },
|
||||
{ name = "dbus-fast", specifier = ">=5.0.4" },
|
||||
{ name = "dbus-fast", specifier = ">=5.0.22" },
|
||||
{ name = "evdev", specifier = ">=1.9.3" },
|
||||
{ name = "libarchive-c", specifier = ">=5.3" },
|
||||
{ name = "orjson", specifier = ">=3.11.8" },
|
||||
{ name = "orjson", specifier = ">=3.12.0" },
|
||||
{ name = "pefile", specifier = ">=2024.8.26" },
|
||||
{ name = "pillow", specifier = ">=12.2.0" },
|
||||
{ name = "pillow", specifier = ">=12.3.0" },
|
||||
{ name = "psutil", specifier = ">=7.2.2" },
|
||||
{ name = "pyside6", specifier = ">=6.11.0" },
|
||||
{ name = "pyside6", specifier = ">=6.11.2" },
|
||||
{ name = "qrcode", specifier = ">=8.2" },
|
||||
{ name = "rapidfuzz", specifier = ">=3.14.5" },
|
||||
{ name = "requests", specifier = ">=2.33.1" },
|
||||
{ name = "tqdm", specifier = ">=4.67.3" },
|
||||
{ name = "requests", specifier = ">=2.34.2" },
|
||||
{ name = "tqdm", specifier = ">=4.70.0" },
|
||||
{ name = "vdf", specifier = ">=3.4" },
|
||||
{ name = "websocket-client", specifier = ">=1.9.0" },
|
||||
{ name = "websocket-client", specifier = ">=1.9.1" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "pre-commit", specifier = ">=4.5.1" },
|
||||
{ name = "pyright", specifier = ">=1.1.408" },
|
||||
{ name = "pytest", specifier = ">=8.0.0" },
|
||||
{ name = "pytest-mock", specifier = ">=3.14.0" },
|
||||
{ name = "pre-commit", specifier = ">=4.6.2" },
|
||||
{ name = "pyright", specifier = ">=1.1.411" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-mock", specifier = ">=3.15.1" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.6.0"
|
||||
version = "4.6.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
@@ -558,9 +545,9 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/89/1f3e8e1fc3e97de0fa963495832f581f025f29471602a309e48808244292/pre_commit-4.6.2.tar.gz", hash = "sha256:8f5d7bfb021ecdbcd9d49d89847082dd24172ccde534390081a679ad046e2441", size = 198670, upload-time = "2026-08-10T22:07:18.421Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e2/bbb7129c9e7999a6b8ee9cca3b66486c25c423ab5a75f34071798b74ce94/pre_commit-4.6.2-py2.py3-none-any.whl", hash = "sha256:e2dde9a75d3bce11bd3831c26d134df00a2803c1d818be6a0383c3dcda25dc4e", size = 226202, upload-time = "2026-08-10T22:07:16.942Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -602,20 +589,20 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyright"
|
||||
version = "1.1.409"
|
||||
version = "1.1.411"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nodeenv" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7e/ab/265f7dc69d28113ebba19092e57b075f41543b2ed048429c5f56e2b88eac/pyright-1.1.411.tar.gz", hash = "sha256:d885a0551f2e763b089a02702174e7f4ba77548cddabc972ab86d1f7f1b0f998", size = 4112861, upload-time = "2026-06-25T02:14:06.37Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/49/385be530a6a5b78d1cbcd5c2e38debc8959a2fc6bdb716f4e581002979fc/pyright-1.1.411-py3-none-any.whl", hash = "sha256:dc7c72a8e2700c55baa127554040e067041ea53ccfd50bf96308cc4291c7d5d9", size = 6181526, upload-time = "2026-06-25T02:14:04.691Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyside6"
|
||||
version = "6.11.1"
|
||||
version = "6.11.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyside6-addons" },
|
||||
@@ -624,47 +611,47 @@ dependencies = [
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/a6/27ba5947ed48918f7b74b7c43a1e280aac069e36f25adeb4c9adfac835c4/pyside6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:537682c3b7530817203e667c1f5a2f00486b37bf52c52eeab438544c7a0917f6", size = 571921, upload-time = "2026-05-13T09:47:36.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/de/af89d71410c83b10654d86ff9aff2a4f87c30163658f1cc145242e222526/pyside6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b1fc521ba2bb5109425ab8add06bddbdd524abcad06cfa012cc39a22a189feb2", size = 572102, upload-time = "2026-05-13T09:47:38.249Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/0e/d583bd3f7bf5046a4497b36f3902cfb64aa29554489a5a25c18e6b4ac0ac/pyside6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:75f0005c3eb95c07cfb65522ec50d0815ac007a96482c21dc3cb4b4c04895d84", size = 572098, upload-time = "2026-05-13T09:47:39.44Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/f2/d9d8ce1373dabb37e5919f63cd18446556079631d3f2eea3ada03c29f6b8/pyside6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0968877ab1fb4ef3587a284da6fe05e8647ada56a6a3750b6395188e01f4aba6", size = 578377, upload-time = "2026-05-13T09:47:40.76Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/02/a6057d8bd2bdb1940820fff2d627fdf4013148c9c57adf69fa40d3452ac3/pyside6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:acee467cb5f256cc47ebb9d815a054c1d8416da380c191b247a76d164aa3f805", size = 561765, upload-time = "2026-05-13T09:47:41.9Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/b5/99ff75f604d69eda1c5d1fe85cc43fb697d95eabc30ba86af099c2ddcd96/pyside6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:13a3c79816879d9743672a669f1988f74459a78f89a97a2624fae4fe059ffa3a", size = 571875, upload-time = "2026-08-18T06:37:27.794Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/fe/968844def58150a41568b505edca0d68df6901b20ea4222ccb0c069cc703/pyside6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:dc6d03990489a5085842770718392ef5de36352d8f608d9a9fe03e60af4d7b66", size = 572120, upload-time = "2026-08-18T06:37:30.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/5c/f4ecc6defaec645e5ac3ca2f99e3da286a28d1c647c2d10b584e0392b94c/pyside6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:57fe867a6c93821a085d74e8a26f863fc2363516848181c466032595b33b1729", size = 572121, upload-time = "2026-08-18T06:37:32.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/fe/4aea9d30300fff4cda9bb729c1363b9e1095a8544e0d6b57065465e16599/pyside6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:3201d67e3c10be2eaedd3910ff0f02351eca7e88c95a291cde5e7f2f55ef207f", size = 578382, upload-time = "2026-08-18T06:37:34.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/b0/8e2fb01e9ee15ad29fe10d0dd2d37f503e4aa3714c9fbb07aca380618de7/pyside6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:0444ac71d0791a19bded35f6d9a94515941f23a6eb7098ca45a1b64a338be697", size = 561768, upload-time = "2026-08-18T06:37:36.437Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-addons"
|
||||
version = "6.11.1"
|
||||
version = "6.11.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pyside6-essentials" },
|
||||
{ name = "shiboken6" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/6b/8bc94aff48b63f788f2d84e5467c12362d68906ba742c0942f46cb04c879/pyside6_addons-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:54733c77f789bef5f03c6aff4ad3bec8b2eff021f0cfcbc53d5e6c250ded24f9", size = 331714589, upload-time = "2026-05-13T09:39:12.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/62/fb1428a523b2a4541e232aab50d9e789e6b4526f37fd9593452a7ea5b6b3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6c65fbd73a512d6f72cda8d8277444a85a34dc99dd1dae9c21d35b8671bb1f", size = 175063224, upload-time = "2026-05-13T09:39:34.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/9b/2ccd52f66db55c06de65d0501170a1935d04d64d0a230c0d892284a02ce3/pyside6_addons-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:bf1c6c4e954e5eba3d2a7c661ad4b9689e8f09c7f4a16bdf29713371d11af993", size = 170553429, upload-time = "2026-05-13T09:39:54.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/bd/8adc4d350b3b363f3dfc8fccdcf5bfed25f7e36c2fff30c64e106f4f1572/pyside6_addons-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:0d13c4dfd671b050a48e4f8d8ddc724b7248f9c0437e7fc47fdf316278572923", size = 168816308, upload-time = "2026-05-13T09:40:13.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/b7/9a840d97f0f0f04e372a87e205dd30ee285b4e3b021b188459a917c9dc76/pyside6_addons-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:3494f480dee92f415be2f2d989c0b3f4755ac332b28045cbf4ba0f5c5a22ba37", size = 35759347, upload-time = "2026-05-13T09:40:21.199Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/91/339fe9f7c94163815004ccd6b5c58b926b2dfb4e51c3b5563d412eab151f/pyside6_addons-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:354c574a839f7d0751960ba03f33bec87c95e0f2796dca3287f54d03abe97dac", size = 332047075, upload-time = "2026-08-18T06:35:33.037Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/cd/ce470e20f68901f4dfa5f742d6a3a58ba865684a232e4b87d5a1a2179cc0/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a8b00956925fbdeffc7052cf933506391289f35ac4f3f71a0a167ec195cd47b4", size = 175062576, upload-time = "2026-08-18T06:35:45.583Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/bd/f725a8984703612c72cd17d0871d047132bacaccc13fc0d288a5e8a37dad/pyside6_addons-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a2ca3c73e5f060d4aca49451abd9fba68dd0e1c66ff40cea3a1fe0415def579a", size = 170827254, upload-time = "2026-08-18T06:35:56.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/c1/08bf6594101b42fc88834009f1dac59a983c5b6805c62c5081f077d09b4a/pyside6_addons-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:f449ea4431da20e7b86752cca8d166f93434516fe417f981c27e5f8e1b554407", size = 168208836, upload-time = "2026-08-18T06:36:07.295Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/60/e4b7f09343e9f29e565f488fb44c4f1fe260db481ba2c08ad8a1a7d48216/pyside6_addons-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:d5606af369484b862b4d5741cfc179e19d2b8819c3e04d210b8188e0c9f85988", size = 35807245, upload-time = "2026-08-18T06:36:12.485Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyside6-essentials"
|
||||
version = "6.11.1"
|
||||
version = "6.11.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "shiboken6" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/da/10d9197e7370eb4fed8df5fc547b7548dec88e5c5949e2d450db4ae96feb/pyside6_essentials-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:228de53c2bc26b07e5021fbe3614fc44ca08e4dab9999af08c2b389d2c239957", size = 110352945, upload-time = "2026-05-13T09:43:08.006Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/49/0e1237c4400bec7e335d2c4eeb49bc40d9fd88a9ac44ca9083ce1abdc308/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:e3ef7027b41e4e55fadb56e3b3257dc8ee92154b639fe67fc4c8e05e9d976c60", size = 79908535, upload-time = "2026-05-13T09:43:24.836Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/c5/da4c5f23c6540ac5211a1f60177c8dee84b1bf40f2719479587ab8c60731/pyside6_essentials-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:a039b6da68a3a4b9d243217b2b98d475eed3f617159ef6be925badab53c11b0d", size = 78960051, upload-time = "2026-05-13T09:43:35.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/0e/b663ecc96ca57b5c91b83b6615d6b174380b0faf30338125c26e053d6aa7/pyside6_essentials-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:63311bd48e32c584599ab04b9ef7c324082374cd2c9fa533f978fb893bb47e40", size = 77549267, upload-time = "2026-05-13T09:43:44.92Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/eb6723faf5cb7fa581145da1c15f40d641b96e080f0491af2f1859fdeedb/pyside6_essentials-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:11253ea52aabecefe9febddbbe78b43a824129e3af1cec98431028fba7fa954f", size = 57964512, upload-time = "2026-05-13T09:43:52.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/16/0b7ecf89ebada82ed0430be33809ff325761ece83104f8635b1bd101fcd0/pyside6_essentials-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:77795c145202e65a78d88f7cd409d186e3ba23d159bdb3ba2dcd159ae5e5f0d9", size = 110800753, upload-time = "2026-08-18T06:36:33.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/1a/84a84bbc75835ee88abdcb66eb875b8e1a21834af9b833bc1893f2c1bfaf/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:aaf9f25f0f324874085fa5b26a610318db8a8e243cf85bb3e5400595191c7778", size = 80108947, upload-time = "2026-08-18T06:36:40.637Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/4c/d92b08a2151d095b9a3725fa0e71b215ec686d40bbd5d8b702d21a9b5eaa/pyside6_essentials-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:d3ec6e1885c46e57f16364f38ea8040ffc5dc0b18341058f608fede3ba930567", size = 79575497, upload-time = "2026-08-18T06:36:46.97Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/09/d2662684aee4d3f027ff9b1e965f22301bc28ffc5b7f167658508e4475dc/pyside6_essentials-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:c8a29def77032773a30879f7f24415b5395ad08592d147c170824ef4c735dfc1", size = 76913043, upload-time = "2026-08-18T06:36:54.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/87/4dd2a6468cea600da1fcf9c7f02bc248eeaa98cdc75658e48e669d95326a/pyside6_essentials-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:fadd75c5c20800d64dd0a586ea8cb337c5e630aa2246df0e5105738afa46e02c", size = 58125520, upload-time = "2026-08-18T06:36:59.073Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.0.3"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
@@ -675,9 +662,9 @@ dependencies = [
|
||||
{ name = "pygments" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -888,14 +875,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "shiboken6"
|
||||
version = "6.11.1"
|
||||
version = "6.11.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/17/f3/f2b63df0251e7cd3172ea28e32ede52739de9566bcefcd0178681538ac81/shiboken6-6.11.1-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:1a16867f103ef1c662a5f09dfed03273a9f81688b174555162c58e83650a3f02", size = 476874, upload-time = "2026-05-13T09:47:01.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/9b/e0355d8897b5c150770f1d95718aad17d432fcc9c035c04f3f58427d4693/shiboken6-6.11.1-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9a8bccfafc8805254cabcfa1edfaf55cd52889f4998c91ad0d9a4433fb1bcdbe", size = 272222, upload-time = "2026-05-13T09:47:02.653Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/d5/dd4f1defed400be03340f2ede34b61f846776650b4e7ed9ebaf4c71979a2/shiboken6-6.11.1-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:1bd2f4314414df2d122d9f646e03b731bc6d6b5f77a5f53f99a4fe4e97d84e6f", size = 270350, upload-time = "2026-05-13T09:47:04.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b5/3f6fb2ee65b534193fb4ef713dd619dc31dadff5d12c16979a7699ad58be/shiboken6-6.11.1-cp310-abi3-win_amd64.whl", hash = "sha256:c2c6863aa80ec18c0f82cea3417837b279cdc60024ac17123461dc9042577df7", size = 1223647, upload-time = "2026-05-13T09:47:05.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/d1/f15ca0e1666faae02c945f48e745ea35f8fcd8243b176109b4e2c4251f47/shiboken6-6.11.1-cp310-abi3-win_arm64.whl", hash = "sha256:7c8d9af17db4495d4fa5b1c393f218311c4855546b9dfa6a0bd21bcd66b55e9d", size = 1784170, upload-time = "2026-05-13T09:47:07.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/44/11bf71c36e71936ab4e923e5dd23599dab527c4488b01860b0f689ce9947/shiboken6-6.11.2-cp310-abi3-macosx_13_0_universal2.whl", hash = "sha256:53659683b1f7a08e9f87eff9b1065f1ceb7110cd7a4bc09fdf5efe43d286604d", size = 476674, upload-time = "2026-08-18T06:37:11.526Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/9a/f41bea4316d0c1bec4800842dcb4bd8fd3e29d6a79c496bc32e1aec42583/shiboken6-6.11.2-cp310-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7a7a0a72a9ed26c9bf77d42246b1c736486befb8f31aa2fb29957ea4cdd1c1c2", size = 272435, upload-time = "2026-08-18T06:37:13.397Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/05/79ed0697515d4fc7435459e4ed4c4a3b5621a55b7c91d0f3f2cc3d617aa0/shiboken6-6.11.2-cp310-abi3-manylinux_2_39_aarch64.whl", hash = "sha256:4bbbd6fa4d7cff5ec5e12bc4c10e1d845fa30c89457ecb13cc64f7bddb77f6f9", size = 270668, upload-time = "2026-08-18T06:37:15.025Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/6b/fee1b4814317e4664e318644c9c007676c65a71e5b2b9d3ac4121a94dc0a/shiboken6-6.11.2-cp310-abi3-win_amd64.whl", hash = "sha256:6ab0eba1c904455df621f9a6df3ca2bb896bab8670572d2bc4e37804ae91f19a", size = 1226578, upload-time = "2026-08-18T06:37:18.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/b5/80c4e5aafeb8ac777cf5a1becfe87ac7197c35051df00b5f7819787e09d5/shiboken6-6.11.2-cp310-abi3-win_arm64.whl", hash = "sha256:97c49432488df958f0308736f3d15b6915d8826fc380af17bb4b9ec5c4a5e81b", size = 1784669, upload-time = "2026-08-18T06:37:21.046Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -954,14 +941,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.3"
|
||||
version = "4.70.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1009,9 +996,9 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "websocket-client"
|
||||
version = "1.9.0"
|
||||
version = "1.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2c/41/aa4bf9664e4cda14c3b39865b12251e8e7d239f4cd0e3cc1b6c2ccde25c1/websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98", size = 70576, upload-time = "2025-10-07T21:16:36.495Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/2e/1d/ae61bca2a99d0735311b7b11b81931279b4a45c74a45034d31b39ad565d1/websocket_client-1.9.1.tar.gz", hash = "sha256:2e0916d2e28ccc82702f2f70aa7a654588e84a5cf2f5914099638b33547f2b7c", size = 82936, upload-time = "2026-08-29T13:45:40.346Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/0e/7b514f6eb4bf518a9af0285c1a78297467b95fd7d1ebab08b41f8889ab2f/websocket_client-1.9.1-py3-none-any.whl", hash = "sha256:19a871b81d0022589dd9e8e1b1891e0516d32113837dfb987d7691b6a425a1e2", size = 95460, upload-time = "2026-08-29T13:45:39.255Z" },
|
||||
]
|
||||
Reference in new issue
Block a user