Move repo from git to gitea
All checks were successful
Check Translations / check-translations (push) Successful in 15s
Code and build check / Check code (push) Successful in 1m21s
Code and build check / Build with uv (push) Successful in 47s

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
Boris Yumankulov 2025-06-01 15:21:32 +05:00
parent aae1ce9c10
commit abec9bbef8
Signed by: Boria138
GPG Key ID: 14B4A5673FD39C76
110 changed files with 545106 additions and 2 deletions

View File

@ -0,0 +1,129 @@
name: Nightly Build - AppImage, Arch, Fedora
on:
workflow_dispatch:
env:
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
jobs:
build-appimage:
name: Build AppImage
runs-on: ubuntu-22.04
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Install required dependencies
run: sudo apt install -y binutils coreutils desktop-file-utils fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync
- name: Install tools
run: pip3 install appimage-builder uv
- name: Build AppImage
run: |
cd build-aux
sed -i '/app_info:/,/- exec:/ s/^\(\s*version:\s*\).*/\1"0"/' AppImageBuilder.yml
appimage-builder
- name: Upload AppImage
uses: https://gitea.com/actions/gitea-upload-artifact@v3
with:
name: PortProtonQt-AppImage
path: build-aux/PortProtonQt*.AppImage
build-fedora:
name: Build Fedora RPM
runs-on: ubuntu-latest
strategy:
matrix:
fedora_version: [40, 41, 42, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
options: --privileged
steps:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
python3-build pyproject-rpm-macros python3-setuptools \
redhat-rpm-config nodejs npm
- name: Setup rpmbuild environment
run: |
useradd rpmbuild -u 5002 -g users || true
mkdir -p /home/rpmbuild/{BUILD,RPMS,SPECS,SRPMS,SOURCES}
chown -R rpmbuild:users /home/rpmbuild
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@v4
- name: Copy fedora.spec
run: |
cp build-aux/fedora-git.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
chown -R rpmbuild:users /home/rpmbuild
- name: Build RPM
run: |
su rpmbuild -c "rpmbuild -ba /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec"
- name: Upload RPM package
uses: https://gitea.com/actions/gitea-upload-artifact@v3
with:
name: PortProtonQt-RPM-Fedora-${{ matrix.fedora_version }}
path: /home/rpmbuild/RPMS/**/*.rpm
build-arch:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel
volumes:
- /usr:/usr-host
- /opt:/opt-host
options: --privileged
steps:
- name: Prepare container
run: |
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
yes | pacman -Scc
pacman-key --init
pacman -S --noconfirm archlinux-keyring
mkdir -p /__w/portproton-repo
pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com
pacman-key --lsign-key 3056513887B78AEB
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst'
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst'
cat << EOM >> /etc/pacman.conf
[chaotic-aur]
Include = /etc/pacman.d/chaotic-mirrorlist
EOM
pacman -Syy
useradd -m user -G wheel && echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
echo "PACKAGER=\"Boris Yumankulov <boria138@altlinux.org>\"" >> /etc/makepkg.conf
chown user -R /tmp
chown user -R ..
- name: Build
run: |
cd /__w/portproton-repo
git clone https://github.com/Boria138/PortProtonQt.git
cd /__w/portproton-repo/PortProtonQt/build-aux
chown user -R ..
su user -c "yes '' | makepkg --noconfirm -s -p PKGBUILD-git"
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v3
with:
name: PortProtonQt-Arch
path: ${{ env.PKGDEST }}/*

132
.gitea/workflows/build.yml Normal file
View File

@ -0,0 +1,132 @@
name: Build AppImage, Arch and Fedora Packages
on:
workflow_dispatch:
env:
# Common version, will be used for tagging the release
VERSION: 0.1.1
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
jobs:
build-appimage:
name: Build AppImage
runs-on: ubuntu-22.04
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Install required dependencies
run: |
sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync
- name: Install tools
run: pip3 install appimage-builder uv
- name: Build AppImage
run: |
cd build-aux
appimage-builder
- name: Upload AppImage
uses: https://gitea.com/actions/gitea-upload-artifact@v3
with:
name: PortProtonQt-AppImage
path: build-aux/PortProtonQt*.AppImage*
build-arch:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel
volumes:
- /usr:/usr-host
- /opt:/opt-host
options: --privileged
steps:
- name: Prepare container
run: |
pacman -Sy --noconfirm --disable-download-timeout --needed git wget gnupg nodejs npm
sed -i 's/#MAKEFLAGS="-j2"/MAKEFLAGS="-j$(nproc) -l$(nproc)"/g' /etc/makepkg.conf
sed -i 's/OPTIONS=(.*)/OPTIONS=(strip docs !libtool !staticlibs emptydirs zipman purge lto)/g' /etc/makepkg.conf
yes | pacman -Scc
pacman-key --init
pacman -S --noconfirm archlinux-keyring
mkdir -p /__w/portproton-repo
pacman-key --recv-key 3056513887B78AEB --keyserver keyserver.ubuntu.com
pacman-key --lsign-key 3056513887B78AEB
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-keyring.pkg.tar.zst'
pacman -U --noconfirm 'https://cdn-mirror.chaotic.cx/chaotic-aur/chaotic-mirrorlist.pkg.tar.zst'
cat << EOM >> /etc/pacman.conf
[chaotic-aur]
Include = /etc/pacman.d/chaotic-mirrorlist
EOM
pacman -Syy
useradd -m user -G wheel && echo "user ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers
echo "PACKAGER=\"Boris Yumankulov <boria138@altlinux.org>\"" >> /etc/makepkg.conf
chown user -R /tmp
chown user -R ..
- name: Build
run: |
cd /__w/portproton-repo
git clone https://github.com/Boria138/PortProtonQt.git
cd /__w/portproton-repo/PortProtonQt/build-aux
chown user -R ..
su user -c "yes '' | makepkg --noconfirm -s"
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Upload Arch package
uses: https://gitea.com/actions/gitea-upload-artifact@v3
with:
name: PortProtonQt-Arch
path: ${{ env.PKGDEST }}/*
build-fedora:
name: Build Fedora RPM
runs-on: ubuntu-latest
strategy:
matrix:
fedora_version: [40, 41, 42, rawhide]
container:
image: fedora:${{ matrix.fedora_version }}
options: --privileged
steps:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
python3-build pyproject-rpm-macros python3-setuptools \
redhat-rpm-config nodejs npm
- name: Setup rpmbuild environment
run: |
useradd rpmbuild -u 5002 -g users || true
mkdir -p /home/rpmbuild/{BUILD,RPMS,SPECS,SRPMS,SOURCES}
chown -R rpmbuild:users /home/rpmbuild
echo '%_topdir /home/rpmbuild' > /home/rpmbuild/.rpmmacros
- name: Checkout repo
uses: https://gitea.com/actions/checkout@v4
- name: Copy fedora.spec
run: |
cp build-aux/fedora.spec /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec
chown -R rpmbuild:users /home/rpmbuild
- name: Build RPM
run: |
su rpmbuild -c "rpmbuild -ba /home/rpmbuild/SPECS/${{ env.PACKAGE }}.spec"
- name: Upload RPM package
uses: https://gitea.com/actions/gitea-upload-artifact@v3
with:
name: PortProtonQt-RPM-Fedora-${{ matrix.fedora_version }}
path: /home/rpmbuild/RPMS/**/*.rpm

View File

@ -0,0 +1,29 @@
name: Check Translations
run-name: Check spelling in translation files
on:
push:
branches: [main]
paths:
- 'portprotonqt/locales/**'
pull_request:
paths:
- 'portprotonqt/locales/**'
workflow_dispatch:
jobs:
check-translations:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Set up Python
uses: https://gitea.com/actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install Python dependencies
run: pip install pyaspeller babel
- name: Run spell check
run: python dev-scripts/l10n.py --spellcheck

View File

@ -0,0 +1,54 @@
name: Code and build check
on:
pull_request:
branches: [main]
paths-ignore:
- "data/**"
- "*.md"
- "dev-scripts/**"
push:
branches: [main]
paths-ignore:
- "data/**"
- "*.md"
- "dev-scripts/**"
workflow_dispatch:
jobs:
pre-commit:
name: Check code
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Install uv
uses: https://github.com/astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Sync dependencies into venv
run: uv sync --all-extras --dev
- name: Activate .venv & run pre-commit
shell: bash
run: |
source .venv/bin/activate
pre-commit run --show-diff-on-failure --color=always --all-files
build-uv:
name: Build with uv
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@v4
- name: Install uv
uses: https://github.com/astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Sync dependencies
run: uv sync
- name: Build project
run: uv build

View File

@ -0,0 +1,77 @@
name: Fetch Data
run-name: Fetch and Write steam apps list
on:
workflow_dispatch:
schedule:
- cron: '0 0 1 * *'
jobs:
build:
if: gitea.repository == 'Boria138/PortProtonQt'
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@v4
- name: Set up Python
uses: https://gitea.com/actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y xz-utils
- name: Set up dependency
run: pip install aiohttp asyncio
- name: Run get_id.py
run: python dev-scripts/get_id.py
env:
STEAM_KEY: ${{ secrets.STEAM_KEY }}
- name: Commit and push changes
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_ACTOR: ${{ gitea.actor }}
GITEA_SERVER: "git.linux-gaming.ru"
GITEA_REPOSITORY: ${{ gitea.repository }}
run: |
# Create the push script
cat << 'EOF' > push-to-gitea.sh
#!/bin/sh
set -e
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
AUTHOR_EMAIL=${INPUT_AUTHOR_EMAIL:-'gitea-actions@users.noreply.gitea.com'}
AUTHOR_NAME=${INPUT_AUTHOR_NAME:-'Gitea Actions'}
MESSAGE=${INPUT_MESSAGE:-"chore: update steam apps list ${timestamp}"}
BRANCH=main
INPUT_DIRECTORY=${INPUT_DIRECTORY:-'.'}
echo "Push to branch $INPUT_BRANCH"
[ -z "${GITEA_TOKEN}" ] && {
echo 'Missing input "gitea_token: ${{ secrets.GITEA_TOKEN }}".'
exit 1
}
cd "${INPUT_DIRECTORY}"
remote_repo="https://${GITEA_ACTOR}:${GITEA_TOKEN}@${GITEA_SERVER}/${GITEA_REPOSITORY}.git"
git config http.sslVerify false
git config --local user.email "${AUTHOR_EMAIL}"
git config --local user.name "${AUTHOR_NAME}"
git add -A
git commit -m "${MESSAGE}" || exit 0
git push "${remote_repo}" HEAD:"${BRANCH}"
EOF
# Make the script executable and run it
chmod +x push-to-gitea.sh
./push-to-gitea.sh

35
.gitignore vendored Normal file
View File

@ -0,0 +1,35 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
venv
pyvenv.cfg
# Ruff
.ruff_cache
# MyPy
.mypy_cache
# MacOS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Editors files
*-swp
.gigaide
.idea
.vscode
.ropeproject
.zed

41
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,41 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
exclude: '(data/|documentation/|portprotonqt/locales/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-toml
- id: check-yaml
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.6.14
hooks:
- id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.5
hooks:
- id: ruff
args: [--fix]
- repo: local
hooks:
- id: pyright
name: pyright
entry: pyright
language: system
'types_or': [python, pyi]
require_serial: true
- repo: local
hooks:
- id: check-qss-properties
name: Check theme for invalid QSS properties
entry: ./dev-scripts/check_qss_properties.py
language: system
types: [file]
files: \.py$
pass_filenames: false

1
.python-version Normal file
View File

@ -0,0 +1 @@
3.10

68
CHANGELOG.md Normal file
View File

@ -0,0 +1,68 @@
# Changelog
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- Кнопки сброса настроек и очистки кэша
- Начальная интеграция с EGS с помощью [Legendary](https://github.com/derrod/legendary)
- Зависимость на `xdg-utils`
- Установка ширины бейджа в две трети ширины карточки
- Интеграция статуса WeAntiCheatYet в карточку
- Стили в AddGameDialog
- Переключение полноэкранного режима через F11
- Выбор QCheckBox через Enter или кнопку A геймпада
- Закрытие окна приложения по комбинации клавиш Ctrl+Q
- Сохранение и восстановление размера при рестарте
- Переключатель полноэкранного режима приложения
- Пункт в контекстное меню “Открыть папку игры”
- Пункт в контекстное меню “Добавить в Steam”
- Пункт в контекстное меню "Удалить из Steam”
- Метод сортировки сначала избранное
- Авто сборки для тестирования
- Благодарности контрибьюторам в README
### Changed
- Обновлены все иконки
- Переименован `_get_steam_home``get_steam_home`
- Догика контекстного меню вынесена в `ContextMenuManager`
- Бейдж Steam теперь открывает Steam Community
- Изменена лицензия с MIT на GPL-3.0 для совместимости с кодом от legendary
- Оптимизирована генерация карточек для предотвращения лагов при поиске и изменения размера окна
### Fixed
- Обработка несуществующей темы с возвратом к “standart”
- Открытие контекстного меню
- Запуск при отсутствии exiftool
- Переводы пунктов настроек
- Бесконечное обращение к get_portproton_location
- Ссылки на документацию в README
- traceback при загрузке placeholder при отсутствии обложек
- Утечки памяти при загрузке обложек
---
## [0.1.1] 2025-05-17
### Added
- Алфавитная сортировка библиотеки
- Проверка переводов через yaspeller
- Сборка Fedora-пакета
- Сборка AppImage
### Changed
- Удалён жёстко заданный ресайз окна
- Использован icoextract как python модуль
### Fixed
- Скрытие статус-бара
- Чтение списка Steam-игр
- Подвисание GUI
- Краш при повреждённом Steam
---
> См. подробности по каждому коммиту в истории репозитория.

109
README.md
View File

@ -1,3 +1,108 @@
# PortProtonQt
<div align="center">
<img src="https://raw.githubusercontent.com/Castro-Fidel/PortWINE/master/data_from_portwine/img/gui/portproton.svg" width="64">
<h1 align="center">PortProtonQt</h1>
<p align="center">Проект нацеленный на переписывание PortProton(PortWINE) на PySide</p>
</div>
Is a project aimed at , providing a modern, user-friendly GUI for managing and launching games from multiple platforms, including PortProton, Steam, Epic Games Store (EGS) and more
## В планах
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
- [ ] Добавить возможность управление с геймпада
- [ ] Добавить возможность управление с тачскрина
- [X] Добавить возможность управление с мыши и клавиатуры
- [X] Добавить систему тем [Документация](documentation/theme_guide)
- [X] Вынести все константы такие как уровень закругления карточек в темы (Частично вынесено)
- [X] Добавить метадату для тем (скришоты, описание, домащняя страница и автор)
- [ ] Продумать систему вкладок вместо той что есть сейчас
- [ ] Добавить Gamescope сессию на подобие той что есть в SteamOS
- [ ] Написать адаптивный дизайн (За эталон берём SteamDeck с разрешением 1280х800)
- [X] Брать описание и названия игр с базы данных Steam
- [X] Брать обложки для игр со SteamGridDB или CDN Steam
- [X] Оптимизировать работу со SteamApi что бы ускорить время запуска
- [X] Улучшить функцию поиска SteamApi что бы исправить некорректное определение ID (Graven определается как ENGRAVEN или GRAVENFALL, Spore определается как SporeBound или Spore Valley)
- [ ] Убрать логи со SteamApi в релизной версии потому что логи замедляют код
- [X] Что-то придумать с ограничением SteamApi в 50 тысяч игр за один запрос (иногда туда не попадают нужные игры и остаются без обложки)
- [X] Избавится от любого вызова yad
- [X] Написать свою реализацию запрета ухода в сон, а не использовать ту что в PortProton (Оставим это [PortProton 2.0](https://github.com/Castro-Fidel/PortProton_2.0))
- [X] Написать свою реализацию трея, а не использовать ту что в PortProton
- [X] Добавить в поиск экранную клавиатуру (Реализовавывать собственную клавиатуру слишком затратно, лучше положится на встроенную в DE клавиатуру malit в KDE, gjs-osk в GNOME,Squeekboard в phosh, стимовская в SteamOS и так далее)
- [X] Добавить сортировку карточек по различным критериям (сейчас есть: недавние, кол-во наиграного времени, избранное или по алфавиту)
- [X] Добавить индикацию запуска приложения
- [X] Достичь паритета функционала с Ingame (кроме поддержки нативных игр)
- [ ] Достичь паритета функционала с PortProton
- [X] Добавить возможность изменения названия, описания и обложки через файлы .local/share/PortProtonQT/custom_data/exe_name/{desc,name,cover}
- [X] Добавить встроенное переопределение имени, описания и обложки, например по пути portprotonqt/custom_data [Документация](documentation/metadata_override/)
- [X] Добавить в карточку игры сведения о поддержке геймадов
- [X] Добавить в карточки данные с ProtonDB
- [X] Добавить в карточки данные с Are We Anti-Cheat Yet?
- [ ] Продублировать бейджы с карточки на страницу с деталями игрыы
- [X] Добавить парсинг ярлыков со Steam
- [X] Добавить парсинг ярлыков с EGS
- [ ] Избавится от бинарника legendary
- [ ] Добавить запуск и скачивание игр с EGS
- [ ] Добавить авторизацию в EGS через WebView, а не вручную
- [X] Брать описания для игр с EGS из их [api](https://store-content.ak.epicgames.com/api)
- [ ] Брать slug через Graphql [запрос](https://launcher.store.epicgames.com/graphql)
- [X] Добавить на карточку бейдж того что игра со стима
- [X] Добавить поддержку Flatpak и Snap версии Steam
- [X] Выводить данные о самом недавнем пользователе Steam, а не первом попавшемся
- [X] Исправить склонения в детальном выводе времени, например не 3 часов назад, а 3 часа назад
- [X] Добавить перевод через gettext [Документация](documentation/localization_guide)
- [X] Писать описание игр и прочие данные на языке системы
- [X] Добавить недокументированные параметры конфигурации в GUI (time detail_level, games sort_method, games display_filter)
- [X] Добавить систему избранного к карточкам
- [X] Заменить все print на logging
- [ ] Привести все логи к одному языку
- [X] Стилизовать все элементы без стилей(QMessageBox, QSlider, QDialog)
- [X] Убрать жёсткую привязку путей на стрелочки QComboBox в styles.py
- [X] Исправить частичное применение тем на лету
- [X] Исправить наложение подписей скриншотов при первом перелистывание в полноэкранном режиме
### Установка (debug)
```sh
uv python install 3.10
uv sync
source .venv/bin/activate
```
Запуск производится по команде portprotonqt
### Разработка
В проект встроен линтер (ruff), статический анализатор (pyright) и проверка lock файла, если эти проверки не пройдут PR не будет принят, поэтому перед коммитом введите такую команду
```sh
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
pre-commit install
```
pre-commit сам запустится при коммите, если вы хотите запустить его вручную введите команду
```sh
pre-commit run --all-files
```
## Авторы
* [Boria138](https://github.com/Boria138) - Программист
* [BlackSnaker](https://github.com/BlackSnaker) - Дизайнер - программист
* [Mikhail Tergoev(Castro-Fidel)](https://github.com/Castro-Fidel) - Автор оригинального проекта PortProton
## Помощники (Контрибьюторы)
Спасибо всем, кто помогает в развитии проекта:
<a href="https://github.com/Boria138/PortProtonQt/graphs/contributors">
<img src="https://contrib.rocks/image?repo=Boria138/PortProtonQt" />
</a>
> [!WARNING]
> Проект находится на стадии WIP (work in progress) корректная работоспособность не гарантирована
> [!WARNING]
> **Будьте осторожны!** Если вы берёте тему не из официального репозитория или надёжного источника, убедитесь, что в её файле `styles.py` нет вредоносного или нежелательного кода. Поскольку `styles.py` — это обычный Python-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.

View File

@ -0,0 +1,58 @@
version: 1
script:
# 1) чистим старый AppDir
- rm -rf AppDir || true
# 2) создаём структуру каталога
- mkdir -p AppDir/usr/local/lib/python3.10/dist-packages
# 3) UV: создаём виртуальное окружение и устанавливаем зависимости из pyproject.toml
- uv venv
- uv pip install --no-cache-dir ../
# 4) копируем всё из .venv в AppDir
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr
# 5) чистим от ненужных модулей и бинарников
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3D*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtHelp*,QtMultimedia*,QtNetwork*,QtOpenGL*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialPort*,QtSql*,QtStateMachine*,QtTest*,QtWeb*,QtXml*}
- shopt -s extglob
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6Gui*|libQt6Widgets*|libQt6OpenGL*|libQt6XcbQpa*|libQt6Wayland*|libQt6Egl*|libicudata*|libicuuc*|libicui18n*|libQt6DBus*|libQt6Svg*|libQt6Qml*|libQt6Network*)
AppDir:
path: ./AppDir
app_info:
id: ru.linux_gaming.PortProtonQt
name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt
version: 0.1.1
exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@"
apt:
arch: amd64
sources:
- sourceline: 'deb [arch=amd64] http://archive.ubuntu.com/ubuntu/ jammy main restricted universe multiverse'
key_url: 'http://keyserver.ubuntu.com/pks/lookup?op=get&search=0x871920d1991bc93c'
include:
- python3
- python3-pkg-resources
- libopengl0
- libk5crypto3
- libkrb5-3
- libgssapi-krb5-2
- libxcb-cursor0
- libimage-exiftool-perl
- xdg-utils
exclude: []
runtime:
env:
PYTHONHOME: '${APPDIR}/usr'
PYTHONPATH: '${APPDIR}/usr/local/lib/python3.10/dist-packages'
AppImage:
update-information: gh-releases-zsync|Boria138|PortProtonQt|latest|PortProtonQt-*x86_64.AppImage.zsync
sign-key: None
arch: x86_64

23
build-aux/PKGBUILD Normal file
View File

@ -0,0 +1,23 @@
pkgname=portprotonqt
pkgver=0.1.1
pkgrel=1
pkgdesc="A modern GUI for PortProton project."
arch=('any')
url="https://github.com/Boria138/PortProtonQt"
license=('MIT')
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')
makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://github.com/Boria138/PortProtonQt.git#tag=$pkgver")
sha256sums=('SKIP')
build() {
cd "$srcdir/PortProtonQt"
python -m build --wheel --no-isolation
}
package() {
cd "$srcdir/PortProtonQt"
python -m installer --destdir="$pkgdir" dist/*.whl
cp -r build-aux/share "$pkgdir/usr/"
}

28
build-aux/PKGBUILD-git Normal file
View File

@ -0,0 +1,28 @@
pkgname=portprotonqt-git
pkgver=.
pkgrel=1
pkgdesc="A modern GUI for PortProton project.(developerment build)"
arch=('any')
url="https://github.com/Boria138/PortProtonQt"
license=('MIT')
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')
makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://github.com/Boria138/PortProtonQt")
sha256sums=('SKIP')
pkgver() {
cd "$srcdir/PortProtonQt"
printf "r%s.%s" "$(git rev-list --count HEAD)" "$(git rev-parse --short HEAD)"
}
build() {
cd "$srcdir/PortProtonQt"
python -m build --wheel --no-isolation
}
package() {
cd "$srcdir/PortProtonQt"
python -m installer --destdir="$pkgdir" dist/*.whl
cp -r build-aux/share "$pkgdir/usr/"
}

68
build-aux/fedora-git.spec Normal file
View File

@ -0,0 +1,68 @@
%global pypi_name portprotonqt
%global pypi_version 0.1.1
%global oname PortProtonQt
%global build_timestamp %(date +"%Y%m%d")
%global rel_build 1.git.%{build_timestamp}%{?dist}
Name: python-%{pypi_name}-git
Version: %{pypi_version}
Release: %{rel_build}
Summary: A modern GUI for PortProton project (devel build)
License: MIT
URL: https://github.com/Boria138/PortProtonQt
BuildArch: noarch
BuildRequires: python3-devel
BuildRequires: python3-wheel
BuildRequires: python3-pip
BuildRequires: python3-build
BuildRequires: pyproject-rpm-macros
BuildRequires: python3dist(setuptools)
BuildRequires: git
%description
%{summary}
%package -n python3-%{pypi_name}-git
Summary: %{summary}
%{?python_provide:%python_provide python3-%{pypi_name}}
Requires: python3dist(babel)
Requires: python3dist(evdev)
Requires: python3dist(icoextract)
Requires: python3dist(numpy)
Requires: python3dist(orjson)
Requires: python3dist(psutil)
Requires: python3dist(pyside6)
Requires: python3dist(pyudev)
Requires: python3dist(requests)
Requires: python3dist(tqdm)
Requires: python3dist(vdf)
Requires: python3dist(pefile)
Requires: python3dist(pillow)
Requires: perl-Image-ExifTool
Requires: xdg-utils
%description -n python3-%{pypi_name}-git
PortProtonQt is a modern graphical user interface for the PortProton project,
designed to simplify the management and launching of games using Wine and Proton.
%prep
git clone https://github.com/Boria138/PortProtonQt
%build
cd %{oname}
%pyproject_wheel
%install
cd %{oname}
%pyproject_install
%pyproject_save_files %{pypi_name}
cp -r build-aux/share %{buildroot}/usr/
%files -n python3-%{pypi_name}-git -f %{pyproject_files}
%{_bindir}/%{pypi_name}
%{_datadir}/*
%changelog

67
build-aux/fedora.spec Normal file
View File

@ -0,0 +1,67 @@
%global pypi_name portprotonqt
%global pypi_version 0.1.1
%global oname PortProtonQt
Name: python-%{pypi_name}
Version: %{pypi_version}
Release: 1%{?dist}
Summary: A modern GUI for PortProton project
License: MIT
URL: https://github.com/Boria138/PortProtonQt
BuildArch: noarch
BuildRequires: python3-devel
BuildRequires: python3-wheel
BuildRequires: python3-pip
BuildRequires: python3-build
BuildRequires: pyproject-rpm-macros
BuildRequires: python3dist(setuptools)
BuildRequires: git
%description
%{summary}
%package -n python3-%{pypi_name}
Summary: %{summary}
%{?python_provide:%python_provide python3-%{pypi_name}}
Requires: python3dist(babel)
Requires: python3dist(evdev)
Requires: python3dist(icoextract)
Requires: python3dist(numpy)
Requires: python3dist(orjson)
Requires: python3dist(psutil)
Requires: python3dist(pyside6)
Requires: python3dist(pyudev)
Requires: python3dist(requests)
Requires: python3dist(tqdm)
Requires: python3dist(vdf)
Requires: python3dist(pefile)
Requires: python3dist(pillow)
Requires: perl-Image-ExifTool
Requires: xdg-utils
%description -n python3-%{pypi_name}
PortProtonQt is a modern graphical user interface for the PortProton project,
designed to simplify the management and launching of games using Wine and Proton.
%prep
git clone https://github.com/Boria138/PortProtonQt
cd %{oname}
git checkout %{pypi_version}
%build
cd %{oname}
%pyproject_wheel
%install
cd %{oname}
%pyproject_install
%pyproject_save_files %{pypi_name}
cp -r build-aux/share %{buildroot}/usr/
%files -n python3-%{pypi_name} -f %{pyproject_files}
%{_bindir}/%{pypi_name}
%{_datadir}/*
%changelog

View File

@ -0,0 +1,9 @@
[Desktop Entry]
Name=PortProtonQt
Exec=portprotonqt
Type=Application
Comment=A modern GUI for PortProton project
Terminal=false
Icon=ru.linux_gaming.PortProtonQt
StartupWMClass=ru.linux_gaming.PortProtonQt
Categories=Game;Utility;

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

4398
data/anticheat_games.json Normal file

File diff suppressed because it is too large Load Diff

BIN
data/anticheat_games.tar.xz Normal file

Binary file not shown.

527494
data/games_appid.json Normal file

File diff suppressed because it is too large Load Diff

BIN
data/games_appid.tar.xz Normal file

Binary file not shown.

16
dev-scripts/.spellignore Normal file
View File

@ -0,0 +1,16 @@
PortProton
\n
flatpak
Auto Install
Project-Id-Version:
Report-Msgid-Bugs-To:
POT-Creation-Date:
PO-Revision-Date:
Last-Translator:
Language:
Language-Team:
Plural-Forms:
MIME-Version:
Content-Type:
Content-Transfer-Encoding:
Generated-By:

133
dev-scripts/bump_ver.py Executable file
View File

@ -0,0 +1,133 @@
#!/usr/bin/env python3
import argparse
import re
from pathlib import Path
# Base directory of the project
BASE_DIR = Path(__file__).parent.parent
# Specific project files
APPIMAGE_RECIPE = BASE_DIR / "build-aux" / "AppImageBuilder.yml"
ARCH_PKGBUILD = BASE_DIR / "build-aux" / "PKGBUILD"
FEDORA_SPEC = BASE_DIR / "build-aux" / "fedora.spec"
PYPROJECT = BASE_DIR / "pyproject.toml"
APP_PY = BASE_DIR / "portprotonqt" / "app.py"
GITHUB_WORKFLOW = BASE_DIR / ".github" / "workflows" / "build.yml"
GITEA_WORKFLOW = BASE_DIR / ".gitea" / "workflows" / "build.yml"
def bump_appimage(path: Path, old: str, new: str) -> bool:
"""
Update only the 'version' field under app_info in AppImageBuilder.yml
"""
if not path.exists():
return False
text = path.read_text(encoding='utf-8')
pattern = re.compile(r"(?m)^(\s*version:\s*)" + re.escape(old) + r"$")
new_text, count = pattern.subn(lambda m: m.group(1) + new, text)
if count:
path.write_text(new_text, encoding='utf-8')
return bool(count)
def bump_arch(path: Path, old: str, new: str) -> bool:
"""
Update pkgver in PKGBUILD
"""
if not path.exists():
return False
text = path.read_text(encoding='utf-8')
pattern = re.compile(r"(?m)^(pkgver=)" + re.escape(old) + r"$")
new_text, count = pattern.subn(lambda m: m.group(1) + new, text)
if count:
path.write_text(new_text, encoding='utf-8')
return bool(count)
def bump_fedora(path: Path, old: str, new: str) -> bool:
"""
Update only the '%global pypi_version' line in fedora.spec
"""
if not path.exists():
return False
text = path.read_text(encoding='utf-8')
pattern = re.compile(r"(?m)^(%global\s+pypi_version\s+)" + re.escape(old) + r"$")
new_text, count = pattern.subn(lambda m: m.group(1) + new, text)
if count:
path.write_text(new_text, encoding='utf-8')
return bool(count)
def bump_pyproject(path: Path, old: str, new: str) -> bool:
"""
Update version in pyproject.toml under [project]
"""
if not path.exists():
return False
text = path.read_text(encoding='utf-8')
pattern = re.compile(r"(?m)^(version\s*=\s*)\"" + re.escape(old) + r"\"$")
new_text, count = pattern.subn(lambda m: m.group(1) + f'"{new}"', text)
if count:
path.write_text(new_text, encoding='utf-8')
return bool(count)
def bump_app_py(path: Path, old: str, new: str) -> bool:
"""
Update __app_version__ in app.py
"""
if not path.exists():
return False
text = path.read_text(encoding='utf-8')
pattern = re.compile(r"(?m)^(\s*__app_version__\s*=\s*)\"" + re.escape(old) + r"\"$")
new_text, count = pattern.subn(lambda m: m.group(1) + f'"{new}"', text)
if count:
path.write_text(new_text, encoding='utf-8')
return bool(count)
def bump_workflow(path: Path, old: str, new: str) -> bool:
"""
Update VERSION in GitHub or Gitea Actions workflow
"""
if not path.exists():
return False
text = path.read_text(encoding='utf-8')
pattern = re.compile(r"(?m)^(\s*VERSION:\s*)" + re.escape(old) + r"$")
new_text, count = pattern.subn(lambda m: m.group(1) + new, text)
if count:
path.write_text(new_text, encoding='utf-8')
return bool(count)
def main():
parser = argparse.ArgumentParser(description='Bump project version in specific files')
parser.add_argument('old', help='Old version string')
parser.add_argument('new', help='New version string')
args = parser.parse_args()
old, new = args.old, args.new
tasks = [
(APPIMAGE_RECIPE, bump_appimage),
(ARCH_PKGBUILD, bump_arch),
(FEDORA_SPEC, bump_fedora),
(PYPROJECT, bump_pyproject),
(APP_PY, bump_app_py),
(GITHUB_WORKFLOW, bump_workflow),
(GITEA_WORKFLOW, bump_workflow)
]
updated = []
for path, func in tasks:
if func(path, old, new):
updated.append(path.relative_to(BASE_DIR))
if updated:
print(f"Updated version from {old} to {new} in {len(updated)} files:")
for p in sorted(updated):
print(f" - {p}")
else:
print(f"No occurrences of version {old} found in specified files.")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,28 @@
#!/usr/bin/env python3
import sys
from pathlib import Path
import re
# Запрещенные свойства
FORBIDDEN_PROPERTIES = {
"box-shadow",
"backdrop-filter",
"cursor",
"text-shadow",
}
def check_qss_files():
has_errors = False
for qss_file in Path("portprotonqt/themes").glob("**/*.py"):
with open(qss_file, "r") as f:
content = f.read()
for prop in FORBIDDEN_PROPERTIES:
if re.search(rf"{prop}\s*:", content, re.IGNORECASE):
print(f"ERROR: Unknown qss property found '{prop}' on file {qss_file}")
has_errors = True
return has_errors
if __name__ == "__main__":
if check_qss_files():
sys.exit(1)

199
dev-scripts/get_id.py Executable file
View File

@ -0,0 +1,199 @@
#!/usr/bin/env python3
import os
import json
import asyncio
import aiohttp
import tarfile
# Получаем ключ Steam из переменной окружения.
key = os.environ.get('STEAM_KEY')
base_url = "https://api.steampowered.com/IStoreService/GetAppList/v1/?"
category = "games"
def normalize_name(s):
"""
Приведение строки к нормальному виду:
- перевод в нижний регистр,
- удаление символов и ®,
- замена разделителей (-, :, ,) на пробел,
- удаление лишних пробелов,
- удаление суффиксов 'bin' или 'app' в конце строки,
- удаление ключевых слов типа 'ultimate', 'edition' и т.п.
"""
s = s.lower()
for ch in ["", "®"]:
s = s.replace(ch, "")
for ch in ["-", ":", ","]:
s = s.replace(ch, " ")
s = " ".join(s.split())
for suffix in ["bin", "app"]:
if s.endswith(suffix):
s = s[:-len(suffix)].strip()
# Удаляем служебные слова, которые не должны влиять на сопоставление
keywords_to_remove = {"ultimate", "edition", "definitive", "complete", "remastered"}
words = s.split()
filtered_words = [word for word in words if word not in keywords_to_remove]
return " ".join(filtered_words)
def process_steam_apps(steam_apps):
"""
Для каждого приложения из Steam добавляет ключ "normalized_name",
содержащий нормализованное значение имени (поле "name"),
и удаляет ненужные поля: "name", "last_modified", "price_change_number".
"""
for app in steam_apps:
original = app.get("name", "")
if not app.get("normalized_name"):
app["normalized_name"] = normalize_name(original)
# Удаляем ненужные поля
app.pop("name", None)
app.pop("last_modified", None)
app.pop("price_change_number", None)
return steam_apps
async def get_app_list(session, last_appid, endpoint):
"""
Получает часть списка приложений из API.
Если last_appid передан, добавляет его к URL для постраничной загрузки.
"""
url = endpoint
if last_appid:
url = f"{url}&last_appid={last_appid}"
async with session.get(url) as response:
response.raise_for_status()
return await response.json()
async def fetch_games_json(session):
"""
Загружает JSON с данными из AreWeAntiCheatYet и извлекает поля normalized_name и status.
"""
url = "https://raw.githubusercontent.com/AreWeAntiCheatYet/AreWeAntiCheatYet/HEAD/games.json"
try:
async with session.get(url) as response:
response.raise_for_status()
text = await response.text()
data = json.loads(text)
# Извлекаем только поля normalized_name и status
return [{"normalized_name": normalize_name(game["name"]), "status": game["status"]} for game in data]
except Exception as error:
print(f"Ошибка загрузки games.json: {error}")
return []
async def request_data():
"""
Получает данные списка приложений для категории "games" до тех пор,
пока не закончатся результаты, обрабатывает данные для добавления
нормализованных имён и записывает итоговый результат в JSON-файл.
Отдельно загружает games.json и сохраняет его в отдельный JSON-файл.
"""
# Параметры запроса для игр.
game_param = "&include_games=true"
dlc_param = "&include_dlc=false"
software_param = "&include_software=false"
videos_param = "&include_videos=false"
hardware_param = "&include_hardware=false"
endpoint = (
f"{base_url}key={key}"
f"{game_param}{dlc_param}{software_param}{videos_param}{hardware_param}"
f"&max_results=50000"
)
output_json = []
total_parsed = 0
try:
async with aiohttp.ClientSession() as session:
# Загружаем данные Steam
have_more_results = True
last_appid_val = None
while have_more_results:
app_list = await get_app_list(session, last_appid_val, endpoint)
apps = app_list['response']['apps']
# Обрабатываем приложения для добавления нормализованных имён
apps = process_steam_apps(apps)
output_json.extend(apps)
total_parsed += len(apps)
have_more_results = app_list['response'].get('have_more_results', False)
last_appid_val = app_list['response'].get('last_appid')
print(f"Обработано {len(apps)} игр, всего: {total_parsed}.")
# Загружаем и сохраняем games.json отдельно
anticheat_games = await fetch_games_json(session)
except Exception as error:
print(f"Ошибка получения данных для {category}: {error}")
return False
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
data_dir = os.path.join(repo_root, "data")
os.makedirs(data_dir, exist_ok=True)
# Путь к JSON-файлам для Steam
output_json_full = os.path.join(data_dir, f"{category}_appid.json")
output_json_min = os.path.join(data_dir, f"{category}_appid_min.json")
# Записываем полные данные Steam с отступами
with open(output_json_full, "w", encoding="utf-8") as f:
json.dump(output_json, f, ensure_ascii=False, indent=2)
# Записываем минимизированные данные Steam
with open(output_json_min, "w", encoding="utf-8") as f:
json.dump(output_json, f, ensure_ascii=False, separators=(',',':'))
# Путь к JSON-файлам для AreWeAntiCheatYet
anticheat_json_full = os.path.join(data_dir, "anticheat_games.json")
anticheat_json_min = os.path.join(data_dir, "anticheat_games_min.json")
# Записываем полные данные AreWeAntiCheatYet с отступами
with open(anticheat_json_full, "w", encoding="utf-8") as f:
json.dump(anticheat_games, f, ensure_ascii=False, indent=2)
# Записываем минимизированные данные AreWeAntiCheatYet
with open(anticheat_json_min, "w", encoding="utf-8") as f:
json.dump(anticheat_games, f, ensure_ascii=False, separators=(',',':'))
# Упаковка только минифицированных JSON в tar.xz архивы с максимальным сжатием
# Архив для Steam
steam_archive_path = os.path.join(data_dir, f"{category}_appid.tar.xz")
try:
with tarfile.open(steam_archive_path, "w:xz", preset=9) as tar:
tar.add(output_json_min, arcname=os.path.basename(output_json_min))
print(f"Упаковано минифицированное JSON Steam в архив: {steam_archive_path}")
# Удаляем исходный минифицированный файл после упаковки
os.remove(output_json_min)
except Exception as e:
print(f"Ошибка при упаковке архива Steam: {e}")
return False
# Архив для AreWeAntiCheatYet
anticheat_archive_path = os.path.join(data_dir, "anticheat_games.tar.xz")
try:
with tarfile.open(anticheat_archive_path, "w:xz", preset=9) as tar:
tar.add(anticheat_json_min, arcname=os.path.basename(anticheat_json_min))
print(f"Упаковано минифицированное JSON AreWeAntiCheatYet в архив: {anticheat_archive_path}")
# Удаляем исходный минифицированный файл после упаковки
os.remove(anticheat_json_min)
except Exception as e:
print(f"Ошибка при упаковке архива AreWeAntiCheatYet: {e}")
return False
return True
async def run():
success = await request_data()
if not success:
exit(1)
if __name__ == "__main__":
asyncio.run(run())

246
dev-scripts/l10n.py Executable file
View File

@ -0,0 +1,246 @@
#!/usr/bin/env python3
import argparse
import sys
import io
import contextlib
import re
from pathlib import Path
from collections import defaultdict
from concurrent.futures import ThreadPoolExecutor
from babel.messages.frontend import CommandLineInterface
from pyaspeller import YandexSpeller
# ---------- Пути ----------
GUIDE_DIR = Path(__file__).parent.parent / "documentation" / "localization_guide"
README_EN = GUIDE_DIR / "README.md"
README_RU = GUIDE_DIR / "README.ru.md"
LOCALES_PATH = Path(__file__).parent.parent / "portprotonqt" / "locales"
THEMES_PATH = Path(__file__).parent.parent / "portprotonqt" / "themes"
README_FILES = [README_EN, README_RU]
POT_FILE = LOCALES_PATH / "messages.pot"
# ---------- Версия проекта ----------
def _get_version() -> str:
return "0.1.1"
# ---------- Обновление README ----------
def _update_coverage(lines: list[str]) -> None:
# Парсим статистику из вывода pybabel --statistics
locales_stats = [line for line in lines if line.endswith(".po")]
# Извлекаем (count, pct, locale) и сортируем
rows = sorted(
(m := re.search(
r"""(\d+\ of\ \d+).* # message counts
\((\d+\%)\).* # message percentage
locales\/(.*)\/LC_MESSAGES # locale name""",
stat, re.VERBOSE
)) and m.groups()
for stat in locales_stats
)
for md_file in README_FILES:
if not md_file.exists():
continue
text = md_file.read_text(encoding="utf-8")
is_ru = (md_file == README_RU)
# Выбираем заголовок раздела
status_header = (
"Current translation status:" if not is_ru
else "Текущий статус перевода:"
)
# Формируем шапку и строки таблицы
if is_ru:
table_header = (
"<!-- Сгенерировано автоматически! -->\n\n"
"| Локаль | Прогресс | Переведено |\n"
"| :----- | -------: | ---------: |\n"
)
fmt = lambda count, pct, loc: f"| [{loc}](./{loc}/LC_MESSAGES/messages.po) | {pct} | {count.replace(' of ', ' из ')} |"
else:
table_header = (
"<!-- Auto-generated coverage table -->\n\n"
"| Locale | Progress | Translated |\n"
"| :----- | -------: | ---------: |\n"
)
fmt = lambda count, pct, loc: f"| [{loc}](./{loc}/LC_MESSAGES/messages.po) | {pct} | {count} |"
# Собираем строки и добавляем '---' в конце
coverage_table = (
table_header
+ "\n".join(fmt(c, p, l) for c, p, l in rows)
+ "\n\n---"
)
# Удаляем старую автоматически сгенерированную таблицу
old_block = (
r"<!--\s*(?:Сгенерировано автоматически!|Auto-generated coverage table)\s*-->"
r".*?(?=\n(?:##|\Z))"
)
cleaned = re.sub(old_block, "", text, flags=re.DOTALL)
# Вставляем новую таблицу сразу после строки с заголовком
insert_pattern = rf"(^.*{re.escape(status_header)}.*$)"
new_text = re.sub(
insert_pattern,
lambda m: m.group(1) + "\n\n" + coverage_table,
cleaned,
count=1,
flags=re.MULTILINE
)
# Записываем файл, если были изменения
if new_text != text:
md_file.write_text(new_text, encoding="utf-8")
# ---------- PyBabel команды ----------
def compile_locales() -> None:
CommandLineInterface().run([
"pybabel", "compile", "--use-fuzzy", "--directory",
f"{LOCALES_PATH.resolve()}", "--statistics"
])
def extract_strings() -> None:
input_dir = (Path(__file__).parent.parent / "portprotonqt").resolve()
CommandLineInterface().run([
"pybabel", "extract", "--project=PortProtonQT",
f"--version={_get_version()}",
"--strip-comment-tag",
"--no-location",
f"--input-dir={input_dir}",
"--copyright-holder=boria138",
f"--ignore-dirs={THEMES_PATH}",
f"--output-file={POT_FILE.resolve()}"
])
def update_locales() -> None:
CommandLineInterface().run([
"pybabel", "update",
f"--input-file={POT_FILE.resolve()}",
f"--output-dir={LOCALES_PATH.resolve()}",
"--ignore-obsolete",
"--update-header-comment",
])
def create_new(locales: list[str]) -> None:
if not POT_FILE.exists():
extract_strings()
for locale in locales:
CommandLineInterface().run([
"pybabel", "init",
f"--input-file={POT_FILE.resolve()}",
f"--output-dir={LOCALES_PATH.resolve()}",
f"--locale={locale}"
])
# ---------- Игнорируемые префиксы для spellcheck ----------
IGNORED_PREFIXES = ()
def load_ignored_prefixes(ignore_file=".spellignore"):
path = Path(__file__).parent / ignore_file
try:
return tuple(path.read_text(encoding='utf-8').splitlines())
except FileNotFoundError:
return ()
IGNORED_PREFIXES = load_ignored_prefixes() + ("PortProton", "flatpak")
# ---------- Проверка орфографии с параллелизмом ----------
speller = YandexSpeller()
MSGID_RE = re.compile(r'^msgid\s+"(.*)"')
MSGSTR_RE = re.compile(r'^msgstr\s+"(.*)"')
def extract_po_strings(filepath: Path) -> list[str]:
# Collect all strings, then filter by ignore list
texts, current_key, buffer = [], None, ""
def flush():
nonlocal buffer
if buffer.strip():
texts.append(buffer)
buffer = ""
for line in filepath.read_text(encoding='utf-8').splitlines():
stripped = line.strip()
if stripped.startswith("msgid ") and filepath.suffix == '.pot':
flush(); current_key = 'msgid'; buffer = MSGID_RE.match(stripped).group(1) or ''
elif stripped.startswith("msgstr "):
flush(); current_key = 'msgstr'; buffer = MSGSTR_RE.match(stripped).group(1) or ''
elif stripped.startswith('"') and stripped.endswith('"') and current_key:
buffer += stripped[1:-1]
else:
flush(); current_key = None
flush()
# Final filter: remove ignored and multi-line
return [
t for t in texts
if t.strip() and all(pref not in t for pref in IGNORED_PREFIXES) and "\n" not in t
]
def _check_text(text: str) -> tuple[str, list[dict]]:
result = speller.spell(text)
errors = [r for r in result if r.get('word') and r.get('s')]
return text, errors
def check_file(filepath: Path, issues_summary: dict) -> bool:
print(f"Checking file: {filepath}")
texts = extract_po_strings(filepath)
has_errors = False
printed_err = False
with ThreadPoolExecutor(max_workers=8) as pool:
for text, errors in pool.map(_check_text, texts):
print(f' In string: "{text}"')
if errors:
if not printed_err:
print(f"❌ Errors in file: {filepath}")
printed_err = True
has_errors = True
for err in errors:
print(f" - typo: {err['word']}, suggestions: {', '.join(err['s'])}")
issues_summary[filepath].extend([(text, err) for err in errors])
return has_errors
# ---------- Основной обработчик ----------
def main(args) -> int:
if args.update_all:
extract_strings(); update_locales()
if args.create_new:
create_new(args.create_new)
if args.spellcheck:
files = list(LOCALES_PATH.glob("**/*.po")) + [POT_FILE]
seen = set(); has_err = False
issues_summary = defaultdict(list)
for f in files:
if not f.exists() or f in seen: continue
seen.add(f)
if check_file(f, issues_summary):
has_err = True
else:
print(f"{f} — no errors found.")
if has_err:
print("\n📋 Summary of Spelling Errors:")
for file, errs in issues_summary.items():
print(f"\n{file}")
print("-----")
for idx, (text, err) in enumerate(errs, 1):
print(f"{idx}. In '{text}': typo '{err['word']}', suggestions: {', '.join(err['s'])}")
print("-----")
return 1 if has_err else 0
extract_strings(); compile_locales()
return 0
if __name__ == "__main__":
parser = argparse.ArgumentParser(prog="l10n", description="Localization utility for PortProtonQT.")
parser.add_argument("--create-new", nargs='+', type=str, default=False, help="Create .po for new locales")
parser.add_argument("--update-all", action='store_true', help="Extract/update locales and update README coverage")
parser.add_argument("--spellcheck", action='store_true', help="Run spellcheck on POT and PO files")
args = parser.parse_args()
if args.spellcheck:
sys.exit(main(args))
f = io.StringIO()
with contextlib.redirect_stdout(f), contextlib.redirect_stderr(f):
main(args)
output = f.getvalue().splitlines()
_update_coverage(output)
sys.exit(0)

View File

@ -0,0 +1,80 @@
📘 Эта документация также доступна на [русском.](README.ru.md)
---
## 📋 Contents
- [Overview](#overview)
- [Adding a New Translation](#adding-a-new-translation)
- [Updating Existing Translations](#updating-existing-translations)
- [Compiling Translations](#compiling-translations)
---
## 📖 Overview
Localization in `PortProtonQT` is powered by `Babel` using `.po/.mo` files stored under `LC_MESSAGES/messages.po` for each language.
Current translation status:
<!-- Auto-generated coverage table -->
| Locale | Progress | Translated |
| :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 152 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 152 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 152 of 152 |
---
## 🏁 Adding a New Translation
1. Run:
```bash
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --create-new <locale_code>
```
2. Edit the file `portprotonqt/locales/<locale>/LC_MESSAGES/messages.po` in Poedit or any text editor.
---
## 🔄 Updating Existing Translations
If youve added new strings to the code:
```bash
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --update-all
```
---
## 🧵 Compiling Translations
```bash
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py
```
## 🔍 Spell Check
To check spelling, run the following commands:
```bash
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --spellcheck
```
The script performs parallel spellchecking of strings in `.po` and `.pot` files. For each file, it prints the list of strings being checked and highlights any spelling errors with suggestions. Words listed in `dev-scripts/.spellignore` are ignored and not treated as typos.

View File

@ -0,0 +1,78 @@
📘 This documentation is also available in [English](README.md)
---
## 📋 Содержание
- [Обзор](#обзор)
- [Добавление нового перевода](#добавление-нового-перевода)
- [Обновление существующих переводов](#обновление-существующих-переводов)
- [Компиляция переводов](#компиляция-переводов)
---
## 📖 Обзор
Локализация в `PortProtonQT` осуществляется через систему `.po/.mo` файлов и управляется утилитой `Babel`. Все переводы находятся в подкаталогах вида `LC_MESSAGES/messages.po` для каждой поддерживаемой локали.
Текущий статус перевода:
<!-- Сгенерировано автоматически! -->
| Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 152 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 152 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 152 из 152 |
---
## 🏁 Добавление нового перевода
1. Выполните:
```bash
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --create-new <код_локали>
```
2. Отредактируйте файл `portprotonqt/locales/<локаль>/LC_MESSAGES/messages.po` в Poedit или любом текстовом редакторе.
---
## 🔄 Обновление существующих переводов
Если вы добавили новые строки в код:
```bash
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --update-all
```
---
## 🧵 Компиляция переводов
```bash
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py
```
## 🔍 Проверка орфографии
Для проверки орфографии используйте команду:
```bash
uv python install 3.10
uv sync --all-extras --dev
source .venv/bin/activate
python dev-scripts/l10n.py --spellcheck
```
Скрипт выполняет параллельную проверку строк в `.po` и `.pot` файлах, выводит для каждого файла список проверяемых строк и ошибки с предложениями исправлений. Игнорирует слова, указанные в файле `dev-scripts/.spellignore`, чтобы не считать их опечатками.

View File

@ -0,0 +1,110 @@
📘 Эта документация также доступна на [русском](README.ru.md)
---
## 📋 Contents
- [Overview](#overview)
- [How It Works](#how-it-works)
- [Data Priorities](#data-priorities)
- [File Structure](#file-structure)
- [For Users](#for-users)
- [Creating User Overrides](#creating-user-overrides)
- [Example](#example)
- [For Developers](#for-developers)
- [Adding Built-In Overrides](#adding-built-in-overrides)
---
## 📖 Overview
In `PortProtonQT`, you can change:
- Game title
- Description
- Cover image
Override types:
| Type | Location | Priority |
|-----------------|--------------------------------------------------|----------|
| User | `~/.local/share/PortProtonQT/custom_data/` | Highest |
| Built-in | `portprotonqt/custom_data/` | Lower |
---
## ⚙️ How It Works
### Data Priorities
Data is used in the following order:
1. **User Overrides**
2. **Built-in Overrides**
3. **Steam Metadata**
4. **`.desktop` file info**
### File Structure
Each `<exe_name>` folder can include:
- `metadata.txt` — contains name and description:
```txt
name=My Game Title
description=My Game Description
```
- `cover.<extension>` — image file (`.png`, `.jpg`, `.jpeg`, `.bmp`)
---
## 👤 For Users
### Creating User Overrides
1. **Create a folder for your game**:
```bash
mkdir -p ~/.local/share/PortProtonQT/custom_data/mygame
```
2. **Add overrides**:
- **Metadata file**:
```bash
echo -e "name=My Game\ndescription=Exciting game" > ~/.local/share/PortProtonQT/custom_data/mygame/metadata.txt
```
- **Cover image**:
```bash
cp ~/Images/custom_cover.png ~/.local/share/PortProtonQT/custom_data/mygame/cover.png
```
3. **Restart PortProtonQT**.
## 🛠 For Developers
### Adding Built-In Overrides
1. **Create a folder in the project**:
```bash
mkdir -p portprotonqt/custom_data/mygame
```
2. **Add files**:
- `metadata.txt`:
```txt
name=Default Title
description=Default Description
```
- Cover image (`cover.png`, for example):
```bash
cp path/to/cover.png portprotonqt/custom_data/mygame/cover.png
```
3. **Commit changes to repository**:
```bash
git add portprotonqt/custom_data/mygame
git commit -m "Added built-in overrides for mygame"
```
---
> Done! Your games will now look exactly how you want 🎮✨

View File

@ -0,0 +1,110 @@
📘 This documentation is also available in [English](README.md)
---
## 📋 Содержание
- [Обзор](#обзор)
- [Как это работает](#как-это-работает)
- [Приоритеты данных](#приоритеты-данных)
- [Структура файлов](#структура-файлов)
- [Для пользователей](#для-пользователей)
- [Создание пользовательских переопределений](#создание-пользовательских-переопределений)
- [Пример](#пример)
- [Для разработчиков](#для-разработчиков)
- [Добавление встроенных переопределений](#добавление-встроенных-переопределений)
---
## 📖 Обзор
В `PortProtonQT` можно изменить:
- Название игры
- Описание
- Обложку
Типы переопределений:
| Тип | Расположение | Приоритет |
|----------------|---------------------------------------------------|-----------|
| Пользовательские | `~/.local/share/PortProtonQT/custom_data/` | Высший |
| Встроенные | `portprotonqt/custom_data/` | Ниже |
---
## ⚙️ Как это работает
### Приоритеты данных
Данные берутся в следующем порядке:
1. **Пользовательские переопределения**
2. **Встроенные переопределения**
3. **Данные Steam**
4. **Информация из `.desktop` файла**
### Структура файлов
В каждой папке `<имя_exe>` могут быть следующие файлы:
- `metadata.txt` — имя и описание в формате:
```txt
name=Моё название игры
description=Описание моей игры
```
- `cover.<расширение>` — обложка (`.png`, `.jpg`, `.jpeg`, `.bmp`)
---
## 👤 Для пользователей
### Создание пользовательских переопределений
1. **Создайте папку для игры**:
```bash
mkdir -p ~/.local/share/PortProtonQT/custom_data/mygame
```
2. **Добавьте переопределения**:
- **Файл метаданных**:
```bash
echo -e "name=Моя игра\ndescription=Захватывающая игра" > ~/.local/share/PortProtonQT/custom_data/mygame/metadata.txt
```
- **Обложку**:
```bash
cp ~/Images/custom_cover.png ~/.local/share/PortProtonQT/custom_data/mygame/cover.png
```
3. **Перезапустите PortProtonQT**.
## 🛠 Для разработчиков
### Добавление встроенных переопределений
1. **Создайте папку в проекте**:
```bash
mkdir -p portprotonqt/custom_data/mygame
```
2. **Добавьте файлы**:
- `metadata.txt`:
```txt
name=Стандартное название
description=Стандартное описание игры
```
- Обложка (`cover.png`, например):
```bash
cp path/to/cover.png portprotonqt/custom_data/mygame/cover.png
```
3. **Добавьте изменения в репозиторий**:
```bash
git add portprotonqt/custom_data/mygame
git commit -m "Добавлены встроенные переопределения для mygame"
```
---
> Готово! Теперь ваши игры будут выглядеть именно так, как вы хотите 🎮✨

View File

@ -0,0 +1,71 @@
📘 Эта документация также доступна на [русском](README.ru.md)
---
## 📋 Contents
- [Overview](#overview)
- [Creating the Theme Folder](#creating-the-theme-folder)
- [Style File](#style-file)
- [Metadata](#metadata)
- [Screenshots](#screenshots)
- [Fonts and Icons](#fonts-and-icons)
---
## 📖 Overview
Themes in `PortProtonQT` allow customizing the UI appearance. Themes are stored under:
- `~/.local/share/PortProtonQT/themes`.
---
## 📁 Creating the Theme Folder
```bash
mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
```
---
## 🎨 Style File (`styles.py`)
Create a `styles.py` in the theme root. It should define variables or functions that return CSS.
**Example:**
```python
def custom_button_style(color1, color2):
return f"""
QPushButton {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {color1}, stop:1 {color2});
}}
"""
```
---
## 📝 Metadata (`metainfo.ini`)
```ini
[Metainfo]
name = My Custom Theme
author = Your Name
author_link = https://example.com
description = Description of your theme.
```
---
## 🖼 Screenshots
Folder: `images/screenshots/` — place UI screenshots there.
---
## 🔡 Fonts and Icons (optional)
- Fonts: `fonts/*.ttf` or `.otf`
- Icons: `images/icons/*.svg/.png`
---

View File

@ -0,0 +1,71 @@
📘 This documentation is also available in [English](README.md)
---
## 📋 Содержание
- [Обзор](#обзор)
- [Создание папки темы](#создание-папки-темы)
- [Файл стилей](#файл-стилей)
- [Метаинформация](#метаинформация)
- [Скриншоты](#скриншоты)
- [Шрифты и иконки](#шрифты-и-иконки)
---
## 📖 Обзор
Темы в `PortProtonQT` позволяют изменить внешний вид интерфейса. Все темы хранятся в папке:
- `~/.local/share/PortProtonQT/themes`.
---
## 📁 Создание папки темы
```bash
mkdir -p ~/.local/share/PortProtonQT/themes/my_custom_theme
```
---
## 🎨 Файл стилей (`styles.py`)
Создайте `styles.py` в корне темы. В нём определите переменные и/или функции, возвращающие CSS-оформление.
**Пример функции:**
```python
def custom_button_style(color1, color2):
return f"""
QPushButton {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {color1}, stop:1 {color2});
}}
"""
```
---
## 📝 Метаинформация (`metainfo.ini`)
```ini
[Metainfo]
name = My Custom Theme
author = Ваше имя
author_link = https://example.com
description = Описание вашей темы.
```
---
## 🖼 Скриншоты
Папка: `images/screenshots/` — любые изображения оформления темы.
---
## 🔡 Шрифты и иконки (опционально)
- Шрифты: `fonts/*.ttf` или `.otf`
- Иконки: `images/icons/*.svg/.png`
---

0
portprotonqt/__init__.py Normal file
View File

50
portprotonqt/app.py Normal file
View File

@ -0,0 +1,50 @@
import sys
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow
from portprotonqt.tray import SystemTray
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
__app_version__ = "0.1.1"
def main():
app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__)
app.setApplicationName(__app_name__)
app.setApplicationVersion(__app_version__)
system_locale = QLocale.system()
qt_translator = QTranslator()
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
app.installTranslator(qt_translator)
else:
logger.error(f"Qt translations for {system_locale.name()} not found in {translations_path}")
window = MainWindow()
current_theme_name = read_theme_from_config()
tray = SystemTray(app, current_theme_name)
tray.show_action.triggered.connect(window.show)
tray.hide_action.triggered.connect(window.hide)
def recreate_tray():
nonlocal tray
tray.hide_tray()
current_theme = read_theme_from_config()
tray = SystemTray(app, current_theme)
tray.show_action.triggered.connect(window.show)
tray.hide_action.triggered.connect(window.hide)
window.settings_saved.connect(recreate_tray)
window.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()

View File

@ -0,0 +1,484 @@
import os
import configparser
import shutil
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
_portproton_location = None
# Пути к конфигурационным файлам
CONFIG_FILE = os.path.join(
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
"PortProtonQT.conf"
)
PORTPROTON_CONFIG_FILE = os.path.join(
os.getenv("XDG_CONFIG_HOME", os.path.join(os.path.expanduser("~"), ".config")),
"PortProton.conf"
)
# Пути к папкам с темами
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQT", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
]
def read_config():
"""
Читает конфигурационный файл и возвращает словарь параметров.
Пример строки в конфиге (без секций):
detail_level = detailed
"""
config_dict = {}
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
key, sep, value = line.partition("=")
if sep:
config_dict[key.strip()] = value.strip()
return config_dict
def read_theme_from_config():
"""
Читает из конфигурационного файла тему из секции [Appearance].
Если параметр не задан, возвращает "standart".
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return "standart"
return cp.get("Appearance", "theme", fallback="standart")
def save_theme_to_config(theme_name):
"""
Сохраняет имя выбранной темы в секции [Appearance] конфигурационного файла.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Appearance" not in cp:
cp["Appearance"] = {}
cp["Appearance"]["theme"] = theme_name
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_time_config():
"""
Читает настройки времени из секции [Time] конфигурационного файла.
Если секция или параметр отсутствуют, сохраняет и возвращает "detailed" по умолчанию.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_time_config("detailed")
return "detailed"
if not cp.has_section("Time") or not cp.has_option("Time", "detail_level"):
save_time_config("detailed")
return "detailed"
return cp.get("Time", "detail_level", fallback="detailed").lower()
return "detailed"
def save_time_config(detail_level):
"""
Сохраняет настройку уровня детализации времени в секции [Time].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Time" not in cp:
cp["Time"] = {}
cp["Time"]["detail_level"] = detail_level
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_file_content(file_path):
"""
Читает содержимое файла и возвращает его как строку.
"""
with open(file_path, encoding="utf-8") as f:
return f.read().strip()
def get_portproton_location():
"""
Возвращает путь к директории PortProton.
Сначала проверяется кэшированный путь. Если он отсутствует, проверяется
наличие пути в файле PORTPROTON_CONFIG_FILE. Если путь недоступен,
используется директория по умолчанию.
"""
global _portproton_location
if _portproton_location is not None:
return _portproton_location
# Попытка чтения пути из конфигурационного файла
if os.path.isfile(PORTPROTON_CONFIG_FILE):
try:
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
if location and os.path.isdir(location):
_portproton_location = location
logger.info(f"Путь PortProton из конфигурации: {location}")
return _portproton_location
logger.warning(f"Недействительный путь в конфиге PortProton: {location}")
except (OSError, PermissionError) as e:
logger.error(f"Ошибка чтения файла конфигурации PortProton: {e}")
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_dir):
_portproton_location = default_dir
logger.info(f"Используется директория flatpak PortProton: {default_dir}")
return _portproton_location
logger.warning("Конфигурация и директория flatpak PortProton не найдены")
return None
def parse_desktop_entry(file_path):
"""
Читает и парсит .desktop файл с помощью configparser.
Если секция [Desktop Entry] отсутствует, возвращается None.
"""
cp = configparser.ConfigParser(interpolation=None)
cp.read(file_path, encoding="utf-8")
if "Desktop Entry" not in cp:
return None
return cp["Desktop Entry"]
def load_theme_metainfo(theme_name):
"""
Загружает метаинформацию темы из файла metainfo.ini в корне папки темы.
Ожидаемые поля: author, author_link, description, name.
"""
meta = {}
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
metainfo_file = os.path.join(theme_folder, "metainfo.ini")
if os.path.exists(metainfo_file):
cp = configparser.ConfigParser()
cp.read(metainfo_file, encoding="utf-8")
if "Metainfo" in cp:
meta["author"] = cp.get("Metainfo", "author", fallback="Unknown")
meta["author_link"] = cp.get("Metainfo", "author_link", fallback="")
meta["description"] = cp.get("Metainfo", "description", fallback="")
meta["name"] = cp.get("Metainfo", "name", fallback=theme_name)
break
return meta
def read_card_size():
"""
Читает размер карточек (ширину) из секции [Cards],
Если параметр не задан, возвращает 250.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_card_size(250)
return 250
if not cp.has_section("Cards") or not cp.has_option("Cards", "card_width"):
save_card_size(250)
return 250
return cp.getint("Cards", "card_width", fallback=250)
return 250
def save_card_size(card_width):
"""
Сохраняет размер карточек (ширину) в секцию [Cards].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Cards" not in cp:
cp["Cards"] = {}
cp["Cards"]["card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_sort_method():
"""
Читает метод сортировки из секции [Games].
Если параметр не задан, возвращает last_launch.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
save_sort_method("last_launch")
return "last_launch"
if not cp.has_section("Games") or not cp.has_option("Games", "sort_method"):
save_sort_method("last_launch")
return "last_launch"
return cp.get("Games", "sort_method", fallback="last_launch").lower()
return "last_launch"
def save_sort_method(sort_method):
"""
Сохраняет метод сортировки в секцию [Games].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "Games" not in cp:
cp["Games"] = {}
cp["Games"]["sort_method"] = sort_method
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_display_filter():
"""
Читает параметр display_filter из секции [Games].
Если параметр отсутствует, сохраняет и возвращает значение "all".
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
save_display_filter("all")
return "all"
if not cp.has_section("Games") or not cp.has_option("Games", "display_filter"):
save_display_filter("all")
return "all"
return cp.get("Games", "display_filter", fallback="all").lower()
return "all"
def save_display_filter(filter_value):
"""
Сохраняет параметр display_filter в секцию [Games] конфигурационного файла.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "Games" not in cp:
cp["Games"] = {}
cp["Games"]["display_filter"] = filter_value
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_favorites():
"""
Читает список избранных игр из секции [Favorites] конфигурационного файла.
Список хранится как строка, заключённая в кавычки, с именами, разделёнными запятыми.
Если секция или параметр отсутствуют, возвращает пустой список.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
return []
if cp.has_section("Favorites") and cp.has_option("Favorites", "games"):
favs = cp.get("Favorites", "games", fallback="").strip()
# Если строка начинается и заканчивается кавычками, удаляем их
if favs.startswith('"') and favs.endswith('"'):
favs = favs[1:-1]
return [s.strip() for s in favs.split(",") if s.strip()]
return []
def save_favorites(favorites):
"""
Сохраняет список избранных игр в секцию [Favorites] конфигурационного файла.
Список сохраняется как строка, заключённая в двойные кавычки, где имена игр разделены запятыми.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфига: %s", e)
if "Favorites" not in cp:
cp["Favorites"] = {}
fav_str = ", ".join(favorites)
cp["Favorites"]["games"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def ensure_default_proxy_config():
"""
Проверяет наличие секции [Proxy] в конфигурационном файле.
Если секция отсутствует, создаёт её с пустыми значениями.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return
if not cp.has_section("Proxy"):
cp.add_section("Proxy")
cp["Proxy"]["proxy_url"] = ""
cp["Proxy"]["proxy_user"] = ""
cp["Proxy"]["proxy_password"] = ""
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_proxy_config():
"""
Читает настройки прокси из секции [Proxy] конфигурационного файла.
Если параметр proxy_url не задан или пустой, возвращает пустой словарь.
"""
ensure_default_proxy_config()
cp = configparser.ConfigParser()
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
return {}
proxy_url = cp.get("Proxy", "proxy_url", fallback="").strip()
if proxy_url:
# Если указаны логин и пароль, добавляем их к URL
proxy_user = cp.get("Proxy", "proxy_user", fallback="").strip()
proxy_password = cp.get("Proxy", "proxy_password", fallback="").strip()
if "://" in proxy_url and "@" not in proxy_url and proxy_user and proxy_password:
protocol, rest = proxy_url.split("://", 1)
proxy_url = f"{protocol}://{proxy_user}:{proxy_password}@{rest}"
return {"http": proxy_url, "https": proxy_url}
return {}
def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
"""
Сохраняет настройки proxy в секцию [Proxy] конфигурационного файла.
Если секция отсутствует, создаёт её.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Proxy" not in cp:
cp["Proxy"] = {}
cp["Proxy"]["proxy_url"] = proxy_url
cp["Proxy"]["proxy_user"] = proxy_user
cp["Proxy"]["proxy_password"] = proxy_password
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_fullscreen_config():
"""
Читает настройку полноэкранного режима приложения из секции [Display].
Если параметр отсутствует, сохраняет и возвращает False по умолчанию.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except Exception as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
save_fullscreen_config(False)
return False
if not cp.has_section("Display") or not cp.has_option("Display", "fullscreen"):
save_fullscreen_config(False)
return False
return cp.getboolean("Display", "fullscreen", fallback=False)
return False
def save_fullscreen_config(fullscreen):
"""
Сохраняет настройку полноэкранного режима приложения в секцию [Display].
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка чтения конфигурационного файла: %s", e)
if "Display" not in cp:
cp["Display"] = {}
cp["Display"]["fullscreen"] = str(fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_window_geometry() -> tuple[int, int]:
"""
Читает ширину и высоту окна из секции [MainWindow] конфигурационного файла.
Возвращает кортеж (width, height). Если данные отсутствуют, возвращает (0, 0).
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
return (0, 0)
if cp.has_section("MainWindow"):
width = cp.getint("MainWindow", "width", fallback=0)
height = cp.getint("MainWindow", "height", fallback=0)
return (width, height)
return (0, 0)
def save_window_geometry(width: int, height: int):
"""
Сохраняет ширину и высоту окна в секцию [MainWindow] конфигурационного файла.
"""
cp = configparser.ConfigParser()
if os.path.exists(CONFIG_FILE):
try:
cp.read(CONFIG_FILE, encoding="utf-8")
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.error("Ошибка в конфигурационном файле: %s", e)
if "MainWindow" not in cp:
cp["MainWindow"] = {}
cp["MainWindow"]["width"] = str(width)
cp["MainWindow"]["height"] = str(height)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def reset_config():
"""
Сбрасывает конфигурационный файл, удаляя его.
После этого все настройки будут возвращены к значениям по умолчанию при следующем чтении.
"""
if os.path.exists(CONFIG_FILE):
try:
os.remove(CONFIG_FILE)
logger.info("Конфигурационный файл %s удалён", CONFIG_FILE)
except Exception as e:
logger.error("Ошибка при удалении конфигурационного файла: %s", e)
def clear_cache():
"""
Очищает кэш PortProtonQT, удаляя папку кэша.
"""
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
cache_dir = os.path.join(xdg_cache_home, "PortProtonQT")
if os.path.exists(cache_dir):
try:
shutil.rmtree(cache_dir)
logger.info("Кэш PortProtonQT удалён: %s", cache_dir)
except Exception as e:
logger.error("Ошибка при удалении кэша: %s", e)

View File

@ -0,0 +1,467 @@
import os
import shlex
import glob
import shutil
import subprocess
from PySide6.QtWidgets import QMessageBox, QDialog, QMenu
from PySide6.QtCore import QUrl, QPoint
from PySide6.QtGui import QDesktopServices
from portprotonqt.config_utils import parse_desktop_entry
from portprotonqt.localization import _
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
class ContextMenuManager:
"""Manages context menu actions for game management in PortProtonQT."""
def __init__(self, parent, portproton_location, theme, load_games_callback, update_game_grid_callback):
"""
Initialize the ContextMenuManager.
Args:
parent: The parent widget (MainWindow instance).
portproton_location: Path to the PortProton directory.
theme: The current theme object.
load_games_callback: Callback to reload games list.
update_game_grid_callback: Callback to update the game grid UI.
"""
self.parent = parent
self.portproton_location = portproton_location
self.theme = theme
self.load_games = load_games_callback
self.update_game_grid = update_game_grid_callback
def show_context_menu(self, game_card, pos: QPoint):
"""
Show the context menu for a game card at the specified position.
Args:
game_card: The GameCard instance requesting the context menu.
pos: The position (in widget coordinates) where the menu should appear.
"""
menu = QMenu(self.parent)
if game_card.steam_game != "true":
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
desktop_path = os.path.join(desktop_dir, f"{game_card.name}.desktop")
if os.path.exists(desktop_path):
remove_action = menu.addAction(_("Remove from Desktop"))
remove_action.triggered.connect(lambda: self.remove_from_desktop(game_card.name))
else:
add_action = menu.addAction(_("Add to Desktop"))
add_action.triggered.connect(lambda: self.add_to_desktop(game_card.name, game_card.exec_line))
edit_action = menu.addAction(_("Edit Shortcut"))
edit_action.triggered.connect(lambda: self.edit_game_shortcut(game_card.name, game_card.exec_line, game_card.cover_path))
delete_action = menu.addAction(_("Delete from PortProton"))
delete_action.triggered.connect(lambda: self.delete_game(game_card.name, game_card.exec_line))
open_folder_action = menu.addAction(_("Open Game Folder"))
open_folder_action.triggered.connect(lambda: self.open_game_folder(game_card.name, game_card.exec_line))
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
desktop_path = os.path.join(applications_dir, f"{game_card.name}.desktop")
if os.path.exists(desktop_path):
remove_action = menu.addAction(_("Remove from Menu"))
remove_action.triggered.connect(lambda: self.remove_from_menu(game_card.name))
else:
add_action = menu.addAction(_("Add to Menu"))
add_action.triggered.connect(lambda: self.add_to_menu(game_card.name, game_card.exec_line))
# Add Steam-related actions
is_in_steam = is_game_in_steam(game_card.name)
if is_in_steam:
remove_steam_action = menu.addAction(_("Remove from Steam"))
remove_steam_action.triggered.connect(lambda: self.remove_from_steam(game_card.name, game_card.exec_line))
else:
add_steam_action = menu.addAction(_("Add to Steam"))
add_steam_action.triggered.connect(lambda: self.add_to_steam(game_card.name, game_card.exec_line, game_card.cover_path))
menu.exec(game_card.mapToGlobal(pos))
def _check_portproton(self):
"""Check if PortProton is available."""
if self.portproton_location is None:
QMessageBox.warning(self.parent, _("Error"), _("PortProton is not found."))
return False
return True
def _get_desktop_path(self, game_name):
"""Construct the .desktop file path, trying both original and sanitized game names."""
desktop_path = os.path.join(self.portproton_location, f"{game_name}.desktop")
if not os.path.exists(desktop_path):
sanitized_name = game_name.replace("/", "_").replace(":", "_").replace(" ", "_")
desktop_path = os.path.join(self.portproton_location, f"{sanitized_name}.desktop")
return desktop_path
def _get_exec_line(self, game_name, exec_line):
"""Retrieve and validate exec_line from .desktop file if necessary."""
if exec_line and exec_line.strip() != "full":
return exec_line
desktop_path = self._get_desktop_path(game_name)
if os.path.exists(desktop_path):
try:
entry = parse_desktop_entry(desktop_path)
if entry:
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
if not exec_line:
QMessageBox.warning(
self.parent, _("Error"),
_("No executable command found in .desktop for game: {0}").format(game_name)
)
return None
else:
QMessageBox.warning(
self.parent, _("Error"),
_("Failed to parse .desktop file for game: {0}").format(game_name)
)
return None
except Exception as e:
QMessageBox.warning(
self.parent, _("Error"),
_("Error reading .desktop file: {0}").format(e)
)
return None
else:
# Fallback: Search all .desktop files
for file in glob.glob(os.path.join(self.portproton_location, "*.desktop")):
entry = parse_desktop_entry(file)
if entry:
exec_line = entry.get("Exec", entry.get("exec", "")).strip()
if exec_line:
return exec_line
QMessageBox.warning(
self.parent, _("Error"),
_(".desktop file not found for game: {0}").format(game_name)
)
return None
return exec_line
def _parse_exe_path(self, exec_line, game_name):
"""Parse the executable path from exec_line."""
try:
entry_exec_split = shlex.split(exec_line)
if not entry_exec_split:
QMessageBox.warning(
self.parent, _("Error"),
_("Invalid executable command: {0}").format(exec_line)
)
return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2]
elif entry_exec_split[0] == "flatpak" and len(entry_exec_split) >= 4:
exe_path = entry_exec_split[3]
else:
exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path):
QMessageBox.warning(
self.parent, _("Error"),
_("Executable file not found: {0}").format(exe_path or "None")
)
return None
return exe_path
except Exception as e:
QMessageBox.warning(
self.parent, _("Error"),
_("Failed to parse executable command: {0}").format(e)
)
return None
def _remove_file(self, file_path, error_message, success_message, game_name):
"""Remove a file and handle errors."""
try:
os.remove(file_path)
self.parent.statusBar().showMessage(success_message.format(game_name), 3000)
return True
except OSError as e:
QMessageBox.warning(self.parent, _("Error"), error_message.format(e))
return False
def delete_game(self, game_name, exec_line):
"""Delete the .desktop file and associated custom data for the game."""
reply = QMessageBox.question(
self.parent,
_("Confirm Deletion"),
_("Are you sure you want to delete '{0}'? This will remove the .desktop file and custom data.")
.format(game_name),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
if not self._check_portproton():
return
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("Error"),
_("Could not locate .desktop file for '{0}'").format(game_name)
)
return
# Get exec_line and parse exe_path
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
exe_name = os.path.splitext(os.path.basename(exe_path))[0] if exe_path else None
# Remove .desktop file
if not self._remove_file(
desktop_path,
_("Failed to delete .desktop file: {0}"),
_("Game '{0}' deleted successfully"),
game_name
):
return
# Remove custom data if we got an exe_name
if exe_name:
xdg_data_home = os.getenv(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name)
if os.path.exists(custom_folder):
try:
shutil.rmtree(custom_folder)
except OSError as e:
QMessageBox.warning(
self.parent, _("Error"),
_("Failed to delete custom data: {0}").format(e)
)
# Refresh UI
self.parent.games = self.load_games()
self.update_game_grid()
def add_to_menu(self, game_name, exec_line):
"""Copy the .desktop file to ~/.local/share/applications."""
if not self._check_portproton():
return
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("Error"),
_("Could not locate .desktop file for '{0}'").format(game_name)
)
return
# Destination path
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
os.makedirs(applications_dir, exist_ok=True)
dest_path = os.path.join(applications_dir, f"{game_name}.desktop")
# Copy .desktop file
try:
shutil.copyfile(desktop_path, dest_path)
os.chmod(dest_path, 0o755) # Ensure executable permissions
self.parent.statusBar().showMessage(_("Game '{0}' added to menu").format(game_name), 3000)
except OSError as e:
QMessageBox.warning(
self.parent, _("Error"),
_("Failed to add game to menu: {0}").format(str(e))
)
def remove_from_menu(self, game_name):
"""Remove the .desktop file from ~/.local/share/applications."""
applications_dir = os.path.join(os.path.expanduser("~"), ".local", "share", "applications")
desktop_path = os.path.join(applications_dir, f"{game_name}.desktop")
self._remove_file(
desktop_path,
_("Failed to remove game from menu: {0}"),
_("Game '{0}' removed from menu"),
game_name
)
def add_to_desktop(self, game_name, exec_line):
"""Copy the .desktop file to Desktop folder."""
if not self._check_portproton():
return
desktop_path = self._get_desktop_path(game_name)
if not os.path.exists(desktop_path):
QMessageBox.warning(
self.parent, _("Error"),
_("Could not locate .desktop file for '{0}'").format(game_name)
)
return
# Destination path
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
os.makedirs(desktop_dir, exist_ok=True)
dest_path = os.path.join(desktop_dir, f"{game_name}.desktop")
# Copy .desktop file
try:
shutil.copyfile(desktop_path, dest_path)
os.chmod(dest_path, 0o755) # Ensure executable permissions
self.parent.statusBar().showMessage(_("Game '{0}' added to desktop").format(game_name), 3000)
except OSError as e:
QMessageBox.warning(
self.parent, _("Error"),
_("Failed to add game to desktop: {0}").format(str(e))
)
def remove_from_desktop(self, game_name):
"""Remove the .desktop file from Desktop folder."""
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
desktop_path = os.path.join(desktop_dir, f"{game_name}.desktop")
self._remove_file(
desktop_path,
_("Failed to remove game from Desktop: {0}"),
_("Game '{0}' removed from Desktop"),
game_name
)
def edit_game_shortcut(self, game_name, exec_line, cover_path):
"""Opens the AddGameDialog in edit mode to modify an existing .desktop file."""
from portprotonqt.dialogs import AddGameDialog # Local import to avoid circular dependency
if not self._check_portproton():
return
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
# Open dialog in edit mode
dialog = AddGameDialog(
parent=self.parent,
theme=self.theme,
edit_mode=True,
game_name=game_name,
exe_path=exe_path,
cover_path=cover_path
)
if dialog.exec() == QDialog.DialogCode.Accepted:
new_name = dialog.nameEdit.text().strip()
new_exe_path = dialog.exeEdit.text().strip()
new_cover_path = dialog.coverEdit.text().strip()
if not new_name or not new_exe_path:
QMessageBox.warning(self.parent, _("Error"), _("Game name and executable path are required."))
return
# Generate new .desktop file content
desktop_entry, new_desktop_path = dialog.getDesktopEntryData()
if not desktop_entry or not new_desktop_path:
QMessageBox.warning(self.parent, _("Error"), _("Failed to generate .desktop file data."))
return
# If the name has changed, remove the old .desktop file
old_desktop_path = self._get_desktop_path(game_name)
if game_name != new_name and os.path.exists(old_desktop_path):
self._remove_file(
old_desktop_path,
_("Failed to remove old .desktop file: {0}"),
_("Old .desktop file removed for '{0}'"),
game_name
)
# Save the updated .desktop file
try:
with open(new_desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(new_desktop_path, 0o755)
except OSError as e:
QMessageBox.warning(self.parent, _("Error"), _("Failed to save .desktop file: {0}").format(e))
return
# Update custom cover if provided
if os.path.isfile(new_cover_path):
exe_name = os.path.splitext(os.path.basename(new_exe_path))[0]
xdg_data_home = os.getenv(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share")
)
custom_folder = os.path.join(xdg_data_home, "PortProtonQT", "custom_data", exe_name)
os.makedirs(custom_folder, exist_ok=True)
ext = os.path.splitext(new_cover_path)[1].lower()
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
try:
shutil.copyfile(new_cover_path, os.path.join(custom_folder, f"cover{ext}"))
except OSError as e:
QMessageBox.warning(self.parent, _("Error"), _("Failed to copy cover image: {0}").format(e))
return
# Refresh the game list
self.parent.games = self.load_games()
self.update_game_grid()
def add_to_steam(self, game_name, exec_line, cover_path):
"""Handle adding a non-Steam game to Steam via steam_api."""
if not self._check_portproton():
return
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
success, message = add_to_steam(game_name, exec_line, cover_path)
if success:
QMessageBox.information(
self.parent, _("Restart Steam"),
_("The game was added successfully.\nPlease restart Steam for changes to take effect.")
)
else:
QMessageBox.warning(self.parent, _("Error"), message)
def remove_from_steam(self, game_name, exec_line):
"""Handle removing a non-Steam game from Steam via steam_api."""
if not self._check_portproton():
return
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
success, message = remove_from_steam(game_name, exec_line)
if success:
QMessageBox.information(
self.parent, _("Restart Steam"),
_("The game was removed successfully.\nPlease restart Steam for changes to take effect.")
)
else:
QMessageBox.warning(self.parent, _("Error"), message)
def open_game_folder(self, game_name, exec_line):
"""Open the folder containing the game's executable."""
if not self._check_portproton():
return
exec_line = self._get_exec_line(game_name, exec_line)
if not exec_line:
return
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
try:
folder_path = os.path.dirname(os.path.abspath(exe_path))
QDesktopServices.openUrl(QUrl.fromLocalFile(folder_path))
self.parent.statusBar().showMessage(_("Opened folder for '{0}'").format(game_name), 3000)
except Exception as e:
QMessageBox.warning(self.parent, _("Error"), _("Failed to open game folder: {0}").format(str(e)))

View File

View File

@ -0,0 +1,393 @@
import numpy as np
from PySide6.QtWidgets import QLabel, QPushButton, QWidget, QLayout, QStyleOption, QLayoutItem
from PySide6.QtCore import Qt, Signal, QRect, QPoint, QSize
from PySide6.QtGui import QFont, QFontMetrics, QPainter
def compute_layout(nat_sizes, rect_width, spacing, max_scale):
"""
Вычисляет расположение элементов с учетом отступов и возможного увеличения карточек.
nat_sizes: массив (N, 2) с натуральными размерами элементов (ширина, высота).
rect_width: доступная ширина контейнера.
spacing: отступ между элементами.
max_scale: максимальный коэффициент масштабирования (например, 1.2).
Возвращает:
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
total_height: итоговая высота всех рядов.
"""
N = nat_sizes.shape[0]
result = np.zeros((N, 4), dtype=np.int32)
y = 0
i = 0
while i < N:
sum_width = 0
row_max_height = 0
count = 0
j = i
# Подбираем количество элементов для текущего ряда
while j < N:
w = nat_sizes[j, 0]
# Если уже есть хотя бы один элемент и следующий не помещается с учетом spacing, выходим
if count > 0 and (sum_width + spacing + w) > rect_width:
break
sum_width += w
count += 1
h = nat_sizes[j, 1]
if h > row_max_height:
row_max_height = h
j += 1
# Доступная ширина ряда с учетом обязательных отступов между элементами
available_width = rect_width - spacing * (count - 1)
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
# Разрешаем увеличение карточек, но не более max_scale
scale = desired_scale if desired_scale < max_scale else max_scale
# Выравниваем по левому краю (offset = 0)
x = 0
for k in range(i, j):
new_w = int(nat_sizes[k, 0] * scale)
new_h = int(nat_sizes[k, 1] * scale)
result[k, 0] = x
result[k, 1] = y
result[k, 2] = new_w
result[k, 3] = new_h
x += new_w + spacing
y += int(row_max_height * scale) + spacing
i = j
return result, y
class FlowLayout(QLayout):
def __init__(self, parent=None):
super().__init__(parent)
self.itemList = []
# Устанавливаем отступы контейнера в 0 и задаем spacing между карточками
self.setContentsMargins(0, 0, 0, 0)
self._spacing = 3 # отступ между карточками
self._max_scale = 1.2 # максимальное увеличение карточек (например, на 20%)
def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item)
def takeAt(self, index: int) -> QLayoutItem:
if 0 <= index < len(self.itemList):
return self.itemList.pop(index)
raise IndexError("Index out of range")
def count(self) -> int:
return len(self.itemList)
def itemAt(self, index: int) -> QLayoutItem | None:
if 0 <= index < len(self.itemList):
return self.itemList[index]
return None
def expandingDirections(self):
return Qt.Orientation(0)
def hasHeightForWidth(self):
return True
def heightForWidth(self, width):
return self.doLayout(QRect(0, 0, width, 0), True)
def setGeometry(self, rect):
super().setGeometry(rect)
self.doLayout(rect, False)
def sizeHint(self):
return self.minimumSize()
def minimumSize(self):
size = QSize()
for item in self.itemList:
size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins()
size += QSize(margins.left() + margins.right(),
margins.top() + margins.bottom())
return size
def doLayout(self, rect, testOnly):
N = len(self.itemList)
if N == 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)
if not testOnly:
for i, item in enumerate(self.itemList):
x = geom_array[i, 0] + rect.x()
y = geom_array[i, 1] + rect.y()
w = geom_array[i, 2]
h = geom_array[i, 3]
item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
return total_height
class ClickableLabel(QLabel):
clicked = Signal()
def __init__(self, *args, icon=None, icon_size=16, icon_space=5, **kwargs):
"""
Поддерживаются вызовы:
- ClickableLabel("текст", parent=...) первый аргумент строка,
- ClickableLabel(parent, text="...") если первым аргументом передается родитель.
Аргументы:
icon: QIcon или None иконка, которая будет отрисована вместе с текстом.
icon_size: int размер иконки (ширина и высота).
icon_space: int отступ между иконкой и текстом.
"""
if args and isinstance(args[0], str):
text = args[0]
parent = kwargs.get("parent", None)
super().__init__(text, parent)
elif args and isinstance(args[0], QWidget):
parent = args[0]
text = kwargs.get("text", "")
super().__init__(parent)
self.setText(text)
else:
text = ""
parent = kwargs.get("parent", None)
super().__init__(text, parent)
self._icon = icon
self._icon_size = icon_size
self._icon_space = icon_space
self.setCursor(Qt.CursorShape.PointingHandCursor)
def setIcon(self, icon):
"""Устанавливает иконку и перерисовывает виджет."""
self._icon = icon
self.update()
def icon(self):
"""Возвращает текущую иконку."""
return self._icon
def paintEvent(self, event):
"""Переопределяем отрисовку: рисуем иконку и текст в одном лейбле."""
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
rect = self.contentsRect()
alignment = self.alignment()
icon_size = self._icon_size
spacing = self._icon_space
icon_rect = QRect()
text_rect = QRect()
text = self.text()
if self._icon:
# Получаем QPixmap нужного размера
pixmap = self._icon.pixmap(icon_size, icon_size)
icon_rect = QRect(0, 0, icon_size, icon_size)
icon_rect.moveTop(rect.top() + (rect.height() - icon_size) // 2)
else:
pixmap = None
fm = QFontMetrics(self.font())
text_width = fm.horizontalAdvance(text)
text_height = fm.height()
total_width = text_width + (icon_size + spacing if pixmap else 0)
if alignment & Qt.AlignmentFlag.AlignHCenter:
x = rect.left() + (rect.width() - total_width) // 2
elif alignment & Qt.AlignmentFlag.AlignRight:
x = rect.right() - total_width
else:
x = rect.left()
y = rect.top() + (rect.height() - text_height) // 2
if pixmap:
icon_rect.moveLeft(x)
text_rect = QRect(x + icon_size + spacing, y, text_width, text_height)
else:
text_rect = QRect(x, y, text_width, text_height)
option = QStyleOption()
option.initFrom(self)
if pixmap:
painter.drawPixmap(icon_rect, pixmap)
self.style().drawItemText(
painter,
text_rect,
alignment,
self.palette(),
self.isEnabled(),
text,
self.foregroundRole(),
)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit()
event.accept()
else:
super().mousePressEvent(event)
class AutoSizeButton(QPushButton):
def __init__(self, *args, icon=None, icon_size=16,
min_font_size=6, max_font_size=14, padding=20, update_size=True, **kwargs):
if args and isinstance(args[0], str):
text = args[0]
parent = kwargs.get("parent", None)
super().__init__(text, parent)
elif args and isinstance(args[0], QWidget):
parent = args[0]
text = kwargs.get("text", "")
super().__init__(text, parent)
else:
text = ""
parent = kwargs.get("parent", None)
super().__init__(text, parent)
self._icon = icon
self._icon_size = icon_size
self._alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
self._min_font_size = min_font_size
self._max_font_size = max_font_size
self._padding = padding
self._update_size = update_size
self._original_font = self.font()
self._original_text = self.text()
if self._icon:
self.setIcon(self._icon)
self.setIconSize(QSize(self._icon_size, self._icon_size))
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setFlat(True)
# Изначально выставляем минимальную ширину
self.setMinimumWidth(50)
self.adjustFontSize()
def setAlignment(self, alignment):
self._alignment = alignment
self.update()
def alignment(self):
return self._alignment
def setText(self, text):
self._original_text = text
if not self._update_size:
super().setText(text)
else:
super().setText(text)
self.adjustFontSize()
def resizeEvent(self, event):
super().resizeEvent(event)
if self._update_size:
self.adjustFontSize()
def adjustFontSize(self):
if not self._original_text:
return
if not self._update_size:
return
# Определяем доступную ширину внутри кнопки
available_width = self.width()
if self._icon:
available_width -= self._icon_size
margins = self.contentsMargins()
available_width -= (margins.left() + margins.right() + self._padding * 2)
font = QFont(self._original_font)
text = self._original_text
# Подбираем максимально возможный размер шрифта, при котором текст укладывается
chosen_size = self._max_font_size
for font_size in range(self._max_font_size, self._min_font_size - 1, -1):
font.setPointSize(font_size)
fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(text)
if text_width <= available_width:
chosen_size = font_size
break
font.setPointSize(chosen_size)
self.setFont(font)
# После выбора шрифта вычисляем требуемую ширину для полного отображения текста
fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(text)
required_width = text_width + margins.left() + margins.right() + self._padding * 2
if self._icon:
required_width += self._icon_size
# Если текущая ширина меньше требуемой, обновляем минимальную ширину
if self.width() < required_width:
self.setMinimumWidth(required_width)
super().setText(text)
def sizeHint(self):
if not self._update_size:
return super().sizeHint()
else:
# Вычисляем оптимальный размер кнопки на основе текста и отступов
font = self.font()
fm = QFontMetrics(font)
text_width = fm.horizontalAdvance(self._original_text)
margins = self.contentsMargins()
width = text_width + margins.left() + margins.right() + self._padding * 2
if self._icon:
width += self._icon_size
height = fm.height() + margins.top() + margins.bottom() + self._padding
return QSize(width, height)
class NavLabel(QLabel):
clicked = Signal()
def __init__(self, text="", parent=None):
super().__init__(text, parent)
self.setWordWrap(True)
self.setAlignment(Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter)
self._checkable = False
self._isChecked = False
self.setProperty("checked", self._isChecked)
self.setCursor(Qt.CursorShape.PointingHandCursor)
# Explicitly enable focus
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
def setCheckable(self, checkable):
self._checkable = checkable
def setChecked(self, checked):
if self._checkable:
self._isChecked = checked
self.setProperty("checked", checked)
self.style().unpolish(self)
self.style().polish(self)
self.update()
def isChecked(self):
return self._isChecked
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
# Ensure widget can take focus on click
self.setFocus(Qt.FocusReason.MouseFocusReason)
if self._checkable:
self.setChecked(not self._isChecked)
self.clicked.emit()
event.accept()
else:
super().mousePressEvent(event)

252
portprotonqt/dialogs.py Normal file
View File

@ -0,0 +1,252 @@
import os
import shutil
import tempfile
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import (
QDialog, QLineEdit, QFormLayout, QPushButton,
QHBoxLayout, QDialogButtonBox, QFileDialog, QLabel
)
from PySide6.QtCore import Qt
from icoextract import IconExtractor, IconExtractorError
from PIL import Image
from portprotonqt.config_utils import get_portproton_location
from portprotonqt.localization import _
from portprotonqt.logger import get_logger
import portprotonqt.themes.standart.styles as default_styles
logger = get_logger(__name__)
def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
"""
Generates a thumbnail for an .exe file.
inputfile: the input file path (%i)
outfile: output filename (%o)
size: determines the thumbnail output size (%s)
"""
logger.debug(f"Начинаем генерацию миниатюры: {inputfile}{outfile}, размер={size}, принудительно={force_resize}")
try:
extractor = IconExtractor(inputfile)
logger.debug("IconExtractor успешно создан.")
except (RuntimeError, IconExtractorError) as e:
logger.warning(f"Не удалось создать IconExtractor: {e}")
return False
try:
data = extractor.get_icon()
im = Image.open(data)
logger.debug(f"Извлечена иконка размером {im.size}, форматы: {im.format}, кадры: {getattr(im, 'n_frames', 1)}")
except Exception as e:
logger.warning(f"Ошибка при извлечении иконки: {e}")
return False
if force_resize:
logger.debug(f"Принудительное изменение размера иконки на {size}x{size}")
im = im.resize((size, size))
else:
if size > 256:
logger.warning('Запрошен размер больше 256, установлен 256')
size = 256
elif size not in (128, 256):
logger.warning(f'Неподдерживаемый размер {size}, установлен 128')
size = 128
if size == 256:
logger.debug("Сохраняем иконку без изменения размера (256x256)")
im.save(outfile, "PNG")
logger.info(f"Иконка сохранена в {outfile}")
return True
frames = getattr(im, 'n_frames', 1)
try:
for frame in range(frames):
im.seek(frame)
if im.size == (size, size):
logger.debug(f"Найден кадр с размером {size}x{size}")
break
except EOFError:
logger.debug("Кадры закончились до нахождения нужного размера.")
if im.size != (size, size):
logger.debug(f"Изменение размера с {im.size} на {size}x{size}")
im = im.resize((size, size))
try:
im.save(outfile, "PNG")
logger.info(f"Миниатюра успешно сохранена в {outfile}")
return True
except Exception as e:
logger.error(f"Ошибка при сохранении миниатюры: {e}")
return False
class AddGameDialog(QDialog):
def __init__(self, parent=None, theme=None, edit_mode=False, game_name=None, exe_path=None, cover_path=None):
super().__init__(parent)
self.theme = theme if theme else default_styles
self.edit_mode = edit_mode
self.original_name = game_name
self.setWindowTitle(_("Edit Game") if edit_mode else _("Add Game"))
self.setModal(True)
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
layout = QFormLayout(self)
# Game name
self.nameEdit = QLineEdit(self)
self.nameEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
if game_name:
self.nameEdit.setText(game_name)
name_label = QLabel(_("Game Name:"))
name_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
layout.addRow(name_label, self.nameEdit)
# Exe path
self.exeEdit = QLineEdit(self)
self.exeEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
if exe_path:
self.exeEdit.setText(exe_path)
exeBrowseButton = QPushButton(_("Browse..."), self)
exeBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
exeBrowseButton.clicked.connect(self.browseExe)
exeLayout = QHBoxLayout()
exeLayout.addWidget(self.exeEdit)
exeLayout.addWidget(exeBrowseButton)
exe_label = QLabel(_("Path to Executable:"))
exe_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
layout.addRow(exe_label, exeLayout)
# Cover path
self.coverEdit = QLineEdit(self)
self.coverEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE + " QLineEdit { color: #ffffff; font-size: 14px; }")
if cover_path:
self.coverEdit.setText(cover_path)
coverBrowseButton = QPushButton(_("Browse..."), self)
coverBrowseButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
coverBrowseButton.clicked.connect(self.browseCover)
coverLayout = QHBoxLayout()
coverLayout.addWidget(self.coverEdit)
coverLayout.addWidget(coverBrowseButton)
cover_label = QLabel(_("Custom Cover:"))
cover_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
layout.addRow(cover_label, coverLayout)
# Preview
self.coverPreview = QLabel(self)
self.coverPreview.setStyleSheet(self.theme.CONTENT_STYLE + " QLabel { color: #ffffff; }")
preview_label = QLabel(_("Cover Preview:"))
preview_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE + " QLabel { color: #ffffff; font-size: 14px; font-weight: bold; }")
layout.addRow(preview_label, self.coverPreview)
# Dialog buttons
buttonBox = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
buttonBox.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
buttonBox.accepted.connect(self.accept)
buttonBox.rejected.connect(self.reject)
layout.addRow(buttonBox)
self.coverEdit.textChanged.connect(self.updatePreview)
self.exeEdit.textChanged.connect(self.updatePreview)
if edit_mode:
self.updatePreview()
def browseExe(self):
fileNameAndFilter = QFileDialog.getOpenFileName(
self,
_("Select Executable"),
"",
"Windows Executables (*.exe)"
)
fileName = fileNameAndFilter[0]
if fileName:
self.exeEdit.setText(fileName)
if not self.edit_mode:
self.nameEdit.setText(os.path.splitext(os.path.basename(fileName))[0])
def browseCover(self):
fileNameAndFilter = QFileDialog.getOpenFileName(
self,
_("Select Cover Image"),
"",
"Images (*.png *.jpg *.jpeg *.bmp)"
)
fileName = fileNameAndFilter[0]
if fileName:
self.coverEdit.setText(fileName)
def updatePreview(self):
"""Update the cover preview image."""
cover_path = self.coverEdit.text().strip()
exe_path = self.exeEdit.text().strip()
if cover_path and os.path.isfile(cover_path):
pixmap = QPixmap(cover_path)
if not pixmap.isNull():
self.coverPreview.setPixmap(pixmap.scaled(250, 250, Qt.AspectRatioMode.KeepAspectRatio))
else:
self.coverPreview.setText(_("Invalid image"))
elif os.path.isfile(exe_path):
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
if generate_thumbnail(exe_path, tmp.name, size=128):
pixmap = QPixmap(tmp.name)
self.coverPreview.setPixmap(pixmap)
os.unlink(tmp.name)
else:
self.coverPreview.setText(_("No cover selected"))
def getDesktopEntryData(self):
"""Returns the .desktop content and save path"""
exe_path = self.exeEdit.text().strip()
name = self.nameEdit.text().strip()
if not exe_path or not name:
return None, None
portproton_path = get_portproton_location()
if portproton_path is None:
return None, None
is_flatpak = ".var" in portproton_path
base_path = os.path.join(portproton_path, "data")
if is_flatpak:
exec_str = f'flatpak run ru.linux_gaming.PortProton "{exe_path}"'
else:
start_sh = os.path.join(base_path, "scripts", "start.sh")
exec_str = f'env "{start_sh}" "{exe_path}"'
icon_path = os.path.join(base_path, "img", f"{name}.png")
desktop_path = os.path.join(portproton_path, f"{name}.desktop")
working_dir = os.path.join(base_path, "scripts")
user_cover_path = self.coverEdit.text().strip()
if os.path.isfile(user_cover_path):
shutil.copy(user_cover_path, icon_path)
else:
os.makedirs(os.path.dirname(icon_path), exist_ok=True)
os.system(f'exe-thumbnailer "{exe_path}" "{icon_path}"')
comment = _('Launch game "{name}" with PortProton').format(name=name)
desktop_entry = f"""[Desktop Entry]
Name={name}
Comment={comment}
Exec={exec_str}
Terminal=false
Type=Application
Categories=Game;
StartupNotify=true
Path={working_dir}
Icon={icon_path}
"""
return desktop_entry, desktop_path

310
portprotonqt/downloader.py Normal file
View File

@ -0,0 +1,310 @@
from PySide6.QtCore import QObject, Signal, QThread
import threading
import os
import requests
import orjson
import socket
from pathlib import Path
from tqdm import tqdm
from collections.abc import Callable
from portprotonqt.config_utils import read_proxy_config
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def get_requests_session():
session = requests.Session()
proxy = read_proxy_config() or {}
if proxy:
session.proxies.update(proxy)
session.verify = True
return session
def download_with_cache(url, local_path, timeout=5, downloader_instance=None):
if os.path.exists(local_path):
return local_path
session = get_requests_session()
try:
with session.get(url, stream=True, timeout=timeout) as response:
response.raise_for_status()
total_size = int(response.headers.get('Content-Length', 0))
os.makedirs(os.path.dirname(local_path), exist_ok=True)
desc = Path(local_path).name
with tqdm(total=total_size if total_size > 0 else None,
unit='B', unit_scale=True, unit_divisor=1024,
desc=f"Downloading {desc}", ascii=True) as pbar:
with open(local_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
pbar.update(len(chunk))
return local_path
except Exception as e:
logger.error(f"Ошибка загрузки {url}: {e}")
if downloader_instance and hasattr(downloader_instance, '_last_error'):
downloader_instance._last_error[url] = True
if os.path.exists(local_path):
os.remove(local_path)
return None
def download_with_parallel(urls, local_paths, max_workers=4, timeout=5, downloader_instance=None):
from concurrent.futures import ThreadPoolExecutor, as_completed
results = {}
session = get_requests_session()
def _download_one(url, local_path):
if os.path.exists(local_path):
return local_path
try:
with session.get(url, stream=True, timeout=timeout) as response:
response.raise_for_status()
total_size = int(response.headers.get('Content-Length', 0))
os.makedirs(os.path.dirname(local_path), exist_ok=True)
desc = Path(local_path).name
with tqdm(total=total_size if total_size > 0 else None,
unit='B', unit_scale=True, unit_divisor=1024,
desc=f"Downloading {desc}", ascii=True) as pbar:
with open(local_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
pbar.update(len(chunk))
return local_path
except Exception as e:
logger.error(f"Ошибка загрузки {url}: {e}")
if downloader_instance and hasattr(downloader_instance, '_last_error'):
downloader_instance._last_error[url] = True
if os.path.exists(local_path):
os.remove(local_path)
return None
with ThreadPoolExecutor(max_workers=max_workers) as executor:
future_to_url = {executor.submit(_download_one, url, local_path): url for url, local_path in zip(urls, local_paths, strict=False)}
for future in tqdm(as_completed(future_to_url), total=len(urls), desc="Downloading in parallel", ascii=True):
url = future_to_url[future]
try:
res = future.result()
results[url] = res
except Exception as e:
logger.error(f"Ошибка при загрузке {url}: {e}")
results[url] = None
return results
class Downloader(QObject):
download_completed = Signal(str, str, bool) # url, local_path, success
def __init__(self, max_workers=4):
super().__init__()
self.max_workers = max_workers
self._cache = {}
self._last_error = {}
self._locks = {}
self._active_threads: list[QThread] = []
self._global_lock = threading.Lock()
self._has_internet = None
def has_internet(self, timeout=3):
if self._has_internet is None:
errors = []
try:
socket.create_connection(("8.8.8.8", 53), timeout=timeout)
except Exception as e:
errors.append(f"8.8.8.8: {e}")
try:
socket.create_connection(("8.8.4.4", 53), timeout=timeout)
except Exception as e:
errors.append(f"8.8.4.4: {e}")
try:
requests.get("https://www.google.com", timeout=timeout)
except Exception as e:
errors.append(f"google.com: {e}")
if errors:
logger.warning("Интернет недоступен:\n" + "\n".join(errors))
self._has_internet = False
else:
self._has_internet = True
return self._has_internet
def reset_internet_check(self):
self._has_internet = None
def _get_url_lock(self, url):
with self._global_lock:
if url not in self._locks:
self._locks[url] = threading.Lock()
return self._locks[url]
def download(self, url, local_path, timeout=5):
if not self.has_internet():
logger.warning(f"Нет интернета, пропускаем загрузку {url}")
return None
with self._global_lock:
if url in self._last_error:
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
return None
if url in self._cache:
return self._cache[url]
url_lock = self._get_url_lock(url)
with url_lock:
with self._global_lock:
if url in self._last_error:
return None
if url in self._cache:
return self._cache[url]
result = download_with_cache(url, local_path, timeout, self)
with self._global_lock:
if result:
self._cache[url] = result
if url in self._locks:
del self._locks[url]
return result
def download_parallel(self, urls, local_paths, timeout=5):
if not self.has_internet():
logger.warning("Нет интернета, пропускаем параллельную загрузку")
return dict.fromkeys(urls)
filtered_urls = []
filtered_paths = []
with self._global_lock:
for url, path in zip(urls, local_paths, strict=False):
if url in self._last_error:
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
continue
if url in self._cache:
continue
filtered_urls.append(url)
filtered_paths.append(path)
results = download_with_parallel(filtered_urls, filtered_paths, max_workers=self.max_workers, timeout=timeout, downloader_instance=self)
with self._global_lock:
for url, path in results.items():
if path:
self._cache[url] = path
# Для URL которые были пропущены, добавляем их из кэша или None
final_results = {}
with self._global_lock:
for url in urls:
if url in self._cache:
final_results[url] = self._cache[url]
else:
final_results[url] = None
return final_results
def download_async(self, url: str, local_path: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None, parallel: bool = False) -> QThread:
class DownloadThread(QThread):
def __init__(self, downloader: 'Downloader', url: str, local_path: str, timeout: int, parallel: bool):
super().__init__()
self.downloader = downloader
self.url = url
self.local_path = local_path
self.timeout = timeout
self.parallel = parallel
def run(self):
try:
if self.parallel:
results = self.downloader.download_parallel([self.url], [self.local_path], timeout=self.timeout)
result = results.get(self.url, None)
else:
result = self.downloader.download(self.url, self.local_path, self.timeout)
success = result is not None
logger.debug(f"Async download completed {self.url}: success={success}, path={result or ''}")
self.downloader.download_completed.emit(self.url, result or "", success)
if callback:
callback(result)
except Exception as e:
logger.error(f"Ошибка при асинхронной загрузке {self.url}: {e}")
self.downloader.download_completed.emit(self.url, "", False)
if callback:
callback(None)
thread = DownloadThread(self, url, local_path, timeout, parallel)
thread.finished.connect(thread.deleteLater)
# Удалить из списка после завершения
def cleanup():
self._active_threads.remove(thread)
thread.finished.connect(cleanup)
self._active_threads.append(thread) # Сохраняем поток, чтобы не уничтожился досрочно
logger.debug(f"Запуск потока для асинхронной загрузки {url}")
thread.start()
return thread
def clear_cache(self):
with self._global_lock:
self._cache.clear()
def is_cached(self, url):
with self._global_lock:
return url in self._cache
def get_latest_legendary_release(self):
"""Get the latest legendary release info from GitHub API."""
try:
api_url = "https://api.github.com/repos/derrod/legendary/releases/latest"
response = requests.get(api_url, timeout=10)
response.raise_for_status()
release_data = orjson.loads(response.content)
# Find the Linux binary asset
for asset in release_data.get('assets', []):
if asset['name'] == 'legendary' and 'linux' in asset.get('content_type', '').lower():
return {
'version': release_data['tag_name'],
'download_url': asset['browser_download_url'],
'size': asset['size']
}
# Fallback: look for asset named just "legendary"
for asset in release_data.get('assets', []):
if asset['name'] == 'legendary':
return {
'version': release_data['tag_name'],
'download_url': asset['browser_download_url'],
'size': asset['size']
}
logger.warning("Could not find legendary binary in latest release assets")
return None
except requests.RequestException as e:
logger.error(f"Failed to fetch latest legendary release info: {e}")
return None
except (KeyError, orjson.JSONDecodeError) as e:
logger.error(f"Failed to parse legendary release info: {e}")
return None
def download_legendary_binary(self, callback: Callable[[str | None], None] | None = None):
"""Download the latest legendary binary for Linux from GitHub releases."""
if not self.has_internet():
logger.warning("No internet connection, skipping legendary binary download")
if callback:
callback(None)
return None
# Get latest release info
latest_release = self.get_latest_legendary_release()
if not latest_release:
logger.error("Could not determine latest legendary version, falling back to hardcoded version")
# Fallback to hardcoded version
binary_url = "https://github.com/derrod/legendary/releases/download/0.20.34/legendary"
version = "0.20.34"
else:
binary_url = latest_release['download_url']
version = latest_release['version']
logger.info(f"Found latest legendary version: {version}")
local_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQT", "legendary_cache", "legendary"
)
logger.info(f"Downloading legendary binary version {version} from {binary_url} to {local_path}")
return self.download_async(binary_url, local_path, timeout=5, callback=callback)

373
portprotonqt/egs_api.py Normal file
View File

@ -0,0 +1,373 @@
import requests
import threading
import orjson
from pathlib import Path
import time
import subprocess
import os
from concurrent.futures import ThreadPoolExecutor
from collections.abc import Callable
from portprotonqt.localization import get_egs_language, _
from portprotonqt.logger import get_logger
from portprotonqt.image_utils import load_pixmap_async
from PySide6.QtGui import QPixmap
logger = get_logger(__name__)
def get_cache_dir() -> Path:
"""Returns the path to the cache directory, creating it if necessary."""
xdg_cache_home = os.getenv(
"XDG_CACHE_HOME",
os.path.join(os.path.expanduser("~"), ".cache")
)
cache_dir = Path(xdg_cache_home) / "PortProtonQT"
cache_dir.mkdir(parents=True, exist_ok=True)
return cache_dir
def get_egs_game_description_async(
app_name: str,
callback: Callable[[str], None],
cache_ttl: int = 3600
) -> None:
"""
Asynchronously fetches the game description from the Epic Games Store API.
Uses per-app cache files named egs_app_{app_name}.json in ~/.cache/PortProtonQT.
Checks the cache first; if the description is cached and not expired, returns it.
Prioritizes the page with type 'productHome' for the base game description.
"""
cache_dir = get_cache_dir()
cache_file = cache_dir / f"egs_app_{app_name.lower().replace(':', '_').replace(' ', '_')}.json"
# Initialize content to avoid unbound variable
content = b""
# Load existing cache
if cache_file.exists():
try:
with open(cache_file, "rb") as f:
content = f.read()
cached_entry = orjson.loads(content)
if not isinstance(cached_entry, dict):
logger.warning(
"Invalid cache format in %s: expected dict, got %s",
cache_file,
type(cached_entry)
)
cache_file.unlink(missing_ok=True)
else:
cached_time = cached_entry.get("timestamp", 0)
if time.time() - cached_time < cache_ttl:
description = cached_entry.get("description", "")
logger.debug(
"Using cached description for %s: %s",
app_name,
(description[:100] + "...") if len(description) > 100 else description
)
callback(description)
return
except orjson.JSONDecodeError as e:
logger.warning(
"Failed to parse description cache for %s: %s",
app_name,
str(e)
)
logger.debug(
"Cache file content (first 100 chars): %s",
content[:100].decode('utf-8', errors='replace')
)
cache_file.unlink(missing_ok=True)
except Exception as e:
logger.error(
"Unexpected error reading description cache for %s: %s",
app_name,
str(e)
)
cache_file.unlink(missing_ok=True)
lang = get_egs_language()
slug = app_name.lower().replace(":", "").replace(" ", "-")
url = f"https://store-content.ak.epicgames.com/api/{lang}/content/products/{slug}"
def fetch_description():
try:
response = requests.get(url, timeout=5)
response.raise_for_status()
data = orjson.loads(response.content)
if not isinstance(data, dict):
logger.warning("Invalid JSON structure for %s: %s", app_name, type(data))
callback("")
return
description = ""
pages = data.get("pages", [])
if pages:
# Look for the page with type "productHome" for the base game
for page in pages:
if page.get("type") == "productHome":
about_data = page.get("data", {}).get("about", {})
description = about_data.get("shortDescription", "")
break
else:
# Fallback to first page's description if no productHome is found
description = (
pages[0].get("data", {})
.get("about", {})
.get("shortDescription", "")
)
if not description:
logger.warning("No valid description found for %s", app_name)
logger.debug(
"Fetched EGS description for %s: %s",
app_name,
(description[:100] + "...") if len(description) > 100 else description
)
cache_entry = {"description": description, "timestamp": time.time()}
try:
temp_file = cache_file.with_suffix('.tmp')
with open(temp_file, "wb") as f:
f.write(orjson.dumps(cache_entry))
temp_file.replace(cache_file)
logger.debug(
"Saved description to cache for %s", app_name
)
except Exception as e:
logger.error(
"Failed to save description cache for %s: %s",
app_name,
str(e)
)
callback(description)
except requests.RequestException as e:
logger.warning(
"Failed to fetch EGS description for %s: %s",
app_name,
str(e)
)
callback("")
except orjson.JSONDecodeError:
logger.warning(
"Invalid JSON response for %s", app_name
)
callback("")
except Exception as e:
logger.error(
"Unexpected error fetching EGS description for %s: %s",
app_name,
str(e)
)
callback("")
thread = threading.Thread(
target=fetch_description,
daemon=True
)
thread.start()
def run_legendary_list_async(legendary_path: str, callback: Callable[[list | None], None]):
"""
Асинхронно выполняет команду 'legendary list --json' и возвращает результат через callback.
"""
def execute_command():
process = None
try:
process = subprocess.Popen(
[legendary_path, "list", "--json"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=False
)
stdout, stderr = process.communicate(timeout=30)
if process.returncode != 0:
logger.error("Legendary list command failed: %s", stderr.decode('utf-8', errors='replace'))
callback(None)
return
try:
result = orjson.loads(stdout)
if not isinstance(result, list):
logger.error("Invalid legendary output format: expected list, got %s", type(result))
callback(None)
return
callback(result)
except orjson.JSONDecodeError as e:
logger.error("Failed to parse JSON output from legendary list: %s", str(e))
callback(None)
except subprocess.TimeoutExpired:
logger.error("Legendary list command timed out")
if process:
process.kill()
callback(None)
except FileNotFoundError:
logger.error("Legendary executable not found at %s", legendary_path)
callback(None)
except Exception as e:
logger.error("Unexpected error executing legendary list: %s", str(e))
callback(None)
threading.Thread(target=execute_command, daemon=True).start()
def load_egs_games_async(legendary_path: str, callback: Callable[[list[tuple]], None], downloader, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
"""
Асинхронно загружает Epic Games Store игры с использованием legendary CLI.
"""
logger.debug("Starting to load Epic Games Store games")
games: list[tuple] = []
cache_dir = Path(os.path.dirname(legendary_path))
metadata_dir = cache_dir / "metadata"
cache_file = cache_dir / "legendary_games.json"
cache_ttl = 3600 # Cache TTL in seconds (1 hour)
if not os.path.exists(legendary_path):
logger.info("Legendary binary not found, downloading...")
def on_legendary_downloaded(result):
if result:
logger.info("Legendary binary downloaded successfully")
try:
os.chmod(legendary_path, 0o755)
except Exception as e:
logger.error(f"Failed to make legendary binary executable: {e}")
callback(games) # Return empty games list on failure
return
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
else:
logger.error("Failed to download legendary binary")
callback(games) # Return empty games list on failure
try:
downloader.download_legendary_binary(on_legendary_downloaded)
except Exception as e:
logger.error(f"Error initiating legendary binary download: {e}")
callback(games)
return
else:
_continue_loading_egs_games(legendary_path, callback, metadata_dir, cache_dir, cache_file, cache_ttl, update_progress, update_status_message)
def _continue_loading_egs_games(legendary_path: str, callback: Callable[[list[tuple]], None], metadata_dir: Path, cache_dir: Path, cache_file: Path, cache_ttl: int, update_progress: Callable[[int], None], update_status_message: Callable[[str, int], None]):
"""
Продолжает процесс загрузки EGS игр, либо из кэша, либо через legendary CLI.
"""
games: list[tuple] = []
cache_dir.mkdir(parents=True, exist_ok=True)
def process_games(installed_games: list | None):
if installed_games is None:
logger.info("No installed Epic Games Store games found")
callback(games)
return
# Сохраняем в кэш
try:
with open(cache_file, "wb") as f:
f.write(orjson.dumps(installed_games))
logger.debug("Saved Epic Games Store games to cache: %s", cache_file)
except Exception as e:
logger.error("Failed to save cache: %s", str(e))
# Фильтруем игры
valid_games = [game for game in installed_games if isinstance(game, dict) and game.get("app_name") and not game.get("is_dlc", False)]
if len(valid_games) != len(installed_games):
logger.warning("Filtered out %d invalid game records", len(installed_games) - len(valid_games))
if not valid_games:
logger.info("No valid Epic Games Store games found after filtering")
callback(games)
return
pending_images = len(valid_games)
total_games = len(valid_games)
update_progress(0)
update_status_message(_("Loading Epic Games Store games..."), 3000)
game_results: dict[int, tuple] = {}
results_lock = threading.Lock()
def process_game_metadata(game, index):
nonlocal pending_images
app_name = game.get("app_name", "")
title = game.get("app_title", app_name)
if not app_name:
with results_lock:
pending_images -= 1
update_progress(total_games - pending_images)
if pending_images == 0:
final_games = [game_results[i] for i in sorted(game_results.keys())]
callback(final_games)
return
metadata_file = metadata_dir / f"{app_name}.json"
cover_url = ""
try:
with open(metadata_file, "rb") as f:
metadata = orjson.loads(f.read())
key_images = metadata.get("metadata", {}).get("keyImages", [])
for img in key_images:
if isinstance(img, dict) and img.get("type") in ["DieselGameBoxTall", "Thumbnail"]:
cover_url = img.get("url", "")
break
except Exception as e:
logger.warning("Error processing metadata for %s: %s", app_name, str(e))
image_folder = os.path.join(os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")), "PortProtonQT", "images")
local_path = os.path.join(image_folder, f"{app_name}.jpg") if cover_url else ""
def on_description_fetched(api_description: str):
final_description = api_description or _("No description available")
def on_cover_loaded(pixmap: QPixmap):
from portprotonqt.steam_api import get_weanticheatyet_status_async
def on_anticheat_status(status: str):
nonlocal pending_images
with results_lock:
game_results[index] = (
title,
final_description,
local_path if os.path.exists(local_path) else "",
app_name,
f"legendary:launch:{app_name}",
"",
_("Never"),
"",
"",
status or "",
0,
0,
"epic"
)
pending_images -= 1
update_progress(total_games - pending_images)
if pending_images == 0:
final_games = [game_results[i] for i in sorted(game_results.keys())]
callback(final_games)
get_weanticheatyet_status_async(title, on_anticheat_status)
load_pixmap_async(cover_url, 600, 900, on_cover_loaded, app_name=app_name)
get_egs_game_description_async(title, on_description_fetched)
max_workers = min(4, len(valid_games))
with ThreadPoolExecutor(max_workers=max_workers) as executor:
for i, game in enumerate(valid_games):
executor.submit(process_game_metadata, game, i)
# Проверяем кэш
use_cache = False
if cache_file.exists():
try:
cache_mtime = cache_file.stat().st_mtime
if time.time() - cache_mtime < cache_ttl and metadata_dir.exists() and any(metadata_dir.iterdir()):
logger.debug("Loading Epic Games Store games from cache: %s", cache_file)
with open(cache_file, "rb") as f:
installed_games = orjson.loads(f.read())
if not isinstance(installed_games, list):
logger.warning("Invalid cache format: expected list, got %s", type(installed_games))
else:
use_cache = True
process_games(installed_games)
except Exception as e:
logger.error("Error reading cache: %s", str(e))
if not use_cache:
logger.info("Fetching Epic Games Store games using legendary list")
run_legendary_list_async(legendary_path, process_games)

473
portprotonqt/game_card.py Normal file
View File

@ -0,0 +1,473 @@
from PySide6.QtGui import QPainter, QPen, QColor, QConicalGradient, QBrush, QDesktopServices
from PySide6.QtCore import QEasingCurve, Signal, Property, Qt, QPropertyAnimation, QByteArray, QUrl
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.image_utils import load_pixmap_async, round_corners
from portprotonqt.localization import _
from portprotonqt.config_utils import read_favorites, save_favorites
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.custom_widgets import ClickableLabel
import weakref
from typing import cast
class GameCard(QFrame):
borderWidthChanged = Signal()
gradientAngleChanged = Signal()
# Signals for context menu actions
editShortcutRequested = Signal(str, str, str) # name, exec_line, cover_path
deleteGameRequested = Signal(str, str) # name, exec_line
addToMenuRequested = Signal(str, str) # name, exec_line
removeFromMenuRequested = Signal(str) # name
addToDesktopRequested = Signal(str, str) # name, exec_line
removeFromDesktopRequested = Signal(str) # name
addToSteamRequested = Signal(str, str, str) # name, exec_line, cover_path
removeFromSteamRequested = Signal(str, str) # name, exec_line
openGameFolderRequested = Signal(str, str) # name, exec_line
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, steam_game,
select_callback, theme=None, card_width=250, parent=None, context_menu_manager=None):
super().__init__(parent)
self.name = name
self.description = description
self.cover_path = cover_path
self.appid = appid
self.controller_support = controller_support
self.exec_line = exec_line
self.last_launch = last_launch
self.formatted_playtime = formatted_playtime
self.protondb_tier = protondb_tier
self.anticheat_status = anticheat_status
self.steam_game = steam_game
self.last_launch_ts = last_launch_ts
self.playtime_seconds = playtime_seconds
self.select_callback = select_callback
self.context_menu_manager = context_menu_manager
self.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_context_menu)
self.theme_manager = ThemeManager()
self.theme = theme if theme is not None else default_styles
self.current_theme_name = read_theme_from_config()
# Дополнительное пространство для анимации
extra_margin = 20
self.setFixedSize(card_width + extra_margin, int(card_width * 1.6) + extra_margin)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setStyleSheet(self.theme.GAME_CARD_WINDOW_STYLE)
# Параметры анимации обводки
self._borderWidth = 2
self._gradientAngle = 0.0
self._hovered = False
self._focused = False
# Анимации
self.thickness_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
self.thickness_anim.setDuration(300)
self.gradient_anim = None
self.pulse_anim = None
# Флаг для отслеживания подключения слота startPulseAnimation
self._isPulseAnimationConnected = False
# Тень
shadow = QGraphicsDropShadowEffect(self)
shadow.setBlurRadius(20)
shadow.setColor(QColor(0, 0, 0, 150))
shadow.setOffset(0, 0)
self.setGraphicsEffect(shadow)
# Отступы
layout = QVBoxLayout(self)
layout.setContentsMargins(extra_margin // 2, extra_margin // 2, extra_margin // 2, extra_margin // 2)
layout.setSpacing(5)
# Контейнер обложки
coverWidget = QWidget()
coverWidget.setFixedSize(card_width, int(card_width * 1.2))
coverLayout = QStackedLayout(coverWidget)
coverLayout.setContentsMargins(0, 0, 0, 0)
coverLayout.setStackingMode(QStackedLayout.StackingMode.StackAll)
# Обложка
self.coverLabel = QLabel()
self.coverLabel.setFixedSize(card_width, int(card_width * 1.2))
self.coverLabel.setStyleSheet(self.theme.COVER_LABEL_STYLE)
coverLayout.addWidget(self.coverLabel)
# создаём слабую ссылку на label
label_ref = weakref.ref(self.coverLabel)
def on_cover_loaded(pixmap):
label = label_ref()
if label is None:
# QLabel уже удалён — ничего не делаем
return
label.setPixmap(round_corners(pixmap, 15))
# асинхронная загрузка обложки (пустая строка даст placeholder внутри load_pixmap_async)
load_pixmap_async(cover_path or "", card_width, int(card_width * 1.2), on_cover_loaded)
# Значок избранного (звёздочка) в левом верхнем углу обложки
self.favoriteLabel = ClickableLabel(coverWidget)
self.favoriteLabel.setFixedSize(*self.theme.favoriteLabelSize)
self.favoriteLabel.move(8, 8)
self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in read_favorites()
self.update_favorite_icon()
self.favoriteLabel.raise_()
# ProtonDB бейдж
tier_text = self.getProtonDBText(protondb_tier)
if tier_text:
icon_filename = self.getProtonDBIconFilename(protondb_tier)
icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name)
self.protondbLabel = ClickableLabel(
tier_text,
icon=icon,
parent=coverWidget,
icon_size=16,
icon_space=3,
)
self.protondbLabel.setStyleSheet(self.theme.get_protondb_badge_style(protondb_tier))
self.protondbLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
protondb_visible = True
else:
self.protondbLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
self.protondbLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину даже для невидимого бейджа
self.protondbLabel.setVisible(False)
protondb_visible = False
# Steam бейдж
steam_icon = self.theme_manager.get_icon("steam")
self.steamLabel = ClickableLabel(
"Steam",
icon=steam_icon,
parent=coverWidget,
icon_size=16,
icon_space=5,
)
self.steamLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.steamLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
steam_visible = (str(steam_game).lower() == "true")
self.steamLabel.setVisible(steam_visible)
# WeAntiCheatYet бейдж
anticheat_text = self.getAntiCheatText(anticheat_status)
if anticheat_text:
icon_filename = self.getAntiCheatIconFilename(anticheat_status)
icon = self.theme_manager.get_icon(icon_filename, self.current_theme_name)
self.anticheatLabel = ClickableLabel(
anticheat_text,
icon=icon,
parent=coverWidget,
icon_size=16,
icon_space=3,
)
self.anticheatLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE)
self.anticheatLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину в 2/3 ширины карточки
anticheat_visible = True
else:
self.anticheatLabel = ClickableLabel("", parent=coverWidget, icon_size=16, icon_space=3)
self.anticheatLabel.setFixedWidth(int(card_width * 2/3)) # Устанавливаем ширину даже для невидимого бейджа
self.anticheatLabel.setVisible(False)
anticheat_visible = False
# Расположение бейджей
right_margin = 8
badge_spacing = 5
top_y = 10
badge_y_positions = []
badge_width = int(card_width * 2/3) # Фиксированная ширина бейджей
if steam_visible:
steam_x = card_width - badge_width - right_margin
self.steamLabel.move(steam_x, top_y)
badge_y_positions.append(top_y + self.steamLabel.height())
if protondb_visible:
protondb_x = card_width - badge_width - right_margin
protondb_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
self.protondbLabel.move(protondb_x, protondb_y)
badge_y_positions.append(protondb_y + self.protondbLabel.height())
if anticheat_visible:
anticheat_x = card_width - badge_width - right_margin
anticheat_y = badge_y_positions[-1] + badge_spacing if badge_y_positions else top_y
self.anticheatLabel.move(anticheat_x, anticheat_y)
self.anticheatLabel.raise_()
self.protondbLabel.raise_()
self.steamLabel.raise_()
self.protondbLabel.clicked.connect(self.open_protondb_report)
self.steamLabel.clicked.connect(self.open_steam_page)
self.anticheatLabel.clicked.connect(self.open_weanticheatyet_page)
layout.addWidget(coverWidget)
# Название игры
nameLabel = QLabel(name)
nameLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
nameLabel.setStyleSheet(self.theme.GAME_CARD_NAME_LABEL_STYLE)
layout.addWidget(nameLabel)
def _show_context_menu(self, pos):
"""Delegate context menu display to ContextMenuManager."""
if self.context_menu_manager:
self.context_menu_manager.show_context_menu(self, pos)
def getAntiCheatText(self, status):
if not status:
return ""
translations = {
"supported": _("Supported"),
"running": _("Running"),
"planned": _("Planned"),
"broken": _("Broken"),
"denied": _("Denied")
}
return translations.get(status.lower(), "")
def getAntiCheatIconFilename(self, status):
status = status.lower()
if status in ("supported", "running"):
return "platinum-gold"
elif status in ("denied", "planned", "broken"):
return "broken"
return ""
def getProtonDBText(self, tier):
if not tier:
return ""
translations = {
"platinum": _("Platinum"),
"gold": _("Gold"),
"silver": _("Silver"),
"bronze": _("Bronze"),
"borked": _("Broken"),
"pending": _("Pending")
}
return translations.get(tier.lower(), "")
def getProtonDBIconFilename(self, tier):
tier = tier.lower()
if tier in ("platinum", "gold"):
return "platinum-gold"
elif tier in ("silver", "bronze"):
return "silver-bronze"
elif tier in ("borked", "pending"):
return "broken"
return ""
def open_protondb_report(self):
url = QUrl(f"https://www.protondb.com/app/{self.appid}")
QDesktopServices.openUrl(url)
def open_steam_page(self):
url = QUrl(f"https://steamcommunity.com/app/{self.appid}")
QDesktopServices.openUrl(url)
def open_weanticheatyet_page(self):
formatted_name = self.name.lower().replace(" ", "-")
url = QUrl(f"https://areweanticheatyet.com/game/{formatted_name}")
QDesktopServices.openUrl(url)
def update_favorite_icon(self):
if self.is_favorite:
self.favoriteLabel.setText("")
else:
self.favoriteLabel.setText("")
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
def toggle_favorite(self):
favorites = read_favorites()
if self.is_favorite:
if self.name in favorites:
favorites.remove(self.name)
self.is_favorite = False
else:
if self.name not in favorites:
favorites.append(self.name)
self.is_favorite = True
save_favorites(favorites)
self.update_favorite_icon()
def getBorderWidth(self) -> int:
return self._borderWidth
def setBorderWidth(self, value: int):
if self._borderWidth != value:
self._borderWidth = value
self.borderWidthChanged.emit()
self.update()
def getGradientAngle(self) -> float:
return self._gradientAngle
def setGradientAngle(self, value: float):
if self._gradientAngle != value:
self._gradientAngle = value
self.gradientAngleChanged.emit()
self.update()
borderWidth = Property(int, getBorderWidth, setBorderWidth, None, "", notify=cast(Callable[[], None], borderWidthChanged))
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
def paintEvent(self, event):
super().paintEvent(event)
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen()
pen.setWidth(self._borderWidth)
if self._hovered or self._focused:
center = self.rect().center()
gradient = QConicalGradient(center, self._gradientAngle)
gradient.setColorAt(0, QColor("#00fff5"))
gradient.setColorAt(0.33, QColor("#FF5733"))
gradient.setColorAt(0.66, QColor("#9B59B6"))
gradient.setColorAt(1, QColor("#00fff5"))
pen.setBrush(QBrush(gradient))
else:
pen.setColor(QColor(0, 0, 0, 0))
painter.setPen(pen)
radius = 18
bw = round(self._borderWidth / 2)
rect = self.rect().adjusted(bw, bw, -bw, -bw)
painter.drawRoundedRect(rect, radius, radius)
def startPulseAnimation(self):
if not (self._hovered or self._focused):
return
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = QPropertyAnimation(self, QByteArray(b"borderWidth"))
self.pulse_anim.setDuration(800)
self.pulse_anim.setLoopCount(0)
self.pulse_anim.setKeyValueAt(0, 8)
self.pulse_anim.setKeyValueAt(0.5, 10)
self.pulse_anim.setKeyValueAt(1, 8)
self.pulse_anim.start()
def enterEvent(self, event):
self._hovered = True
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(8)
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(3000)
self.gradient_anim.setStartValue(360)
self.gradient_anim.setEndValue(0)
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
super().enterEvent(event)
def leaveEvent(self, event):
self._hovered = False
if not self._focused: # Сохраняем анимацию, если есть фокус
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(2)
self.thickness_anim.start()
super().leaveEvent(event)
def focusInEvent(self, event):
self._focused = True
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.OutBack))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(12)
self.thickness_anim.finished.connect(self.startPulseAnimation)
self._isPulseAnimationConnected = True
self.thickness_anim.start()
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = QPropertyAnimation(self, QByteArray(b"gradientAngle"))
self.gradient_anim.setDuration(3000)
self.gradient_anim.setStartValue(360)
self.gradient_anim.setEndValue(0)
self.gradient_anim.setLoopCount(-1)
self.gradient_anim.start()
super().focusInEvent(event)
def focusOutEvent(self, event):
self._focused = False
if not self._hovered: # Сохраняем анимацию, если есть наведение
if self.gradient_anim:
self.gradient_anim.stop()
self.gradient_anim = None
self.thickness_anim.stop()
if self._isPulseAnimationConnected:
self.thickness_anim.finished.disconnect(self.startPulseAnimation)
self._isPulseAnimationConnected = False
if self.pulse_anim:
self.pulse_anim.stop()
self.pulse_anim = None
self.thickness_anim.setEasingCurve(QEasingCurve(QEasingCurve.Type.InBack))
self.thickness_anim.setStartValue(self._borderWidth)
self.thickness_anim.setEndValue(2)
self.thickness_anim.start()
super().focusOutEvent(event)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self.select_callback(
self.name,
self.description,
self.cover_path,
self.appid,
self.controller_support,
self.exec_line,
self.last_launch,
self.formatted_playtime,
self.protondb_tier,
self.steam_game
)
super().mousePressEvent(event)
def keyPressEvent(self, event):
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self.select_callback(
self.name,
self.description,
self.cover_path,
self.appid,
self.controller_support,
self.exec_line,
self.last_launch,
self.formatted_playtime,
self.protondb_tier,
self.steam_game
)
else:
super().keyPressEvent(event)

503
portprotonqt/image_utils.py Normal file
View File

@ -0,0 +1,503 @@
import os
from PySide6.QtGui import QPen, QColor, QPixmap, QPainter, QPainterPath
from PySide6.QtCore import Qt, QFile, QEvent, QByteArray, QEasingCurve, QPropertyAnimation
from PySide6.QtWidgets import QGraphicsItem, QToolButton, QFrame, QLabel, QGraphicsScene, QHBoxLayout, QWidget, QGraphicsView, QVBoxLayout, QSizePolicy
from PySide6.QtWidgets import QSpacerItem, QGraphicsPixmapItem, QDialog, QApplication
import portprotonqt.themes.standart.styles as default_styles
from portprotonqt.config_utils import read_theme_from_config
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.downloader import Downloader
from portprotonqt.logger import get_logger
from collections.abc import Callable
from concurrent.futures import ThreadPoolExecutor
from queue import Queue
import threading
downloader = Downloader()
logger = get_logger(__name__)
# Глобальная очередь и пул потоков для загрузки изображений
image_load_queue = Queue()
image_executor = ThreadPoolExecutor(max_workers=4)
queue_lock = threading.Lock()
def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[QPixmap], None], app_name: str = ""):
"""
Асинхронно загружает обложку через очередь задач.
"""
def process_image():
theme_manager = ThemeManager()
current_theme_name = read_theme_from_config()
def finish_with(pixmap: QPixmap):
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
x = (scaled.width() - width) // 2
y = (scaled.height() - height) // 2
cropped = scaled.copy(x, y, width, height)
callback(cropped)
# Removed: pixmap = None (unnecessary, causes type error)
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQT", "images")
os.makedirs(image_folder, exist_ok=True)
if cover and cover.startswith("https://steamcdn-a.akamaihd.net/steam/apps/"):
try:
parts = cover.split("/")
appid = None
if "apps" in parts:
idx = parts.index("apps")
if idx + 1 < len(parts):
appid = parts[idx + 1]
if appid:
local_path = os.path.join(image_folder, f"{appid}.jpg")
if os.path.exists(local_path):
pixmap = QPixmap(local_path)
finish_with(pixmap)
return
def on_downloaded(result: str | None):
pixmap = QPixmap()
if result and os.path.exists(result):
pixmap.load(result)
if pixmap.isNull():
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
painter = QPainter(pixmap)
painter.setPen(QPen(QColor("white")))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
finish_with(pixmap)
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
return
except Exception as e:
logger.error(f"Ошибка обработки URL {cover}: {e}")
if cover and cover.startswith(("http://", "https://")):
try:
local_path = os.path.join(image_folder, f"{app_name}.jpg")
if os.path.exists(local_path):
pixmap = QPixmap(local_path)
finish_with(pixmap)
return
def on_downloaded(result: str | None):
pixmap = QPixmap()
if result and os.path.exists(result):
pixmap.load(result)
if pixmap.isNull():
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
painter = QPainter(pixmap)
painter.setPen(QPen(QColor("white")))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
finish_with(pixmap)
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
return
except Exception as e:
logger.error("Error processing EGS URL %s: %s", cover, str(e))
if cover and QFile.exists(cover):
pixmap = QPixmap(cover)
finish_with(pixmap)
return
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
pixmap = QPixmap()
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
painter = QPainter(pixmap)
painter.setPen(QPen(QColor("white")))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
finish_with(pixmap)
with queue_lock:
image_load_queue.put(process_image)
image_executor.submit(lambda: image_load_queue.get()())
def round_corners(pixmap, radius):
"""
Возвращает QPixmap с закруглёнными углами.
"""
if pixmap.isNull():
return pixmap
size = pixmap.size()
rounded = QPixmap(size)
rounded.fill(QColor(0, 0, 0, 0))
painter = QPainter(rounded)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
path = QPainterPath()
path.addRoundedRect(0, 0, size.width(), size.height(), radius, radius)
painter.setClipPath(path)
painter.drawPixmap(0, 0, pixmap)
painter.end()
return rounded
class FullscreenDialog(QDialog):
"""
Диалог для просмотра изображений без стандартных элементов управления.
Изображение отображается в области фиксированного размера, а подпись располагается чуть выше нижней границы.
В окне есть кнопки-стрелки для перелистывания изображений.
Диалог закрывается при клике по изображению или подписи.
"""
FIXED_WIDTH = 800
FIXED_HEIGHT = 400
def __init__(self, images, current_index=0, parent=None, theme=None):
"""
:param images: Список кортежей (QPixmap, caption)
:param current_index: Индекс текущего изображения
:param theme: Объект темы для стилизации (если None, используется default_styles)
"""
super().__init__(parent)
# Удаление диалога после закрытия
self.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose)
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.setFocus()
self.images = images
self.current_index = current_index
self.theme = theme if theme else default_styles
# Убираем стандартные элементы управления окна
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Dialog)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.init_ui()
self.update_display()
# Фильтруем события для закрытия диалога по клику
self.imageLabel.installEventFilter(self)
self.captionLabel.installEventFilter(self)
def init_ui(self):
self.mainLayout = QVBoxLayout(self)
self.setLayout(self.mainLayout)
self.mainLayout.setContentsMargins(0, 0, 0, 0)
self.mainLayout.setSpacing(0)
# Контейнер для изображения и стрелок
self.imageContainer = QWidget()
self.imageContainer.setFixedSize(self.FIXED_WIDTH, self.FIXED_HEIGHT)
self.imageContainerLayout = QHBoxLayout(self.imageContainer)
self.imageContainerLayout.setContentsMargins(0, 0, 0, 0)
self.imageContainerLayout.setSpacing(0)
# Левая стрелка
self.prevButton = QToolButton()
self.prevButton.setArrowType(Qt.ArrowType.LeftArrow)
self.prevButton.setStyleSheet(self.theme.PREV_BUTTON_STYLE)
self.prevButton.setCursor(Qt.CursorShape.PointingHandCursor)
self.prevButton.setFixedSize(40, 40)
self.prevButton.clicked.connect(self.show_prev)
self.imageContainerLayout.addWidget(self.prevButton)
# Метка для изображения
self.imageLabel = QLabel()
self.imageLabel.setFixedSize(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
self.imageLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.imageContainerLayout.addWidget(self.imageLabel, stretch=1)
# Правая стрелка
self.nextButton = QToolButton()
self.nextButton.setArrowType(Qt.ArrowType.RightArrow)
self.nextButton.setStyleSheet(self.theme.NEXT_BUTTON_STYLE)
self.nextButton.setCursor(Qt.CursorShape.PointingHandCursor)
self.nextButton.setFixedSize(40, 40)
self.nextButton.clicked.connect(self.show_next)
self.imageContainerLayout.addWidget(self.nextButton)
self.mainLayout.addWidget(self.imageContainer)
# Небольшой отступ между изображением и подписью
spacer = QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed)
self.mainLayout.addItem(spacer)
# Подпись
self.captionLabel = QLabel()
self.captionLabel.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.captionLabel.setFixedHeight(40)
self.captionLabel.setWordWrap(True)
self.captionLabel.setStyleSheet(self.theme.CAPTION_LABEL_STYLE)
self.captionLabel.setCursor(Qt.CursorShape.PointingHandCursor)
self.mainLayout.addWidget(self.captionLabel)
def update_display(self):
"""Обновляет изображение и подпись согласно текущему индексу."""
if not self.images:
return
# Очищаем старое содержимое
self.imageLabel.clear()
self.captionLabel.clear()
QApplication.processEvents()
pixmap, caption = self.images[self.current_index]
# Масштабируем изображение так, чтобы оно поместилось в область фиксированного размера
scaled_pixmap = pixmap.scaled(
self.FIXED_WIDTH - 80, # учитываем ширину стрелок
self.FIXED_HEIGHT,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
self.imageLabel.setPixmap(scaled_pixmap)
self.captionLabel.setText(caption)
self.setWindowTitle(caption)
# Принудительная перерисовка виджетов
self.imageLabel.repaint()
self.captionLabel.repaint()
self.repaint()
def show_prev(self):
"""Показывает предыдущее изображение."""
if self.images:
self.current_index = (self.current_index - 1) % len(self.images)
self.update_display()
def show_next(self):
"""Показывает следующее изображение."""
if self.images:
self.current_index = (self.current_index + 1) % len(self.images)
self.update_display()
def eventFilter(self, obj, event):
"""Закрывает диалог при клике по изображению или подписи."""
if event.type() == QEvent.Type.MouseButtonPress and obj in [self.imageLabel, self.captionLabel]:
self.close()
return True
return super().eventFilter(obj, event)
def changeEvent(self, event):
"""Закрывает диалог при потере фокуса."""
if event.type() == QEvent.Type.ActivationChange:
if not self.isActiveWindow():
self.close()
super().changeEvent(event)
def mousePressEvent(self, event):
"""Закрывает диалог при клике на пустую область."""
pos = event.pos()
# Проверяем, находится ли клик вне imageContainer и captionLabel
if not (self.imageContainer.geometry().contains(pos) or
self.captionLabel.geometry().contains(pos)):
self.close()
super().mousePressEvent(event)
class ClickablePixmapItem(QGraphicsPixmapItem):
"""
Элемент карусели, реагирующий на клик.
При клике открывается FullscreenDialog с возможностью перелистывания изображений.
"""
def __init__(self, pixmap, caption="Просмотр изображения", images_list=None, index=0, carousel=None):
"""
:param pixmap: QPixmap для отображения в карусели
:param caption: Подпись к изображению
:param images_list: Список всех изображений (кортежей (QPixmap, caption)),
чтобы в диалоге можно было перелистывать.
Если не передан, будет использован только текущее изображение.
:param index: Индекс текущего изображения в images_list.
:param carousel: Ссылка на родительскую карусель (ImageCarousel) для управления стрелками.
"""
super().__init__(pixmap)
self.caption = caption
self.images_list = images_list if images_list is not None else [(pixmap, caption)]
self.index = index
self.carousel = carousel
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.setToolTip(caption)
self._click_start_position = None
self.setAcceptedMouseButtons(Qt.MouseButton.LeftButton)
self.setFlag(QGraphicsItem.GraphicsItemFlag.ItemIsSelectable)
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._click_start_position = event.scenePos()
event.accept()
def mouseReleaseEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton and self._click_start_position is not None:
distance = (event.scenePos() - self._click_start_position).manhattanLength()
if distance < 2:
self.show_fullscreen()
event.accept()
return
event.accept()
def show_fullscreen(self):
# Скрываем стрелки карусели перед открытием FullscreenDialog
if self.carousel:
self.carousel.prevArrow.hide()
self.carousel.nextArrow.hide()
dialog = FullscreenDialog(self.images_list, current_index=self.index)
dialog.exec()
# После закрытия диалога обновляем видимость стрелок
if self.carousel:
self.carousel.update_arrows_visibility()
class ImageCarousel(QGraphicsView):
"""
Карусель изображений с адаптивностью, возможностью увеличения по клику
и перетаскиванием мыши.
"""
def __init__(self, images: list[tuple], parent: QWidget | None = None, theme: object | None = None):
super().__init__(parent)
# Аннотируем тип scene как QGraphicsScene
self.carousel_scene: QGraphicsScene = QGraphicsScene(self)
self.setScene(self.carousel_scene)
self.images = images # Список кортежей: (QPixmap, caption)
self.image_items = []
self._animation = None
self.theme = theme if theme else default_styles
self.init_ui()
self.create_arrows()
# Переменные для поддержки перетаскивания
self._drag_active = False
self._drag_start_position = None
self._scroll_start_value = None
def init_ui(self):
self.setRenderHint(QPainter.RenderHint.Antialiasing)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.setFrameShape(QFrame.Shape.NoFrame)
x_offset = 10 # Отступ между изображениями
max_height = 300 # Фиксированная высота изображений
x = 0
for i, (pixmap, caption) in enumerate(self.images):
item = ClickablePixmapItem(
pixmap.scaledToHeight(max_height, Qt.TransformationMode.SmoothTransformation),
caption,
images_list=self.images,
index=i,
carousel=self # Передаем ссылку на карусель
)
item.setPos(x, 0)
self.carousel_scene.addItem(item)
self.image_items.append(item)
x += item.pixmap().width() + x_offset
self.setSceneRect(0, 0, x, max_height)
def create_arrows(self):
"""Создаёт кнопки-стрелки и привязывает их к функциям прокрутки."""
self.prevArrow = QToolButton(self)
self.prevArrow.setArrowType(Qt.ArrowType.LeftArrow)
self.prevArrow.setStyleSheet(self.theme.PREV_BUTTON_STYLE) # type: ignore
self.prevArrow.setFixedSize(40, 40)
self.prevArrow.setCursor(Qt.CursorShape.PointingHandCursor)
self.prevArrow.setAutoRepeat(True)
self.prevArrow.setAutoRepeatDelay(300)
self.prevArrow.setAutoRepeatInterval(100)
self.prevArrow.clicked.connect(self.scroll_left)
self.prevArrow.raise_()
self.nextArrow = QToolButton(self)
self.nextArrow.setArrowType(Qt.ArrowType.RightArrow)
self.nextArrow.setStyleSheet(self.theme.NEXT_BUTTON_STYLE) # type: ignore
self.nextArrow.setFixedSize(40, 40)
self.nextArrow.setCursor(Qt.CursorShape.PointingHandCursor)
self.nextArrow.setAutoRepeat(True)
self.nextArrow.setAutoRepeatDelay(300)
self.nextArrow.setAutoRepeatInterval(100)
self.nextArrow.clicked.connect(self.scroll_right)
self.nextArrow.raise_()
# Проверяем видимость стрелок при создании
self.update_arrows_visibility()
def update_arrows_visibility(self):
"""
Показывает стрелки, если контент шире видимой области.
Иначе скрывает их.
"""
if hasattr(self, "prevArrow") and hasattr(self, "nextArrow"):
if self.horizontalScrollBar().maximum() == 0:
self.prevArrow.hide()
self.nextArrow.hide()
else:
self.prevArrow.show()
self.nextArrow.show()
def resizeEvent(self, event):
super().resizeEvent(event)
margin = 10
self.prevArrow.move(margin, (self.height() - self.prevArrow.height()) // 2)
self.nextArrow.move(self.width() - self.nextArrow.width() - margin,
(self.height() - self.nextArrow.height()) // 2)
self.update_arrows_visibility()
def animate_scroll(self, end_value):
scrollbar = self.horizontalScrollBar()
start_value = scrollbar.value()
animation = QPropertyAnimation(scrollbar, QByteArray(b"value"), self)
animation.setDuration(300)
animation.setStartValue(start_value)
animation.setEndValue(end_value)
animation.setEasingCurve(QEasingCurve.Type.InOutQuad)
self._animation = animation
animation.start()
def scroll_left(self):
scrollbar = self.horizontalScrollBar()
new_value = scrollbar.value() - 100
self.animate_scroll(new_value)
def scroll_right(self):
scrollbar = self.horizontalScrollBar()
new_value = scrollbar.value() + 100
self.animate_scroll(new_value)
def update_images(self, new_images):
self.carousel_scene.clear()
self.images = new_images
self.image_items.clear()
self.init_ui()
self.update_arrows_visibility()
# Обработка событий мыши для перетаскивания
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
self._drag_active = True
self._drag_start_position = event.pos()
self._scroll_start_value = self.horizontalScrollBar().value()
# Скрываем стрелки при начале перетаскивания
if hasattr(self, "prevArrow"):
self.prevArrow.hide()
if hasattr(self, "nextArrow"):
self.nextArrow.hide()
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self._drag_active and self._drag_start_position is not None:
delta = event.pos().x() - self._drag_start_position.x()
new_value = self._scroll_start_value - delta
self.horizontalScrollBar().setValue(new_value)
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
self._drag_active = False
# Показываем стрелки после завершения перетаскивания (с проверкой видимости)
self.update_arrows_visibility()
super().mouseReleaseEvent(event)

View File

@ -0,0 +1,430 @@
import time
import threading
from typing import Protocol, cast
from evdev import InputDevice, ecodes, list_devices
import pyudev
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit
from PySide6.QtCore import Qt, QObject, QEvent, QPoint
from PySide6.QtGui import QKeyEvent
from portprotonqt.logger import get_logger
from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel
from portprotonqt.game_card import GameCard
from portprotonqt.config_utils import read_fullscreen_config
logger = get_logger(__name__)
class MainWindowProtocol(Protocol):
def activateFocusedWidget(self) -> None:
...
def goBackDetailPage(self, page: QWidget | None) -> None:
...
def switchTab(self, index: int) -> None:
...
def openAddGameDialog(self, exe_path: str | None = None) -> None:
...
def toggleGame(self, exec_line: str | None, button: QWidget | None = None) -> None:
...
stackedWidget: QStackedWidget
tabButtons: dict[int, QWidget]
gamesListWidget: QWidget
currentDetailPage: QWidget | None
current_exec_line: str | None
# Mapping of actions to evdev button codes, includes PlayStation, Xbox, and Switch controllers (https://www.kernel.org/doc/html/v4.12/input/gamepad.html)
BUTTONS = {
# South button: X (PlayStation), A (Xbox), B (Switch Joy-Con south)
'confirm': {ecodes.BTN_SOUTH, ecodes.BTN_A},
# East button: Circle (PS), B (Xbox), A (Switch Joy-Con east)
'back': {ecodes.BTN_EAST, ecodes.BTN_B},
# North button: Triangle (PS), Y (Xbox), X (Switch Joy-Con north)
'add_game': {ecodes.BTN_NORTH, ecodes.BTN_Y},
# Shoulder buttons: L1/L2 (PS), LB (Xbox), L (Switch): BTN_TL, BTN_TL2
'prev_tab': {ecodes.BTN_TL, ecodes.BTN_TL2},
# Shoulder buttons: R1/R2 (PS), RB (Xbox), R (Switch): BTN_TR, BTN_TR2
'next_tab': {ecodes.BTN_TR, ecodes.BTN_TR2},
# Optional: stick presses on Switch Joy-Con
'confirm_stick': {ecodes.BTN_THUMBL, ecodes.BTN_THUMBR},
# Start button for context menu
'context_menu': {ecodes.BTN_START},
# Select/home for back/menu
'menu': {ecodes.BTN_SELECT, ecodes.BTN_MODE},
}
class InputManager(QObject):
"""
Manages input from gamepads and keyboards for navigating the application interface.
Supports gamepad hotplugging, button and axis events, and keyboard event filtering
for seamless UI interaction.
"""
def __init__(
self,
main_window: MainWindowProtocol,
axis_deadzone: float = 0.5,
initial_axis_move_delay: float = 0.3,
repeat_axis_move_delay: float = 0.15
):
super().__init__(cast(QObject, main_window))
self._parent = main_window
# Ensure attributes exist on main_window
self._parent.currentDetailPage = getattr(self._parent, 'currentDetailPage', None)
self._parent.current_exec_line = getattr(self._parent, 'current_exec_line', None)
self.axis_deadzone = axis_deadzone
self.initial_axis_move_delay = initial_axis_move_delay
self.repeat_axis_move_delay = repeat_axis_move_delay
self.current_axis_delay = initial_axis_move_delay
self.last_move_time = 0.0
self.axis_moving = False
self.gamepad: InputDevice | None = None
self.gamepad_thread: threading.Thread | None = None
self.running = True
self._is_fullscreen = read_fullscreen_config()
# Install keyboard event filter
app = QApplication.instance()
if app is not None:
app.installEventFilter(self)
else:
logger.error("QApplication instance is None, cannot install event filter")
# Initialize evdev + hotplug
self.init_gamepad()
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
app = QApplication.instance()
if not app:
return super().eventFilter(obj, event)
# 1) Интересуют только нажатия клавиш
if not (isinstance(event, QKeyEvent) and event.type() == QEvent.Type.KeyPress):
return super().eventFilter(obj, event)
key = event.key()
modifiers = event.modifiers()
focused = QApplication.focusWidget()
popup = QApplication.activePopupWidget()
# 2) Закрытие приложения по Ctrl+Q
if key == Qt.Key.Key_Q and modifiers & Qt.KeyboardModifier.ControlModifier:
app.quit()
return True
# 3) Если открыт любой popup — не перехватываем ENTER, ESC и стрелки
if popup:
# возвращаем False, чтобы событие пошло дальше в Qt и закрыло popup как нужно
return False
# 4) Навигация в полноэкранном просмотре
active_win = QApplication.activeWindow()
if isinstance(active_win, FullscreenDialog):
if key == Qt.Key.Key_Right:
active_win.show_next()
return True
if key == Qt.Key.Key_Left:
active_win.show_prev()
return True
if key in (Qt.Key.Key_Escape, Qt.Key.Key_Return, Qt.Key.Key_Enter, Qt.Key.Key_Backspace):
active_win.close()
return True
# 5) На странице деталей Enter запускает/останавливает игру
if self._parent.currentDetailPage and key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
if self._parent.current_exec_line:
self._parent.toggleGame(self._parent.current_exec_line, None)
return True
# 6) Открытие контекстного меню для GameCard
if isinstance(focused, GameCard):
if key == Qt.Key.Key_F10 and Qt.KeyboardModifier.ShiftModifier:
pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos)
return True
# 7) Навигация по карточкам в Library
if self._parent.stackedWidget.currentIndex() == 0:
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
scroll_area = self._parent.gamesListWidget.parentWidget()
while scroll_area and not isinstance(scroll_area, QScrollArea):
scroll_area = scroll_area.parentWidget()
if not scroll_area:
logger.warning("No QScrollArea found for gamesListWidget")
if isinstance(focused, GameCard):
current_index = game_cards.index(focused) if focused in game_cards else -1
if key == Qt.Key.Key_Down:
if current_index >= 0 and current_index + 1 < len(game_cards):
next_card = game_cards[current_index + 1]
next_card.setFocus()
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
return True
elif key == Qt.Key.Key_Up:
if current_index > 0:
prev_card = game_cards[current_index - 1]
prev_card.setFocus()
if scroll_area:
scroll_area.ensureWidgetVisible(prev_card, 50, 50)
return True
elif current_index == 0:
self._parent.tabButtons[0].setFocus()
return True
elif key == Qt.Key.Key_Left:
if current_index > 0:
prev_card = game_cards[current_index - 1]
prev_card.setFocus()
if scroll_area:
scroll_area.ensureWidgetVisible(prev_card, 50, 50)
return True
elif key == Qt.Key.Key_Right:
if current_index >= 0 and current_index + 1 < len(game_cards):
next_card = game_cards[current_index + 1]
next_card.setFocus()
if scroll_area:
scroll_area.ensureWidgetVisible(next_card, 50, 50)
return True
# 8) Переключение вкладок ←/→
idx = self._parent.stackedWidget.currentIndex()
total = len(self._parent.tabButtons)
if key == Qt.Key.Key_Left and not isinstance(focused, GameCard):
new = (idx - 1) % total
self._parent.switchTab(new)
self._parent.tabButtons[new].setFocus()
return True
if key == Qt.Key.Key_Right and not isinstance(focused, GameCard):
new = (idx + 1) % total
self._parent.switchTab(new)
self._parent.tabButtons[new].setFocus()
return True
# 9) Спуск в содержимое вкладки ↓
if key == Qt.Key.Key_Down:
if isinstance(focused, NavLabel):
page = self._parent.stackedWidget.currentWidget()
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus()
return True
else:
if focused is not None:
focused.focusNextChild()
return True
# 10) Подъём по содержимому вкладки ↑
if key == Qt.Key.Key_Up:
if isinstance(focused, NavLabel):
return True # Не даём уйти выше NavLabel
if focused is not None:
focused.focusPreviousChild()
return True
# 11) Общие: Activate, Back, Add
if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
self._parent.activateFocusedWidget()
return True
elif key in (Qt.Key.Key_Escape, Qt.Key.Key_Backspace):
if isinstance(focused, QLineEdit):
return False
self._parent.goBackDetailPage(self._parent.currentDetailPage)
return True
elif key == Qt.Key.Key_E:
if isinstance(focused, QLineEdit):
return False
self._parent.openAddGameDialog()
return True
# 12) Переключение полноэкранного режима по F11
if key == Qt.Key.Key_F11:
if read_fullscreen_config():
return True
window = self._parent
if isinstance(window, QWidget):
if self._is_fullscreen:
window.showNormal()
self._is_fullscreen = False
else:
window.showFullScreen()
self._is_fullscreen = True
return True
return super().eventFilter(obj, event)
def init_gamepad(self) -> None:
self.check_gamepad()
threading.Thread(target=self.run_udev_monitor, daemon=True).start()
logger.info("Input support initialized with hotplug (evdev + pyudev)")
def run_udev_monitor(self) -> None:
context = pyudev.Context()
monitor = pyudev.Monitor.from_netlink(context)
monitor.filter_by(subsystem='input')
observer = pyudev.MonitorObserver(monitor, self.handle_udev_event)
observer.start()
while self.running:
time.sleep(1)
def handle_udev_event(self, action: str, device: pyudev.Device) -> None:
if action == 'add':
time.sleep(0.1)
self.check_gamepad()
elif action == 'remove' and self.gamepad:
if not any(self.gamepad.path == path for path in list_devices()):
logger.info("Gamepad disconnected")
self.gamepad = None
if self.gamepad_thread:
self.gamepad_thread.join()
def check_gamepad(self) -> None:
new_gamepad = self.find_gamepad()
if new_gamepad and new_gamepad != self.gamepad:
logger.info(f"Gamepad connected: {new_gamepad.name}")
self.gamepad = new_gamepad
if self.gamepad_thread:
self.gamepad_thread.join()
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True)
self.gamepad_thread.start()
def find_gamepad(self) -> InputDevice | None:
devices = [InputDevice(path) for path in list_devices()]
for device in devices:
caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps:
return device
return None
def monitor_gamepad(self) -> None:
try:
if not self.gamepad:
return
for event in self.gamepad.read_loop():
if not self.running:
break
if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS):
continue
now = time.time()
if event.type == ecodes.EV_KEY and event.value == 1:
self.handle_button(event.code)
elif event.type == ecodes.EV_ABS:
self.handle_dpad(event.code, event.value, now)
except Exception as e:
logger.error(f"Error accessing gamepad: {e}")
def handle_button(self, button_code: int) -> None:
app = QApplication.instance()
if app is None:
logger.error("QApplication instance is None")
return
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
# FullscreenDialog
if isinstance(active, FullscreenDialog):
if button_code in BUTTONS['prev_tab']:
active.show_prev()
elif button_code in BUTTONS['next_tab']:
active.show_next()
elif button_code in BUTTONS['back']:
active.close()
return
# Context menu for GameCard
if isinstance(focused, GameCard):
if button_code in BUTTONS['context_menu']:
pos = QPoint(focused.width() // 2, focused.height() // 2)
focused._show_context_menu(pos)
return
# Game launch on detail page
if (button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']) and self._parent.currentDetailPage is not None:
if self._parent.current_exec_line:
self._parent.toggleGame(self._parent.current_exec_line, None)
return
# Standard navigation
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['confirm_stick']:
self._parent.activateFocusedWidget()
elif button_code in BUTTONS['back'] or button_code in BUTTONS['menu']:
self._parent.goBackDetailPage(getattr(self._parent, 'currentDetailPage', None))
elif button_code in BUTTONS['add_game']:
self._parent.openAddGameDialog()
elif button_code in BUTTONS['prev_tab']:
idx = (self._parent.stackedWidget.currentIndex() - 1) % len(self._parent.tabButtons)
self._parent.switchTab(idx)
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
elif button_code in BUTTONS['next_tab']:
idx = (self._parent.stackedWidget.currentIndex() + 1) % len(self._parent.tabButtons)
self._parent.switchTab(idx)
self._parent.tabButtons[idx].setFocus(Qt.FocusReason.OtherFocusReason)
def handle_dpad(self, code: int, value: int, current_time: float) -> None:
app = QApplication.instance()
if app is None:
logger.error("QApplication instance is None")
return
active = QApplication.activeWindow()
# Fullscreen horizontal
if isinstance(active, FullscreenDialog) and code == ecodes.ABS_HAT0X:
if value < 0:
active.show_prev()
elif value > 0:
active.show_next()
return
# Vertical navigation (DPAD up/down)
if code == ecodes.ABS_HAT0Y:
# ignore release
if value == 0:
return
focused = QApplication.focusWidget()
page = self._parent.stackedWidget.currentWidget()
if value > 0:
# down
if isinstance(focused, NavLabel):
focusables = page.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
focusables = [w for w in focusables if w.focusPolicy() & Qt.FocusPolicy.StrongFocus]
if focusables:
focusables[0].setFocus()
return
elif focused:
focused.focusNextChild()
return
elif value < 0 and focused:
# up
focused.focusPreviousChild()
return
# Horizontal wrap navigation repeat logic
if code != ecodes.ABS_HAT0X:
return
if value == 0:
self.axis_moving = False
self.current_axis_delay = self.initial_axis_move_delay
return
if not self.axis_moving:
self.trigger_dpad_movement(code, value)
self.last_move_time = current_time
self.axis_moving = True
elif current_time - self.last_move_time >= self.current_axis_delay:
self.trigger_dpad_movement(code, value)
self.last_move_time = current_time
self.current_axis_delay = self.repeat_axis_move_delay
def trigger_dpad_movement(self, code: int, value: int) -> None:
if code != ecodes.ABS_HAT0X:
return
idx = self._parent.stackedWidget.currentIndex()
if value < 0:
new = (idx - 1) % len(self._parent.tabButtons)
else:
new = (idx + 1) % len(self._parent.tabButtons)
self._parent.switchTab(new)
self._parent.tabButtons[new].setFocus(Qt.FocusReason.OtherFocusReason)
def cleanup(self) -> None:
self.running = False
if self.gamepad:
self.gamepad.close()
logger.info("Input support cleaned up")

Binary file not shown.

View File

@ -0,0 +1,516 @@
# German (Germany) translations for PortProtonQT.
# Copyright (C) 2025 boria138
# This file is distributed under the same license as the PortProtonQT
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-05-29 17:42+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
"Language-Team: de_DE <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
msgid "Remove from Desktop"
msgstr ""
msgid "Add to Desktop"
msgstr ""
msgid "Edit Shortcut"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Open Game Folder"
msgstr ""
msgid "Remove from Menu"
msgstr ""
msgid "Add to Menu"
msgstr ""
msgid "Remove from Steam"
msgstr ""
msgid "Add to Steam"
msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found."
msgstr ""
#, python-brace-format
msgid "No executable command found in .desktop for game: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to parse .desktop file for game: {0}"
msgstr ""
#, python-brace-format
msgid "Error reading .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid ".desktop file not found for game: {0}"
msgstr ""
#, python-brace-format
msgid "Invalid executable command: {0}"
msgstr ""
#, python-brace-format
msgid "Executable file not found: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable command: {0}"
msgstr ""
msgid "Confirm Deletion"
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete '{0}'? This will remove the .desktop file"
" and custom data."
msgstr ""
#, python-brace-format
msgid "Could not locate .desktop file for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to delete .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' deleted successfully"
msgstr ""
#, python-brace-format
msgid "Failed to delete custom data: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' added to menu"
msgstr ""
#, python-brace-format
msgid "Failed to add game to menu: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to remove game from menu: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' removed from menu"
msgstr ""
#, python-brace-format
msgid "Game '{0}' added to desktop"
msgstr ""
#, python-brace-format
msgid "Failed to add game to desktop: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to remove game from Desktop: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' removed from Desktop"
msgstr ""
msgid "Game name and executable path are required."
msgstr ""
msgid "Failed to generate .desktop file data."
msgstr ""
#, python-brace-format
msgid "Failed to remove old .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Old .desktop file removed for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {0}"
msgstr ""
msgid "Restart Steam"
msgstr ""
msgid ""
"The game was added successfully.\n"
"Please restart Steam for changes to take effect."
msgstr ""
msgid ""
"The game was removed successfully.\n"
"Please restart Steam for changes to take effect."
msgstr ""
#, python-brace-format
msgid "Opened folder for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to open game folder: {0}"
msgstr ""
msgid "Edit Game"
msgstr ""
msgid "Add Game"
msgstr ""
msgid "Game Name:"
msgstr ""
msgid "Browse..."
msgstr ""
msgid "Path to Executable:"
msgstr ""
msgid "Custom Cover:"
msgstr ""
msgid "Cover Preview:"
msgstr ""
msgid "Select Executable"
msgstr ""
msgid "Select Cover Image"
msgstr ""
msgid "Invalid image"
msgstr ""
msgid "No cover selected"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
msgid "No description available"
msgstr ""
msgid "Never"
msgstr ""
msgid "Supported"
msgstr ""
msgid "Running"
msgstr ""
msgid "Planned"
msgstr ""
msgid "Broken"
msgstr ""
msgid "Denied"
msgstr ""
msgid "Platinum"
msgstr ""
msgid "Gold"
msgstr ""
msgid "Silver"
msgstr ""
msgid "Bronze"
msgstr ""
msgid "Pending"
msgstr ""
msgid "Library"
msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
msgid "PortProton Settings"
msgstr ""
msgid "Themes"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
msgid "Loading PortProton games..."
msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Game Library"
msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Main PortProton parameters..."
msgstr ""
msgid "detailed"
msgstr ""
msgid "brief"
msgstr ""
msgid "Time Detail Level:"
msgstr ""
msgid "last launch"
msgstr ""
msgid "playtime"
msgstr ""
msgid "alphabetical"
msgstr ""
msgid "favorites"
msgstr ""
msgid "Games Sort Method:"
msgstr ""
msgid "all"
msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Proxy URL"
msgstr ""
msgid "Proxy URL:"
msgstr ""
msgid "Proxy Username"
msgstr ""
msgid "Proxy Username:"
msgstr ""
msgid "Proxy Password"
msgstr ""
msgid "Proxy Password:"
msgstr ""
msgid "Launch Application in Fullscreen"
msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings"
msgstr ""
msgid "Reset Settings"
msgstr ""
msgid "Clear Cache"
msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset"
msgstr ""
msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr ""
msgid "Settings reset. Restarting..."
msgstr ""
msgid "Confirm Clear Cache"
msgstr ""
msgid "Are you sure you want to clear the cache? This action cannot be undone."
msgstr ""
msgid "Cache cleared"
msgstr ""
msgid "Settings saved"
msgstr ""
msgid "Select Theme:"
msgstr ""
msgid "Apply Theme"
msgstr ""
msgid "No link"
msgstr ""
msgid "Unknown"
msgstr ""
msgid "Name:"
msgstr ""
msgid "Description:"
msgstr ""
msgid "Author:"
msgstr ""
msgid "Link:"
msgstr ""
#, python-brace-format
msgid "Theme '{0}' applied successfully"
msgstr ""
#, python-brace-format
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
msgid "Invalid command format (native)"
msgstr ""
msgid "Invalid command format (flatpak)"
msgstr ""
#, python-brace-format
msgid "File not found: {0}"
msgstr ""
msgid "Cannot launch game while another game is running"
msgstr ""
msgid "Launching"
msgstr ""
msgid "just now"
msgstr ""
msgid "d."
msgstr ""
msgid "h."
msgstr ""
msgid "min."
msgstr ""
msgid "sec."
msgstr ""

Binary file not shown.

View File

@ -0,0 +1,516 @@
# Spanish (Spain) translations for PortProtonQT.
# Copyright (C) 2025 boria138
# This file is distributed under the same license as the PortProtonQT
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-05-29 17:42+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
"Language-Team: es_ES <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
msgid "Remove from Desktop"
msgstr ""
msgid "Add to Desktop"
msgstr ""
msgid "Edit Shortcut"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Open Game Folder"
msgstr ""
msgid "Remove from Menu"
msgstr ""
msgid "Add to Menu"
msgstr ""
msgid "Remove from Steam"
msgstr ""
msgid "Add to Steam"
msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found."
msgstr ""
#, python-brace-format
msgid "No executable command found in .desktop for game: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to parse .desktop file for game: {0}"
msgstr ""
#, python-brace-format
msgid "Error reading .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid ".desktop file not found for game: {0}"
msgstr ""
#, python-brace-format
msgid "Invalid executable command: {0}"
msgstr ""
#, python-brace-format
msgid "Executable file not found: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable command: {0}"
msgstr ""
msgid "Confirm Deletion"
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete '{0}'? This will remove the .desktop file"
" and custom data."
msgstr ""
#, python-brace-format
msgid "Could not locate .desktop file for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to delete .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' deleted successfully"
msgstr ""
#, python-brace-format
msgid "Failed to delete custom data: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' added to menu"
msgstr ""
#, python-brace-format
msgid "Failed to add game to menu: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to remove game from menu: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' removed from menu"
msgstr ""
#, python-brace-format
msgid "Game '{0}' added to desktop"
msgstr ""
#, python-brace-format
msgid "Failed to add game to desktop: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to remove game from Desktop: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' removed from Desktop"
msgstr ""
msgid "Game name and executable path are required."
msgstr ""
msgid "Failed to generate .desktop file data."
msgstr ""
#, python-brace-format
msgid "Failed to remove old .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Old .desktop file removed for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {0}"
msgstr ""
msgid "Restart Steam"
msgstr ""
msgid ""
"The game was added successfully.\n"
"Please restart Steam for changes to take effect."
msgstr ""
msgid ""
"The game was removed successfully.\n"
"Please restart Steam for changes to take effect."
msgstr ""
#, python-brace-format
msgid "Opened folder for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to open game folder: {0}"
msgstr ""
msgid "Edit Game"
msgstr ""
msgid "Add Game"
msgstr ""
msgid "Game Name:"
msgstr ""
msgid "Browse..."
msgstr ""
msgid "Path to Executable:"
msgstr ""
msgid "Custom Cover:"
msgstr ""
msgid "Cover Preview:"
msgstr ""
msgid "Select Executable"
msgstr ""
msgid "Select Cover Image"
msgstr ""
msgid "Invalid image"
msgstr ""
msgid "No cover selected"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
msgid "No description available"
msgstr ""
msgid "Never"
msgstr ""
msgid "Supported"
msgstr ""
msgid "Running"
msgstr ""
msgid "Planned"
msgstr ""
msgid "Broken"
msgstr ""
msgid "Denied"
msgstr ""
msgid "Platinum"
msgstr ""
msgid "Gold"
msgstr ""
msgid "Silver"
msgstr ""
msgid "Bronze"
msgstr ""
msgid "Pending"
msgstr ""
msgid "Library"
msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
msgid "PortProton Settings"
msgstr ""
msgid "Themes"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
msgid "Loading PortProton games..."
msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Game Library"
msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Main PortProton parameters..."
msgstr ""
msgid "detailed"
msgstr ""
msgid "brief"
msgstr ""
msgid "Time Detail Level:"
msgstr ""
msgid "last launch"
msgstr ""
msgid "playtime"
msgstr ""
msgid "alphabetical"
msgstr ""
msgid "favorites"
msgstr ""
msgid "Games Sort Method:"
msgstr ""
msgid "all"
msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Proxy URL"
msgstr ""
msgid "Proxy URL:"
msgstr ""
msgid "Proxy Username"
msgstr ""
msgid "Proxy Username:"
msgstr ""
msgid "Proxy Password"
msgstr ""
msgid "Proxy Password:"
msgstr ""
msgid "Launch Application in Fullscreen"
msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings"
msgstr ""
msgid "Reset Settings"
msgstr ""
msgid "Clear Cache"
msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset"
msgstr ""
msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr ""
msgid "Settings reset. Restarting..."
msgstr ""
msgid "Confirm Clear Cache"
msgstr ""
msgid "Are you sure you want to clear the cache? This action cannot be undone."
msgstr ""
msgid "Cache cleared"
msgstr ""
msgid "Settings saved"
msgstr ""
msgid "Select Theme:"
msgstr ""
msgid "Apply Theme"
msgstr ""
msgid "No link"
msgstr ""
msgid "Unknown"
msgstr ""
msgid "Name:"
msgstr ""
msgid "Description:"
msgstr ""
msgid "Author:"
msgstr ""
msgid "Link:"
msgstr ""
#, python-brace-format
msgid "Theme '{0}' applied successfully"
msgstr ""
#, python-brace-format
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
msgid "Invalid command format (native)"
msgstr ""
msgid "Invalid command format (flatpak)"
msgstr ""
#, python-brace-format
msgid "File not found: {0}"
msgstr ""
msgid "Cannot launch game while another game is running"
msgstr ""
msgid "Launching"
msgstr ""
msgid "just now"
msgstr ""
msgid "d."
msgstr ""
msgid "h."
msgstr ""
msgid "min."
msgstr ""
msgid "sec."
msgstr ""

View File

@ -0,0 +1,514 @@
# Translations template for PortProtonQT.
# Copyright (C) 2025 boria138
# This file is distributed under the same license as the PortProtonQT
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PortProtonQT 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-05-29 17:42+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
msgid "Remove from Desktop"
msgstr ""
msgid "Add to Desktop"
msgstr ""
msgid "Edit Shortcut"
msgstr ""
msgid "Delete from PortProton"
msgstr ""
msgid "Open Game Folder"
msgstr ""
msgid "Remove from Menu"
msgstr ""
msgid "Add to Menu"
msgstr ""
msgid "Remove from Steam"
msgstr ""
msgid "Add to Steam"
msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found."
msgstr ""
#, python-brace-format
msgid "No executable command found in .desktop for game: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to parse .desktop file for game: {0}"
msgstr ""
#, python-brace-format
msgid "Error reading .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid ".desktop file not found for game: {0}"
msgstr ""
#, python-brace-format
msgid "Invalid executable command: {0}"
msgstr ""
#, python-brace-format
msgid "Executable file not found: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to parse executable command: {0}"
msgstr ""
msgid "Confirm Deletion"
msgstr ""
#, python-brace-format
msgid ""
"Are you sure you want to delete '{0}'? This will remove the .desktop file"
" and custom data."
msgstr ""
#, python-brace-format
msgid "Could not locate .desktop file for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to delete .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' deleted successfully"
msgstr ""
#, python-brace-format
msgid "Failed to delete custom data: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' added to menu"
msgstr ""
#, python-brace-format
msgid "Failed to add game to menu: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to remove game from menu: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' removed from menu"
msgstr ""
#, python-brace-format
msgid "Game '{0}' added to desktop"
msgstr ""
#, python-brace-format
msgid "Failed to add game to desktop: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to remove game from Desktop: {0}"
msgstr ""
#, python-brace-format
msgid "Game '{0}' removed from Desktop"
msgstr ""
msgid "Game name and executable path are required."
msgstr ""
msgid "Failed to generate .desktop file data."
msgstr ""
#, python-brace-format
msgid "Failed to remove old .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Old .desktop file removed for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to save .desktop file: {0}"
msgstr ""
#, python-brace-format
msgid "Failed to copy cover image: {0}"
msgstr ""
msgid "Restart Steam"
msgstr ""
msgid ""
"The game was added successfully.\n"
"Please restart Steam for changes to take effect."
msgstr ""
msgid ""
"The game was removed successfully.\n"
"Please restart Steam for changes to take effect."
msgstr ""
#, python-brace-format
msgid "Opened folder for '{0}'"
msgstr ""
#, python-brace-format
msgid "Failed to open game folder: {0}"
msgstr ""
msgid "Edit Game"
msgstr ""
msgid "Add Game"
msgstr ""
msgid "Game Name:"
msgstr ""
msgid "Browse..."
msgstr ""
msgid "Path to Executable:"
msgstr ""
msgid "Custom Cover:"
msgstr ""
msgid "Cover Preview:"
msgstr ""
msgid "Select Executable"
msgstr ""
msgid "Select Cover Image"
msgstr ""
msgid "Invalid image"
msgstr ""
msgid "No cover selected"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
msgid "No description available"
msgstr ""
msgid "Never"
msgstr ""
msgid "Supported"
msgstr ""
msgid "Running"
msgstr ""
msgid "Planned"
msgstr ""
msgid "Broken"
msgstr ""
msgid "Denied"
msgstr ""
msgid "Platinum"
msgstr ""
msgid "Gold"
msgstr ""
msgid "Silver"
msgstr ""
msgid "Bronze"
msgstr ""
msgid "Pending"
msgstr ""
msgid "Library"
msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
msgid "PortProton Settings"
msgstr ""
msgid "Themes"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
msgid "Loading PortProton games..."
msgstr ""
msgid "Unknown Game"
msgstr ""
msgid "Game Library"
msgstr ""
msgid "Find Games ..."
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Main PortProton parameters..."
msgstr ""
msgid "detailed"
msgstr ""
msgid "brief"
msgstr ""
msgid "Time Detail Level:"
msgstr ""
msgid "last launch"
msgstr ""
msgid "playtime"
msgstr ""
msgid "alphabetical"
msgstr ""
msgid "favorites"
msgstr ""
msgid "Games Sort Method:"
msgstr ""
msgid "all"
msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Proxy URL"
msgstr ""
msgid "Proxy URL:"
msgstr ""
msgid "Proxy Username"
msgstr ""
msgid "Proxy Username:"
msgstr ""
msgid "Proxy Password"
msgstr ""
msgid "Proxy Password:"
msgstr ""
msgid "Launch Application in Fullscreen"
msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Open Legendary Login"
msgstr ""
msgid "Legendary Authentication:"
msgstr ""
msgid "Enter Legendary Authorization Code"
msgstr ""
msgid "Authorization Code:"
msgstr ""
msgid "Submit Code"
msgstr ""
msgid "Save Settings"
msgstr ""
msgid "Reset Settings"
msgstr ""
msgid "Clear Cache"
msgstr ""
msgid "Opened Legendary login page in browser"
msgstr ""
msgid "Failed to open Legendary login page"
msgstr ""
msgid "Please enter an authorization code"
msgstr ""
msgid "Successfully authenticated with Legendary"
msgstr ""
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr ""
msgid "Legendary executable not found"
msgstr ""
msgid "Unexpected error during authentication"
msgstr ""
msgid "Confirm Reset"
msgstr ""
msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr ""
msgid "Settings reset. Restarting..."
msgstr ""
msgid "Confirm Clear Cache"
msgstr ""
msgid "Are you sure you want to clear the cache? This action cannot be undone."
msgstr ""
msgid "Cache cleared"
msgstr ""
msgid "Settings saved"
msgstr ""
msgid "Select Theme:"
msgstr ""
msgid "Apply Theme"
msgstr ""
msgid "No link"
msgstr ""
msgid "Unknown"
msgstr ""
msgid "Name:"
msgstr ""
msgid "Description:"
msgstr ""
msgid "Author:"
msgstr ""
msgid "Link:"
msgstr ""
#, python-brace-format
msgid "Theme '{0}' applied successfully"
msgstr ""
#, python-brace-format
msgid "Error applying theme '{0}'"
msgstr ""
msgid "Back"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
msgid "PLAY TIME"
msgstr ""
msgid "full"
msgstr ""
msgid "partial"
msgstr ""
msgid "none"
msgstr ""
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr ""
msgid "Stop"
msgstr ""
msgid "Play"
msgstr ""
msgid "Invalid command format (native)"
msgstr ""
msgid "Invalid command format (flatpak)"
msgstr ""
#, python-brace-format
msgid "File not found: {0}"
msgstr ""
msgid "Cannot launch game while another game is running"
msgstr ""
msgid "Launching"
msgstr ""
msgid "just now"
msgstr ""
msgid "d."
msgstr ""
msgid "h."
msgstr ""
msgid "min."
msgstr ""
msgid "sec."
msgstr ""

Binary file not shown.

View File

@ -0,0 +1,525 @@
# Russian (Russia) translations for PortProtonQT.
# Copyright (C) 2025 boria138
# This file is distributed under the same license as the PortProtonQT
# project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-05-29 17:42+0500\n"
"PO-Revision-Date: 2025-05-29 17:42+0500\n"
"Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
msgid "Remove from Desktop"
msgstr "Удалить с рабочего стола"
msgid "Add to Desktop"
msgstr "Добавить на рабочий стол"
msgid "Edit Shortcut"
msgstr "Редактировать"
msgid "Delete from PortProton"
msgstr "Удалить из PortProton"
msgid "Open Game Folder"
msgstr "Открыть папку с игрой"
msgid "Remove from Menu"
msgstr "Удалить из меню"
msgid "Add to Menu"
msgstr "Добавить в меню"
msgid "Remove from Steam"
msgstr "Удалить из Steam"
msgid "Add to Steam"
msgstr "Добавить в Steam"
msgid "Error"
msgstr "Ошибка"
msgid "PortProton is not found."
msgstr "PortProton не найден."
#, python-brace-format
msgid "No executable command found in .desktop for game: {0}"
msgstr "Не найдено ни одной исполняемой команды для игры: {0}"
#, python-brace-format
msgid "Failed to parse .desktop file for game: {0}"
msgstr "Не удалось удалить файл .desktop: {0}"
#, python-brace-format
msgid "Error reading .desktop file: {0}"
msgstr "Не удалось удалить файл .desktop: {0}"
#, python-brace-format
msgid ".desktop file not found for game: {0}"
msgstr "Файл не найден: {0}"
#, python-brace-format
msgid "Invalid executable command: {0}"
msgstr "Недопустимая исполняемая команда: {0}"
#, python-brace-format
msgid "Executable file not found: {0}"
msgstr "Файл не найден: {0}"
#, python-brace-format
msgid "Failed to parse executable command: {0}"
msgstr "Не удалось удалить игру из меню: {0}"
msgid "Confirm Deletion"
msgstr "Подтвердите удаление"
#, python-brace-format
msgid ""
"Are you sure you want to delete '{0}'? This will remove the .desktop file"
" and custom data."
msgstr ""
"Вы уверены, что хотите удалить '{0}'? Это приведет к удалению файла "
".desktop и настраиваемых данных."
#, python-brace-format
msgid "Could not locate .desktop file for '{0}'"
msgstr "Не удалось найти файл .desktop для '{0}'"
#, python-brace-format
msgid "Failed to delete .desktop file: {0}"
msgstr "Не удалось удалить файл .desktop: {0}"
#, python-brace-format
msgid "Game '{0}' deleted successfully"
msgstr "Игра '{0}' успешно удалена"
#, python-brace-format
msgid "Failed to delete custom data: {0}"
msgstr "Не удалось удалить настраиваемые данные: {0}"
#, python-brace-format
msgid "Game '{0}' added to menu"
msgstr "Игра '{0}' добавлена в меню"
#, python-brace-format
msgid "Failed to add game to menu: {0}"
msgstr "Не удалось добавить игру в меню: {0}"
#, python-brace-format
msgid "Failed to remove game from menu: {0}"
msgstr "Не удалось удалить игру из меню: {0}"
#, python-brace-format
msgid "Game '{0}' removed from menu"
msgstr "Игра '{0}' удалена из меню"
#, python-brace-format
msgid "Game '{0}' added to desktop"
msgstr "Игра '{0}' добавлена на рабочий стол"
#, python-brace-format
msgid "Failed to add game to desktop: {0}"
msgstr "Не удалось добавить игру на рабочий стол: {0}"
#, python-brace-format
msgid "Failed to remove game from Desktop: {0}"
msgstr "Не удалось удалить игру с рабочего стола: {0}"
#, python-brace-format
msgid "Game '{0}' removed from Desktop"
msgstr "Игра '{0}' удалена с рабочего стола"
msgid "Game name and executable path are required."
msgstr "Необходимо указать название игры и путь к исполняемому файлу."
msgid "Failed to generate .desktop file data."
msgstr "Не удалось сгенерировать данные файла .desktop."
#, python-brace-format
msgid "Failed to remove old .desktop file: {0}"
msgstr "Не удалось удалить файл .desktop: {0}"
#, python-brace-format
msgid "Old .desktop file removed for '{0}'"
msgstr "Старый файл .desktop удален для '{0}'"
#, python-brace-format
msgid "Failed to save .desktop file: {0}"
msgstr "Не удалось удалить файл .desktop: {0}"
#, python-brace-format
msgid "Failed to copy cover image: {0}"
msgstr "Не удалось удалить игру из меню: {0}"
msgid "Restart Steam"
msgstr "Перезапустите Steam"
msgid ""
"The game was added successfully.\n"
"Please restart Steam for changes to take effect."
msgstr ""
"Игра была успешно добавлена.\n"
"Пожалуйста, перезапустите Steam, чтобы изменения вступили в силу."
msgid ""
"The game was removed successfully.\n"
"Please restart Steam for changes to take effect."
msgstr ""
"Игра была успешно удалена..\n"
"Пожалуйста, перезапустите Steam, чтобы изменения вступили в силу."
#, python-brace-format
msgid "Opened folder for '{0}'"
msgstr "Открытие папки для '{0}'"
#, python-brace-format
msgid "Failed to open game folder: {0}"
msgstr "Не удалось открыть папку для игры: {0}"
msgid "Edit Game"
msgstr "Редактировать игру"
msgid "Add Game"
msgstr "Добавить игру"
msgid "Game Name:"
msgstr "Имя игры:"
msgid "Browse..."
msgstr "Обзор..."
msgid "Path to Executable:"
msgstr "Путь к исполняемому файлу:"
msgid "Custom Cover:"
msgstr "Обложка:"
msgid "Cover Preview:"
msgstr "Предпросмотр обложки:"
msgid "Select Executable"
msgstr "Выберите исполняемый файл"
msgid "Select Cover Image"
msgstr "Выберите обложку"
msgid "Invalid image"
msgstr "Недопустимое изображение"
msgid "No cover selected"
msgstr "Обложка не выбрана"
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr "Запустить игру \"{name}\" с помощью PortProton"
msgid "Loading Epic Games Store games..."
msgstr "Загрузка игр из Epic Games Store..."
msgid "No description available"
msgstr "Описание не найдено"
msgid "Never"
msgstr "Никогда"
msgid "Supported"
msgstr "Поддерживается"
msgid "Running"
msgstr "Запускается"
msgid "Planned"
msgstr "Планируется"
msgid "Broken"
msgstr "Сломано"
msgid "Denied"
msgstr "Отказано"
msgid "Platinum"
msgstr "Платина"
msgid "Gold"
msgstr "Золото"
msgid "Silver"
msgstr "Серебро"
msgid "Bronze"
msgstr "Бронза"
msgid "Pending"
msgstr "В ожидании"
msgid "Library"
msgstr "Библиотека"
msgid "Auto Install"
msgstr "Автоустановка"
msgid "Emulators"
msgstr "Эмуляторы"
msgid "Wine Settings"
msgstr "Настройки wine"
msgid "PortProton Settings"
msgstr "Настройки PortProton"
msgid "Themes"
msgstr "Темы"
msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..."
msgid "Loading PortProton games..."
msgstr "Загрузка игр из PortProton..."
msgid "Unknown Game"
msgstr "Неизвестная игра"
msgid "Game Library"
msgstr "Игровая библиотека"
msgid "Find Games ..."
msgstr "Найти игры..."
msgid "Here you can configure automatic game installation..."
msgstr "Здесь можно настроить автоматическую установку игр..."
msgid "List of available emulators and their configuration..."
msgstr "Список доступных эмуляторов и их настройка..."
msgid "Various Wine parameters and versions..."
msgstr "Различные параметры и версии wine..."
msgid "Main PortProton parameters..."
msgstr "Основные параметры PortProton..."
msgid "detailed"
msgstr "детальный"
msgid "brief"
msgstr "упрощённый"
msgid "Time Detail Level:"
msgstr "Уровень детализации вывода времени:"
msgid "last launch"
msgstr "последний запуск"
msgid "playtime"
msgstr "время игры"
msgid "alphabetical"
msgstr "алфавитный"
msgid "favorites"
msgstr "избранное"
msgid "Games Sort Method:"
msgstr "Метод сортировки игр:"
msgid "all"
msgstr "все"
msgid "Games Display Filter:"
msgstr "Фильтр игр:"
msgid "Proxy URL"
msgstr "Адрес прокси"
msgid "Proxy URL:"
msgstr "Адрес прокси:"
msgid "Proxy Username"
msgstr "Имя пользователя прокси"
msgid "Proxy Username:"
msgstr "Имя пользователя прокси:"
msgid "Proxy Password"
msgstr "Пароль прокси"
msgid "Proxy Password:"
msgstr "Пароль прокси:"
msgid "Launch Application in Fullscreen"
msgstr "Запуск приложения в полноэкранном режиме"
msgid "Application Fullscreen Mode:"
msgstr "Режим полноэкранного отображения приложения:"
msgid "Open Legendary Login"
msgstr "Открыть браузер для входа в Legendary"
msgid "Legendary Authentication:"
msgstr "Авторизация в Legendary:"
msgid "Enter Legendary Authorization Code"
msgstr "Введите код авторизации Legendary"
msgid "Authorization Code:"
msgstr "Код авторизации:"
msgid "Submit Code"
msgstr "Отправить код"
msgid "Save Settings"
msgstr "Сохранить настройки"
msgid "Reset Settings"
msgstr "Сбросить настройки"
msgid "Clear Cache"
msgstr "Очистить кэш"
msgid "Opened Legendary login page in browser"
msgstr "Открытие страницы входа в Legendary в браузере"
msgid "Failed to open Legendary login page"
msgstr "Не удалось открыть страницу входа в Legendary"
msgid "Please enter an authorization code"
msgstr "Пожалуйста, введите код авторизации"
msgid "Successfully authenticated with Legendary"
msgstr "Успешная аутентификация с Legendary"
#, python-brace-format
msgid "Legendary authentication failed: {0}"
msgstr "Сбой аутентификации в Legendary: {0}"
msgid "Legendary executable not found"
msgstr "Не найден исполняемый файл Legendary"
msgid "Unexpected error during authentication"
msgstr "Неожиданная ошибка при аутентификации"
msgid "Confirm Reset"
msgstr "Подтвердите удаление"
msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr ""
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
"отменить."
msgid "Settings reset. Restarting..."
msgstr "Настройки сброшены. Перезапуск..."
msgid "Confirm Clear Cache"
msgstr "Подтвердите очистку кэша"
msgid "Are you sure you want to clear the cache? This action cannot be undone."
msgstr "Вы уверены, что хотите очистить кэш? Это действие нельзя отменить."
msgid "Cache cleared"
msgstr "Кэш очищен"
msgid "Settings saved"
msgstr "Настройки сохранены"
msgid "Select Theme:"
msgstr "Выбрать тему:"
msgid "Apply Theme"
msgstr "Применить тему"
msgid "No link"
msgstr "Нет ссылки"
msgid "Unknown"
msgstr "Неизвестен"
msgid "Name:"
msgstr "Название:"
msgid "Description:"
msgstr "Описание:"
msgid "Author:"
msgstr "Автор:"
msgid "Link:"
msgstr "Ссылка:"
#, python-brace-format
msgid "Theme '{0}' applied successfully"
msgstr "Тема '{0}' применена успешно"
#, python-brace-format
msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'"
msgid "Back"
msgstr "Назад"
msgid "LAST LAUNCH"
msgstr "Последний запуск"
msgid "PLAY TIME"
msgstr "Время игры"
msgid "full"
msgstr "полная"
msgid "partial"
msgstr "частичная"
msgid "none"
msgstr "отсутствует"
#, python-brace-format
msgid "Gamepad Support: {0}"
msgstr "Поддержка геймпадов: {0}"
msgid "Stop"
msgstr "Остановить"
msgid "Play"
msgstr "Играть"
msgid "Invalid command format (native)"
msgstr "Неправильный формат команды (нативная версия)"
msgid "Invalid command format (flatpak)"
msgstr "Неправильный формат команды (flatpak)"
#, python-brace-format
msgid "File not found: {0}"
msgstr "Файл не найден: {0}"
msgid "Cannot launch game while another game is running"
msgstr "Невозможно запустить игру пока запущена другая"
msgid "Launching"
msgstr "Идёт запуск"
msgid "just now"
msgstr "только что"
msgid "d."
msgstr "д."
msgid "h."
msgstr "ч."
msgid "min."
msgstr "мин."
msgid "sec."
msgstr "сек."

View File

@ -0,0 +1,74 @@
import gettext
from pathlib import Path
import locale
from babel import Locale
LOCALE_MAP = {
'ru': 'russian',
'en': 'english',
'fr': 'french',
'de': 'german',
'es': 'spanish',
'it': 'italian',
'zh': 'schinese',
'zh_Hant': 'tchinese',
'ja': 'japanese',
'ko': 'koreana',
'pt': 'brazilian',
'pl': 'polish',
'nl': 'dutch',
'sv': 'swedish',
'no': 'norwegian',
'da': 'danish',
'fi': 'finnish',
'cs': 'czech',
'hu': 'hungarian',
'tr': 'turkish',
'ro': 'romanian',
'th': 'thai',
'uk': 'ukrainian',
'bg': 'bulgarian',
'el': 'greek',
}
translate = gettext.translation(
domain="messages",
localedir = Path(__file__).parent / "locales",
fallback=True,
)
_ = translate.gettext
def get_system_locale():
"""Возвращает системную локаль, например, 'ru_RU'. Если не удаётся определить возвращает 'en'."""
loc = locale.getdefaultlocale()[0]
return loc if loc else 'en'
def get_steam_language():
try:
# Babel автоматически разбирает сложные локали, например, 'zh_Hant_HK' → 'zh_Hant'
system_locale = get_system_locale()
if system_locale:
locale = Locale.parse(system_locale)
# Используем только языковой код ('ru', 'en', и т.д.)
language_code = locale.language
return LOCALE_MAP.get(language_code, 'english')
except Exception as e:
print(f"Failed to detect locale: {e}")
# Если что-то пошло не так — используем английский по умолчанию
return 'english'
def get_egs_language():
try:
# Babel автоматически разбирает сложные локали, например, 'zh_Hant_HK' → 'zh_Hant'
system_locale = get_system_locale()
if system_locale:
locale = Locale.parse(system_locale)
# Используем только языковой код ('ru', 'en', и т.д.)
language_code = locale.language
return language_code
except Exception as e:
print(f"Failed to detect locale: {e}")
# Если что-то пошло не так — используем английский по умолчанию
return 'en'

16
portprotonqt/logger.py Normal file
View File

@ -0,0 +1,16 @@
import logging
def setup_logger():
"""Настройка базовой конфигурации логирования."""
logging.basicConfig(
level=logging.INFO,
format='[%(levelname)s] %(message)s',
handlers=[logging.StreamHandler()]
)
def get_logger(name):
"""Возвращает логгер для указанного модуля."""
return logging.getLogger(name)
# Инициализация логгера при импорте модуля
setup_logger()

1684
portprotonqt/main_window.py Normal file

File diff suppressed because it is too large Load Diff

1134
portprotonqt/steam_api.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,286 @@
import importlib.util
import os
from portprotonqt.logger import get_logger
from PySide6.QtSvg import QSvgRenderer
from PySide6.QtGui import QIcon, QColor, QFontDatabase, QPixmap, QPainter
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
logger = get_logger(__name__)
# Папка, где располагаются все дополнительные темы
xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
THEMES_DIRS = [
os.path.join(xdg_data_home, "PortProtonQT", "themes"),
os.path.join(os.path.dirname(os.path.abspath(__file__)), "themes")
]
def list_themes():
"""
Возвращает список доступных тем (названий папок) из каталогов THEMES_DIRS.
"""
themes = []
for themes_dir in THEMES_DIRS:
if os.path.exists(themes_dir):
for entry in os.listdir(themes_dir):
theme_path = os.path.join(themes_dir, entry)
if os.path.isdir(theme_path) and os.path.exists(os.path.join(theme_path, "styles.py")):
themes.append(entry)
return themes
def load_theme_screenshots(theme_name):
"""
Загружает все скриншоты из папки "screenshots", расположенной в папке темы.
Возвращает список кортежей (pixmap, filename).
Если папка отсутствует или пуста, возвращается пустой список.
"""
screenshots = []
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
screenshots_folder = os.path.join(theme_folder, "images", "screenshots")
if os.path.exists(screenshots_folder) and os.path.isdir(screenshots_folder):
for file in os.listdir(screenshots_folder):
screenshot_path = os.path.join(screenshots_folder, file)
if os.path.isfile(screenshot_path):
pixmap = QPixmap(screenshot_path)
if not pixmap.isNull():
screenshots.append((pixmap, file))
return screenshots
def load_theme_fonts(theme_name):
"""
Загружает все шрифты выбранной темы.
:param theme_name: Имя темы.
"""
QFontDatabase.removeAllApplicationFonts()
fonts_folder = None
if theme_name == "standart":
base_dir = os.path.dirname(os.path.abspath(__file__))
fonts_folder = os.path.join(base_dir, "themes", "standart", "fonts")
else:
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
possible_fonts_folder = os.path.join(theme_folder, "fonts")
if os.path.exists(possible_fonts_folder):
fonts_folder = possible_fonts_folder
break
if not fonts_folder or not os.path.exists(fonts_folder):
logger.error(f"Папка fonts не найдена для темы '{theme_name}'")
return
for filename in os.listdir(fonts_folder):
if filename.lower().endswith((".ttf", ".otf")):
font_path = os.path.join(fonts_folder, filename)
font_id = QFontDatabase.addApplicationFont(font_path)
if font_id != -1:
families = QFontDatabase.applicationFontFamilies(font_id)
logger.info(f"Шрифт {filename} успешно загружен: {families}")
else:
logger.error(f"Ошибка загрузки шрифта: {filename}")
def load_logo():
logo_path = None
base_dir = os.path.dirname(os.path.abspath(__file__))
logo_path = os.path.join(base_dir, "themes", "standart", "images", "theme_logo.svg")
file_extension = os.path.splitext(logo_path)[1].lower()
if file_extension == ".svg":
renderer = QSvgRenderer(logo_path)
if not renderer.isValid():
logger.error(f"Ошибка загрузки SVG логотипа: {logo_path}")
return None
pixmap = QPixmap(128, 128)
pixmap.fill(QColor(0, 0, 0, 0))
painter = QPainter(pixmap)
renderer.render(painter)
painter.end()
return pixmap
class ThemeWrapper:
"""
Обёртка для кастомной темы с поддержкой метаинформации.
При обращении к атрибуту сначала ищется его наличие в кастомной теме,
если атрибут отсутствует, значение берётся из стандартного модуля стилей.
"""
def __init__(self, custom_theme, metainfo=None):
self.custom_theme = custom_theme
self.metainfo = metainfo or {}
self.screenshots = load_theme_screenshots(self.metainfo.get("name", ""))
def __getattr__(self, name):
if hasattr(self.custom_theme, name):
return getattr(self.custom_theme, name)
import portprotonqt.themes.standart.styles as default_styles
return getattr(default_styles, name)
def load_theme(theme_name):
"""
Динамически загружает модуль стилей выбранной темы и метаинформацию.
Если выбрана стандартная тема, импортируется оригинальный styles.py.
Для кастомных тем возвращается обёртка, которая подставляет недостающие атрибуты.
"""
if theme_name == "standart":
import portprotonqt.themes.standart.styles as default_styles
return default_styles
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(themes_dir, theme_name)
styles_file = os.path.join(theme_folder, "styles.py")
if os.path.exists(styles_file):
spec = importlib.util.spec_from_file_location("theme_styles", styles_file)
if spec is None or spec.loader is None:
continue
custom_theme = importlib.util.module_from_spec(spec)
spec.loader.exec_module(custom_theme)
meta = load_theme_metainfo(theme_name)
wrapper = ThemeWrapper(custom_theme, metainfo=meta)
wrapper.screenshots = load_theme_screenshots(theme_name)
return wrapper
raise FileNotFoundError(f"Файл стилей не найден для темы '{theme_name}'")
class ThemeManager:
"""
Класс для управления темами приложения.
Позволяет получить список доступных тем, загрузить и применить выбранную тему.
"""
def __init__(self):
self.current_theme_name = None
self.current_theme_module = None
def get_available_themes(self):
"""Возвращает список доступных тем."""
return list_themes()
def get_theme_logo(self):
"""Возвращает логотип для текущей или указанной темы."""
return load_logo()
def apply_theme(self, theme_name):
"""
Применяет выбранную тему: загружает модуль стилей, шрифты и логотип.
Если загрузка прошла успешно, сохраняет выбранную тему в конфигурации.
:param theme_name: Имя темы.
:return: Загруженный модуль темы (или обёртка).
"""
theme_module = load_theme(theme_name)
load_theme_fonts(theme_name)
self.current_theme_name = theme_name
self.current_theme_module = theme_module
save_theme_to_config(theme_name)
logger.info(f"Тема '{theme_name}' успешно применена")
return theme_module
def get_icon(self, icon_name, theme_name=None, as_path=False):
"""
Возвращает QIcon из папки icons текущей темы,
а если файл не найден, то из стандартной темы.
Если as_path=True, возвращает путь к иконке вместо QIcon.
"""
icon_path = None
theme_name = theme_name or self.current_theme_name
supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
has_extension = any(icon_name.lower().endswith(ext) for ext in supported_extensions)
base_name = icon_name if has_extension else icon_name
# Поиск иконки в папке текущей темы
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(str(themes_dir), str(theme_name))
icons_folder = os.path.join(theme_folder, "images", "icons")
# Если передано имя с расширением, проверяем только этот файл
if has_extension:
candidate = os.path.join(icons_folder, str(base_name))
if os.path.exists(candidate):
icon_path = candidate
break
else:
# Проверяем все поддерживаемые расширения
for ext in supported_extensions:
candidate = os.path.join(icons_folder, str(base_name) + str(ext))
if os.path.exists(candidate):
icon_path = candidate
break
if icon_path:
break
# Если не нашли используем стандартную тему
if not icon_path:
base_dir = os.path.dirname(os.path.abspath(__file__))
standard_icons_folder = os.path.join(base_dir, "themes", "standart", "images", "icons")
# Аналогично проверяем в стандартной теме
if has_extension:
icon_path = os.path.join(standard_icons_folder, base_name)
if not os.path.exists(icon_path):
icon_path = None
else:
for ext in supported_extensions:
candidate = os.path.join(standard_icons_folder, base_name + ext)
if os.path.exists(candidate):
icon_path = candidate
break
# Если иконка всё равно не найдена
if not icon_path or not os.path.exists(icon_path):
logger.error(f"Предупреждение: иконка '{icon_name}' не найдена")
return QIcon() if not as_path else None
if as_path:
return icon_path
return QIcon(icon_path)
def get_theme_image(self, image_name, theme_name=None):
"""
Возвращает путь к изображению из папки текущей темы.
Если не найдено, проверяет стандартную тему.
Принимает название иконки без расширения и находит соответствующий файл
с поддерживаемым расширением (.svg, .png, .jpg и др.).
"""
image_path = None
theme_name = theme_name or self.current_theme_name
supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
has_extension = any(image_name.lower().endswith(ext) for ext in supported_extensions)
base_name = image_name if has_extension else image_name
# Check theme-specific images
for themes_dir in THEMES_DIRS:
theme_folder = os.path.join(str(themes_dir), str(theme_name))
images_folder = os.path.join(theme_folder, "images")
if has_extension:
candidate = os.path.join(images_folder, str(base_name))
if os.path.exists(candidate):
image_path = candidate
break
else:
for ext in supported_extensions:
candidate = os.path.join(images_folder, str(base_name) + str(ext))
if os.path.exists(candidate):
image_path = candidate
break
if image_path:
break
# Check standard theme
if not image_path:
base_dir = os.path.dirname(os.path.abspath(__file__))
standard_images_folder = os.path.join(base_dir, "themes", "standart", "images")
if has_extension:
image_path = os.path.join(standard_images_folder, base_name)
if not os.path.exists(image_path):
image_path = None
else:
for ext in supported_extensions:
candidate = os.path.join(standard_images_folder, base_name + ext)
if os.path.exists(candidate):
image_path = candidate
break
return image_path

Binary file not shown.

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 213 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 164 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>

After

Width:  |  Height:  |  Size: 570 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@ -0,0 +1 @@
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z"/></svg>

After

Width:  |  Height:  |  Size: 392 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 660 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 208 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 165 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848t-1.5848 3.8596q-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z"/></svg>

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

View File

@ -0,0 +1,5 @@
[Metainfo]
author = BlackSnaker
author_link =
description = Стандартная тема PortProtonQT (светлый вариант)
name = Light

View File

@ -0,0 +1,558 @@
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
theme_manager = ThemeManager()
current_theme_name = read_theme_from_config()
# КОНСТАНТЫ
favoriteLabelSize = 48, 48
pixmapsScaledSize = 60, 60
# СТИЛЬ ШАПКИ ГЛАВНОГО ОКНА
MAIN_WINDOW_HEADER_STYLE = """
QFrame {
background: transparent;
border: 10px solid rgba(255, 255, 255, 0.10);
border-bottom: 0px solid rgba(255, 255, 255, 0.15);
border-top-left-radius: 30px;
border-top-right-radius: 30px;
border: none;
}
"""
# СТИЛЬ ЗАГОЛОВКА (ЛОГО) В ШАПКЕ
TITLE_LABEL_STYLE = """
QLabel {
font-family: 'RASKHAL';
font-size: 38px;
margin: 0 0 0 0;
color: #007AFF;
}
"""
# СТИЛЬ ОБЛАСТИ НАВИГАЦИИ (КНОПКИ ВКЛАДОК)
NAV_WIDGET_STYLE = """
QWidget {
background: #ffffff;
border-bottom: 0px solid rgba(0, 0, 0, 0.10);
}
"""
# СТИЛЬ КНОПОК ВКЛАДОК НАВИГАЦИИ
NAV_BUTTON_STYLE = """
NavLabel {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(242, 242, 242, 0.5),
stop:1 rgba(232, 232, 232, 0.5));
padding: 10px 10px;
margin: 10px 0 10px 10px;
color: #333333;
font-size: 16px;
font-family: 'Poppins';
text-transform: uppercase;
border: 1px solid rgba(179, 179, 179, 0.4);
border-radius: 15px;
}
NavLabel[checked = true] {
background: rgba(0,122,255,0.25);
color: #002244;
font-weight: bold;
border-radius: 15px;
}
NavLabel:hover {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(0,122,255,0.12),
stop:1 rgba(0,122,255,0.08));
color: #002244;
}
"""
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН) И QLabel
MAIN_WINDOW_STYLE = """
QMainWindow {
background: none;
}
QLabel {
color: #333333;
}
"""
# СТИЛЬ ПОЛЯ ПОИСКА
SEARCH_EDIT_STYLE = """
QLineEdit {
background-color: rgba(30, 30, 30, 0.50);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
padding: 7px 14px;
font-family: 'Poppins';
font-size: 16px;
color: #ffffff;
}
QLineEdit:focus {
border: 1px solid rgba(0,122,255,0.25);
}
"""
# ОТКЛЮЧАЕМ РАМКУ У QScrollArea
SCROLL_AREA_STYLE = """
QWidget {
background: transparent;
}
QScrollBar:vertical {
width: 10px;
border: 0px solid;
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}
QScrollBar::handle:vertical {
background: rgba(255, 255, 255, 0.7);
border: 0px solid;
border-radius: 5px;
}
QScrollBar::add-line:vertical {
border: 0px solid;
background: none;
}
QScrollBar::sub-line:vertical {
border: 0px solid;
background: none;
}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {
border: 0px solid;
width: 3px;
height: 3px;
background: none;
}
QScrollBar:horizontal {
height: 10px;
border: 0px solid;
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}
QScrollBar::handle:horizontal {
background: #bebebe;
border: 0px solid;
border-radius: 5px;
}
QScrollBar::add-line:horizontal {
border: 0px solid;
background: none;
}
QScrollBar::sub-line:horizontal {
border: 0px solid;
background: none;
}
QScrollBar::up-arrow:horizontal, QScrollBar::down-arrow:horizontal {
border: 0px solid;
width: 3px;
height: 3px;
background: none;
}
"""
# SLIDER_SIZE_STYLE
SLIDER_SIZE_STYLE= """
QWidget {
background: transparent;
height: 25px;
}
QSlider::groove:horizontal {
border: 0px solid;
border-radius: 3px;
height: 6px; /* the groove expands to the size of the slider by default. by giving it a height, it has a fixed size */
background: rgba(20, 20, 20, 0.30);
margin: 6px 0;
}
QSlider::handle:horizontal {
background: #bebebe;
border: 0px solid;
width: 18px;
height: 18px;
margin: -6px 0; /* handle is placed by default on the contents rect of the groove. Expand outside the groove */
border-radius: 9px;
}
"""
# СТИЛЬ ОБЛАСТИ ДЛЯ КАРТОЧЕК ИГР (QWidget)
LIST_WIDGET_STYLE = """
QWidget {
background: none;
border: 0px solid rgba(255, 255, 255, 0.10);
border-radius: 25px;
}
"""
# ЗАГОЛОВОК "БИБЛИОТЕКА" НА ВКЛАДКЕ
INSTALLED_TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627;"
# СТИЛЬ КНОПОК "СОХРАНЕНИЯ, ПРИМЕНЕНИЯ И Т.Д."
ACTION_BUTTON_STYLE = """
QPushButton {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(242, 242, 242, 0.5),
stop:1 rgba(232, 232, 232, 0.5));
border: 1px solid rgba(179, 179, 179, 0.4);
border-radius: 10px;
color: #232627;
font-size: 16px;
font-family: 'Poppins';
padding: 8px 16px;
}
QPushButton:hover {
background: rgba(0,122,255,0.25);
}
QPushButton:pressed {
background: rgba(0,122,255,0.25);
}
"""
# ТЕКСТОВЫЕ СТИЛИ: ЗАГОЛОВКИ И ОСНОВНОЙ КОНТЕНТ
TAB_TITLE_STYLE = "font-family: 'Poppins'; font-size: 24px; color: #232627; background-color: none;"
CONTENT_STYLE = """
QLabel {
font-family: 'Poppins';
font-size: 16px;
color: #232627;
background-color: none;
border-bottom: 1px solid rgba(165, 165, 165, 0.7);
padding-bottom: 15px;
}
"""
# СТИЛЬ ОСНОВНЫХ СТРАНИЦ
# LIBRARY_WIDGET_STYLE
LIBRARY_WIDGET_STYLE= """
QWidget {
background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
border-radius: 0px;
}
"""
# CONTAINER_STYLE
CONTAINER_STYLE= """
QWidget {
background-color: none;
}
"""
# OTHER_PAGES_WIDGET_STYLE
OTHER_PAGES_WIDGET_STYLE= """
QWidget {
background: qlineargradient(spread:pad, x1:0.162, y1:0.0313409, x2:1, y2:1, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(253, 252, 255, 255));
border-radius: 0px;
}
"""
# CAROUSEL_WIDGET_STYLE
CAROUSEL_WIDGET_STYLE= """
QWidget {
background: qlineargradient(spread:pad, x1:0.099, y1:0.119, x2:0.917, y2:0.936149, stop:0 rgba(215, 235, 255, 255), stop:1 rgba(217, 193, 255, 255));
border-radius: 0px;
}
"""
# ФОН ДЛЯ ДЕТАЛЬНОЙ СТРАНИЦЫ, ЕСЛИ ОБЛОЖКА НЕ ЗАГРУЖЕНА
DETAIL_PAGE_NO_COVER_STYLE = "background: rgba(20,20,20,0.95); border-radius: 15px;"
# СТИЛЬ КНОПКИ "ДОБАВИТЬ ИГРУ" И "НАЗАД" НА ДЕТАЛЬНОЙ СТРАНИЦЕ И БИБЛИОТЕКИ
ADDGAME_BACK_BUTTON_STYLE = """
QPushButton {
background: rgba(20, 20, 20, 0.40);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
color: #ffffff;
font-size: 16px;
font-family: 'Poppins';
padding: 4px 16px;
}
QPushButton:hover {
background: rgba(0,122,255,0.25);
}
QPushButton:pressed {
background: rgba(0,122,255,0.25);
}
"""
# ОСНОВНОЙ ФРЕЙМ ДЕТАЛЕЙ ИГРЫ
DETAIL_CONTENT_FRAME_STYLE = """
QFrame {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(20, 20, 20, 0.40),
stop:1 rgba(20, 20, 20, 0.35));
border: 0px solid rgba(255, 255, 255, 0.10);
border-radius: 15px;
}
"""
# ФРЕЙМ ПОД ОБЛОЖКОЙ
COVER_FRAME_STYLE = """
QFrame {
background: rgba(30, 30, 30, 0.80);
border-radius: 15px;
border: 0px solid rgba(255, 255, 255, 0.15);
}
"""
# СКРУГЛЕНИЕ LABEL ПОД ОБЛОЖКУ
COVER_LABEL_STYLE = "border-radius: 100px;"
# ВИДЖЕТ ДЕТАЛЕЙ (ТЕКСТ, ОПИСАНИЕ)
DETAILS_WIDGET_STYLE = "background: rgba(20,20,20,0.40); border-radius: 15px; padding: 10px;"
# НАЗВАНИЕ (ЗАГОЛОВОК) НА ДЕТАЛЬНОЙ СТРАНИЦЕ
DETAIL_PAGE_TITLE_STYLE = "font-family: 'Orbitron'; font-size: 32px; color: #007AFF;"
# ЛИНИЯ-РАЗДЕЛИТЕЛЬ
DETAIL_PAGE_LINE_STYLE = "color: rgba(255,255,255,0.12); margin: 10px 0;"
# ТЕКСТ ОПИСАНИЯ
DETAIL_PAGE_DESC_STYLE = "font-family: 'Poppins'; font-size: 16px; color: #ffffff; line-height: 1.5;"
# СТИЛЬ КНОПКИ "ИГРАТЬ"
PLAY_BUTTON_STYLE = """
QPushButton {
background: rgba(20, 20, 20, 0.40);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
font-size: 18px;
color: #ffffff;
font-weight: bold;
font-family: 'Orbitron';
padding: 8px 16px;
min-width: 120px;
min-height: 40px;
}
QPushButton:hover {
background: rgba(0,122,255,0.25);
}
QPushButton:pressed {
background: rgba(0,122,255,0.25);
}
"""
# СТИЛЬ КНОПКИ "ОБЗОР..." В ДИАЛОГЕ "ДОБАВИТЬ ИГРУ"
DIALOG_BROWSE_BUTTON_STYLE = """
QPushButton {
background: rgba(20, 20, 20, 0.40);
border: 0px solid rgba(255, 255, 255, 0.20);
border-radius: 15px;
color: #ffffff;
font-size: 16px;
padding: 5px 10px;
}
QPushButton:hover {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(0,122,255,0.20),
stop:1 rgba(0,122,255,0.15));
}
QPushButton:pressed {
background: rgba(20, 20, 20, 0.60);
border: 0px solid rgba(255, 255, 255, 0.25);
}
"""
# СТИЛЬ КАРТОЧКИ ИГРЫ (GAMECARD)
GAME_CARD_WINDOW_STYLE = """
QFrame {
border-radius: 20px;
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
stop:0 rgba(255, 255, 255, 0.3),
stop:1 rgba(249, 249, 249, 0.3));
border: 0px solid rgba(255, 255, 255, 0.4);
}
"""
# НАЗВАНИЕ В КАРТОЧКЕ (QLabel)
GAME_CARD_NAME_LABEL_STYLE = """
QLabel {
color: #333333;
font-family: 'Orbitron';
font-size: 16px;
font-weight: bold;
background-color: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(242, 242, 242, 0.5),
stop:1 rgba(232, 232, 232, 0.5));
border-radius: 20px;
padding: 7px;
qproperty-wordWrap: true;
}
"""
# ДОПОЛНИТЕЛЬНЫЕ СТИЛИ ИНФОРМАЦИИ НА СТРАНИЦЕ ИГР
LAST_LAUNCH_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
LAST_LAUNCH_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
PLAY_TIME_TITLE_STYLE = "font-family: 'Poppins'; font-size: 11px; color: #bbbbbb; text-transform: uppercase; letter-spacing: 0.75px; margin-bottom: 2px;"
PLAY_TIME_VALUE_STYLE = "font-family: 'Poppins'; font-size: 13px; color: #ffffff; font-weight: 600; letter-spacing: 0.75px;"
GAMEPAD_SUPPORT_VALUE_STYLE = """
font-family: 'Poppins'; font-size: 12px; color: #00ff00;
font-weight: bold; background: rgba(0, 0, 0, 0.3);
border-radius: 5px; padding: 4px 8px;
"""
# СТИЛИ ПОЛНОЭКРАНОГО ПРЕВЬЮ СКРИНШОТОВ ТЕМЫ
PREV_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
NEXT_BUTTON_STYLE="background-color: rgba(0, 0, 0, 0.5); color: white; border: none;"
CAPTION_LABEL_STYLE="color: white; font-size: 16px;"
# СТИЛИ БЕЙДЖА PROTONDB НА КАРТОЧКЕ
def get_protondb_badge_style(tier):
tier = tier.lower()
tier_colors = {
"platinum": {"background": "rgba(255,255,255,0.9)", "color": "black"},
"gold": {"background": "rgba(253,185,49,0.7)", "color": "black"},
"silver": {"background": "rgba(169,169,169,0.8)", "color": "black"},
"bronze": {"background": "rgba(205,133,63,0.7)", "color": "black"},
"borked": {"background": "rgba(255,0,0,0.7)", "color": "black"},
"pending": {"background": "rgba(160,82,45,0.7)", "color": "black"}
}
colors = tier_colors.get(tier, {"background": "rgba(0, 0, 0, 0.5)", "color": "white"})
return f"""
qproperty-alignment: AlignCenter;
background-color: {colors["background"]};
color: {colors["color"]};
font-size: 14px;
border-radius: 5px;
font-family: 'Poppins';
font-weight: bold;
"""
# СТИЛИ БЕЙДЖА STEAM
STEAM_BADGE_STYLE= """
qproperty-alignment: AlignCenter;
background: rgba(0, 0, 0, 0.5);
color: white;
font-size: 14px;
border-radius: 5px;
font-family: 'Poppins';
font-weight: bold;
"""
# Favorite Star
FAVORITE_LABEL_STYLE = "color: gold; font-size: 32px; background: transparent; border: none;"
# СТИЛИ ДЛЯ QMessageBox (ОКНА СООБЩЕНИЙ)
MESSAGE_BOX_STYLE = """
QMessageBox {
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 rgba(40, 40, 40, 0.95),
stop:1 rgba(25, 25, 25, 0.95));
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 12px;
}
QMessageBox QLabel {
color: #ffffff;
font-family: 'Poppins';
font-size: 16px;
}
QMessageBox QPushButton {
background: rgba(30, 30, 30, 0.6);
border: 1px solid rgba(165, 165, 165, 0.7);
border-radius: 8px;
color: #ffffff;
font-family: 'Poppins';
padding: 8px 20px;
min-width: 80px;
}
QMessageBox QPushButton:hover {
background: #09bec8;
border-color: rgba(255, 255, 255, 0.3);
}
"""
# СТИЛИ ДЛЯ ВКЛАДКИ НАСТРОЕК PORTPROTON
# PARAMS_TITLE_STYLE
PARAMS_TITLE_STYLE = "color: #232627; font-family: 'Poppins'; font-size: 16px; padding: 10px; background: transparent;"
PROXY_INPUT_STYLE = """
QLineEdit {
background: rgba(20, 20, 20, 0.40);
border: 0px solid rgba(165, 165, 165, 0.7);
border-radius: 10px;
height: 34px;
padding-left: 12px;
color: #ffffff;
font-family: 'Poppins';
font-size: 16px;
}
QLineEdit:focus {
border: 1px solid rgba(0,122,255,0.25);
}
QMenu {
border: 1px solid rgba(255, 255, 255, 0.5);
padding: 5px 10px;
background: #c7c7c7;
}
QMenu::item {
padding: 0px 10px;
border: 10px solid transparent; /* reserve space for selection border */
}
QMenu::item:selected {
background: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
}
"""
SETTINGS_COMBO_STYLE = f"""
QComboBox {{
background: rgba(20, 20, 20, 0.40);
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 10px;
height: 34px;
padding-left: 12px;
color: #ffffff;
font-family: 'Poppins';
font-size: 16px;
min-width: 120px;
combobox-popup: 0;
}}
QComboBox:on {{
background: rgba(20, 20, 20, 0.40);
border: 1px solid rgba(165, 165, 165, 0.7);
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}}
QComboBox:hover {{
border: 1px solid rgba(165, 165, 165, 0.7);
}}
QComboBox::drop-down {{
subcontrol-origin: padding;
subcontrol-position: center right;
border-left: 1px solid rgba(255, 255, 255, 0.5);
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow {{
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow:on {{
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox QAbstractItemView {{
outline: none;
border: 1px solid rgba(165, 165, 165, 0.7);
border-top-style: none;
}}
QListView {{
background: #ffffff;
}}
QListView::item {{
padding: 7px 7px 7px 12px;
border-radius: 0px;
color: #232627;
}}
QListView::item:hover {{
background: rgba(0,122,255,0.25);
}}
QListView::item:selected {{
background: rgba(0,122,255,0.25);
}}
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m3.3334 1c-1.2926 0-2.3333 1.0408-2.3333 2.3333v9.3333c0 1.2926 1.0408 2.3333 2.3333 2.3333h9.3333c1.2926 0 2.3333-1.0408 2.3333-2.3333v-9.3333c0-1.2926-1.0408-2.3333-2.3333-2.3333zm4.6667 2.4979c0.41261 0 0.75035 0.33774 0.75035 0.75035v3.0014h3.0014c0.41261 0 0.75035 0.33774 0.75035 0.75035s-0.33774 0.75035-0.75035 0.75035h-3.0014v3.0014c0 0.41261-0.33774 0.75035-0.75035 0.75035s-0.75035-0.33774-0.75035-0.75035v-3.0014h-3.0014c-0.41261 0-0.75035-0.33774-0.75035-0.75035s0.33774-0.75035 0.75035-0.75035h3.0014v-3.0014c0-0.41261 0.33774-0.75035 0.75035-0.75035z" fill="#fff" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 734 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12.13 2.2617-5.7389 5.7389 5.7389 5.7389-1.2604 1.2605-7-7 7-7z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 213 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m5.48 11.5 2.52-2.52 2.52 2.52 0.98-0.98-2.52-2.52 2.52-2.52-0.98-0.98-2.52 2.52-2.52-2.52-0.98 0.98 2.52 2.52-2.52 2.52zm2.52 3.5q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-7-7h14z" fill="#b3b3b3"/></svg>

After

Width:  |  Height:  |  Size: 167 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.02 11.22 4.935-4.935-0.98-0.98-3.955 3.955-1.995-1.995-0.98 0.98zm0.98 3.78q-1.4525 0-2.73-0.55125t-2.2225-1.4962-1.4962-2.2225-0.55125-2.73 0.55125-2.73 1.4962-2.2225 2.2225-1.4962 2.73-0.55125 2.73 0.55125 2.2225 1.4962 1.4962 2.2225 0.55125 2.73-0.55125 2.73-1.4962 2.2225-2.2225 1.4962-2.73 0.55125zm0-1.4q2.345 0 3.9725-1.6275t1.6275-3.9725-1.6275-3.9725-3.9725-1.6275-3.9725 1.6275-1.6275 3.9725 1.6275 3.9725 3.9725 1.6275z"/></svg>

After

Width:  |  Height:  |  Size: 570 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m0.52495 13.312c0.03087 1.3036 1.3683 2.0929 2.541 1.4723l9.4303-5.3381c0.51362-0.29103 0.86214-0.82453 0.86214-1.4496 0-0.62514-0.34835-1.1586-0.86214-1.4497l-9.4303-5.3304c-1.1727-0.62059-2.5111 0.16139-2.541 1.4647z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 367 B

View File

@ -0,0 +1 @@
<svg width="64" height="64" version="1.1" viewBox="0 0 64 64" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m31.994 2c-3.5777 0.00874-6.7581 2.209-8.0469 5.5059-6.026 1.9949-11.103 6.1748-14.25 11.576 0.3198-0.03567 0.64498-0.05624 0.9668-0.05664 1.8247 4.03e-4 3.6022 0.57036 5.0801 1.6406 2.053-2.9466 4.892-5.3048 8.2148-6.7754 1.3182 3.2847 4.496 5.4368 8.0352 5.4375 4.7859 5.76e-4 8.6634-3.8782 8.6641-8.6641 0-4.787-3.8774-8.6647-8.6641-8.6641zm14.148 8.4629c0.001493 0.068825 1.08e-4 0.13229 0 0.20117 0 2.0592-0.7314 4.0514-2.0664 5.6191 2.6029 1.9979 4.687 4.6357 6.0332 7.6758-0.03487 0.01246-0.051814 0.019533-0.089844 0.033204-3.239 1.3412-5.3501 4.5078-5.3496 8.0137-5.6e-5 3.5056 2.111 6.6588 5.3496 8 1.0511 0.43551 2.1787 0.66386 3.3164 0.66406 0.37838-3.45e-4 0.74796-0.028635 1.123-0.078125 4.3115-0.56733 7.5408-4.2367 7.541-8.5859-2.29e-4 -0.38522-0.026915-0.77645-0.078125-1.1582-0.41836-3.1252-2.5021-5.7755-5.4395-6.9219-1.8429-5.5649-5.5319-10.293-10.34-13.463zm-35.479 12.867v0.011719c-4.7868-5.8e-4 -8.6645 3.8772-8.6641 8.6641 5.184e-4 3.5694 2.1832 6.7701 5.5078 8.0684 1.9494 5.8885 5.9701 10.844 11.191 14.004-0.02187-0.24765-0.032753-0.49357-0.033203-0.74219 0.0011-1.8915 0.6215-3.7301 1.7656-5.2363-2.8389-2.0415-5.1101-4.8211-6.541-8.0586-0.010718 0.00405-0.023854 0.005164-0.035156 0.007812 3.2771-1.2961 5.4686-4.4709 5.4746-8.043 7e-6 -4.787-3.8793-8.6762-8.666-8.6758zm18.264 2.9746c-1.1128 0.59718-2.0251 1.5031-2.627 2.6133 0.49567 0.91964 0.77734 1.9689 0.77734 3.082 0 1.1135-0.28142 2.168-0.77734 3.0879 0.60218 1.1114 1.5189 2.0216 2.6328 2.6191 0.9175-0.49216 1.9588-0.77148 3.0684-0.77148 1.1032 0 2.1508 0.27284 3.0645 0.75976 1.1182-0.60366 2.032-1.5238 2.6309-2.6445-0.48449-0.91196-0.76367-1.9505-0.76367-3.0508 0-1.1 0.27944-2.1331 0.76367-3.0449-0.59834-1.1194-1.5143-2.0409-2.6309-2.6445-0.91432 0.48781-1.9603 0.76367-3.0645 0.76367-1.1106 3e-6 -2.1561-0.27646-3.0742-0.76953zm19.328 17.041c-2.0547 2.9472-4.8918 5.3038-8.2148 6.7754-1.3171-3.2891-4.504-5.4498-8.0469-5.4492-4.7846 0.0017-8.6634 3.8796-8.6641 8.6641 0 4.7856 3.8788 8.6627 8.6641 8.6641 3.5711 7.48e-4 6.782-2.179 8.0801-5.5059 6.026-1.9954 11.081-6.1633 14.227-11.564-0.3202 0.03625-0.64263 0.05628-0.96484 0.05664-1.822-2.87e-4 -3.6033-0.57345-5.0801-1.6406z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 11.5-4.375-4.375 1.225-1.2688 2.275 2.275v-7.1312h1.75v7.1312l2.275-2.275 1.225 1.2688zm-5.25 3.5q-0.72188 0-1.2359-0.51406-0.51406-0.51406-0.51406-1.2359v-2.625h1.75v2.625h10.5v-2.625h1.75v2.625q0 0.72188-0.51406 1.2359-0.51406 0.51406-1.2359 0.51406z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.396 13.564-2.5415-2.5415c0.94769-1.0626 1.5364-2.4698 1.5364-4.0062 0-3.3169-2.6995-6.0164-6.0164-6.0164-3.3169 0-6.0164 2.6995-6.0164 6.0164s2.6995 6.0164 6.0164 6.0164c1.1774 0 2.2688-0.34469 3.1877-0.91896l2.6421 2.6421c0.15799 0.15799 0.37342 0.24416 0.58871 0.24416 0.21544 0 0.43079-0.08617 0.58874-0.24416 0.34469-0.33033 0.34469-0.86155 0.01512-1.1918zm-11.358-6.5477c0-2.3836 1.9384-4.3364 4.3364-4.3364 2.3979 0 4.3221 1.9528 4.3221 4.3364s-1.9384 4.3364-4.3221 4.3364-4.3364-1.9384-4.3364-4.3364z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 660 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m14.125 8.875h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87502 0.87498-0.87502h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87502 0 0.48386-0.39113 0.87498-0.87498 0.87498zm-2.4133-3.3494c-0.34211 0.34211-0.89513 0.34211-1.2372 0-0.34211-0.34214-0.34211-0.89513 0-1.2373l1.2372-1.2372c0.34211-0.34211 0.89513-0.34211 1.2372 0 0.34211 0.34214 0.34211 0.89513 0 1.2372zm-3.7117 9.4744c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm0-10.5c-0.48389 0-0.87501-0.39113-0.87501-0.87498v-1.75c0-0.48386 0.39113-0.87498 0.87501-0.87498 0.48386 0 0.87498 0.39113 0.87498 0.87498v1.75c0 0.48386-0.39113 0.87498-0.87498 0.87498zm-3.7117 8.449c-0.34211 0.34211-0.8951 0.34211-1.2372 0-0.34211-0.34211-0.34211-0.89513 0-1.2372l1.2372-1.2372c0.34214-0.34211 0.89513-0.34211 1.2373 0 0.34211 0.34211 0.34211 0.89513 0 1.2372zm0-7.4234-1.2372-1.2373c-0.34211-0.34211-0.34211-0.8951 0-1.2372 0.34214-0.34211 0.89513-0.34211 1.2372 0l1.2373 1.2372c0.34211 0.34214 0.34211 0.89513 0 1.2373-0.34214 0.34211-0.89513 0.34211-1.2373 0zm0.21165 2.4744c0 0.48386-0.39113 0.87498-0.87498 0.87498h-1.75c-0.48386 0-0.87498-0.39113-0.87498-0.87498 0-0.48389 0.39113-0.87501 0.87498-0.87501h1.75c0.48386 0 0.87498 0.39113 0.87498 0.87501zm7.2117 2.4744 1.2372 1.2372c0.34211 0.34211 0.34211 0.89598 0 1.2372-0.34211 0.34126-0.89513 0.34211-1.2372 0l-1.2372-1.2372c-0.34211-0.34211-0.34211-0.89513 0-1.2372s0.89513-0.34211 1.2372 0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m7.9881 1.001c-3.6755 0-6.6898 2.835-6.9751 6.4383l3.752 1.5505c0.31806-0.21655 0.70174-0.34431 1.1152-0.34431 0.03671 0 0.07309 0.0017 0.1098 0.0033l1.6691-2.4162v-0.03456c0-1.4556 1.183-2.639 2.639-2.639 1.4547 0 2.639 1.1847 2.639 2.6407 0 1.456-1.1843 2.6395-2.639 2.6395h-0.06118l-2.3778 1.6979c0 0.03009 0.0017 0.06118 0.0017 0.09276 0 1.0938-0.88376 1.981-1.9776 1.981-0.95374 0-1.7592-0.68426-1.9429-1.5907l-2.6863-1.1126c0.83168 2.9383 3.5292 5.0924 6.7335 5.0924 3.8657 0 6.9995-3.1343 6.9995-7s-3.1343-7-6.9995-7zm-2.5895 10.623-0.85924-0.35569c0.15262 0.31676 0.4165 0.58274 0.7665 0.72931 0.75645 0.31456 1.6293-0.04415 1.9438-0.80194 0.15361-0.3675 0.15394-0.76956 0.0033-1.1371-0.15097-0.3675-0.4375-0.65408-0.80324-0.80674-0.364-0.1518-0.7525-0.14535-1.0955-0.01753l0.88855 0.3675c0.55782 0.23318 0.82208 0.875 0.58845 1.4319-0.23145 0.55826-0.87326 0.8225-1.4315 0.59019zm6.6587-5.4267c0-0.96948-0.78926-1.7587-1.7587-1.7587-0.97126 0-1.7587 0.78926-1.7587 1.7587 0 0.97126 0.7875 1.7587 1.7587 1.7587 0.96994 0 1.7587-0.7875 1.7587-1.7587zm-3.0756-0.0033c0-0.73019 0.59106-1.3217 1.3212-1.3217 0.72844 0 1.3217 0.59148 1.3217 1.3217 0 0.72976-0.59326 1.3213-1.3217 1.3213-0.73106 0-1.3212-0.5915-1.3212-1.3213z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><rect x="1" y="1" width="14" height="14" rx="2.8" fill="#fff" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 208 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m1 11.5 7-7 7 7z" fill="#b3b3b3"/></svg>

After

Width:  |  Height:  |  Size: 168 B

View File

@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8 15q-1.4583 0-2.7319-0.55417-1.2735-0.55417-2.2167-1.4972-0.94313-0.94306-1.4972-2.2167t-0.55417-2.7319q-7.699e-5 -1.4583 0.55417-2.7319 0.55424-1.2736 1.4972-2.2167 0.94298-0.94306 2.2167-1.4972 1.2737-0.55417 2.7319-0.55417 1.5944 0 3.0237 0.68056 1.4292 0.68056 2.4208 1.925v-1.8278h1.5556v4.6667h-4.6667v-1.5556h2.1389q-0.79722-1.0889-1.9639-1.7111-1.1667-0.62222-2.5083-0.62222-2.275 0-3.8596 1.5848-1.5846 1.5848-1.5848 3.8596-1.55e-4 2.2748 1.5848 3.8596 1.585 1.5848 3.8596 1.5848 2.0417 0 3.5681-1.3222t1.7985-3.3444h1.5944q-0.29167 2.6639-2.2848 4.443-1.9931 1.7791-4.6763 1.7792z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 741 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

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