1 Commits

Author SHA1 Message Date
77d4287f12 chore(ci): replace uv github action to manual install
Some checks failed
Code check / Check code (pull_request) Failing after 11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-23 21:01:56 +05:00
113 changed files with 4236 additions and 32803 deletions

View File

@@ -12,27 +12,17 @@ jobs:
name: Build AppImage name: Build AppImage
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
steps: steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: https://gitea.com/actions/checkout@v4
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
- name: Upgrade pip toolchain - name: Install tools
run: | run: |
python3 -m pip install --upgrade \ pip3 install git+https://github.com/Boria138/appimage-builder.git
pip setuptools setuptools-scm wheel packaging build pip3 install uv
- name: Install appimage-builder
run: |
git clone https://github.com/Boria138/appimage-builder
cd appimage-builder
pip install .
- name: Install uv
run: |
pip install uv
- name: Build AppImage - name: Build AppImage
run: | run: |
@@ -52,7 +42,7 @@ jobs:
strategy: strategy:
matrix: matrix:
fedora_version: [41, 42, 43, rawhide] fedora_version: [41, 42, rawhide]
container: container:
image: fedora:${{ matrix.fedora_version }} image: fedora:${{ matrix.fedora_version }}
@@ -73,7 +63,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo - name: Checkout repo
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 uses: https://gitea.com/actions/checkout@v4
- name: Copy fedora.spec - name: Copy fedora.spec
run: | run: |
@@ -94,7 +84,7 @@ jobs:
name: Build Arch Package name: Build Arch Package
runs-on: ubuntu-22.04 runs-on: ubuntu-22.04
container: container:
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731 image: archlinux:base-devel
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host
@@ -134,7 +124,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 uses: https://gitea.com/actions/checkout@v4
- name: Upload Arch package - name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -8,7 +8,7 @@ on:
env: env:
# Common version, will be used for tagging the release # Common version, will be used for tagging the release
VERSION: 0.1.8 VERSION: 0.1.4
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -23,22 +23,12 @@ jobs:
- name: Install required dependencies - name: Install required dependencies
run: | run: |
sudo apt update sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
- name: Upgrade pip toolchain - name: Install tools
run: | run: |
python3 -m pip install --upgrade \ pip3 install git+https://github.com/Boria138/appimage-builder.git
pip setuptools setuptools-scm wheel packaging build pip3 install uv
- name: Install appimage-builder
run: |
git clone https://github.com/Boria138/appimage-builder
cd appimage-builder
pip install .
- name: Install uv
run: |
pip install uv
- name: Build AppImage - name: Build AppImage
run: | run: |
@@ -109,7 +99,7 @@ jobs:
strategy: strategy:
matrix: matrix:
fedora_version: [41, 42, 43, rawhide] fedora_version: [41, 42, rawhide]
container: container:
image: fedora:${{ matrix.fedora_version }} image: fedora:${{ matrix.fedora_version }}
@@ -180,12 +170,10 @@ jobs:
- name: Release - name: Release
uses: https://gitea.com/actions/gitea-release-action@v1 uses: https://gitea.com/actions/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with: with:
body_path: changelog.txt body_path: changelog.txt
token: ${{ env.GITEA_TOKEN }} token: ${{ env.GITEA_TOKEN }}
tag_name: v${{ env.VERSION }} tag_name: v${{ env.VERSION }}
prerelease: true prerelease: true
files: release/**/* files: release/**/*
sha256sum: false sha256sum: true

View File

@@ -1,4 +1,4 @@
name: Check Translations (disabled until yaspeller is fixed) name: Check Translations
run-name: Check spelling in translation files run-name: Check spelling in translation files
on: on:
push: push:
@@ -12,14 +12,13 @@ on:
jobs: jobs:
check-translations: check-translations:
if: false
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 uses: https://gitea.com/actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 uses: https://gitea.com/actions/setup-python@v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"

View File

@@ -18,7 +18,7 @@ jobs:
fedora: ${{ steps.check.outputs.fedora }} fedora: ${{ steps.check.outputs.fedora }}
arch: ${{ steps.check.outputs.arch }} arch: ${{ steps.check.outputs.arch }}
steps: steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: https://gitea.com/actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
@@ -63,7 +63,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch' if: needs.changes.outputs.appimage == 'true' || github.event_name == 'workflow_dispatch'
steps: steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: https://gitea.com/actions/checkout@v4
- name: Install required dependencies - name: Install required dependencies
run: | run: |
@@ -115,7 +115,7 @@ jobs:
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo - name: Checkout repo
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 uses: https://gitea.com/actions/checkout@v4
- name: Copy fedora-git.spec - name: Copy fedora-git.spec
run: | run: |
@@ -138,7 +138,7 @@ jobs:
needs: changes needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch' if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container: container:
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731 image: archlinux:base-devel
volumes: volumes:
- /usr:/usr-host - /usr:/usr-host
- /opt:/opt-host - /opt:/opt-host
@@ -178,7 +178,7 @@ jobs:
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git" su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 uses: https://gitea.com/actions/checkout@v4
- name: Upload Arch package - name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v4 uses: https://gitea.com/actions/gitea-upload-artifact@v4

View File

@@ -20,17 +20,17 @@ jobs:
name: Check code name: Check code
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: https://gitea.com/actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: https://gitea.com/actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: Install uv manually - name: Install uv manually
run: | run: |
curl -LsSf https://astral.sh/uv/install.sh | sh curl -LsSf https://astral.sh/uv/install.sh | sh
source $HOME/.local/bin/env echo "$HOME/.cargo/bin" >> $GITHUB_PATH
uv --version uv --version
- name: Sync dependencies into venv - name: Sync dependencies into venv

View File

@@ -11,10 +11,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 uses: https://gitea.com/actions/checkout@v4
- name: Set up Python - name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 uses: https://gitea.com/actions/setup-python@v5
with: with:
python-version-file: "pyproject.toml" python-version-file: "pyproject.toml"

View File

@@ -8,20 +8,19 @@ on:
jobs: jobs:
renovate: renovate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6 container: ghcr.io/renovatebot/renovate:latest
steps: steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - uses: https://gitea.com/actions/checkout@v4
- name: Set up Node.js - name: Set up Node.js
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 uses: https://gitea.com/actions/setup-node@v4
with: with:
node-version: 20 node-version: 20
- name: Install uv manually - name: Install uv
run: | uses: https://github.com/astral-sh/setup-uv@v6
curl -LsSf https://astral.sh/uv/install.sh | sh with:
. $HOME/.local/bin/env enable-cache: true
uv --version
- name: Download external renovate config - name: Download external renovate config
run: | run: |
@@ -35,4 +34,3 @@ jobs:
RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js" RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
LOG_LEVEL: "debug" LOG_LEVEL: "debug"
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
RENOVATE_GITHUB_COM_TOKEN: ${{ secrets.RENOVATE_GITHUB_COM_TOKEN }}

View File

@@ -11,12 +11,12 @@ repos:
- id: check-yaml - id: check-yaml
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.8.22 rev: 0.8.9
hooks: hooks:
- id: uv-lock - id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0 rev: v0.12.8
hooks: hooks:
- id: ruff-check - id: ruff-check

View File

@@ -3,101 +3,12 @@
Все заметные изменения в этом проекте фиксируются в этом файле. Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/). Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [0.1.8] - 2025-10-18 ## [Unreleased]
### Added
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
### Changed
- При завершении автоустановки приложение больше не перезапускается
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
- Обновлены и дополнены скриншоты темы
### Fixed
- Исправлено наложение карточек при смене фильтра игр
- Исправлена невозможность запуска приложения без подключёного геймпада
- Исправлена невозможность установки компонентов Winetricks через геймпад
- Ресиверы и виртуальные устройства больше не считаются за геймпад
### Contributors
- @Vector_null
---
## [0.1.7] - 2025-10-12
### Added
- Возможность скроллинга библиотеки мышью или пальцем
- Импорт и экспорт бекапа префикса
- Диалог для управление Winetricks
- Кнопки для удаления префикса, wine или proton
- Все настройки Wine с оригинального PortProton
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
- Вкладка автоустановок
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
- В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку
### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
- При сохранении настроек теперь не меняется размер окна
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---
## [0.1.6] - 2025-09-23
### Added
- Кэширование шрифтов в load_theme_fonts для предотвращения повторной загрузки
- Проверка безопасности в theme_manager.py для всех сторонних тем, с проверкой на запрещённые модули и функции (подробности см. в коде theme_manager под полями FORBIDDEN_MODULES и FORBIDDEN_FUNCTIONS)
- Фильтрация ASRock LED контроллера, чтобы предотвратить его обнаружение как геймпада
- Подсказки по управлению в интерфейсе
- Поддержка боковой кнопки мыши, которая теперь работает как кнопка "назад"
- Аргумент cli --debug-level для указания уровня дебага
### Changed
- Управления с геймпада теперь перехватывается только если окно в фокусе
### Fixed
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---
## [0.1.5] - 2025-08-31
### Added ### Added
- Больше типов анимаций при открытии карточки игры (подробности см. в документации). - Больше типов анимаций при открытии карточки игры (подробности см. в документации).
- Второй тип анимации при наведении и фокусе карточки (подробности см. в документации).
- Анимация при закрытии карточки игры (подробности см. в документации). - Анимация при закрытии карточки игры (подробности см. в документации).
- Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок). - Добавлен обработчик нажатий стрелок на клавиатуре в поле ввода (позволяет перемещаться между символами с помощью стрелок).
- Система быстрого доступа (избранного) в диалоге выбора файлов.
- Автоматическая прокрутка для панели дисков в диалоге выбора файлов.
- Возможность выбора папок и / или дисков в диалоге выбора файлов через клавиатуру.
- Переход в родительскую директорию в диалоге выбора файлов по клавише Backspace.
- Пункты "Избранное" и "Недавние" в трей для быстрого запуска игр.
- Пункт "Выход" в трей.
- Пункт "Темы" в трей для быстрого переключения тем.
- Двойной клик по иконке трея для показа/скрытия главного окна.
- Запуск через трей показывает модальное окно для слежки за процессом запуска
### Changed ### Changed
- Уменьшена длительность анимации открытия карточки с 800 до 350 мс. - Уменьшена длительность анимации открытия карточки с 800 до 350 мс.
@@ -107,9 +18,7 @@
- Временно удалена светлая тема. - Временно удалена светлая тема.
- Добавление и удаление игр из Steam больше не требует перезапуска клиента. - Добавление и удаление игр из Steam больше не требует перезапуска клиента.
- Обновлены все зависимости (затрагивает только AppImage). - Обновлены все зависимости (затрагивает только AppImage).
- Приложение теперь не закрывается полностью, а сворачивается в трей. - Удалён отдельный трей, так как у PortProton есть собственный.
- Карточки теперь все находятся друг под другом, а не в разнабой
- Изменено соотношение сторон карточек
### Fixed ### Fixed
- `legendary list` теперь не вызывается, если вход в EGS не был выполнен. - `legendary list` теперь не вызывается, если вход в EGS не был выполнен.
@@ -118,10 +27,7 @@
- Диалог добавления игры больше не добавляет игру, если `exe` не существует. - Диалог добавления игры больше не добавляет игру, если `exe` не существует.
- Вкладки больше не переключаются стрелками, если фокус в поле ввода. - Вкладки больше не переключаются стрелками, если фокус в поле ввода.
- Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS). - Исправлено переключение слайдера: RT (Xbox) / R2 (PS), LT (Xbox) / L2 (PS).
- Заголовок окна диалога выбора файлов теперь можно перевести. - Переведен заголовок окна диалога выбора файлов.
- Трей теперь можно перевести.
- Отображение устройств смонтированных в /run/media в диалоге выбора файлов.
- Закрытие диалогов добавления / редактирования игры и выбора файлов по клавише Escape.
### Contributors ### Contributors
- @Alex Smith - @Alex Smith

View File

@@ -1,5 +1,5 @@
<div align="center"> <div align="center">
<img src="build-aux/share/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg" width="64"> <img src="https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/themes/standart/images/theme_logo.svg" width="64">
<h1 align="center">PortProtonQt</h1> <h1 align="center">PortProtonQt</h1>
<p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p> <p align="center">Удобный графический интерфейс для управления и запуска игр из PortProton, Steam и Epic Games Store. Оно объединяет библиотеки игр в единый центр для лёгкой навигации и организации. Лёгкая структура и кроссплатформенная поддержка обеспечивают цельный игровой опыт без необходимости использования нескольких лаунчеров. Интеграция с PortProton упрощает запуск Windows-игр на Linux с минимальной настройкой.</p>
</div> </div>
@@ -54,6 +54,7 @@ PortProtonQt использует код и зависимости от след
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE). - [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE). - [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md). - [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
Полный текст лицензий см. в файле [LICENSE](LICENSE). Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING] > [!WARNING]

15
TODO.md
View File

@@ -1,6 +1,6 @@
- [X] Адаптировать структуру проекта для поддержки инструментов сборки - [X] Адаптировать структуру проекта для поддержки инструментов сборки
- [X] Добавить возможность управления с геймпада - [X] Добавить возможность управления с геймпада
- [X] Добавить возможность управления с тачскрина (Формально и так есть) - [ ] Добавить возможность управления с тачскрина
- [X] Добавить возможность управления с мыши и клавиатуры - [X] Добавить возможность управления с мыши и клавиатуры
- [X] Добавить систему тем [Документация](documentation/theme_guide) - [X] Добавить систему тем [Документация](documentation/theme_guide)
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено) - [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
@@ -11,18 +11,18 @@
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800) - [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots) - [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
- [X] Получать описания и названия игр из базы данных Steam - [X] Получать описания и названия игр из базы данных Steam
- [X] Получать обложки для игр из CDN Steam - [X] Получать обложки для игр из SteamGridDB или CDN Steam
- [X] Оптимизировать работу со Steam API для ускорения времени запуска - [X] Оптимизировать работу со Steam API для ускорения времени запуска
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley) - [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода - [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки) - [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
- [X] Избавиться от вызовов yad - [X] Избавиться от вызовов yad
- [X] Реализовать собственный системный трей вместо использования трея PortProton - [X] Реализовать собственный системный трей вместо использования трея PortProton
- [X] Добавить экранную клавиатуру в поиск - [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
- [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту) - [X] Добавить сортировку карточек по различным критериям (доступны: по недавности, количеству наигранного времени, избранному или алфавиту)
- [X] Добавить индикацию запуска приложения - [X] Добавить индикацию запуска приложения
- [X] Достигнуть паритета функциональности с Ingame - [X] Достигнуть паритета функциональности с Ingame
- [ ] Достигнуть паритета функциональности с PortProton (остались настройки игр и обновление скриптов) - [ ] Достигнуть паритета функциональности с PortProton
- [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}` - [X] Добавить возможность изменения названия, описания и обложки через файлы `.local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}`
- [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/) - [X] Добавить встроенное переопределение названия, описания и обложки, например, по пути `portprotonqt/custom_data` [Документация](documentation/metadata_override/)
- [X] Добавить переводы в переопределения - [X] Добавить переводы в переопределения
@@ -49,7 +49,7 @@
- [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter) - [X] Добавить недокументированные параметры конфигурации в GUI (time_detail_level, games_sort_method, games_display_filter)
- [X] Добавить систему избранного для карточек - [X] Добавить систему избранного для карточек
- [X] Заменить все `print` на `logging` - [X] Заменить все `print` на `logging`
- [X] Привести все логи к единому языку - [ ] Привести все логи к единому языку
- [X] Уменьшить количество подстановок в переводах - [X] Уменьшить количество подстановок в переводах
- [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog) - [X] Стилизовать все элементы без стилей (QMessageBox, QSlider, QDialog)
- [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py` - [X] Убрать жёсткую привязку путей к стрелочкам QComboBox в `styles.py`
@@ -62,6 +62,7 @@
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63) - [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)) - [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры - [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
- [X] Добавить подсказки к управлению с геймпада - [ ] Доделать светлую тему
- [ ] Добавить подсказки к управлению с геймпада
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры - [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры - [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры

View File

@@ -1,11 +1,16 @@
version: 1 version: 1
script: script:
# 1) чистим старый AppDir
- rm -rf AppDir || true - rm -rf AppDir || true
# 2) создаём структуру каталога
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages - mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
# 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
- uv venv - uv venv
- uv pip install --no-cache-dir ../ - uv pip install --no-cache-dir ../
# 4) копируем всё из .venv в AppDir
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr - cp -r share AppDir/usr
# 5) чистим от ненужных модулей и бинарников
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
@@ -14,6 +19,7 @@ script:
AppDir: AppDir:
path: ./AppDir path: ./AppDir
after_bundle: after_bundle:
# Документация, справка, примеры
- rm -rf $TARGET_APPDIR/usr/share/man || true - rm -rf $TARGET_APPDIR/usr/share/man || true
- rm -rf $TARGET_APPDIR/usr/share/doc || true - rm -rf $TARGET_APPDIR/usr/share/doc || true
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true - rm -rf $TARGET_APPDIR/usr/share/doc-base || true
@@ -29,14 +35,17 @@ AppDir:
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true - rm -rf $TARGET_APPDIR/usr/share/metainfo || true
- rm -rf $TARGET_APPDIR/usr/include || true - rm -rf $TARGET_APPDIR/usr/include || true
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true - rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
# Статика и отладка
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true - find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
# Strip ELF бинарников (исключая Python extensions)
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true" - "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
# Удаление пустых папок
- find $TARGET_APPDIR -type d -empty -delete || true - find $TARGET_APPDIR -type d -empty -delete || true
app_info: app_info:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt icon: ru.linux_gaming.PortProtonQt
version: 0.1.8 version: 0.1.4
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:
@@ -54,18 +63,16 @@ AppDir:
- libxcb-cursor0 - libxcb-cursor0
- libimage-exiftool-perl - libimage-exiftool-perl
- xdg-utils - xdg-utils
- cabextract
- curl
- 7zip
- unzip
- unrar
exclude: exclude:
# Документация и man-страницы
- "*-doc" - "*-doc"
- "*-man" - "*-man"
- manpages - manpages
- mandb - mandb
# Статические библиотеки
- "*-dev" - "*-dev"
- "*-static" - "*-static"
# Дебаг-символы
- "*-dbg" - "*-dbg"
- "*-dbgsym" - "*-dbgsym"
runtime: runtime:
@@ -76,4 +83,3 @@ AppDir:
AppImage: AppImage:
sign-key: None sign-key: None
arch: x86_64 arch: x86_64
comp: zstd

View File

@@ -1,12 +1,12 @@
pkgname=portprotonqt pkgname=portprotonqt
pkgver=0.1.8 pkgver=0.1.4
pkgrel=1 pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store" pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any') arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt" url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0') license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') 'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
makedepends=('python-'{'build','installer','setuptools','wheel'}) makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver") source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP') sha256sums=('SKIP')

View File

@@ -6,7 +6,7 @@ arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt" url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0') license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson' depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar') 'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
makedepends=('python-'{'build','installer','setuptools','wheel'}) makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git") source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
sha256sums=('SKIP') sha256sums=('SKIP')

View File

@@ -46,11 +46,6 @@ Requires: python3-pillow
Requires: perl-Image-ExifTool Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
Requires: python3-beautifulsoup4 Requires: python3-beautifulsoup4
Requires: cabextract
Requires: gzip
Requires: unzip
Requires: curl
Requires: unrar
%description -n python3-%{pypi_name}-git %description -n python3-%{pypi_name}-git
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup. This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.8 %global pypi_version 0.1.4
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1
@@ -43,11 +43,6 @@ Requires: python3-pillow
Requires: perl-Image-ExifTool Requires: perl-Image-ExifTool
Requires: xdg-utils Requires: xdg-utils
Requires: python3-beautifulsoup4 Requires: python3-beautifulsoup4
Requires: cabextract
Requires: gzip
Requires: unzip
Requires: curl
Requires: unrar
%description -n python3-%{pypi_name} %description -n python3-%{pypi_name}
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup. This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.

View File

@@ -1,30 +1,19 @@
_portprotonqt_completions() { _portprotonqt() {
local cur prev opts local cur prev
COMPREPLY=() _init_completion || return
cur="${COMP_WORDS[COMP_CWORD]}"
prev="${COMP_WORDS[COMP_CWORD-1]}"
# Available options case $prev in
opts="--fullscreen --debug-level --help -h" --help|-h)
return
# Debug level choices
debug_levels="ALL DEBUG INFO WARNING ERROR CRITICAL"
case "${prev}" in
--debug-level)
# Complete debug levels
COMPREPLY=( $(compgen -W "${debug_levels}" -- ${cur}) )
return 0
;;
*)
;; ;;
esac esac
# Complete options if [[ "$cur" == -* ]]; then
if [[ ${cur} == -* ]]; then COMPREPLY=( $( compgen -W '--fullscreen' -- "$cur" ) )
COMPREPLY=( $(compgen -W "${opts}" -- ${cur}) )
return 0 return 0
fi fi
return 0
} }
complete -F _portprotonqt_completions portprotonqt complete -F _portprotonqt portprotonqt

View File

