39 Commits
v0.1.6 ... main

Author SHA1 Message Date
af4e3e95bb chore(localization): update
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m17s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:57:52 +05:00
017d9a42cf feat(wine settings): make prefix and wine delete work
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:55:24 +05:00
18b7c4054b chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:47:45 +05:00
dd7f71b70a feat(wine settings): make pfx_backup work
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:29:06 +05:00
8fd44c575b fix: expose gamesListWidget from GameLibraryManager to fix gamepad navigation
All checks were successful
Code check / Check code (push) Successful in 3m29s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 13:21:58 +05:00
65b43c1572 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m15s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 00:04:55 +05:00
f35276abfe fix: reject candidate if normalized name equals "game"
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 00:02:06 +05:00
6fea9a9a7e chore(wine settings): rework layout
All checks were successful
Code check / Check code (push) Successful in 1m10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-05 20:01:00 +05:00
5189474631 feat(wine settings): initial introdouce
All checks were successful
Code check / Check code (push) Successful in 1m36s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-05 16:19:06 +05:00
Renovate Bot
416cc6a268 chore(deps): update archlinux:base-devel docker digest to 5d95edc
All checks were successful
Code check / Check code (push) Successful in 1m12s
2025-10-05 08:20:07 +00:00
Renovate Bot
3b44ed5252 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to e459af1
Some checks failed
Code check / Check code (pull_request) Successful in 1m21s
Code check / Check code (push) Has been cancelled
2025-10-05 00:01:07 +00:00
c8c45dda06 chore(readme): drop Those Awesome Guys
All checks were successful
Code check / Check code (push) Successful in 1m8s
renovate / renovate (push) Successful in 1m1s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-04 20:54:57 +05:00
3f9f794e6f hint icons
All checks were successful
Code check / Check code (pull_request) Successful in 1m21s
2025-10-04 22:12:10 +07:00
ba9d8b76d8 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m8s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:31:01 +05:00
e99c71c1f8 feat: optimize search
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:29:18 +05:00
baec62d1cb chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m20s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:52 +05:00
cb76961e4f feat: optimize add and remove game
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-01 11:19:37 +05:00
Gitea Actions
081cd07253 chore: update steam apps list 2025-10-01T00:01:41Z 2025-10-01 00:01:42 +00:00
b5efee29ea chore: cleanup MainWindow class
All checks were successful
Code check / Check code (push) Successful in 1m8s
Fetch Data / build (push) Successful in 1m34s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-30 15:43:11 +05:00
69360f7e7e touchscreen scrolling
All checks were successful
Code check / Check code (pull_request) Successful in 1m49s
Code check / Check code (push) Successful in 1m5s
2025-09-28 16:04:11 +03:00
Renovate Bot
39712f0591 chore(deps): update https://gitea.com/actions/setup-node action to v5
All checks were successful
Code check / Check code (push) Successful in 1m6s
2025-09-28 07:43:25 +00:00
Renovate Bot
60b508af18 chore(deps): update https://gitea.com/actions/checkout action to v5
Some checks failed
Code check / Check code (pull_request) Successful in 1m18s
Code check / Check code (push) Has been cancelled
2025-09-28 07:40:23 +00:00
Renovate Bot
b6637b4163 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to dd5721b
All checks were successful
Code check / Check code (push) Successful in 1m11s
2025-09-28 07:34:37 +00:00
Renovate Bot
6d9eed42f8 chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.13.2
All checks were successful
Code check / Check code (pull_request) Successful in 1m5s
Code check / Check code (push) Successful in 1m3s
2025-09-28 00:01:38 +00:00
7372e3b7f5 chore: added zstd comp to appimage
All checks were successful
Code check / Check code (push) Successful in 1m4s
renovate / renovate (push) Successful in 1m9s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-27 17:52:32 +05:00
e0d5bd7993 chore: update appimage fork
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-27 07:11:11 +00:00
Renovate Bot
12f8067af1 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 2098143
All checks were successful
Code check / Check code (push) Successful in 1m6s
2025-09-24 17:38:45 +00:00
Renovate Bot
716a813ca9 chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.8.22
Some checks failed
Code check / Check code (push) Has been cancelled
Code check / Check code (pull_request) Successful in 1m11s
2025-09-24 17:37:21 +00:00
c62cc6853f chore(check-translation): disable untill yaspeller fixed
All checks were successful
Check Translations (disabled until yaspeller is fixed) / check-translations (push) Has been skipped
Code check / Check code (push) Successful in 1m10s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:31:19 +05:00
2e018b4690 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:27:15 +05:00
ad5b25f713 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:17:29 +05:00
3fb8201305 feat(file explorer): added ThumbnailLoader class
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 22:14:13 +05:00
04d8302d6c chore(logs): start translate
All checks were successful
Code check / Check code (push) Successful in 2m27s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 21:05:58 +05:00
Renovate Bot
f868b21178 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 06348c5
All checks were successful
Code check / Check code (push) Successful in 1m8s
2025-09-23 12:10:57 +00:00
Renovate Bot
ebe25b41d8 chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.8.20
All checks were successful
Code check / Check code (pull_request) Successful in 1m8s
Code check / Check code (push) Successful in 1m5s
2025-09-23 12:07:17 +00:00
Renovate Bot
fae6cad52d chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to edaa35b
All checks were successful
Code check / Check code (push) Successful in 1m15s
2025-09-23 07:14:54 +00:00
Renovate Bot
42bce11ada chore(deps): update archlinux:base-devel docker digest to 0589aa8
Some checks failed
Code check / Check code (pull_request) Successful in 1m2s
Code check / Check code (push) Has been cancelled
2025-09-23 07:12:08 +00:00
f088c01768 chore(renovate): validate and fix renovate.json configuration
All checks were successful
Code check / Check code (push) Successful in 1m5s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 12:10:19 +05:00
e7eee85ed4 feat(dev-scripts): regenerate uv.lock on bump ver
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-09-23 11:58:10 +05:00
78 changed files with 13749 additions and 1609 deletions

View File

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

View File

@@ -23,12 +23,22 @@ jobs:
- name: Install required dependencies
run: |
sudo apt update
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools squashfs-tools strace util-linux zsync git zstd
sudo apt install -y binutils coreutils desktop-file-utils gtk-update-icon-cache fakeroot fuse libgdk-pixbuf2.0-dev patchelf python3-pip python3-dev python3-setuptools python3-build python3-venv squashfs-tools strace util-linux zsync git zstd adwaita-icon-theme
- name: Install tools
- name: Upgrade pip toolchain
run: |
pip3 install git+https://github.com/Boria138/appimage-builder.git
pip3 install uv
python3 -m pip install --upgrade \
pip setuptools setuptools-scm wheel packaging build
- name: Install appimage-builder
run: |
git clone https://github.com/Boria138/appimage-builder
cd appimage-builder
pip install .
- name: Install uv
run: |
pip install uv
- name: Build AppImage
run: |

View File

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

View File

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

View File

@@ -20,10 +20,10 @@ jobs:
name: Check code
runs-on: ubuntu-latest
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 20

View File

@@ -11,7 +11,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5

View File

@@ -8,12 +8,12 @@ on:
jobs:
renovate:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:46b57bb9816dec6409e7be57e0e5f7b26d214281044f5aedd3b160be178475e2
container: ghcr.io/renovatebot/renovate:latest@sha256:e459af116e0cb6c7d5094c0dd4c999d4335d948324192902125b7aff91601a00
steps:
- uses: https://gitea.com/actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
with:
node-version: 20

View File

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

View File

@@ -3,6 +3,24 @@
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- Возможность скроллинга библиотеки мышью или пальцем
### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
### Contributors
---
## [0.1.6] - 2025-09-23
### Added
@@ -16,12 +34,12 @@
### Changed
- Управления с геймпада теперь перехватывается только если окно в фокусе
### Fixed
- Исправлена проблема с устаревшими кэш-файлами, вызывающими несоответствия при обновлении JSON
- Исправлено переключение в полноэкранный режим при нажатии кнопки "Select во время запущенной игры
### Contributors
- @wmigor (Igor Akulov)
---

View File

@@ -54,8 +54,6 @@ PortProtonQt использует код и зависимости от след
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/)
Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING]

View File

@@ -1,16 +1,11 @@
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/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*}
@@ -19,7 +14,6 @@ script:
AppDir:
path: ./AppDir
after_bundle:
# Документация, справка, примеры
- rm -rf $TARGET_APPDIR/usr/share/man || true
- rm -rf $TARGET_APPDIR/usr/share/doc || true
- rm -rf $TARGET_APPDIR/usr/share/doc-base || true
@@ -35,11 +29,8 @@ AppDir:
- rm -rf $TARGET_APPDIR/usr/share/metainfo || true
- rm -rf $TARGET_APPDIR/usr/include || true
- rm -rf $TARGET_APPDIR/usr/lib/pkgconfig || true
# Статика и отладка
- find $TARGET_APPDIR -type f \( -name '*.a' -o -name '*.la' -o -name '*.h' -o -name '*.cmake' -o -name '*.pdb' \) -delete || true
# Strip ELF бинарников (исключая Python extensions)
- "find $TARGET_APPDIR -type f -executable -exec file {} \\; | grep ELF | grep -v '/dist-packages/' | grep -v '/site-packages/' | cut -d: -f1 | xargs strip --strip-unneeded || true"
# Удаление пустых папок
- find $TARGET_APPDIR -type d -empty -delete || true
app_info:
id: ru.linux_gaming.PortProtonQt
@@ -64,15 +55,12 @@ AppDir:
- libimage-exiftool-perl
- xdg-utils
exclude:
# Документация и man-страницы
- "*-doc"
- "*-man"
- manpages
- mandb
# Статические библиотеки
- "*-dev"
- "*-static"
# Дебаг-символы
- "*-dbg"
- "*-dbgsym"
runtime:
@@ -83,3 +71,4 @@ AppDir:
AppImage:
sign-key: None
arch: x86_64
comp: zstd

View File

