72 Commits
v0.1.7 ... main

Author SHA1 Message Date
836e6cdd36 feat(settings): added initial gamepad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-21 00:08:02 +05:00
b2a1046f9d fix: eliminate blocking calls causing startup freezes and UI hangs
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-20 10:55:35 +05:00
80a2c06b5a feat: added refresh button
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 17:07:58 +05:00
f0a4ace735 perf: add config and icon caching to reduce I/O and improve UI responsiveness
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 16:57:40 +05:00
7dfaee6831 feat(settings): added proton, 3d_api and prefixes settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 13:35:35 +05:00
5481cd80d7 chore: added null pixmaps check
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 12:25:01 +05:00
a016cfa810 chore: convert list to set for optimize
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 12:13:42 +05:00
8fc097ccaf chore: remove broken styles
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-16 12:03:18 +05:00
ad3eeb6e06 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-15 21:39:05 +05:00
92631cd2c6 chore: separate settings list to new module
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-15 21:39:03 +05:00
4477679f2d chore: replace emulataion buttons to xbox + start
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-15 21:38:07 +05:00
Renovate Bot
b6644eeee5 fix(deps): update dependency pillow to v12 2025-11-15 21:38:07 +05:00
Renovate Bot
2e921226c4 chore(deps): update https://gitea.com/actions/setup-python action to v6 2025-11-15 21:38:06 +05:00
Renovate Bot
4fc1ea73d3 chore(deps): update https://gitea.com/actions/setup-node action to v6 2025-11-15 21:38:06 +05:00
Renovate Bot
3c15cbe495 chore(deps): update archlinux:base-devel docker digest to 943bdad 2025-11-15 21:38:06 +05:00
fed6aafed5 feat: trigger emulation by Xbox + B
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-15 21:38:06 +05:00
2e8be13437 WINETRICKS_TABBLE_STYLE more fixes 2025-11-15 16:22:59 +07:00
ea272c29b6 WINETRICKS_TABBLE_STYLE reworked 2025-11-13 15:43:13 +07:00
17262f6c9f Play Button & Settings Button in row 2025-11-07 12:17:36 +07:00
e07f3f06bc chore(build): return QtSvg to appimage
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-04 12:28:09 +05:00
16a3f4e09a chore(build): added udev rule to allow create virtual devices
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-04 11:14:17 +05:00
a448ba29b0 feat(input_manager): added mouse emulation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-03 12:34:27 +05:00
06e55db54d feat(settings): update styles
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-02 16:05:22 +05:00
5fce23f261 chore: disable pre-commit auto update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-11-02 15:31:01 +05:00
Renovate Bot
96ad40d625 chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.14.3 2025-11-02 00:01:26 +00:00
Gitea Actions
a30f6f2e74 chore: update steam apps list 2025-11-01T00:01:57Z 2025-11-01 00:01:58 +00:00
0231073b19 feat(settings): added advanced
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-30 16:27:45 +05:00
dec24429f5 chore: separate start.sh to new function
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-28 15:34:01 +05:00
4a758f3b3c chore: use flatpak run for flatpak not start.sh
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-27 23:13:48 +05:00
0853dd1579 chore: use CLI for clear pfx
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-27 22:36:14 +05:00
bbb87c0455 feat(settings): added icon to button thanks to @Dervart
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-27 12:17:00 +05:00
b32a71a125 feat(settings): block settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-27 12:16:54 +05:00
Renovate Bot
bddf9f850a chore(deps): update pre-commit hook astral-sh/uv-pre-commit to v0.9.5 2025-10-26 00:01:29 +00:00
Renovate Bot
a9c3cfa167 chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.14.2 2025-10-26 00:01:19 +00:00
7675bc4cdc feat: added initial exe settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-26 00:12:00 +05:00
ffa203f019 feat: restore instance from tray
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-22 15:46:57 +05:00
3eed25ecee feat: update grid on update_favorite_icon
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-21 20:41:21 +05:00
3736bb279e feat: use SGDB for cover too
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-20 13:07:09 +05:00
Renovate Bot
b59ee5ae8e chore(deps): update archlinux:base-devel docker digest to 87a967f 2025-10-19 12:07:20 +00:00
33176590fd feat: Make autoinstall games loading asynchronous with caching
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-19 17:03:26 +05:00
8046065929 refactor(gamepad): replace busy-wait with threading.Event for monitor readiness
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-19 11:00:22 +05:00
Renovate Bot
fbad5add6c chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.14.1 2025-10-19 00:01:23 +00:00
438e9737ea chore(release): drop sha256 sums
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 21:07:37 +05:00
2d39a4c740 fix: fix CloseEvent on native package
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 21:06:23 +05:00
567203b0b0 chore: bump to 0.1.8
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:22:32 +05:00
502cbc5030 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:20:50 +05:00
9b61215152 chore(theme): update screenshots
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 18:17:47 +05:00
10d3fe8ab4 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 13:40:56 +05:00
a568ad9ef8 fix(add_game_dialog): prevent overwriting manually entered game name
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 13:09:58 +05:00
f074843fc8 fix: prevent udev monitor hang by using non-blocking poll with timeout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 12:53:47 +05:00
4ab078b93e fix: sync card_width between GameLibraryManager and MainWindow to prevent config overwrite
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-18 12:17:17 +05:00
7df6ad3b80 feat(autoinstalls): added slider
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 13:55:17 +05:00
464ad0fe9c chore: optimize and clean code
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 13:09:02 +05:00
cde92885d4 feat(virtual_keybord): added gamepad hint
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 00:04:47 +05:00
120c7b319c fix: improve gamepad detection using udev ID_INPUT_JOYSTICK property 2025-10-16 23:20:48 +05:00
596aed0077 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:54:30 +05:00
6fc6cb1e02 feat: added minimize to tray
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:53:08 +05:00
186e28a19b fix(gamepad): resolve MonitorObserver blocking issue causing application hang
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:43:49 +05:00
28e4d1e77c Revert "chore: broke autorelease for tasting purpose"
This reverts commit fff1f888c4.
2025-10-16 14:11:36 +05:00
fff1f888c4 chore: broke autorelease for tasting purpose
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 12:48:21 +05:00
fdd5a0a3d5 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 10:44:30 +05:00
792e52d981 feat(dialogs): added controller hints
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 10:39:24 +05:00
84d5e46a74 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 22:53:08 +05:00
4bc764d568 partially revert b1047ba18e
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 22:31:35 +05:00
9a18aa037e feat(autoinstall): no restart on autoinstall finished
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 21:58:40 +05:00
ed62d2d1c4 fix: resolve lambda variable capture issue in switchTab method
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 21:47:14 +05:00
accc9b18b6 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 15:31:56 +05:00
82249d7eab feat(settings): Added Gamepad type settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 15:30:31 +05:00
476c896940 chore(TODO): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 12:44:01 +05:00
b1047ba18e fix: fix card overlap on display_filter change
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-13 12:14:54 +05:00
987199d8e6 chore(release): enable node experimental-fetch
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-13 11:52:43 +05:00
Renovate Bot
ef1acd4581 chore(deps): update archlinux:base-devel docker digest to 06ab929 2025-10-12 17:46:27 +00:00
65 changed files with 16974 additions and 2484 deletions

View File

@@ -62,7 +62,7 @@ jobs:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
python3-build pyproject-rpm-macros python3-setuptools \
python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
redhat-rpm-config nodejs npm
- name: Setup rpmbuild environment
@@ -94,7 +94,7 @@ jobs:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e
volumes:
- /usr:/usr-host
- /opt:/opt-host

View File

@@ -8,7 +8,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 0.1.7
VERSION: 0.1.8
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -119,7 +119,7 @@ jobs:
- name: Install build dependencies
run: |
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \
python3-build pyproject-rpm-macros python3-setuptools \
python3-build pyproject-rpm-macros systemd-rpm-macros python3-setuptools \
redhat-rpm-config nodejs npm
- name: Setup rpmbuild environment
@@ -180,10 +180,12 @@ jobs:
- name: Release
uses: https://gitea.com/actions/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
body_path: changelog.txt
token: ${{ env.GITEA_TOKEN }}
tag_name: v${{ env.VERSION }}
prerelease: true
files: release/**/*
sha256sum: true
sha256sum: false

View File

@@ -19,7 +19,7 @@ jobs:
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: https://gitea.com/actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
with:
python-version-file: "pyproject.toml"

View File

@@ -138,7 +138,7 @@ jobs:
needs: changes
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
container:
image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166
image: archlinux:base-devel@sha256:943bdad9e9d0d23503f24797b44ce2cc1531bf101e18b3e7fb8c8776190dc45e
volumes:
- /usr:/usr-host
- /opt:/opt-host

View File

@@ -23,7 +23,7 @@ jobs:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
with:
node-version: 20

View File

@@ -14,7 +14,7 @@ jobs:
uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Python
uses: https://gitea.com/actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: https://gitea.com/actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6
with:
python-version-file: "pyproject.toml"

View File

@@ -13,7 +13,7 @@ jobs:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
- name: Set up Node.js
uses: https://gitea.com/actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5
uses: https://gitea.com/actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6
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.22
rev: 0.9.5
hooks:
- id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.14.0
rev: v0.14.3
hooks:
- id: ruff-check

View File

@@ -3,6 +3,31 @@
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [0.1.8] - 2025-10-18
### Added
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
### Changed
- При завершении автоустановки приложение больше не перезапускается
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
- Обновлены и дополнены скриншоты темы
### Fixed
- Исправлено наложение карточек при смене фильтра игр
- Исправлена невозможность запуска приложения без подключёного геймпада
- Исправлена невозможность установки компонентов Winetricks через геймпад
- Ресиверы и виртуальные устройства больше не считаются за геймпад
### Contributors
- @Vector_null
---
## [0.1.7] - 2025-10-12
### Added

15
TODO.md
View File

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

View File

@@ -6,11 +6,12 @@ script:
- uv pip install --no-cache-dir ../
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr
- cp -r lib AppDir/usr
- 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*}
- 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*,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*}
- shopt -s extglob
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!(libQt6Core*|libQt6DBus*|libQt6Egl*|libQt6Gui*|libQt6Network*|libQt6Svg*|libQt6Wayland*|libQt6Widgets*|libQt6XcbQpa*|libicudata*|libicui18n*|libicuuc*)
AppDir:
path: ./AppDir
after_bundle:
@@ -36,7 +37,7 @@ AppDir:
id: ru.linux_gaming.PortProtonQt
name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt
version: 0.1.7
version: 0.1.8
exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@"
apt:

View File