@@ -217,7 +217,7 @@
}, },
{ {
"normalized_name": "watch_dogs 2", "normalized_name": "watch_dogs 2",
"status": "Running" "status": "Broken"
}, },
{ {
"normalized_name": "zero hour", "normalized_name": "zero hour",
@@ -1777,7 +1777,7 @@
}, },
{ {
"normalized_name": "supervive", "normalized_name": "supervive",
"status": "Running" "status": "Denied"
}, },
{ {
"normalized_name": "splitgate 2", "normalized_name": "splitgate 2",
@@ -4472,7 +4472,7 @@
"status": "Running" "status": "Running"
}, },
{ {
"normalized_name": "battlefield 6", "normalized_name": "f1 25",
"status": "Denied" "status": "Denied"
}, },
{ {
@@ -4482,65 +4482,5 @@
{ {
"normalized_name": "sword of justice", "normalized_name": "sword of justice",
"status": "Broken" "status": "Broken"
},
{
"normalized_name": "blade & soul neo",
"status": "Broken"
},
{
"normalized_name": "the finals (cn)",
"status": "Broken"
},
{
"normalized_name": "tom clancy's rainbow six siege x",
"status": "Denied"
},
{
"normalized_name": "dragonheir silent gods",
"status": "Broken"
},
{
"normalized_name": "the quinfall",
"status": "Running"
},
{
"normalized_name": "redmatch 2",
"status": "Broken"
},
{
"normalized_name": "blade & soul heroes",
"status": "Broken"
},
{
"normalized_name": "blue archive",
"status": "Running"
},
{
"normalized_name": "midnight murder club",
"status": "Broken"
},
{
"normalized_name": "dungeon done",
"status": "Broken"
},
{
"normalized_name": "project wraith",
"status": "Broken"
},
{
"normalized_name": "solo leveling arise",
"status": "Broken"
},
{
"normalized_name": "freedom wars",
"status": "Running"
},
{
"normalized_name": "open fortress",
"status": "Running"
},
{
"normalized_name": "no more room in hell 2",
"status": "Running"
} }
] ]

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,140 +1,4 @@
[ [
{
"normalized_title": "dirt rally 2.0 game of the year",
"slug": "dirt-rally-2-0-game-of-the-year-edition"
},
{
"normalized_title": "deus ex human revolution directors cut",
"slug": "deus-ex-human-revolution-director-s-cut"
},
{
"normalized_title": "freelancer",
"slug": "freelancer"
},
{
"normalized_title": "everspace",
"slug": "everspace"
},
{
"normalized_title": "blades of time limited",
"slug": "blades-of-time-limited-edition"
},
{
"normalized_title": "chorus",
"slug": "chorus"
},
{
"normalized_title": "tom clancy's splinter cell pandora tomorrow",
"slug": "tom-clancys-splinter-cell-pandora-tomorrow"
},
{
"normalized_title": "the alters",
"slug": "the-alters"
},
{
"normalized_title": "hard reset redux",
"slug": "hard-reset-redux"
},
{
"normalized_title": "far cry 5",
"slug": "far-cry-5"
},
{
"normalized_title": "metal eden",
"slug": "metal-eden"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "old world",
"slug": "old-world"
},
{
"normalized_title": "witchfire",
"slug": "witchfire"
},
{
"normalized_title": "prototype",
"slug": "prototype"
},
{
"normalized_title": "mandragora whispers of the witch tree",
"slug": "mandragora-whispers-of-the-witch-tree"
},
{
"normalized_title": "grand theft auto v (gta 5)",
"slug": "grand-theft-auto-v-gta-5"
},
{
"normalized_title": "lifeless planet premier",
"slug": "lifeless-planet-premier-edition"
},
{
"normalized_title": "warcraft iii the frozen throne",
"slug": "warcraft-iii-the-frozen-throne"
},
{
"normalized_title": "star wars republic commando",
"slug": "star-wars-republic-commando"
},
{
"normalized_title": "hollow knight silksong",
"slug": "hollow-knight-silksong"
},
{
"normalized_title": "arma reforger",
"slug": "arma-reforger"
},
{
"normalized_title": "arma 3",
"slug": "arma-3"
},
{
"normalized_title": "astroneer",
"slug": "astroneer"
},
{
"normalized_title": "anno 2205",
"slug": "anno-2205"
},
{
"normalized_title": "anno 2070",
"slug": "anno-2070"
},
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{
"normalized_title": "ultrakill (early access)",
"slug": "ultrakill-early-access"
},
{
"normalized_title": "vintage story",
"slug": "vintage-story"
},
{
"normalized_title": "disco elysium the finul cut",
"slug": "disco-elysium-the-finul-cut"
},
{
"normalized_title": "warcraft iii reign of chaos",
"slug": "warcraft-iii-reign-of-chaos"
},
{
"normalized_title": "dying light",
"slug": "dying-light"
},
{
"normalized_title": "лихо одноглазое",
"slug": "liho-odnoglazoe"
},
{
"normalized_title": "indika",
"slug": "indika"
},
{ {
"normalized_title": "no sleep for kaname date from ai the somnium files", "normalized_title": "no sleep for kaname date from ai the somnium files",
"slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files" "slug": "no-sleep-for-kaname-date-from-ai-the-somnium-files"
@@ -287,6 +151,10 @@
"normalized_title": "slitterhead", "normalized_title": "slitterhead",
"slug": "slitterhead" "slug": "slitterhead"
}, },
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{ {
"normalized_title": "crossout", "normalized_title": "crossout",
"slug": "crossout" "slug": "crossout"
@@ -367,6 +235,10 @@
"normalized_title": "cardlife creative survival", "normalized_title": "cardlife creative survival",
"slug": "cardlife-creative-survival" "slug": "cardlife-creative-survival"
}, },
{
"normalized_title": "kompas 3d v23 / компас 3d v23",
"slug": "kompas-3d-v23-kompas-3d-v23"
},
{ {
"normalized_title": "kompas 3d v24 / компас 3d v24 beta", "normalized_title": "kompas 3d v24 / компас 3d v24 beta",
"slug": "kompas-3d-v24-kompas-3d-v24-beta" "slug": "kompas-3d-v24-kompas-3d-v24-beta"

Binary file not shown.

View File

@@ -17,6 +17,4 @@ Generated-By:
start.sh start.sh
EGS EGS
Stop Game Stop Game
Fullscreen
Fulscreen
\t \t

View File

@@ -2,7 +2,6 @@
import argparse import argparse
import re import re
import subprocess
from pathlib import Path from pathlib import Path
from datetime import date from datetime import date
@@ -135,12 +134,6 @@ def main():
print(f"Updated version from {old} to {new} in {len(updated)} files:") print(f"Updated version from {old} to {new} in {len(updated)} files:")
for p in sorted(updated): for p in sorted(updated):
print(f" - {p}") print(f" - {p}")
try:
subprocess.run(["uv", "lock"], check=True)
print("Regenerated uv.lock")
except subprocess.CalledProcessError as e:
print(f"Failed to regenerate uv.lock: {e}")
else: else:
print(f"No occurrences of version {old} found in specified files.") print(f"No occurrences of version {old} found in specified files.")

View File

@@ -3,9 +3,8 @@
import sys import sys
from pathlib import Path from pathlib import Path
import re import re
import ast
# Запрещенные QSS-свойства # Запрещенные свойства
FORBIDDEN_PROPERTIES = { FORBIDDEN_PROPERTIES = {
"box-shadow", "box-shadow",
"backdrop-filter", "backdrop-filter",
@@ -13,55 +12,15 @@ FORBIDDEN_PROPERTIES = {
"text-shadow", "text-shadow",
} }
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_qss_files(): def check_qss_files():
has_errors = False has_errors = False
for qss_file in Path("portprotonqt/themes").glob("**/*.py"): for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
with open(qss_file, "r") as f: with open(qss_file, "r") as f:
content = f.read() content = f.read()
# Проверка на запрещённые QSS-свойства
for prop in FORBIDDEN_PROPERTIES: for prop in FORBIDDEN_PROPERTIES:
if re.search(rf"{prop}\s*:", content, re.IGNORECASE): if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
print(f"ERROR: Unknown QSS property found '{prop}' in file {qss_file}") print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}")
has_errors = True has_errors = True
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, (ast.Import, ast.ImportFrom)):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
print(f"ERROR: Forbidden module '{name.name}' found in file {qss_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
print(f"ERROR: Forbidden function '{node.func.id}' found in file {qss_file}")
has_errors = True
except SyntaxError as e:
print(f"ERROR: Syntax error in file {qss_file}: {e}")
has_errors = True
return has_errors return has_errors
if __name__ == "__main__": if __name__ == "__main__":

View File

@@ -21,9 +21,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 249 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 195 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 249 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 195 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 of 249 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 of 195 |
--- ---

View File

@@ -21,9 +21,9 @@
| Локаль | Прогресс | Переведено | | Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 249 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 249 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 195 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 249 из 249 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 195 из 195 |
--- ---

View File

@@ -52,151 +52,102 @@ The `GAME_CARD_ANIMATION` dictionary controls all animation parameters for game
```python ```python
GAME_CARD_ANIMATION = { GAME_CARD_ANIMATION = {
# Type of animation when entering or exiting the detail page # Type of animation when entering and exiting the detail page
# Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce" # Possible values: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Determines how the detail page appears and disappears
"detail_page_animation_type": "fade", "detail_page_animation_type": "fade",
# Border width of the card in idle state (no hover or focus) # Border width of the card in idle state (no hover or focus).
# Affects the thickness of the border around the card when it's not selected # Affects the thickness of the border when the card is not highlighted.
# Value in pixels # Value in pixels.
"default_border_width": 2, "default_border_width": 2,
# Border width on hover # Border width on hover.
# Increases the border thickness when the cursor is over the card # Increases the border thickness when the cursor is over the card.
# Value in pixels # Value in pixels.
"hover_border_width": 8, "hover_border_width": 8,
# Border width on focus (e.g., when selected via keyboard) # Border width on focus (e.g., selected via keyboard).
# Increases the border thickness when the card is focused # Increases the border thickness when the card is focused.
# Value in pixels # Value in pixels.
"focus_border_width": 12, "focus_border_width": 12,
# Minimum border width during pulsing animation # Minimum border width during pulsing animation.
# Determines the minimum border thickness during the "breathing" animation # Sets the minimum border thickness during the "breathing" animation.
# Value in pixels # Value in pixels.
"pulse_min_border_width": 8, "pulse_min_border_width": 8,
# Maximum border width during pulsing animation # Maximum border width during pulsing animation.
# Determines the maximum border thickness during pulsing # Sets the maximum border thickness during pulsing.
# Value in pixels # Value in pixels.
"pulse_max_border_width": 10, "pulse_max_border_width": 10,
# Duration of the border thickness animation (e.g., on hover or focus) # Duration of the border thickness animation (e.g., on hover or focus).
# Affects the speed of transition from one border width to another # Affects the speed of transition between different border widths.
# Value in milliseconds # Value in milliseconds.
"thickness_anim_duration": 300, "thickness_anim_duration": 300,
# Duration of one pulsing animation cycle # Duration of one pulsing animation cycle.
# Determines how fast the border "pulses" between min and max values # Defines how fast the border "pulses" between min and max values.
# Value in milliseconds # Value in milliseconds.
"pulse_anim_duration": 800, "pulse_anim_duration": 800,
# Duration of the gradient rotation animation # Duration of the gradient rotation animation.
# Affects how fast the gradient border rotates around the card # Affects how fast the gradient border rotates around the card.
# Value in milliseconds # Value in milliseconds.
"gradient_anim_duration": 3000, "gradient_anim_duration": 3000,
# Starting angle of the gradient (in degrees) # Starting angle of the gradient (in degrees).
# Determines the initial rotation point of the gradient at animation start # Defines the initial rotation point of the gradient when the animation starts.
"gradient_start_angle": 360, "gradient_start_angle": 360,
# Ending angle of the gradient (in degrees) # Ending angle of the gradient (in degrees).
# Determines the final rotation point of the gradient # Defines the end rotation point of the gradient.
# Value 0 means a full 360° rotation # A value of 0 means a full 360-degree rotation.
"gradient_end_angle": 0, "gradient_end_angle": 0,
# Type of card animation on hover or focus # Easing curve type for border expansion animation (on hover/focus).
# Possible values: "gradient", "scale" # Affects the "feel" of the animation (e.g., smooth acceleration or deceleration).
# "gradient" enables a rotating gradient for the border, "scale" enlarges the card # Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad").
"card_animation_type": "gradient",
# Card scale in idle state
# Determines the base size of the card (1.0 = 100% of original size)
# Value as a fraction (e.g., 1.0 for normal size)
"default_scale": 1.0,
# Card scale on hover
# Increases the card size on hover
# Value as a fraction (e.g., 1.1 = 110% of original size)
"hover_scale": 1.1,
# Card scale on focus (e.g., when selected via keyboard)
# Increases the card size on focus
# Value as a fraction (e.g., 1.05 = 105% of original size)
"focus_scale": 1.05,
# Duration of scale animation
# Affects how fast the card changes size on hover or focus
# Value in milliseconds
"scale_anim_duration": 200,
# Easing curve type for border thickness increase animation (on hover/focus)
# Affects the "feel" of the animation (e.g., smooth acceleration or deceleration)
# Possible values: strings corresponding to QEasingCurve.Type (e.g., "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack", "thickness_easing_curve": "OutBack",
# Easing curve type for border thickness decrease animation (on hover/focus exit) # Easing curve type for border contraction animation (on mouse leave/focus loss).
# Affects the "feel" of returning to the default border width # Affects the "feel" of returning to the original border width.
"thickness_easing_curve_out": "InBack", "thickness_easing_curve_out": "InBack",
# Easing curve type for scale increase animation (on hover/focus) # Gradient colors for the animated border.
# Affects the "feel" of the scaling animation (e.g., with a "bounce" effect) # A list of dictionaries where each defines a position (0.01.0) and color in hex format.
# Possible values: strings corresponding to QEasingCurve.Type # Affects the appearance of the border on hover or focus.
"scale_easing_curve": "OutBack",
# Easing curve type for scale decrease animation (on hover/focus exit)
# Affects the "feel" of returning to the original scale
"scale_easing_curve_out": "InBack",
# Gradient colors for animated border
# List of dictionaries, each specifying position (0.01.0) and color in hex format
# Affects the appearance of the border on hover or focus if card_animation_type="gradient"
"gradient_colors": [ "gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Starting color (cyan) {"position": 0, "color": "#00fff5"}, # Start color (cyan)
{"position": 0.33, "color": "#FF5733"}, # Color at 33% (orange) {"position": 0.33, "color": "#FF5733"}, # 33% color (orange)
{"position": 0.66, "color": "#9B59B6"}, # Color at 66% (purple) {"position": 0.66, "color": "#9B59B6"}, # 66% color (purple)
{"position": 1, "color": "#00fff5"} # Ending color (back to cyan) {"position": 1, "color": "#00fff5"} # End color (back to cyan)
], ],
# Duration of fade animation when entering the detail page # Duration of the fade animation when entering the detail page
# Affects the speed of page appearance with fade animation
# Value in milliseconds
"detail_page_fade_duration": 350, "detail_page_fade_duration": 350,
# Duration of slide animation when entering the detail page # Duration of the slide animation when entering the detail page
# Affects the speed of page sliding animation
# Value in milliseconds
"detail_page_slide_duration": 500, "detail_page_slide_duration": 500,
# Duration of bounce animation when entering the detail page # Duration of the bounce animation when entering the detail page
# Affects the speed of page "bounce" animation
# Value in milliseconds
"detail_page_bounce_duration": 400, "detail_page_bounce_duration": 400,
# Duration of fade animation when exiting the detail page # Duration of the fade animation when exiting the detail page
# Affects the speed of page disappearance with fade animation
# Value in milliseconds
"detail_page_fade_duration_exit": 350, "detail_page_fade_duration_exit": 350,
# Duration of slide animation when exiting the detail page # Duration of the slide animation when exiting the detail page
# Affects the speed of page sliding animation
# Value in milliseconds
"detail_page_slide_duration_exit": 500, "detail_page_slide_duration_exit": 500,
# Duration of bounce animation when exiting the detail page # Duration of the bounce animation when exiting the detail page
# Affects the speed of page "compression" animation
# Value in milliseconds
"detail_page_bounce_duration_exit": 400, "detail_page_bounce_duration_exit": 400,
# Easing curve type for animations when entering the detail page # Easing curve type for animation when entering the detail page
# Applied to slide and bounce animations; affects the "feel" of movement # Applies to slide and bounce animations
# Possible values: strings corresponding to QEasingCurve.Type
"detail_page_easing_curve": "OutCubic", "detail_page_easing_curve": "OutCubic",
# Easing curve type for animations when exiting the detail page # Easing curve type for animation when exiting the detail page
# Applied to slide and bounce animations; affects the "feel" of movement # Applies to slide and bounce animations
# Possible values: strings corresponding to QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic" "detail_page_easing_curve_exit": "InCubic"
} }
``` ```

View File

@@ -54,104 +54,69 @@ def custom_button_style(color1, color2):
GAME_CARD_ANIMATION = { GAME_CARD_ANIMATION = {
# Тип анимации при входе и выходе на детальную страницу # Тип анимации при входе и выходе на детальную страницу
# Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce" # Возможные значения: "fade", "slide_left", "slide_right", "slide_up", "slide_down", "bounce"
# Определяет, как детальная страница появляется и исчезает
"detail_page_animation_type": "fade", "detail_page_animation_type": "fade",
# Ширина обводки карточки в состоянии покоя (без наведения или фокуса) # Ширина обводки карточки в состоянии покоя (без наведения или фокуса).
# Влияет на толщину рамки вокруг карточки, когда она не выделена # Влияет на толщину рамки вокруг карточки, когда она не выделена.
# Значение в пикселях # Значение в пикселях.
"default_border_width": 2, "default_border_width": 2,
# Ширина обводки при наведении курсора # Ширина обводки при наведении курсора.
# Увеличивает толщину рамки, когда курсор находится над карточкой # Увеличивает толщину рамки, когда курсор находится над карточкой.
# Значение в пикселях # Значение в пикселях.
"hover_border_width": 8, "hover_border_width": 8,
# Ширина обводки при фокусе (например, при выборе с клавиатуры) # Ширина обводки при фокусе (например, при выборе с клавиатуры).
# Увеличивает толщину рамки, когда карточка в фокусе # Увеличивает толщину рамки, когда карточка в фокусе.
# Значение в пикселях # Значение в пикселях.
"focus_border_width": 12, "focus_border_width": 12,
# Минимальная ширина обводки во время пульсирующей анимации # Минимальная ширина обводки во время пульсирующей анимации.
# Определяет минимальную толщину рамки при пульсации (анимация "дыхания") # Определяет минимальную толщину рамки при пульсации (анимация "дыхания").
# Значение в пикселях # Значение в пикселях.
"pulse_min_border_width": 8, "pulse_min_border_width": 8,
# Максимальная ширина обводки во время пульсирующей анимации # Максимальная ширина обводки во время пульсирующей анимации.
# Определяет максимальную толщину рамки при пульсации # Определяет максимальную толщину рамки при пульсации.
# Значение в пикселях # Значение в пикселях.
"pulse_max_border_width": 10, "pulse_max_border_width": 10,
# Длительность анимации изменения толщины обводки (например, при наведении или фокусе) # Длительность анимации изменения толщины обводки (например, при наведении или фокусе).
# Влияет на скорость перехода от одной ширины обводки к другой # Влияет на скорость перехода от одной ширины обводки к другой.
# Значение в миллисекундах # Значение в миллисекундах.
"thickness_anim_duration": 300, "thickness_anim_duration": 300,
# Длительность одного цикла пульсирующей анимации # Длительность одного цикла пульсирующей анимации.
# Определяет, как быстро рамка "пульсирует" между min и max значениями # Определяет, как быстро рамка "пульсирует" между min и max значениями.
# Значение в миллисекундах # Значение в миллисекундах.
"pulse_anim_duration": 800, "pulse_anim_duration": 800,
# Длительность анимации вращения градиента # Длительность анимации вращения градиента.
# Влияет на скорость, с которой градиентная обводка вращается вокруг карточки # Влияет на скорость, с которой градиентная обводка вращается вокруг карточки.
# Значение в миллисекундах # Значение в миллисекундах.
"gradient_anim_duration": 3000, "gradient_anim_duration": 3000,
# Начальный угол градиента (в градусах) # Начальный угол градиента (в градусах).
# Определяет начальную точку вращения градиента при старте анимации # Определяет начальную точку вращения градиента при старте анимации.
"gradient_start_angle": 360, "gradient_start_angle": 360,
# Конечный угол градиента (в градусах) # Конечный угол градиента (в градусах).
# Определяет конечную точку вращения градиента # Определяет конечную точку вращения градиента.
# Значение 0 означает полный поворот на 360 градусов # Значение 0 означает полный поворот на 360 градусов.
"gradient_end_angle": 0, "gradient_end_angle": 0,
# Тип анимации для карточки при наведении или фокусе # Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе).
# Возможные значения: "gradient", "scale" # Влияет на "чувство" анимации (например, плавное ускорение или замедление).
# "gradient" включает вращающийся градиент для обводки, "scale" увеличивает размер карточки # Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad").
"card_animation_type": "gradient",
# Масштаб карточки в состоянии покоя
# Определяет базовый размер карточки (1.0 = 100% от исходного размера)
# Значение в долях (например, 1.0 для нормального размера)
"default_scale": 1.0,
# Масштаб карточки при наведении курсора
# Увеличивает размер карточки при наведении
# Значение в долях (например, 1.1 = 110% от исходного размера)
"hover_scale": 1.1,
# Масштаб карточки при фокусе (например, при выборе с клавиатуры)
# Увеличивает размер карточки при фокусе
# Значение в долях (например, 1.05 = 105% от исходного размера)
"focus_scale": 1.05,
# Длительность анимации масштабирования
# Влияет на скорость изменения размера карточки при наведении или фокусе
# Значение в миллисекундах
"scale_anim_duration": 200,
# Тип кривой сглаживания для анимации увеличения обводки (при наведении/фокусе)
# Влияет на "чувство" анимации (например, плавное ускорение или замедление)
# Возможные значения: строки, соответствующие QEasingCurve.Type (например, "OutBack", "InOutQuad")
"thickness_easing_curve": "OutBack", "thickness_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса) # Тип кривой сглаживания для анимации уменьшения обводки (при уходе курсора/потере фокуса).
# Влияет на "чувство" возврата к исходной ширине обводки # Влияет на "чувство" возврата к исходной ширине обводки.
"thickness_easing_curve_out": "InBack", "thickness_easing_curve_out": "InBack",
# Тип кривой сглаживания для анимации увеличения масштаба (при наведении/фокусе) # Цвета градиента для анимированной обводки.
# Влияет на "чувство" анимации масштабирования (например, с эффектом "отскока") # Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex.
# Возможные значения: строки, соответствующие QEasingCurve.Type # Влияет на внешний вид обводки при наведении или фокусе.
"scale_easing_curve": "OutBack",
# Тип кривой сглаживания для анимации уменьшения масштаба (при уходе курсора/потере фокуса)
# Влияет на "чувство" возврата к исходному масштабу
"scale_easing_curve_out": "InBack",
# Цвета градиента для анимированной обводки
# Список словарей, где каждый словарь задает позицию (0.01.0) и цвет в формате hex
# Влияет на внешний вид обводки при наведении или фокусе, если card_animation_type="gradient"
"gradient_colors": [ "gradient_colors": [
{"position": 0, "color": "#00fff5"}, # Начальный цвет (циан) {"position": 0, "color": "#00fff5"}, # Начальный цвет (циан)
{"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый) {"position": 0.33, "color": "#FF5733"}, # Цвет на 33% (оранжевый)
@@ -160,43 +125,29 @@ GAME_CARD_ANIMATION = {
], ],
# Длительность анимации fade при входе на детальную страницу # Длительность анимации fade при входе на детальную страницу
# Влияет на скорость появления страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration": 350, "detail_page_fade_duration": 350,
# Длительность анимации slide при входе на детальную страницу # Длительность анимации slide при входе на детальную страницу
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration": 500, "detail_page_slide_duration": 500,
# Длительность анимации bounce при входе на детальную страницу # Длительность анимации bounce при входе на детальную страницу
# Влияет на скорость "прыжка" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration": 400, "detail_page_bounce_duration": 400,
# Длительность анимации fade при выходе из детальной страницы # Длительность анимации fade при выходе из детальной страницы
# Влияет на скорость исчезновения страницы при fade-анимации
# Значение в миллисекундах
"detail_page_fade_duration_exit": 350, "detail_page_fade_duration_exit": 350,
# Длительность анимации slide при выходе из детальной страницы # Длительность анимации slide при выходе из детальной страницы
# Влияет на скорость скольжения страницы при slide-анимации
# Значение в миллисекундах
"detail_page_slide_duration_exit": 500, "detail_page_slide_duration_exit": 500,
# Длительность анимации bounce при выходе из детальной страницы # Длительность анимации bounce при выходе из детальной страницы
# Влияет на скорость "сжатия" страницы при bounce-анимации
# Значение в миллисекундах
"detail_page_bounce_duration_exit": 400, "detail_page_bounce_duration_exit": 400,
# Тип кривой сглаживания для анимации при входе на детальную страницу # Тип кривой сглаживания для анимации при входе на детальную страницу
# Применяется к slide и bounce анимациям, влияет на "чувство" движения # Применяется к slide и bounce анимациям
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve": "OutCubic", "detail_page_easing_curve": "OutCubic",
# Тип кривой сглаживания для анимации при выходе из детальной страницы # Тип кривой сглаживания для анимации при выходе из детальной страницы
# Применяется к slide и bounce анимациям, влияет на "чувство" движения # Применяется к slide и bounce анимациям
# Возможные значения: строки, соответствующие QEasingCurve.Type
"detail_page_easing_curve_exit": "InCubic" "detail_page_easing_curve_exit": "InCubic"
} }
``` ```

View File

@@ -2,9 +2,8 @@ from PySide6.QtCore import QPropertyAnimation, QByteArray, QEasingCurve, QAbstra
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush
from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect from PySide6.QtWidgets import QWidget, QGraphicsOpacityEffect
from collections.abc import Callable from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -24,27 +23,17 @@ class SafeOpacityEffect(QGraphicsOpacityEffect):
class GameCardAnimations: class GameCardAnimations:
def __init__(self, game_card, theme=None): def __init__(self, game_card, theme=None):
self.game_card = game_card self.game_card = game_card
self.theme_manager = ThemeManager() self.theme = theme if theme is not None else default_styles
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.thickness_anim: QPropertyAnimation | None = None self.thickness_anim: QPropertyAnimation | None = None
self.gradient_anim: QPropertyAnimation | None = None self.gradient_anim: QPropertyAnimation | None = None
self.scale_anim: QPropertyAnimation | None = None
self.pulse_anim: QPropertyAnimation | None = None self.pulse_anim: QPropertyAnimation | None = None
self._isPulseAnimationConnected = False self._isPulseAnimationConnected = False
def setup_animations(self): def setup_animations(self):
"""Initialize animation properties based on theme.""" """Initialize animation properties."""
self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth")) self.thickness_anim = QPropertyAnimation(self.game_card, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"]) self.thickness_anim.setDuration(self.theme.GAME_CARD_ANIMATION["thickness_anim_duration"])
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if animation_type == "gradient":
self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(self.theme.GAME_CARD_ANIMATION["gradient_anim_duration"])
elif animation_type == "scale":
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
def start_pulse_animation(self): def start_pulse_animation(self):
"""Start pulse animation for border width when hovered or focused.""" """Start pulse animation for border width when hovered or focused."""
if not (self.game_card._hovered or self.game_card._focused): if not (self.game_card._hovered or self.game_card._focused):
@@ -68,8 +57,6 @@ class GameCardAnimations:
if not self.thickness_anim: if not self.thickness_anim:
self.setup_animations() self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
@@ -82,44 +69,23 @@ class GameCardAnimations:
self._isPulseAnimationConnected = True self._isPulseAnimationConnected = True
self.thickness_anim.start() self.thickness_anim.start()
if animation_type == "gradient": if self.gradient_anim:
if self.gradient_anim: self.gradient_anim.stop()
self.gradient_anim.stop() self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
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.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
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.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) self.gradient_anim.setLoopCount(-1)
self.gradient_anim.setLoopCount(-1) self.gradient_anim.start()
self.gradient_anim.start()
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["hover_scale"])
self.scale_anim.start()
def handle_leave_event(self): def handle_leave_event(self):
"""Handle mouse leave event animations.""" """Handle mouse leave event animations."""
self.game_card._hovered = False self.game_card._hovered = False
self.game_card.hoverChanged.emit(self.game_card.name, False) self.game_card.hoverChanged.emit(self.game_card.name, False)
if not self.game_card._focused: if not self.game_card._focused:
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") if self.gradient_anim:
if animation_type == "gradient": self.gradient_anim.stop()
if self.gradient_anim: self.gradient_anim = None
self.gradient_anim.stop()
self.gradient_anim = None
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start()
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() self.pulse_anim.stop()
self.pulse_anim = None self.pulse_anim = None
@@ -142,8 +108,6 @@ class GameCardAnimations:
if not self.thickness_anim: if not self.thickness_anim:
self.setup_animations() self.setup_animations()
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient")
if self.thickness_anim: if self.thickness_anim:
self.thickness_anim.stop() self.thickness_anim.stop()
if self._isPulseAnimationConnected: if self._isPulseAnimationConnected:
@@ -156,44 +120,23 @@ class GameCardAnimations:
self._isPulseAnimationConnected = True self._isPulseAnimationConnected = True
self.thickness_anim.start() self.thickness_anim.start()
if animation_type == "gradient": if self.gradient_anim:
if self.gradient_anim: self.gradient_anim.stop()
self.gradient_anim.stop() self.gradient_anim = QPropertyAnimation(self.game_card, QByteArray(b"gradientAngle"))
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.theme.GAME_CARD_ANIMATION["gradient_anim_duration"]) self.gradient_anim.setStartValue(self.theme.GAME_CARD_ANIMATION["gradient_start_angle"])
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.setEndValue(self.theme.GAME_CARD_ANIMATION["gradient_end_angle"]) self.gradient_anim.setLoopCount(-1)
self.gradient_anim.setLoopCount(-1) self.gradient_anim.start()
self.gradient_anim.start()
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["focus_scale"])
self.scale_anim.start()
def handle_focus_out_event(self): def handle_focus_out_event(self):
"""Handle focus out event animations.""" """Handle focus out event animations."""
self.game_card._focused = False self.game_card._focused = False
self.game_card.focusChanged.emit(self.game_card.name, False) self.game_card.focusChanged.emit(self.game_card.name, False)
if not self.game_card._hovered: if not self.game_card._hovered:
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") if self.gradient_anim:
if animation_type == "gradient": self.gradient_anim.stop()
if self.gradient_anim: self.gradient_anim = None
self.gradient_anim.stop()
self.gradient_anim = None
elif animation_type == "scale":
if self.scale_anim:
self.scale_anim.stop()
self.scale_anim = QPropertyAnimation(self.game_card, QByteArray(b"scale"))
self.scale_anim.setDuration(self.theme.GAME_CARD_ANIMATION["scale_anim_duration"])
self.scale_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION["scale_easing_curve_out"]]))
self.scale_anim.setStartValue(self.game_card._scale)
self.scale_anim.setEndValue(self.theme.GAME_CARD_ANIMATION["default_scale"])
self.scale_anim.start()
if self.pulse_anim: if self.pulse_anim:
self.pulse_anim.stop() self.pulse_anim.stop()
self.pulse_anim = None self.pulse_anim = None
@@ -209,13 +152,12 @@ class GameCardAnimations:
def paint_border(self, painter: QPainter): def paint_border(self, painter: QPainter):
if not painter.isActive(): if not painter.isActive():
logger.debug("Painter is not active; skipping border paint") logger.warning("Painter is not active; skipping border paint")
return return
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen() pen = QPen()
pen.setWidth(self.game_card._borderWidth) pen.setWidth(self.game_card._borderWidth)
animation_type = self.theme.GAME_CARD_ANIMATION.get("card_animation_type", "gradient") if self.game_card._hovered or self.game_card._focused:
if (self.game_card._hovered or self.game_card._focused) and animation_type == "gradient":
center = self.game_card.rect().center() center = self.game_card.rect().center()
gradient = QConicalGradient(center, self.game_card._gradientAngle) gradient = QConicalGradient(center, self.game_card._gradientAngle)
for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]: for stop in self.theme.GAME_CARD_ANIMATION["gradient_colors"]:
@@ -224,18 +166,17 @@ class GameCardAnimations:
else: else:
pen.setColor(QColor(0, 0, 0, 0)) pen.setColor(QColor(0, 0, 0, 0))
painter.setPen(pen) painter.setPen(pen)
radius = 18 * self.game_card._scale radius = 18
bw = round(self.game_card._borderWidth / 2) bw = round(self.game_card._borderWidth / 2)
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw) rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
if rect.isEmpty(): if rect.isEmpty():
return return # Avoid drawing invalid rect
painter.drawRoundedRect(rect, radius, radius) painter.drawRoundedRect(rect, radius, radius)
class DetailPageAnimations: class DetailPageAnimations:
def __init__(self, main_window, theme=None): def __init__(self, main_window, theme=None):
self.main_window = main_window self.main_window = main_window
self.theme_manager = ThemeManager() self.theme = theme if theme is not None else default_styles
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.animations = main_window._animations if hasattr(main_window, '_animations') else {} self.animations = main_window._animations if hasattr(main_window, '_animations') else {}
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable): def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
@@ -258,7 +199,7 @@ class DetailPageAnimations:
try: try:
detail_page.setGraphicsEffect(original_effect) # type: ignore detail_page.setGraphicsEffect(original_effect) # type: ignore
except RuntimeError: except RuntimeError:
logger.warning("Original effect already deleted") logger.debug("Original effect already deleted")
animation.finished.connect(restore_effect) animation.finished.connect(restore_effect)
animation.finished.connect(load_image_and_restore_effect) animation.finished.connect(load_image_and_restore_effect)
animation.finished.connect(opacity_effect.deleteLater) animation.finished.connect(opacity_effect.deleteLater)
@@ -317,7 +258,7 @@ class DetailPageAnimations:
if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running: if isinstance(animation, QAbstractAnimation) and animation.state() == QAbstractAnimation.State.Running:
animation.stop() animation.stop()
except RuntimeError: except RuntimeError:
logger.warning("Animation already deleted for page") logger.debug("Animation already deleted for page")
except Exception as e: except Exception as e:
logger.error(f"Error stopping existing animation: {e}", exc_info=True) logger.error(f"Error stopping existing animation: {e}", exc_info=True)
finally: finally:
@@ -343,15 +284,15 @@ class DetailPageAnimations:
logger.debug("Original effect already deleted") logger.debug("Original effect already deleted")
cleanup_callback() cleanup_callback()
animation.finished.connect(restore_and_cleanup) animation.finished.connect(restore_and_cleanup)
animation.finished.connect(opacity_effect.deleteLater) animation.finished.connect(opacity_effect.deleteLater) # Clean up effect
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]: elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])
end_pos = { end_pos = {
"slide_left": QPoint(-self.main_window.width(), 0), "slide_left": QPoint(-self.main_window.width(), 0), # Exit to left (opposite of entry)
"slide_right": QPoint(self.main_window.width(), 0), "slide_right": QPoint(self.main_window.width(), 0), # Exit to right
"slide_up": QPoint(0, self.main_window.height()), "slide_up": QPoint(0, self.main_window.height()), # Exit downward
"slide_down": QPoint(0, -self.main_window.height()) "slide_down": QPoint(0, -self.main_window.height()) # Exit upward
}[animation_type] }[animation_type]
animation = QPropertyAnimation(detail_page, QByteArray(b"pos")) animation = QPropertyAnimation(detail_page, QByteArray(b"pos"))
animation.setDuration(duration) animation.setDuration(duration)
@@ -384,4 +325,4 @@ class DetailPageAnimations:
except Exception as e: except Exception as e:
logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True) logger.error(f"Error in animate_detail_page_exit: {e}", exc_info=True)
self.animations.pop(detail_page, None) self.animations.pop(detail_page, None)
cleanup_callback() cleanup_callback() # Fallback to cleanup if animation setup fails