@@ -217,7 +217,7 @@
},
{
"normalized_name": "watch_dogs 2",
"status": "Broken"
"status": "Running"
},
{
"normalized_name": "zero hour",

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,96 @@
[
{
"normalized_title": "dirt rally 2.0 game of the year",
"slug": "dirt-rally-2-0-game-of-the-year-edition"
},
{
"normalized_title": "deus ex human revolution directors cut",
"slug": "deus-ex-human-revolution-director-s-cut"
},
{
"normalized_title": "freelancer",
"slug": "freelancer"
},
{
"normalized_title": "everspace",
"slug": "everspace"
},
{
"normalized_title": "blades of time limited",
"slug": "blades-of-time-limited-edition"
},
{
"normalized_title": "chorus",
"slug": "chorus"
},
{
"normalized_title": "tom clancy's splinter cell pandora tomorrow",
"slug": "tom-clancys-splinter-cell-pandora-tomorrow"
},
{
"normalized_title": "the alters",
"slug": "the-alters"
},
{
"normalized_title": "hard reset redux",
"slug": "hard-reset-redux"
},
{
"normalized_title": "far cry 5",
"slug": "far-cry-5"
},
{
"normalized_title": "metal eden",
"slug": "metal-eden"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "old world",
"slug": "old-world"
},
{
"normalized_title": "witchfire",
"slug": "witchfire"
},
{
"normalized_title": "prototype",
"slug": "prototype"
},
{
"normalized_title": "mandragora whispers of the witch tree",
"slug": "mandragora-whispers-of-the-witch-tree"
},
{
"normalized_title": "grand theft auto v (gta 5)",
"slug": "grand-theft-auto-v-gta-5"
},
{
"normalized_title": "lifeless planet premier",
"slug": "lifeless-planet-premier-edition"
},
{
"normalized_title": "warcraft iii the frozen throne",
"slug": "warcraft-iii-the-frozen-throne"
},
{
"normalized_title": "star wars republic commando",
"slug": "star-wars-republic-commando"
},
{
"normalized_title": "hollow knight silksong",
"slug": "hollow-knight-silksong"
},
{
"normalized_title": "arma reforger",
"slug": "arma-reforger"
},
{
"normalized_title": "arma 3",
"slug": "arma-3"
},
{
"normalized_title": "astroneer",
"slug": "astroneer"
@@ -195,10 +287,6 @@
"normalized_title": "slitterhead",
"slug": "slitterhead"
},
{
"normalized_title": "indiana jones and the great circle",
"slug": "indiana-jones-and-the-great-circle"
},
{
"normalized_title": "crossout",
"slug": "crossout"

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,15 @@
import argparse
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
def parse_args():
"""
Парсит аргументы командной строки.
Parses command-line arguments.
"""
parser = argparse.ArgumentParser(description="PortProtonQt CLI")
parser.add_argument(
"--fullscreen",
action="store_true",
help="Запустить приложение в полноэкранном режиме и сохранить эту настройку"
help="Launch the application in fullscreen mode and save this setting"
)
parser.add_argument(
"--debug-level",

View File

@@ -29,7 +29,7 @@ class ContextMenuSignals(QObject):
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):
def __init__(self, parent, portproton_location, theme, load_games_callback, game_library_manager):
"""
Initialize the ContextMenuManager.
@@ -45,7 +45,8 @@ class ContextMenuManager:
self.theme = theme
self.theme_manager = ThemeManager()
self.load_games = load_games_callback
self.update_game_grid = update_game_grid_callback
self.game_library_manager = game_library_manager
self.update_game_grid = game_library_manager.update_game_grid
self.legendary_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache", "legendary"
@@ -62,7 +63,7 @@ class ContextMenuManager:
self.parent.statusBar().showMessage,
Qt.ConnectionType.QueuedConnection
)
logger.debug("Connected show_status_message signal to statusBar")
logger.debug("Connected show_status_message signal to status bar")
self.signals.show_warning_dialog.connect(
self._show_warning_dialog,
Qt.ConnectionType.QueuedConnection
@@ -74,28 +75,28 @@ class ContextMenuManager:
def _show_warning_dialog(self, title: str, message: str):
"""Show a warning dialog in the main thread."""
logger.debug("Showing warning dialog: %s - %s", title, message)
logger.debug("Displaying warning dialog: %s - %s", title, message)
QMessageBox.warning(self.parent, title, message)
def _show_info_dialog(self, title: str, message: str):
"""Show an info dialog in the main thread."""
logger.debug("Showing info dialog: %s - %s", title, message)
logger.debug("Displaying info dialog: %s - %s", title, message)
QMessageBox.information(self.parent, title, message)
def _show_status_message(self, message: str, timeout: int = 3000):
"""Show a status message on the status bar if available."""
if self.parent.statusBar():
self.parent.statusBar().showMessage(message, timeout)
logger.debug("Direct status message: %s", message)
logger.debug("Displayed status message: %s", message)
else:
logger.warning("Status bar not available for message: %s", message)
logger.warning("Status bar unavailable for message: %s", message)
def _check_portproton(self):
"""Check if PortProton is available."""
if self.portproton_location is None:
self.signals.show_warning_dialog.emit(
_("Error"),
_("PortProton is not found")
_("PortProton directory not found")
)
return False
return True
@@ -119,7 +120,7 @@ class ContextMenuManager:
installed_games = orjson.loads(f.read())
return app_name in installed_games
except (OSError, orjson.JSONDecodeError) as e:
logger.error("Failed to read installed.json: %s", e)
logger.error("Error reading installed.json: %s", e)
return False
def _is_game_running(self, game_card) -> bool:
@@ -155,7 +156,7 @@ class ContextMenuManager:
try:
item = file_explorer.file_list.itemAt(pos)
if not item:
logger.debug("No item selected at position %s", pos)
logger.debug("No folder selected at position %s", pos)
return
selected = item.text()
if not selected.endswith("/"):
@@ -202,7 +203,7 @@ class ContextMenuManager:
global_pos = file_explorer.file_list.mapToGlobal(pos)
menu.exec(global_pos)
except Exception as e:
logger.error("Error showing folder context menu: %s", e)
logger.error("Error displaying folder context menu: %s", e)
def toggle_favorite_folder(self, file_explorer, folder_path, add):
"""Adds or removes a folder from favorites."""
@@ -211,12 +212,12 @@ class ContextMenuManager:
if folder_path not in favorite_folders:
favorite_folders.append(folder_path)
save_favorite_folders(favorite_folders)
logger.info(f"Folder added to favorites: {folder_path}")
logger.info("Added folder to favorites: %s", folder_path)
else:
if folder_path in favorite_folders:
favorite_folders.remove(folder_path)
save_favorite_folders(favorite_folders)
logger.info(f"Folder removed from favorites: {folder_path}")
logger.info("Removed folder from favorites: %s", folder_path)
file_explorer.update_drives_list()
def _get_safe_icon(self, icon_name: str) -> QIcon:
@@ -607,10 +608,10 @@ class ContextMenuManager:
exe_path = get_egs_executable(app_name, self.legendary_config_path)
if exe_path and os.path.exists(exe_path):
if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail from exe: {exe_path}")
logger.error("Failed to generate thumbnail for EGS game: %s", exe_path)
icon_path = ""
else:
logger.error(f"No executable found for EGS game: {app_name}")
logger.error("No executable found for EGS game: %s", app_name)
icon_path = ""
egs_desktop_dir = os.path.join(self.portproton_location, "egs_desktops")
@@ -750,7 +751,7 @@ Icon={icon_path}
if not exec_line:
self.signals.show_warning_dialog.emit(
_("Error"),
_("No executable command in .desktop file for '{game_name}'").format(game_name=game_name)
_("No executable command found in .desktop file for '{game_name}'").format(game_name=game_name)
)
return None
else:
@@ -762,7 +763,7 @@ Icon={icon_path}
except Exception as e:
self.signals.show_warning_dialog.emit(
_("Error"),
_("Failed to read .desktop file: {error}").format(error=str(e))
_("Error reading .desktop file: {error}").format(error=str(e))
)
return None
else:
@@ -784,7 +785,7 @@ Icon={icon_path}
try:
entry_exec_split = shlex.split(exec_line)
if not entry_exec_split:
logger.debug("Invalid executable command for '%s': %s", game_name, exec_line)
logger.debug("Invalid executable command for game '%s': %s", game_name, exec_line)
return None
if entry_exec_split[0] == "env" and len(entry_exec_split) >= 3:
exe_path = entry_exec_split[2]
@@ -793,11 +794,11 @@ Icon={icon_path}
else:
exe_path = entry_exec_split[-1]
if not exe_path or not os.path.exists(exe_path):
logger.debug("Executable not found for '%s': %s", game_name, exe_path or "None")
logger.debug("Executable not found for game '%s': %s", game_name, exe_path or "None")
return None
return exe_path
except Exception as e:
logger.debug("Failed to parse executable for '%s': %s", game_name, e)
logger.debug("Error parsing executable for game '%s': %s", game_name, e)
return None
def _remove_file(self, file_path, error_message, success_message, game_name, location=""):
@@ -859,9 +860,16 @@ Icon={icon_path}
_("Failed to delete custom data: {error}").format(error=str(e))
)
# Reload games list and update grid
self.load_games()
self.update_game_grid()
self.update_game_grid = self.game_library_manager.remove_game_incremental
self.game_library_manager.remove_game_incremental(game_name, exec_line)
def add_game_incremental(self, game_data: tuple):
"""Add game after .desktop creation."""
if not self._check_portproton():
return
# Assume game_data is built from new .desktop (name, desc, cover, etc.)
self.game_library_manager.add_game_incremental(game_data)
self._show_status_message(_("Added '{game_name}' successfully").format(game_name=game_data[0]))
def add_to_menu(self, game_name, exec_line):
"""Copy the .desktop file to ~/.local/share/applications."""
@@ -936,7 +944,7 @@ Icon={icon_path}
icon_path = os.path.join(self.portproton_location, "data", "img", f"{game_name}.png")
if not os.path.exists(icon_path):
if not generate_thumbnail(exe_path, icon_path, size=128):
logger.error(f"Failed to generate thumbnail for {exe_path}")
logger.error("Failed to generate thumbnail for game: %s", exe_path)
desktop_dir = subprocess.check_output(['xdg-user-dir', 'DESKTOP']).decode('utf-8').strip()
os.makedirs(desktop_dir, exist_ok=True)
@@ -1072,7 +1080,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
logger.debug("Adding '%s' to Steam", game_name)
logger.debug("Adding game '%s' to Steam", game_name)
try:
success, message = add_to_steam(game_name, exec_line, cover_path)
self.signals.show_info_dialog.emit(
@@ -1115,7 +1123,7 @@ Icon={icon_path}
exe_path = self._parse_exe_path(exec_line, game_name)
if not exe_path:
return
logger.debug("Removing non-EGS game '%s' from Steam", game_name)
logger.debug("Removing game '%s' from Steam", game_name)
try:
success, message = remove_from_steam(game_name, exec_line)
self.signals.show_info_dialog.emit(

View File

@@ -5,29 +5,29 @@ 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.0).
Computes the layout of elements considering spacing and potential scaling of cards.
nat_sizes: Array (N, 2) with natural sizes of elements (width, height).
rect_width: Available container width.
spacing: Spacing between elements (horizontal and vertical).
max_scale: Maximum scaling factor (e.g., 1.0).
Возвращает:
result: массив (N, 4), где каждая строка содержит [x, y, new_width, new_height].
total_height: итоговая высота всех рядов.
Returns:
result: Array (N, 4), where each row contains [x, y, new_width, new_height].
total_height: Total height of all rows.
"""
N = nat_sizes.shape[0]
result = np.zeros((N, 4), dtype=np.int32)
y = 0
i = 0
min_margin = 20 # Минимальный отступ по краям
min_margin = 20 # Minimum margin on edges
# Определяем максимальное количество элементов в ряду и общий масштаб
# Determine the maximum number of items per row and overall scale
max_items_per_row = 0
global_scale = 1.0
max_row_x_start = min_margin # Начальная позиция x самого длинного ряда
max_row_x_start = min_margin # Starting x position of the widest row
temp_i = 0
# Первый проход: находим максимальное количество элементов в ряду
# First pass: Find the maximum number of items in a row
while temp_i < N:
sum_width = 0
count = 0
@@ -42,23 +42,23 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
if count > max_items_per_row:
max_items_per_row = count
# Вычисляем масштаб для самого заполненного ряда
# Calculate scale for the most populated row
available_width = rect_width - spacing * (count - 1) - 2 * min_margin
desired_scale = available_width / sum_width if sum_width > 0 else 1.0
global_scale = desired_scale if desired_scale < max_scale else max_scale
# Сохраняем начальную позицию x для самого длинного ряда
# Store starting x position for the widest row
scaled_row_width = int(sum_width * global_scale) + spacing * (count - 1)
max_row_x_start = max(min_margin, (rect_width - scaled_row_width) // 2)
temp_i = temp_j
# Второй проход: размещаем элементы
# Second pass: Place elements
while i < N:
sum_width = 0
row_max_height = 0
count = 0
j = i
# Подбираем количество элементов для текущего ряда
# Determine the number of items for the current row
while j < N:
w = nat_sizes[j, 0]
if count > 0 and (sum_width + spacing + w) > rect_width - 2 * min_margin:
@@ -70,16 +70,16 @@ def compute_layout(nat_sizes, rect_width, spacing, max_scale):
row_max_height = h
j += 1
# Используем глобальный масштаб для всех рядов
# Use global scale for all rows
scale = global_scale
scaled_row_width = int(sum_width * scale) + spacing * (count - 1)
# Определяем начальную координату x
# Determine starting x coordinate
if count == max_items_per_row:
# Центрируем полный ряд
# Center the full row
x = max(min_margin, (rect_width - scaled_row_width) // 2)
else:
# Выравниваем неполный ряд по левому краю, совмещая с началом самого длинного ряда
# Align incomplete row to the left, matching the widest row's start
x = max_row_x_start
for k in range(i, j):
@@ -99,9 +99,9 @@ class FlowLayout(QLayout):
def __init__(self, parent=None):
super().__init__(parent)
self.itemList = []
self.setContentsMargins(20, 20, 20, 20) # Отступы по краям
self._spacing = 20 # Отступ для анимации и предотвращения перекрытий
self._max_scale = 1.0 # Отключено масштабирование в layout
self.setContentsMargins(20, 20, 20, 20) # Margins around the layout
self._spacing = 20 # Spacing for animation and overlap prevention
self._max_scale = 1.0 # Scaling disabled in layout
def addItem(self, item: QLayoutItem) -> None:
self.itemList.append(item)

View File

@@ -4,9 +4,9 @@ import re
from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller
)
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
from icoextract import IconExtractor, IconExtractorError
from PIL import Image
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
@@ -179,9 +179,11 @@ class FileExplorer(QDialog):
self.mime_db = QMimeDatabase() # Initialize QMimeDatabase for mimetype detection
self.path_history = {} # Dictionary to store last selected item per directory
self.initial_path = initial_path # Store initial path if provided
self.thumbnail_cache = {} # Cache for loaded thumbnails
self.pending_thumbnails = set() # Track files pending thumbnail loading
self.setup_ui()
# Настройки окна
# Window settings
self.setWindowModality(Qt.WindowModality.ApplicationModal)
self.setWindowFlags(self.windowFlags() & ~Qt.WindowType.WindowContextHelpButtonHint)
@@ -208,8 +210,115 @@ class FileExplorer(QDialog):
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
self.update_file_list()
class ThumbnailLoader(QRunnable):
"""Class for asynchronous thumbnail loading in a separate thread."""
class Signals(QObject):
thumbnail_ready = Signal(str, QIcon) # Signal for ready thumbnail: file path and icon
def __init__(self, file_path, mime_type, size=64):
super().__init__()
self.file_path = file_path
self.mime_type = mime_type
self.size = size
self.signals = self.Signals()
@Slot()
def run(self):
"""Performs thumbnail loading in a background thread."""
try:
if self.mime_type.startswith("image/"):
pixmap = QPixmap(self.file_path)
if not pixmap.isNull():
scaled_pixmap = pixmap.scaled(self.size, self.size, Qt.AspectRatioMode.KeepAspectRatio)
self.signals.thumbnail_ready.emit(self.file_path, QIcon(scaled_pixmap))
else:
logger.warning("Failed to load image: %s", self.file_path)
elif self.file_path.lower().endswith(".exe"):
with tempfile.NamedTemporaryFile(suffix='.png', delete=False) as tmp:
if generate_thumbnail(self.file_path, tmp.name, size=self.size):
pixmap = QPixmap(tmp.name)
if not pixmap.isNull():
self.signals.thumbnail_ready.emit(self.file_path, QIcon(pixmap))
os.unlink(tmp.name)
else:
logger.warning("Failed to generate thumbnail for .exe: %s", self.file_path)
except Exception as e:
logger.error("Error loading thumbnail for %s: %s", self.file_path, str(e))
def async_load_thumbnails(self, files, mime_db):
"""
Asynchronously loads thumbnails for a list of files.
Args:
files (list): List of file names to process.
mime_db (QMimeDatabase): QMimeDatabase instance for file type detection.
"""
thread_pool = QThreadPool.globalInstance()
thread_pool.setMaxThreadCount(4) # Limit the number of threads
for f in files:
file_path = os.path.join(self.current_path, f)
if file_path in self.thumbnail_cache or file_path in self.pending_thumbnails:
continue # Skip if already cached or pending
mime_type = mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/") or file_path.lower().endswith(".exe"):
self.pending_thumbnails.add(file_path)
loader = self.ThumbnailLoader(file_path, mime_type, size=64)
loader.signals.thumbnail_ready.connect(self.update_thumbnail)
thread_pool.start(loader)
@Slot(str, QIcon)
def update_thumbnail(self, file_path, icon):
"""
Updates the icon for a file list item after thumbnail loading.
Args:
file_path (str): Path to the file for which the thumbnail was loaded.
icon (QIcon): Loaded icon.
"""
try:
# Cache the thumbnail
self.thumbnail_cache[file_path] = icon
self.pending_thumbnails.discard(file_path)
# Update the item in the file list
file_name = os.path.basename(file_path)
for i in range(self.file_list.count()):
item = self.file_list.item(i)
if item.text() == file_name:
item.setIcon(icon)
break
except Exception as e:
logger.error("Error updating thumbnail for %s: %s", file_path, str(e))
def load_visible_thumbnails(self):
"""Load thumbnails only for visible items in the file list."""
try:
visible_range = self.file_list.count()
first_visible = max(0, self.file_list.indexAt(self.file_list.viewport().rect().topLeft()).row())
last_visible = min(visible_range - 1, self.file_list.indexAt(self.file_list.viewport().rect().bottomRight()).row() + 5)
files_to_load = []
for i in range(first_visible, last_visible + 1):
item = self.file_list.item(i)
if not item:
continue
file_name = item.text()
if file_name.endswith("/"):
continue # Skip directories
file_path = os.path.join(self.current_path, file_name)
if file_path not in self.thumbnail_cache and file_path not in self.pending_thumbnails:
files_to_load.append(file_name)
if files_to_load:
self.async_load_thumbnails(files_to_load, self.mime_db)
except Exception as e:
logger.error("Error loading visible thumbnails: %s", str(e))
def get_mounted_drives(self):
"""Получение списка смонтированных дисков из /proc/mounts, исключая системные пути"""
"""Retrieve a list of mounted drives from /proc/mounts, excluding system paths."""
mounted_drives = []
try:
with open('/proc/mounts') as f:
@@ -218,20 +327,20 @@ class FileExplorer(QDialog):
if len(parts) < 2:
continue
mount_point = parts[1]
# Исключаем системные и временные пути, но сохраняем /run/media
# Exclude system and temporary paths, but keep /run/media
if (mount_point.startswith(('/dev', '/sys', '/proc', '/tmp', '/snap', '/var/lib')) or
(mount_point.startswith('/run') and not mount_point.startswith('/run/media'))):
continue
# Проверяем, является ли точка монтирования директорией и доступна ли она
# Check if the mount point is a directory and accessible
if os.path.isdir(mount_point) and os.access(mount_point, os.R_OK):
mounted_drives.append(mount_point)
return sorted(mounted_drives)
except Exception as e:
logger.error(f"Ошибка при получении смонтированных дисков: {e}")
logger.error(f"Error retrieving mounted drives: {e}")
return []
def setup_ui(self):
"""Настройка интерфейса"""
"""Set up the user interface."""
self.setWindowTitle(_("File Explorer"))
self.setGeometry(100, 100, 600, 600)
@@ -240,7 +349,7 @@ class FileExplorer(QDialog):
self.main_layout.setSpacing(10)
self.setLayout(self.main_layout)
# Панель для смонтированных дисков и избранных папок
# Panel for mounted drives and favorite folders
self.drives_layout = QHBoxLayout()
self.drives_scroll = QScrollArea()
self.drives_scroll.setWidgetResizable(True)
@@ -253,21 +362,27 @@ class FileExplorer(QDialog):
self.drives_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
self.drives_scroll.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Путь
# Path label
self.path_label = QLabel()
self.path_label.setStyleSheet(self.theme.FILE_EXPLORER_PATH_LABEL_STYLE)
self.main_layout.addWidget(self.path_label)
# Список файлов
# File list
self.file_list = QListWidget()
self.file_list.setStyleSheet(self.theme.FILE_EXPLORER_STYLE)
self.file_list.itemClicked.connect(self.handle_item_click)
self.file_list.itemDoubleClicked.connect(self.handle_item_double_click)
self.file_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.file_list.customContextMenuRequested.connect(self.show_folder_context_menu)
self.file_list.setHorizontalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
self.file_list.setVerticalScrollMode(QListWidget.ScrollMode.ScrollPerPixel)
QScroller.grabGesture(self.file_list.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.main_layout.addWidget(self.file_list)
# Кнопки
# Connect scroll signal for lazy loading
self.file_list.verticalScrollBar().valueChanged.connect(self.load_visible_thumbnails)
# Buttons
self.button_layout = QHBoxLayout()
self.button_layout.setSpacing(10)
self.select_button = AutoSizeButton(_("Select"), icon=theme_manager.get_icon("apply"))
@@ -289,40 +404,40 @@ class FileExplorer(QDialog):
logger.warning("ContextMenuManager not found in parent")
def move_selection(self, direction):
"""Перемещение выбора по списку"""
"""Move selection in the list."""
current_row = self.file_list.currentRow()
if direction < 0 and current_row > 0: # Вверх
if direction < 0 and current_row > 0: # Up
self.file_list.setCurrentRow(current_row - 1)
elif direction > 0 and current_row < self.file_list.count() - 1: # Вниз
elif direction > 0 and current_row < self.file_list.count() - 1: # Down
self.file_list.setCurrentRow(current_row + 1)
self.file_list.scrollToItem(self.file_list.currentItem())
def handle_item_click(self, item):
"""Обработка одинарного клика мышью"""
"""Handle single mouse click."""
try:
self.file_list.setCurrentItem(item)
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент
self.path_history[self.current_path] = item.text() # Save selected item
logger.debug("Selected item: %s", item.text())
except Exception as e:
logger.error("Error in handle_item_click: %s", e)
def handle_item_double_click(self, item):
"""Обработка двойного клика мышью по элементу списка"""
"""Handle double mouse click on a list item."""
try:
self.file_list.setCurrentItem(item)
self.path_history[self.current_path] = item.text() # Сохраняем выбранный элемент
self.path_history[self.current_path] = item.text() # Save selected item
selected = item.text()
full_path = os.path.join(self.current_path, selected)
if os.path.isdir(full_path):
if selected == "../":
# Переходим в родительскую директорию
# Navigate to parent directory
self.previous_dir()
else:
# Открываем директорию
# Open directory
self.current_path = os.path.normpath(full_path)
self.update_file_list()
elif not self.directory_only:
# Выбираем файл, если directory_only=False
# Select file if directory_only=False
self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept()
else:
@@ -331,7 +446,7 @@ class FileExplorer(QDialog):
logger.error("Error in handle_item_double_click: %s", e)
def select_item(self):
"""Обработка выбора файла/папки"""
"""Handle file/folder selection."""
if self.file_list.count() == 0:
return
@@ -340,30 +455,30 @@ class FileExplorer(QDialog):
if os.path.isdir(full_path):
if self.directory_only:
# Подтверждаем выбор директории
# Confirm directory selection
self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept()
else:
# Открываем директорию
# Open directory
self.current_path = os.path.normpath(full_path)
self.update_file_list()
else:
if not self.directory_only:
# Для файла отправляем нормализованный путь
# Emit normalized path for file
self.file_signal.file_selected.emit(os.path.normpath(full_path))
self.accept()
else:
logger.debug("Selected item is not a directory, ignoring: %s", full_path)
def previous_dir(self):
"""Возврат к родительской директории"""
"""Navigate to parent directory."""
try:
if self.current_path == "/":
return # Уже в корне
return # Already at root
# Нормализуем путь (убираем конечный слеш, если есть)
# Normalize path (remove trailing slash if present)
normalized_path = os.path.normpath(self.current_path)
# Получаем родительскую директорию
# Get parent directory
parent_dir = os.path.dirname(normalized_path)
if not parent_dir:
@@ -389,7 +504,7 @@ class FileExplorer(QDialog):
logger.error(f"Error ensuring button visible: {e}")
def update_drives_list(self):
"""Обновление списка смонтированных дисков и избранных папок."""
"""Update the list of mounted drives and favorite folders."""
for i in reversed(range(self.drives_layout.count())):
item = self.drives_layout.itemAt(i)
if item and item.widget():
@@ -401,7 +516,7 @@ class FileExplorer(QDialog):
drives = self.get_mounted_drives()
favorite_folders = read_favorite_folders()
# Добавляем смонтированные диски
# Add mounted drives
for drive in drives:
drive_name = os.path.basename(drive) or drive.split('/')[-1] or drive
button = AutoSizeButton(drive_name, icon=theme_manager.get_icon("mount_point"))
@@ -411,7 +526,7 @@ class FileExplorer(QDialog):
self.drives_layout.addWidget(button)
self.drive_buttons.append(button)
# Добавляем избранные папки
# Add favorite folders
for folder in favorite_folders:
folder_name = os.path.basename(folder) or folder.split('/')[-1] or folder
button = AutoSizeButton(folder_name, icon=theme_manager.get_icon("folder"))
@@ -421,67 +536,69 @@ class FileExplorer(QDialog):
self.drives_layout.addWidget(button)
self.drive_buttons.append(button)
# Добавляем растяжку, чтобы выровнять элементы
# Add spacer to align elements
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.drives_layout.addWidget(spacer)
def select_drive(self):
"""Обрабатывает выбор диска или избранной папки через геймпад."""
"""Handle drive or favorite folder selection via gamepad."""
focused_widget = QApplication.focusWidget()
if isinstance(focused_widget, AutoSizeButton) and focused_widget in self.drive_buttons:
drive_name = focused_widget.text().strip() # Удаляем пробелы
logger.debug(f"Выбрано имя: {drive_name}")
drive_name = focused_widget.text().strip() # Remove whitespace
logger.debug(f"Selected name: {drive_name}")
# Специальная обработка корневого каталога
# Special handling for root directory
if drive_name == "/":
if os.path.isdir("/") and os.access("/", os.R_OK):
self.current_path = "/"
self.update_file_list()
logger.info("Выбран корневой каталог: /")
logger.info("Selected root directory")
return
else:
logger.warning("Корневой каталог недоступен: недостаточно прав или ошибка пути")
logger.warning("Root directory is inaccessible: insufficient permissions or path error")
return
# Проверяем избранные папки
# Check favorite folders
favorite_folders = read_favorite_folders()
logger.debug(f"Избранные папки: {favorite_folders}")
logger.debug(f"Favorite folders: {favorite_folders}")
for folder in favorite_folders:
folder_name = os.path.basename(os.path.normpath(folder)) or folder # Для корневых путей
folder_name = os.path.basename(os.path.normpath(folder)) or folder # For root paths
if folder_name == drive_name and os.path.isdir(folder) and os.access(folder, os.R_OK):
self.current_path = os.path.normpath(folder)
self.update_file_list()
logger.info(f"Выбрана избранная папка: {self.current_path}")
logger.info(f"Selected favorite folder: {self.current_path}")
return
# Проверяем смонтированные диски
# Check mounted drives
mounted_drives = self.get_mounted_drives()
logger.debug(f"Смонтированные диски: {mounted_drives}")
logger.debug(f"Mounted drives: {mounted_drives}")
for drive in mounted_drives:
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # Для корневых путей
drive_basename = os.path.basename(os.path.normpath(drive)) or drive # For root paths
if drive_basename == drive_name and os.path.isdir(drive) and os.access(drive, os.R_OK):
self.current_path = os.path.normpath(drive)
self.update_file_list()
logger.info(f"Выбран смонтированный диск: {self.current_path}")
logger.info(f"Selected mounted drive: {self.current_path}")
return
logger.warning(f"Путь недоступен: {drive_name}.")
logger.warning(f"Path is inaccessible: {drive_name}.")
def change_drive(self, drive_path):
"""Переход к выбранному диску"""
"""Navigate to the selected drive."""
if os.path.isdir(drive_path) and os.access(drive_path, os.R_OK):
self.current_path = os.path.normpath(drive_path)
self.update_file_list()
else:
logger.warning(f"Путь диска недоступен: {drive_path}")
logger.warning(f"Drive path is inaccessible: {drive_path}")
def update_file_list(self):
"""Обновление списка файлов с превью в виде иконок"""
"""Update the file list with asynchronous thumbnail loading."""
self.file_list.clear()
self.thumbnail_cache.clear() # Clear cache when changing directories
self.pending_thumbnails.clear() # Clear pending thumbnails
try:
if self.current_path != "/":
item = QListWidgetItem("../")
if self.directory_only:
item = QListWidgetItem("./")
folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
@@ -490,23 +607,31 @@ class FileExplorer(QDialog):
folder_icon = QIcon() # Fallback to empty icon
item.setIcon(folder_icon)
self.file_list.addItem(item)
if self.current_path != "/":
item = QListWidgetItem("../")
folder_icon = theme_manager.get_icon("folder")
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon()
item.setIcon(folder_icon)
self.file_list.addItem(item)
items = os.listdir(self.current_path)
dirs = [d for d in items if os.path.isdir(os.path.join(self.current_path, d))]
# Добавляем директории
# Add directories
for d in sorted(dirs):
item = QListWidgetItem(f"{d}/")
folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon
folder_icon = QIcon()
item.setIcon(folder_icon)
self.file_list.addItem(item)
# Добавляем файлы только если directory_only=False
# Add files only if directory_only=False
if not self.directory_only:
files = [f for f in items if os.path.isfile(os.path.join(self.current_path, f))]
if self.file_filter:
@@ -515,26 +640,14 @@ class FileExplorer(QDialog):
elif isinstance(self.file_filter, tuple):
files = [f for f in files if any(f.lower().endswith(ext) for ext in self.file_filter)]
# Add files to the list without immediate thumbnail loading
for f in sorted(files):
item = QListWidgetItem(f)
file_path = os.path.join(self.current_path, f)
mime_type = self.mime_db.mimeTypeForFile(file_path).name()
if mime_type.startswith("image/"):
pixmap = QPixmap(file_path)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap.scaled(64, 64, Qt.AspectRatioMode.KeepAspectRatio)))
elif file_path.lower().endswith(".exe"):
tmp = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
tmp.close()
if generate_thumbnail(file_path, tmp.name, size=64):
pixmap = QPixmap(tmp.name)
if not pixmap.isNull():
item.setIcon(QIcon(pixmap))
os.unlink(tmp.name)
self.file_list.addItem(item)
# Load thumbnails for visible items only
self.load_visible_thumbnails()
self.path_label.setText(_("Path: ") + self.current_path)
# Restore last selected item for this directory
@@ -556,10 +669,10 @@ class FileExplorer(QDialog):
self.file_list.setAlternatingRowColors(True)
except PermissionError:
self.path_label.setText(f"Access denied: {self.current_path}")
self.path_label.setText(_("Access denied: %s") % self.current_path)
def closeEvent(self, event):
"""Закрытие окна"""
"""Handle window closing."""
try:
if self.input_manager:
self.input_manager.disable_file_explorer_mode()
@@ -573,13 +686,13 @@ class FileExplorer(QDialog):
super().closeEvent(event)
def reject(self):
"""Закрытие диалога"""
"""Close the dialog."""
if self.input_manager:
self.input_manager.disable_file_explorer_mode()
super().reject()
def accept(self):
"""Принятие диалога"""
"""Accept the dialog."""
if self.input_manager:
self.input_manager.disable_file_explorer_mode()
super().accept()

View File

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

View File

@@ -597,6 +597,9 @@ class InputManager(QObject):
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
if not self._gamepad_handling_enabled:
return
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
logger.error("gamesListWidget not available yet, skipping D-pad navigation")
return
try:
app = QApplication.instance()

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"POT-Creation-Date: 2025-10-06 17:57+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -23,7 +23,7 @@ msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found"
msgid "PortProton directory not found"
msgstr ""
msgid "Remove from Favorites"
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr ""
#, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'"
msgid "No executable command found in .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to read .desktop file: {error}"
msgid "Error reading .desktop file: {error}"
msgstr ""
#, python-brace-format
@@ -191,6 +191,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -264,6 +268,10 @@ msgstr ""
msgid "Path: "
msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game"
msgstr ""
@@ -378,13 +386,95 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
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..."
msgid "Compatibility tool:"
msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."
@@ -456,21 +546,6 @@ msgstr ""
msgid "Gamepad haptic feedback:"
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 ""
@@ -480,28 +555,6 @@ 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 ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"POT-Creation-Date: 2025-10-06 17:57+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -23,7 +23,7 @@ msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found"
msgid "PortProton directory not found"
msgstr ""
msgid "Remove from Favorites"
@@ -155,7 +155,7 @@ msgid "Menu"
msgstr ""
#, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'"
msgid "No executable command found in .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
@@ -163,7 +163,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to read .desktop file: {error}"
msgid "Error reading .desktop file: {error}"
msgstr ""
#, python-brace-format
@@ -191,6 +191,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -264,6 +268,10 @@ msgstr ""
msgid "Path: "
msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game"
msgstr ""
@@ -378,13 +386,95 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
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..."
msgid "Compatibility tool:"
msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."
@@ -456,21 +546,6 @@ msgstr ""
msgid "Gamepad haptic feedback:"
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 ""
@@ -480,28 +555,6 @@ 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 ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"POT-Creation-Date: 2025-10-06 17:57+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"
@@ -21,7 +21,7 @@ msgstr ""
msgid "Error"
msgstr ""
msgid "PortProton is not found"
msgid "PortProton directory not found"
msgstr ""
msgid "Remove from Favorites"
@@ -153,7 +153,7 @@ msgid "Menu"
msgstr ""
#, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'"
msgid "No executable command found in .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
@@ -161,7 +161,7 @@ msgid "Failed to parse .desktop file for '{game_name}'"
msgstr ""
#, python-brace-format
msgid "Failed to read .desktop file: {error}"
msgid "Error reading .desktop file: {error}"
msgstr ""
#, python-brace-format
@@ -189,6 +189,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -262,6 +266,10 @@ msgstr ""
msgid "Path: "
msgstr ""
#, python-format
msgid "Access denied: %s"
msgstr ""
msgid "Edit Game"
msgstr ""
@@ -376,13 +384,95 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
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..."
msgid "Compatibility tool:"
msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."
@@ -454,21 +544,6 @@ msgstr ""
msgid "Gamepad haptic feedback:"
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 ""
@@ -478,28 +553,6 @@ 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 ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-13 11:51+0500\n"
"PO-Revision-Date: 2025-09-13 11:47+0500\n"
"POT-Creation-Date: 2025-10-06 17:57+0500\n"
"PO-Revision-Date: 2025-10-06 17:57+0500\n"
"Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
@@ -24,8 +24,8 @@ msgstr ""
msgid "Error"
msgstr "Ошибка"
msgid "PortProton is not found"
msgstr "PortProton не найден"
msgid "PortProton directory not found"
msgstr "Не найден каталог PortProton"
msgid "Remove from Favorites"
msgstr "Удалить из Избранного"
@@ -158,16 +158,16 @@ msgid "Menu"
msgstr "Меню"
#, python-brace-format
msgid "No executable command in .desktop file for '{game_name}'"
msgstr "В файле .desktop для '{game_name}' отсутствует исполняемая команда"
msgid "No executable command found in .desktop file for '{game_name}'"
msgstr "В файле .desktop не найдена исполняемая команда для '{game_name}'"
#, python-brace-format
msgid "Failed to parse .desktop file for '{game_name}'"
msgstr "Не удалось разобрать файл .desktop для '{game_name}'"
#, python-brace-format
msgid "Failed to read .desktop file: {error}"
msgstr "Не удалось прочитать файл .desktop: {error}"
msgid "Error reading .desktop file: {error}"
msgstr "Ошибка при чтении файла .desktop: {error}"
#, python-brace-format
msgid "No .desktop file found for '{game_name}'"
@@ -196,6 +196,10 @@ msgstr "'{game_name}' был(а) успешно удалён(а)"
msgid "Failed to delete custom data: {error}"
msgstr "Не удалось удалить пользовательские данные: {error}"
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr "'{game_name}' успешно добавлен(а)"
msgid "Game name and executable path are required"
msgstr "Требуются название игры и путь к исполняемому файлу"
@@ -271,6 +275,10 @@ msgstr "Выбрать"
msgid "Path: "
msgstr "Путь: "
#, python-format
msgid "Access denied: %s"
msgstr "Доступ запрещён: %s"
msgid "Edit Game"
msgstr "Редактировать игру"
@@ -370,7 +378,6 @@ msgstr "Темы"
msgid "Back"
msgstr "Назад"
#, fuzzy
msgid "Fullscreen"
msgstr "Полный экран"
@@ -386,14 +393,96 @@ msgstr "Игровая библиотека"
msgid "Find Games ..."
msgstr "Найти игры..."
#, python-brace-format
msgid "Added '{name}'"
msgstr "'{name}' добавлен(а)"
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 "Compatibility tool:"
msgstr "Инструмент совместимости:"
msgid "Prefix:"
msgstr "Префикс:"
msgid "Wine Configuration"
msgstr "Конфигурация Wine"
msgid "Registry Editor"
msgstr "Редактор реестра"
msgid "Control Panel"
msgstr "Панель управления"
msgid "Task Manager"
msgstr "Диспетчер задач"
msgid "Command Prompt"
msgstr "Командная строка"
msgid "Uninstaller"
msgstr "Удаление программ"
msgid "Create Prefix Backup"
msgstr "Создать резервную копию префикса"
msgid "Load Prefix Backup"
msgstr "Загрузить резервную копию префикса"
msgid "Delete Compatibility Tool"
msgstr "Удалить Инструмент совместимости"
msgid "Delete Prefix"
msgstr "Удалить Префикс"
msgid "Clear Prefix"
msgstr "Очистить Префикс"
msgid "Failed to start backup process."
msgstr "Не удалось запустить процесс резервного копирования."
msgid "Failed to start restore process."
msgstr "Не удалось запустить процесс восстановления."
msgid "Prefix backup completed."
msgstr "Резервное копирование префикса завершено."
msgid "Prefix backup failed."
msgstr "Сбой резервного копирования префикса."
msgid "Prefix restore completed."
msgstr "Восстановление префикса завершено."
msgid "Prefix restore failed."
msgstr "Восстановление префикса не удалось."
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr "Префикс «{}» удален."
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr "Не удалось удалить префикс: {}"
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?"
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr "Инструмент совместимости «{}» удален."
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr "Не удалось удалить инструмент совместимости: {}"
msgid "Main PortProton parameters..."
msgstr "Основные параметры PortProton..."
@@ -464,21 +553,6 @@ msgstr "Тактильная отдача на геймпаде"
msgid "Gamepad haptic feedback:"
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 "Сохранить настройки"
@@ -488,28 +562,6 @@ 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 "Подтвердите удаление"

View File

@@ -10,7 +10,7 @@ from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer
from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import FlowLayout, ClickableLabel, AutoSizeButton, NavLabel
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel
from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.input_manager import InputManager
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
@@ -34,10 +34,12 @@ from portprotonqt.localization import _, get_egs_language, read_metadata_transla
from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
from portprotonqt.game_library_manager import GameLibraryManager
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QScrollArea, QSlider,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from typing import cast
from collections.abc import Callable
@@ -47,14 +49,12 @@ from datetime import datetime
logger = get_logger(__name__)
class MainWindow(QMainWindow):
"""Main window of PortProtonQt."""
games_loaded = Signal(list)
update_progress = Signal(int) # Signal to update progress bar
update_status_message = Signal(str, int) # Signal to update status message
update_progress = Signal(int)
update_status_message = Signal(str, int)
def __init__(self, app_name: str):
super().__init__()
# Создаём менеджер тем и читаем, какая тема выбрана
self.theme_manager = ThemeManager()
self.is_exiting = False
selected_theme = read_theme_from_config()
@@ -62,50 +62,50 @@ class MainWindow(QMainWindow):
self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size()
self._last_card_width = self.card_width
self.setWindowTitle(app_name)
self.setMinimumSize(800, 600)
self.games = []
self.filtered_games = self.games
self.game_processes = []
self.target_exe = None
self.current_running_button = None
self.portproton_location = get_portproton_location()
self.game_library_manager = GameLibraryManager(self, self.theme, None)
self.context_menu_manager = ContextMenuManager(
self,
self.portproton_location,
self.theme,
self.loadGames,
self.updateGameGrid
self.game_library_manager
)
self.game_library_manager.context_menu_manager = self.context_menu_manager
QApplication.setStyle("Fusion")
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE)
self.setAcceptDrops(True)
self.current_exec_line = None
self.currentDetailPage = None
self.current_play_button = None
self.current_focused_card = None
self.current_focused_card: GameCard | None = None
self.current_hovered_card: GameCard | None = None
self.pending_games = []
self.game_card_cache = {}
self.pending_images = {}
self.total_games = 0
self.games_load_timer = QTimer(self)
self.games_load_timer.setSingleShot(True)
self.games_load_timer.timeout.connect(self.finalize_game_loading)
self.games_loaded.connect(self.on_games_loaded)
self.current_add_game_dialog = None
self.current_hovered_card = None
# Добавляем таймер для дебаунсинга сохранения настроек
self.settingsDebounceTimer = QTimer(self)
self.settingsDebounceTimer.setSingleShot(True)
self.settingsDebounceTimer.setInterval(300) # 300 мс задержка
self.settingsDebounceTimer.setInterval(300)
self.settingsDebounceTimer.timeout.connect(self.applySettingsDelayed)
read_time_config()
# Set LEGENDARY_CONFIG_PATH to ~/.cache/PortProtonQt/legendary_cache
self.legendary_config_path = os.path.join(
os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")),
"PortProtonQt", "legendary_cache"
@@ -144,7 +144,7 @@ class MainWindow(QMainWindow):
headerLayout.setContentsMargins(0, 0, 0, 0)
headerLayout.addStretch()
self.input_manager = InputManager(self)
self.input_manager = InputManager(self) # type: ignore
self.input_manager.button_pressed.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints)
@@ -196,15 +196,13 @@ class MainWindow(QMainWindow):
self.stackedWidget = QStackedWidget()
mainLayout.addWidget(self.stackedWidget)
# Создаём все вкладки
self.createInstalledTab() # вкладка 0
self.createAutoInstallTab() # вкладка 1
self.createEmulatorsTab() # вкладка 2
self.createWineTab() # вкладка 3
self.createPortProtonTab() # вкладка 4
self.createThemeTab() # вкладка 5
self.createInstalledTab()
self.createAutoInstallTab()
self.createEmulatorsTab()
self.createWineTab()
self.createPortProtonTab()
self.createThemeTab()
# Подсказки управления
self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget)
@@ -222,6 +220,11 @@ class MainWindow(QMainWindow):
else:
self.showNormal()
def on_slider_released(self) -> None:
"""Delegate to game library manager."""
if hasattr(self, 'game_library_manager'):
self.game_library_manager.on_slider_released()
def get_button_icon(self, action: str, gtype: GamepadType) -> str:
"""Get the icon name for a specific action and gamepad type."""
mappings = {
@@ -298,12 +301,12 @@ class MainWindow(QMainWindow):
def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setContentsMargins(0, 5, 0, 0)
layout.setSpacing(6)
# иконка кнопки
icon_label = QLabel()
icon_label.setFixedSize(32, 32)
icon_label.setFixedSize(26, 26)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
@@ -316,7 +319,7 @@ class MainWindow(QMainWindow):
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
@@ -409,7 +412,7 @@ class MainWindow(QMainWindow):
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
@@ -418,7 +421,7 @@ class MainWindow(QMainWindow):
placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
else: # Keyboard hint
@@ -429,31 +432,7 @@ class MainWindow(QMainWindow):
@Slot(list)
def on_games_loaded(self, games: list[tuple]):
self.games = games
favorites = read_favorites()
sort_method = read_sort_method()
# Sort by: favorites first, then descending playtime, then descending last launch
if sort_method == "playtime":
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[11], -g[10]))
# Sort by: favorites first, then alphabetically by game name
elif sort_method == "alphabetical":
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, g[0].lower()))
# Sort by: favorites first, then leave the rest in their original order
elif sort_method == "favorites":
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1))
# Sort by: favorites first, then descending last launch, then descending playtime
elif sort_method == "last_launch":
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11]))
# Fallback: same as last_launch
else:
self.games.sort(key=lambda g: (0 if g[0] in favorites else 1, -g[10], -g[11]))
self.updateGameGrid()
self.game_library_manager.set_games(games)
self.progress_bar.setVisible(False)
def open_portproton_forum_topic(self, topic_name: str):
@@ -466,65 +445,6 @@ class MainWindow(QMainWindow):
url = QUrl(f"{base_url}t/{result}")
QDesktopServices.openUrl(url)
def _on_card_focused(self, game_name: str, is_focused: bool):
"""Обработчик сигнала focusChanged от GameCard."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_focused:
# Если карточка получила фокус
if self.current_hovered_card and self.current_hovered_card != card:
# Сбрасываем текущую hovered карточку
self.current_hovered_card._hovered = False
self.current_hovered_card.leaveEvent(None)
self.current_hovered_card = None
if self.current_focused_card and self.current_focused_card != card:
# Сбрасываем текущую focused карточку
self.current_focused_card._focused = False
self.current_focused_card.clearFocus()
self.current_focused_card = card
else:
# Если карточка потеряла фокус
if self.current_focused_card == card:
self.current_focused_card = None
def _on_card_hovered(self, game_name: str, is_hovered: bool):
"""Обработчик сигнала hoverChanged от GameCard."""
card_key = None
for key, card in self.game_card_cache.items():
if card.name == game_name:
card_key = key
break
if not card_key:
return
card = self.game_card_cache[card_key]
if is_hovered:
# Если мышь наведена на карточку
if self.current_focused_card and self.current_focused_card != card:
# Сбрасываем текущую focused карточку
self.current_focused_card._focused = False
self.current_focused_card.clearFocus()
if self.current_hovered_card and self.current_hovered_card != card:
# Сбрасываем предыдущую hovered карточку
self.current_hovered_card._hovered = False
self.current_hovered_card.leaveEvent(None)
self.current_hovered_card = card
else:
# Если мышь покинула карточку
if self.current_hovered_card == card:
self.current_hovered_card = None
def loadGames(self):
display_filter = read_display_filter()
favorites = read_favorites()
@@ -797,7 +717,7 @@ class MainWindow(QMainWindow):
overlay = SystemOverlay(self, self.theme)
overlay.exec()
def createSearchWidget(self) -> tuple[QWidget, QLineEdit]:
def createSearchWidget(self) -> tuple[QWidget, CustomLineEdit]:
self.container = QWidget()
self.container.setStyleSheet(self.theme.CONTAINER_STYLE)
layout = QHBoxLayout(self.container)
@@ -823,88 +743,34 @@ class MainWindow(QMainWindow):
self.searchEdit.setClearButtonEnabled(True)
self.searchEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE)
# Добавляем дебансирование для поиска
self.searchEdit.textChanged.connect(self.startSearchDebounce)
self.searchDebounceTimer = QTimer(self)
self.searchDebounceTimer.setSingleShot(True)
self.searchDebounceTimer.setInterval(300)
self.searchDebounceTimer.timeout.connect(self.filterGamesDelayed)
self.searchDebounceTimer.timeout.connect(self.on_search_changed)
layout.addWidget(self.searchEdit)
return self.container, self.searchEdit
def on_search_text_changed(self, text: str):
"""Search text change handler with debounce."""
self.searchDebounceTimer.stop()
self.searchDebounceTimer.start()
@Slot()
def on_search_changed(self):
"""Triggers filtering with delay."""
if hasattr(self, 'game_library_manager'):
self.game_library_manager.filter_games_delayed()
def startSearchDebounce(self, text):
self.searchDebounceTimer.start()
def on_slider_released(self):
self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width)
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.updateGameGrid()
def filterGamesDelayed(self):
"""Filters games based on search text and updates the grid."""
text = self.searchEdit.text().strip().lower()
if text == "":
self.filtered_games = self.games
else:
self.filtered_games = [game for game in self.games if text in game[0].lower()]
self.updateGameGrid(self.filtered_games)
def createInstalledTab(self):
self.gamesLibraryWidget = QWidget()
self.gamesLibraryWidget.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
layout = QVBoxLayout(self.gamesLibraryWidget)
layout.setSpacing(15)
searchWidget, self.searchEdit = self.createSearchWidget()
layout.addWidget(searchWidget)
scrollArea = QScrollArea()
scrollArea.setWidgetResizable(True)
scrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
self.gamesListWidget = QWidget()
self.gamesListWidget.setStyleSheet(self.theme.LIST_WIDGET_STYLE)
self.gamesListLayout = FlowLayout(self.gamesListWidget)
self.gamesListWidget.setLayout(self.gamesListLayout)
scrollArea.setWidget(self.gamesListWidget)
layout.addWidget(scrollArea)
sliderLayout = QHBoxLayout()
sliderLayout.addStretch()
# Слайдер
self.sizeSlider = QSlider(Qt.Orientation.Horizontal)
self.sizeSlider.setMinimum(200)
self.sizeSlider.setMaximum(250)
self.sizeSlider.setValue(self.card_width)
self.sizeSlider.setTickInterval(10)
self.sizeSlider.setFixedWidth(150)
self.sizeSlider.setToolTip(f"{self.card_width} px")
self.sizeSlider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.sizeSlider.sliderReleased.connect(self.on_slider_released)
sliderLayout.addWidget(self.sizeSlider)
layout.addLayout(sliderLayout)
def calculate_card_width():
available_width = scrollArea.width() - 20
spacing = self.gamesListLayout._spacing
target_cards_per_row = 8
calculated_width = (available_width - spacing * (target_cards_per_row - 1)) // target_cards_per_row
calculated_width = max(200, min(calculated_width, 250))
QTimer.singleShot(0, calculate_card_width)
# Добавляем обработчик прокрутки для ленивой загрузки
scrollArea.verticalScrollBar().valueChanged.connect(self.loadVisibleImages)
self.gamesLibraryWidget = self.game_library_manager.create_games_library_widget()
self.stackedWidget.addWidget(self.gamesLibraryWidget)
self.updateGameGrid()
self.gamesListWidget = self.game_library_manager.gamesListWidget
self.game_library_manager.update_game_grid()
def resizeEvent(self, event):
super().resizeEvent(event)
@@ -922,135 +788,6 @@ class MainWindow(QMainWindow):
if abs(self.width() - self._last_width) > 10:
self._last_width = self.width()
def loadVisibleImages(self):
visible_region = self.gamesListWidget.visibleRegion()
max_concurrent_loads = 5
loaded_count = 0
for card_key, card in self.game_card_cache.items():
if card_key in self.pending_images and visible_region.contains(card.pos()) and loaded_count < max_concurrent_loads:
cover_path, width, height, callback = self.pending_images.pop(card_key)
load_pixmap_async(cover_path, width, height, callback)
loaded_count += 1
def updateGameGrid(self, games_list=None):
"""Обновляет сетку игровых карточек с сохранением порядка сортировки"""
# Подготовка данных
games_list = games_list if games_list is not None else self.games
search_text = self.searchEdit.text().strip().lower()
favorites = read_favorites()
sort_method = read_sort_method()
# Сортируем игры согласно текущим настройкам
def sort_key(game):
name = game[0]
# Избранные всегда первые
if name in favorites:
fav_order = 0
else:
fav_order = 1
if sort_method == "playtime":
return (fav_order, -game[11], -game[10]) # playtime_seconds, last_launch_ts
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
else: # "last_launch" или по умолчанию
return (fav_order, -game[10], -game[11]) # last_launch_ts, playtime_seconds
sorted_games = sorted(games_list, key=sort_key)
# Создаем временный список для новых карточек
new_card_order = []
# Обрабатываем каждую игру в отсортированном порядке
for game_data in sorted_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
should_be_visible = not search_text or search_text in game_name.lower()
# Если карточка уже существует - используем существующую
if game_key in self.game_card_cache:
card = self.game_card_cache[game_key]
card.setVisible(should_be_visible)
new_card_order.append((game_key, card))
continue
# Создаем новую карточку
card = GameCard(
*game_data,
select_callback=self.openGameDetailPage,
theme=self.theme,
card_width=self.card_width,
context_menu_manager=self.context_menu_manager
)
# Подключаем сигналы
card.hoverChanged.connect(self._on_card_hovered)
card.focusChanged.connect(self._on_card_focused)
# Подключаем сигналы контекстного меню
card.editShortcutRequested.connect(self.context_menu_manager.edit_game_shortcut)
card.deleteGameRequested.connect(self.context_menu_manager.delete_game)
card.addToMenuRequested.connect(self.context_menu_manager.add_to_menu)
card.removeFromMenuRequested.connect(self.context_menu_manager.remove_from_menu)
card.addToDesktopRequested.connect(self.context_menu_manager.add_to_desktop)
card.removeFromDesktopRequested.connect(self.context_menu_manager.remove_from_desktop)
card.addToSteamRequested.connect(self.context_menu_manager.add_to_steam)
card.removeFromSteamRequested.connect(self.context_menu_manager.remove_from_steam)
card.openGameFolderRequested.connect(self.context_menu_manager.open_game_folder)
# Добавляем в кэш и временный список
self.game_card_cache[game_key] = card
new_card_order.append((game_key, card))
card.setVisible(should_be_visible)
# Полностью перестраиваем макет в правильном порядке, чистим FlowLayout
while self.gamesListLayout.count():
child = self.gamesListLayout.takeAt(0)
if child.widget():
child.widget().setParent(None)
# Добавляем карточки в макет в отсортированном порядке
for _game_key, card in new_card_order:
self.gamesListLayout.addWidget(card)
# Загружаем обложку, если карточка видима
if card.isVisible():
self.loadVisibleImages()
# Удаляем карточки для игр, которых больше нет в списке
existing_keys = {game_key for game_key, _ in new_card_order}
for card_key in list(self.game_card_cache.keys()):
if card_key not in existing_keys:
card = self.game_card_cache.pop(card_key)
card.deleteLater()
if card_key in self.pending_images:
del self.pending_images[card_key]
# Принудительно обновляем макет
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.gamesListWidget.update()
# Сохраняем текущий размер карточек
self._last_card_width = self.card_width
def clearLayout(self, layout):
"""Удаляет все виджеты из layout."""
while layout.count():
child = layout.takeAt(0)
if child.widget():
widget = child.widget()
# Remove from game_card_cache if it's a GameCard
for key, card in list(self.game_card_cache.items()):
if card == widget:
del self.game_card_cache[key]
# Also remove from pending_images if present
if key in self.pending_images:
del self.pending_images[key]
widget.deleteLater()
def dragEnterEvent(self, event):
if event.mimeData().hasUrls():
@@ -1068,26 +805,22 @@ class MainWindow(QMainWindow):
break
def openAddGameDialog(self, exe_path=None):
"""Открывает диалоговое окно 'Add Game' с текущей темой."""
# Проверяем, открыт ли уже диалог
if self.current_add_game_dialog is not None and self.current_add_game_dialog.isVisible():
self.current_add_game_dialog.activateWindow() # Активируем существующий диалог
self.current_add_game_dialog.raise_() # Поднимаем окно
self.current_add_game_dialog.activateWindow()
self.current_add_game_dialog.raise_()
return
dialog = AddGameDialog(self, self.theme)
dialog.setFocus(Qt.FocusReason.OtherFocusReason)
self.current_add_game_dialog = dialog # Сохраняем ссылку на диалог
self.current_add_game_dialog = dialog
# Предзаполняем путь к .exe при drag-and-drop
if exe_path:
dialog.exeEdit.setText(exe_path)
dialog.nameEdit.setText(os.path.splitext(os.path.basename(exe_path))[0])
dialog.updatePreview()
# Обработчик закрытия диалога
def on_dialog_finished():
self.current_add_game_dialog = None # Сбрасываем ссылку при закрытии
self.current_add_game_dialog = None
dialog.finished.connect(on_dialog_finished)
@@ -1099,33 +832,123 @@ class MainWindow(QMainWindow):
if not name or not exe_path:
return
# Сохраняем .desktop файл
desktop_entry, desktop_path = dialog.getDesktopEntryData()
if desktop_entry and desktop_path:
with open(desktop_path, "w", encoding="utf-8") as f:
f.write(desktop_entry)
os.chmod(desktop_path, 0o755)
# Проверяем путь обложки, если он отличается от стандартной
if os.path.isfile(user_cover):
exe_name = os.path.splitext(os.path.basename(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)
exe_name = os.path.splitext(os.path.basename(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)
# Сохраняем пользовательскую обложку как cover.*
# Handle user cover copy
cover_path = None
if user_cover:
ext = os.path.splitext(user_cover)[1].lower()
if ext in [".png", ".jpg", ".jpeg", ".bmp"]:
shutil.copyfile(user_cover, os.path.join(custom_folder, f"cover{ext}"))
if os.path.isfile(user_cover) and ext in [".png", ".jpg", ".jpeg", ".bmp"]:
copied_cover = os.path.join(custom_folder, f"cover{ext}")
shutil.copyfile(user_cover, copied_cover)
cover_path = copied_cover
self.games = self.loadGames()
self.updateGameGrid()
# Parse .desktop (adapt from _process_desktop_file_async)
entry = parse_desktop_entry(desktop_path)
if not entry:
return
description = entry.get("Comment", "")
exec_line = entry.get("Exec", exe_path)
# Builtin custom folder (adapt path)
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
builtin_custom_folder = os.path.join(repo_root, "portprotonqt", "custom_data")
builtin_game_folder = os.path.join(builtin_custom_folder, exe_name)
builtin_cover = ""
if os.path.exists(builtin_game_folder):
builtin_files = set(os.listdir(builtin_game_folder))
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
candidate = f"cover{ext}"
if candidate in builtin_files:
builtin_cover = os.path.join(builtin_game_folder, candidate)
break
# User cover fallback
user_cover_path = cover_path # Already set if user provided
# Statistics (playtime, last launch - defaults for new)
playtime_seconds = 0
formatted_playtime = format_playtime(playtime_seconds)
last_played_timestamp = 0
last_launch = _("Never")
# Language for translations
language_code = get_egs_language()
# Read translations from metadata.txt
user_metadata_file = os.path.join(custom_folder, "metadata.txt")
builtin_metadata_file = os.path.join(builtin_game_folder, "metadata.txt")
translations = {'name': name, 'description': description}
if os.path.exists(user_metadata_file):
translations = read_metadata_translations(user_metadata_file, language_code)
elif os.path.exists(builtin_metadata_file):
translations = read_metadata_translations(builtin_metadata_file, language_code)
final_name = translations['name']
final_desc = translations['description']
def on_steam_info(steam_info: dict):
nonlocal final_name, final_desc
# Adapt final_cover logic from _process_desktop_file_async
final_cover = (user_cover_path if user_cover_path else
builtin_cover if builtin_cover else
steam_info.get("cover", "") or entry.get("Icon", ""))
# Use Steam description as fallback if no translation
steam_desc = steam_info.get("description", "")
if steam_desc and steam_desc != final_desc:
final_desc = steam_desc
# Use Steam name as fallback if better
steam_name = steam_info.get("name", "")
if steam_name and steam_name != final_name:
final_name = steam_name
# Build full game_data tuple with all Steam data
game_data = (
final_name,
final_desc,
final_cover,
steam_info.get("appid", ""),
exec_line,
steam_info.get("controller_support", ""),
last_launch,
formatted_playtime,
steam_info.get("protondb_tier", ""),
steam_info.get("anticheat_status", ""),
last_played_timestamp,
playtime_seconds,
"portproton"
)
# Incremental add
self.game_library_manager.add_game_incremental(game_data)
# Status message
msg = _("Added '{name}'").format(name=final_name)
self.statusBar().showMessage(msg, 3000)
# Trigger visible images load
QTimer.singleShot(200, self.game_library_manager.load_visible_images)
from portprotonqt.steam_api import get_steam_game_info_async
get_steam_game_info_async(final_name, exec_line, on_steam_info)
def createAutoInstallTab(self):
"""Вкладка 'Auto Install'."""
@@ -1182,14 +1005,227 @@ class MainWindow(QMainWindow):
self.wineTitle.setObjectName("tabTitle")
layout.addWidget(self.wineTitle)
self.wineContent = QLabel(_("Various Wine parameters and versions..."))
self.wineContent.setStyleSheet(self.theme.CONTENT_STYLE)
self.wineContent.setObjectName("tabContent")
layout.addWidget(self.wineContent)
if self.portproton_location is None:
return
dist_path = os.path.join(self.portproton_location, "data", "dist")
prefixes_path = os.path.join(self.portproton_location, "data", "prefixes")
if not os.path.exists(dist_path):
return
formLayout = QFormLayout()
formLayout.setContentsMargins(0, 10, 0, 0)
formLayout.setSpacing(10)
formLayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
self.wine_versions = [d for d in os.listdir(dist_path) if os.path.isdir(os.path.join(dist_path, d))]
self.wineCombo = QComboBox()
self.wineCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.wineCombo.addItems(self.wine_versions)
self.wineCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
self.wineCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.wineTitleLabel = QLabel(_("Compatibility tool:"))
self.wineTitleLabel.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.wineTitleLabel.setFocusPolicy(Qt.FocusPolicy.NoFocus)
if self.wine_versions:
self.wineCombo.setCurrentIndex(0)
formLayout.addRow(self.wineTitleLabel, self.wineCombo)
self.prefixes = [d for d in os.listdir(prefixes_path) if os.path.isdir(os.path.join(prefixes_path, d))] if os.path.exists(prefixes_path) else []
self.prefixCombo = QComboBox()
self.prefixCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.prefixCombo.addItems(self.prefixes)
self.prefixCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
self.prefixCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.prefixTitleLabel = QLabel(_("Prefix:"))
self.prefixTitleLabel.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.prefixTitleLabel.setFocusPolicy(Qt.FocusPolicy.NoFocus)
if self.prefixes:
self.prefixCombo.setCurrentIndex(0)
formLayout.addRow(self.prefixTitleLabel, self.prefixCombo)
layout.addLayout(formLayout)
# --- Wine Tools ---
tools_grid = QGridLayout()
tools_grid.setSpacing(6)
tools = [
("winecfg", _("Wine Configuration")),
("regedit", _("Registry Editor")),
("control", _("Control Panel")),
("taskmgr", _("Task Manager")),
("explorer", _("File Explorer")),
("cmd", _("Command Prompt")),
("uninstaller", _("Uninstaller")),
]
for i, (_tool_cmd, tool_name) in enumerate(tools):
row = i // 3
col = i % 3
btn = AutoSizeButton(tool_name, update_size=False)
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
tools_grid.addWidget(btn, row, col)
for col in range(3):
tools_grid.setColumnStretch(col, 1)
layout.addLayout(tools_grid)
# --- Additional Tools ---
additional_grid = QGridLayout()
additional_grid.setSpacing(6)
additional_buttons = [
("Winetricks", None),
(_("Create Prefix Backup"), self.create_prefix_backup),
(_("Load Prefix Backup"), self.load_prefix_backup),
(_("Delete Compatibility Tool"), self.delete_compat_tool),
(_("Delete Prefix"), self.delete_prefix),
(_("Clear Prefix"), None),
]
for i, (text, callback) in enumerate(additional_buttons):
row = i // 3
col = i % 3
btn = AutoSizeButton(text, update_size=False)
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
if callback:
btn.clicked.connect(callback)
additional_grid.addWidget(btn, row, col)
for col in range(3):
additional_grid.setColumnStretch(col, 1)
layout.addLayout(additional_grid)
tools_grid.setContentsMargins(10, 4, 10, 0)
additional_grid.setContentsMargins(10, 6, 10, 0)
layout.addStretch(1)
self.stackedWidget.addWidget(self.wineWidget)
def create_prefix_backup(self):
selected_prefix = self.prefixCombo.currentText()
if not selected_prefix:
return
file_explorer = FileExplorer(self, directory_only=True)
file_explorer.file_signal.file_selected.connect(lambda path: self._perform_backup(path, selected_prefix))
file_explorer.exec()
def _perform_backup(self, backup_dir, prefix_name):
os.makedirs(backup_dir, exist_ok=True)
if not self.portproton_location:
return
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
if not os.path.exists(start_sh):
return
self.backup_process = QProcess(self)
self.backup_process.finished.connect(lambda exitCode, exitStatus: self._on_backup_finished(exitCode))
cmd = [start_sh, "--backup-prefix", prefix_name, backup_dir]
self.backup_process.start(cmd[0], cmd[1:])
if not self.backup_process.waitForStarted():
QMessageBox.warning(self, _("Error"), _("Failed to start backup process."))
def load_prefix_backup(self):
file_explorer = FileExplorer(self, file_filter='.ppack')
file_explorer.file_signal.file_selected.connect(self._perform_restore)
file_explorer.exec()
def _perform_restore(self, file_path):
if not file_path or not os.path.exists(file_path):
return
if not self.portproton_location:
return
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
if not os.path.exists(start_sh):
return
self.restore_process = QProcess(self)
self.restore_process.finished.connect(lambda exitCode, exitStatus: self._on_restore_finished(exitCode))
cmd = [start_sh, "--restore-prefix", file_path]
self.restore_process.start(cmd[0], cmd[1:])
if not self.restore_process.waitForStarted():
QMessageBox.warning(self, _("Error"), _("Failed to start restore process."))
def _on_backup_finished(self, exitCode):
if exitCode == 0:
QMessageBox.information(self, _("Success"), _("Prefix backup completed."))
else:
QMessageBox.warning(self, _("Error"), _("Prefix backup failed."))
def _on_restore_finished(self, exitCode):
if exitCode == 0:
QMessageBox.information(self, _("Success"), _("Prefix restore completed."))
else:
QMessageBox.warning(self, _("Error"), _("Prefix restore failed."))
def delete_prefix(self):
selected_prefix = self.prefixCombo.currentText()
if not self.portproton_location:
return
if not selected_prefix:
return
prefix_path = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix)
if not os.path.exists(prefix_path):
return
reply = QMessageBox.question(
self,
_("Confirm Deletion"),
_("Are you sure you want to delete prefix '{}'?").format(selected_prefix),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
try:
shutil.rmtree(prefix_path)
QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix))
# обновляем список
self.prefixCombo.clear()
self.prefixes = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "prefixes"))
if os.path.isdir(os.path.join(self.portproton_location, "data", "prefixes", d))]
self.prefixCombo.addItems(self.prefixes)
except Exception as e:
QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e)))
def delete_compat_tool(self):
"""Удаляет выбранный Wine/Proton дистрибутив из каталога dist."""
if not self.portproton_location:
return
selected_tool = self.wineCombo.currentText()
if not selected_tool:
return
tool_path = os.path.join(self.portproton_location, "data", "dist", selected_tool)
if not os.path.exists(tool_path):
return
reply = QMessageBox.question(
self,
_("Confirm Deletion"),
_("Are you sure you want to delete compatibility tool '{}'?").format(selected_tool),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
try:
shutil.rmtree(tool_path)
QMessageBox.information(self, _("Success"), _("Compatibility tool '{}' deleted.").format(selected_tool))
# обновляем список
self.wineCombo.clear()
self.wine_versions = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "dist"))
if os.path.isdir(os.path.join(self.portproton_location, "data", "dist", d))]
self.wineCombo.addItems(self.wine_versions)
except Exception as e:
QMessageBox.warning(self, _("Error"), _("Failed to delete compatibility tool: {}").format(str(e)))
def createPortProtonTab(self):
"""Вкладка 'PortProton Settings'."""
self.portProtonWidget = QWidget()
@@ -1487,18 +1523,14 @@ class MainWindow(QMainWindow):
self.statusBar().showMessage(_("Cache cleared"), 3000)
def applySettingsDelayed(self):
"""Applies settings with the new filter and updates the game list."""
read_time_config()
self.games = []
self.loadGames()
display_filter = read_display_filter()
for card in self.game_card_cache.values():
for card in self.game_library_manager.game_card_cache.values():
card.update_badge_visibility(display_filter)
def savePortProtonSettings(self):
"""
Сохраняет параметры конфигурации в конфигурационный файл.
"""
time_idx = self.timeDetailCombo.currentIndex()
time_key = self.time_keys[time_idx]
save_time_config(time_key)
@@ -1511,7 +1543,6 @@ class MainWindow(QMainWindow):
filter_key = self.filter_keys[filter_idx]
save_display_filter(filter_key)
# Сохранение proxy настроек
proxy_url = self.proxyUrlEdit.text().strip()
proxy_user = self.proxyUserEdit.text().strip()
proxy_password = self.proxyPasswordEdit.text().strip()
@@ -1523,11 +1554,10 @@ class MainWindow(QMainWindow):
auto_fullscreen_gamepad = self.autoFullscreenGamepadCheckBox.isChecked()
save_auto_fullscreen_gamepad(auto_fullscreen_gamepad)
# Сохранение настройки виброотдачи геймпада
rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
save_rumble_config(rumble_enabled)
for card in self.game_card_cache.values():
for card in self.game_library_manager.game_card_cache.values():
card.update_badge_visibility(filter_key)
if self.currentDetailPage and self.current_exec_line:
@@ -1540,14 +1570,12 @@ class MainWindow(QMainWindow):
self.settingsDebounceTimer.start()
# Управление полноэкранным режимом
gamepad_connected = self.input_manager.find_gamepad() is not None
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
self.showFullScreen()
else:
# Если обе галочки сняты и геймпад не подключен, возвращаем нормальное состояние
self.showNormal()
self.resize(*read_window_geometry()) # Восстанавливаем сохраненные размеры окна
self.resize(*read_window_geometry())
self.statusBar().showMessage(_("Settings saved"), 3000)
@@ -2129,7 +2157,7 @@ class MainWindow(QMainWindow):
favorites.append(game_name)
label.setText("")
save_favorites(favorites)
self.updateGameGrid()
self.game_library_manager.update_game_grid()
def activateFocusedWidget(self):
"""Activate the currently focused widget."""

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 B

View File

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

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

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

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

View File

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

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 B

View File

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

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

View File

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

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

View File

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

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

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

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

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

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

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

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m15.973 13.476h8.5299q3.0163 0 4.6379 0.45406 1.6541 0.42163 2.3352 1.3946 0.68109 0.94056 0.68109 2.6595v2.5946q0 0.87569-0.71353 1.6541-0.68109 0.77839-1.6216 1.0703v0.16216q1.2325 0.12973 2.2379 1.0703 1.0379 0.90812 1.0379 2.0433v3.2433q0 2.5622-2.0433 3.6325t-6.3244 1.0703h-8.7569zm8.5299 8.5623q1.2 0 1.7838-0.1946t0.77839-0.61623q0.22703-0.45406 0.22703-1.2973v-1.0379q0-0.74596-0.1946-1.1027-0.1946-0.3892-0.81082-0.55136-0.58379-0.16216-1.8811-0.16216h-3.373v4.9622zm0.12973 8.8866q1.8487 0 2.6271-0.42163t0.77839-1.3622v-1.6865q0-1.1676-0.61623-1.6541-0.58379-0.4865-2.1081-0.4865h-4.2812v5.6109z" fill="#3f424d" stroke-width=".67675" aria-label="B"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m13.757 18h2.8844v9.9476h5.8983v2.0524h-8.7827zm10.724 0h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.0169-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="LB"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m12.943 18h5.3991q2.4037 0 3.4761 0.59168 1.0724 0.57319 1.0724 1.9414v2.1079q0 1.0539-0.75809 1.6641-0.7396 0.59168-1.9784 0.75809l3.4761 4.9368h-3.3282l-3.1433-4.7149h-1.3313v4.7149h-2.8844zm5.3621 5.3806q1.0169 0 1.3867-0.25886 0.3698-0.27735 0.3698-1.0539v-0.85054q0-0.66564-0.40678-0.90601-0.38829-0.25886-1.3498-0.25886h-2.4777v3.3282zm6.9892-5.3806h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.017-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="RB"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m16.169 14.061c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395z" fill="#3f424d" fill-rule="evenodd" stroke-width=".19943"/></svg>

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m12.75 14.492c-0.62128 0-1.1257 0.38721-1.1257 0.86456v12.1c0 0.47737 0.50442 0.86456 1.1257 0.86456h3.3753v-1.7274h-2.2496v-10.373h13.498v1.7291h2.2496v-2.5937c0-0.47735-0.50268-0.86456-1.1239-0.86456zm6.7489 5.1874c-0.62128 0-1.1239 0.38721-1.1239 0.86456v12.1c0 0.47737 0.50266 0.86456 1.1239 0.86456h15.749c0.62125 0 1.1239-0.3872 1.1239-0.86456v-12.1c0-0.47735-0.50268-0.86456-1.1239-0.86456zm1.1257 1.7291h13.498v10.371h-13.498z" clip-rule="evenodd" fill="#3f424d" fill-rule="evenodd" stroke-width=".98604"/></svg>

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.097 23.789-7.4272-10.314h5.8379l4.5082 7.0055 4.4758-7.0055h5.8379l-7.4272 10.314 7.7839 10.735h-5.8704l-4.8001-7.4596-4.8325 7.4596h-5.8379z" fill="#3f424d" stroke-width=".67675" aria-label="X"/></svg>

After

Width:  |  Height:  |  Size: 605 B

View File

@@ -20,7 +20,7 @@
},
{
"matchFileNames": [".python-version"],
"enabled": false,
"enabled": false
},
{
"matchManagers": ["poetry", "pyenv"],
@@ -41,6 +41,6 @@
"matchDatasources": ["github-runners", "python-version"],
"enabled": false,
"description": "Prevent Renovate from updating runs-on to unsupported ubuntu-24.04"
},
}
]
}