@@ -1,5 +1,5 @@
pkgname=portprotonqt
pkgver=0.1.7
pkgver=0.1.8
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any')
@@ -20,4 +20,5 @@ package() {
cd "$srcdir/PortProtonQt"
python -m installer --destdir="$pkgdir" dist/*.whl
cp -r build-aux/share "$pkgdir/usr/"
cp -r build-aux/lib "$pkgdir/usr/"
}

View File

@@ -25,4 +25,5 @@ package() {
cd "$srcdir/PortProtonQt"
python -m installer --destdir="$pkgdir" dist/*.whl
cp -r build-aux/share "$pkgdir/usr/"
cp -r build-aux/lib "$pkgdir/usr/"
}

View File

@@ -22,6 +22,7 @@ BuildRequires: python3-build
BuildRequires: pyproject-rpm-macros
BuildRequires: python3dist(setuptools)
BuildRequires: git
BuildRequires: systemd-rpm-macros
%description
%{summary}
@@ -69,11 +70,13 @@ cd %{oname}
%pyproject_install
%pyproject_save_files %{pypi_name}
cp -r build-aux/share %{buildroot}/usr/
cp -r build-aux/lib %{buildroot}/usr/
%files -n python3-%{pypi_name}-git -f %{pyproject_files}
%{_bindir}/%{pypi_name}
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
%{_udevrulesdir}/60-portprotonqt.rules
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%{bash_completions_dir}/portprotonqt

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 0.1.7
%global pypi_version 0.1.8
%global oname PortProtonQt
%global _python_no_extras_requires 1
@@ -19,6 +19,7 @@ BuildRequires: python3-build
BuildRequires: pyproject-rpm-macros
BuildRequires: python3dist(setuptools)
BuildRequires: git
BuildRequires: systemd-rpm-macros
%description
%{summary}
@@ -68,11 +69,13 @@ cd %{oname}
%pyproject_install
%pyproject_save_files %{pypi_name}
cp -r build-aux/share %{buildroot}/usr/
cp -r build-aux/lib %{buildroot}/usr/
%files -n python3-%{pypi_name} -f %{pyproject_files}
%{_bindir}/%{pypi_name}
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
%{_udevrulesdir}/60-portprotonqt.rules
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%{bash_completions_dir}/portprotonqt

View File

@@ -0,0 +1 @@
KERNEL=="uinput", SUBSYSTEM=="misc", OPTIONS+="static_node=uinput", TAG+="uaccess"

View File

@@ -1021,7 +1021,7 @@
},
{
"normalized_name": "farlight 84",
"status": "Supported"
"status": "Denied"
},
{
"normalized_name": "riders republic",
@@ -1436,8 +1436,8 @@
"status": "Broken"
},
{
"normalized_name": "blue protocol",
"status": "Broken"
"normalized_name": "blue protocol star resonance",
"status": "Running"
},
{
"normalized_name": "dark and darker",

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -1,4 +1,108 @@
[
{
"normalized_title": "split/second",
"slug": "split-second"
},
{
"normalized_title": "warzone 2100",
"slug": "warzone-2100"
},
{
"normalized_title": "foundation",
"slug": "foundation"
},
{
"normalized_title": "земский собор [демо]",
"slug": "zemskij-sobor-demo"
},
{
"normalized_title": "crusader kings 3",
"slug": "crusader-kings-3"
},
{
"normalized_title": "nadir a grimdark deck builder",
"slug": "nadir-a-grimdark-deck-builder"
},
{
"normalized_title": "oriental empires",
"slug": "oriental-empires"
},
{
"normalized_title": "vampire the masquerade bloodlines 2",
"slug": "vampire-the-masquerade-bloodlines-2"
},
{
"normalized_title": "escape from duckov",
"slug": "escape-from-duckov"
},
{
"normalized_title": "xiii",
"slug": "xiii"
},
{
"normalized_title": "saints row 2",
"slug": "saints-row-2"
},
{
"normalized_title": "frozenheim",
"slug": "frozenheim"
},
{
"normalized_title": "saints row (2022)",
"slug": "saints-row-2022"
},
{
"normalized_title": "iron harvest",
"slug": "iron-harvest"
},
{
"normalized_title": "tom clancy's splinter cell blacklist",
"slug": "tom-clancys-splinter-cell-blacklist"
},
{
"normalized_title": "painkiller overdose",
"slug": "painkiller-overdose"
},
{
"normalized_title": "ancestors legacy",
"slug": "ancestors-legacy"
},
{
"normalized_title": "bye sweet carole",
"slug": "bye-sweet-carole"
},
{
"normalized_title": "painkiller black",
"slug": "painkiller-black-edition"
},
{
"normalized_title": "hogwarts legacy",
"slug": "hogwarts-legacy"
},
{
"normalized_title": "active matter",
"slug": "active-matter"
},
{
"normalized_title": "tom clancy's splinter cell",
"slug": "tom-clancys-splinter-cell"
},
{
"normalized_title": "sniper ghost warrior",
"slug": "sniper-ghost-warrior"
},
{
"normalized_title": "fate undiscovered realms",
"slug": "fate-undiscovered-realms"
},
{
"normalized_title": "dying light the beast deluxe",
"slug": "dying-light-the-beast-deluxe-edition"
},
{
"normalized_title": "spellforce platinum",
"slug": "spellforce-platinum-edition"
},
{
"normalized_title": "dirt rally 2.0 game of the year",
"slug": "dirt-rally-2-0-game-of-the-year-edition"
@@ -271,10 +375,6 @@
"normalized_title": "steins;gate the distant valhalla",
"slug": "steins-gate-the-distant-valhalla"
},
{
"normalized_title": "hogwarts legacy",
"slug": "hogwarts-legacy"
},
{
"normalized_title": "osu!",
"slug": "osu"

Binary file not shown.

View File

@@ -17,17 +17,31 @@ import json
class PySide6DependencyAnalyzer:
def __init__(self):
def __init__(self, project_root: Path = None):
# Системные библиотеки, которые нужно всегда оставлять
self.system_libs = {
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus'
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus',
'libQt6Svg'
}
self.critical_modules = {
'QtSvg',
}
self.real_dependencies = {}
self.used_modules_code = set()
self.used_modules_ldd = set()
self.all_required_modules = set()
# Определяем корень проекта
if project_root is None:
# Корень проекта - две директории выше от скрипта
self.project_root = Path(__file__).parent.parent
else:
self.project_root = project_root
self.venv_path = self.project_root / ".venv"
self.build_path = self.project_root / "build-aux"
def find_python_files(self, directory: Path) -> List[Path]:
"""Находит все Python файлы в директории"""
@@ -44,24 +58,61 @@ class PySide6DependencyAnalyzer:
"""Находит все PySide6 библиотеки (.so файлы)"""
libs = {}
# Поиск в единственной локации
search_path = Path("../.venv/lib/python3.10/site-packages/PySide6")
print(f"Поиск PySide6 библиотек в: {search_path}")
# Ищем venv в корне проекта
venv_candidates = [
self.venv_path, # .venv
self.project_root / "venv",
self.project_root / ".virtualenv",
]
if search_path.exists():
# Ищем .so файлы модулей
for so_file in search_path.glob("Qt*.*.so"):
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
if module_name.startswith('Qt'):
libs[module_name] = so_file
pyside6_path = None
# Также ищем в подпапках
for subdir in search_path.iterdir():
if subdir.is_dir() and subdir.name.startswith('Qt'):
for so_file in subdir.glob("*.so*"):
if 'Qt' in so_file.name:
libs[subdir.name] = so_file
break
# Пробуем найти PySide6 в venv
for venv in venv_candidates:
if venv.exists():
# Ищем Python версию
lib_path = venv / "lib"
if lib_path.exists():
for python_dir in lib_path.iterdir():
if python_dir.name.startswith('python'):
candidate = python_dir / "site-packages" / "PySide6"
if candidate.exists():
pyside6_path = candidate
print(f"Найден PySide6 в: {candidate}")
break
if pyside6_path:
break
if not pyside6_path:
print(f"Предупреждение: PySide6 не найден в venv, проверяем AppDir...")
# Если не нашли в venv, пробуем в AppDir
if base_path:
appdir_candidate = base_path / "AppDir/usr/local/lib"
if appdir_candidate.exists():
for python_dir in appdir_candidate.iterdir():
if python_dir.name.startswith('python'):
candidate = python_dir / "dist-packages" / "PySide6"
if candidate.exists():
pyside6_path = candidate
print(f"Найден PySide6 в AppDir: {candidate}")
break
if not pyside6_path:
return libs
# Ищем .so файлы модулей
for so_file in pyside6_path.glob("Qt*.*.so"):
module_name = so_file.stem.split('.')[0] # QtCore.abi3.so -> QtCore
if module_name.startswith('Qt'):
libs[module_name] = so_file
# Также ищем в подпапках
for subdir in pyside6_path.iterdir():
if subdir.is_dir() and subdir.name.startswith('Qt'):
for so_file in subdir.glob("*.so*"):
if 'Qt' in so_file.name:
libs[subdir.name] = so_file
break
return libs
@@ -257,8 +308,10 @@ class PySide6DependencyAnalyzer:
# Модули для удаления
if removable_modules:
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)])
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
removable_filtered = [m for m in removable_modules if m not in self.critical_modules]
if removable_filtered:
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_filtered)])
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}")
# Генерируем команду для удаления нативных библиотек с сохранением нужных
required_libs = set()
@@ -276,39 +329,82 @@ class PySide6DependencyAnalyzer:
f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
])
# Заменяем блок очистки в рецепте
import re
# Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir:
pattern = r'( # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n # [0-9]+\)|$)'
# Ищем весь блок команд очистки PySide6 (от первой rm до AppDir:)
# Паттерн: после " - cp -r lib AppDir/usr\n" идут команды rm, а затем "AppDir:"
pattern = r'( - cp -r lib AppDir/usr\n)((?: - (?:rm|shopt).*\n)*?)(?=AppDir:)'
new_cleanup_block = " # 5) чистим от ненужных модулей и бинарников\n" + '\n'.join(cleanup_lines)
match = re.search(pattern, recipe_content)
updated_recipe = re.sub(pattern, new_cleanup_block, recipe_content, flags=re.DOTALL)
if not match:
print("ПРЕДУПРЕЖДЕНИЕ: Не удалось найти блок очистки в рецепте")
print("Добавляем команды очистки перед блоком AppDir:")
# Просто вставим команды перед AppDir:
appdir_pos = recipe_content.find('AppDir:')
if appdir_pos != -1:
new_content = (
recipe_content[:appdir_pos] +
'\n'.join(cleanup_lines) + '\n' +
recipe_content[appdir_pos:]
)
return new_content
else:
print("ОШИБКА: Не найден блок AppDir: в рецепте")
return ""
# Создаем замену - группа 1 (cp -r lib) + новые команды очистки
replacement = r'\1' + '\n'.join(cleanup_lines) + '\n'
updated_recipe = re.sub(pattern, replacement, recipe_content, count=1)
return updated_recipe
def main():
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd')
parser.add_argument('project_path', help='Путь к проекту для анализа')
parser.add_argument('project_path', nargs='?', default='.',
help='Путь к проекту для анализа (по умолчанию: текущая директория)')
parser.add_argument('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
parser.add_argument('--venv', help='Путь к виртуальному окружению (по умолчанию: .venv в корне проекта)')
args = parser.parse_args()
project_path = Path(args.project_path)
project_path = Path(args.project_path).resolve()
if not project_path.exists():
print(f"Ошибка: путь {project_path} не существует")
sys.exit(1)
appdir_path = Path(args.appdir) if args.appdir else None
appdir_path = Path(args.appdir).resolve() if args.appdir else None
if appdir_path and not appdir_path.exists():
print(f"Предупреждение: AppDir путь {appdir_path} не существует")
appdir_path = None
analyzer = PySide6DependencyAnalyzer()
# Определяем корень проекта
# Если запущен из подпапки проекта, ищем корень
project_root = project_path
if (project_path / ".git").exists() or (project_path / "pyproject.toml").exists():
project_root = project_path
else:
# Пытаемся найти корень проекта
current = project_path
while current != current.parent:
if (current / ".git").exists() or (current / "pyproject.toml").exists():
project_root = current
break
current = current.parent
print(f"Корень проекта: {project_root}")
analyzer = PySide6DependencyAnalyzer(project_root=project_root)
# Если указан custom venv путь
if args.venv:
analyzer.venv_path = Path(args.venv).resolve()
print(f"Использую указанный venv: {analyzer.venv_path}")
results = analyzer.analyze_project(project_path, appdir_path)
# Сохраняем в анализатор для генерации команд
@@ -347,13 +443,13 @@ def main():
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
if args.verbose and results['real_dependencies']:
Devlin(f"\nРеальные зависимости (ldd):")
print(f"\nРеальные зависимости (ldd):")
for module, deps in results['real_dependencies'].items():
if deps:
print(f" {module}{', '.join(deps)}")
# Обновляем AppImage рецепт
recipe_path = Path("../build-aux/AppImageBuilder.yml")
recipe_path = analyzer.build_path / "AppImageBuilder.yml"
if recipe_path.exists():
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
if updated_recipe:

View File

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

View File

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

View File

@@ -1,40 +1,57 @@
import sys
import os
import subprocess
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from portprotonqt.main_window import MainWindow
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location
from portprotonqt.config_utils import (
save_fullscreen_config,
read_fullscreen_config,
get_portproton_start_command
)
from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.cli import parse_args
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
__app_version__ = "0.1.7"
__app_version__ = "0.1.8"
def get_version():
try:
commit = subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'],
stderr=subprocess.DEVNULL
).decode('utf-8').strip()
["git", "rev-parse", "--short", "HEAD"],
stderr=subprocess.DEVNULL,
).decode("utf-8").strip()
return f"{__app_version__} ({commit})"
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return __app_version__
def main():
os.environ['PW_CLI'] = '1'
os.environ['PROCESS_LOG'] = '1'
os.environ['START_FROM_STEAM'] = '1'
os.environ["PW_CLI"] = "1"
os.environ["PROCESS_LOG"] = "1"
os.environ["START_FROM_STEAM"] = "1"
portproton_path = get_portproton_location()
start_sh = get_portproton_start_command()
if portproton_path is None:
if start_sh is None:
return
script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh')
subprocess.run([script_path, 'cli', '--initial'])
# Run the initial command asynchronously to avoid blocking startup
import threading
def run_initial_command():
try:
subprocess.run(start_sh + ["cli", "--initial"], timeout=10) # Add timeout to prevent hanging
except subprocess.TimeoutExpired:
logger.warning("Initial command timed out after 10 seconds")
except Exception as e:
logger.warning(f"Initial command failed: {e}")
# Start the initial command in a background thread to not block UI startup
initial_thread = threading.Thread(target=run_initial_command, daemon=True)
initial_thread.start()
app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__))
@@ -43,41 +60,116 @@ def main():
app.setApplicationVersion(__app_version__)
args = parse_args()
# Setup logger with specified debug level
setup_logger(args.debug_level)
# Reinitialize logger after setup to ensure it uses the new configuration
logger = get_logger(__name__)
# --- Single-instance logic ---
server_name = __app_id__
socket = QLocalSocket()
socket.connectToServer(server_name)
if socket.waitForConnected(200):
# Второй экземпляр — передаём команду первому
fullscreen = args.fullscreen or read_fullscreen_config()
msg = b"show:fullscreen" if fullscreen else b"show"
socket.write(msg)
socket.flush()
socket.waitForBytesWritten(500)
socket.disconnectFromServer()
logger.info("Restored existing instance from tray")
return
# Если старый сокет остался — удалить
QLocalServer.removeServer(server_name)
local_server = QLocalServer()
if not local_server.listen(server_name):
logger.warning(f"Failed to start local server: {local_server.errorString()}")
return
# --- Qt translations ---
system_locale = QLocale.system()
qt_translator = QTranslator()
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
app.installTranslator(qt_translator)
else:
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
logger.warning(
f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
)
# --- Main Window ---
version = get_version()
window = MainWindow(app_name=__app_name__, version=version)
if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag")
# --- Handle incoming connections ---
def handle_new_connection():
conn = local_server.nextPendingConnection()
if not conn:
return
if conn.waitForReadyRead(1000):
data = conn.readAll().data()
msg = bytes(data).decode("utf-8", errors="ignore")
logger.info(f"IPC message received: {msg}")
def restore_window():
try:
if msg.startswith("show"):
if hasattr(window, "restore_from_tray"):
window.restore_from_tray() # type: ignore[attr-defined]
else:
window.showNormal()
window.raise_()
window.activateWindow()
window.setWindowState(
window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
)
if ":fullscreen" in msg:
logger.info("Switching to fullscreen via IPC")
save_fullscreen_config(True)
window.showFullScreen()
else:
logger.info("Switching to normal window via IPC")
save_fullscreen_config(False)
window.showNormal()
except Exception as e:
logger.warning(f"Failed to restore window: {e}")
# Выполняем в основном потоке
QTimer.singleShot(0, restore_window)
conn.disconnectFromServer()
local_server.newConnection.connect(handle_new_connection)
# --- Initial fullscreen state ---
launch_fullscreen = args.fullscreen or read_fullscreen_config()
if launch_fullscreen:
logger.info(
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
)
save_fullscreen_config(True)
window.showFullScreen()
else:
logger.info("Launching in normal mode")
save_fullscreen_config(False)
window.showNormal()
# --- Cleanup ---
def cleanup_on_exit():
nonlocal window
app.aboutToQuit.disconnect()
if window:
window.close()
app.quit()
try:
local_server.close()
QLocalServer.removeServer(server_name)
if window:
window.close()
except Exception as e:
logger.warning(f"Cleanup error: {e}")
app.aboutToQuit.connect(cleanup_on_exit)
window.show()
sys.exit(app.exec())
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@@ -1,11 +1,17 @@
import os
import configparser
import shutil
import subprocess
from portprotonqt.logger import get_logger
logger = get_logger(__name__)
_portproton_location = None
_portproton_start_sh = None
# Configuration cache for performance optimization
_config_cache = {}
_config_last_modified = {}
# Paths to configuration files
CONFIG_FILE = os.path.join(
@@ -26,13 +32,35 @@ THEMES_DIRS = [
]
def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails."""
cp = configparser.ConfigParser()
"""Safely reads a configuration file and returns a ConfigParser object or None if reading fails.
Uses caching to avoid repeated file reads for better performance.
"""
# Check if file exists
if not os.path.exists(config_file):
logger.debug(f"Configuration file {config_file} not found")
return None
# Get file modification time
try:
current_mtime = os.path.getmtime(config_file)
except OSError:
logger.warning(f"Failed to get modification time for {config_file}")
return None
# Check if we have a cached version that's still valid
if config_file in _config_cache and config_file in _config_last_modified:
if _config_last_modified[config_file] == current_mtime:
logger.debug(f"Using cached config for {config_file}")
return _config_cache[config_file]
# Read and parse the config file
cp = configparser.ConfigParser()
try:
cp.read(config_file, encoding="utf-8")
# Update cache
_config_cache[config_file] = cp
_config_last_modified[config_file] = current_mtime
logger.debug(f"Config file {config_file} loaded and cached")
return cp
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.warning(f"Invalid configuration file format: {e}")
@@ -41,6 +69,14 @@ def read_config_safely(config_file: str) -> configparser.ConfigParser | None:
logger.warning(f"Failed to read configuration file: {e}")
return None
def invalidate_config_cache(config_file: str = CONFIG_FILE):
"""Invalidates the cached configuration for the specified file."""
if config_file in _config_cache:
del _config_cache[config_file]
if config_file in _config_last_modified:
del _config_last_modified[config_file]
logger.debug(f"Config cache invalidated for {config_file}")
def read_config():
"""Reads the configuration file and returns a dictionary of parameters.
Example line in config (no sections):
@@ -75,6 +111,8 @@ def save_theme_to_config(theme_name):
cp["Appearance"]["theme"] = theme_name
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_time_config():
"""Reads time settings from the [Time] section of the configuration file.
@@ -94,6 +132,8 @@ def save_time_config(detail_level):
cp["Time"]["detail_level"] = detail_level
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_file_content(file_path):
"""Reads the content of a file and returns it as a string."""
@@ -101,14 +141,14 @@ def read_file_content(file_path):
return f.read().strip()
def get_portproton_location():
"""Returns the path to the PortProton directory.
Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
If the path is invalid, uses the default directory.
"""
"""Возвращает путь к PortProton каталогу (строку) или None."""
global _portproton_location
if _portproton_location is not None:
return _portproton_location
location = None
if os.path.isfile(PORTPROTON_CONFIG_FILE):
try:
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
@@ -116,19 +156,46 @@ def get_portproton_location():
_portproton_location = location
logger.info(f"PortProton path from configuration: {location}")
return _portproton_location
logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
except (OSError, PermissionError) as e:
logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
logger.warning(f"Failed to read PortProton configuration file: {e}")
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_dir):
_portproton_location = default_dir
logger.info(f"Using flatpak PortProton directory: {default_dir}")
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_flatpak_dir):
_portproton_location = default_flatpak_dir
logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
return _portproton_location
logger.warning("PortProton configuration and flatpak directory not found")
logger.warning("PortProton configuration and Flatpak directory not found")
return None
def get_portproton_start_command():
"""Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
portproton_path = get_portproton_location()
if not portproton_path:
return None
try:
result = subprocess.run(
["flatpak", "list"],
capture_output=True,
text=True,
check=False
)
if "ru.linux_gaming.PortProton" in result.stdout:
logger.info("Detected Flatpak installation")
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
except Exception:
pass
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
if os.path.exists(start_sh_path):
return [start_sh_path]
logger.warning("Neither flatpak nor start.sh found for PortProton")
return None
def parse_desktop_entry(file_path):
"""Reads and parses a .desktop file using configparser.
Returns None if the [Desktop Entry] section is missing.
@@ -176,6 +243,30 @@ def save_card_size(card_width):
cp["Cards"]["card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_auto_card_size():
"""Reads the card size (width) for Auto Install from the [Cards] section.
Returns 250 if the parameter is not set.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Cards") or not cp.has_option("Cards", "auto_card_width"):
save_auto_card_size(250)
return 250
return cp.getint("Cards", "auto_card_width", fallback=250)
def save_auto_card_size(card_width):
"""Saves the card size (width) for Auto Install to the [Cards] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Cards" not in cp:
cp["Cards"] = {}
cp["Cards"]["auto_card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_sort_method():
"""Reads the sort method from the [Games] section.
@@ -195,6 +286,8 @@ def save_sort_method(sort_method):
cp["Games"]["sort_method"] = sort_method
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_display_filter():
"""Reads the display_filter parameter from the [Games] section.
@@ -214,6 +307,8 @@ def save_display_filter(filter_value):
cp["Games"]["display_filter"] = filter_value
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_favorites():
"""Reads the list of favorite games from the [Favorites] section.
@@ -239,6 +334,8 @@ def save_favorites(favorites):
cp["Favorites"]["games"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_rumble_config():
"""Reads the gamepad rumble setting from the [Gamepad] section.
@@ -258,6 +355,29 @@ def save_rumble_config(rumble_enabled):
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_gamepad_type():
"""Reads the gamepad type from the [Gamepad] section.
Returns 'xbox' if the parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"):
save_gamepad_type("xbox")
return "xbox"
return cp.get("Gamepad", "type", fallback="xbox").lower()
def save_gamepad_type(gpad_type):
"""Saves the gamepad type to the [Gamepad] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Gamepad" not in cp:
cp["Gamepad"] = {}
cp["Gamepad"]["type"] = gpad_type
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file.
@@ -302,6 +422,8 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
cp["Proxy"]["proxy_password"] = proxy_password
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_fullscreen_config():
"""Reads the fullscreen mode setting from the [Display] section.
@@ -321,6 +443,8 @@ def save_fullscreen_config(fullscreen):
cp["Display"]["fullscreen"] = str(fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_window_geometry() -> tuple[int, int]:
"""Reads the window width and height from the [MainWindow] section.
@@ -342,6 +466,8 @@ def save_window_geometry(width: int, height: int):
cp["MainWindow"]["height"] = str(height)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def reset_config():
"""Resets the configuration file by deleting it.
@@ -351,6 +477,8 @@ def reset_config():
try:
os.remove(CONFIG_FILE)
logger.info("Configuration file %s deleted", CONFIG_FILE)
# Invalidate cache after deletion
invalidate_config_cache()
except Exception as e:
logger.warning(f"Failed to delete configuration file: {e}")
@@ -365,6 +493,9 @@ def clear_cache():
except Exception as e:
logger.warning(f"Failed to delete cache: {e}")
# Also clear our internal config cache
invalidate_config_cache()
def read_auto_fullscreen_gamepad():
"""Reads the auto-fullscreen setting for gamepad from the [Display] section.
Returns False if the parameter is missing.
@@ -383,6 +514,8 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_favorite_folders():
"""Reads the list of favorite folders from the [FavoritesFolders] section.
@@ -408,3 +541,26 @@ def save_favorite_folders(folders):
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_minimize_to_tray():
"""Reads the minimize-to-tray setting from the [Display] section.
Returns True if the parameter is missing (default: minimize to tray).
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
save_minimize_to_tray(True)
return True
return cp.getboolean("Display", "minimize_to_tray", fallback=True)
def save_minimize_to_tray(minimize_to_tray):
"""Saves the minimize-to-tray setting to the [Display] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Display" not in cp:
cp["Display"] = {}
cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()

View File

@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
from portprotonqt.localization import _
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
@@ -406,16 +406,7 @@ class ContextMenuManager:
)
return
# Construct EGS launch command
wrapper = "flatpak run ru.linux_gaming.PortProton"
start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
if self.portproton_location and ".var" not in self.portproton_location:
wrapper = start_sh_path
if not os.path.exists(start_sh_path):
self.signals.show_warning_dialog.emit(
_("Error"),
_("start.sh not found at {path}").format(path=start_sh_path)
)
return
wrapper = get_portproton_start_command()
exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
else:
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)

View File

@@ -2,12 +2,11 @@ import os
import tempfile
import re
from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon, QTextCursor
from PySide6.QtGui import QPixmap, QIcon, QTextCursor, QColor
from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller,
QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget
QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget, QComboBox, QLineEdit
)
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
from icoextract import IconExtractor, IconExtractorError
from PIL import Image
@@ -19,6 +18,7 @@ from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.preloader import Preloader
from portprotonqt.settings_manager import get_toggle_settings, get_advanced_settings, ADVANCED_SETTING_KEYS
import psutil
if TYPE_CHECKING:
@@ -91,6 +91,139 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
logger.error(f"Ошибка при сохранении миниатюры: {e}")
return False
def create_dialog_hints_widget(theme, main_window, input_manager, context='default'):
"""
Common function to create hints widget for all dialogs.
Uses main_window for get_button_icon/get_nav_icon, input_manager for gamepad detection.
"""
theme_manager = ThemeManager()
current_theme_name = read_theme_from_config()
hintsWidget = QWidget()
hintsWidget.setStyleSheet(theme.STATUS_BAR_STYLE)
hintsLayout = QHBoxLayout(hintsWidget)
hintsLayout.setContentsMargins(10, 0, 10, 0)
hintsLayout.setSpacing(20)
dialog_actions = []
# Context-specific actions (gamepad only, no keyboard)
if context == 'file_explorer':
dialog_actions = [
("confirm", _("Open")), # A / Cross
("add_game", _("Select Dir")), # X / Triangle
("prev_dir", _("Prev Dir")), # Y / Square
("back", _("Cancel")), # B / Circle
("context_menu", _("Menu")), # Start / Options
]
elif context == 'winetricks':
dialog_actions = [
("confirm", _("Toggle")), # A / Cross
("add_game", _("Install")), # X / Triangle
("prev_dir", _("Force Install")), # Y / Square
("back", _("Cancel")), # B / Circle
("prev_tab", _("Prev Tab")), # LB / L1
("next_tab", _("Next Tab")), # RB / R1
]
elif context == 'settings':
dialog_actions = [
("confirm", _("Toggle")), # A / Cross
("add_game", _("Save")), # X / Triangle
("prev_dir", _("Search")), # Y / Square
("back", _("Cancel")), # B / Circle
("prev_tab", _("Prev Tab")), # LB / L1
("next_tab", _("Next Tab")), # RB / R1
]
hints_labels = [] # Store for updates (returned for class storage)
def make_hint(icon_name, text, action=None):
container = QWidget()
hlayout = QHBoxLayout(container)
hlayout.setContentsMargins(0, 5, 0, 0)
hlayout.setSpacing(6)
icon_label = QLabel()
icon_label.setFixedSize(26, 26)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
hlayout.addWidget(icon_label)
text_label = QLabel(text)
text_label.setStyleSheet(theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
hlayout.addWidget(text_label)
# Initially hidden; show only if gamepad connected
container.setVisible(False)
hints_labels.append((container, icon_label, action))
hintsLayout.addWidget(container)
# Add gamepad hints only
for action, text in dialog_actions:
make_hint("placeholder", text, action)
hintsLayout.addStretch()
# Return widget and labels for class storage
return hintsWidget, hints_labels
def update_dialog_hints(hints_labels, main_window, input_manager, theme_manager, current_theme_name):
"""
Common function to update hints for any dialog.
"""
if not input_manager or not main_window:
# Hide all if no input_manager or main_window
for container, _, _ in hints_labels:
container.setVisible(False)
return
is_gamepad = input_manager.gamepad is not None
if not is_gamepad:
# Hide all hints if no gamepad
for container, _, _ in hints_labels:
container.setVisible(False)
return
gtype = input_manager.gamepad_type
gamepad_actions = ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir', 'prev_tab', 'next_tab']
for container, icon_label, action in hints_labels:
if action and action in gamepad_actions:
container.setVisible(True)
# Update icon using main_window methods
if action in ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir']:
icon_name = main_window.get_button_icon(action, gtype)
else: # only prev_tab/next_tab (treat as nav)
direction = 'left' if action == 'prev_tab' else 'right'
icon_name = main_window.get_nav_icon(direction, gtype)
icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# Fallback to placeholder
placeholder = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу
@@ -185,6 +318,7 @@ class FileExplorer(QDialog):
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.main_window = None # Add reference to MainWindow
self.setup_ui()
# Window settings
@@ -198,6 +332,7 @@ class FileExplorer(QDialog):
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
if hasattr(parent, 'context_menu_manager'):
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
parent = parent.parent()
@@ -214,6 +349,17 @@ class FileExplorer(QDialog):
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
self.update_file_list()
# Create hints widget using common function
self.current_theme_name = read_theme_from_config()
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='file_explorer')
self.main_layout.addWidget(self.hints_widget)
# Connect signals
if self.input_manager:
self.input_manager.button_event.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
self.input_manager.dpad_moved.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
class ThumbnailLoader(QRunnable):
"""Class for asynchronous thumbnail loading in a separate thread."""
class Signals(QObject):
@@ -897,8 +1043,8 @@ class AddGameDialog(QDialog):
"""Обработчик выбора файла в FileExplorer"""
self.exeEdit.setText(file_path)
self.last_exe_path = file_path # Update last selected exe path
if not self.edit_mode:
# Автоматически заполняем имя игры, если не в режиме редактирования
if not self.edit_mode and not self.nameEdit.text().strip():
# Автоматически заполняем имя игры, если не в режиме редактирования или если оно не введено вручную
game_name = os.path.splitext(os.path.basename(file_path))[0]
self.nameEdit.setText(game_name)
@@ -1037,8 +1183,6 @@ Icon={icon_path}
return desktop_entry, desktop_path
class WinetricksDialog(QDialog):
"""Dialog for managing Winetricks components in a prefix."""
def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None):
super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
@@ -1071,6 +1215,36 @@ class WinetricksDialog(QDialog):
self.setup_ui()
self.load_lists()
# Find input_manager and main_window
self.input_manager = None
self.main_window = None
parent = self.parent()
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
parent = parent.parent()
self.current_theme_name = read_theme_from_config()
# Enable Winetricks-specific mode
if self.input_manager:
self.input_manager.enable_winetricks_mode(self)
# Create hints widget using common function
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='winetricks')
self.main_layout.addWidget(self.hints_widget)
# Connect signals (use self.theme_manager)
if self.input_manager:
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def update_winetricks(self):
"""Update the winetricks script."""
if not self.downloader.has_internet():
@@ -1143,15 +1317,15 @@ class WinetricksDialog(QDialog):
def setup_ui(self):
"""Set up the user interface with tabs and tables."""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(10, 10, 10, 10)
self.main_layout.setSpacing(10)
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
main_layout.addWidget(self.log_output)
self.main_layout.addWidget(self.log_output)
# Tab widget
self.tab_widget = QTabWidget()
@@ -1161,7 +1335,9 @@ class WinetricksDialog(QDialog):
# DLLs tab
self.dll_table = QTableWidget()
self.dll_table.setAlternatingRowColors(True)
self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.dll_table.setColumnCount(3)
@@ -1192,7 +1368,9 @@ class WinetricksDialog(QDialog):
# Fonts tab
self.fonts_table = QTableWidget()
self.fonts_table.setAlternatingRowColors(True)
self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.fonts_table.setColumnCount(3)
@@ -1223,7 +1401,9 @@ class WinetricksDialog(QDialog):
# Settings tab
self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True)
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.settings_table.setColumnCount(3)
@@ -1258,7 +1438,7 @@ class WinetricksDialog(QDialog):
"settings": self.settings_container
}
main_layout.addWidget(self.tab_widget)
self.main_layout.addWidget(self.tab_widget)
# Buttons
button_layout = QHBoxLayout()
@@ -1272,7 +1452,7 @@ class WinetricksDialog(QDialog):
button_layout.addWidget(self.cancel_button)
button_layout.addWidget(self.force_button)
button_layout.addWidget(self.install_button)
main_layout.addLayout(button_layout)
self.main_layout.addLayout(button_layout)
self.cancel_button.clicked.connect(self.reject)
self.force_button.clicked.connect(lambda: self.install_selected(force=True))
@@ -1497,3 +1677,569 @@ class WinetricksDialog(QDialog):
"""Добавляет в лог."""
self.log_output.append(message)
self.log_output.moveCursor(QTextCursor.MoveOperation.End)
def closeEvent(self, event):
"""Disable mode on close."""
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().closeEvent(event)
def reject(self):
"""Disable mode on reject."""
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().reject()
class ExeSettingsDialog(QDialog):
def __init__(self, parent=None, theme=None, exe_path=None):
super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.exe_path = exe_path
if not self.exe_path:
return
self.portproton_path = get_portproton_location()
if self.portproton_path is None:
logger.error("PortProton location not found")
return
base_path = os.path.join(self.portproton_path, "data")
self.start_sh = [os.path.join(base_path, "scripts", "start.sh")]
self.dist_options = []
self.prefix_options = []
if self.portproton_path:
dist_dir = os.path.join(self.portproton_path, 'dist')
if os.path.exists(dist_dir):
self.dist_options = [f for f in os.listdir(dist_dir) if os.path.isdir(os.path.join(dist_dir, f))]
prefixes_dir = os.path.join(self.portproton_path, 'prefixes')
if os.path.exists(prefixes_dir):
self.prefix_options = [f for f in os.listdir(prefixes_dir) if os.path.isdir(os.path.join(prefixes_dir, f))]
self.current_settings = {}
self.value_widgets = {}
self.original_values = {}
self.advanced_widgets = {}
self.original_display_values = {}
self.available_keys = set()
self.blocked_keys = set()
self.numa_nodes = {}
self.is_amd = False
self.locale_options = []
self.logical_core_options = []
self.amd_vulkan_drivers = []
self.setWindowTitle(_("Exe Settings"))
self.setModal(True)
self.resize(1100, 720)
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
# Set focus policy to handle focus changes properly
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# Load toggle settings from config module
self.toggle_settings = get_toggle_settings()
self.setup_ui()
# Find input_manager and main_window
self.input_manager = None
self.main_window = None
parent = self.parent()
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
parent = parent.parent()
self.current_theme_name = read_theme_from_config()
# Enable settings dialog-specific mode
if self.input_manager:
self.input_manager.enable_settings_mode(self)
# Create hints widget using common function
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='settings')
self.main_layout.addWidget(self.hints_widget)
# Connect signals
if self.input_manager:
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
# Initialize virtual keyboard
self.init_virtual_keyboard()
# Load current settings (includes list-db)
self.load_current_settings()
def _get_process_args(self, subcommand_args):
"""Get the full arguments for QProcess.start, handling flatpak separator."""
if self.start_sh[0] == "flatpak":
return self.start_sh[1:] + ["--"] + subcommand_args
else:
return self.start_sh + subcommand_args
def setup_ui(self):
"""Set up the user interface."""
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(10, 10, 10, 10)
self.main_layout.setSpacing(10)
# Search bar
search_layout = QHBoxLayout()
self.search_label = QLabel(_("Search:"))
self.search_label.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.search_edit = QLineEdit()
self.search_edit.setStyleSheet(self.theme.ADDGAME_INPUT_STYLE)
self.search_edit.setPlaceholderText(_("Search settings..."))
self.search_edit.textChanged.connect(self.filter_settings)
self.search_edit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
search_layout.addWidget(self.search_label)
search_layout.addWidget(self.search_edit)
self.main_layout.addLayout(search_layout)
# Tab widget
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(self.theme.WINETRICKS_TAB_STYLE)
self.main_tab = QWidget()
self.main_tab_layout = QVBoxLayout(self.main_tab)
self.advanced_tab = QWidget()
self.advanced_tab_layout = QVBoxLayout(self.advanced_tab)
self.tab_widget.addTab(self.main_tab, _("Main"))
self.tab_widget.addTab(self.advanced_tab, _("Advanced"))
# Main settings table
self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True)
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.settings_table.setColumnCount(3)
self.settings_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.settings_table.horizontalHeader().resizeSection(1, 100)
self.settings_table.setWordWrap(True)
self.settings_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.settings_table.setTextElideMode(Qt.TextElideMode.ElideNone)
self.settings_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
self.main_tab_layout.addWidget(self.settings_table)
# Advanced settings table
self.advanced_table = QTableWidget()
self.advanced_table.setAlternatingRowColors(True)
self.advanced_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.advanced_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
# self.advanced_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.advanced_table.setColumnCount(3)
self.advanced_table.setHorizontalHeaderLabels([_("Setting"), _("Value"), _("Description")])
self.advanced_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
self.advanced_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.advanced_table.horizontalHeader().resizeSection(1, 200)
self.advanced_table.setWordWrap(True)
self.advanced_table.verticalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
self.advanced_table.setTextElideMode(Qt.TextElideMode.ElideNone)
self.advanced_table.setStyleSheet(self.theme.WINETRICKS_TABBLE_STYLE)
self.advanced_tab_layout.addWidget(self.advanced_table)
self.main_layout.addWidget(self.tab_widget)
# Buttons
button_layout = QHBoxLayout()
self.apply_button = AutoSizeButton(_("Apply"), icon=ThemeManager().get_icon("apply"))
self.cancel_button = AutoSizeButton(_("Cancel"), icon=ThemeManager().get_icon("cancel"))
self.apply_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button_layout.addWidget(self.apply_button)
button_layout.addWidget(self.cancel_button)
self.main_layout.addLayout(button_layout)
self.apply_button.clicked.connect(self.apply_changes)
self.cancel_button.clicked.connect(self.reject)
def load_current_settings(self):
"""Load available toggles first, then current settings."""
process = QProcess(self)
process.finished.connect(self.on_list_db_finished)
process.start(self.start_sh[0], ["cli", "--list-db"])
def on_list_db_finished(self, exit_code, exit_status):
"""Handle --list-db output and extract available keys and system info."""
process = cast(QProcess, self.sender())
self.available_keys = set()
self.blocked_keys = set()
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
lines = output.splitlines()
self.numa_nodes = {}
self.is_amd = False
self.logical_core_options = []
self.locale_options = []
self.amd_vulkan_drivers = []
for line in lines:
line_stripped = line.strip()
if not line_stripped:
continue
if re.match(r'^[A-Z_0-9]+=[^=]+$', line_stripped) and not line_stripped.startswith('PW_'):
# System info
k, v = line_stripped.split('=', 1)
if k.startswith('NUMA_NODE_'):
node_id = k[10:]
self.numa_nodes[node_id] = v
elif k == 'IS_AMD':
self.is_amd = v.lower() == 'true'
elif k == 'LOGICAL_CORE_OPTIONS':
self.logical_core_options = v.split('!') if v else []
elif k == 'LOCALE_LIST':
self.locale_options = v.split('!') if v else []
elif k == 'AMD_VULKAN_DRIVER_LIST':
self.amd_vulkan_drivers = v.split('!') if v else []
continue
if line_stripped.startswith('PW_'):
parts = line_stripped.split(maxsplit=1)
key = parts[0]
self.available_keys.add(key)
if len(parts) > 1 and 'blocked' in parts[1]:
self.blocked_keys.add(key)
# Show only intersection
self.available_keys &= set(self.toggle_settings.keys())
# Load current settings
process = QProcess(self)
process.finished.connect(self.on_show_ppdb_finished)
process.start(self.start_sh[0], ["cli", "--show-ppdb", f"{self.exe_path}.ppdb"])
def on_show_ppdb_finished(self, exit_code, exit_status):
"""Handle --show-ppdb output."""
process = cast(QProcess, self.sender())
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
# Fallback to defaults if load fails
for key in self.toggle_settings:
self.current_settings[key] = '0'
for adv_key in ADVANCED_SETTING_KEYS:
self.current_settings[adv_key] = 'disabled' if 'TOPOLOGY' in adv_key or 'SELECT' in adv_key or 'MODE' in adv_key or 'LEVEL' in adv_key or 'GL_VERSION' in adv_key or 'NUMA' in adv_key else ''
else:
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore').strip()
self.current_settings = {}
for line in output.split('\n'):
line_stripped = line.strip()
if '=' in line_stripped:
try:
key, val = line_stripped.split('=', 1)
if key in self.toggle_settings or key in ADVANCED_SETTING_KEYS:
self.current_settings[key] = val
except ValueError:
continue
# Force blocked settings to '0'
for key in self.blocked_keys:
self.current_settings[key] = '0'
self.original_values = self.current_settings.copy()
for key in set(self.toggle_settings.keys()):
self.original_values.setdefault(key, '0')
self.populate_table()
self.populate_advanced()
def populate_table(self):
"""Populate the table with settings that are available in both lists."""
self.settings_table.setRowCount(0)
self.value_widgets.clear()
self.settings_table.verticalHeader().setVisible(False)
visible_keys = sorted(self.available_keys) if self.available_keys else sorted(self.toggle_settings.keys())
for toggle in visible_keys:
description = self.toggle_settings.get(toggle)
if not description:
continue
row = self.settings_table.rowCount()
self.settings_table.insertRow(row)
name_item = QTableWidgetItem(toggle)
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
current_val = self.current_settings.get(toggle, '0')
is_blocked = toggle in self.blocked_keys
checkbox = QTableWidgetItem()
checkbox.setFlags(checkbox.flags() | Qt.ItemFlag.ItemIsUserCheckable)
check_state = Qt.CheckState.Checked if current_val == '1' and not is_blocked else Qt.CheckState.Unchecked
checkbox.setCheckState(check_state)
checkbox.setTextAlignment(Qt.AlignmentFlag.AlignCenter)
if is_blocked:
checkbox.setFlags(checkbox.flags() & ~Qt.ItemFlag.ItemIsUserCheckable)
checkbox.setBackground(QColor(240, 240, 240))
name_item.setForeground(QColor(128, 128, 128))
self.settings_table.setItem(row, 1, checkbox)
desc_item = QTableWidgetItem(description)
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
desc_item.setToolTip(description)
desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
if is_blocked:
desc_item.setForeground(QColor(128, 128, 128))
self.settings_table.setItem(row, 2, desc_item)
self.settings_table.setItem(row, 0, name_item)
self.value_widgets[(row, 1)] = checkbox
self.settings_table.resizeRowsToContents()
if self.settings_table.rowCount() > 0:
self.settings_table.setCurrentCell(0, 0)
self.settings_table.setFocus(Qt.FocusReason.OtherFocusReason)
def populate_advanced(self):
"""Populate the advanced tab with table format."""
self.advanced_table.setRowCount(0)
self.advanced_widgets.clear()
self.original_display_values = {}
self.advanced_table.verticalHeader().setVisible(False)
current = self.current_settings
disabled_text = _('disabled')
# Get advanced settings from config module
advanced_settings = get_advanced_settings(
disabled_text=disabled_text,
logical_core_options=self.logical_core_options,
locale_options=self.locale_options,
amd_vulkan_drivers=self.amd_vulkan_drivers,
is_amd=self.is_amd,
numa_nodes=self.numa_nodes,
dist_options=self.dist_options,
prefix_options=self.prefix_options
)
# Populate table
for setting in advanced_settings:
row = self.advanced_table.rowCount()
self.advanced_table.insertRow(row)
# Name column
name_item = QTableWidgetItem(setting['name'])
name_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
self.advanced_table.setItem(row, 0, name_item)
# Value column (widget)
if setting['type'] == 'combo':
combo = QComboBox()
combo.addItems(setting['options'])
# Get current value
current_raw = current.get(setting['key'], setting['default'])
if setting['key'] == 'PW_WINE_CPU_TOPOLOGY':
current_val = disabled_text if current_raw == 'disabled' else (current_raw.split(':')[0] if isinstance(current_raw, str) and ':' in current_raw else current_raw)
elif setting['key'] == 'PW_AMD_VULKAN_USE':
current_val = disabled_text if not current_raw or current_raw == '' else current_raw
else:
current_val = disabled_text if current_raw == 'disabled' else current_raw
if current_val not in setting['options']:
combo.addItem(current_val)
combo.setCurrentText(current_val)
# Block if only disabled option
if len(setting['options']) == 1:
combo.setEnabled(False)
# Set focus policy for combo box
combo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.advanced_table.setCellWidget(row, 1, combo)
self.advanced_widgets[setting['key']] = combo
self.original_display_values[setting['key']] = current_val
elif setting['type'] == 'text':
line_edit = QLineEdit()
current_val = current.get(setting['key'], setting['default'])
line_edit.setText(current_val)
self.advanced_table.setCellWidget(row, 1, line_edit)
self.advanced_widgets[setting['key']] = line_edit
self.original_display_values[setting['key']] = current_val
# Description column
desc_item = QTableWidgetItem(setting['description'])
desc_item.setFlags(Qt.ItemFlag.ItemIsSelectable | Qt.ItemFlag.ItemIsEnabled)
desc_item.setToolTip(setting['description'])
desc_item.setTextAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
self.advanced_table.setItem(row, 2, desc_item)
def init_virtual_keyboard(self):
"""Initialize virtual keyboard"""
self.keyboard = VirtualKeyboard(self, theme=self.theme, button_width=40)
self.keyboard.hide()
self.keyboard.current_input_widget = None
def show_virtual_keyboard(self, widget=None):
"""Show virtual keyboard for search or text input"""
if not widget:
# Default to search edit
widget = self.search_edit
if not widget or not widget.isVisible():
return
# Set the current input widget
self.keyboard.current_input_widget = widget
# Position the keyboard
keyboard_height = 220
self.keyboard.setFixedWidth(self.width())
self.keyboard.setFixedHeight(keyboard_height)
self.keyboard.move(0, self.height() - keyboard_height)
# Show and raise keyboard
self.keyboard.setParent(self)
self.keyboard.show()
self.keyboard.raise_()
# Focus on first button of keyboard
first_button = self.keyboard.findFirstFocusableButton()
if first_button:
# First hide the current focus to prevent conflicts
focused_widget = QApplication.focusWidget()
if focused_widget and focused_widget != self.keyboard:
focused_widget.clearFocus()
# Then focus the keyboard button
QTimer.singleShot(50, lambda: first_button.setFocus())
def filter_settings(self, text):
"""Filter settings based on search text."""
# Filter main settings table
search_text = text.lower()
for row in range(self.settings_table.rowCount()):
name_item = self.settings_table.item(row, 0) # Setting name
desc_item = self.settings_table.item(row, 2) # Description
should_show = False
if name_item and search_text in name_item.text().lower():
should_show = True
elif desc_item and search_text in desc_item.text().lower():
should_show = True
self.settings_table.setRowHidden(row, not should_show)
# Filter advanced settings table
for row in range(self.advanced_table.rowCount()):
name_item = self.advanced_table.item(row, 0) # Setting name
desc_item = self.advanced_table.item(row, 2) # Description
should_show = False
if name_item and search_text in name_item.text().lower():
should_show = True
elif desc_item and search_text in desc_item.text().lower():
should_show = True
self.advanced_table.setRowHidden(row, not should_show)
def apply_changes(self):
"""Apply changes by collecting diffs from both main and advanced tabs."""
changes = []
for key, orig_val in self.original_values.items():
if key in self.blocked_keys:
continue # Skip blocked keys
row = -1
for r in range(self.settings_table.rowCount()):
item0 = self.settings_table.item(r, 0)
if item0 and item0.text() == key:
row = r
break
if row == -1:
continue
item = self.settings_table.item(row, 1)
if not item:
continue
new_val = '1' if item.checkState() == Qt.CheckState.Checked else '0'
if new_val != orig_val:
changes.append(f"{key}={new_val}")
for key, widget in self.advanced_widgets.items():
orig_val = self.original_display_values.get(key, '')
if isinstance(widget, QComboBox):
new_val = widget.currentText()
if new_val.lower() == _('disabled').lower():
new_val = 'disabled'
elif isinstance(widget, QLineEdit):
new_val = widget.text().strip()
else:
continue
if new_val != orig_val:
changes.append(f"{key}={new_val}")
if not changes:
QMessageBox.information(self, _("Info"), _("No changes to apply."))
return
process = QProcess(self)
process.finished.connect(self.on_edit_db_finished)
args = ["cli", "--edit-db", self.exe_path] + changes
process.start(self.start_sh[0], args)
self.apply_button.setEnabled(False)
def on_edit_db_finished(self, exit_code, exit_status):
"""Handle --edit-db output."""
process = cast(QProcess, self.sender())
self.apply_button.setEnabled(True)
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
QMessageBox.warning(self, _("Error"), _("Failed to apply changes. Check logs."))
logger.error(f"Failed to apply changes: {error_output}")
else:
self.load_current_settings()
QMessageBox.information(self, _("Success"), _("Settings updated successfully."))
def keyPressEvent(self, event):
"""Override key press event to handle combo box interaction properly."""
# If a combo box in the advanced table is active and has an open dropdown,
# we need to handle Escape key specially to prevent dialog closure
focused_widget = QApplication.focusWidget()
if (event.key() == Qt.Key.Key_Escape and
isinstance(focused_widget, QComboBox) and
focused_widget.view().isVisible()):
# If a combo box dropdown is open, just close the dropdown instead of the dialog
focused_widget.hidePopup()
self.advanced_table.setFocus()
return
super().keyPressEvent(event)
def closeEvent(self, event):
# Hide virtual keyboard if visible
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
if self.input_manager:
self.input_manager.disable_settings_mode()
super().closeEvent(event)
def reject(self):
# Hide virtual keyboard if visible
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
if self.input_manager:
self.input_manager.disable_settings_mode()
super().reject()
def accept(self):
# Hide virtual keyboard if visible
if hasattr(self, 'keyboard') and self.keyboard.isVisible():
self.keyboard.hide()
if self.input_manager:
self.input_manager.disable_settings_mode()
super().accept()

View File

@@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _
from portprotonqt.logger import get_logger
from portprotonqt.image_utils import load_pixmap_async
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
from portprotonqt.config_utils import get_portproton_location
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
from portprotonqt.steam_api import (
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
@@ -254,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
return
# Determine wrapper
wrapper = "flatpak run ru.linux_gaming.PortProton"
start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
if portproton_dir is not None and ".var" not in portproton_dir:
wrapper = start_sh_path
if not os.path.exists(start_sh_path):
logger.error(f"start.sh not found at {start_sh_path}")
callback((False, f"start.sh not found at {start_sh_path}"))
return
wrapper = get_portproton_start_command()
# Create launch script
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")

View File

@@ -1,5 +1,5 @@
from PySide6.QtGui import QPainter, QColor, QDesktopServices
from PySide6.QtCore import Signal, Property, Qt, QUrl
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable
from portprotonqt.image_utils import load_pixmap_async, round_corners
@@ -102,7 +102,7 @@ class GameCard(QFrame):
self.favoriteLabel = ClickableLabel(self.coverWidget)
self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in read_favorites()
self.is_favorite = self.name in set(read_favorites())
self.update_favorite_icon()
self.favoriteLabel.raise_()
@@ -203,7 +203,7 @@ class GameCard(QFrame):
self.update_cover_pixmap()
def update_cover_pixmap(self):
if self.base_pixmap:
if self.base_pixmap and not self.base_pixmap.isNull():
scaled_width = int(self.base_card_width * self._scale)
scaled_pixmap = self.base_pixmap.scaled(scaled_width, int(scaled_width * 1.5), Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
@@ -404,14 +404,22 @@ class GameCard(QFrame):
self.favoriteLabel.setText("")
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
parent = self.parent()
while parent:
if hasattr(parent, 'game_library_manager'):
QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
break
parent = parent.parent()
def toggle_favorite(self):
favorites = read_favorites()
favorites_set = set(favorites)
if self.is_favorite:
if self.name in favorites:
if self.name in favorites_set:
favorites.remove(self.name)
self.is_favorite = False
else:
if self.name not in favorites:
if self.name not in favorites_set:
favorites.append(self.name)
self.is_favorite = True
save_favorites(favorites)

View File

@@ -33,6 +33,7 @@ class MainWindowProtocol(Protocol):
# Required attributes
searchEdit: CustomLineEdit
_last_card_width: int
card_width: int
current_hovered_card: GameCard | None
current_focused_card: GameCard | None
gamesListWidget: QWidget | None
@@ -128,6 +129,8 @@ class GameLibraryManager:
self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width)
self.main_window.card_width = self.card_width
self.main_window._last_card_width = self.card_width
for card in self.game_card_cache.values():
card.update_card_size(self.card_width)
self.update_game_grid()
@@ -217,6 +220,16 @@ class GameLibraryManager:
else:
self._update_game_grid_immediate()
def force_update_cards_library(self):
if self.gamesListWidget and self.gamesListLayout:
self.gamesListLayout.invalidate()
self.gamesListWidget.updateGeometry()
widget = self.gamesListWidget
QTimer.singleShot(0, lambda: (
widget.adjustSize(),
widget.updateGeometry()
))
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
if self.gamesListLayout is None or self.gamesListWidget is None:
@@ -254,8 +267,9 @@ class GameLibraryManager:
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]
favorites_set = set(favorites) # Convert to set for O(1) lookup
fav_games = [g for g in games_list if g[0] in favorites_set]
non_fav_games = [g for g in games_list if g[0] not in favorites_set]
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
@@ -346,6 +360,8 @@ class GameLibraryManager:
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
self.force_update_cards_library()
self.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str):
@@ -453,11 +469,3 @@ class GameLibraryManager:
def filter_games_delayed(self):
"""Filters games based on search text and updates the grid."""
self.update_game_grid(is_filter=True)
def calculate_columns(self, card_width: int) -> int:
"""Calculate the number of columns based on card width and assumed container width."""
# Assuming a typical container width; adjust as needed
available_width = 1200 # Example width, can be dynamic if widget access is added
spacing = 15 # Assumed spacing between cards
columns = max(1, (available_width - spacing) // (card_width + spacing))
return min(columns, 8) # Cap at reasonable max

View File

@@ -36,11 +36,22 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
current_theme_name = read_theme_from_config()
def finish_with(pixmap: QPixmap):
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
x = (scaled.width() - width) // 2
y = (scaled.height() - height) // 2
cropped = scaled.copy(x, y, width, height)
callback(cropped)
# Check if pixmap is valid before attempting to scale it
if pixmap.isNull():
# Create a default placeholder pixmap instead of trying to scale a null pixmap
placeholder_pixmap = QPixmap(width, height)
placeholder_pixmap.fill(QColor("#333333"))
painter = QPainter(placeholder_pixmap)
painter.setPen(QPen(QColor("white")))
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
callback(placeholder_pixmap)
else:
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation)
x = (scaled.width() - width) // 2
y = (scaled.height() - height) // 2
cropped = scaled.copy(x, y, width, height)
callback(cropped)
xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache"))
image_folder = os.path.join(xdg_cache_home, "PortProtonQt", "images")
@@ -58,6 +69,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
local_path = os.path.join(image_folder, f"{appid}.jpg")
if os.path.exists(local_path):
pixmap = QPixmap(local_path)
# Check if the pixmap loaded successfully
if pixmap.isNull():
logger.warning(f"Failed to load image from {local_path}")
finish_with(pixmap)
return
@@ -69,6 +83,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
@@ -83,11 +99,19 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
except Exception as e:
logger.error(f"Ошибка обработки URL {cover}: {e}")
if cover and cover.startswith(("http://", "https://")):
# SteamGridDB (SGDB)
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
try:
local_path = os.path.join(image_folder, f"{app_name}.jpg")
parts = cover.split("/")
filename = parts[-1] if parts else "sgdb_cover.png"
# SGDB ссылки содержат уникальный хеш в названии — используем как имя
local_path = os.path.join(image_folder, filename)
if os.path.exists(local_path):
pixmap = QPixmap(local_path)
# Check if the pixmap loaded successfully
if pixmap.isNull():
logger.warning(f"Failed to load image from {local_path}")
finish_with(pixmap)
return
@@ -99,6 +123,45 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
painter = QPainter(pixmap)
painter.setPen(QPen(QColor("white")))
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
finish_with(pixmap)
logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
return
except Exception as e:
logger.error(f"Ошибка обработки SGDB URL {cover}: {e}")
if cover and cover.startswith(("http://", "https://")):
try:
local_path = os.path.join(image_folder, f"{app_name}.jpg")
if os.path.exists(local_path):
pixmap = QPixmap(local_path)
# Check if the pixmap loaded successfully
if pixmap.isNull():
logger.warning(f"Failed to load image from {local_path}")
finish_with(pixmap)
return
def on_downloaded(result: str | None):
pixmap = QPixmap()
if result and os.path.exists(result):
pixmap.load(result)
if pixmap.isNull():
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
@@ -115,6 +178,9 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
if cover and QFile.exists(cover):
pixmap = QPixmap(cover)
# Check if the pixmap loaded successfully
if pixmap.isNull():
logger.warning(f"Failed to load image from {cover}")
finish_with(pixmap)
return
@@ -122,6 +188,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
pixmap = QPixmap()
if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else:
pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333"))
@@ -141,7 +209,15 @@ def round_corners(pixmap, radius):
"""
if pixmap.isNull():
return pixmap
# Check if radius is valid to prevent issues
if radius <= 0:
return pixmap
size = pixmap.size()
if size.width() <= 0 or size.height() <= 0:
return pixmap
rounded = QPixmap(size)
rounded.fill(QColor(0, 0, 0, 0))
painter = QPainter(rounded)
@@ -244,20 +320,31 @@ class FullscreenDialog(QDialog):
QApplication.processEvents()
pixmap, caption = self.images[self.current_index]
# Учитываем devicePixelRatio для масштабирования высокого качества
device_pixel_ratio = get_device_pixel_ratio()
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
target_height = int(self.FIXED_HEIGHT * device_pixel_ratio)
# Check if pixmap is valid before attempting to scale it
if pixmap.isNull():
# Create a default placeholder pixmap instead of trying to scale a null pixmap
placeholder_pixmap = QPixmap(self.FIXED_WIDTH - 80, self.FIXED_HEIGHT)
placeholder_pixmap.fill(QColor("#333333"))
painter = QPainter(placeholder_pixmap)
painter.setPen(QPen(QColor("white")))
painter.drawText(placeholder_pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
painter.end()
self.imageLabel.setPixmap(placeholder_pixmap)
else:
# Учитываем devicePixelRatio для масштабирования высокого качества
device_pixel_ratio = get_device_pixel_ratio()
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio)
target_height = int(self.FIXED_HEIGHT * device_pixel_ratio)
# Масштабируем изображение из оригинального pixmap
scaled_pixmap = pixmap.scaled(
target_width,
target_height,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
self.imageLabel.setPixmap(scaled_pixmap)
# Масштабируем изображение из оригинального pixmap
scaled_pixmap = pixmap.scaled(
target_width,
target_height,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
)
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
self.imageLabel.setPixmap(scaled_pixmap)
self.captionLabel.setText(caption)
self.setWindowTitle(caption)

File diff suppressed because it is too large Load Diff

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-10-12 17:14+0500\n"
"POT-Creation-Date: 2025-11-11 17:00+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -76,10 +76,6 @@ msgstr ""
msgid "Legendary executable not found at {path}"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success"
msgstr ""
@@ -124,6 +120,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
@@ -252,13 +252,37 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer"
msgstr ""
@@ -326,12 +350,6 @@ msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -347,6 +365,39 @@ msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Main"
msgstr ""
msgid "Advanced"
msgstr ""
msgid "Setting"
msgstr ""
msgid "Value"
msgstr ""
msgid "Description"
msgstr ""
msgid "disabled"
msgstr ""
msgid "Info"
msgstr ""
msgid "No changes to apply."
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Settings updated successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -494,14 +545,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgid "Clearing prefix..."
msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgid "Prefix clear failed with exit code {}."
msgstr ""
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr ""
msgid "Failed to start backup process."
@@ -579,6 +637,9 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -603,6 +664,12 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""
@@ -677,6 +744,10 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -735,6 +806,232 @@ msgstr ""
msgid "File not found: {0}"
msgstr ""
msgid ""
"Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)"
msgstr ""
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr ""
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
msgstr ""
msgid ""
"Super + F : Toggle fullscreen\n"
"Super + N : Toggle nearest neighbour filtering\n"
"Super + U : Toggle FSR upscaling\n"
"Super + Y : Toggle NIS upscaling\n"
"Super + I : Increase FSR sharpness by 1\n"
"Super + O : Decrease FSR sharpness by 1\n"
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
"Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard"
msgstr ""
msgid "Enable in-process synchronization primitives based on eventfd."
msgstr ""
msgid "Enable futex-based in-process synchronization primitives."
msgstr ""
msgid "Enable in-process synchronization via the Linux ntsync driver."
msgstr ""
msgid "Enable vkd3d support - Ray Tracing"
msgstr ""
msgid "Enable DLSS on supported NVIDIA graphics cards"
msgstr ""
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr ""
msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr ""
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr ""
msgid "Disguise all NVIDIA GPU features"
msgstr ""
msgid "Run the application in WINE virtual desktop"
msgstr ""
msgid "Run the application in a terminal"
msgstr ""
msgid "Disable startup mode and WINE version selector window"
msgstr ""
msgid "Use system GameMode for performance optimization"
msgstr ""
msgid "Enable forced use of third-party DirectX libraries"
msgstr ""
msgid "Fix pink-tinted video playback in some games"
msgstr ""
msgid "Reduce PulseAudio latency to fix intermittent sound"
msgstr ""
msgid "Force US keyboard layout"
msgstr ""
msgid "Use GStreamer for in-game clips (WMF support)"
msgstr ""
msgid "Use WINE shader caching"
msgstr ""
msgid "Force use of built-in DXGI library"
msgstr ""
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr ""
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr ""
msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr ""
msgid "Disable desktop compositing for performance"
msgstr ""
msgid "Use container launch mode (recommended default)"
msgstr ""
msgid "Force DirectInput protocol instead of XInput"
msgstr ""
msgid "Enable experimental native Wayland support"
msgstr ""
msgid "Enable HDR settings under native Wayland"
msgstr ""
msgid "Use Gallium Zink (OpenGL via Vulkan)"
msgstr ""
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
msgstr ""
msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr ""
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr ""
msgid "Use async dxvk-sarek (experimental)"
msgstr ""
msgid "Windows version"
msgstr ""
msgid ""
"Changing the WINDOWS emulation version may be required to run older "
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
msgstr ""
msgid "DLL Overrides"
msgstr ""
msgid ""
"Forced to use/disable the library only for the given application.\n"
"\n"
"A brief instruction:\n"
"* libraries are written WITHOUT the .dll file extension\n"
"* libraries are separated by semicolons - ;\n"
"* library=n - use the WINDOWS (third-party) library\n"
"* library=b - use WINE (built-in) library\n"
"* library=n,b - use WINDOWS library and then WINE\n"
"* library=b,n - use WINE library and then WINDOWS\n"
"* library= - disable the use of this library\n"
"\n"
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgstr ""
msgid "Launch Arguments"
msgstr ""
msgid ""
"Adding an argument after the .exe file, just like you would add an "
"argument in a shortcut on a WINDOWS system.\n"
"\n"
"Example: -dx11 -skipintro 1"
msgstr ""
msgid "CPU Cores Limit"
msgstr ""
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
msgid "OpenGL Version"
msgstr ""
msgid ""
"You can select the required OpenGL version, some games require a forced "
"Compatibility Profile (COMP)."
msgstr ""
msgid "VKD3D Feature Level"
msgstr ""
msgid "You can set a forced feature level VKD3D for games on DirectX12"
msgstr ""
msgid "Locale"
msgstr ""
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
msgstr ""
msgid "Window Mode"
msgstr ""
msgid ""
"Window mode (for Vulkan and OpenGL):\n"
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
"immediate - Unlimited frame rate + tearing.\n"
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
" rate."
msgstr ""
msgid "AMD Vulkan Driver"
msgstr ""
msgid ""
"Select needed AMD vulkan implementation. Choosing which implementation of"
" vulkan will be used to run the game"
msgstr ""
msgid "NUMA Node"
msgstr ""
msgid ""
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
" single node reduces memory-access latency and limits costly core-to-core"
" switches."
msgstr ""
msgid "Reboot"
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-10-12 17:14+0500\n"
"POT-Creation-Date: 2025-11-11 17:00+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -76,10 +76,6 @@ msgstr ""
msgid "Legendary executable not found at {path}"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success"
msgstr ""
@@ -124,6 +120,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
@@ -252,13 +252,37 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer"
msgstr ""
@@ -326,12 +350,6 @@ msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -347,6 +365,39 @@ msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Main"
msgstr ""
msgid "Advanced"
msgstr ""
msgid "Setting"
msgstr ""
msgid "Value"
msgstr ""
msgid "Description"
msgstr ""
msgid "disabled"
msgstr ""
msgid "Info"
msgstr ""
msgid "No changes to apply."
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Settings updated successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -494,14 +545,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgid "Clearing prefix..."
msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgid "Prefix clear failed with exit code {}."
msgstr ""
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr ""
msgid "Failed to start backup process."
@@ -579,6 +637,9 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -603,6 +664,12 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""
@@ -677,6 +744,10 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -735,6 +806,232 @@ msgstr ""
msgid "File not found: {0}"
msgstr ""
msgid ""
"Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)"
msgstr ""
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr ""
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
msgstr ""
msgid ""
"Super + F : Toggle fullscreen\n"
"Super + N : Toggle nearest neighbour filtering\n"
"Super + U : Toggle FSR upscaling\n"
"Super + Y : Toggle NIS upscaling\n"
"Super + I : Increase FSR sharpness by 1\n"
"Super + O : Decrease FSR sharpness by 1\n"
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
"Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard"
msgstr ""
msgid "Enable in-process synchronization primitives based on eventfd."
msgstr ""
msgid "Enable futex-based in-process synchronization primitives."
msgstr ""
msgid "Enable in-process synchronization via the Linux ntsync driver."
msgstr ""
msgid "Enable vkd3d support - Ray Tracing"
msgstr ""
msgid "Enable DLSS on supported NVIDIA graphics cards"
msgstr ""
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr ""
msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr ""
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr ""
msgid "Disguise all NVIDIA GPU features"
msgstr ""
msgid "Run the application in WINE virtual desktop"
msgstr ""
msgid "Run the application in a terminal"
msgstr ""
msgid "Disable startup mode and WINE version selector window"
msgstr ""
msgid "Use system GameMode for performance optimization"
msgstr ""
msgid "Enable forced use of third-party DirectX libraries"
msgstr ""
msgid "Fix pink-tinted video playback in some games"
msgstr ""
msgid "Reduce PulseAudio latency to fix intermittent sound"
msgstr ""
msgid "Force US keyboard layout"
msgstr ""
msgid "Use GStreamer for in-game clips (WMF support)"
msgstr ""
msgid "Use WINE shader caching"
msgstr ""
msgid "Force use of built-in DXGI library"
msgstr ""
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr ""
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr ""
msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr ""
msgid "Disable desktop compositing for performance"
msgstr ""
msgid "Use container launch mode (recommended default)"
msgstr ""
msgid "Force DirectInput protocol instead of XInput"
msgstr ""
msgid "Enable experimental native Wayland support"
msgstr ""
msgid "Enable HDR settings under native Wayland"
msgstr ""
msgid "Use Gallium Zink (OpenGL via Vulkan)"
msgstr ""
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
msgstr ""
msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr ""
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr ""
msgid "Use async dxvk-sarek (experimental)"
msgstr ""
msgid "Windows version"
msgstr ""
msgid ""
"Changing the WINDOWS emulation version may be required to run older "
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
msgstr ""
msgid "DLL Overrides"
msgstr ""
msgid ""
"Forced to use/disable the library only for the given application.\n"
"\n"
"A brief instruction:\n"
"* libraries are written WITHOUT the .dll file extension\n"
"* libraries are separated by semicolons - ;\n"
"* library=n - use the WINDOWS (third-party) library\n"
"* library=b - use WINE (built-in) library\n"
"* library=n,b - use WINDOWS library and then WINE\n"
"* library=b,n - use WINE library and then WINDOWS\n"
"* library= - disable the use of this library\n"
"\n"
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgstr ""
msgid "Launch Arguments"
msgstr ""
msgid ""
"Adding an argument after the .exe file, just like you would add an "
"argument in a shortcut on a WINDOWS system.\n"
"\n"
"Example: -dx11 -skipintro 1"
msgstr ""
msgid "CPU Cores Limit"
msgstr ""
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
msgid "OpenGL Version"
msgstr ""
msgid ""
"You can select the required OpenGL version, some games require a forced "
"Compatibility Profile (COMP)."
msgstr ""
msgid "VKD3D Feature Level"
msgstr ""
msgid "You can set a forced feature level VKD3D for games on DirectX12"
msgstr ""
msgid "Locale"
msgstr ""
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
msgstr ""
msgid "Window Mode"
msgstr ""
msgid ""
"Window mode (for Vulkan and OpenGL):\n"
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
"immediate - Unlimited frame rate + tearing.\n"
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
" rate."
msgstr ""
msgid "AMD Vulkan Driver"
msgstr ""
msgid ""
"Select needed AMD vulkan implementation. Choosing which implementation of"
" vulkan will be used to run the game"
msgstr ""
msgid "NUMA Node"
msgstr ""
msgid ""
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
" single node reduces memory-access latency and limits costly core-to-core"
" switches."
msgstr ""
msgid "Reboot"
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-10-12 17:14+0500\n"
"POT-Creation-Date: 2025-11-11 17:00+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"
@@ -74,10 +74,6 @@ msgstr ""
msgid "Legendary executable not found at {path}"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success"
msgstr ""
@@ -122,6 +118,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites"
msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr ""
@@ -250,13 +250,37 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer"
msgstr ""
@@ -324,12 +348,6 @@ msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -345,6 +363,39 @@ msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Exe Settings"
msgstr ""
msgid "Main"
msgstr ""
msgid "Advanced"
msgstr ""
msgid "Setting"
msgstr ""
msgid "Value"
msgstr ""
msgid "Description"
msgstr ""
msgid "disabled"
msgstr ""
msgid "Info"
msgstr ""
msgid "No changes to apply."
msgstr ""
msgid "Failed to apply changes. Check logs."
msgstr ""
msgid "Settings updated successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -492,14 +543,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgid "Clearing prefix..."
msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgid "Prefix clear failed with exit code {}."
msgstr ""
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr ""
msgid "Failed to start backup process."
@@ -577,6 +635,9 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -601,6 +662,12 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""
@@ -675,6 +742,10 @@ msgstr ""
msgid "Error applying theme '{0}'"
msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH"
msgstr ""
@@ -733,6 +804,232 @@ msgstr ""
msgid "File not found: {0}"
msgstr ""
msgid ""
"Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)"
msgstr ""
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr ""
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr ""
msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
msgstr ""
msgid ""
"Super + F : Toggle fullscreen\n"
"Super + N : Toggle nearest neighbour filtering\n"
"Super + U : Toggle FSR upscaling\n"
"Super + Y : Toggle NIS upscaling\n"
"Super + I : Increase FSR sharpness by 1\n"
"Super + O : Decrease FSR sharpness by 1\n"
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
"Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard"
msgstr ""
msgid "Enable in-process synchronization primitives based on eventfd."
msgstr ""
msgid "Enable futex-based in-process synchronization primitives."
msgstr ""
msgid "Enable in-process synchronization via the Linux ntsync driver."
msgstr ""
msgid "Enable vkd3d support - Ray Tracing"
msgstr ""
msgid "Enable DLSS on supported NVIDIA graphics cards"
msgstr ""
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr ""
msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr ""
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr ""
msgid "Disguise all NVIDIA GPU features"
msgstr ""
msgid "Run the application in WINE virtual desktop"
msgstr ""
msgid "Run the application in a terminal"
msgstr ""
msgid "Disable startup mode and WINE version selector window"
msgstr ""
msgid "Use system GameMode for performance optimization"
msgstr ""
msgid "Enable forced use of third-party DirectX libraries"
msgstr ""
msgid "Fix pink-tinted video playback in some games"
msgstr ""
msgid "Reduce PulseAudio latency to fix intermittent sound"
msgstr ""
msgid "Force US keyboard layout"
msgstr ""
msgid "Use GStreamer for in-game clips (WMF support)"
msgstr ""
msgid "Use WINE shader caching"
msgstr ""
msgid "Force use of built-in DXGI library"
msgstr ""
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr ""
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr ""
msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr ""
msgid "Disable desktop compositing for performance"
msgstr ""
msgid "Use container launch mode (recommended default)"
msgstr ""
msgid "Force DirectInput protocol instead of XInput"
msgstr ""
msgid "Enable experimental native Wayland support"
msgstr ""
msgid "Enable HDR settings under native Wayland"
msgstr ""
msgid "Use Gallium Zink (OpenGL via Vulkan)"
msgstr ""
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
msgstr ""
msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr ""
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr ""
msgid "Use async dxvk-sarek (experimental)"
msgstr ""
msgid "Windows version"
msgstr ""
msgid ""
"Changing the WINDOWS emulation version may be required to run older "
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
msgstr ""
msgid "DLL Overrides"
msgstr ""
msgid ""
"Forced to use/disable the library only for the given application.\n"
"\n"
"A brief instruction:\n"
"* libraries are written WITHOUT the .dll file extension\n"
"* libraries are separated by semicolons - ;\n"
"* library=n - use the WINDOWS (third-party) library\n"
"* library=b - use WINE (built-in) library\n"
"* library=n,b - use WINDOWS library and then WINE\n"
"* library=b,n - use WINE library and then WINDOWS\n"
"* library= - disable the use of this library\n"
"\n"
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgstr ""
msgid "Launch Arguments"
msgstr ""
msgid ""
"Adding an argument after the .exe file, just like you would add an "
"argument in a shortcut on a WINDOWS system.\n"
"\n"
"Example: -dx11 -skipintro 1"
msgstr ""
msgid "CPU Cores Limit"
msgstr ""
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
msgid "OpenGL Version"
msgstr ""
msgid ""
"You can select the required OpenGL version, some games require a forced "
"Compatibility Profile (COMP)."
msgstr ""
msgid "VKD3D Feature Level"
msgstr ""
msgid "You can set a forced feature level VKD3D for games on DirectX12"
msgstr ""
msgid "Locale"
msgstr ""
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
msgstr ""
msgid "Window Mode"
msgstr ""
msgid ""
"Window mode (for Vulkan and OpenGL):\n"
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
"immediate - Unlimited frame rate + tearing.\n"
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
" rate."
msgstr ""
msgid "AMD Vulkan Driver"
msgstr ""
msgid ""
"Select needed AMD vulkan implementation. Choosing which implementation of"
" vulkan will be used to run the game"
msgstr ""
msgid "NUMA Node"
msgstr ""
msgid ""
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
" single node reduces memory-access latency and limits costly core-to-core"
" switches."
msgstr ""
msgid "Reboot"
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-10-12 17:14+0500\n"
"PO-Revision-Date: 2025-10-12 17:13+0500\n"
"POT-Creation-Date: 2025-11-11 17:00+0500\n"
"PO-Revision-Date: 2025-11-11 17:00+0500\n"
"Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
@@ -77,10 +77,6 @@ msgstr "Остановлен(а) '{game_name}'"
msgid "Legendary executable not found at {path}"
msgstr "Legendary не найден по пути {path}"
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr "start.sh не найден по адресу {path}"
msgid "Success"
msgstr "Успешно"
@@ -127,6 +123,10 @@ msgstr "'{game_name}' был(а) добавлен(а) в избранное"
msgid "Removed '{game_name}' from favorites"
msgstr "'{game_name}' был(а) удалён(а) из избранного"
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr "start.sh не найден по адресу {path}"
#, python-brace-format
msgid "Launch game \"{name}\" with PortProton"
msgstr "Запустить игру \"{name}\" с помощью PortProton"
@@ -259,13 +259,37 @@ msgstr "Удалить"
msgid "Select All"
msgstr "Выбрать всё"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "Open"
msgstr "Открыть"
msgid "Select Dir"
msgstr "Выбрать папку"
msgid "Prev Dir"
msgstr "Предыдущий каталог"
msgid "Cancel"
msgstr "Отмена"
msgid "Toggle"
msgstr "Переключить"
msgid "Install"
msgstr "Установить"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Prev Tab"
msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "File Explorer"
msgstr "Проводник"
@@ -333,12 +357,6 @@ msgstr "Шрифты"
msgid "Settings"
msgstr "Настройки"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Install"
msgstr "Установить"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку."
@@ -354,6 +372,39 @@ msgstr "Установка не удалась. Проверьте журнал
msgid "Components installed successfully."
msgstr "Компоненты успешно установлены."
msgid "Exe Settings"
msgstr "Настройки EXE"
msgid "Main"
msgstr "Основные"
msgid "Advanced"
msgstr "Расширенные"
msgid "Setting"
msgstr "Параметр"
msgid "Value"
msgstr "Значение"
msgid "Description"
msgstr "Описание"
msgid "disabled"
msgstr "отключено"
msgid "Info"
msgstr "Информация"
msgid "No changes to apply."
msgstr "Изменений для применения нет."
msgid "Failed to apply changes. Check logs."
msgstr "Не удалось применить изменения. Проверьте логи."
msgid "Settings updated successfully."
msgstr "Настройки успешно обновлены."
msgid "Loading Epic Games Store games..."
msgstr "Загрузка игр из Epic Games Store..."
@@ -501,17 +552,22 @@ msgstr "Подтвердите очистку"
msgid "Are you sure you want to clear prefix '{}'?"
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr "Префикс '{}' успешно удален."
msgid "Clearing prefix..."
msgstr "Очистка префикса..."
msgid "Failed to start prefix clear process."
msgstr "Не удалось запустить процесс очистки префикса."
msgid "Prefix cleared successfully."
msgstr "Префикс удален успешно."
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
"Префикс '{}' очищен с ошибками:\n"
"{}"
msgid "Prefix clear failed with exit code {}."
msgstr "Очистка префикса завершилась с кодом завершения {}."
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr "Не удалось выполнить команду очистки префикса: {}"
msgid "Failed to start backup process."
msgstr "Не удалось запустить процесс резервного копирования."
@@ -588,6 +644,9 @@ msgstr "все"
msgid "Games Display Filter:"
msgstr "Фильтр игр:"
msgid "Gamepad Type:"
msgstr "Тип геймпада:"
msgid "Proxy URL"
msgstr "Адрес прокси"
@@ -612,6 +671,12 @@ msgstr "Запуск приложения в полноэкранном режи
msgid "Application Fullscreen Mode:"
msgstr "Режим полноэкранного отображения приложения:"
msgid "Minimize to tray on close"
msgstr "Сворачивать в трей при закрытии"
msgid "Application Close Mode:"
msgstr "Режим закрытия приложения:"
msgid "Auto Fullscreen on Gamepad connected"
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
@@ -688,6 +753,10 @@ msgstr "Тема '{0}' применена успешно"
msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Исполняемый файл не найден: {0}"
msgid "LAST LAUNCH"
msgstr "Последний запуск"
@@ -746,6 +815,288 @@ msgstr "Неправильный формат команды (flatpak)"
msgid "File not found: {0}"
msgstr "Файл не найден: {0}"
msgid ""
"Using FPS and system load monitoring (Turns on and off by the key "
"combination - right Shift + F12)"
msgstr ""
"Использование мониторинга FPS и нагрузки системы (включается и "
"выключается комбинацией клавиш - правая Shift + F12)"
msgid "Forced use of MANGOHUD system settings (GOverlay, etc.)"
msgstr "Принудительное использование системных настроек MANGOHUD (GOverlay и т.д.)"
msgid ""
"Enable vkBasalt by default to improve graphics in games running on "
"Vulkan. (The HOME hotkey disables vkbasalt)"
msgstr ""
"Включить vkBasalt по умолчанию для улучшения графики в играх на Vulkan. "
"(Горячая клавиша HOME отключает vkbasalt)"
msgid "Forced use of VKBASALT system settings (GOverlay, etc.)"
msgstr "Принудительное использование системных настроек VKBASALT (GOverlay и т.д.)"
msgid ""
"Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, "
"DirectDraw 1-7, Direct3D 2-9) on all 3D API."
msgstr ""
"Включить dgVoodoo2. Принудительное использование всех библиотек dgVoodoo2"
" (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) на всех 3D API."
msgid ""
"Super + F : Toggle fullscreen\n"
"Super + N : Toggle nearest neighbour filtering\n"
"Super + U : Toggle FSR upscaling\n"
"Super + Y : Toggle NIS upscaling\n"
"Super + I : Increase FSR sharpness by 1\n"
"Super + O : Decrease FSR sharpness by 1\n"
"Super + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\n"
"Super + G : Toggle keyboard grab\n"
"Super + C : Update clipboard"
msgstr ""
"Super + F: Переключить полноэкранный режим\n"
"Super + N: Переключить фильтрацию ближайшего соседа\n"
"Super + U: Переключить апскейлинг FSR\n"
"Super + Y: Переключить апскейлинг NIS\n"
"Super + I: Увеличить резкость FSR на 1\n"
"Super + O: Уменьшить резкость FSR на 1\n"
"Super + S: Сделать скриншот (сейчас сохраняется в "
"/tmp/gamescope_DATE.png)\n"
"Super + G: Переключить захват клавиатуры\n"
"Super + C: Обновить буфер обмена"
msgid "Enable in-process synchronization primitives based on eventfd."
msgstr "Включить примитивы синхронизации в процессе на основе eventfd."
msgid "Enable futex-based in-process synchronization primitives."
msgstr "Включить примитивы синхронизации в процессе на основе futex."
msgid "Enable in-process synchronization via the Linux ntsync driver."
msgstr "Включить синхронизацию в процессе через драйвер ntsync в Linux."
msgid "Enable vkd3d support - Ray Tracing"
msgstr "Включить поддержку vkd3d — трассировка лучей"
msgid "Enable DLSS on supported NVIDIA graphics cards"
msgstr "Включить DLSS на поддерживаемых видеокартах NVIDIA"
msgid "Enable OptiScaler (replacement upscaler / frame generator)"
msgstr "Включить OptiScaler (замена апскейлера / генератора кадров)"
msgid "Enable Lossless Scaling frame generation (experimental)"
msgstr "Включить генерацию кадров Lossless Scaling (экспериментально)"
msgid "FSR upscaling in fullscreen with ProtonGE below native resolution"
msgstr "Апскейлинг FSR в полноэкранном режиме с ProtonGE ниже родного разрешения"
msgid "Disguise all NVIDIA GPU features"
msgstr "Маскировать все функции GPU NVIDIA"
msgid "Run the application in WINE virtual desktop"
msgstr "Запускать приложение в виртуальном рабочем столе WINE"
msgid "Run the application in a terminal"
msgstr "Запускать приложение в терминале"
msgid "Disable startup mode and WINE version selector window"
msgstr "Отключить окно выбора режима запуска и версии WINE"
msgid "Use system GameMode for performance optimization"
msgstr "Использовать системный GameMode для оптимизации производительности"
msgid "Enable forced use of third-party DirectX libraries"
msgstr "Включить принудительное использование сторонних библиотек DirectX"
msgid "Fix pink-tinted video playback in some games"
msgstr "Исправить розовый оттенок видео в некоторых играх"
msgid "Reduce PulseAudio latency to fix intermittent sound"
msgstr "Уменьшить задержку PulseAudio для исправления прерывистого звука"
msgid "Force US keyboard layout"
msgstr "Принудительно использовать раскладку клавиатуры US"
msgid "Use GStreamer for in-game clips (WMF support)"
msgstr "Использовать GStreamer для внутриигровых клипов (поддержка WMF)"
msgid "Use WINE shader caching"
msgstr "Использовать кэширование шейдеров WINE"
msgid "Force use of built-in DXGI library"
msgstr "Принудительно использовать встроенную библиотеку DXGI"
msgid "Enable Easy Anti-Cheat and BattlEye runtimes"
msgstr "Включить среды выполнения Easy Anti-Cheat и BattlEye"
msgid "Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"
msgstr "Использовать системные слои Vulkan (MangoHud, vkBasalt, OBS и т.д.)"
msgid "Enable OBS Studio capture via obs-vkcapture"
msgstr "Включить захват OBS Studio через obs-vkcapture"
msgid "Disable desktop compositing for performance"
msgstr "Отключить композицию рабочего стола для производительности"
msgid "Use container launch mode (recommended default)"
msgstr "Использовать режим запуска в контейнере (рекомендуемый по умолчанию)"
msgid "Force DirectInput protocol instead of XInput"
msgstr "Принудительно использовать протокол DirectInput вместо XInput"
msgid "Enable experimental native Wayland support"
msgstr "Включить экспериментальную нативную поддержку Wayland"
msgid "Enable HDR settings under native Wayland"
msgstr "Включить настройки HDR под нативным Wayland"
msgid "Use Gallium Zink (OpenGL via Vulkan)"
msgstr "Использовать Gallium Zink (OpenGL через Vulkan)"
msgid "Use Gallium Nine (native DirectX 9 for Mesa)"
msgstr "Использовать Gallium Nine (нативный DirectX 9 для Mesa)"
msgid "Use WineD3D Vulkan backend (Damavand)"
msgstr "Использовать бэкенд Vulkan WineD3D (Damavand)"
msgid "Use bundled dxvk/vkd3d from Wine/Proton"
msgstr "Использовать встроенные dxvk/vkd3d из Wine/Proton"
msgid "Use async dxvk-sarek (experimental)"
msgstr "Использовать асинхронный dxvk-sarek (экспериментально)"
msgid "Windows version"
msgstr "Версия Windows"
msgid ""
"Changing the WINDOWS emulation version may be required to run older "
"games. WINDOWS versions below 10 do not support new games with DirectX 12"
msgstr ""
"Изменение версии эмуляции WINDOWS может потребоваться для запуска старых "
"игр. Версии WINDOWS ниже 10 не поддерживают новые игры с DirectX 12"
msgid "DLL Overrides"
msgstr "Переопределения DLL"
msgid ""
"Forced to use/disable the library only for the given application.\n"
"\n"
"A brief instruction:\n"
"* libraries are written WITHOUT the .dll file extension\n"
"* libraries are separated by semicolons - ;\n"
"* library=n - use the WINDOWS (third-party) library\n"
"* library=b - use WINE (built-in) library\n"
"* library=n,b - use WINDOWS library and then WINE\n"
"* library=b,n - use WINE library and then WINDOWS\n"
"* library= - disable the use of this library\n"
"\n"
"Example: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgstr ""
"Принудительное использование/отключение библиотеки только для данного "
"приложения.\n"
"\n"
"Краткая инструкция:\n"
"* библиотеки пишутся БЕЗ расширения .dll\n"
"* библиотеки разделяются точкой с запятой - ;\n"
"* library=n — использовать библиотеку WINDOWS (стороннюю)\n"
"* library=b — использовать библиотеку WINE (встроенную)\n"
"* library=n,b — использовать библиотеку WINDOWS, затем WINE\n"
"* library=b,n — использовать библиотеку WINE, затем WINDOWS\n"
"* library= — отключить использование этой библиотеки\n"
"\n"
"Пример: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"
msgid "Launch Arguments"
msgstr "Аргументы запуска"
msgid ""
"Adding an argument after the .exe file, just like you would add an "
"argument in a shortcut on a WINDOWS system.\n"
"\n"
"Example: -dx11 -skipintro 1"
msgstr ""
"Добавление аргумента после файла .exe, как вы бы добавили аргумент в "
"ярлыке на системе WINDOWS.\n"
"\n"
"Пример: -dx11 -skipintro 1"
msgid "CPU Cores Limit"
msgstr "Ограничение ядер CPU"
msgid ""
"Limiting the number of CPU cores is useful for Unity games (It is "
"recommended to set the value equal to 8)"
msgstr ""
"Ограничение количества ядер CPU полезно для игр на Unity (рекомендуется "
"установить значение равным 8)"
msgid "OpenGL Version"
msgstr "Версия OpenGL"
msgid ""
"You can select the required OpenGL version, some games require a forced "
"Compatibility Profile (COMP)."
msgstr ""
"Вы можете выбрать требуемую версию OpenGL, некоторые игры требуют "
"принудительного профиля совместимости (COMP)."
msgid "VKD3D Feature Level"
msgstr "Уровень возможностей VKD3D"
msgid "You can set a forced feature level VKD3D for games on DirectX12"
msgstr ""
"Вы можете установить принудительный уровень возможностей VKD3D для игр на"
" DirectX12"
msgid "Locale"
msgstr "Локаль"
msgid "Force certain locale for an app. Fixes encoding issues in legacy software"
msgstr ""
"Принудительно установить определённую локаль для приложения. Исправляет "
"проблемы с кодировкой в устаревшем ПО"
msgid "Window Mode"
msgstr "Режим окна"
msgid ""
"Window mode (for Vulkan and OpenGL):\n"
"fifo - First in, first out. Limits the frame rate + no tearing. (VSync)\n"
"immediate - Unlimited frame rate + tearing.\n"
"mailbox - Triple buffering. Unlimited frame rate + no tearing.\n"
"relaxed - Same as fifo but allows tearing when below the monitors refresh"
" rate."
msgstr ""
"Режим окна (для Vulkan и OpenGL):\n"
"fifo — Первый вошёл, первый вышел. Ограничивает частоту кадров + без "
"разрывов. (VSync)\n"
"immediate — Неограниченная частота кадров + разрывы.\n"
"mailbox — Трёхбуферная. Неограниченная частота кадров + без разрывов.\n"
"relaxed — То же, что fifo, но позволяет разрывы при частоте ниже частоты "
"обновления монитора."
msgid "AMD Vulkan Driver"
msgstr "Драйвер Vulkan AMD"
msgid ""
"Select needed AMD vulkan implementation. Choosing which implementation of"
" vulkan will be used to run the game"
msgstr ""
"Выберите нужную реализацию Vulkan AMD. Выбор, какая реализация Vulkan "
"будет использоваться для запуска игры"
msgid "NUMA Node"
msgstr "Узел NUMA"
msgid ""
"NUMA node for CPU affinity. In multi-core systems, CPUs are split into "
"NUMA nodes, each with its own local memory and cores. Binding a game to a"
" single node reduces memory-access latency and limits costly core-to-core"
" switches."
msgstr ""
"Узел NUMA для аффинности CPU. В многоядерных системах CPU разделены на "
"узлы NUMA, каждый со своей локальной памятью и ядрами. Привязка игры к "
"одному узлу уменьшает задержку доступа к памяти и ограничивает "
"дорогостоящие переключения между ядрами."
msgid "Reboot"
msgstr "Перезагрузить"

View File

@@ -8,7 +8,7 @@ import psutil
import re
from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog, ExeSettingsDialog
from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout
@@ -29,7 +29,8 @@ from portprotonqt.config_utils import (
read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method,
save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray,
read_auto_card_size, save_auto_card_size, get_portproton_start_command
)
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.howlongtobeat_api import HowLongToBeat
@@ -39,7 +40,7 @@ from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller)
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from typing import cast
@@ -63,6 +64,7 @@ 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.auto_card_width = read_auto_card_size()
self._last_card_width = self.card_width
self.setWindowTitle(f"{app_name} {version}")
self.setMinimumSize(800, 600)
@@ -72,6 +74,7 @@ class MainWindow(QMainWindow):
self.target_exe = None
self.current_running_button = None
self.portproton_location = get_portproton_location()
self.start_sh = get_portproton_start_command()
self.game_library_manager = GameLibraryManager(self, self.theme, None)
@@ -260,6 +263,10 @@ class MainWindow(QMainWindow):
GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square",
},
'prev_dir': {
GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
@@ -452,11 +459,11 @@ class MainWindow(QMainWindow):
self.current_install_script = script_name
self.seen_progress = False
self.current_percent = 0.0
start_sh = os.path.join(self.portproton_location or "", "data", "scripts", "start.sh") if self.portproton_location else ""
if not os.path.exists(start_sh):
start_sh = self.start_sh
if not start_sh:
self.installing = False
return
cmd = [start_sh, "cli", "--autoinstall", script_name]
cmd = start_sh + ["cli", "--autoinstall", script_name]
self.install_process = QProcess(self)
self.install_process.finished.connect(self.on_install_finished)
self.install_process.errorOccurred.connect(self.on_install_error)
@@ -516,12 +523,26 @@ class MainWindow(QMainWindow):
self.install_monitor_timer = None
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(100)
if exit_code == 0:
self.update_status_message.emit(_("Installation completed successfully."), 5000)
QTimer.singleShot(500, lambda: self.restart_application())
desktop_dir = self.portproton_location or ""
new_desktops = [e.path for e in os.scandir(desktop_dir) if e.name.endswith(".desktop")]
if new_desktops:
latest = max(new_desktops, key=os.path.getmtime)
self._process_desktop_file_async(
latest,
lambda result: (
self.game_library_manager.add_game_incremental(result)
if result else None
)
)
else:
self.update_status_message.emit(_("Installation failed."), 5000)
QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).")
self.progress_bar.setVisible(False)
self.current_install_script = None
if self.install_process:
@@ -544,6 +565,16 @@ class MainWindow(QMainWindow):
self.game_library_manager.set_games(games)
self.progress_bar.setVisible(False)
# Clear the refresh in progress flag
if hasattr(self, '_refresh_in_progress'):
self._refresh_in_progress = False
# Re-enable the refresh button if it exists
if hasattr(self, 'refreshButton'):
self.refreshButton.setEnabled(True)
self.refreshButton.setText(_("Refresh Grid"))
self.update_status_message.emit(_("Game library refreshed"), 3000)
def open_portproton_forum_topic(self, topic_name: str):
"""Open the PortProton forum topic or search page for this game."""
result = self.portproton_api.get_forum_topic_slug(topic_name)
@@ -820,6 +851,25 @@ class MainWindow(QMainWindow):
for i, btn in self.tabButtons.items():
btn.setChecked(i == index)
self.stackedWidget.setCurrentIndex(index)
if hasattr(self, "game_library_manager"):
mgr = self.game_library_manager
if mgr.gamesListWidget and mgr.gamesListLayout:
games_layout = mgr.gamesListLayout
games_widget = mgr.gamesListWidget
QTimer.singleShot(0, lambda: (
games_layout.invalidate(),
games_widget.adjustSize(),
games_widget.updateGeometry()
))
if hasattr(self, "autoInstallContainer") and hasattr(self, "autoInstallContainerLayout"):
auto_layout = self.autoInstallContainerLayout
auto_widget = self.autoInstallContainer
QTimer.singleShot(0, lambda: (
auto_layout.invalidate(),
auto_widget.adjustSize(),
auto_widget.updateGeometry()
))
def openSystemOverlay(self):
"""Opens the system overlay dialog."""
@@ -841,7 +891,16 @@ class MainWindow(QMainWindow):
self.addGameButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE)
self.addGameButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.addGameButton.clicked.connect(self.openAddGameDialog)
layout.addWidget(self.addGameButton, alignment=Qt.AlignmentFlag.AlignRight)
layout.addWidget(self.addGameButton)
# Refresh button
self.refreshButton = AutoSizeButton(_("Refresh Grid"), icon=self.theme_manager.get_icon("update"))
self.refreshButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE)
self.refreshButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.refreshButton.clicked.connect(self.refreshGames)
layout.addWidget(self.refreshButton)
layout.addStretch() # Add stretch to push search to the right
self.searchEdit = CustomLineEdit(self, theme=self.theme)
icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search"))
@@ -861,6 +920,32 @@ class MainWindow(QMainWindow):
layout.addWidget(self.searchEdit)
return self.container, self.searchEdit
def refreshGames(self):
"""Refresh the game grid by reloading all games without restarting the application."""
# Prevent multiple refreshes at once
if hasattr(self, '_refresh_in_progress') and self._refresh_in_progress:
# If refresh is already in progress, just update the status
self.update_status_message.emit(_("A refresh is already in progress..."), 3000)
return
# Mark that a refresh is in progress
self._refresh_in_progress = True
# Clear the search field to ensure all games are shown after refresh
self.searchEdit.clear()
# Disable the refresh button during refresh to prevent multiple clicks
self.refreshButton.setEnabled(False)
self.refreshButton.setText(_("Refreshing..."))
# Show progress bar
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # Indeterminate
self.update_status_message.emit(_("Refreshing game library..."), 0)
# Reload games using the existing loadGames functionality
QTimer.singleShot(0, self.loadGames)
def on_search_text_changed(self, text: str):
"""Search text change handler with debounce."""
self.searchDebounceTimer.stop()
@@ -1063,8 +1148,7 @@ class MainWindow(QMainWindow):
autoInstallPage = QWidget()
autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
autoInstallLayout = QVBoxLayout(autoInstallPage)
autoInstallLayout.setContentsMargins(20, 0, 20, 0)
autoInstallLayout.setSpacing(0)
autoInstallLayout.setSpacing(15)
# Верхняя панель с заголовком и поиском
headerWidget = QWidget()
@@ -1113,6 +1197,25 @@ class MainWindow(QMainWindow):
autoInstallLayout.addWidget(self.autoInstallScrollArea)
# Slider for card size
sliderLayout = QHBoxLayout()
sliderLayout.setSpacing(0)
sliderLayout.setContentsMargins(0, 0, 0, 0)
sliderLayout.addStretch()
self.auto_size_slider = QSlider(Qt.Orientation.Horizontal)
self.auto_size_slider.setMinimum(200)
self.auto_size_slider.setMaximum(250)
self.auto_size_slider.setValue(self.auto_card_width)
self.auto_size_slider.setTickInterval(10)
self.auto_size_slider.setFixedWidth(150)
self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
self.auto_size_slider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.auto_size_slider.sliderReleased.connect(self.on_auto_slider_released)
sliderLayout.addWidget(self.auto_size_slider)
autoInstallLayout.addLayout(sliderLayout)
# Хранение карточек
self.autoInstallGameCards = {}
self.allAutoInstallCards = []
@@ -1122,7 +1225,7 @@ class MainWindow(QMainWindow):
if exe_name in self.autoInstallGameCards and local_path:
card = self.autoInstallGameCards[exe_name]
card.cover_path = local_path
load_pixmap_async(local_path, self.card_width, int(self.card_width * 1.5), card.on_cover_loaded)
load_pixmap_async(local_path, self.auto_card_width, int(self.auto_card_width * 1.5), card.on_cover_loaded)
# Загрузка игр
def on_autoinstall_games_loaded(games: list[tuple]):
@@ -1158,7 +1261,7 @@ class MainWindow(QMainWindow):
None, None, None, game_source,
select_callback=select_callback,
theme=self.theme,
card_width=self.card_width,
card_width=self.auto_card_width,
parent=self.autoInstallContainer,
)
@@ -1196,10 +1299,30 @@ class MainWindow(QMainWindow):
# Показываем прогресс
self.autoInstallProgress.setVisible(True)
self.autoInstallProgress.setRange(0, 0)
self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded)
# Store the thread to prevent premature destruction
self.autoInstallLoadThread = self.portproton_api.start_autoinstall_games_load(on_autoinstall_games_loaded)
# Optional: Clean up thread when finished (prevents leak)
if self.autoInstallLoadThread:
def on_thread_finished():
self.autoInstallLoadThread = None # Release reference
self.autoInstallLoadThread.finished.connect(on_thread_finished)
self.stackedWidget.addWidget(autoInstallPage)
def on_auto_slider_released(self):
"""Handles auto-install slider release to update card size."""
if hasattr(self, 'auto_size_slider') and self.auto_size_slider:
self.auto_card_width = self.auto_size_slider.value()
self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
save_auto_card_size(self.auto_card_width)
for card in self.allAutoInstallCards:
card.update_card_size(self.auto_card_width)
self.autoInstallContainerLayout.invalidate()
self.autoInstallContainer.updateGeometry()
self.autoInstallScrollArea.updateGeometry()
def filterAutoInstallGames(self):
"""Filter auto install game cards based on search text."""
search_text = self.autoInstallSearchLineEdit.text().lower().strip()
@@ -1347,12 +1470,10 @@ class MainWindow(QMainWindow):
prefix = self.prefixCombo.currentText()
if not wine or not prefix:
return
if not self.portproton_location:
if not self.portproton_location or not self.start_sh:
return
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
if not os.path.exists(start_sh):
return
cmd = [start_sh, "cli", cli_arg, wine, prefix]
start_sh = self.start_sh
cmd = start_sh + ["cli", cli_arg, wine, prefix]
# Показываем прогресс-бар перед запуском
self.wine_progress_bar.setVisible(True)
@@ -1431,12 +1552,13 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
def clear_prefix(self):
"""Очистка префикса (позже удалить)."""
"""Очищает префикс"""
selected_prefix = self.prefixCombo.currentText()
selected_wine = self.wineCombo.currentText()
if not selected_prefix or not selected_wine:
return
if not self.portproton_location:
if not self.portproton_location or not self.start_sh:
return
reply = QMessageBox.question(
@@ -1449,98 +1571,35 @@ class MainWindow(QMainWindow):
if reply != QMessageBox.StandardButton.Yes:
return
prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix)
if not os.path.exists(prefix_dir):
start_sh = self.start_sh
self.wine_progress_bar.setVisible(True)
self.update_status_message.emit(_("Clearing prefix..."), 0)
self.clear_process = QProcess(self)
self.clear_process.finished.connect(lambda exitCode, exitStatus: self._on_clear_prefix_finished(exitCode))
self.clear_process.errorOccurred.connect(lambda error: self._on_clear_prefix_error(error))
cmd = start_sh + ["cli", "--clear_pfx", selected_wine, selected_prefix]
self.clear_process.start(cmd[0], cmd[1:])
if not self.clear_process.waitForStarted(5000):
self.wine_progress_bar.setVisible(False)
self.update_status_message.emit("", 0)
QMessageBox.warning(self, _("Error"), _("Failed to start prefix clear process."))
return
success = True
errors = []
# Удаление файлов
files_to_remove = [
os.path.join(prefix_dir, "*.dot*"),
os.path.join(prefix_dir, "*.prog*"),
os.path.join(prefix_dir, ".wine_ver"),
os.path.join(prefix_dir, "system.reg"),
os.path.join(prefix_dir, "user.reg"),
os.path.join(prefix_dir, "userdef.reg"),
os.path.join(prefix_dir, "winetricks.log"),
os.path.join(prefix_dir, ".update-timestamp"),
os.path.join(prefix_dir, "drive_c", ".windows-serial"),
]
import glob
for pattern in files_to_remove:
if "*" in pattern: # Глобальный паттерн
matches = glob.glob(pattern)
for file_path in matches:
try:
if os.path.exists(file_path):
os.remove(file_path)
except Exception as e:
success = False
errors.append(str(e))
else: # Конкретный файл
try:
if os.path.exists(pattern):
os.remove(pattern)
except Exception as e:
success = False
errors.append(str(e))
# Удаление директорий
dirs_to_remove = [
os.path.join(prefix_dir, "drive_c", "windows"),
os.path.join(prefix_dir, "drive_c", "ProgramData", "Setup"),
os.path.join(prefix_dir, "drive_c", "ProgramData", "Windows"),
os.path.join(prefix_dir, "drive_c", "ProgramData", "WindowsTask"),
os.path.join(prefix_dir, "drive_c", "ProgramData", "Package Cache"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Microsoft"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Temp"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Temporary Internet Files"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "Microsoft"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "wine_gecko"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Temp"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Microsoft"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Temp"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Temporary Internet Files"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "Microsoft"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "wine_gecko"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Temp"),
os.path.join(prefix_dir, "drive_c", "Program Files", "Internet Explorer"),
os.path.join(prefix_dir, "drive_c", "Program Files", "Windows Media Player"),
os.path.join(prefix_dir, "drive_c", "Program Files", "Windows NT"),
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Internet Explorer"),
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows Media Player"),
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows NT"),
]
import shutil
for dir_path in dirs_to_remove:
try:
if os.path.exists(dir_path):
shutil.rmtree(dir_path)
except Exception as e:
success = False
errors.append(str(e))
tmp_path = os.path.join(self.portproton_location, "data", "tmp")
if os.path.exists(tmp_path):
import glob
bin_files = glob.glob(os.path.join(tmp_path, "*.bin"))
foz_files = glob.glob(os.path.join(tmp_path, "*.foz"))
for file_path in bin_files + foz_files:
try:
os.remove(file_path)
except Exception as e:
success = False
errors.append(str(e))
if success:
QMessageBox.information(self, _("Success"), _("Prefix '{}' cleared successfully.").format(selected_prefix))
def _on_clear_prefix_finished(self, exitCode):
self.wine_progress_bar.setVisible(False)
self.update_status_message.emit("", 0)
if exitCode == 0:
QMessageBox.information(self, _("Success"), _("Prefix cleared successfully."))
else:
error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5]))
QMessageBox.warning(self, _("Warning"), error_msg)
QMessageBox.warning(self, _("Error"), _("Prefix clear failed with exit code {}.").format(exitCode))
def _on_clear_prefix_error(self, error):
self.wine_progress_bar.setVisible(False)
self.update_status_message.emit("", 0)
QMessageBox.warning(self, _("Error"), _("Failed to run clear prefix command: {}").format(error))
def create_prefix_backup(self):
selected_prefix = self.prefixCombo.currentText()
@@ -1552,14 +1611,12 @@ class MainWindow(QMainWindow):
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):
if not self.portproton_location or not self.start_sh:
return
start_sh = self.start_sh
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]
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."))
@@ -1572,14 +1629,12 @@ class MainWindow(QMainWindow):
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):
if not self.portproton_location or not self.start_sh:
return
start_sh = self.start_sh
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]
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."))
@@ -1765,7 +1820,22 @@ class MainWindow(QMainWindow):
self.gamesDisplayCombo.setCurrentIndex(idx)
formLayout.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo)
# 4. Proxy settings
# 4 Gamepad Type
self.gamepadTypeCombo = QComboBox()
self.gamepadTypeCombo.addItems(["Xbox", "PlayStation"])
self.gamepadTypeCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.gamepadTypeCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
self.gamepadTypeTitle = QLabel(_("Gamepad Type:"))
self.gamepadTypeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.gamepadTypeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
current_type_str = read_gamepad_type()
if current_type_str == "playstation":
self.gamepadTypeCombo.setCurrentText("PlayStation")
else:
self.gamepadTypeCombo.setCurrentText("Xbox")
formLayout.addRow(self.gamepadTypeTitle, self.gamepadTypeCombo)
# 5. Proxy settings
self.proxyUrlEdit = CustomLineEdit(self, theme=self.theme)
self.proxyUrlEdit.setPlaceholderText(_("Proxy URL"))
self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
@@ -1797,7 +1867,7 @@ class MainWindow(QMainWindow):
self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.proxyPasswordTitle, self.proxyPasswordEdit)
# 5. Fullscreen setting for application
# 6. Fullscreen setting for application
self.fullscreenCheckBox = QCheckBox(_("Launch Application in Fullscreen"))
self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -1808,7 +1878,19 @@ class MainWindow(QMainWindow):
self.fullscreenCheckBox.setChecked(current_fullscreen)
formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox)
# 6. Automatic fullscreen on gamepad connection
# 7. Minimize to tray setting
self.minimizeToTrayCheckBox = QCheckBox(_("Minimize to tray on close"))
self.minimizeToTrayCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.minimizeToTrayTitle = QLabel(_("Application Close Mode:"))
self.minimizeToTrayTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.minimizeToTrayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
current_minimize_to_tray = read_minimize_to_tray()
self.minimizeToTrayCheckBox.setChecked(current_minimize_to_tray)
self.minimizeToTrayCheckBox.toggled.connect(lambda checked: save_minimize_to_tray(checked))
formLayout.addRow(self.minimizeToTrayTitle, self.minimizeToTrayCheckBox)
# 8. Automatic fullscreen on gamepad connection
self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -1820,7 +1902,7 @@ class MainWindow(QMainWindow):
self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
# 7. Gamepad haptic feedback config
# 9. Gamepad haptic feedback config
self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
@@ -1831,7 +1913,7 @@ class MainWindow(QMainWindow):
self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
# # 8. Legendary Authentication
# # 9. Legendary Authentication
# self.legendaryAuthButton = AutoSizeButton(
# _("Open Legendary Login"),
# icon=self.theme_manager.get_icon("login")self.theme_manager.get_icon("login")
@@ -2011,6 +2093,19 @@ class MainWindow(QMainWindow):
rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
save_rumble_config(rumble_enabled)
gamepad_type_text = self.gamepadTypeCombo.currentText()
gpad_type = "playstation" if gamepad_type_text == "PlayStation" else "xbox"
save_gamepad_type(gpad_type)
if hasattr(self, 'input_manager'):
if gpad_type == "playstation":
self.input_manager.gamepad_type = GamepadType.PLAYSTATION
elif gpad_type == "xbox":
self.input_manager.gamepad_type = GamepadType.XBOX
else:
self.input_manager.gamepad_type = GamepadType.UNKNOWN
self.updateControlHints()
for card in self.game_library_manager.game_card_cache.values():
card.update_badge_visibility(filter_key)
@@ -2209,6 +2304,14 @@ class MainWindow(QMainWindow):
def darkenColor(self, color, factor=200):
return color.darker(factor)
def open_exe_settings(self, exe_path):
"""Open the ExeSettingsDialog for the given executable."""
if not os.path.exists(exe_path):
QMessageBox.warning(self, _("Error"), _("Executable not found: {0}").format(exe_path))
return
dialog = ExeSettingsDialog(self, self.theme, exe_path)
dialog.exec()
def openGameDetailPage(self, name, description, cover_path=None, appid="", exec_line="", controller_support="", last_launch="", formatted_playtime="", protondb_tier="", game_source="", anticheat_status=""):
detailPage = QWidget()
self._animations = {}
@@ -2511,8 +2614,6 @@ class MainWindow(QMainWindow):
clear_layout(hltbLayout)
has_data = False
if main_story_time is not None:
@@ -2586,6 +2687,7 @@ class MainWindow(QMainWindow):
file_to_check = entry_exec_split[0]
current_exe = os.path.basename(file_to_check) if file_to_check else None
buttons_layout = QHBoxLayout()
if self.target_exe is not None and current_exe == self.target_exe:
playButton = AutoSizeButton(_("Stop"), icon=self.theme_manager.get_icon("stop"))
else:
@@ -2594,7 +2696,17 @@ class MainWindow(QMainWindow):
playButton.setFixedSize(120, 40)
playButton.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton))
detailsLayout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
buttons_layout.addWidget(playButton, alignment=Qt.AlignmentFlag.AlignLeft)
# Settings button
settings_icon = self.theme_manager.get_icon("settings")
settings_button = AutoSizeButton(_("Settings"), icon=settings_icon)
settings_button.setFixedSize(120, 40)
settings_button.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
settings_button.clicked.connect(lambda: self.open_exe_settings(file_to_check))
buttons_layout.addWidget(settings_button, alignment=Qt.AlignmentFlag.AlignLeft)
buttons_layout.addStretch()
detailsLayout.addLayout(buttons_layout)
contentFrameLayout.addWidget(detailsWidget)
mainLayout.addStretch()
@@ -2818,10 +2930,7 @@ class MainWindow(QMainWindow):
env_vars = os.environ.copy()
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
wrapper = "flatpak run ru.linux_gaming.PortProton"
if self.portproton_location is not None and ".var" not in self.portproton_location:
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
wrapper = start_sh
wrapper = self.start_sh or ""
cmd = [wrapper, game_exe]
@@ -2915,13 +3024,6 @@ class MainWindow(QMainWindow):
exe_name = os.path.splitext(current_exe)[0]
env_vars = os.environ.copy()
if entry_exec_split[0] == "env" and len(entry_exec_split) > 1 and 'data/scripts/start.sh' in entry_exec_split[1]:
env_vars['START_FROM_STEAM'] = '1'
env_vars['PROCESS_LOG'] = '1'
elif entry_exec_split[0] == "flatpak":
env_vars['START_FROM_STEAM'] = '1'
env_vars['PROCESS_LOG'] = '1'
# Запускаем игру
try:
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
@@ -2943,57 +3045,77 @@ class MainWindow(QMainWindow):
logger.error(f"Failed to launch game {exe_name}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
def closeEvent(self, event):
"""Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
if hasattr(self, 'is_exiting') and self.is_exiting:
# Принудительное закрытие: завершаем процессы и приложение
for proc in self.game_processes:
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
for child in children:
try:
logger.debug(f"Terminating child process {child.pid}")
child.terminate()
except psutil.NoSuchProcess:
logger.debug(f"Child process {child.pid} already terminated")
psutil.wait_procs(children, timeout=5)
for child in children:
if child.is_running():
logger.debug(f"Killing child process {child.pid}")
child.kill()
logger.debug(f"Terminating process group {proc.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (psutil.NoSuchProcess, ProcessLookupError) as e:
logger.debug(f"Process {proc.pid} already terminated: {e}")
"""Обработчик закрытия окна: проверяет настройку minimize_to_tray.
Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
"""
minimize_to_tray = read_minimize_to_tray()
self.game_processes = [] # Очищаем список процессов
# Очищаем таймеры
if hasattr(self, 'games_load_timer') and self.games_load_timer is not None and self.games_load_timer.isActive():
self.games_load_timer.stop()
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer is not None and self.settingsDebounceTimer.isActive():
self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer is not None and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None
if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None:
self.wine_monitor_timer.stop()
self.wine_monitor_timer.deleteLater()
self.wine_monitor_timer = None
# Сохраняем настройки окна
if not read_fullscreen_config():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
save_window_geometry(self.width(), self.height())
save_card_size(self.card_width)
event.accept()
else:
# Сворачиваем в трей вместо закрытия
self.hide()
if minimize_to_tray:
# Просто сворачиваем в трей
event.ignore()
self.hide()
return
# Полное закрытие приложения
self.is_exiting = True
event.accept()
# Скрываем и удаляем иконку трея
if hasattr(self, "tray_manager") and self.tray_manager.tray_icon:
self.tray_manager.tray_icon.hide()
self.tray_manager.tray_icon.deleteLater()
# Сохраняем размеры карточек
save_card_size(self.card_width)
save_auto_card_size(self.auto_card_width)
# Сохраняем размеры окна (если не в полноэкранном режиме)
if not read_fullscreen_config():
logger.debug(f"Saving window geometry: {self.width()}x{self.height()}")
save_window_geometry(self.width(), self.height())
# Завершаем все игровые процессы
for proc in getattr(self, "game_processes", []):
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
for child in children:
try:
logger.debug(f"Terminating child process {child.pid}")
child.terminate()
except psutil.NoSuchProcess:
logger.debug(f"Child process {child.pid} already terminated")
psutil.wait_procs(children, timeout=5)
for child in children:
if child.is_running():
logger.debug(f"Killing child process {child.pid}")
child.kill()
logger.debug(f"Terminating process group {proc.pid}")
os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
except (psutil.NoSuchProcess, ProcessLookupError) as e:
logger.debug(f"Process {getattr(proc, 'pid', '?')} already terminated: {e}")
except Exception as e:
logger.warning(f"Failed to terminate process {getattr(proc, 'pid', '?')}: {e}")
self.game_processes = []
# Универсальная остановка и удаление таймеров
timers = [
"games_load_timer",
"settingsDebounceTimer",
"searchDebounceTimer",
"checkProcessTimer",
"wine_monitor_timer",
]
for tname in timers:
timer = getattr(self, tname, None)
if timer and timer.isActive():
timer.stop()
if timer:
timer.deleteLater()
setattr(self, tname, None)

View File

@@ -6,13 +6,16 @@ import urllib.parse
import time
import glob
import re
import hashlib
from collections.abc import Callable
from PySide6.QtCore import QThread, Signal
from portprotonqt.downloader import Downloader
from portprotonqt.logger import get_logger
from portprotonqt.config_utils import get_portproton_location
logger = get_logger(__name__)
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
AUTOINSTALL_CACHE_DURATION = 3600 # 1 hour for autoinstall cache
def normalize_name(s):
"""
@@ -59,6 +62,7 @@ class PortProtonAPI:
self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
self._topics_data = None
self._autoinstall_cache = None # New: In-memory cache
def _get_game_dir(self, exe_name: str) -> str:
game_dir = os.path.join(self.custom_data_dir, exe_name)
@@ -231,67 +235,139 @@ class PortProtonAPI:
logger.error(f"Failed to parse {file_path}: {e}")
return None, None
def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
"""Load auto-install games with user/builtin covers (no async download here)."""
games = []
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else ""
def _compute_scripts_signature(self, auto_dir: str) -> str:
"""Compute a hash-based signature of the autoinstall scripts to detect changes."""
if not os.path.exists(auto_dir):
callback(games)
return
return ""
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
if not scripts:
callback(games)
return
# Simple hash: concatenate sorted filenames and hash
filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
return hashlib.md5(filenames_str.encode()).hexdigest()
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
os.makedirs(base_autoinstall_dir, exist_ok=True)
def _load_autoinstall_cache(self):
"""Load cached autoinstall games if fresh and scripts unchanged."""
if self._autoinstall_cache is not None:
return self._autoinstall_cache
cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
if os.path.exists(cache_file):
try:
mod_time = os.path.getmtime(cache_file)
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
with open(cache_file, "rb") as f:
data = orjson.loads(f.read())
# Check signature
cached_signature = data.get("scripts_signature", "")
current_signature = self._compute_scripts_signature(
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
)
if cached_signature != current_signature:
logger.info("Scripts signature mismatch; invalidating cache")
return None
self._autoinstall_cache = data["games"]
logger.info(f"Loaded {len(self._autoinstall_cache)} cached autoinstall games")
return self._autoinstall_cache
except Exception as e:
logger.error(f"Failed to load autoinstall cache: {e}")
return None
for script_path in scripts:
display_name, exe_name = self.parse_autoinstall_script(script_path)
script_name = os.path.splitext(os.path.basename(script_path))[0]
def _save_autoinstall_cache(self, games):
"""Save parsed autoinstall games to cache with scripts signature."""
try:
cache_dir = get_cache_dir()
cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
scripts_signature = self._compute_scripts_signature(auto_dir)
data = {"games": games, "scripts_signature": scripts_signature, "timestamp": time.time()}
with open(cache_file, "wb") as f:
f.write(orjson.dumps(data))
logger.debug(f"Saved {len(games)} autoinstall games to cache with signature {scripts_signature}")
except Exception as e:
logger.error(f"Failed to save autoinstall cache: {e}")
if not (display_name and exe_name):
continue
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
"""Start loading auto-install games in a background thread. Returns the thread for management."""
# Check cache first (sync, fast)
cached_games = self._load_autoinstall_cache()
if cached_games is not None:
# Emit via callback immediately if cached
QThread.msleep(0) # Yield to Qt event loop
callback(cached_games)
return None # No thread needed
exe_name = os.path.splitext(exe_name)[0] # Без .exe
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
os.makedirs(user_game_folder, exist_ok=True)
# No cache: Start background thread
class AutoinstallWorker(QThread):
finished = Signal(list)
api: "PortProtonAPI"
portproton_location: str | None
# Поиск обложки
cover_path = ""
user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set()
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
candidate = f"cover{ext}"
if candidate in user_files:
cover_path = os.path.join(user_game_folder, candidate)
break
def run(self):
games = []
auto_dir = os.path.join(
self.portproton_location or "", "data", "scripts", "pw_autoinstall"
) if self.portproton_location else ""
if not os.path.exists(auto_dir):
self.finished.emit(games)
return
if not cover_path:
logger.debug(f"No local cover found for autoinstall {exe_name}")
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
if not scripts:
self.finished.emit(games)
return
# Формируем кортеж игры (добавлен exe_name в конец)
game_tuple = (
display_name, # name
"", # description
cover_path, # cover
"", # appid
f"autoinstall:{script_name}", # exec_line
"", # controller_support
"Never", # last_launch
"0h 0m", # formatted_playtime
"", # protondb_tier
"", # anticheat_status
0, # last_played
0, # playtime_seconds
"autoinstall", # game_source
exe_name # exe_name
)
games.append(game_tuple)
xdg_data_home = os.getenv(
"XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"),
)
base_autoinstall_dir = os.path.join(
xdg_data_home, "PortProtonQt", "custom_data", "autoinstall"
)
os.makedirs(base_autoinstall_dir, exist_ok=True)
callback(games)
for script_path in scripts:
display_name, exe_name = self.api.parse_autoinstall_script(script_path)
script_name = os.path.splitext(os.path.basename(script_path))[0]
if not (display_name and exe_name):
continue
exe_name = os.path.splitext(exe_name)[0]
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
os.makedirs(user_game_folder, exist_ok=True)
# Find cover
cover_path = ""
user_files = (
set(os.listdir(user_game_folder))
if os.path.exists(user_game_folder)
else set()
)
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
candidate = f"cover{ext}"
if candidate in user_files:
cover_path = os.path.join(user_game_folder, candidate)
break
if not cover_path:
logger.debug(f"No local cover found for autoinstall {exe_name}")
game_tuple = (
display_name, "", cover_path, "", f"autoinstall:{script_name}",
"", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
)
games.append(game_tuple)
self.api._save_autoinstall_cache(games)
self.api._autoinstall_cache = games
self.finished.emit(games)
worker = AutoinstallWorker()
worker.api = self
worker.portproton_location = self.portproton_location
worker.finished.connect(lambda games: callback(games))
worker.start()
logger.info("Started background load of autoinstall games")
return worker
def _load_topics_data(self):
"""Load and cache linux_gaming_topics_min.json from the archive."""
@@ -340,18 +416,24 @@ class PortProtonAPI:
self._topics_data = []
self.downloader.download_async(self.topics_url, cache_tar, timeout=5, callback=process_tar)
# Wait for async download to complete if called synchronously
while self._topics_data is None:
time.sleep(0.1)
return self._topics_data
# Instead of blocking, return cached data if available, or empty list
# The actual data will be loaded asynchronously for future calls
if hasattr(self, '_topics_data') and self._topics_data is not None:
return self._topics_data
else:
# Return empty list as fallback to prevent blocking
# The topics data will be loaded asynchronously in the background
logger.warning("Returning empty topics data, async loading in progress")
return []
def get_forum_topic_slug(self, game_name: str) -> str:
"""Get the forum topic slug or search URL for a given game name."""
topics = self._load_topics_data()
normalized_name = normalize_name(game_name)
for topic in topics:
if topic["normalized_title"] == normalized_name:
return topic["slug"]
if topics: # Only search if topics were loaded
normalized_name = normalize_name(game_name)
for topic in topics:
if topic["normalized_title"] == normalized_name:
return topic["slug"]
logger.debug("No forum topic found for game: %s, redirecting to search", game_name)
encoded_name = urllib.parse.quote(f"#ppdb {game_name}")
return f"search?q={encoded_name}"

View File

@@ -0,0 +1,208 @@
def get_toggle_settings():
"""Get predefined toggle settings with descriptions."""
from portprotonqt.localization import _
return {
'PW_MANGOHUD': _("Using FPS and system load monitoring (Turns on and off by the key combination - right Shift + F12)"),
'PW_MANGOHUD_USER_CONF': _("Forced use of MANGOHUD system settings (GOverlay, etc.)"),
'PW_VKBASALT': _("Enable vkBasalt by default to improve graphics in games running on Vulkan. (The HOME hotkey disables vkbasalt)"),
'PW_VKBASALT_USER_CONF': _("Forced use of VKBASALT system settings (GOverlay, etc.)"),
'PW_DGVOODOO2': _("Enable dgVoodoo2. Forced use all dgVoodoo2 libs (Glide 2.11-3.1, DirectDraw 1-7, Direct3D 2-9) on all 3D API."),
'PW_GAMESCOPE': _("Super + F : Toggle fullscreen\nSuper + N : Toggle nearest neighbour filtering\nSuper + U : Toggle FSR upscaling\nSuper + Y : Toggle NIS upscaling\nSuper + I : Increase FSR sharpness by 1\nSuper + O : Decrease FSR sharpness by 1\nSuper + S : Take screenshot (currently goes to /tmp/gamescope_DATE.png)\nSuper + G : Toggle keyboard grab\nSuper + C : Update clipboard"),
'PW_USE_ESYNC': _("Enable in-process synchronization primitives based on eventfd."),
'PW_USE_FSYNC': _("Enable futex-based in-process synchronization primitives."),
'PW_USE_NTSYNC': _("Enable in-process synchronization via the Linux ntsync driver."),
'PW_USE_RAY_TRACING': _("Enable vkd3d support - Ray Tracing"),
'PW_USE_NVAPI_AND_DLSS': _("Enable DLSS on supported NVIDIA graphics cards"),
'PW_USE_OPTISCALER': _("Enable OptiScaler (replacement upscaler / frame generator)"),
'PW_USE_LS_FRAME_GEN': _("Enable Lossless Scaling frame generation (experimental)"),
'PW_WINE_FULLSCREEN_FSR': _("FSR upscaling in fullscreen with ProtonGE below native resolution"),
'PW_HIDE_NVIDIA_GPU': _("Disguise all NVIDIA GPU features"),
'PW_VIRTUAL_DESKTOP': _("Run the application in WINE virtual desktop"),
'PW_USE_TERMINAL': _("Run the application in a terminal"),
'PW_GUI_DISABLED_CS': _("Disable startup mode and WINE version selector window"),
'PW_USE_GAMEMODE': _("Use system GameMode for performance optimization"),
'PW_USE_D3D_EXTRAS': _("Enable forced use of third-party DirectX libraries"),
'PW_FIX_VIDEO_IN_GAME': _("Fix pink-tinted video playback in some games"),
'PW_REDUCE_PULSE_LATENCY': _("Reduce PulseAudio latency to fix intermittent sound"),
'PW_USE_US_LAYOUT': _("Force US keyboard layout"),
'PW_USE_GSTREAMER': _("Use GStreamer for in-game clips (WMF support)"),
'PW_USE_SHADER_CACHE': _("Use WINE shader caching"),
'PW_USE_WINE_DXGI': _("Force use of built-in DXGI library"),
'PW_USE_EAC_AND_BE': _("Enable Easy Anti-Cheat and BattlEye runtimes"),
'PW_USE_SYSTEM_VK_LAYERS': _("Use system Vulkan layers (MangoHud, vkBasalt, OBS, etc.)"),
'PW_USE_OBS_VKCAPTURE': _("Enable OBS Studio capture via obs-vkcapture"),
'PW_DISABLE_COMPOSITING': _("Disable desktop compositing for performance"),
'PW_USE_RUNTIME': _("Use container launch mode (recommended default)"),
'PW_DINPUT_PROTOCOL': _("Force DirectInput protocol instead of XInput"),
'PW_USE_NATIVE_WAYLAND': _("Enable experimental native Wayland support"),
'PW_USE_DXVK_HDR': _("Enable HDR settings under native Wayland"),
'PW_USE_GALLIUM_ZINK': _("Use Gallium Zink (OpenGL via Vulkan)"),
'PW_USE_GALLIUM_NINE': _("Use Gallium Nine (native DirectX 9 for Mesa)"),
'PW_USE_WINED3D_VULKAN': _("Use WineD3D Vulkan backend (Damavand)"),
'PW_USE_SUPPLIED_DXVK_VKD3D': _("Use bundled dxvk/vkd3d from Wine/Proton"),
'PW_USE_SAREK_ASYNC': _("Use async dxvk-sarek (experimental)")
}
def get_advanced_settings(disabled_text, logical_core_options, locale_options,
amd_vulkan_drivers, is_amd, numa_nodes, dist_options=None, prefix_options=None):
"""Get advanced settings configuration."""
from portprotonqt.localization import _
advanced_settings = []
if dist_options is None:
dist_options = []
if prefix_options is None:
prefix_options = []
# 1. Wine Version
advanced_settings.append({
'key': 'PW_WINE_USE',
'name': _("Wine Version"),
'description': _("Select the Wine or Proton version to use for this executable."),
'type': 'combo',
'options': dist_options,
'default': ''
})
# 2. Prefix Name
advanced_settings.append({
'key': 'PW_PREFIX_NAME',
'name': _("Prefix Name"),
'description': _("Select the Wine prefix to use."),
'type': 'combo',
'options': prefix_options,
'default': 'DEFAULT'
})
# 3. Vulkan Backend
advanced_settings.append({
'key': 'PW_VULKAN_USE',
'name': _("Vulkan Backend"),
'description': _("Select Vulkan rendering backend:\n0 - WINED3D OpenGL\n1 - DXVK-Sarek and VKD3D\n2 - Stable DXVK and VKD3D\n6 - Newest DXVK and VKD3D"),
'type': 'combo',
'options': [ '0', '1', '2', '6'],
'default': '6'
})
# 4. Windows version
advanced_settings.append({
'key': 'PW_WINDOWS_VER',
'name': _("Windows version"),
'description': _("Changing the WINDOWS emulation version may be required to run older games. WINDOWS versions below 10 do not support new games with DirectX 12"),
'type': 'combo',
'options': ['11', '10', '7', 'XP'],
'default': '10'
})
# 5. DLL Overrides
advanced_settings.append({
'key': 'WINEDLLOVERRIDES',
'name': _("DLL Overrides"),
'description': _("Forced to use/disable the library only for the given application.\n\nA brief instruction:\n* libraries are written WITHOUT the .dll file extension\n* libraries are separated by semicolons - ;\n* library=n - use the WINDOWS (third-party) library\n* library=b - use WINE (built-in) library\n* library=n,b - use WINDOWS library and then WINE\n* library=b,n - use WINE library and then WINDOWS\n* library= - disable the use of this library\n\nExample: libglesv2=;d3dx9_36,d3dx9_42=n,b;mfc120=b,n"),
'type': 'text',
'default': ''
})
# 6. Launch arguments
advanced_settings.append({
'key': 'LAUNCH_PARAMETERS',
'name': _("Launch Arguments"),
'description': _("Adding an argument after the .exe file, just like you would add an argument in a shortcut on a WINDOWS system.\n\nExample: -dx11 -skipintro 1"),
'type': 'text',
'default': ''
})
# 7. CPU cores limit
advanced_settings.append({
'key': 'PW_WINE_CPU_TOPOLOGY',
'name': _("CPU Cores Limit"),
'description': _("Limiting the number of CPU cores is useful for Unity games (It is recommended to set the value equal to 8)"),
'type': 'combo',
'options': [disabled_text] + logical_core_options,
'default': disabled_text
})
# 8. OpenGL version
advanced_settings.append({
'key': 'PW_MESA_GL_VERSION_OVERRIDE',
'name': _("OpenGL Version"),
'description': _("You can select the required OpenGL version, some games require a forced Compatibility Profile (COMP)."),
'type': 'combo',
'options': [disabled_text, '4.6COMPAT', '4.5COMPAT', '4.3COMPAT', '4.1COMPAT', '3.3COMPAT', '3.2COMPAT'],
'default': disabled_text
})
# 9. VKD3D feature level
advanced_settings.append({
'key': 'PW_VKD3D_FEATURE_LEVEL',
'name': _("VKD3D Feature Level"),
'description': _("You can set a forced feature level VKD3D for games on DirectX12"),
'type': 'combo',
'options': [disabled_text, '12_2', '12_1', '12_0', '11_1', '11_0'],
'default': disabled_text
})
# 10. Locale
advanced_settings.append({
'key': 'PW_LOCALE_SELECT',
'name': _("Locale"),
'description': _("Force certain locale for an app. Fixes encoding issues in legacy software"),
'type': 'combo',
'options': [disabled_text] + locale_options,
'default': disabled_text
})
# 11. Present mode
advanced_settings.append({
'key': 'PW_MESA_VK_WSI_PRESENT_MODE',
'name': _("Window Mode"),
'description': _("Window mode (for Vulkan and OpenGL):\nfifo - First in, first out. Limits the frame rate + no tearing. (VSync)\nimmediate - Unlimited frame rate + tearing.\nmailbox - Triple buffering. Unlimited frame rate + no tearing.\nrelaxed - Same as fifo but allows tearing when below the monitors refresh rate."),
'type': 'combo',
'options': [disabled_text, 'fifo', 'immediate', 'mailbox', 'relaxed'],
'default': disabled_text
})
# 12. AMD Vulkan driver
amd_options = [disabled_text] + amd_vulkan_drivers if is_amd and amd_vulkan_drivers else [disabled_text]
advanced_settings.append({
'key': 'PW_AMD_VULKAN_USE',
'name': _("AMD Vulkan Driver"),
'description': _("Select needed AMD vulkan implementation. Choosing which implementation of vulkan will be used to run the game"),
'type': 'combo',
'options': amd_options,
'default': disabled_text
})
# 13. NUMA node
numa_ids = sorted(numa_nodes.keys())
numa_options = [disabled_text] + numa_ids if len(numa_ids) > 1 else [disabled_text]
advanced_settings.append({
'key': 'PW_CPU_NUMA_NODE_INDEX',
'name': _("NUMA Node"),
'description': _("NUMA node for CPU affinity. In multi-core systems, CPUs are split into NUMA nodes, each with its own local memory and cores. Binding a game to a single node reduces memory-access latency and limits costly core-to-core switches."),
'type': 'combo',
'options': numa_options,
'default': disabled_text
})
return advanced_settings
# Keys that should be recognized as advanced settings
ADVANCED_SETTING_KEYS = [
'PW_WINE_USE',
'PW_PREFIX_NAME',
'PW_VULKAN_USE',
'PW_WINDOWS_VER',
'WINEDLLOVERRIDES',
'LAUNCH_PARAMETERS',
'PW_WINE_CPU_TOPOLOGY',
'PW_MESA_GL_VERSION_OVERRIDE',
'PW_VKD3D_FEATURE_LEVEL',
'PW_LOCALE_SELECT',
'PW_MESA_VK_WSI_PRESENT_MODE',
'PW_AMD_VULKAN_USE',
'PW_CPU_NUMA_NODE_INDEX',
]

View File

@@ -13,7 +13,7 @@ from portprotonqt.logger import get_logger
from portprotonqt.localization import get_steam_language
from portprotonqt.downloader import Downloader
from portprotonqt.dialogs import generate_thumbnail
from portprotonqt.config_utils import get_portproton_location
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
from collections.abc import Callable
import re
import shutil
@@ -23,6 +23,7 @@ import requests
import random
import base64
import glob
import urllib.parse
downloader = Downloader()
logger = get_logger(__name__)
@@ -411,6 +412,39 @@ def save_app_details(app_id, data):
with open(cache_file, "wb") as f:
f.write(orjson.dumps(data))
def fetch_sgdb_cover(game_name: str) -> str:
"""
Fetch a cover image URL from steamgrid.usebottles.com for the given game.
The API returns a single string (quoted URL).
"""
try:
encoded = urllib.parse.quote(game_name)
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
resp = requests.get(url, timeout=5)
if resp.status_code != 200:
logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
return ""
text = resp.text.strip()
# Убираем возможные кавычки вокруг строки
if text.startswith('"') and text.endswith('"'):
text = text[1:-1]
if text:
logger.info("Fetched SGDB cover for %s: %s", game_name, text)
return text
except Exception as e:
logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
return ""
def check_url_exists(url: str) -> bool:
"""Check whether a URL returns HTTP 200."""
try:
r = requests.head(url, timeout=3)
return r.status_code == 200
except Exception:
return False
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
"""
Asynchronously fetches detailed app info from Steam API.
@@ -629,6 +663,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None])
title = decode_text(app_info.get("name", ""))
description = decode_text(app_info.get("short_description", ""))
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
if not check_url_exists(cover):
logger.info("Steam cover not found for %s, trying SGDB", title)
alt_cover = fetch_sgdb_cover(title)
if alt_cover:
cover = alt_cover
def on_protondb_tier(tier: str):
def on_anticheat_status(anticheat_status: str):
@@ -722,12 +761,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
game_name = desktop_name or exe_name.capitalize()
if not matching_app:
cover = fetch_sgdb_cover(game_name) or ""
logger.info("Using SGDB cover for non-Steam game '%s': %s", game_name, cover)
def on_anticheat_status(anticheat_status: str):
callback({
"appid": "",
"name": decode_text(game_name),
"description": "",
"cover": "",
"cover": cover,
"controller_support": "",
"protondb_tier": "",
"steam_game": "false",
@@ -758,6 +800,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
title = decode_text(app_info.get("name", game_name))
description = decode_text(app_info.get("short_description", ""))
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
if not check_url_exists(cover):
logger.info("Steam cover not found for %s, trying SGDB", title)
alt_cover = fetch_sgdb_cover(title)
if alt_cover:
cover = alt_cover
controller_support = app_info.get("controller_support", "")
def on_protondb_tier(tier: str):
@@ -957,7 +1004,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
return (False, f"Executable file not found: {exe_path}")
portproton_dir = get_portproton_location()
if not portproton_dir:
start_sh = get_portproton_start_command()
if not portproton_dir or not start_sh:
logger.error("PortProton directory not found")
return (False, "PortProton directory not found")
@@ -966,17 +1014,12 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
if not os.path.exists(start_sh_path):
logger.error(f"start.sh not found at {start_sh_path}")
return (False, f"start.sh not found at {start_sh_path}")
if not os.path.exists(script_path):
script_content = f"""#!/usr/bin/env bash
export LD_PRELOAD=
export START_FROM_STEAM=1
"{start_sh_path}" "{exe_path}" "$@"
"{start_sh}" "{exe_path}" "$@"
"""
try:
with open(script_path, "w", encoding="utf-8") as f:

View File

@@ -5,6 +5,9 @@ from portprotonqt.logger import get_logger
from PySide6.QtGui import QIcon, QFontDatabase, QPixmap
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
# Icon caching for performance optimization
_icon_cache = {}
logger = get_logger(__name__)
# Папка, где располагаются все дополнительные темы
@@ -232,6 +235,14 @@ class ThemeManager:
а если файл не найден, то из стандартной темы.
Если as_path=True, возвращает путь к иконке вместо QIcon.
"""
# Create cache key
cache_key = f"{icon_name}_{theme_name or self.current_theme_name}_{as_path}"
# Check if we already have this icon cached
if cache_key in _icon_cache:
logger.debug(f"Using cached icon for {icon_name}")
return _icon_cache[cache_key]
icon_path = None
theme_name = theme_name or self.current_theme_name
supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
@@ -279,12 +290,20 @@ class ThemeManager:
# Если иконка всё равно не найдена
if not icon_path or not os.path.exists(icon_path):
logger.error(f"Warning: icon '{icon_name}' not found")
return QIcon() if not as_path else None
result = QIcon() if not as_path else None
# Cache the result even if it's None
_icon_cache[cache_key] = result
return result
if as_path:
# Cache the path
_icon_cache[cache_key] = icon_path
return icon_path
return QIcon(icon_path)
# Create QIcon and cache it
icon = QIcon(icon_path)
_icon_cache[cache_key] = icon
return icon
def get_theme_image(self, image_name, theme_name=None):
"""

View File

@@ -0,0 +1 @@
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0005 1c-0.38761 0-0.77522 0.0327-1.1588 0.0979-0.16351 0.0281-0.30273 0.13627-0.37209 0.28935l-0.39088 0.86264c-0.49378 0.16682-0.96454 0.39759-1.4007 0.68616 2.5e-4 0-0.90672-0.2272-0.90672-0.2272-0.161-0.0403-0.33098 3e-3 -0.45442 0.11569-0.57867 0.5285-1.0672 1.1514-1.4451 1.8432-0.0804 0.14721-0.0841 0.32549-0.01 0.47628l0.41938 0.84865c-0.17954 0.49666-0.29567 1.0147-0.346 1.5417l-0.73995 0.57946c-0.13121 0.10289-0.20407 0.26514-0.19431 0.4335 0.0453 0.78981 0.21961 1.5666 0.51558 2.2983 0.0631 0.15587 0.1978 0.27003 0.36005 0.30467l0.91397 0.19559c0.26993 0.45234 0.59572 0.86802 0.96931 1.2363l-0.0161 0.94973c-3e-3 0.16861 0.0766 0.32755 0.21183 0.42484 0.63551 0.45642 1.3414 0.80207 2.0884 1.0229 0.15926 0.0471 0.33077 0.0109 0.45872-0.0963l0.72016-0.60485c0.51582 0.0674 1.0384 0.0674 1.5544 0l0.72016 0.60485c0.12796 0.10722 0.29946 0.14343 0.45872 0.0963 0.74693-0.22083 1.4528-0.56648 2.0883-1.0229 0.13521-0.0973 0.21465-0.25623 0.21189-0.42484l-0.0161-0.94973c0.37359-0.36829 0.69939-0.78372 0.96932-1.2363l0.91396-0.19559c0.16226-0.0347 0.29695-0.1488 0.36005-0.30467 0.29597-0.73174 0.47026-1.5085 0.51558-2.2983 0.01-0.16836-0.0631-0.33061-0.1943-0.4335l-0.73996-0.57946c-0.0501-0.52671-0.16652-1.045-0.34606-1.5417l0.41944-0.84865c0.0746-0.15079 0.0709-0.32907-0.01-0.47628-0.37785-0.69176-0.86638-1.3147-1.445-1.8432-0.12345-0.11258-0.29343-0.15594-0.45443-0.11569l-0.90697 0.2272c-0.43594-0.28857-0.9067-0.51908-1.4005-0.68616l-0.39088-0.86264c-0.0694-0.15308-0.20858-0.26132-0.37209-0.28935-0.38361-0.0653-0.77121-0.0979-1.1588-0.0979zm0 4.1365a2.8152 2.8635 0 0 1 2.8152 2.8636 2.8152 2.8635 0 0 1-2.8152 2.8635 2.8152 2.8635 0 0 1-2.8152-2.8635 2.8152 2.8635 0 0 1 2.8152-2.8636z" fill="#fff" stroke-width=".25254"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -968,9 +968,8 @@ SETTINGS_CHECKBOX_STYLE = f"""
WINETRICKS_TAB_STYLE = f"""
QTabWidget::pane {{
border: 1px solid {color_d};
background: {color_b};
border-radius: {border_radius_a};
border-top: 1px solid {color_c};
background: {color_h};
}}
QTabBar::tab {{
background: {color_c};
@@ -985,15 +984,113 @@ QTabBar::tab:selected {{
color: {color_f};
}}
QTabBar::tab:hover {{
background: {color_e};
background: {color_a};
}}
"""
WINETRICKS_TABBLE_STYLE = f"""
QTableWidget {{
QComboBox {{
background: {color_c};
border: {border_c} {color_g};
border-radius: {border_radius_a};
padding-left: 12px;
color: {color_f};
gridline-color: {color_d};
font-family: '{font_family}';
font-size: {font_size_a};
min-width: 120px;
combobox-popup: 0;
}}
QComboBox:on {{
background: {color_b};
border: {border_c} {color_a};
border-bottom-style: none;
border-top-left-radius: 10px;
border-top-right-radius: 10px;
border-bottom-left-radius: 0px;
border-bottom-right-radius: 0px;
}}
QComboBox:hover {{
border: {border_c} {color_a};
background: {color_a};
}}
/* Состояние фокуса */
QComboBox:focus {{
border: {border_c} {color_a};
background-color: {color_a};
}}
QComboBox::drop-down {{
subcontrol-origin: padding;
subcontrol-position: center right;
border-left: {border_b} rgba(255, 255, 255, 0.05);
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow {{
image: url({theme_manager.get_icon("down", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
QComboBox::down-arrow:on {{
image: url({theme_manager.get_icon("up", current_theme_name, as_path=True)});
padding: 12px;
height: 12px;
width: 12px;
}}
/* Список при открытом комбобоксе */
QComboBox QAbstractItemView {{
outline: none;
background: {color_c};
border: {border_c} {color_a};
border-top-style: none;
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
}}
QListView {{
background: {color_c};
}}
QListView::item {{
padding: 7px 7px 7px 12px;
margin: 3px;
border-radius: {border_radius_a};
color: {color_f};
}}
QListView::item:hover {{
background: {color_b};
}}
QListView::item:selected {{
background: {color_b};
}}
/* Выделение в списке при фокусе на элементе */
QListView::item:focus {{
background: {color_a};
color: {color_f};
}}
QLineEdit {{
background: {color_c};
border: {border_c} rgba(255, 255, 255, 0.01);
border-radius: {border_radius_a};
height: 34px;
padding-left: 12px;
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QLineEdit:hover {{
background: {color_c};
border: {border_c} {color_a};
}}
QLineEdit:focus {{
border: {border_c} {color_a};
background-color: {color_e};
}}
QTableWidget {{
background: {color_h};
color: {color_f};
gridline-color: {color_h};
alternate-background-color: {color_d};
border: {border_a};
border-radius: {border_radius_a};
@@ -1009,39 +1106,68 @@ QHeaderView::section {{
}}
QTableWidget::item {{
padding: 8px;
border-bottom: 1px solid {color_d};
border-bottom: {border_a } {color_c};
height: 36px;
}}
QTableWidget::item:selected {{
QTableWidget::item:selected,
QTableWidget::item:focus,
QTableWidget::item:selected:focus {{
background: {color_a};
color: {color_f};
selection-background-color: {color_a};
}}
QTableWidget::item:hover {{
background: {color_e};
background: {color_h};
}}
QTableWidget::indicator {{
width: 24px;
height: 24px;
border: {border_b} {color_a};
border: {border_c} {color_h};
border-radius: {border_radius_a};
background: rgba(255, 255, 255, 0.1);
background: {color_b};
}}
QTableWidget::indicator:unchecked {{
background: rgba(255, 255, 255, 0.1);
image: none;
}}
QTableWidget::indicator:checked {{
background: {color_a};
background: {color_b};
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
border: {border_b} {color_f};
border: {border_c} {color_a};
}}
QTableWidget::indicator:hover {{
background: rgba(255, 255, 255, 0.2);
border: {border_b} {color_a};
}}
QTableWidget::indicator:focus {{
border: {border_c} {color_a};
}}
{SCROLL_AREA_STYLE}
QTableWidget::indicator:focus {{
background: rgba(255, 255, 255, 0.2);
border: {border_c} {color_a};
}}
QScrollBar:vertical {{
width: 10px;
border: {border_a};
border-radius: 5px;
background: rgba(20, 20, 20, 0.30);
}}
QScrollBar::handle:vertical {{
background: #bebebe;
border: {border_a};
border-radius: 5px;
}}
QScrollBar::add-line:vertical {{
border: {border_a};
background: none;
}}
QScrollBar::sub-line:vertical {{
border: {border_a};
background: none;
}}
QScrollBar::up-arrow:vertical, QScrollBar::down-arrow:vertical {{
border: {border_a};
width: 3px;
height: 3px;
background: none;
}}
"""
WINETRICKS_LOG_STYLE = f"""

View File

@@ -1,7 +1,8 @@
from typing import cast
from typing import cast, Any
from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
QSizePolicy, QWidget, QLineEdit)
from PySide6.QtCore import Qt, Signal, QProcess
from PySide6.QtCore import Qt, Signal, QProcess, QSize
from PySide6.QtGui import QPixmap, QIcon
from portprotonqt.keyboard_layouts import keyboard_layouts
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
@@ -43,6 +44,18 @@ class VirtualKeyboard(QFrame):
self.margins = 10
self.num_cols = 14
# Find input_manager and main_window
self.input_manager: Any = None
self.main_window: Any = None
parent_widget: QWidget | None = self._parent
while parent_widget:
if hasattr(parent_widget, 'input_manager'):
self.input_manager = cast(Any, parent_widget).input_manager
self.main_window = cast(Any, parent_widget)
parent_widget = cast(QWidget | None, parent_widget.parent())
self.current_theme_name = read_theme_from_config()
self.initUI()
self.hide()
@@ -78,18 +91,41 @@ class VirtualKeyboard(QFrame):
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def run_shell_command(self, cmd: str) -> str | None:
"""Synchronous shell command execution - kept for backward compatibility"""
process = QProcess(self)
process.start("sh", ["-c", cmd])
process.waitForFinished(-1)
if process.exitCode() == 0:
output_bytes = process.readAllStandardOutput().data()
if isinstance(output_bytes, memoryview):
output_str = output_bytes.tobytes().decode('utf-8').strip()
# Use a reasonable timeout instead of indefinite wait
if process.waitForFinished(5000): # 5 second timeout
if process.exitCode() == 0:
output_bytes = process.readAllStandardOutput().data()
if isinstance(output_bytes, memoryview):
output_str = output_bytes.tobytes().decode('utf-8').strip()
else:
output_str = output_bytes.decode('utf-8').strip()
return output_str
return None
def run_shell_command_async(self, cmd: str, callback=None):
"""
Run a shell command asynchronously and optionally call a callback with the result.
"""
def on_process_finished(exit_code, exit_status):
if exit_code == 0:
output_bytes = process.readAllStandardOutput().data()
if isinstance(output_bytes, memoryview):
output_str = output_bytes.tobytes().decode('utf-8').strip()
else:
output_str = output_bytes.decode('utf-8').strip()
if callback:
callback(output_str)
else:
output_str = output_bytes.decode('utf-8').strip()
return output_str
else:
return None
if callback:
callback(None)
process = QProcess(self)
# Connect the finished signal to handle the result
process.finished.connect(on_process_finished)
process.start("sh", ["-c", cmd])
def get_layouts_setxkbmap(self) -> list[str]:
"""Получаем раскладки, которые используются в системе, возвращаем список вида ['us', 'ru'] и т.п."""
@@ -119,6 +155,34 @@ class VirtualKeyboard(QFrame):
self.buttons: dict[str, QPushButton] = {}
self.update_keyboard()
def set_gamepad_icon(self, button, icon_type, gtype=''):
"""Set gamepad icon on button based on type"""
if icon_type in ['back', 'add_game']:
icon_name = self.main_window.get_button_icon(icon_type, gtype)
else: # nav left/right
if icon_type in ['left', 'right']:
direction = icon_type
icon_name = self.main_window.get_nav_icon(direction, gtype)
else:
direction = 'left' if icon_type == 'left' else 'right'
icon_name = self.main_window.get_nav_icon(direction, gtype)
icon_path = theme_manager.get_theme_image(icon_name, self.current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
button.setIcon(QIcon(pixmap))
button.setIconSize(QSize(20, 20))
return
else:
# Fallback to placeholder
placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
button.setIcon(QIcon(placeholder))
button.setIconSize(QSize(20, 20))
return
def update_keyboard(self):
coords = self._save_focused_coords()
@@ -151,6 +215,9 @@ class VirtualKeyboard(QFrame):
button.setCheckable(True)
button.setChecked(self.shift_pressed)
button.clicked.connect(lambda checked: self.on_shift_click(checked))
# Add gamepad icon for Shift (RB/R)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(button, 'right', gtype)
else:
button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
@@ -163,6 +230,9 @@ class VirtualKeyboard(QFrame):
shift.setCheckable(True)
shift.setChecked(self.shift_pressed)
shift.clicked.connect(lambda checked: self.on_shift_click(checked))
# Add gamepad icon for Shift (RB/R)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(shift, 'right', gtype)
self.keyboard_layout.addWidget(shift, 3, 11, 1, 3)
button = QPushButton('CAPS')
@@ -179,6 +249,9 @@ class VirtualKeyboard(QFrame):
backspace.setFixedSize(fixed_w, fixed_h)
backspace.pressed.connect(self.on_backspace_pressed)
backspace.released.connect(self.stop_backspace_repeat)
# Add gamepad icon for Backspace (X/Triangle)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(backspace, 'add_game', gtype)
self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1)
enter = QPushButton('Enter')
@@ -189,6 +262,9 @@ class VirtualKeyboard(QFrame):
lang = QPushButton('🌐')
lang.setFixedSize(fixed_w, fixed_h)
lang.clicked.connect(self.on_lang_click)
# Add gamepad icon for Lang (LB/L)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(lang, 'left', gtype)
self.keyboard_layout.addWidget(lang, 4, 0, 1, 1)
clear = QPushButton('Clear')
@@ -219,6 +295,9 @@ class VirtualKeyboard(QFrame):
hide_button = QPushButton('Hide')
hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
hide_button.clicked.connect(self.hide)
# Add gamepad icon for Hide (B/Circle)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(hide_button, 'back', gtype)
self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2)
if coords:

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portprotonqt"
version = "0.1.7"
version = "0.1.8"
description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md"
license = { text = "GPL-3.0" }
@@ -32,7 +32,7 @@ dependencies = [
"icoextract>=0.2.0",
"numpy>=2.2.4",
"orjson>=3.11.3",
"pillow>=11.3.0",
"pillow>=12.0.0",
"psutil>=7.1.0",
"pyside6==6.9.1",
"pyudev>=0.24.3",

View File

@@ -8,7 +8,7 @@
"enabled": true
},
"pre-commit": {
"enabled": true
"enabled": false
},
"packageRules": [
{

978
uv.lock generated

File diff suppressed because it is too large Load Diff