View File

@@ -1,65 +1,36 @@
import sys import sys
import os
import subprocess
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location from portprotonqt.config_utils import save_fullscreen_config
from portprotonqt.logger import get_logger, setup_logger from portprotonqt.logger import get_logger
from portprotonqt.cli import parse_args from portprotonqt.cli import parse_args
logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.8" __app_version__ = "0.1.4"
def get_version():
try:
commit = subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'],
stderr=subprocess.DEVNULL
).decode('utf-8').strip()
return f"{__app_version__} ({commit})"
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return __app_version__
def main(): def main():
os.environ['PW_CLI'] = '1'
os.environ['PROCESS_LOG'] = '1'
os.environ['START_FROM_STEAM'] = '1'
portproton_path = get_portproton_location()
if portproton_path is None:
return
script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh')
subprocess.run([script_path, 'cli', '--initial'])
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__)) app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__) app.setDesktopFileName(__app_id__)
app.setApplicationName(__app_name__) app.setApplicationName(__app_name__)
app.setApplicationVersion(__app_version__) app.setApplicationVersion(__app_version__)
args = parse_args()
# Setup logger with specified debug level
setup_logger(args.debug_level)
# Reinitialize logger after setup to ensure it uses the new configuration
logger = get_logger(__name__)
system_locale = QLocale.system() system_locale = QLocale.system()
qt_translator = QTranslator() qt_translator = QTranslator()
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if qt_translator.load(system_locale, "qtbase", "_", translations_path): if qt_translator.load(system_locale, "qtbase", "_", translations_path):
app.installTranslator(qt_translator) app.installTranslator(qt_translator)
else: else:
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language") logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
version = get_version() args = parse_args()
window = MainWindow(app_name=__app_name__, version=version)
window = MainWindow()
if args.fullscreen: if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag") logger.info("Launching in fullscreen mode due to --fullscreen flag")

View File

@@ -1,20 +1,16 @@
import argparse import argparse
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def parse_args(): def parse_args():
""" """
Parses command-line arguments. Парсит аргументы командной строки.
""" """
parser = argparse.ArgumentParser(description="PortProtonQt CLI") parser = argparse.ArgumentParser(description="PortProtonQt CLI")
parser.add_argument( parser.add_argument(
"--fullscreen", "--fullscreen",
action="store_true", action="store_true",
help="Launch the application in fullscreen mode and save this setting" help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
)
parser.add_argument(
"--debug-level",
choices=['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
default='NOTSET',
help="Установить уровень логирования (ALL для всех сообщений, по умолчанию: без логов)"
) )
return parser.parse_args() return parser.parse_args()

View File

@@ -7,7 +7,7 @@ logger = get_logger(__name__)
_portproton_location = None _portproton_location = None
# Paths to configuration files # Пути к конфигурационным файлам
CONFIG_FILE = os.path.join( CONFIG_FILE = os.path.join(
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")), os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
"PortProtonQt.conf" "PortProtonQt.conf"
@@ -18,32 +18,17 @@ PORTPROTON_CONFIG_FILE = os.path.join(
"PortProton.conf" "PortProton.conf"
) )
# Paths to theme directories # Пути к папкам с темами
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQt", "themes"), os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
] ]
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails."""
cp = configparser.ConfigParser()
if not os.path.exists(config_file):
logger.debug(f"Configuration file {config_file} not found")
return None
try:
cp.read(config_file, encoding="utf-8")
return cp
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.warning(f"Invalid configuration file format: {e}")
return None
except Exception as e:
logger.warning(f"Failed to read configuration file: {e}")
return None
def read_config(): def read_config():
"""Reads the configuration file and returns a dictionary of parameters. """
Example line in config (no sections): Читает конфигурационный файл и возвращает словарь параметров.
Пример строки в конфиге (без секций):
detail_level = detailed detail_level = detailed
""" """
config_dict = {} config_dict = {}
@@ -59,17 +44,29 @@ def read_config():
return config_dict return config_dict
def read_theme_from_config(): def read_theme_from_config():
"""Reads the theme from the [Appearance] section of the configuration file.
Returns 'standart' if the parameter is not set.
""" """
cp = read_config_safely(CONFIG_FILE) Читает из конфигурационного файла тему из секции [Appearance].
if cp is None: Если параметр не задан, возвращает "standart".
return "standart" """
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return "standart"
return cp.get("Appearance", "theme", fallback="standart") return cp.get("Appearance", "theme", fallback="standart")
def save_theme_to_config(theme_name): def save_theme_to_config(theme_name):
"""Saves the selected theme name to the [Appearance] section of the configuration file.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Appearance" not in cp: if "Appearance" not in cp:
cp["Appearance"] = {} cp["Appearance"] = {}
cp["Appearance"]["theme"] = theme_name cp["Appearance"]["theme"] = theme_name
@@ -77,18 +74,34 @@ def save_theme_to_config(theme_name):
cp.write(configfile) cp.write(configfile)
def read_time_config(): def read_time_config():
"""Reads time settings from the [Time] section of the configuration file.
If the section or parameter is missing, saves and returns 'detailed' as default.
""" """
cp = read_config_safely(CONFIG_FILE) Читает настройки времени из секции [Time] конфигурационного файла.
if cp is None or not cp.has_section("Time") or not cp.has_option("Time", "detail_level"): Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию.
save_time_config("detailed") """
return "detailed" cp = configparser.ConfigParser()
return cp.get("Time", "detail_level", fallback="detailed").lower() if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_time_config("detailed")
return "detailed"
if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
save_time_config("detailed")
return "detailed"
return cp.get("Time", "detail_level", fallback="detailed").lower()
return "detailed"
def save_time_config(detail_level): def save_time_config(detail_level):
"""Saves the time detail level to the [Time] section of the configuration file.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет настройку уровня детализации времени в секции [Time].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Time" not in cp: if "Time" not in cp:
cp["Time"] = {} cp["Time"] = {}
cp["Time"]["detail_level"] = detail_level cp["Time"]["detail_level"] = detail_level
@@ -96,42 +109,48 @@ def save_time_config(detail_level):
cp.write(configfile) cp.write(configfile)
def read_file_content(file_path): def read_file_content(file_path):
"""Reads the content of a file and returns it as a string.""" """
Читает содержимое файла и возвращает его как строку.
"""
with open(file_path, encoding="utf-8") as f: with open(file_path, encoding="utf-8") as f:
return f.read().strip() return f.read().strip()
def get_portproton_location(): def get_portproton_location():
"""Returns the path to the PortProton directory. """
Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE. Возвращает путь к директории PortProton.
If the path is invalid, uses the default directory. Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
используется директория по умолчанию.
""" """
global _portproton_location global _portproton_location
if _portproton_location is not None: if _portproton_location is not None:
return _portproton_location return _portproton_location
# Попытка чтения пути из конфигурационного файла
if os.path.isfile(PORTPROTON_CONFIG_FILE): if os.path.isfile(PORTPROTON_CONFIG_FILE):
try: try:
location = read_file_content(PORTPROTON_CONFIG_FILE).strip() location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
if location and os.path.isdir(location): if location and os.path.isdir(location):
_portproton_location = location _portproton_location = location
logger.info(f"PortProton path from configuration: {location}") logger.info(f"Путь PortProton из конфигурации: {location}")
return _portproton_location return _portproton_location
logger.warning(f"Invalid PortProton path in configuration: {location}, using default path") logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
except (OSError, PermissionError) as e: except (OSError, PermissionError) as e:
logger.warning(f"Failed to read PortProton configuration file: {e}, using default path") logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton") default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_dir): if os.path.isdir(default_dir):
_portproton_location = default_dir _portproton_location = default_dir
logger.info(f"Using flatpak PortProton directory: {default_dir}") logger.info(f"Используется директория flatpak PortProton: {default_dir}")
return _portproton_location return _portproton_location
logger.warning("PortProton configuration and flatpak directory not found") logger.warning("Конфигурация и директория flatpak PortProton не найдены")
return None return None
def parse_desktop_entry(file_path): def parse_desktop_entry(file_path):
"""Reads and parses a .desktop file using configparser. """
Returns None if the [Desktop Entry] section is missing. Читает и парсит .desktop файл с помощью configparser.
Если секция [Desktop Entry] отсутствует, возвращается None.
""" """
cp = configparser.ConfigParser(interpolation=None) cp = configparser.ConfigParser(interpolation=None)
cp.read(file_path, encoding="utf-8") cp.read(file_path, encoding="utf-8")
@@ -140,8 +159,9 @@ def parse_desktop_entry(file_path):
return cp["Desktop Entry"] return cp["Desktop Entry"]
def load_theme_metainfo(theme_name): def load_theme_metainfo(theme_name):
"""Loads theme metadata from metainfo.ini in the theme's root directory. """
Expected fields: author, author_link, description, name. Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
Ожидаемые поля: author, author_link, description, name.
""" """
meta = {} meta = {}
for themes_dir in THEMES_DIRS: for themes_dir in THEMES_DIRS:
@@ -159,57 +179,69 @@ def load_theme_metainfo(theme_name):
return meta return meta
def read_card_size(): def read_card_size():
"""Reads the card size (width) from the [Cards] section.
Returns 250 if the parameter is not set.
""" """
cp = read_config_safely(CONFIG_FILE) Читает размер карточек (ширину) из секции [Cards],
if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"): Если параметр не задан, возвращает 250.
save_card_size(250) """
return 250 cp = configparser.ConfigParser()
return cp.getint("Cards", "card_width", fallback=250) if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_card_size(250)
return 250
if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
save_card_size(250)
return 250
return cp.getint("Cards", "card_width", fallback=250)
return 250
def save_card_size(card_width): def save_card_size(card_width):
"""Saves the card size (width) to the [Cards] section.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет размер карточек (ширину) в секцию [Cards].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Cards" not in cp: if "Cards" not in cp:
cp["Cards"] = {} cp["Cards"] = {}
cp["Cards"]["card_width"] = str(card_width) cp["Cards"]["card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
def read_auto_card_size():
"""Reads the card size (width) for Auto Install from the [Cards] section.
Returns 250 if the parameter is not set.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
save_auto_card_size(250)
return 250
return cp.getint("Cards", "auto_card_width", fallback=250)
def save_auto_card_size(card_width):
"""Saves the card size (width) for Auto Install to the [Cards] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Cards" not in cp:
cp["Cards"] = {}
cp["Cards"]["auto_card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_sort_method(): def read_sort_method():
"""Reads the sort method from the [Games] section.
Returns 'last_launch' if the parameter is not set.
""" """
cp = read_config_safely(CONFIG_FILE) Читает метод сортировки из секции [Games].
if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "sort_method"): Если параметр не задан, возвращает last_launch.
save_sort_method("last_launch") """
return "last_launch" cp = configparser.ConfigParser()
return cp.get("Games", "sort_method", fallback="last_launch").lower() if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_sort_method("last_launch")
return "last_launch"
if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
save_sort_method("last_launch")
return "last_launch"
return cp.get("Games", "sort_method", fallback="last_launch").lower()
return "last_launch"
def save_sort_method(sort_method): def save_sort_method(sort_method):
"""Saves the sort method to the [Games] section.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет метод сортировки в секцию [Games].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Games" not in cp: if "Games" not in cp:
cp["Games"] = {} cp["Games"] = {}
cp["Games"]["sort_method"] = sort_method cp["Games"]["sort_method"] = sort_method
@@ -217,18 +249,34 @@ def save_sort_method(sort_method):
cp.write(configfile) cp.write(configfile)
def read_display_filter(): def read_display_filter():
"""Reads the display_filter parameter from the [Games] section.
Returns 'all' if the parameter is missing.
""" """
cp = read_config_safely(CONFIG_FILE) Читает параметр display_filter из секции [Games].
if cp is None or not cp.has_section("Games") or not cp.has_option("Games", "display_filter"): Если параметр отсутствует, сохраняет и возвращает значение "all".
save_display_filter("all") """
return "all" cp = configparser.ConfigParser()
return cp.get("Games", "display_filter", fallback="all").lower() if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
save_display_filter("all")
return "all"
if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
save_display_filter("all")
return "all"
return cp.get("Games", "display_filter", fallback="all").lower()
return "all"
def save_display_filter(filter_value): def save_display_filter(filter_value):
"""Saves the display_filter parameter to the [Games] section.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет параметр display_filter в секцию [Games] конфигурационного файла.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "Games" not in cp: if "Games" not in cp:
cp["Games"] = {} cp["Games"] = {}
cp["Games"]["display_filter"] = filter_value cp["Games"]["display_filter"] = filter_value
@@ -236,23 +284,37 @@ def save_display_filter(filter_value):
cp.write(configfile) cp.write(configfile)
def read_favorites(): def read_favorites():
"""Reads the list of favorite games from the [Favorites] section.
The list is stored as a quoted string with comma-separated names.
Returns an empty list if the section or parameter is missing.
""" """
cp = read_config_safely(CONFIG_FILE) Читает список избранных игр из секции [Favorites] конфигурационного файла.
if cp is None or not cp.has_section("Favorites") or not cp.has_option("Favorites", "games"): Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми.
return [] Если секция или параметр отсутствуют, возвращает пустой список.
favs = cp.get("Favorites", "games", fallback="").strip() """
if favs.startswith('"') and favs.endswith('"'): cp = configparser.ConfigParser()
favs = favs[1:-1] if os.path.exists(CONFIG_FILE):
return [s.strip() for s in favs.split(",") if s.strip()] try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
return []
if cp.has_section("Favorites") and cp.has_option("Favorites", "games"):
favs = cp.get("Favorites", "games", fallback="").strip()
# Если строка начинается и заканчивается кавычками, удаляем их
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [s.strip() for s in favs.split(",") if s.strip()]
return []
def save_favorites(favorites): def save_favorites(favorites):
"""Saves the list of favorite games to the [Favorites] section.
The list is stored as a quoted string with comma-separated names.
""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла.
Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "Favorites" not in cp: if "Favorites" not in cp:
cp["Favorites"] = {} cp["Favorites"] = {}
fav_str = ", ".join(favorites) fav_str = ", ".join(favorites)
@@ -261,66 +323,76 @@ def save_favorites(favorites):
cp.write(configfile) cp.write(configfile)
def read_rumble_config(): def read_rumble_config():
"""Reads the gamepad rumble setting from the [Gamepad] section.
Returns False if the parameter is missing.
""" """
cp = read_config_safely(CONFIG_FILE) Читает настройку виброотдачи геймпада из секции [Gamepad].
if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"): Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
save_rumble_config(False) """
return False cp = configparser.ConfigParser()
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False) if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_rumble_config(False)
return False
if not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "rumble_enabled"):
save_rumble_config(False)
return False
return cp.getboolean("Gamepad", "rumble_enabled", fallback=False)
return False
def save_rumble_config(rumble_enabled): def save_rumble_config(rumble_enabled):
"""Saves the gamepad rumble setting to the [Gamepad] section.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет настройку виброотдачи геймпада в секцию [Gamepad].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Gamepad" not in cp: if "Gamepad" not in cp:
cp["Gamepad"] = {} cp["Gamepad"] = {}
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled) cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
def read_gamepad_type():
"""Reads the gamepad type from the [Gamepad] section.
Returns 'xbox' if the parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"):
save_gamepad_type("xbox")
return "xbox"
return cp.get("Gamepad", "type", fallback="xbox").lower()
def save_gamepad_type(gpad_type):
"""Saves the gamepad type to the [Gamepad] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Gamepad" not in cp:
cp["Gamepad"] = {}
cp["Gamepad"]["type"] = gpad_type
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def ensure_default_proxy_config(): def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file.
Creates it with empty values if missing.
""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Проверяет наличие секции [Proxy] в конфигурационном файле.
if "Proxy" not in cp: Если секция отсутствует, создаёт её с пустыми значениями.
cp.add_section("Proxy") """
cp["Proxy"]["proxy_url"] = "" cp = configparser.ConfigParser()
cp["Proxy"]["proxy_user"] = "" if os.path.exists(CONFIG_FILE):
cp["Proxy"]["proxy_password"] = "" try:
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: cp.read(CONFIG_FILE, encoding="utf-8")
cp.write(configfile) except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return
if not cp.has_section("Proxy"):
cp.add_section("Proxy")
cp["Proxy"]["proxy_url"] = ""
cp["Proxy"]["proxy_user"] = ""
cp["Proxy"]["proxy_password"] = ""
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_proxy_config(): def read_proxy_config():
"""Reads proxy settings from the [Proxy] section. """
Returns an empty dict if proxy_url is not set or empty. Читает настройки прокси из секции [Proxy] конфигурационного файла.
Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
""" """
ensure_default_proxy_config() ensure_default_proxy_config()
cp = read_config_safely(CONFIG_FILE) cp = configparser.ConfigParser()
if cp is None: try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return {} return {}
proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip() proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
if proxy_url: if proxy_url:
# Если указаны логин и пароль, добавляем их к URL
proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip() proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip() proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password: if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
@@ -330,10 +402,16 @@ def read_proxy_config():
return {} return {}
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""): def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
"""Saves proxy settings to the [Proxy] section.
Creates the section if it does not exist.
""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла.
Если секция отсутствует, создаёт её.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Proxy" not in cp: if "Proxy" not in cp:
cp["Proxy"] = {} cp["Proxy"] = {}
cp["Proxy"]["proxy_url"] = proxy_url cp["Proxy"]["proxy_url"] = proxy_url
@@ -343,18 +421,34 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
cp.write(configfile) cp.write(configfile)
def read_fullscreen_config(): def read_fullscreen_config():
"""Reads the fullscreen mode setting from the [Display] section.
Returns False if the parameter is missing.
""" """
cp = read_config_safely(CONFIG_FILE) Читает настройку полноэкранного режима приложения из секции [Display].
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"): Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
save_fullscreen_config(False) """
return False cp = configparser.ConfigParser()
return cp.getboolean("Display", "fullscreen", fallback=False) if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_fullscreen_config(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
save_fullscreen_config(False)
return False
return cp.getboolean("Display", "fullscreen", fallback=False)
return False
def save_fullscreen_config(fullscreen): def save_fullscreen_config(fullscreen):
"""Saves the fullscreen mode setting to the [Display] section.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет настройку полноэкранного режима приложения в секцию [Display].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp: if "Display" not in cp:
cp["Display"] = {} cp["Display"] = {}
cp["Display"]["fullscreen"] = str(fullscreen) cp["Display"]["fullscreen"] = str(fullscreen)
@@ -362,19 +456,33 @@ def save_fullscreen_config(fullscreen):
cp.write(configfile) cp.write(configfile)
def read_window_geometry() -> tuple[int, int]: def read_window_geometry() -> tuple[int, int]:
"""Reads the window width and height from the [MainWindow] section.
Returns (0, 0) if the parameters are missing.
""" """
cp = read_config_safely(CONFIG_FILE) Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
if cp is None or not cp.has_section("MainWindow"): Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0).
return (0, 0) """
width = cp.getint("MainWindow", "width", fallback=0) cp = configparser.ConfigParser()
height = cp.getint("MainWindow", "height", fallback=0) if os.path.exists(CONFIG_FILE):
return (width, height) try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return (0, 0)
if cp.has_section("MainWindow"):
width = cp.getint("MainWindow", "width", fallback=0)
height = cp.getint("MainWindow", "height", fallback=0)
return (width, height)
return (0, 0)
def save_window_geometry(width: int, height: int): def save_window_geometry(width: int, height: int):
"""Saves the window width and height to the [MainWindow] section.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "MainWindow" not in cp: if "MainWindow" not in cp:
cp["MainWindow"] = {} cp["MainWindow"] = {}
cp["MainWindow"]["width"] = str(width) cp["MainWindow"]["width"] = str(width)
@@ -383,86 +491,61 @@ def save_window_geometry(width: int, height: int):
cp.write(configfile) cp.write(configfile)
def reset_config(): def reset_config():
"""Resets the configuration file by deleting it. """
Subsequent reads will use default values. Сбрасывает конфигурационный файл, удаляя его.
После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
""" """
if os.path.exists(CONFIG_FILE): if os.path.exists(CONFIG_FILE):
try: try:
os.remove(CONFIG_FILE) os.remove(CONFIG_FILE)
logger.info("Configuration file %s deleted", CONFIG_FILE) logger.info("Конфигурационный файл %s удалён", CONFIG_FILE)
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete configuration file: {e}") logger.error("Ошибка при удалении конфигурационного файла: %s", e)
def clear_cache(): def clear_cache():
"""Clears the PortProtonQt cache by deleting the cache directory.""" """
Очищает кэш PortProtonQt, удаляя папку кэша.
"""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
if os.path.exists(cache_dir): if os.path.exists(cache_dir):
try: try:
shutil.rmtree(cache_dir) shutil.rmtree(cache_dir)
logger.info("PortProtonQt cache deleted: %s", cache_dir) logger.info("Кэш PortProtonQt удалён: %s", cache_dir)
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete cache: {e}") logger.error("Ошибка при удалении кэша: %s", e)
def read_auto_fullscreen_gamepad(): def read_auto_fullscreen_gamepad():
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
Returns False if the parameter is missing.
""" """
cp = read_config_safely(CONFIG_FILE) Читает настройку автоматического полноэкранного режима при подключении геймпада из секции [Display].
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"): Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
save_auto_fullscreen_gamepad(False) """
return False cp = configparser.ConfigParser()
return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False) if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_auto_fullscreen_gamepad(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "auto_fullscreen_gamepad"):
save_auto_fullscreen_gamepad(False)
return False
return cp.getboolean("Display", "auto_fullscreen_gamepad", fallback=False)
return False
def save_auto_fullscreen_gamepad(auto_fullscreen): def save_auto_fullscreen_gamepad(auto_fullscreen):
"""Saves the auto-fullscreen setting for gamepad to the [Display] section.""" """
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser() Сохраняет настройку автоматического полноэкранного режима при подключении геймпада в секцию [Display].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp: if "Display" not in cp:
cp["Display"] = {} cp["Display"] = {}
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen) cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
def read_favorite_folders():
"""Reads the list of favorite folders from the [FavoritesFolders] section.
The list is stored as a quoted string with comma-separated paths.
Returns an empty list if the section or parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("FavoritesFolders") or not cp.has_option("FavoritesFolders", "folders"):
return []
favs = cp.get("FavoritesFolders", "folders", fallback="").strip()
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [os.path.normpath(s.strip()) for s in favs.split(",") if s.strip() and os.path.isdir(os.path.normpath(s.strip()))]
def save_favorite_folders(folders):
"""Saves the list of favorite folders to the [FavoritesFolders] section.
The list is stored as a quoted string with comma-separated paths.
"""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "FavoritesFolders" not in cp:
cp["FavoritesFolders"] = {}
fav_str = ", ".join([os.path.normpath(folder) for folder in folders])
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_minimize_to_tray():
"""Reads the minimize-to-tray setting from the [Display] section.
Returns True if the parameter is missing (default: minimize to tray).
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
save_minimize_to_tray(True)
return True
return cp.getboolean("Display", "minimize_to_tray", fallback=True)
def save_minimize_to_tray(minimize_to_tray):
"""Saves the minimize-to-tray setting to the [Display] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Display" not in cp:
cp["Display"] = {}
cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)

View File

