Move repo from git to gitea
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
129
.gitea/workflows/build-nightlly.yml
Normal 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
@ -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
|
29
.gitea/workflows/check-spell.yml
Normal 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
|
54
.gitea/workflows/code-check.yml
Normal 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
|
77
.gitea/workflows/generate-appid.yml
Normal 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
@ -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
@ -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
@ -0,0 +1 @@
|
||||
3.10
|
68
CHANGELOG.md
Normal 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
@ -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-файл, он может содержать любые инструкции. Всегда проверяйте содержимое чужих тем перед использованием.
|
||||
|
58
build-aux/AppImageBuilder.yml
Normal 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
@ -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
@ -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
@ -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
@ -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
|
@ -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;
|
After Width: | Height: | Size: 12 KiB |
4398
data/anticheat_games.json
Normal file
BIN
data/anticheat_games.tar.xz
Normal file
527494
data/games_appid.json
Normal file
BIN
data/games_appid.tar.xz
Normal file
16
dev-scripts/.spellignore
Normal 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
@ -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()
|
28
dev-scripts/check_qss_properties.py
Executable 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
@ -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
@ -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)
|
80
documentation/localization_guide/README.md
Normal 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 you’ve 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.
|
||||
|
78
documentation/localization_guide/README.ru.md
Normal 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`, чтобы не считать их опечатками.
|
110
documentation/metadata_override/README.md
Normal 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 🎮✨
|
110
documentation/metadata_override/README.ru.md
Normal 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"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> Готово! Теперь ваши игры будут выглядеть именно так, как вы хотите 🎮✨
|
71
documentation/theme_guide/README.md
Normal 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`
|
||||
|
||||
---
|
71
documentation/theme_guide/README.ru.md
Normal 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
50
portprotonqt/app.py
Normal 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()
|
484
portprotonqt/config_utils.py
Normal 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)
|
467
portprotonqt/context_menu_manager.py
Normal 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)))
|
0
portprotonqt/custom_data/.gitkeep
Normal file
393
portprotonqt/custom_widgets.py
Normal 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
@ -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
@ -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
@ -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
@ -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
@ -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)
|
430
portprotonqt/input_manager.py
Normal 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")
|
BIN
portprotonqt/locales/de_DE/LC_MESSAGES/messages.mo
Normal file
516
portprotonqt/locales/de_DE/LC_MESSAGES/messages.po
Normal 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 ""
|
||||
|
BIN
portprotonqt/locales/es_ES/LC_MESSAGES/messages.mo
Normal file
516
portprotonqt/locales/es_ES/LC_MESSAGES/messages.po
Normal 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 ""
|
||||
|
514
portprotonqt/locales/messages.pot
Normal 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 ""
|
||||
|
BIN
portprotonqt/locales/ru_RU/LC_MESSAGES/messages.mo
Normal file
525
portprotonqt/locales/ru_RU/LC_MESSAGES/messages.po
Normal 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 "сек."
|
||||
|
74
portprotonqt/localization.py
Normal 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
@ -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
1134
portprotonqt/steam_api.py
Normal file
286
portprotonqt/theme_manager.py
Normal 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
|
BIN
portprotonqt/themes/standart-light/fonts/Orbitron-Regular.ttf
Normal file
BIN
portprotonqt/themes/standart-light/fonts/Poppins-Regular.ttf
Normal file
BIN
portprotonqt/themes/standart-light/fonts/RASKHAL-Regular.ttf
Executable 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 |
1
portprotonqt/themes/standart-light/images/icons/back.svg
Normal 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 |
@ -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 |
1
portprotonqt/themes/standart-light/images/icons/down.svg
Normal 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 |
@ -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 |
1
portprotonqt/themes/standart-light/images/icons/play.svg
Normal 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 |
@ -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 |
1
portprotonqt/themes/standart-light/images/icons/save.svg
Normal 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 |
@ -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 |
After Width: | Height: | Size: 7.9 KiB |
@ -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 |
@ -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 |
1
portprotonqt/themes/standart-light/images/icons/stop.svg
Normal 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 |
1
portprotonqt/themes/standart-light/images/icons/up.svg
Normal 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 |
@ -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 |
BIN
portprotonqt/themes/standart-light/images/placeholder.jpg
Normal file
After Width: | Height: | Size: 1.1 KiB |
After Width: | Height: | Size: 1.6 MiB |
After Width: | Height: | Size: 475 KiB |
After Width: | Height: | Size: 151 KiB |
5
portprotonqt/themes/standart-light/metainfo.ini
Normal file
@ -0,0 +1,5 @@
|
||||
[Metainfo]
|
||||
author = BlackSnaker
|
||||
author_link =
|
||||
description = Стандартная тема PortProtonQT (светлый вариант)
|
||||
name = Light
|
558
portprotonqt/themes/standart-light/styles.py
Normal 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);
|
||||
}}
|
||||
"""
|
BIN
portprotonqt/themes/standart/fonts/Orbitron-Regular.ttf
Normal file
BIN
portprotonqt/themes/standart/fonts/Play-Bold.ttf
Normal file
BIN
portprotonqt/themes/standart/fonts/Play-Regular.ttf
Normal file
BIN
portprotonqt/themes/standart/fonts/RASKHAL-Regular.ttf
Executable file
1
portprotonqt/themes/standart/images/icons/addgame.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/back.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/broken.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/down.svg
Normal 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 |
@ -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 |
1
portprotonqt/themes/standart/images/icons/play.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/ppqt-tray.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/save.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/search.svg
Normal 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 |
After Width: | Height: | Size: 7.9 KiB |
1
portprotonqt/themes/standart/images/icons/spinner.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/steam.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/stop.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/up.svg
Normal 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 |
1
portprotonqt/themes/standart/images/icons/update.svg
Normal 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 |
BIN
portprotonqt/themes/standart/images/placeholder.jpg
Normal file
After Width: | Height: | Size: 1.1 KiB |