@@ -4,6 +4,7 @@ import glob
import shutil import shutil
import subprocess import subprocess
import threading import threading
import logging
import orjson import orjson
import psutil import psutil
import signal import signal
@@ -11,14 +12,13 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.logger import get_logger
logger = get_logger(__name__) logger = logging.getLogger(__name__)
class ContextMenuSignals(QObject): class ContextMenuSignals(QObject):
"""Signals for thread-safe UI updates from worker threads.""" """Signals for thread-safe UI updates from worker threads."""
@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
class ContextMenuManager: class ContextMenuManager:
"""Manages context menu actions for game management in PortProtonQt.""" """Manages context menu actions for game management in PortProtonQt."""
def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager): def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
""" """
Initialize the ContextMenuManager. Initialize the ContextMenuManager.
@@ -45,8 +45,7 @@ class ContextMenuManager:
self.theme = theme self.theme = theme
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.load_games = load_games_callback self.load_games = load_games_callback
self.game_library_manager = game_library_manager self.update_game_grid = update_game_grid_callback
self.update_game_grid = game_library_manager.update_game_grid
self.legendary_path = os.path.join( self.legendary_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache", "legendary" "PortProtonQt", "legendary_cache", "legendary"
@@ -63,7 +62,7 @@ class ContextMenuManager:
self.parent.statusBar().showMessage, self.parent.statusBar().showMessage,
Qt.ConnectionType.QueuedConnection Qt.ConnectionType.QueuedConnection
) )
logger.debug("Connected show_status_message signal to status bar") logger.debug("Connected show_status_message signal to statusBar")
self.signals.show_warning_dialog.connect( self.signals.show_warning_dialog.connect(
self._show_warning_dialog, self._show_warning_dialog,
Qt.ConnectionType.QueuedConnection Qt.ConnectionType.QueuedConnection
@@ -75,28 +74,28 @@ class ContextMenuManager:
def _show_warning_dialog(self, title: str, message: str): def _show_warning_dialog(self, title: str, message: str):
"""Show a warning dialog in the main thread.""" """Show a warning dialog in the main thread."""
logger.debug("Displaying warning dialog: %s - %s", title, message) logger.debug("Showing warning dialog: %s - %s", title, message)
QMessageBox.warning(self.parent, title, message) QMessageBox.warning(self.parent, title, message)
def _show_info_dialog(self, title: str, message: str): def _show_info_dialog(self, title: str, message: str):
"""Show an info dialog in the main thread.""" """Show an info dialog in the main thread."""
logger.debug("Displaying info dialog: %s - %s", title, message) logger.debug("Showing info dialog: %s - %s", title, message)
QMessageBox.information(self.parent, title, message) QMessageBox.information(self.parent, title, message)
def _show_status_message(self, message: str, timeout: int = 3000): def _show_status_message(self, message: str, timeout: int = 3000):
"""Show a status message on the status bar if available.""" """Show a status message on the status bar if available."""
if self.parent.statusBar(): if self.parent.statusBar():
self.parent.statusBar().showMessage(message, timeout) self.parent.statusBar().showMessage(message, timeout)
logger.debug("Displayed status message: %s", message) logger.debug("Direct status message: %s", message)
else: else:
logger.warning("Status bar unavailable for message: %s", message) logger.warning("Status bar not available for message: %s", message)
def _check_portproton(self): def _check_portproton(self):
"""Check if PortProton is available.""" """Check if PortProton is available."""
if self.portproton_location is None: if self.portproton_location is None:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("PortProton directory not found") _("PortProton is not found")
) )
return False return False
return True return True
@@ -120,7 +119,7 @@ class ContextMenuManager:
installed_games = orjson.loads(f.read()) installed_games = orjson.loads(f.read())
return app_name in installed_games return app_name in installed_games
except (OSError, orjson.JSONDecodeError) as e: except (OSError, orjson.JSONDecodeError) as e:
logger.error("Error reading installed.json: %s", e) logger.error("Failed to read installed.json: %s", e)
return False return False
def _is_game_running(self, game_card) -> bool: def _is_game_running(self, game_card) -> bool:
@@ -151,84 +150,6 @@ class ContextMenuManager:
return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe return hasattr(self.parent, 'target_exe') and self.parent.target_exe == current_exe
def show_folder_context_menu(self, file_explorer, pos):
"""Shows the context menu for a folder in FileExplorer."""
try:
item = file_explorer.file_list.itemAt(pos)
if not item:
logger.debug("No folder selected at position %s", pos)
return
selected = item.text()
if not selected.endswith("/"):
logger.debug("Selected item is not a folder: %s", selected)
return # Only for folders
full_path = os.path.normpath(os.path.join(file_explorer.current_path, selected.rstrip("/")))
if not os.path.isdir(full_path):
logger.debug("Path is not a directory: %s", full_path)
return
menu = QMenu(file_explorer)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
menu.setParent(file_explorer, Qt.WindowType.Popup) # Set transientParent for Wayland
favorite_folders = read_favorite_folders()
is_favorite = full_path in favorite_folders
action_text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
favorite_action = menu.addAction(self._get_safe_icon("star" if is_favorite else "star_full"), action_text)
favorite_action.triggered.connect(lambda: self.toggle_favorite_folder(file_explorer, full_path, not is_favorite))
# Disconnect file_list signals to prevent navigation during menu interaction
try:
file_explorer.file_list.itemClicked.disconnect(file_explorer.handle_item_click)
file_explorer.file_list.itemDoubleClicked.disconnect(file_explorer.handle_item_double_click)
except TypeError:
pass # Signals may not be connected
# Reconnect signals after menu closes
def reconnect_signals():
try:
file_explorer.file_list.itemClicked.connect(file_explorer.handle_item_click)
file_explorer.file_list.itemDoubleClicked.connect(file_explorer.handle_item_double_click)
except Exception as e:
logger.error("Error reconnecting file list signals: %s", e)
menu.aboutToHide.connect(reconnect_signals)
# Set focus to the first menu item
actions = menu.actions()
if actions:
menu.setActiveAction(actions[0])
# Map local position to global for menu display
global_pos = file_explorer.file_list.mapToGlobal(pos)
menu.exec(global_pos)
except Exception as e:
logger.error("Error displaying folder context menu: %s", e)
def toggle_favorite_folder(self, file_explorer, folder_path, add):
"""Adds or removes a folder from favorites."""
favorite_folders = read_favorite_folders()
if add:
if folder_path not in favorite_folders:
favorite_folders.append(folder_path)
save_favorite_folders(favorite_folders)
logger.info("Added folder to favorites: %s", folder_path)
else:
if folder_path in favorite_folders:
favorite_folders.remove(folder_path)
save_favorite_folders(favorite_folders)
logger.info("Removed folder from favorites: %s", folder_path)
file_explorer.update_drives_list()
def _get_safe_icon(self, icon_name: str) -> QIcon:
"""Returns a QIcon, ensuring it is valid."""
icon = self.theme_manager.get_icon(icon_name)
if isinstance(icon, QIcon):
return icon
elif isinstance(icon, str) and os.path.exists(icon):
return QIcon(icon)
return QIcon()
def show_context_menu(self, game_card, pos: QPoint): def show_context_menu(self, game_card, pos: QPoint):
""" """
Show the context menu for a game card at the specified position. Show the context menu for a game card at the specified position.
@@ -237,6 +158,14 @@ class ContextMenuManager:
game_card: The GameCard instance requesting the context menu. game_card: The GameCard instance requesting the context menu.
pos: The position (in widget coordinates) where the menu should appear. pos: The position (in widget coordinates) where the menu should appear.
""" """
def get_safe_icon(icon_name: str) -> QIcon:
icon = self.theme_manager.get_icon(icon_name)
if isinstance(icon, QIcon):
return icon
elif isinstance(icon, str) and os.path.exists(icon):
return QIcon(icon)
return QIcon()
menu = QMenu(self.parent) menu = QMenu(self.parent)
menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE) menu.setStyleSheet(self.theme.CONTEXT_MENU_STYLE)
@@ -246,7 +175,7 @@ class ContextMenuManager:
exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None exe_path = self._parse_exe_path(exec_line, game_card.name) if exec_line else None
if not exe_path: if not exe_path:
# Show only "Delete from PortProton" if no valid exe # Show only "Delete from PortProton" if no valid exe
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton")) delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line)) delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
menu.exec(game_card.mapToGlobal(pos)) menu.exec(game_card.mapToGlobal(pos))
return return
@@ -255,7 +184,7 @@ class ContextMenuManager:
is_running = self._is_game_running(game_card) is_running = self._is_game_running(game_card)
action_text = _("Stop Game") if is_running else _("Launch Game") action_text = _("Stop Game") if is_running else _("Launch Game")
action_icon = "stop" if is_running else "play" action_icon = "stop" if is_running else "play"
launch_action = menu.addAction(self._get_safe_icon(action_icon), action_text) launch_action = menu.addAction(get_safe_icon(action_icon), action_text)
launch_action.triggered.connect( launch_action.triggered.connect(
lambda: self._launch_game(game_card) lambda: self._launch_game(game_card)
) )
@@ -264,11 +193,11 @@ class ContextMenuManager:
is_favorite = game_card.name in favorites is_favorite = game_card.name in favorites
icon_name = "star_full" if is_favorite else "star" icon_name = "star_full" if is_favorite else "star"
text = _("Remove from Favorites") if is_favorite else _("Add to Favorites") text = _("Remove from Favorites") if is_favorite else _("Add to Favorites")
favorite_action = menu.addAction(self._get_safe_icon(icon_name), text) favorite_action = menu.addAction(get_safe_icon(icon_name), text)
favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite)) favorite_action.triggered.connect(lambda: self.toggle_favorite(game_card, not is_favorite))
if game_card.game_source == "epic": if game_card.game_source == "epic":
import_action = menu.addAction(self._get_safe_icon("epic_games"), _("Import to Legendary")) import_action = menu.addAction(get_safe_icon("epic_games"), _("Import to Legendary"))
import_action.triggered.connect( import_action.triggered.connect(
lambda: self.import_to_legendary(game_card.name, game_card.appid) lambda: self.import_to_legendary(game_card.name, game_card.appid)
) )
@@ -276,13 +205,13 @@ class ContextMenuManager:
is_in_steam = is_game_in_steam(game_card.name) is_in_steam = is_game_in_steam(game_card.name)
icon_name = "delete" if is_in_steam else "steam" icon_name = "delete" if is_in_steam else "steam"
text = _("Remove from Steam") if is_in_steam else _("Add to Steam") text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(self._get_safe_icon(icon_name), text) steam_action = menu.addAction(get_safe_icon(icon_name), text)
steam_action.triggered.connect( steam_action.triggered.connect(
lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) lambda: self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
if is_in_steam if is_in_steam
else self.add_egs_to_steam(game_card.name, game_card.appid) else self.add_egs_to_steam(game_card.name, game_card.appid)
) )
open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder")) open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
open_folder_action.triggered.connect( open_folder_action.triggered.connect(
lambda: self.open_egs_game_folder(game_card.appid) lambda: self.open_egs_game_folder(game_card.appid)
) )
@@ -290,7 +219,7 @@ class ContextMenuManager:
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop") desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(desktop_path) else "desktop" icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop") text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
desktop_action = menu.addAction(self._get_safe_icon(icon_name), text) desktop_action = menu.addAction(get_safe_icon(icon_name), text)
desktop_action.triggered.connect( desktop_action.triggered.connect(
lambda: self.remove_egs_from_desktop(game_card.name) lambda: self.remove_egs_from_desktop(game_card.name)
if os.path.exists(desktop_path) if os.path.exists(desktop_path)
@@ -299,7 +228,7 @@ class ContextMenuManager:
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications") applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop") menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
menu_action = menu.addAction( menu_action = menu.addAction(
self._get_safe_icon("delete" if os.path.exists(menu_path) else "menu"), get_safe_icon("delete" if os.path.exists(menu_path) else "menu"),
_("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu") _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
) )
menu_action.triggered.connect( menu_action.triggered.connect(
@@ -313,19 +242,19 @@ class ContextMenuManager:
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop") desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(desktop_path) else "desktop" icon_name = "delete" if os.path.exists(desktop_path) else "desktop"
text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop") text = _("Remove from Desktop") if os.path.exists(desktop_path) else _("Add to Desktop")
desktop_action = menu.addAction(self._get_safe_icon(icon_name), text) desktop_action = menu.addAction(get_safe_icon(icon_name), text)
desktop_action.triggered.connect( desktop_action.triggered.connect(
lambda: self.remove_from_desktop(game_card.name) lambda: self.remove_from_desktop(game_card.name)
if os.path.exists(desktop_path) if os.path.exists(desktop_path)
else self.add_to_desktop(game_card.name, game_card.exec_line) else self.add_to_desktop(game_card.name, game_card.exec_line)
) )
edit_action = menu.addAction(self._get_safe_icon("edit"), _("Edit Shortcut")) edit_action = menu.addAction(get_safe_icon("edit"), _("Edit Shortcut"))
edit_action.triggered.connect( edit_action.triggered.connect(
lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path) lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path)
) )
delete_action = menu.addAction(self._get_safe_icon("delete"), _("Delete from PortProton")) delete_action = menu.addAction(get_safe_icon("delete"), _("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line)) delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
open_folder_action = menu.addAction(self._get_safe_icon("search"), _("Open Game Folder")) open_folder_action = menu.addAction(get_safe_icon("search"), _("Open Game Folder"))
open_folder_action.triggered.connect( open_folder_action.triggered.connect(
lambda: self.open_game_folder(game_card.name, game_card.exec_line) lambda: self.open_game_folder(game_card.name, game_card.exec_line)
) )
@@ -333,7 +262,7 @@ class ContextMenuManager:
menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop") menu_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
icon_name = "delete" if os.path.exists(menu_path) else "menu" icon_name = "delete" if os.path.exists(menu_path) else "menu"
text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu") text = _("Remove from Menu") if os.path.exists(menu_path) else _("Add to Menu")
menu_action = menu.addAction(self._get_safe_icon(icon_name), text) menu_action = menu.addAction(get_safe_icon(icon_name), text)
menu_action.triggered.connect( menu_action.triggered.connect(
lambda: self.remove_from_menu(game_card.name) lambda: self.remove_from_menu(game_card.name)
if os.path.exists(menu_path) if os.path.exists(menu_path)
@@ -342,7 +271,7 @@ class ContextMenuManager:
is_in_steam = is_game_in_steam(game_card.name) is_in_steam = is_game_in_steam(game_card.name)
icon_name = "delete" if is_in_steam else "steam" icon_name = "delete" if is_in_steam else "steam"
text = _("Remove from Steam") if is_in_steam else _("Add to Steam") text = _("Remove from Steam") if is_in_steam else _("Add to Steam")
steam_action = menu.addAction(self._get_safe_icon(icon_name), text) steam_action = menu.addAction(get_safe_icon(icon_name), text)
steam_action.triggered.connect( steam_action.triggered.connect(
lambda: ( lambda: (
self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source) self.remove_from_steam(game_card.name, game_card.exec_line, game_card.game_source)
@@ -351,7 +280,7 @@ class ContextMenuManager:
) )
) )
# Set focus to the first menu item # Устанавливаем фокус на первый элемент меню
actions = menu.actions() actions = menu.actions()
if actions: if actions:
menu.setActiveAction(actions[0]) menu.setActiveAction(actions[0])
@@ -493,7 +422,7 @@ class ContextMenuManager:
) )
return return
# Use FileExplorer with directory_only=True # Используем FileExplorer с directory_only=True
file_explorer = FileExplorer( file_explorer = FileExplorer(
parent=self.parent, parent=self.parent,
theme=self.theme, theme=self.theme,
@@ -523,10 +452,10 @@ class ContextMenuManager:
self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name)) self._show_status_message(_("Importing '{game_name}' to Legendary...").format(game_name=game_name))
threading.Thread(target=run_import, daemon=True).start() threading.Thread(target=run_import, daemon=True).start()
# Connect the file selection signal # Подключаем сигнал выбора файла/папки
file_explorer.file_signal.file_selected.connect(on_folder_selected) file_explorer.file_signal.file_selected.connect(on_folder_selected)
# Center FileExplorer relative to the parent widget # Центрируем FileExplorer относительно родительского виджета
parent_widget = self.parent parent_widget = self.parent
if parent_widget: if parent_widget:
parent_geometry = parent_widget.geometry() parent_geometry = parent_widget.geometry()
@@ -608,10 +537,10 @@ class ContextMenuManager:
exe_path = get_egs_executable(app_name, self.legendary_config_path) exe_path = get_egs_executable(app_name, self.legendary_config_path)
if exe_path and os.path.exists(exe_path): if exe_path and os.path.exists(exe_path):
if not generate_thumbnail(exe_path, icon_path, size=128): if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error("Failed to generate thumbnail for EGS game: %s", exe_path) logger.error(f"Failed to generate thumbnail from exe: {exe_path}")
icon_path = "" icon_path = ""
else: else:
logger.error("No executable found for EGS game: %s", app_name) logger.error(f"No executable found for EGS game: {app_name}")
icon_path = "" icon_path = ""
egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops") egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
@@ -751,7 +680,7 @@ Icon={icon_path}
if not exec_line: if not exec_line:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name) _("No executable command in .desktop file for '{game_name}'").format(game_name=game_name)
) )
return None return None
else: else:
@@ -763,7 +692,7 @@ Icon={icon_path}
except Exception as e: except Exception as e:
self.signals.show_warning_dialog.emit( self.signals.show_warning_dialog.emit(
_("Error"), _("Error"),
_("Error reading .desktop file: {error}").format(error=str(e)) _("Failed to read .desktop file: {error}").format(error=str(e))
) )
return None return None
else: else:
@@ -785,7 +714,7 @@ Icon={icon_path}
try: try:
entry_exec_split = shlex.split(exec_line) entry_exec_split = shlex.split(exec_line)
if not entry_exec_split: if not entry_exec_split:
logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line) logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
return None return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3: if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2] exe_path = entry_exec_split[2]
@@ -794,11 +723,11 @@ Icon={icon_path}
else: else:
exe_path = entry_exec_split[-1] exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path): if not exe_path or not os.path.exists(exe_path):
logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None") logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
return None return None
return exe_path return exe_path
except Exception as e: except Exception as e:
logger.debug("Error parsing executable for game '%s': %s", game_name, e) logger.debug("Failed to parse executable for '%s': %s", game_name, e)
return None return None
def _remove_file(self, file_path, error_message, success_message, game_name, location=""): def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
@@ -860,16 +789,9 @@ Icon={icon_path}
_("Failed to delete custom data: {error}").format(error=str(e)) _("Failed to delete custom data: {error}").format(error=str(e))
) )
self.update_game_grid = self.game_library_manager.remove_game_incremental # Перезагрузка списка игр и обновление сетки
self.game_library_manager.remove_game_incremental(game_name, exec_line) self.load_games()
self.update_game_grid()
def add_game_incremental(self, game_data: tuple):
"""Add game after .desktop creation."""
if not self._check_portproton():
return
# Assume game_data is built from new .desktop (name, desc, cover, etc.)
self.game_library_manager.add_game_incremental(game_data)
self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0]))
def add_to_menu(self, game_name, exec_line): def add_to_menu(self, game_name, exec_line):
"""Copy the .desktop file to ~/.local/share/applications.""" """Copy the .desktop file to ~/.local/share/applications."""
@@ -944,7 +866,7 @@ Icon={icon_path}
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png") icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
if not os.path.exists(icon_path): if not os.path.exists(icon_path):
if not generate_thumbnail(exe_path, icon_path, size=128): if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error("Failed to generate thumbnail for game: %s", exe_path) logger.error(f"Failed to generate thumbnail for {exe_path}")
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip() desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
os.makedirs(desktop_dir, exist_ok=True) os.makedirs(desktop_dir, exist_ok=True)
@@ -1080,7 +1002,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name) exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path: if not exe_path:
return return
logger.debug("Adding game '%s' to Steam", game_name) logger.debug("Adding '%s' to Steam", game_name)
try: try:
success, message = add_to_steam(game_name, exec_line, cover_path) success, message = add_to_steam(game_name, exec_line, cover_path)
self.signals.show_info_dialog.emit( self.signals.show_info_dialog.emit(
@@ -1123,7 +1045,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name) exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path: if not exe_path:
return return
logger.debug("Removing game '%s' from Steam", game_name) logger.debug("Removing non-EGS game '%s' from Steam", game_name)
try: try:
success, message = remove_from_steam(game_name, exec_line) success, message = remove_from_steam(game_name, exec_line)
self.signals.show_info_dialog.emit( self.signals.show_info_dialog.emit(

View File

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 315 KiB

View File

Before

Width:  |  Height:  |  Size: 978 KiB

After

Width:  |  Height:  |  Size: 978 KiB

View File

Before

Width:  |  Height:  |  Size: 634 KiB

After

Width:  |  Height:  |  Size: 634 KiB

View File

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 650 KiB

View File

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 391 KiB

View File

Before

Width:  |  Height:  |  Size: 710 KiB

After

Width:  |  Height:  |  Size: 710 KiB

View File

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 627 KiB

View File

@@ -5,63 +5,30 @@ from PySide6.QtGui import QFont, QFontMetrics, QPainter
def compute_layout(nat_sizes, rect_width, spacing, max_scale): def compute_layout(nat_sizes, rect_width, spacing, max_scale):
""" """
Computes the layout of elements considering spacing and potential scaling of cards. Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
nat_sizes: Array (N, 2) with natural sizes of elements (width, height). nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
rect_width: Available container width. rect_width: доступная ширина контейнера.
spacing: Spacing between elements (horizontal and vertical). spacing: отступ между элементами.
max_scale: Maximum scaling factor (e.g., 1.0). max_scale: максимальный коэффициент масштабирования (например, 1.2).
Returns: Возвращает:
result: Array (N, 4), where each row contains [x, y, new_width, new_height]. result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
total_height: Total height of all rows. total_height: итоговая высота всех рядов.
""" """
N = nat_sizes.shape[0] N = nat_sizes.shape[0]
result = np.zeros((N, 4), dtype=np.int32) result = np.zeros((N, 4), dtype=np.int32)
y = 0 y = 0
i = 0 i = 0
min_margin = 20 # Minimum margin on edges
# Determine the maximum number of items per row and overall scale
max_items_per_row = 0
global_scale = 1.0
max_row_x_start = min_margin # Starting x position of the widest row
temp_i = 0
# First pass: Find the maximum number of items in a row
while temp_i < N:
sum_width = 0
count = 0
temp_j = temp_i
while temp_j < N:
w = nat_sizes[temp_j, 0]
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
break
sum_width += w
count += 1
temp_j += 1
if count > max_items_per_row:
max_items_per_row = count
# Calculate scale for the most populated row
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
global_scale = desired_scale if desired_scale < max_scale else max_scale
# Store starting x position for the widest row
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
temp_i = temp_j
# Second pass: Place elements
while i < N: while i < N:
sum_width = 0 sum_width = 0
row_max_height = 0 row_max_height = 0
count = 0 count = 0
j = i j = i
# Подбираем количество элементов для текущего ряда
# Determine the number of items for the current row
while j < N: while j < N:
w = nat_sizes[j, 0] w = nat_sizes[j, 0]
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin: # Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
if count > 0 and (sum_width + spacing + w) > rect_width:
break break
sum_width += w sum_width += w
count += 1 count += 1
@@ -69,19 +36,13 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
if h > row_max_height: if h > row_max_height:
row_max_height = h row_max_height = h
j += 1 j += 1
# Доступная ширина ряда с учетом обязательных отступов между элементами
# Use global scale for all rows available_width = rect_width - spacing * (count - 1)
scale = global_scale desired_scale = available_width / sum_width if sum_width > 0 else 1.0
scaled_row_width = int(sum_width * scale) + spacing * (count - 1) # Разрешаем увеличение карточек, но не более max_scale
scale = desired_scale if desired_scale < max_scale else max_scale
# Determine starting x coordinate # Выравниваем по левому краю (offset = 0)
if count == max_items_per_row: x = 0
# Center the full row
x = max(min_margin, (rect_width - scaled_row_width) // 2)
else:
# Align incomplete row to the left, matching the widest row's start
x = max_row_x_start
for k in range(i, j): for k in range(i, j):
new_w = int(nat_sizes[k, 0] * scale) new_w = int(nat_sizes[k, 0] * scale)
new_h = int(nat_sizes[k, 1] * scale) new_h = int(nat_sizes[k, 1] * scale)
@@ -90,7 +51,6 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
result[k, 2] = new_w result[k, 2] = new_w
result[k, 3] = new_h result[k, 3] = new_h
x += new_w + spacing x += new_w + spacing
y += int(row_max_height * scale) + spacing y += int(row_max_height * scale) + spacing
i = j i = j
return result, y return result, y
@@ -99,17 +59,18 @@ class FlowLayout(QLayout):
def __init__(self, parent=None): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self.itemList = [] self.itemList = []
self.setContentsMargins(20, 20, 20, 20) # Margins around the layout # Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
self._spacing = 20 # Spacing for animation and overlap prevention self.setContentsMargins(0, 0, 0, 0)
self._max_scale = 1.0 # Scaling disabled in layout self._spacing = 3 # отступ между карточками
self._max_scale = 1.2 # максимальное увеличение карточек (например, на 20%)
def addItem(self, item: QLayoutItem) -> None: def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item) self.itemList.append(item)
def takeAt(self, index: int) -> QLayoutItem: def takeAt(self, index: int) -> QLayoutItem:
if 0 <= index < len(self.itemList): if 0 <= index < len(self.itemList):
return self.itemList.pop(index) return self.itemList.pop(index)
raise IndexError("Index out of range") raise IndexError("Index out of range")
def count(self) -> int: def count(self) -> int:
return len(self.itemList) return len(self.itemList)
@@ -126,21 +87,7 @@ class FlowLayout(QLayout):
return True return True
def heightForWidth(self, width): def heightForWidth(self, width):
# Аналогично фильтруем видимые для тестового расчёта высоты return self.doLayout(QRect(0, 0, width, 0), True)
visible_items = []
nat_sizes = np.empty((0, 2), dtype=np.int32)
for item in self.itemList:
if item.widget() and item.widget().isVisible():
visible_items.append(item)
s = item.sizeHint()
new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
if len(visible_items) == 0:
return 0
_, total_height = compute_layout(nat_sizes, width, self._spacing, self._max_scale)
return total_height
def setGeometry(self, rect): def setGeometry(self, rect):
super().setGeometry(rect) super().setGeometry(rect)
@@ -155,50 +102,32 @@ class FlowLayout(QLayout):
size = size.expandedTo(item.minimumSize()) size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins() margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(), size += QSize(margins.left() + margins.right(),
margins.top() + margins.bottom()) margins.top() + margins.bottom())
return size return size
def doLayout(self, rect, testOnly): def doLayout(self, rect, testOnly):
N_total = len(self.itemList) N = len(self.itemList)
if N_total == 0:
return 0
# Фильтруем только видимые элементы
visible_items = []
visible_indices = [] # Индексы в оригинальном itemList для установки геометрии
nat_sizes = np.empty((0, 2), dtype=np.int32)
for i, item in enumerate(self.itemList):
if item.widget() and item.widget().isVisible():
visible_items.append(item)
visible_indices.append(i)
s = item.sizeHint()
new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
N = len(visible_items)
if N == 0: if N == 0:
# Если все скрыты, устанавливаем нулевые геометрии для всех
if not testOnly:
for item in self.itemList:
item.setGeometry(QRect())
return 0 return 0
# Собираем натуральные размеры всех элементов в массив NumPy
nat_sizes = np.empty((N, 2), dtype=np.int32)
for i, item in enumerate(self.itemList):
s = item.sizeHint()
nat_sizes[i, 0] = s.width()
nat_sizes[i, 1] = s.height()
# Вычисляем геометрию с учетом spacing и max_scale через numba-функцию
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale) geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
if not testOnly: if not testOnly:
# Устанавливаем геометрии только для видимых for i, item in enumerate(self.itemList):
for idx, (_vis_idx, item) in enumerate(zip(visible_indices, visible_items, strict=True)): x = geom_array[i, 0] + rect.x()
x = geom_array[idx, 0] + rect.x() y = geom_array[i, 1] + rect.y()
y = geom_array[idx, 1] + rect.y() w = geom_array[i, 2]
w = geom_array[idx, 2] h = geom_array[i, 3]
h = geom_array[idx, 3]
item.setGeometry(QRect(QPoint(x, y), QSize(w, h))) item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
# Для невидимых — нулевая геометрия
for i in range(N_total):
if i not in visible_indices:
self.itemList[i].setGeometry(QRect())
return total_height return total_height
class ClickableLabel(QLabel): class ClickableLabel(QLabel):
@@ -223,7 +152,7 @@ class ClickableLabel(QLabel):
self._icon_size = icon_size self._icon_size = icon_size
self._icon_space = icon_space self._icon_space = icon_space
self._font_scale_factor = font_scale_factor self._font_scale_factor = font_scale_factor
self._card_width = 250 self._card_width = 250 # Значение по умолчанию
if change_cursor: if change_cursor:
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.updateFontSize() self.updateFontSize()
@@ -241,23 +170,28 @@ class ClickableLabel(QLabel):
self.update() self.update()
def setCardWidth(self, card_width: int): def setCardWidth(self, card_width: int):
"""Обновляет ширину карточки и пересчитывает размер шрифта."""
self._card_width = card_width self._card_width = card_width
self.updateFontSize() self.updateFontSize()
def updateFontSize(self): def updateFontSize(self):
"""Обновляет размер шрифта на основе card_width и font_scale_factor."""
font = self.font() font = self.font()
font_size = int(self._card_width * self._font_scale_factor) font_size = int(self._card_width * self._font_scale_factor)
font.setPointSize(max(8, font_size)) font.setPointSize(max(8, font_size)) # Минимальный размер шрифта 8
self.setFont(font) self.setFont(font)
self.update() self.update()
def paintEvent(self, event): def paintEvent(self, event):
painter = QPainter(self) painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.contentsRect() rect = self.contentsRect()
alignment = self.alignment() alignment = self.alignment()
icon_size = self._icon_size icon_size = self._icon_size
spacing = self._icon_space spacing = self._icon_space
text = self.text() text = self.text()
if self._icon: if self._icon:
@@ -266,11 +200,17 @@ class ClickableLabel(QLabel):
pixmap = None pixmap = None
fm = QFontMetrics(self.font()) fm = QFontMetrics(self.font())
# Считаем, сколько места остаётся под текст
available_width = rect.width() available_width = rect.width()
if pixmap: if pixmap:
available_width -= (icon_size + spacing) available_width -= (icon_size + spacing)
# Отступы по 2px с каждой стороны
available_width = max(0, available_width - 4) available_width = max(0, available_width - 4)
# Получаем «обрезанный» текст с многоточием
display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width) display_text = fm.elidedText(text, Qt.TextElideMode.ElideRight, available_width)
text_width = fm.horizontalAdvance(display_text) text_width = fm.horizontalAdvance(display_text)
text_height = fm.height() text_height = fm.height()
total_width = text_width + (icon_size + spacing if pixmap else 0) total_width = text_width + (icon_size + spacing if pixmap else 0)
@@ -340,6 +280,8 @@ class AutoSizeButton(QPushButton):
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFlat(True) self.setFlat(True)
# Изначально выставляем минимальную ширину
self.setMinimumWidth(50) self.setMinimumWidth(50)
self.adjustFontSize() self.adjustFontSize()
@@ -370,6 +312,7 @@ class AutoSizeButton(QPushButton):
if not self._update_size: if not self._update_size:
return return
# Определяем доступную ширину внутри кнопки
available_width = self.width() available_width = self.width()
if self._icon: if self._icon:
available_width -= self._icon_size available_width -= self._icon_size
@@ -380,6 +323,7 @@ class AutoSizeButton(QPushButton):
font = QFont(self._original_font) font = QFont(self._original_font)
text = self._original_text text = self._original_text
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
chosen_size = self._max_font_size chosen_size = self._max_font_size
for font_size in range(self._max_font_size, self._min_font_size - 1, -1): for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
font.setPointSize(font_size) font.setPointSize(font_size)
@@ -392,12 +336,14 @@ class AutoSizeButton(QPushButton):
font.setPointSize(chosen_size) font.setPointSize(chosen_size)
self.setFont(font) self.setFont(font)
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
fm = QFontMetrics(font) fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(text) text_width = fm.horizontalAdvance(text)
required_width = text_width + margins.left() + margins.right() + self._padding * 2 required_width = text_width + margins.left() + margins.right() + self._padding * 2
if self._icon: if self._icon:
required_width += self._icon_size required_width += self._icon_size
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
if self.width() < required_width: if self.width() < required_width:
self.setMinimumWidth(required_width) self.setMinimumWidth(required_width)
@@ -407,6 +353,7 @@ class AutoSizeButton(QPushButton):
if not self._update_size: if not self._update_size:
return super().sizeHint() return super().sizeHint()
else: else:
# Вычисляем оптимальный размер кнопки на основе текста и отступов
font = self.font() font = self.font()
fm = QFontMetrics(font) fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(self._original_text) text_width = fm.horizontalAdvance(self._original_text)
@@ -417,6 +364,7 @@ class AutoSizeButton(QPushButton):
height = fm.height() + margins.top() + margins.bottom() + self._padding height = fm.height() + margins.top() + margins.bottom() + self._padding
return QSize(width, height) return QSize(width, height)
class NavLabel(QLabel): class NavLabel(QLabel):
clicked = Signal() clicked = Signal()
@@ -428,6 +376,7 @@ class NavLabel(QLabel):
self._isChecked = False self._isChecked = False
self.setProperty("checked", self._isChecked) self.setProperty("checked", self._isChecked)
self.setCursor(Qt.CursorShape.PointingHandCursor) self.setCursor(Qt.CursorShape.PointingHandCursor)
# Explicitly enable focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def setCheckable(self, checkable): def setCheckable(self, checkable):
@@ -446,6 +395,7 @@ class NavLabel(QLabel):
def mousePressEvent(self, event): def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton: if event.button() == Qt.MouseButton.LeftButton:
# Ensure widget can take focus on click
self.setFocus(Qt.FocusReason.MouseFocusReason) self.setFocus(Qt.FocusReason.MouseFocusReason)
if self._checkable: if self._checkable:
self.setChecked(not self._isChecked) self.setChecked(not self._isChecked)

File diff suppressed because it is too large Load Diff

View File

@@ -2,36 +2,39 @@ from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import Signal, Property, Qt, QUrl from PySide6.QtCore import Signal, Property, Qt, QUrl
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.image_utils import load_pixmap_async, round_corners from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter, read_theme_from_config from portprotonqt.config_utils import read_favorites, save_favorites, read_display_filter
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.custom_widgets import ClickableLabel from portprotonqt.custom_widgets import ClickableLabel
from portprotonqt.portproton_api import PortProtonAPI from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.animations import GameCardAnimations from portprotonqt.animations import GameCardAnimations
import weakref
from typing import cast from typing import cast
class GameCard(QFrame): class GameCard(QFrame):
borderWidthChanged = Signal() borderWidthChanged = Signal()
gradientAngleChanged = Signal() gradientAngleChanged = Signal()
scaleChanged = Signal() # Signals for context menu actions
editShortcutRequested = Signal(str, str, str) editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path
deleteGameRequested = Signal(str, str) deleteGameRequested = Signal(str, str) # name, exec_line
addToMenuRequested = Signal(str, str) addToMenuRequested = Signal(str, str) # name, exec_line
removeFromMenuRequested = Signal(str) removeFromMenuRequested = Signal(str) # name
addToDesktopRequested = Signal(str, str) addToDesktopRequested = Signal(str, str) # name, exec_line
removeFromDesktopRequested = Signal(str) removeFromDesktopRequested = Signal(str) # name
addToSteamRequested = Signal(str, str, str) addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path
removeFromSteamRequested = Signal(str, str) removeFromSteamRequested = Signal(str, str) # name, exec_line
openGameFolderRequested = Signal(str, str) openGameFolderRequested = Signal(str, str) # name, exec_line
hoverChanged = Signal(str, bool) hoverChanged = Signal(str, bool)
focusChanged = Signal(str, bool) focusChanged = Signal(str, bool)
def __init__(self, name, description, cover_path, appid, controller_support, exec_line, def __init__(self, name, description, cover_path, appid, controller_support, exec_line,
last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source, last_launch, formatted_playtime, protondb_tier, anticheat_status, last_launch_ts, playtime_seconds, game_source,
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None): select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
super().__init__(parent) super().__init__(parent)
self.name = name self.name = name
self.description = description self.description = description
@@ -46,16 +49,14 @@ class GameCard(QFrame):
self.game_source = game_source self.game_source = game_source
self.last_launch_ts = last_launch_ts self.last_launch_ts = last_launch_ts
self.playtime_seconds = playtime_seconds self.playtime_seconds = playtime_seconds
self.base_card_width = card_width self.card_width = card_width
self.base_pixmap = None
self.base_font_size = None
self.select_callback = select_callback self.select_callback = select_callback
self.context_menu_manager = context_menu_manager self.context_menu_manager = context_menu_manager
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu) self.customContextMenuRequested.connect(self._show_context_menu)
self.theme_manager = ThemeManager() self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config()) self.theme = theme if theme is not None else default_styles
self.display_filter = read_display_filter() self.display_filter = read_display_filter()
self.current_theme_name = read_theme_from_config() self.current_theme_name = read_theme_from_config()
@@ -66,46 +67,75 @@ class GameCard(QFrame):
self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites"))
self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites")) self.portproton_visible = (str(game_source).lower() == "portproton" and self.display_filter in ("all", "favorites"))
self.base_extra_margin = 20 # Дополнительное пространство для анимации
extra_margin = 20
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE) self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
# Параметры анимации обводки
self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"] self._borderWidth = self.theme.GAME_CARD_ANIMATION["default_border_width"]
self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"] self._gradientAngle = self.theme.GAME_CARD_ANIMATION["gradient_start_angle"]
self._scale = self.theme.GAME_CARD_ANIMATION["default_scale"]
self._hovered = False self._hovered = False
self._focused = False self._focused = False
# Анимации
self.animations = GameCardAnimations(self, self.theme) self.animations = GameCardAnimations(self, self.theme)
self.animations.setup_animations() self.animations.setup_animations()
self.shadow = QGraphicsDropShadowEffect(self) # Тень
self.shadow.setBlurRadius(20) shadow = QGraphicsDropShadowEffect(self)
self.shadow.setColor(QColor(0, 0, 0, 150)) shadow.setBlurRadius(20)
self.shadow.setOffset(0, 0) shadow.setColor(QColor(0, 0, 0, 150))
self.setGraphicsEffect(self.shadow) shadow.setOffset(0, 0)
self.setGraphicsEffect(shadow)
self.layout_ = QVBoxLayout(self) # Отступы
self.layout_.setSpacing(5) layout = QVBoxLayout(self)
self.layout_.setContentsMargins(self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2, self.base_extra_margin // 2) layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2)
layout.setSpacing(5)
self.coverWidget = QWidget() # Контейнер обложки
coverLayout = QStackedLayout(self.coverWidget) coverWidget = QWidget()
coverWidget.setFixedSize(card_width, int(card_width * 1.2))
coverLayout = QStackedLayout(coverWidget)
coverLayout.setContentsMargins(0, 0, 0, 0) coverLayout.setContentsMargins(0, 0, 0, 0)
coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll) coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
# Обложка
self.coverLabel = QLabel() self.coverLabel = QLabel()
self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE) self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
coverLayout.addWidget(self.coverLabel) coverLayout.addWidget(self.coverLabel)
load_pixmap_async(cover_path or "", self.base_card_width, int(self.base_card_width * 1.5), self.on_cover_loaded) # создаём слабую ссылку на label
label_ref = weakref.ref(self.coverLabel)
self.favoriteLabel = ClickableLabel(self.coverWidget) def on_cover_loaded(pixmap):
label = label_ref()
if label is None:
return
label.setPixmap(round_corners(pixmap, 15))
# асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async)
load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded)
# Значок избранного (звёздочка) в левом верхнем углу обложки
self.favoriteLabel = ClickableLabel(coverWidget)
self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize)
self.favoriteLabel.move(8, 8)
self.favoriteLabel.clicked.connect(self.toggle_favorite) self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in read_favorites() self.is_favorite = self.name in read_favorites()
self.update_favorite_icon() self.update_favorite_icon()
self.favoriteLabel.raise_() self.favoriteLabel.raise_()
# Определяем общие параметры для бейджей
badge_width = int(card_width * 2/3)
icon_size = int(card_width * 0.06) # 6% от ширины карточки
icon_space = int(card_width * 0.012) # 1.2% от ширины карточки
font_scale_factor = 0.06 # Шрифт будет 6% от card_width
# ProtonDB бейдж
tier_text = self.getProtonDBText(protondb_tier) tier_text = self.getProtonDBText(protondb_tier)
if tier_text: if tier_text:
icon_filename = self.getProtonDBIconFilename(protondb_tier) icon_filename = self.getProtonDBIconFilename(protondb_tier)
@@ -113,50 +143,67 @@ class GameCard(QFrame):
self.protondbLabel = ClickableLabel( self.protondbLabel = ClickableLabel(
tier_text, tier_text,
icon=icon, icon=icon,
parent=self.coverWidget, parent=coverWidget,
font_scale_factor=0.06 icon_size=icon_size,
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier)) self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
self.protondbLabel.setFixedWidth(badge_width)
self.protondbLabel.setCardWidth(card_width) self.protondbLabel.setCardWidth(card_width)
else: else:
self.protondbLabel = ClickableLabel("", parent=self.coverWidget) self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
self.protondbLabel.setFixedWidth(badge_width)
self.protondbLabel.setVisible(False) self.protondbLabel.setVisible(False)
# Steam бейдж
steam_icon = self.theme_manager.get_icon("steam") steam_icon = self.theme_manager.get_icon("steam")
self.steamLabel = ClickableLabel( self.steamLabel = ClickableLabel(
"Steam", "Steam",
icon=steam_icon, icon=steam_icon,
parent=self.coverWidget, parent=coverWidget,
font_scale_factor=0.06 icon_size=icon_size,
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.steamLabel.setFixedWidth(badge_width)
self.steamLabel.setCardWidth(card_width) self.steamLabel.setCardWidth(card_width)
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
# Epic Games Store бейдж
egs_icon = self.theme_manager.get_icon("epic_games") egs_icon = self.theme_manager.get_icon("epic_games")
self.egsLabel = ClickableLabel( self.egsLabel = ClickableLabel(
"Epic Games", "Epic Games",
icon=egs_icon, icon=egs_icon,
parent=self.coverWidget, parent=coverWidget,
font_scale_factor=0.06, icon_size=icon_size,
icon_space=icon_space,
font_scale_factor=font_scale_factor,
change_cursor=False change_cursor=False
) )
self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.egsLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.egsLabel.setFixedWidth(badge_width)
self.egsLabel.setCardWidth(card_width) self.egsLabel.setCardWidth(card_width)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
# PortProton бейдж
portproton_icon = self.theme_manager.get_icon("portproton") portproton_icon = self.theme_manager.get_icon("portproton")
self.portprotonLabel = ClickableLabel( self.portprotonLabel = ClickableLabel(
"PortProton", "PortProton",
icon=portproton_icon, icon=portproton_icon,
parent=self.coverWidget, parent=coverWidget,
font_scale_factor=0.06 icon_size=icon_size,
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.portprotonLabel.setFixedWidth(badge_width)
self.portprotonLabel.setCardWidth(card_width) self.portprotonLabel.setCardWidth(card_width)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic) self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic)
# WeAntiCheatYet бейдж
anticheat_text = self.getAntiCheatText(anticheat_status) anticheat_text = self.getAntiCheatText(anticheat_status)
if anticheat_text: if anticheat_text:
icon_filename = self.getAntiCheatIconFilename(anticheat_status) icon_filename = self.getAntiCheatIconFilename(anticheat_status)
@@ -164,57 +211,40 @@ class GameCard(QFrame):
self.anticheatLabel = ClickableLabel( self.anticheatLabel = ClickableLabel(
anticheat_text, anticheat_text,
icon=icon, icon=icon,
parent=self.coverWidget, parent=coverWidget,
font_scale_factor=0.06 icon_size=icon_size,
icon_space=icon_space,
font_scale_factor=font_scale_factor
) )
self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status)) self.anticheatLabel.setStyleSheet(self.theme.get_anticheat_badge_style(anticheat_status))
self.anticheatLabel.setFixedWidth(badge_width)
self.anticheatLabel.setCardWidth(card_width) self.anticheatLabel.setCardWidth(card_width)
else: else:
self.anticheatLabel = ClickableLabel("", parent=self.coverWidget) self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=icon_size, icon_space=icon_space)
self.anticheatLabel.setFixedWidth(badge_width)
self.anticheatLabel.setVisible(False) self.anticheatLabel.setVisible(False)
# Расположение бейджей
self._position_badges(card_width)
self.protondbLabel.clicked.connect(self.open_protondb_report) self.protondbLabel.clicked.connect(self.open_protondb_report)
self.steamLabel.clicked.connect(self.open_steam_page) self.steamLabel.clicked.connect(self.open_steam_page)
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page) self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
self.layout_.addWidget(self.coverWidget) layout.addWidget(coverWidget)
self.nameLabel = QLabel(name) # Название игры
self.nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter) nameLabel = QLabel(name)
self.nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE) nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.layout_.addWidget(self.nameLabel) nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
layout.addWidget(nameLabel)
font_size = self.nameLabel.font().pointSizeF() def _position_badges(self, card_width):
self.base_font_size = font_size if font_size > 0 else 10.0 """Позиционирует бейджи на основе ширины карточки."""
right_margin = 8
self.update_scale() badge_spacing = int(card_width * 0.02) # 2% от ширины карточки
top_y = 10
# Force initial layout update to ensure correct geometry
self.updateGeometry()
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
parent.updateGeometry()
def on_cover_loaded(self, pixmap):
self.base_pixmap = pixmap
self.update_cover_pixmap()
def update_cover_pixmap(self):
if self.base_pixmap:
scaled_width = int(self.base_card_width * self._scale)
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
self.coverLabel.setPixmap(rounded_pixmap)
def _position_badges(self, current_width):
right_margin = int(8 * self._scale)
badge_spacing = int(current_width * 0.02)
top_y = int(10 * self._scale)
badge_y_positions = [] badge_y_positions = []
badge_width = int(current_width * 2/3) badge_width = int(card_width * 2/3)
badges = [ badges = [
(self.steam_visible, self.steamLabel), (self.steam_visible, self.steamLabel),
@@ -226,99 +256,80 @@ class GameCard(QFrame):
for is_visible, badge in badges: for is_visible, badge in badges:
if is_visible: if is_visible:
badge_x = current_width - badge_width - right_margin badge_x = card_width - badge_width - right_margin
badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y badge_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
badge.move(int(badge_x), int(badge_y)) badge.move(badge_x, badge_y)
badge_y_positions.append(badge_y + badge.height()) badge_y_positions.append(badge_y + badge.height())
# Поднимаем бейджи в правильном порядке (от нижнего к верхнему)
self.anticheatLabel.raise_() self.anticheatLabel.raise_()
self.protondbLabel.raise_() self.protondbLabel.raise_()
self.portprotonLabel.raise_() self.portprotonLabel.raise_()
self.egsLabel.raise_() self.egsLabel.raise_()
self.steamLabel.raise_() self.steamLabel.raise_()
def update_scale(self): def update_card_size(self, new_width: int):
scaled_width = int(self.base_card_width * self._scale) """Обновляет размер карточки, обложки и бейджей."""
scaled_height = int(self.base_card_width * 1.8 * self._scale) self.card_width = new_width
scaled_extra = int(self.base_extra_margin * self._scale) extra_margin = 20
self.setFixedSize(scaled_width + scaled_extra, scaled_height + scaled_extra) self.setFixedSize(new_width + extra_margin, int(new_width * 1.6) + extra_margin)
self.layout_.setContentsMargins(scaled_extra // 2, scaled_extra // 2, scaled_extra // 2, scaled_extra // 2)
self.coverWidget.setFixedSize(scaled_width, int(scaled_width * 1.5)) if self.coverLabel is None:
self.coverLabel.setFixedSize(scaled_width, int(scaled_width * 1.5)) return
self.update_cover_pixmap() coverWidget = self.coverLabel.parentWidget()
if coverWidget is None:
return
favorite_size = (int(self.theme.favoriteLabelSize[0] * self._scale), int(self.theme.favoriteLabelSize[1] * self._scale)) coverWidget.setFixedSize(new_width, int(new_width * 1.2))
self.favoriteLabel.setFixedSize(*favorite_size) self.coverLabel.setFixedSize(new_width, int(new_width * 1.2))
self.favoriteLabel.move(int(8 * self._scale), int(8 * self._scale))
badge_width = int(scaled_width * 2/3) label_ref = weakref.ref(self.coverLabel)
icon_size = int(scaled_width * 0.06) def on_cover_loaded(pixmap):
icon_space = int(scaled_width * 0.012) label = label_ref()
if label:
scaled_pixmap = pixmap.scaled(new_width, int(new_width * 1.2), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, 15)
label.setPixmap(rounded_pixmap)
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.2), on_cover_loaded)
# Обновляем размеры и шрифты бейджей
badge_width = int(new_width * 2/3)
icon_size = int(new_width * 0.06)
icon_space = int(new_width * 0.012)
for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]: for label in [self.steamLabel, self.egsLabel, self.portprotonLabel, self.protondbLabel, self.anticheatLabel]:
if label is not None: if label is not None:
label.setFixedWidth(badge_width) label.setFixedWidth(badge_width)
label.setIconSize(icon_size, icon_space) label.setIconSize(icon_size, icon_space)
label.setCardWidth(scaled_width) label.setCardWidth(new_width) # Пересчитываем размер шрифта
self._position_badges(scaled_width) # Перепозиционируем бейджи
self._position_badges(new_width)
if self.base_font_size is not None:
font = self.nameLabel.font()
new_font_size = self.base_font_size * self._scale
if new_font_size > 0:
font.setPointSizeF(new_font_size)
self.nameLabel.setFont(font)
self.shadow.setBlurRadius(int(20 * self._scale))
self.updateGeometry()
self.update() self.update()
# Ensure parent layout is updated safely
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
layout.activate()
layout.update()
parent.updateGeometry()
def update_card_size(self, new_width: int):
self.base_card_width = new_width
load_pixmap_async(self.cover_path or "", new_width, int(new_width * 1.5), self.on_cover_loaded)
self.update_scale()
def update_badge_visibility(self, display_filter: str): def update_badge_visibility(self, display_filter: str):
"""Обновляет видимость бейджей на основе display_filter."""
self.display_filter = display_filter self.display_filter = display_filter
self.steam_visible = (str(self.game_source).lower() == "steam" and self.display_filter in ("all", "favorites")) self.steam_visible = (str(self.game_source).lower() == "steam" and display_filter in ("all", "favorites"))
self.egs_visible = (str(self.game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) self.egs_visible = (str(self.game_source).lower() == "epic" and display_filter in ("all", "favorites"))
self.portproton_visible = (str(self.game_source).lower() == "portproton" and self.display_filter in ("all", "favorites")) self.portproton_visible = (str(self.game_source).lower() == "portproton" and display_filter in ("all", "favorites"))
protondb_visible = bool(self.getProtonDBText(self.protondb_tier)) protondb_visible = bool(self.getProtonDBText(self.protondb_tier))
anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status)) anticheat_visible = bool(self.getAntiCheatText(self.anticheat_status))
# Обновляем видимость бейджей
self.steamLabel.setVisible(self.steam_visible) self.steamLabel.setVisible(self.steam_visible)
self.egsLabel.setVisible(self.egs_visible) self.egsLabel.setVisible(self.egs_visible)
self.portprotonLabel.setVisible(self.portproton_visible) self.portprotonLabel.setVisible(self.portproton_visible)
self.protondbLabel.setVisible(protondb_visible) self.protondbLabel.setVisible(protondb_visible)
self.anticheatLabel.setVisible(anticheat_visible) self.anticheatLabel.setVisible(anticheat_visible)
scaled_width = int(self.base_card_width * self._scale) # Перепозиционируем бейджи
self._position_badges(scaled_width) self._position_badges(self.card_width)
# Update layout after visibility changes
self.updateGeometry()
parent = self.parentWidget()
if parent:
layout = parent.layout()
if layout:
layout.invalidate()
layout.update()
parent.updateGeometry()
def _show_context_menu(self, pos): def _show_context_menu(self, pos):
"""Delegate context menu display to ContextMenuManager."""
if self.context_menu_manager: if self.context_menu_manager:
self.context_menu_manager.show_context_menu(self, pos) self.context_menu_manager.show_context_menu(self, pos)
@@ -376,6 +387,7 @@ class GameCard(QFrame):
return "" return ""
def open_portproton_forum_topic(self): def open_portproton_forum_topic(self):
"""Open the PortProton forum topic or search page for this game."""
result = self.portproton_api.get_forum_topic_slug(self.name) result = self.portproton_api.get_forum_topic_slug(self.name)
base_url = "https://linux-gaming.ru/" base_url = "https://linux-gaming.ru/"
if result.startswith("search?q="): if result.startswith("search?q="):
@@ -435,19 +447,8 @@ class GameCard(QFrame):
self.gradientAngleChanged.emit() self.gradientAngleChanged.emit()
self.update() self.update()
def getScale(self) -> float:
return self._scale
def setScale(self, value: float):
if self._scale != value:
self._scale = value
self.update_scale()
self.scaleChanged.emit()
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged)) borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged)) gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
def paintEvent(self, event): def paintEvent(self, event):
super().paintEvent(event) super().paintEvent(event)
@@ -486,7 +487,6 @@ class GameCard(QFrame):
) )
super().mousePressEvent(event) super().mousePressEvent(event)
def keyPressEvent(self, event): def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter): if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self.select_callback( self.select_callback(

View File

@@ -1,470 +0,0 @@
from typing import Protocol
from portprotonqt.game_card import GameCard
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, QSlider, QScroller
from PySide6.QtCore import Qt, QTimer
from portprotonqt.custom_widgets import FlowLayout
from portprotonqt.config_utils import read_favorites, read_sort_method, read_card_size, save_card_size
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
from collections import deque
class MainWindowProtocol(Protocol):
"""Protocol defining the interface that MainWindow must implement for GameLibraryManager."""
def openGameDetailPage(
self,
name: str,
description: str,
cover_path: str | None = None,
appid: str = "",
exec_line: str = "",
controller_support: str = "",
last_launch: str = "",
formatted_playtime: str = "",
protondb_tier: str = "",
game_source: str = "",
anticheat_status: str = "",
) -> None: ...
def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]: ...
def on_slider_released(self) -> None: ...
# Required attributes
searchEdit: CustomLineEdit
_last_card_width: int
card_width: int
current_hovered_card: GameCard | None
current_focused_card: GameCard | None
gamesListWidget: QWidget | None
class GameLibraryManager:
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
self.main_window = main_window
self.theme = theme
self.context_menu_manager: ContextMenuManager | None = context_menu_manager
self.games: list[tuple] = []
self.filtered_games: list[tuple] = []
self.game_card_cache = {}
self.pending_images = {}
self.card_width = read_card_size()
self.gamesListWidget: QWidget | None = None
self.gamesListLayout: FlowLayout | None = None
self.sizeSlider: QSlider | None = None
self._update_timer: QTimer | None = None
self._pending_update = False
self.pending_deletions = deque()
self.is_filtering = False
self.dirty = False
def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider."""
self.gamesLibraryWidget = QWidget()
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
layout = QVBoxLayout(self.gamesLibraryWidget)
layout.setSpacing(15)
# Search widget
searchWidget, self.searchEdit = self.main_window.createSearchWidget()
layout.addWidget(searchWidget)
# Scroll area for game grid
scrollArea = QScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
QScroller.grabGesture(scrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.gamesListWidget = QWidget()
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
layout.addWidget(scrollArea)
# Slider for card size
sliderLayout = QHBoxLayout()
sliderLayout.addStretch()
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250)
self.sizeSlider.setValue(self.card_width)
self.sizeSlider.setTickInterval(10)
self.sizeSlider.setFixedWidth(150)
self.sizeSlider.setToolTip(f"{self.card_width} px")
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.main_window.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout)
# Initialize update timer
self._update_timer = QTimer()
self._update_timer.setSingleShot(True)
self._update_timer.setInterval(100) # 100ms debounce
self._update_timer.timeout.connect(self._perform_update)
# Calculate initial card width
def calculate_card_width():
if self.gamesListLayout is None:
return
available_width = scrollArea.width() - 20
spacing = self.gamesListLayout._spacing
target_cards_per_row = 8
calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
calculated_width = max(200, min(calculated_width, 250))
QTimer.singleShot(0, calculate_card_width)
# Connect scroll event for lazy loading
scrollArea.verticalScrollBar().valueChanged.connect(self.load_visible_images)
return self.gamesLibraryWidget
def on_slider_released(self):
"""Handles slider release to update card size."""
if self.sizeSlider is None:
return
self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width)
self.main_window.card_width = self.card_width
self.main_window._last_card_width = self.card_width
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.update_game_grid()
def load_visible_images(self):
"""Loads images for visible game cards."""
if self.gamesListWidget is None:
return
visible_region = self.gamesListWidget.visibleRegion()
max_concurrent_loads = 5
loaded_count = 0
for card_key, card in self.game_card_cache.items():
if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads:
cover_path, width, height, callback = self.pending_images.pop(card_key)
load_pixmap_async(cover_path, width, height, callback)
loaded_count += 1
def _on_card_focused(self, game_name: str, is_focused: bool):
"""Handles card focus events."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_focused:
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card = None
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
self.main_window.current_focused_card = card
else:
if self.main_window.current_focused_card == card:
self.main_window.current_focused_card = None
def _on_card_hovered(self, game_name: str, is_hovered: bool):
"""Handles card hover events."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_hovered:
if self.main_window.current_focused_card and self.main_window.current_focused_card != card:
self.main_window.current_focused_card._focused = False
self.main_window.current_focused_card.clearFocus()
if self.main_window.current_hovered_card and self.main_window.current_hovered_card != card:
self.main_window.current_hovered_card._hovered = False
self.main_window.current_hovered_card.leaveEvent(None)
self.main_window.current_hovered_card = card
else:
if self.main_window.current_hovered_card == card:
self.main_window.current_hovered_card = None
def _perform_update(self):
"""Performs the actual grid update."""
if not self._pending_update:
return
self._pending_update = False
self._update_game_grid_immediate()
def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False):
"""Schedules a game grid update with debouncing."""
if not is_filter:
if games_list is not None:
self.filtered_games = games_list
self.dirty = True # Full rebuild only for non-filter
self.is_filtering = is_filter
self._pending_update = True
if self._update_timer is not None:
self._update_timer.start()
else:
self._update_game_grid_immediate()
def force_update_cards_library(self):
if self.gamesListWidget and self.gamesListLayout:
self.gamesListLayout.invalidate()
self.gamesListWidget.updateGeometry()
widget = self.gamesListWidget
QTimer.singleShot(0, lambda: (
widget.adjustSize(),
widget.updateGeometry()
))
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
if self.gamesListLayout is None or self.gamesListWidget is None:
return
search_text = self.main_window.searchEdit.text().strip().lower()
if self.is_filtering:
# Filter mode: do not change layout, only hide/show cards
self._apply_filter_visibility(search_text)
else:
# Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games if self.filtered_games else self.games
favorites = read_favorites()
sort_method = read_sort_method()
# Batch layout updates (extended scope)
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Optimized sorting: Partition favorites first, then sort subgroups
def partition_sort_key(game):
name = game[0]
is_fav = name in favorites
fav_order = 0 if is_fav else 1
if sort_method == "playtime":
return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0)
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
else:
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
# Quick partition: Sort favorites and non-favorites separately, then merge
fav_games = [g for g in games_list if g[0] in favorites]
non_fav_games = [g for g in games_list if g[0] not in favorites]
sorted_fav = sorted(fav_games, key=partition_sort_key)
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
sorted_games = sorted_fav + sorted_non_fav
# Build set of current game keys for faster lookup
current_game_keys = {(game[0], game[4]) for game in sorted_games}
# Remove cards that no longer exist (batch)
cards_to_remove = []
for card_key in list(self.game_card_cache.keys()):
if card_key not in current_game_keys:
cards_to_remove.append(card_key)
for card_key in cards_to_remove:
card = self.game_card_cache.pop(card_key)
if self.gamesListLayout is not None:
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer
if card_key in self.pending_images:
del self.pending_images[card_key]
# Track current layout order (only if dirty/full update needed)
if self.dirty and self.gamesListLayout is not None:
current_layout_order = []
for i in range(self.gamesListLayout.count()):
item = self.gamesListLayout.itemAt(i)
if item is not None:
widget = item.widget()
if widget:
for key, card in self.game_card_cache.items():
if card == widget:
current_layout_order.append(key)
break
else:
current_layout_order = None # Skip reorg if not dirty
new_card_order = []
cards_to_add = []
for game_data in sorted_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
should_be_visible = not search_text or search_text in game_name.lower()
if game_key in self.game_card_cache:
card = self.game_card_cache[game_key]
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
new_card_order.append(game_key)
else:
if self.context_menu_manager is None:
continue
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(should_be_visible)
new_card_order.append(game_key)
cards_to_add.append((game_key, card))
# Only reorganize if order changed AND dirty
if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order):
# Remove all widgets from layout (batch)
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
# Add widgets in new order (batch)
for game_key in new_card_order:
card = self.game_card_cache[game_key]
self.gamesListLayout.addWidget(card)
self.dirty = False # Reset flag
# Deferred deletions (run in timer to avoid stack overflow)
if self.pending_deletions:
QTimer.singleShot(0, lambda: self._flush_deletions())
# Load visible images for new cards only
if cards_to_add:
self.load_visible_images()
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
self.force_update_cards_library()
self.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str):
"""Applies visibility to cards based on search, without changing the layout."""
visible_count = 0
for game_key, card in self.game_card_cache.items():
game_name = card.name # Assume GameCard has 'name' attribute
should_be_visible = not search_text or search_text in game_name.lower()
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
if should_be_visible:
visible_count += 1
# Load image only for newly visible cards
if game_key in self.pending_images:
cover_path, width, height, callback = self.pending_images.pop(game_key)
load_pixmap_async(cover_path, width, height, callback)
# Force full relayout after visibility changes
if self.gamesListLayout is not None:
self.gamesListLayout.invalidate() # Принудительно инвалидируем для пересчёта
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
# If search is empty, load images for visible ones
if not search_text:
self.load_visible_images()
def _create_game_card(self, game_data: tuple) -> GameCard:
"""Creates a new game card with all necessary connections."""
card = GameCard(
*game_data,
select_callback=self.main_window.openGameDetailPage,
theme=self.theme,
card_width=self.card_width,
context_menu_manager=self.context_menu_manager
)
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
if self.context_menu_manager:
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu)
card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop)
card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop)
card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
return card
def _flush_deletions(self):
"""Delete pending widgets off the main update cycle."""
for card in list(self.pending_deletions):
card.deleteLater()
self.pending_deletions.remove(card)
def clear_layout(self, layout):
"""Clears all widgets from the layout."""
if layout is None:
return
while layout.count():
child = layout.takeAt(0)
if child.widget():
widget = child.widget()
for key, card in list(self.game_card_cache.items()):
if card == widget:
del self.game_card_cache[key]
if key in self.pending_images:
del self.pending_images[key]
widget.deleteLater()
def set_games(self, games: list[tuple]):
"""Sets the games list and updates the filtered games."""
self.games = games
self.filtered_games = self.games
self.dirty = True # Full resort needed
self.update_game_grid()
def add_game_incremental(self, game_data: tuple):
"""Add a single game without full reload."""
self.games.append(game_data)
self.filtered_games.append(game_data) # Assume no filter active; adjust if needed
self.dirty = True
self.update_game_grid()
def remove_game_incremental(self, game_name: str, exec_line: str):
"""Remove a single game without full reload."""
key = (game_name, exec_line)
self.games = [g for g in self.games if (g[0], g[4]) != key]
self.filtered_games = [g for g in self.filtered_games if (g[0], g[4]) != key]
if key in self.game_card_cache and self.gamesListLayout is not None:
card = self.game_card_cache.pop(key)
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer deleteLater
if key in self.pending_images:
del self.pending_images[key]
self.dirty = True
self.update_game_grid()
def filter_games_delayed(self):
"""Filters games based on search text and updates the grid."""
self.update_game_grid(is_filter=True)

View File

@@ -3,6 +3,7 @@ from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.config_utils import read_theme_from_config from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
@@ -176,8 +177,7 @@ class FullscreenDialog(QDialog):
self.images = images self.images = images
self.current_index = current_index self.current_index = current_index
self.theme_manager = ThemeManager() self.theme = theme if theme else default_styles
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog) self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
@@ -378,8 +378,7 @@ class ImageCarousel(QGraphicsView):
self.images = images # Список кортежей: (QPixmap, caption) self.images = images # Список кортежей: (QPixmap, caption)
self.image_items = [] self.image_items = []
self._animation = None self._animation = None
self.theme_manager = ThemeManager() self.theme = theme if theme else default_styles
self.theme = theme if theme is not None else self.theme_manager.apply_theme(read_theme_from_config())
self.max_height = 300 # Default height for images self.max_height = 300 # Default height for images
self.init_ui() self.init_ui()
self.create_arrows() self.create_arrows()

File diff suppressed because it is too large Load Diff

View File

@@ -1,73 +0,0 @@
# keyboard_layouts.py
keyboard_layouts = {
'en': {
'normal': [
['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"],
['', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/']
],
'shift': [
['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'],
['', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?']
]
},
'ru': {
'normal': [
['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'],
['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'],
['', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.']
],
'shift': [
['Ё', '!', '"', '', ';', '%', ':', '?', '*', '(', ')', '_', '+'],
['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'],
['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'],
['', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ',']
]
},
'fr': {
'normal': [
['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='],
['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'],
['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'],
['', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!']
],
'shift': [
['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'],
['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'],
['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'],
['', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§']
]
},
'es': {
'normal': [
['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'],
['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'],
['', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
],
'shift': [
['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'],
['', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
]
},
'de': {
'normal': [
['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'],
['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'],
['', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
],
'shift': [
['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'],
['', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
]
}
}

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n" "POT-Creation-Date: 2025-08-23 20:35+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -23,13 +23,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton directory not found" msgid "PortProton is not found"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton" msgid "Delete from PortProton"
@@ -41,6 +35,12 @@ msgstr ""
msgid "Launch Game" msgid "Launch Game"
msgstr "" msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command found in .desktop file for '{game_name}'" msgid "No executable command in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Error reading .desktop file: {error}" msgid "Failed to read .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -191,10 +191,6 @@ msgstr ""
msgid "Failed to delete custom data: {error}" msgid "Failed to delete custom data: {error}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required" msgid "Game name and executable path are required"
msgstr "" msgstr ""
@@ -252,48 +248,16 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
msgid "Select" msgid "Select"
msgstr "" msgstr ""
msgid "Path: " msgid "Cancel"
msgstr "" msgstr ""
#, python-format msgid "Path: "
msgid "Access denied: %s"
msgstr "" msgstr ""
msgid "Edit Game" msgid "Edit Game"
@@ -332,39 +296,6 @@ msgstr ""
msgid "No cover selected" msgid "No cover selected"
msgstr "" msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..." msgid "Loading Epic Games Store games..."
msgstr "" msgstr ""
@@ -413,6 +344,9 @@ msgstr ""
msgid "Auto Install" msgid "Auto Install"
msgstr "" msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings" msgid "Wine Settings"
msgstr "" msgstr ""
@@ -422,34 +356,6 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -462,106 +368,13 @@ msgstr ""
msgid "Find Games ..." msgid "Find Games ..."
msgstr "" msgstr ""
#, python-brace-format msgid "Here you can configure automatic game installation..."
msgid "Added '{name}'"
msgstr "" msgstr ""
msgid "Compatibility tool:" msgid "List of available emulators and their configuration..."
msgstr "" msgstr ""
msgid "Prefix:" msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr "" msgstr ""
msgid "Main PortProton parameters..." msgid "Main PortProton parameters..."
@@ -597,9 +410,6 @@ msgstr ""
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "" msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL" msgid "Proxy URL"
msgstr "" msgstr ""
@@ -624,12 +434,6 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" msgstr ""
@@ -642,6 +446,21 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -651,6 +470,28 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -704,6 +545,9 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -807,24 +651,3 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n" "POT-Creation-Date: 2025-08-23 20:35+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -23,13 +23,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton directory not found" msgid "PortProton is not found"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton" msgid "Delete from PortProton"
@@ -41,6 +35,12 @@ msgstr ""
msgid "Launch Game" msgid "Launch Game"
msgstr "" msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command found in .desktop file for '{game_name}'" msgid "No executable command in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Error reading .desktop file: {error}" msgid "Failed to read .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -191,10 +191,6 @@ msgstr ""
msgid "Failed to delete custom data: {error}" msgid "Failed to delete custom data: {error}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required" msgid "Game name and executable path are required"
msgstr "" msgstr ""
@@ -252,48 +248,16 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
msgid "Select" msgid "Select"
msgstr "" msgstr ""
msgid "Path: " msgid "Cancel"
msgstr "" msgstr ""
#, python-format msgid "Path: "
msgid "Access denied: %s"
msgstr "" msgstr ""
msgid "Edit Game" msgid "Edit Game"
@@ -332,39 +296,6 @@ msgstr ""
msgid "No cover selected" msgid "No cover selected"
msgstr "" msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..." msgid "Loading Epic Games Store games..."
msgstr "" msgstr ""
@@ -413,6 +344,9 @@ msgstr ""
msgid "Auto Install" msgid "Auto Install"
msgstr "" msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings" msgid "Wine Settings"
msgstr "" msgstr ""
@@ -422,34 +356,6 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -462,106 +368,13 @@ msgstr ""
msgid "Find Games ..." msgid "Find Games ..."
msgstr "" msgstr ""
#, python-brace-format msgid "Here you can configure automatic game installation..."
msgid "Added '{name}'"
msgstr "" msgstr ""
msgid "Compatibility tool:" msgid "List of available emulators and their configuration..."
msgstr "" msgstr ""
msgid "Prefix:" msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr "" msgstr ""
msgid "Main PortProton parameters..." msgid "Main PortProton parameters..."
@@ -597,9 +410,6 @@ msgstr ""
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "" msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL" msgid "Proxy URL"
msgstr "" msgstr ""
@@ -624,12 +434,6 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" msgstr ""
@@ -642,6 +446,21 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -651,6 +470,28 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -704,6 +545,9 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -807,24 +651,3 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n" "POT-Creation-Date: 2025-08-23 20:35+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -21,13 +21,7 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "" msgstr ""
msgid "PortProton directory not found" msgid "PortProton is not found"
msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr "" msgstr ""
msgid "Delete from PortProton" msgid "Delete from PortProton"
@@ -39,6 +33,12 @@ msgstr ""
msgid "Launch Game" msgid "Launch Game"
msgstr "" msgstr ""
msgid "Remove from Favorites"
msgstr ""
msgid "Add to Favorites"
msgstr ""
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "" msgstr ""
@@ -153,7 +153,7 @@ msgid "Menu"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "No executable command found in .desktop file for '{game_name}'" msgid "No executable command in .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -161,7 +161,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "Error reading .desktop file: {error}" msgid "Failed to read .desktop file: {error}"
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
@@ -189,10 +189,6 @@ msgstr ""
msgid "Failed to delete custom data: {error}" msgid "Failed to delete custom data: {error}"
msgstr "" msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required" msgid "Game name and executable path are required"
msgstr "" msgstr ""
@@ -250,48 +246,16 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
msgid "Select" msgid "Select"
msgstr "" msgstr ""
msgid "Path: " msgid "Cancel"
msgstr "" msgstr ""
#, python-format msgid "Path: "
msgid "Access denied: %s"
msgstr "" msgstr ""
msgid "Edit Game" msgid "Edit Game"
@@ -330,39 +294,6 @@ msgstr ""
msgid "No cover selected" msgid "No cover selected"
msgstr "" msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..." msgid "Loading Epic Games Store games..."
msgstr "" msgstr ""
@@ -411,6 +342,9 @@ msgstr ""
msgid "Auto Install" msgid "Auto Install"
msgstr "" msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings" msgid "Wine Settings"
msgstr "" msgstr ""
@@ -420,34 +354,6 @@ msgstr ""
msgid "Themes" msgid "Themes"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "" msgstr ""
@@ -460,106 +366,13 @@ msgstr ""
msgid "Find Games ..." msgid "Find Games ..."
msgstr "" msgstr ""
#, python-brace-format msgid "Here you can configure automatic game installation..."
msgid "Added '{name}'"
msgstr "" msgstr ""
msgid "Compatibility tool:" msgid "List of available emulators and their configuration..."
msgstr "" msgstr ""
msgid "Prefix:" msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr "" msgstr ""
msgid "Main PortProton parameters..." msgid "Main PortProton parameters..."
@@ -595,9 +408,6 @@ msgstr ""
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "" msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL" msgid "Proxy URL"
msgstr "" msgstr ""
@@ -622,12 +432,6 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" msgstr ""
@@ -640,6 +444,21 @@ msgstr ""
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "" msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings" msgid "Save Settings"
msgstr "" msgstr ""
@@ -649,6 +468,28 @@ msgstr ""
msgid "Clear Cache" msgid "Clear Cache"
msgstr "" msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "" msgstr ""
@@ -702,6 +543,9 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -805,24 +649,3 @@ msgstr ""
msgid "sec." msgid "sec."
msgstr "" msgstr ""
msgid "Show"
msgstr ""
msgid "Favorites"
msgstr ""
msgid "Recent Games"
msgstr ""
msgid "Exit"
msgstr ""
msgid "Hide"
msgstr ""
msgid "No favorites"
msgstr ""
msgid "No recent games"
msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n" "POT-Creation-Date: 2025-08-23 20:35+0500\n"
"PO-Revision-Date: 2025-10-16 14:54+0500\n" "PO-Revision-Date: 2025-08-23 20:35+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n" "Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
@@ -24,14 +24,8 @@ msgstr ""
msgid "Error" msgid "Error"
msgstr "Ошибка" msgstr "Ошибка"
msgid "PortProton directory not found" msgid "PortProton is not found"
msgstr "Не найден каталог PortProton" msgstr "PortProton не найден"
msgid "Remove from Favorites"
msgstr "Удалить из Избранного"
msgid "Add to Favorites"
msgstr "Добавить в Избранное"
msgid "Delete from PortProton" msgid "Delete from PortProton"
msgstr "Удалить из PortProton" msgstr "Удалить из PortProton"
@@ -42,6 +36,12 @@ msgstr "Остановить игру"
msgid "Launch Game" msgid "Launch Game"
msgstr "Запустить игру" msgstr "Запустить игру"
msgid "Remove from Favorites"
msgstr "Удалить из Избранного"
msgid "Add to Favorites"
msgstr "Добавить в Избранное"
msgid "Import to Legendary" msgid "Import to Legendary"
msgstr "Импортировать игру" msgstr "Импортировать игру"
@@ -158,16 +158,16 @@ msgid "Menu"
msgstr "Меню" msgstr "Меню"
#, python-brace-format #, python-brace-format
msgid "No executable command found in .desktop file for '{game_name}'" msgid "No executable command in .desktop file for '{game_name}'"
msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'" msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда"
#, python-brace-format #, python-brace-format
msgid "Failed to parse .desktop file for '{game_name}'" msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "Не удалось разобрать файл .desktop для '{game_name}'" msgstr "Не удалось разобрать файл .desktop для '{game_name}'"
#, python-brace-format #, python-brace-format
msgid "Error reading .desktop file: {error}" msgid "Failed to read .desktop file: {error}"
msgstr "Ошибка при чтении файла .desktop: {error}" msgstr "Не удалось прочитать файл .desktop: {error}"
#, python-brace-format #, python-brace-format
msgid "No .desktop file found for '{game_name}'" msgid "No .desktop file found for '{game_name}'"
@@ -196,10 +196,6 @@ msgstr "'{game_name}' был(а) успешно удалён(а)"
msgid "Failed to delete custom data: {error}" msgid "Failed to delete custom data: {error}"
msgstr "Не удалось удалить пользовательские данные: {error}" msgstr "Не удалось удалить пользовательские данные: {error}"
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr "'{game_name}' успешно добавлен(а)"
msgid "Game name and executable path are required" msgid "Game name and executable path are required"
msgstr "Требуются название игры и путь к исполняемому файлу" msgstr "Требуются название игры и путь к исполняемому файлу"
@@ -259,50 +255,18 @@ msgstr "Удалить"
msgid "Select All" msgid "Select All"
msgstr "Выбрать всё" msgstr "Выбрать всё"
msgid "Open"
msgstr "Открыть"
msgid "Select Dir"
msgstr "Выбрать папку"
msgid "Prev Dir"
msgstr "Предыдущий каталог"
msgid "Cancel"
msgstr "Отмена"
msgid "Toggle"
msgstr "Переключить"
msgid "Install"
msgstr "Установить"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Prev Tab"
msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "File Explorer" msgid "File Explorer"
msgstr "Проводник" msgstr "Проводник"
msgid "Select" msgid "Select"
msgstr "Выбрать" msgstr "Выбрать"
msgid "Cancel"
msgstr "Отмена"
msgid "Path: " msgid "Path: "
msgstr "Путь: " msgstr "Путь: "
#, python-format
msgid "Access denied: %s"
msgstr "Доступ запрещён: %s"
msgid "Edit Game" msgid "Edit Game"
msgstr "Редактировать игру" msgstr "Редактировать игру"
@@ -339,39 +303,6 @@ msgstr "Скачивание обложки..."
msgid "No cover selected" msgid "No cover selected"
msgstr "Обложка не выбрана" msgstr "Обложка не выбрана"
msgid "Prefix Manager"
msgstr "Менеджер префиксов"
msgid "Set"
msgstr "Выбор"
msgid "Libraries"
msgstr "Библиотеки"
msgid "Information"
msgstr "Описание"
msgid "Fonts"
msgstr "Шрифты"
msgid "Settings"
msgstr "Настройки"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку."
msgid "Warning"
msgstr "Предупреждение"
msgid "No components selected."
msgstr "Не выбрано ни одного компонента."
msgid "Installation failed. Check logs."
msgstr "Установка не удалась. Проверьте журналы."
msgid "Components installed successfully."
msgstr "Компоненты успешно установлены."
msgid "Loading Epic Games Store games..." msgid "Loading Epic Games Store games..."
msgstr "Загрузка игр из Epic Games Store..." msgstr "Загрузка игр из Epic Games Store..."
@@ -420,6 +351,9 @@ msgstr "Библиотека"
msgid "Auto Install" msgid "Auto Install"
msgstr "Автоустановка" msgstr "Автоустановка"
msgid "Emulators"
msgstr "Эмуляторы"
msgid "Wine Settings" msgid "Wine Settings"
msgstr "Настройки wine" msgstr "Настройки wine"
@@ -429,34 +363,6 @@ msgstr "Настройки PortProton"
msgid "Themes" msgid "Themes"
msgstr "Темы" msgstr "Темы"
msgid "Back"
msgstr "Назад"
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Search"
msgstr "Поиск"
msgid "Installation already in progress."
msgstr "Установка уже выполняется."
msgid "Failed to start installation."
msgstr "Не удалось запустить установку."
#, python-brace-format
msgid "Processed {} installation..."
msgstr "В процессе установки {}..."
msgid "Installation completed successfully."
msgstr "Установка завершена успешно."
msgid "Installation failed."
msgstr "Установка не удалась."
msgid "Installation error."
msgstr "Ошибка установки."
msgid "Loading Steam games..." msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..." msgstr "Загрузка игр из Steam..."
@@ -469,109 +375,14 @@ msgstr "Игровая библиотека"
msgid "Find Games ..." msgid "Find Games ..."
msgstr "Найти игры..." msgstr "Найти игры..."
#, python-brace-format msgid "Here you can configure automatic game installation..."
msgid "Added '{name}'" msgstr "Здесь можно настроить автоматическую установку игр..."
msgstr "'{name}' добавлен(а)"
msgid "Compatibility tool:" msgid "List of available emulators and their configuration..."
msgstr "Инструмент совместимости:" msgstr "Список доступных эмуляторов и их настройка..."
msgid "Prefix:" msgid "Various Wine parameters and versions..."
msgstr "Префикс:" msgstr "Различные параметры и версии wine..."
msgid "Wine Configuration"
msgstr "Конфигурация Wine"
msgid "Registry Editor"
msgstr "Редактор реестра"
msgid "Command Prompt"
msgstr "Командная строка"
msgid "Uninstaller"
msgstr "Удаление программ"
msgid "Create Prefix Backup"
msgstr "Создать резервную копию префикса"
msgid "Load Prefix Backup"
msgstr "Загрузить резервную копию префикса"
msgid "Delete Compatibility Tool"
msgstr "Удалить Инструмент совместимости"
msgid "Delete Prefix"
msgstr "Удалить Префикс"
msgid "Clear Prefix"
msgstr "Очистить Префикс"
msgid "Launching tool..."
msgstr "Запуск инструмента..."
msgid "Failed to start process."
msgstr "Не удалось запустить процесс."
msgid "Confirm Clear"
msgstr "Подтвердите очистку"
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr "Префикс '{}' успешно удален."
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
"Префикс '{}' очищен с ошибками:\n"
"{}"
msgid "Failed to start backup process."
msgstr "Не удалось запустить процесс резервного копирования."
msgid "Failed to start restore process."
msgstr "Не удалось запустить процесс восстановления."
msgid "Prefix backup completed."
msgstr "Резервное копирование префикса завершено."
msgid "Prefix backup failed."
msgstr "Сбой резервного копирования префикса."
msgid "Prefix restore completed."
msgstr "Восстановление префикса завершено."
msgid "Prefix restore failed."
msgstr "Восстановление префикса не удалось."
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr "Префикс «{}» удален."
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr "Не удалось удалить префикс: {}"
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?"
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr "Инструмент совместимости «{}» удален."
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr "Не удалось удалить инструмент совместимости: {}"
msgid "Main PortProton parameters..." msgid "Main PortProton parameters..."
msgstr "Основные параметры PortProton..." msgstr "Основные параметры PortProton..."
@@ -606,9 +417,6 @@ msgstr "все"
msgid "Games Display Filter:" msgid "Games Display Filter:"
msgstr "Фильтр игр:" msgstr "Фильтр игр:"
msgid "Gamepad Type:"
msgstr "Тип геймпада:"
msgid "Proxy URL" msgid "Proxy URL"
msgstr "Адрес прокси" msgstr "Адрес прокси"
@@ -633,12 +441,6 @@ msgstr "Запуск приложения в полноэкранном режи
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "Режим полноэкранного отображения приложения:" msgstr "Режим полноэкранного отображения приложения:"
msgid "Minimize to tray on close"
msgstr "Сворачивать в трей при закрытии"
msgid "Application Close Mode:"
msgstr "Режим закрытия приложения:"
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "Режим полноэкранного отображения приложения при подключении геймпада" msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
@@ -651,6 +453,21 @@ msgstr "Тактильная отдача на геймпаде"
msgid "Gamepad haptic feedback:" msgid "Gamepad haptic feedback:"
msgstr "Тактильная отдача на геймпаде:" msgstr "Тактильная отдача на геймпаде:"
msgid "Open Legendary Login"
msgstr "Открыть браузер для входа в Legendary"
msgid "Legendary Authentication:"
msgstr "Авторизация в Legendary:"
msgid "Enter Legendary Authorization Code"
msgstr "Введите код авторизации Legendary"
msgid "Authorization Code:"
msgstr "Код авторизации:"
msgid "Submit Code"
msgstr "Отправить код"
msgid "Save Settings" msgid "Save Settings"
msgstr "Сохранить настройки" msgstr "Сохранить настройки"
@@ -660,6 +477,28 @@ msgstr "Сбросить настройки"
msgid "Clear Cache" msgid "Clear Cache"
msgstr "Очистить кэш" msgstr "Очистить кэш"
msgid "Opened Legendary login page in browser"
msgstr "Открытие страницы входа в Legendary в браузере"
msgid "Failed to open Legendary login page"
msgstr "Не удалось открыть страницу входа в Legendary"
msgid "Please enter an authorization code"
msgstr "Пожалуйста, введите код авторизации"
msgid "Successfully authenticated with Legendary"
msgstr "Успешная аутентификация в Legendary"
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr "Не удалось выполнить аутентификацию Legendary: {0}"
msgid "Legendary executable not found"
msgstr "Не найден исполняемый файл Legendary"
msgid "Unexpected error during authentication"
msgstr "Неожиданная ошибка при аутентификации"
msgid "Confirm Reset" msgid "Confirm Reset"
msgstr "Подтвердите удаление" msgstr "Подтвердите удаление"
@@ -715,6 +554,9 @@ msgstr "Тема '{0}' применена успешно"
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'" msgstr "Ошибка при применение темы '{0}'"
msgid "Back"
msgstr "Назад"
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "Последний запуск" msgstr "Последний запуск"
@@ -818,24 +660,3 @@ msgstr "мин."
msgid "sec." msgid "sec."
msgstr "сек." msgstr "сек."
msgid "Show"
msgstr "Показать"
msgid "Favorites"
msgstr "Избранное"
msgid "Recent Games"
msgstr "Недавние"
msgid "Exit"
msgstr "Выход"
msgid "Hide"
msgstr "Скрыть"
msgid "No favorites"
msgstr "Нет избранных"
msgid "No recent games"
msgstr "Нет недавних игр"

View File

@@ -1,34 +1,16 @@
import logging import logging
def setup_logger(level='NOTSET'): def setup_logger():
"""Настройка базовой конфигурации логирования.""" """Настройка базовой конфигурации логирования."""
# Clear existing handlers to prevent duplicates logging.basicConfig(
root_logger = logging.getLogger() level=logging.INFO,
for handler in root_logger.handlers[:]: format='[%(levelname)s] %(message)s',
root_logger.removeHandler(handler) handlers=[logging.StreamHandler()]
)
# Convert string level to logging level constant, map ALL to DEBUG
if level.upper() == 'ALL':
log_level = logging.DEBUG
else:
log_level = getattr(logging, level.upper(), logging.NOTSET)
# Configure logging with null handler if level is NOTSET
if log_level == logging.NOTSET:
logging.basicConfig(
level=logging.NOTSET,
handlers=[logging.NullHandler()]
)
else:
logging.basicConfig(
level=log_level,
format='[%(levelname)s] %(message)s',
handlers=[logging.StreamHandler()]
)
def get_logger(name): def get_logger(name):
"""Возвращает логгер для указанного модуля.""" """Возвращает логгер для указанного модуля."""
return logging.getLogger(name) return logging.getLogger(name)
# Инициализация логгера при импорте модуля (без логов по умолчанию) # Инициализация логгера при импорте модуля
setup_logger() setup_logger()

File diff suppressed because it is too large Load Diff

View File

@@ -4,12 +4,9 @@ import orjson
import requests import requests
import urllib.parse import urllib.parse
import time import time
import glob
import re
from collections.abc import Callable from collections.abc import Callable
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.config_utils import get_portproton_location
logger = get_logger(__name__) logger = get_logger(__name__)
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
@@ -55,9 +52,6 @@ class PortProtonAPI:
self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data") self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
os.makedirs(self.custom_data_dir, exist_ok=True) os.makedirs(self.custom_data_dir, exist_ok=True)
self.portproton_location = get_portproton_location()
self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
self._topics_data = None self._topics_data = None
def _get_game_dir(self, exe_name: str) -> str: def _get_game_dir(self, exe_name: str) -> str:
@@ -74,6 +68,40 @@ class PortProtonAPI:
logger.debug(f"Failed to check file at {url}: {e}") logger.debug(f"Failed to check file at {url}: {e}")
return False return False
def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]:
game_dir = self._get_game_dir(exe_name)
results: dict[str, str | None] = {"cover": None, "metadata": None}
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
cover_url_base = f"{self.base_url}/{exe_name}/cover"
metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
for ext in cover_extensions:
cover_url = f"{cover_url_base}{ext}"
if self._check_file_exists(cover_url, timeout):
local_cover_path = os.path.join(game_dir, f"cover{ext}")
result = self.downloader.download(cover_url, local_cover_path, timeout=timeout)
if result:
results["cover"] = result
logger.info(f"Downloaded cover for {exe_name} to {result}")
break
else:
logger.error(f"Failed to download cover for {exe_name} from {cover_url}")
else:
logger.debug(f"No cover found for {exe_name} with extension {ext}")
if self._check_file_exists(metadata_url, timeout):
local_metadata_path = os.path.join(game_dir, "metadata.txt")
result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout)
if result:
results["metadata"] = result
logger.info(f"Downloaded metadata for {exe_name} to {result}")
else:
logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}")
else:
logger.debug(f"No metadata found for {exe_name}")
return results
def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None: def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None:
game_dir = self._get_game_dir(exe_name) game_dir = self._get_game_dir(exe_name)
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"] cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
@@ -135,164 +163,6 @@ class PortProtonAPI:
if callback: if callback:
callback(results) callback(results)
def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
"""Download only autoinstall cover image (PNG only, no metadata)."""
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
user_game_folder = os.path.join(autoinstall_root, exe_name)
if not os.path.isdir(user_game_folder):
try:
os.mkdir(user_game_folder)
except FileExistsError:
pass
cover_url = f"{self.base_url}/{exe_name}/cover.png"
local_cover_path = os.path.join(user_game_folder, "cover.png")
def on_cover_downloaded(local_path: str | None):
if local_path:
logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
else:
logger.debug(f"No autoinstall cover downloaded for {exe_name}")
if callback:
callback(local_path)
if self._check_file_exists(cover_url, timeout):
self.downloader.download_async(
cover_url,
local_cover_path,
timeout=timeout,
callback=on_cover_downloaded
)
else:
logger.debug(f"No autoinstall cover found for {exe_name}")
if callback:
callback(None)
def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]:
"""Extract display_name from # name comment and exe_name from autoinstall bash script."""
try:
with open(file_path, encoding='utf-8') as f:
content = f.read()
# Skip emulators
if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE):
return None, None
display_name = None
exe_name = None
# Extract display_name from "# name:" comment
name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE)
if name_match:
display_name = name_match.group(1).strip()
# --- pw_create_unique_exe ---
pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content)
if pw_match:
arg = pw_match.group(1)
if arg:
exe_name = arg.strip()
if not exe_name.lower().endswith(".exe"):
exe_name += ".exe"
else:
export_match = re.search(
r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']',
content, re.IGNORECASE)
if export_match:
exe_name = f"{export_match.group(1).strip()}.exe"
else:
portwine_match = None
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
if "portwine_exe" in stripped and "=" in stripped:
portwine_match = stripped
break
if portwine_match:
exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ")
exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr)
if exe_candidates:
exe_name = os.path.basename(exe_candidates[-1].strip())
# Fallback
if not display_name and exe_name:
display_name = exe_name
return display_name, exe_name
except Exception as e:
logger.error(f"Failed to parse {file_path}: {e}")
return None, None
def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
"""Load auto-install games with user/builtin covers (no async download here)."""
games = []
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else ""
if not os.path.exists(auto_dir):
callback(games)
return
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
if not scripts:
callback(games)
return
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
os.makedirs(base_autoinstall_dir, exist_ok=True)
for script_path in scripts:
display_name, exe_name = self.parse_autoinstall_script(script_path)
script_name = os.path.splitext(os.path.basename(script_path))[0]
if not (display_name and exe_name):
continue
exe_name = os.path.splitext(exe_name)[0] # Без .exe
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
os.makedirs(user_game_folder, exist_ok=True)
# Поиск обложки
cover_path = ""
user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set()
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
candidate = f"cover{ext}"
if candidate in user_files:
cover_path = os.path.join(user_game_folder, candidate)
break
if not cover_path:
logger.debug(f"No local cover found for autoinstall {exe_name}")
# Формируем кортеж игры (добавлен exe_name в конец)
game_tuple = (
display_name, # name
"", # description
cover_path, # cover
"", # appid
f"autoinstall:{script_name}", # exec_line
"", # controller_support
"Never", # last_launch
"0h 0m", # formatted_playtime
"", # protondb_tier
"", # anticheat_status
0, # last_played
0, # playtime_seconds
"autoinstall", # game_source
exe_name # exe_name
)
games.append(game_tuple)
callback(games)
def _load_topics_data(self): def _load_topics_data(self):
"""Load and cache linux_gaming_topics_min.json from the archive.""" """Load and cache linux_gaming_topics_min.json from the archive."""
if self._topics_data is not None: if self._topics_data is not None:

View File

@@ -1,49 +0,0 @@
import time
from PySide6.QtCore import QRect
from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient
from PySide6.QtWidgets import QWidget
class Preloader(QWidget):
def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None):
super().__init__(parent)
self.setFixedSize(150, 150)
self._speed = speed
self._line_width = line_line_width
self._color1 = color
self._color2 = QColor(color.red(), color.green(), color.blue(), 0)
self._start_time = time.time()
def showEvent(self, event):
self._start_time = time.time()
def paintEvent(self, event):
rect = self._get_preloader_rect()
center = rect.center()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(self._get_pen())
painter.translate(center)
painter.rotate(self._get_angle())
painter.translate(-center)
painter.drawArc(rect, 0, 270 * 16)
self.update()
def _get_pen(self) -> QPen:
gradient = QConicalGradient()
gradient.setCenter(self.rect().center())
gradient.setColorAt(0, self._color1)
gradient.setColorAt(1, self._color2)
pen = QPen(QBrush(gradient), self._line_width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
return pen
def _get_angle(self) -> float:
duration = time.time() - self._start_time
return (self._speed * duration) % 360.0
def _get_preloader_rect(self) -> QRect:
size = self._line_width // 2
rect = self.rect()
rect.adjust(size, size, -size, -size)
return rect

View File

@@ -22,7 +22,6 @@ import websocket
import requests import requests
import random import random
import base64 import base64
import glob
downloader = Downloader() downloader = Downloader()
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -45,14 +44,14 @@ def safe_vdf_load(path: str | Path) -> dict:
def decode_text(text: str) -> str: def decode_text(text: str) -> str:
""" """
Decodes HTML entities in a string. Декодирует HTML-сущности в строке.
For example, "&amp;quot;" is converted to '"'. Например, "&amp;quot;" преобразуется в '"'.
Other characters and HTML tags remain unchanged. Остальные символы и HTML-теги остаются без изменений.
""" """
return html.unescape(text) return html.unescape(text)
def get_cache_dir(): def get_cache_dir():
"""Returns the path to the cache directory, creating it if necessary.""" """Возвращает путь к каталогу кэша, создаёт его при необходимости."""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") cache_dir = os.path.join(xdg_cache_home, "PortProtonQt")
os.makedirs(cache_dir, exist_ok=True) os.makedirs(cache_dir, exist_ok=True)
@@ -65,7 +64,7 @@ STEAM_DATA_DIRS = (
) )
def get_steam_home(): def get_steam_home():
"""Returns the path to the Steam directory using a list of possible directories.""" """Возвращает путь к директории Steam, используя список возможных директорий."""
for dir_path in STEAM_DATA_DIRS: for dir_path in STEAM_DATA_DIRS:
expanded_path = Path(os.path.expanduser(dir_path)) expanded_path = Path(os.path.expanduser(dir_path))
if expanded_path.exists(): if expanded_path.exists():
@@ -73,7 +72,7 @@ def get_steam_home():
return None return None
def get_last_steam_user(steam_home: Path) -> dict | None: def get_last_steam_user(steam_home: Path) -> dict | None:
"""Returns data for the last Steam user from loginusers.vdf.""" """Возвращает данные последнего пользователя Steam из loginusers.vdf."""
loginusers_path = steam_home / "config/loginusers.vdf" loginusers_path = steam_home / "config/loginusers.vdf"
data = safe_vdf_load(loginusers_path) data = safe_vdf_load(loginusers_path)
if not data: if not data:
@@ -84,20 +83,20 @@ def get_last_steam_user(steam_home: Path) -> dict | None:
try: try:
return {'SteamID': int(user_id)} return {'SteamID': int(user_id)}
except ValueError: except ValueError:
logger.error(f"Invalid SteamID format: {user_id}") logger.error(f"Неверный формат SteamID: {user_id}")
return None return None
logger.info("No user found with MostRecent=1") logger.info("Не найден пользователь с MostRecent=1")
return None return None
def convert_steam_id(steam_id: int) -> int: def convert_steam_id(steam_id: int) -> int:
""" """
Converts a signed 32-bit integer to an unsigned 32-bit integer. Преобразует знаковое 32-битное целое число в беззнаковое 32-битное целое число.
Uses bitwise AND with 0xFFFFFFFF to correctly handle negative values. Использует побитовое И с 0xFFFFFFFF, что корректно обрабатывает отрицательные значения.
""" """
return steam_id & 0xFFFFFFFF return steam_id & 0xFFFFFFFF
def get_steam_libs(steam_dir: Path) -> set[Path]: def get_steam_libs(steam_dir: Path) -> set[Path]:
"""Returns a set of Steam library folders.""" """Возвращает набор директорий Steam libraryfolders."""
libs = set() libs = set()
libs_vdf = steam_dir / "steamapps/libraryfolders.vdf" libs_vdf = steam_dir / "steamapps/libraryfolders.vdf"
data = safe_vdf_load(libs_vdf) data = safe_vdf_load(libs_vdf)
@@ -113,7 +112,7 @@ def get_steam_libs(steam_dir: Path) -> set[Path]:
return libs return libs
def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]: def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, int]]:
"""Returns playtime data for the last user.""" """Возвращает данные о времени игры для последнего пользователя."""
play_data: dict[int, tuple[int, int]] = {} play_data: dict[int, tuple[int, int]] = {}
if steam_home is None: if steam_home is None:
steam_home = get_steam_home() steam_home = get_steam_home()
@@ -133,14 +132,14 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
return play_data return play_data
if not last_user: if not last_user:
logger.info("Could not identify the last Steam user") logger.info("Не удалось определить последнего пользователя Steam")
return play_data return play_data
user_id = last_user['SteamID'] user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id) unsigned_id = convert_steam_id(user_id)
user_dir = userdata_dir / str(unsigned_id) user_dir = userdata_dir / str(unsigned_id)
if not user_dir.exists(): if not user_dir.exists():
logger.info(f"User directory {unsigned_id} not found") logger.info(f"Директория пользователя {unsigned_id} не найдена")
return play_data return play_data
localconfig = user_dir / "config/localconfig.vdf" localconfig = user_dir / "config/localconfig.vdf"
@@ -154,11 +153,11 @@ def get_playtime_data(steam_home: Path | None = None) -> dict[int, tuple[int, in
playtime = int(info.get('Playtime', 0)) playtime = int(info.get('Playtime', 0))
play_data[appid] = (last_played, playtime) play_data[appid] = (last_played, playtime)
except ValueError: except ValueError:
logger.warning(f"Invalid playtime data for app {appid_str}") logger.warning(f"Некорректные данные playtime для app {appid_str}")
return play_data return play_data
def get_steam_installed_games() -> list[tuple[str, int, int, int]]: def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
"""Returns a list of installed Steam games in the format (name, appid, last_played, playtime_sec).""" """Возвращает список установленных Steam игр в формате (name, appid, last_played, playtime_sec)."""
games: list[tuple[str, int, int, int]] = [] games: list[tuple[str, int, int, int]] = []
steam_home = get_steam_home() steam_home = get_steam_home()
if steam_home is None or not steam_home.exists(): if steam_home is None or not steam_home.exists():
@@ -187,13 +186,13 @@ def get_steam_installed_games() -> list[tuple[str, int, int, int]]:
def normalize_name(s): def normalize_name(s):
""" """
Normalizes a string by: Приведение строки к нормальному виду:
- converting to lowercase, - перевод в нижний регистр,
- removing ™ and ® symbols, - удаление символов ™ и ®,
- replacing separators (-, :, ,) with spaces, - замена разделителей (-, :, ,) на пробел,
- removing extra spaces, - удаление лишних пробелов,
- removing 'bin' or 'app' suffixes, - удаление суффиксов 'bin' или 'app' в конце строки,
- removing keywords like 'ultimate', 'edition', etc. - удаление ключевых слов типа 'ultimate', 'edition' и т.п.
""" """
s = s.lower() s = s.lower()
for ch in ["", "®"]: for ch in ["", "®"]:
@@ -211,28 +210,14 @@ def normalize_name(s):
def is_valid_candidate(candidate): def is_valid_candidate(candidate):
""" """
Determines whether a given candidate string is valid for use as a game name. Проверяет, содержит ли кандидат запрещённые подстроки:
- win32
The function performs the following checks: - win64
1. Normalizes the candidate using `normalize_name()`. - gamelauncher
2. Rejects the candidate if the normalized name is exactly "game" Для проверки дополнительно используется строка без пробелов.
(to avoid overly generic names). Возвращает True, если кандидат допустим, иначе False.
3. Removes spaces and checks for forbidden substrings:
- "win32"
- "win64"
- "gamelauncher"
These are checked in the space-free version of the string.
4. Returns True only if none of the forbidden conditions are met.
Args:
candidate (str): The candidate string to validate.
Returns:
bool: True if the candidate is valid, False otherwise.
""" """
normalized_candidate = normalize_name(candidate) normalized_candidate = normalize_name(candidate)
if normalized_candidate == "game":
return False
normalized_no_space = normalized_candidate.replace(" ", "") normalized_no_space = normalized_candidate.replace(" ", "")
forbidden = ["win32", "win64", "gamelauncher"] forbidden = ["win32", "win64", "gamelauncher"]
for token in forbidden: for token in forbidden:
@@ -242,7 +227,7 @@ def is_valid_candidate(candidate):
def filter_candidates(candidates): def filter_candidates(candidates):
""" """
Filters a list of candidates, discarding invalid ones. Фильтрует список кандидатов, отбрасывая недопустимые.
""" """
valid = [] valid = []
dropped = [] dropped = []
@@ -252,18 +237,18 @@ def filter_candidates(candidates):
else: else:
dropped.append(cand) dropped.append(cand)
if dropped: if dropped:
logger.info("Discarding candidates: %s", dropped) logger.info("Отбрасываю кандидатов: %s", dropped)
return valid return valid
def remove_duplicates(candidates): def remove_duplicates(candidates):
""" """
Removes duplicates from a list while preserving order. Удаляет дубликаты из списка, сохраняя порядок.
""" """
return list(dict.fromkeys(candidates)) return list(dict.fromkeys(candidates))
@functools.lru_cache(maxsize=256) @functools.lru_cache(maxsize=256)
def get_exiftool_data(game_exe): def get_exiftool_data(game_exe):
"""Retrieves metadata using exiftool.""" """Получает метаданные через exiftool"""
try: try:
proc = subprocess.run( proc = subprocess.run(
["exiftool", "-j", game_exe], ["exiftool", "-j", game_exe],
@@ -272,28 +257,18 @@ def get_exiftool_data(game_exe):
check=False check=False
) )
if proc.returncode != 0: if proc.returncode != 0:
logger.error(f"exiftool failed for {game_exe}: {proc.stderr.strip()}") logger.error(f"exiftool error for {game_exe}: {proc.stderr.strip()}")
return {} return {}
meta_data_list = orjson.loads(proc.stdout.encode("utf-8")) meta_data_list = orjson.loads(proc.stdout.encode("utf-8"))
return meta_data_list[0] if meta_data_list else {} return meta_data_list[0] if meta_data_list else {}
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in get_exiftool_data for {game_exe}: {e}") logger.error(f"An unexpected error occurred in get_exiftool_data for {game_exe}: {e}")
return {} return {}
def delete_cached_app_files(cache_dir: str, pattern: str):
"""Deletes cached files matching the given pattern in the cache directory."""
try:
for file_path in glob.glob(os.path.join(cache_dir, pattern)):
os.remove(file_path)
logger.info(f"Deleted cached file: {file_path}")
except Exception as e:
logger.error(f"Failed to delete cached files matching {pattern}: {e}")
def load_steam_apps_async(callback: Callable[[list], None]): def load_steam_apps_async(callback: Callable[[list], None]):
""" """
Asynchronously loads the list of Steam applications, using cache if available. Asynchronously loads the list of Steam applications, using cache if available.
Calls the callback with the list of apps. Calls the callback with the list of apps.
Deletes cached app detail files when downloading a new steam_apps.json.
""" """
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "games_appid.tar.xz") cache_tar = os.path.join(cache_dir, "games_appid.tar.xz")
@@ -319,14 +294,12 @@ def load_steam_apps_async(callback: Callable[[list], None]):
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
if os.path.exists(cache_tar): if os.path.exists(cache_tar):
os.remove(cache_tar) os.remove(cache_tar)
logger.info("Deleted archive: %s", cache_tar) logger.info("Archive %s deleted after extraction", cache_tar)
# Delete all cached app detail files (steam_app_*.json)
delete_cached_app_files(cache_dir, "steam_app_*.json")
steam_apps = data if isinstance(data, list) else [] steam_apps = data if isinstance(data, list) else []
logger.info("Loaded %d apps from archive", len(steam_apps)) logger.info("Loaded %d apps from archive", len(steam_apps))
callback(steam_apps) callback(steam_apps)
except Exception as e: except Exception as e:
logger.error("Failed to extract Steam apps archive: %s", e) logger.error("Error extracting Steam apps archive: %s", e)
callback([]) callback([])
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
@@ -336,41 +309,37 @@ def load_steam_apps_async(callback: Callable[[list], None]):
data = orjson.loads(f.read()) data = orjson.loads(f.read())
# Validate JSON structure # Validate JSON structure
if not isinstance(data, list): if not isinstance(data, list):
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json) logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure") raise ValueError("Invalid JSON structure")
# Validate each app entry # Validate each app entry
for app in data: for app in data:
if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app: if not isinstance(app, dict) or "appid" not in app or "normalized_name" not in app:
logger.error("Invalid app entry in cached JSON %s, re-downloading", cache_json) logger.error("Cached JSON %s contains invalid app entry, re-downloading", cache_json)
raise ValueError("Invalid app entry structure") raise ValueError("Invalid app entry structure")
steam_apps = data steam_apps = data
logger.info("Loaded %d apps from cache", len(steam_apps)) logger.info("Loaded %d apps from cache", len(steam_apps))
callback(steam_apps) callback(steam_apps)
except Exception as e: except Exception as e:
logger.error("Failed to read or validate cached JSON %s: %s", cache_json, e) logger.error("Error reading or validating cached JSON %s: %s", cache_json, e)
# Attempt to re-download if cache is invalid or corrupted # Attempt to re-download if cache is invalid or corrupted
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
) )
# Delete cached app detail files before re-downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
else: else:
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/games_appid.tar.xz"
) )
# Delete cached app detail files before downloading
delete_cached_app_files(cache_dir, "steam_app_*.json")
downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar) downloader.download_async(app_list_url, cache_tar, timeout=5, callback=process_tar)
def build_index(steam_apps): def build_index(steam_apps):
""" """
Builds an index of applications by normalized_name field. Строит индекс приложений по полю normalized_name.
""" """
steam_apps_index = {} steam_apps_index = {}
if not steam_apps: if not steam_apps:
return steam_apps_index return steam_apps_index
logger.info("Building Steam apps index") logger.info("Построение индекса Steam приложений:")
for app in steam_apps: for app in steam_apps:
normalized = app["normalized_name"] normalized = app["normalized_name"]
steam_apps_index[normalized] = app steam_apps_index[normalized] = app
@@ -378,24 +347,25 @@ def build_index(steam_apps):
def search_app(candidate, steam_apps_index): def search_app(candidate, steam_apps_index):
""" """
Searches for an application by candidate: tries exact match first, then substring match. Ищет приложение по кандидату: сначала пытается точное совпадение, затем ищет подстроку.
""" """
candidate_norm = normalize_name(candidate) candidate_norm = normalize_name(candidate)
logger.info("Searching for app with candidate: '%s' -> '%s'", candidate, candidate_norm) logger.info("Поиск приложения для кандидата: '%s' -> '%s'", candidate, candidate_norm)
if candidate_norm in steam_apps_index: if candidate_norm in steam_apps_index:
logger.info("Found exact match: '%s'", candidate_norm) logger.info(" Найдено точное совпадение: '%s'", candidate_norm)
return steam_apps_index[candidate_norm] return steam_apps_index[candidate_norm]
for name_norm, app in steam_apps_index.items(): for name_norm, app in steam_apps_index.items():
if candidate_norm in name_norm: if candidate_norm in name_norm:
ratio = len(candidate_norm) / len(name_norm) ratio = len(candidate_norm) / len(name_norm)
if ratio > 0.8: if ratio > 0.8:
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f)", candidate_norm, name_norm, ratio) logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f)",
candidate_norm, name_norm, ratio)
return app return app
logger.info("No app found for candidate '%s'", candidate_norm) logger.info(" Приложение для кандидата '%s' не найдено", candidate_norm)
return None return None
def load_app_details(app_id): def load_app_details(app_id):
"""Loads cached game data by appid if not outdated.""" """Загружает кэшированные данные для игры по appid, если они не устарели."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
if os.path.exists(cache_file): if os.path.exists(cache_file):
@@ -405,7 +375,7 @@ def load_app_details(app_id):
return None return None
def save_app_details(app_id, data): def save_app_details(app_id, data):
"""Saves appid data to a cache file.""" """Сохраняет данные по appid в файл кэша."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json") cache_file = os.path.join(cache_dir, f"steam_app_{app_id}.json")
with open(cache_file, "wb") as f: with open(cache_file, "wb") as f:
@@ -448,7 +418,7 @@ def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
save_app_details(app_id, app_data) save_app_details(app_id, app_data)
callback(app_data) callback(app_data)
except Exception as e: except Exception as e:
logger.error("Failed to process Steam app info for appid %s: %s", app_id, e) logger.error("Error processing Steam app info for appid %s: %s", app_id, e)
callback(None) callback(None)
downloader.download_async(url, cache_file, timeout=5, callback=process_response) downloader.download_async(url, cache_file, timeout=5, callback=process_response)
@@ -457,7 +427,6 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
""" """
Asynchronously loads the list of WeAntiCheatYet data, using cache if available. Asynchronously loads the list of WeAntiCheatYet data, using cache if available.
Calls the callback with the list of anti-cheat data. Calls the callback with the list of anti-cheat data.
Deletes cached anti-cheat files when downloading a new anticheat_games.json.
""" """
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz") cache_tar = os.path.join(cache_dir, "anticheat_games.tar.xz")
@@ -483,12 +452,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
if os.path.exists(cache_tar): if os.path.exists(cache_tar):
os.remove(cache_tar) os.remove(cache_tar)
logger.info("Deleted archive: %s", cache_tar) logger.info("Archive %s deleted after extraction", cache_tar)
anti_cheat_data = data or [] anti_cheat_data = data or []
logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data)) logger.info("Loaded %d anti-cheat entries from archive", len(anti_cheat_data))
callback(anti_cheat_data) callback(anti_cheat_data)
except Exception as e: except Exception as e:
logger.error("Failed to extract WeAntiCheatYet archive: %s", e) logger.error("Error extracting WeAntiCheatYet archive: %s", e)
callback([]) callback([])
if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION):
@@ -498,18 +467,18 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
data = orjson.loads(f.read()) data = orjson.loads(f.read())
# Validate JSON structure # Validate JSON structure
if not isinstance(data, list): if not isinstance(data, list):
logger.error("Invalid JSON format in %s (not a list), re-downloading", cache_json) logger.error("Cached JSON %s has invalid format (not a list), re-downloading", cache_json)
raise ValueError("Invalid JSON structure") raise ValueError("Invalid JSON structure")
# Validate each anti-cheat entry # Validate each anti-cheat entry
for entry in data: for entry in data:
if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry: if not isinstance(entry, dict) or "normalized_name" not in entry or "status" not in entry:
logger.error("Invalid anti-cheat entry in cached JSON %s, re-downloading", cache_json) logger.error("Cached JSON %s contains invalid anti-cheat entry, re-downloading", cache_json)
raise ValueError("Invalid anti-cheat entry structure") raise ValueError("Invalid anti-cheat entry structure")
anti_cheat_data = data anti_cheat_data = data
logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data)) logger.info("Loaded %d anti-cheat entries from cache", len(anti_cheat_data))
callback(anti_cheat_data) callback(anti_cheat_data)
except Exception as e: except Exception as e:
logger.error("Failed to read or validate cached WeAntiCheatYet JSON %s: %s", cache_json, e) logger.error("Error reading or validating cached WeAntiCheatYet JSON %s: %s", cache_json, e)
# Attempt to re-download if cache is invalid or corrupted # Attempt to re-download if cache is invalid or corrupted
app_list_url = ( app_list_url = (
"https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz" "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/anticheat_games.tar.xz"
@@ -523,12 +492,12 @@ def load_weanticheatyet_data_async(callback: Callable[[list], None]):
def build_weanticheatyet_index(anti_cheat_data): def build_weanticheatyet_index(anti_cheat_data):
""" """
Builds an index of anti-cheat data by normalized_name field. Строит индекс античит-данных по полю normalized_name.
""" """
anti_cheat_index = {} anti_cheat_index = {}
if not anti_cheat_data: if not anti_cheat_data:
return anti_cheat_index return anti_cheat_index
logger.info("Building WeAntiCheatYet data index") logger.info("Построение индекса WeAntiCheatYet данных:")
for entry in anti_cheat_data: for entry in anti_cheat_data:
normalized = entry["normalized_name"] normalized = entry["normalized_name"]
anti_cheat_index[normalized] = entry anti_cheat_index[normalized] = entry
@@ -536,19 +505,20 @@ def build_weanticheatyet_index(anti_cheat_data):
def search_anticheat_status(candidate, anti_cheat_index): def search_anticheat_status(candidate, anti_cheat_index):
candidate_norm = normalize_name(candidate) candidate_norm = normalize_name(candidate)
logger.info("Searching for anti-cheat status for candidate: '%s' -> '%s'", candidate, candidate_norm) logger.info("Поиск античит-статуса для кандидата: '%s' -> '%s'", candidate, candidate_norm)
if candidate_norm in anti_cheat_index: if candidate_norm in anti_cheat_index:
status = anti_cheat_index[candidate_norm]["status"] status = anti_cheat_index[candidate_norm]["status"]
logger.info("Found exact match: '%s', status: '%s'", candidate_norm, status) logger.info(" Найдено точное совпадение: '%s', статус: '%s'", candidate_norm, status)
return status return status
for name_norm, entry in anti_cheat_index.items(): for name_norm, entry in anti_cheat_index.items():
if candidate_norm in name_norm: if candidate_norm in name_norm:
ratio = len(candidate_norm) / len(name_norm) ratio = len(candidate_norm) / len(name_norm)
if ratio > 0.8: if ratio > 0.8:
status = entry["status"] status = entry["status"]
logger.info("Found partial match: candidate '%s' in '%s' (ratio: %.2f), status: '%s'", candidate_norm, name_norm, ratio, status) logger.info(" Найдено частичное совпадение: кандидат '%s' в '%s' (ratio: %.2f), статус: '%s'",
candidate_norm, name_norm, ratio, status)
return status return status
logger.info("No anti-cheat status found for candidate '%s'", candidate_norm) logger.info(" Античит-статус для кандидата '%s' не найден", candidate_norm)
return "" return ""
def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]): def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], None]):
@@ -564,7 +534,7 @@ def get_weanticheatyet_status_async(game_name: str, callback: Callable[[str], No
load_weanticheatyet_data_async(on_anticheat_data) load_weanticheatyet_data_async(on_anticheat_data)
def load_protondb_status(appid): def load_protondb_status(appid):
"""Loads cached ProtonDB data for a game by appid if not outdated.""" """Загружает закешированные данные ProtonDB для игры по appid, если они не устарели."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
if os.path.exists(cache_file): if os.path.exists(cache_file):
@@ -573,18 +543,18 @@ def load_protondb_status(appid):
with open(cache_file, "rb") as f: with open(cache_file, "rb") as f:
return orjson.loads(f.read()) return orjson.loads(f.read())
except Exception as e: except Exception as e:
logger.error("Failed to load ProtonDB cache for appid %s: %s", appid, e) logger.error("Ошибка загрузки кеша ProtonDB для appid %s: %s", appid, e)
return None return None
def save_protondb_status(appid, data): def save_protondb_status(appid, data):
"""Saves ProtonDB data for a game by appid to a cache file.""" """Сохраняет данные ProtonDB для игры по appid в файл кэша."""
cache_dir = get_cache_dir() cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, f"protondb_{appid}.json") cache_file = os.path.join(cache_dir, f"protondb_{appid}.json")
try: try:
with open(cache_file, "wb") as f: with open(cache_file, "wb") as f:
f.write(orjson.dumps(data)) f.write(orjson.dumps(data))
except Exception as e: except Exception as e:
logger.error("Failed to save ProtonDB cache for appid %s: %s", appid, e) logger.error("Ошибка сохранения кеша ProtonDB для appid %s: %s", appid, e)
def get_protondb_tier_async(appid: int, callback: Callable[[str], None]): def get_protondb_tier_async(appid: int, callback: Callable[[str], None]):
""" """
@@ -672,7 +642,7 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
if game_exe.lower().endswith('.exe'): if game_exe.lower().endswith('.exe'):
break break
except Exception as e: except Exception as e:
logger.error("Failed to process bat file %s: %s", game_exe, e) logger.error("Error processing bat file %s: %s", game_exe, e)
else: else:
logger.error("Bat file not found: %s", game_exe) logger.error("Bat file not found: %s", game_exe)
@@ -807,55 +777,55 @@ def get_steam_apps_and_index_async(callback: Callable[[tuple[list, dict]], None]
def enable_steam_cef() -> tuple[bool, str]: def enable_steam_cef() -> tuple[bool, str]:
""" """
Checks and enables Steam CEF remote debugging if necessary. Проверяет и при необходимости активирует режим удаленной отладки Steam CEF.
Creates a .cef-enable-remote-debugging file in the Steam directory. Создает файл .cef-enable-remote-debugging в директории Steam.
Steam must be restarted after the file is first created. Steam необходимо перезапустить после первого создания этого файла.
Returns a tuple: Возвращает кортеж:
- (True, "already_enabled") if already enabled. - (True, "already_enabled") если уже было активно.
- (True, "restart_needed") if just enabled and Steam restart is needed. - (True, "restart_needed") если было только что активировано и нужен перезапуск Steam.
- (False, "steam_not_found") if Steam directory is not found. - (False, "steam_not_found") если директория Steam не найдена.
""" """
steam_home = get_steam_home() steam_home = get_steam_home()
if not steam_home: if not steam_home:
return (False, "steam_not_found") return (False, "steam_not_found")
cef_flag_file = steam_home / ".cef-enable-remote-debugging" cef_flag_file = steam_home / ".cef-enable-remote-debugging"
logger.info(f"Checking CEF flag: {cef_flag_file}") logger.info(f"Проверка CEF флага: {cef_flag_file}")
if cef_flag_file.exists(): if cef_flag_file.exists():
logger.info("CEF Remote Debugging is already enabled") logger.info("CEF Remote Debugging уже активирован.")
return (True, "already_enabled") return (True, "already_enabled")
else: else:
try: try:
os.makedirs(cef_flag_file.parent, exist_ok=True) os.makedirs(cef_flag_file.parent, exist_ok=True)
cef_flag_file.touch() cef_flag_file.touch()
logger.info("Enabled CEF Remote Debugging. Steam restart required") logger.info("CEF Remote Debugging активирован. Steam необходимо перезапустить.")
return (True, "restart_needed") return (True, "restart_needed")
except Exception as e: except Exception as e:
logger.error(f"Failed to create CEF flag {cef_flag_file}: {e}") logger.error(f"Не удалось создать CEF флаг {cef_flag_file}: {e}")
return (False, str(e)) return (False, str(e))
def call_steam_api(js_cmd: str, *args) -> dict | None: def call_steam_api(js_cmd: str, *args) -> dict | None:
""" """
Executes a JavaScript function in the Steam context via CEF Remote Debugging. Выполняет JavaScript функцию в контексте Steam через CEF Remote Debugging.
Args: Args:
js_cmd: Name of the JS function to call (e.g., 'createShortcut'). js_cmd: Имя JS функции для вызова (напр. 'createShortcut').
*args: Arguments to pass to the JS function. *args: Аргументы для передачи в JS функцию.
Returns: Returns:
Dictionary with the result or None if an error occurs. Словарь с результатом выполнения или None в случае ошибки.
""" """
status, message = enable_steam_cef() status, message = enable_steam_cef()
if not (status is True and message == "already_enabled"): if not (status is True and message == "already_enabled"):
if message == "restart_needed": if message == "restart_needed":
logger.warning("Steam CEF API is available but requires Steam restart for full activation") logger.warning("Steam CEF API доступен, но требует перезапуска Steam для полной активации.")
elif message == "steam_not_found": elif message == "steam_not_found":
logger.error("Could not find Steam directory to check CEF API") logger.error("Не удалось найти директорию Steam для проверки CEF API.")
else: else:
logger.error(f"Steam CEF API is unavailable or not ready: {message}") logger.error(f"Steam CEF API недоступен или не готов: {message}")
return None return None
steam_debug_url = "http://localhost:8080/json" steam_debug_url = "http://localhost:8080/json"
@@ -866,10 +836,10 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
contexts = response.json() contexts = response.json()
ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None) ws_url = next((ctx['webSocketDebuggerUrl'] for ctx in contexts if ctx['title'] == 'SharedJSContext'), None)
if not ws_url: if not ws_url:
logger.warning("SharedJSContext not found. Is Steam running with -cef-enable-remote-debugging?") logger.warning("Не удалось найти SharedJSContext. Steam запущен с флагом -cef-enable-remote-debugging?")
return None return None
except Exception as e: except Exception as e:
logger.warning(f"Failed to connect to Steam CEF API at {steam_debug_url}. Is Steam running? {e}") logger.warning(f"Не удалось подключиться к Steam CEF API по адресу {steam_debug_url}. Steam запущен? {e}")
return None return None
js_code = """ js_code = """
@@ -914,15 +884,15 @@ def call_steam_api(js_cmd: str, *args) -> dict | None:
response_data = orjson.loads(response_str) response_data = orjson.loads(response_str)
if "error" in response_data: if "error" in response_data:
logger.error(f"JavaScript execution error in Steam: {response_data['error']['message']}") logger.error(f"Ошибка выполнения JS в Steam: {response_data['error']['message']}")
return None return None
result = response_data.get('result', {}).get('result', {}) result = response_data.get('result', {}).get('result', {})
if result.get('type') == 'object' and result.get('subtype') == 'error': if result.get('type') == 'object' and result.get('subtype') == 'error':
logger.error(f"JavaScript execution error in Steam: {result.get('description')}") logger.error(f"Ошибка выполнения JS в Steam: {result.get('description')}")
return None return None
return result.get('value') return result.get('value')
except Exception as e: except Exception as e:
logger.error(f"WebSocket interaction error with Steam: {e}") logger.error(f"Ошибка при взаимодействии с WebSocket Steam: {e}")
return None return None
def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]: def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool, str]:
@@ -999,24 +969,24 @@ export START_FROM_STEAM=1
else: else:
success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True) success = generate_thumbnail(exe_path, generated_icon_path, size=128, force_resize=True)
if not success or not os.path.exists(generated_icon_path): if not success or not os.path.exists(generated_icon_path):
logger.warning(f"Failed to generate thumbnail for {exe_path}") logger.warning(f"generate_thumbnail failed to create icon for {exe_path}")
icon_path = "" icon_path = ""
else: else:
logger.info(f"Generated thumbnail: {generated_icon_path}") logger.info(f"Generated thumbnail: {generated_icon_path}")
icon_path = generated_icon_path icon_path = generated_icon_path
except Exception as e: except Exception as e:
logger.error(f"Failed to generate thumbnail for {exe_path}: {e}") logger.error(f"Error generating thumbnail for {exe_path}: {e}")
icon_path = "" icon_path = ""
steam_home = get_steam_home() steam_home = get_steam_home()
if not steam_home: if not steam_home:
logger.error("Steam home directory not found") logger.error("Steam home directory not found")
return (False, "Steam directory not found") return (False, "Steam directory not found.")
last_user = get_last_steam_user(steam_home) last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user: if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID") logger.error("Failed to retrieve Steam user ID")
return (False, "Failed to get Steam user ID") return (False, "Failed to get Steam user ID.")
userdata_dir = steam_home / "userdata" userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID'] user_id = last_user['SteamID']
@@ -1029,7 +999,7 @@ export START_FROM_STEAM=1
appid = None appid = None
was_api_used = False was_api_used = False
logger.info("Attempting to add shortcut via Steam CEF API") logger.info("Попытка добавления ярлыка через Steam CEF API...")
api_response = call_steam_api( api_response = call_steam_api(
"createShortcut", "createShortcut",
game_name, game_name,
@@ -1042,9 +1012,9 @@ export START_FROM_STEAM=1
if api_response and isinstance(api_response, dict) and 'id' in api_response: if api_response and isinstance(api_response, dict) and 'id' in api_response:
appid = api_response['id'] appid = api_response['id']
was_api_used = True was_api_used = True
logger.info(f"Shortcut successfully added via API. AppID: {appid}") logger.info(f"Ярлык успешно добавлен через API. AppID: {appid}")
else: else:
logger.warning("Failed to add shortcut via API. Falling back to shortcuts.vdf") logger.warning("Не удалось добавить ярлык через API. Используется запасной метод (запись в shortcuts.vdf).")
backup_path = f"{steam_shortcuts_path}.backup" backup_path = f"{steam_shortcuts_path}.backup"
if os.path.exists(steam_shortcuts_path): if os.path.exists(steam_shortcuts_path):
try: try:
@@ -1118,7 +1088,7 @@ export START_FROM_STEAM=1
appid = None appid = None
if not appid: if not appid:
return (False, "Failed to create shortcut using any method") return (False, "Не удалось создать ярлык ни одним из способов.")
steam_appid = None steam_appid = None
@@ -1128,7 +1098,7 @@ export START_FROM_STEAM=1
if not steam_appid or not isinstance(steam_appid, int): if not steam_appid or not isinstance(steam_appid, int):
logger.info("No valid Steam appid found, skipping cover download") logger.info("No valid Steam appid found, skipping cover download")
return return
logger.info(f"Found Steam AppID {steam_appid} for cover download") logger.info(f"Найден Steam AppID {steam_appid} для загрузки обложек.")
cover_types = [ cover_types = [
("p.jpg", "library_600x900_2x.jpg"), ("p.jpg", "library_600x900_2x.jpg"),
@@ -1145,15 +1115,15 @@ export START_FROM_STEAM=1
try: try:
with open(result_path, 'rb') as f: with open(result_path, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8') img_b64 = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"Applying cover type '{steam_name}' via API for AppID {appid}") logger.info(f"Применение обложки типа '{steam_name}' через API для AppID {appid}")
ext = Path(steam_name).suffix.lstrip('.') ext = Path(steam_name).suffix.lstrip('.')
call_steam_api("setGrid", appid, index, ext, img_b64) call_steam_api("setGrid", appid, index, ext, img_b64)
except Exception as e: except Exception as e:
logger.error(f"Failed to apply cover '{steam_name}' via API: {e}") logger.error(f"Ошибка при применении обложки '{steam_name}' через API: {e}")
else: else:
logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}") logger.warning(f"Failed to download cover {steam_name} for appid {steam_appid}")
except Exception as e: except Exception as e:
logger.error(f"Failed to process cover {steam_name} for appid {steam_appid}: {e}") logger.error(f"Error processing cover {steam_name} for appid {steam_appid}: {e}")
for i, (suffix, steam_name) in enumerate(cover_types): for i, (suffix, steam_name) in enumerate(cover_types):
cover_file = os.path.join(grid_dir, f"{appid}{suffix}") cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
@@ -1194,13 +1164,13 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
steam_home = get_steam_home() steam_home = get_steam_home()
if not steam_home: if not steam_home:
logger.error("Steam home directory not found") logger.error("Steam home directory not found")
return (False, "Steam directory not found") return (False, "Steam directory not found.")
# Get current Steam user ID # Get current Steam user ID
last_user = get_last_steam_user(steam_home) last_user = get_last_steam_user(steam_home)
if not last_user or 'SteamID' not in last_user: if not last_user or 'SteamID' not in last_user:
logger.error("Failed to retrieve Steam user ID") logger.error("Failed to retrieve Steam user ID")
return (False, "Failed to get Steam user ID") return (False, "Failed to get Steam user ID.")
userdata_dir = steam_home / "userdata" userdata_dir = steam_home / "userdata"
user_id = last_user['SteamID'] user_id = last_user['SteamID']
unsigned_id = convert_steam_id(user_id) unsigned_id = convert_steam_id(user_id)
@@ -1246,10 +1216,10 @@ def remove_from_steam(game_name: str, exec_line: str) -> tuple[bool, str]:
return (False, f"Game '{game_name}' not found in Steam") return (False, f"Game '{game_name}' not found in Steam")
api_response = call_steam_api("removeShortcut", appid) api_response = call_steam_api("removeShortcut", appid)
if api_response is not None: # API responded, even if response is empty if api_response is not None: # API ответил, даже если ответ пустой
logger.info(f"Shortcut for AppID {appid} successfully removed via API") logger.info(f"Ярлык для AppID {appid} успешно удален через API.")
else: else:
logger.warning("Failed to remove shortcut via API. Falling back to editing shortcuts.vdf") logger.warning("Не удалось удалить ярлык через API. Используется запасной метод (редактирование shortcuts.vdf).")
# Create backup of shortcuts.vdf # Create backup of shortcuts.vdf
backup_path = f"{steam_shortcuts_path}.backup" backup_path = f"{steam_shortcuts_path}.backup"
@@ -1328,5 +1298,5 @@ def is_game_in_steam(game_name: str) -> bool:
if entry.get("AppName") == game_name: if entry.get("AppName") == game_name:
return True return True
except Exception as e: except Exception as e:
logger.error(f"Failed to check if game {game_name} is in Steam: {e}") logger.error(f"Error checking if game {game_name} is in Steam: {e}")
return False return False

View File

@@ -1,8 +1,9 @@
import importlib.util import importlib.util
import os import os
import ast
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap from PySide6.QtSvg import QSvgRenderer
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -13,59 +14,6 @@ THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQt", "themes"), os.path.join(xdg_data_home, "PortProtonQt", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes") os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
] ]
_loaded_theme = None
# Запрещенные модули и функции
FORBIDDEN_MODULES = {
"os",
"subprocess",
"shutil",
"sys",
"socket",
"ctypes",
"pathlib",
"glob",
}
FORBIDDEN_FUNCTIONS = {
"exec",
"eval",
"open",
"__import__",
}
def check_theme_safety(theme_file: str) -> bool:
"""
Проверяет файл темы на наличие запрещённых модулей и функций.
Возвращает True, если файл безопасен, иначе False.
"""
has_errors = False
try:
with open(theme_file) as f:
content = f.read()
# Проверка на опасные импорты и функции
try:
tree = ast.parse(content)
for node in ast.walk(tree):
# Проверка импортов
if isinstance(node, ast.Import | ast.ImportFrom):
for name in node.names:
if name.name in FORBIDDEN_MODULES:
logger.error(f"Forbidden module '{name.name}' found in file {theme_file}")
has_errors = True
# Проверка вызовов функций
if isinstance(node, ast.Call):
if isinstance(node.func, ast.Name) and node.func.id in FORBIDDEN_FUNCTIONS:
logger.error(f"Forbidden function '{node.func.id}' found in file {theme_file}")
has_errors = True
except SyntaxError as e:
logger.error(f"Syntax error in file {theme_file}: {e}")
has_errors = True
except Exception as e:
logger.error(f"Failed to check theme safety for {theme_file}: {e}")
has_errors = True
return not has_errors
def list_themes(): def list_themes():
""" """
@@ -101,13 +49,9 @@ def load_theme_screenshots(theme_name):
def load_theme_fonts(theme_name): def load_theme_fonts(theme_name):
""" """
Загружает все шрифты выбранной темы, если они ещё не были загружены. Загружает все шрифты выбранной темы.
:param theme_name: Имя темы.
""" """
global _loaded_theme
if _loaded_theme == theme_name:
logger.debug(f"Fonts for theme '{theme_name}' already loaded, skipping")
return
QFontDatabase.removeAllApplicationFonts() QFontDatabase.removeAllApplicationFonts()
fonts_folder = None fonts_folder = None
if theme_name == "standart": if theme_name == "standart":
@@ -122,7 +66,7 @@ def load_theme_fonts(theme_name):
break break
if not fonts_folder or not os.path.exists(fonts_folder): if not fonts_folder or not os.path.exists(fonts_folder):
logger.error(f"Fonts folder not found for theme '{theme_name}'") logger.error(f"Папка fonts не найдена для темы '{theme_name}'")
return return
for filename in os.listdir(fonts_folder): for filename in os.listdir(fonts_folder):
@@ -131,11 +75,29 @@ def load_theme_fonts(theme_name):
font_id = QFontDatabase.addApplicationFont(font_path) font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1: if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id) families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Font {filename} successfully loaded: {families}") logger.info(f"Шрифт {filename} успешно загружен: {families}")
else: else:
logger.error(f"Error loading font: {filename}") logger.error(f"Ошибка загрузки шрифта: {filename}")
_loaded_theme = theme_name def load_logo():
logo_path = None
base_dir = os.path.dirname(os.path.abspath(__file__))
logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg")
file_extension = os.path.splitext(logo_path)[1].lower()
if file_extension == ".svg":
renderer = QSvgRenderer(logo_path)
if not renderer.isValid():
logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}")
return None
pixmap = QPixmap(128, 128)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return pixmap
class ThemeWrapper: class ThemeWrapper:
""" """
@@ -147,83 +109,69 @@ class ThemeWrapper:
self.custom_theme = custom_theme self.custom_theme = custom_theme
self.metainfo = metainfo or {} self.metainfo = metainfo or {}
self.screenshots = load_theme_screenshots(self.metainfo.get("name", "")) self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
self._default_theme = None # Lazy-loaded default theme
def __getattr__(self, name): def __getattr__(self, name):
if hasattr(self.custom_theme, name): if hasattr(self.custom_theme, name):
return getattr(self.custom_theme, name) return getattr(self.custom_theme, name)
if self._default_theme is None: import portprotonqt.themes.standart.styles as default_styles
self._default_theme = load_theme("standart") # Dynamically load standard theme return getattr(default_styles, name)
return getattr(self._default_theme, name)
def load_theme(theme_name): def load_theme(theme_name):
""" """
Динамически загружает модуль стилей выбранной темы и метаинформацию. Динамически загружает модуль стилей выбранной темы и метаинформацию.
Все темы, включая стандартную, проходят проверку безопасности. Если выбрана стандартная тема, импортируется оригинальный styles.py.
Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты. Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
""" """
if theme_name == "standart":
import portprotonqt.themes.standart.styles as default_styles
return default_styles
for themes_dir in THEMES_DIRS: for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name) theme_folder = os.path.join(themes_dir, theme_name)
styles_file = os.path.join(theme_folder, "styles.py") styles_file = os.path.join(theme_folder, "styles.py")
if os.path.exists(styles_file): if os.path.exists(styles_file):
# Проверяем безопасность темы перед загрузкой
if not check_theme_safety(styles_file):
logger.error(f"Theme '{theme_name}' is unsafe, falling back to 'standart'")
raise FileNotFoundError(f"Theme '{theme_name}' contains forbidden modules or functions")
spec = importlib.util.spec_from_file_location("theme_styles", styles_file) spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
if spec is None or spec.loader is None: if spec is None or spec.loader is None:
continue continue
custom_theme = importlib.util.module_from_spec(spec) custom_theme = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_theme) spec.loader.exec_module(custom_theme)
if theme_name == "standart":
return custom_theme
meta = load_theme_metainfo(theme_name) meta = load_theme_metainfo(theme_name)
wrapper = ThemeWrapper(custom_theme, metainfo=meta) wrapper = ThemeWrapper(custom_theme, metainfo=meta)
wrapper.screenshots = load_theme_screenshots(theme_name) wrapper.screenshots = load_theme_screenshots(theme_name)
return wrapper return wrapper
raise FileNotFoundError(f"Styles file not found for theme '{theme_name}'") raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'")
class ThemeManager: class ThemeManager:
""" """
Класс для управления темами приложения. Класс для управления темами приложения.
Реализует паттерн Singleton для единого экземпляра.
Позволяет получить список доступных тем, загрузить и применить выбранную тему.
""" """
_instance = None def __init__(self):
self.current_theme_name = None
self.current_theme_module = None
def __new__(cls): def get_available_themes(self):
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance.current_theme_name = None
cls._instance.current_theme_module = None
return cls._instance
def get_available_themes(self) -> list:
"""Возвращает список доступных тем.""" """Возвращает список доступных тем."""
return list_themes() return list_themes()
def apply_theme(self, theme_name: str): def get_theme_logo(self):
""" """Возвращает логотип для текущей или указанной темы."""
Применяет указанную тему, если она ещё не применена. return load_logo()
Возвращает модуль темы или обёртку.
"""
if self.current_theme_name == theme_name and self.current_theme_module is not None:
logger.debug(f"Theme '{theme_name}' is already applied, skipping")
return self.current_theme_module
try:
theme_module = load_theme(theme_name)
except FileNotFoundError:
logger.warning(f"Theme '{theme_name}' not found or unsafe, applying standard theme 'standart'")
theme_module = load_theme("standart")
theme_name = "standart"
save_theme_to_config("standart")
def apply_theme(self, theme_name):
"""
Применяет выбранную тему: загружает модуль стилей, шрифты и логотип.
Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации.
:param theme_name: Имя темы.
:return: Загруженный модуль темы (или обёртка).
"""
theme_module = load_theme(theme_name)
load_theme_fonts(theme_name) load_theme_fonts(theme_name)
self.current_theme_name = theme_name self.current_theme_name = theme_name
self.current_theme_module = theme_module self.current_theme_module = theme_module
save_theme_to_config(theme_name) save_theme_to_config(theme_name)
logger.info(f"Theme '{theme_name}' successfully applied") logger.info(f"Тема '{theme_name}' успешно применена")
return theme_module return theme_module
def get_icon(self, icon_name, theme_name=None, as_path=False): def get_icon(self, icon_name, theme_name=None, as_path=False):
@@ -278,7 +226,7 @@ class ThemeManager:
# Если иконка всё равно не найдена # Если иконка всё равно не найдена
if not icon_path or not os.path.exists(icon_path): if not icon_path or not os.path.exists(icon_path):
logger.error(f"Warning: icon '{icon_name}' not found") logger.error(f"Предупреждение: иконка '{icon_name}' не найдена")
return QIcon() if not as_path else None return QIcon() if not as_path else None
if as_path: if as_path:

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg>

Before

Width:  |  Height:  |  Size: 751 B

View File

@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="48"
height="48"
version="1.1"
viewBox="0 0 48 48"
xml:space="preserve"
id="svg2"
sodipodi:docname="key_context.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs2" /><sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="8.6915209"
inkscape:cx="72.311855"
inkscape:cy="22.780823"
inkscape:window-width="2560"
inkscape:window-height="1406"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000"
d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z"
id="path10"
sodipodi:nodetypes="csccscc" /><path
fill="#000000"
d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z"
id="path2"
style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g
id="g15"
transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z"
id="path14" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z"
id="path15" /></g></svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg>

Before

Width:  |  Height:  |  Size: 726 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg>

Before

Width:  |  Height:  |  Size: 823 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg>

Before

Width:  |  Height:  |  Size: 857 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

Before

Width:  |  Height:  |  Size: 865 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

Before

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>

Before

Width:  |  Height:  |  Size: 736 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></svg>

Before

Width:  |  Height:  |  Size: 961 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></svg>

Before

Width:  |  Height:  |  Size: 1015 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg>

Before

Width:  |  Height:  |  Size: 682 B

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></svg>

Before

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.8 KiB

View File

@@ -1 +0,0 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></svg>

Before

Width:  |  Height:  |  Size: 600 B

Some files were not shown because too many files have changed in this diff Show More