60 Commits

Author SHA1 Message Date
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
64 changed files with 16275 additions and 2411 deletions

View File

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

View File

@@ -8,7 +8,7 @@ on:
env: env:
# Common version, will be used for tagging the release # Common version, will be used for tagging the release
VERSION: 0.1.7 VERSION: 0.1.8
PKGDEST: "/tmp/portprotonqt" PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt" PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -119,7 +119,7 @@ jobs:
- name: Install build dependencies - name: Install build dependencies
run: | run: |
dnf install -y git rpmdevtools python3-devel python3-wheel python3-pip \ 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 redhat-rpm-config nodejs npm
- name: Setup rpmbuild environment - name: Setup rpmbuild environment
@@ -188,4 +188,4 @@ jobs:
tag_name: v${{ env.VERSION }} tag_name: v${{ env.VERSION }}
prerelease: true prerelease: true
files: release/**/* files: release/**/*
sha256sum: true sha256sum: false

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,11 +6,12 @@ script:
- uv pip install --no-cache-dir ../ - uv pip install --no-cache-dir ../
- cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages - cp -r .venv/lib/python3.10/site-packages/* AppDir/usr/local/lib/python3.10/dist-packages
- cp -r share AppDir/usr - cp -r share AppDir/usr
- cp -r lib AppDir/usr
- rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/ - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/qml/
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{assistant,designer,linguist,lrelease,lupdate}
- rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,QtNetwork*,QtNetworkAuth*,QtNfc*,QtOpenGL*,QtOpenGLWidgets*,QtPdf*,QtPdfWidgets*,QtPositioning*,QtPrintSupport*,QtQml*,QtQuick*,QtQuick3D*,QtQuickControls2*,QtQuickTest*,QtQuickWidgets*,QtRemoteObjects*,QtScxml*,QtSensors*,QtSerialBus*,QtSerialPort*,QtSpatialAudio*,QtSql*,QtStateMachine*,QtSvgWidgets*,QtTest*,QtTextToSpeech*,QtUiTools*,QtWebChannel*,QtWebEngineCore*,QtWebEngineQuick*,QtWebEngineWidgets*,QtWebSockets*,QtWebView*,QtXml*} - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{Qt3DAnimation*,Qt3DCore*,Qt3DExtras*,Qt3DInput*,Qt3DLogic*,Qt3DRender*,QtBluetooth*,QtCharts*,QtConcurrent*,QtDataVisualization*,QtDesigner*,QtExampleIcons*,QtGraphs*,QtGraphsWidgets*,QtHelp*,QtHttpServer*,QtLocation*,QtMultimedia*,QtMultimediaWidgets*,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 - 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: AppDir:
path: ./AppDir path: ./AppDir
after_bundle: after_bundle:
@@ -36,7 +37,7 @@ AppDir:
id: ru.linux_gaming.PortProtonQt id: ru.linux_gaming.PortProtonQt
name: PortProtonQt name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt icon: ru.linux_gaming.PortProtonQt
version: 0.1.7 version: 0.1.8
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@" exec_args: "-m portprotonqt.app $@"
apt: apt:

View File

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

View File

@@ -25,4 +25,5 @@ package() {
cd "$srcdir/PortProtonQt" cd "$srcdir/PortProtonQt"
python -m installer --destdir="$pkgdir" dist/*.whl python -m installer --destdir="$pkgdir" dist/*.whl
cp -r build-aux/share "$pkgdir/usr/" 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: pyproject-rpm-macros
BuildRequires: python3dist(setuptools) BuildRequires: python3dist(setuptools)
BuildRequires: git BuildRequires: git
BuildRequires: systemd-rpm-macros
%description %description
%{summary} %{summary}
@@ -69,11 +70,13 @@ cd %{oname}
%pyproject_install %pyproject_install
%pyproject_save_files %{pypi_name} %pyproject_save_files %{pypi_name}
cp -r build-aux/share %{buildroot}/usr/ cp -r build-aux/share %{buildroot}/usr/
cp -r build-aux/lib %{buildroot}/usr/
%files -n python3-%{pypi_name}-git -f %{pyproject_files} %files -n python3-%{pypi_name}-git -f %{pyproject_files}
%{_bindir}/%{pypi_name} %{_bindir}/%{pypi_name}
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg %{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml %{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
%{_udevrulesdir}/60-portprotonqt.rules
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop %{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%{bash_completions_dir}/portprotonqt %{bash_completions_dir}/portprotonqt

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt %global pypi_name portprotonqt
%global pypi_version 0.1.7 %global pypi_version 0.1.8
%global oname PortProtonQt %global oname PortProtonQt
%global _python_no_extras_requires 1 %global _python_no_extras_requires 1
@@ -19,6 +19,7 @@ BuildRequires: python3-build
BuildRequires: pyproject-rpm-macros BuildRequires: pyproject-rpm-macros
BuildRequires: python3dist(setuptools) BuildRequires: python3dist(setuptools)
BuildRequires: git BuildRequires: git
BuildRequires: systemd-rpm-macros
%description %description
%{summary} %{summary}
@@ -68,11 +69,13 @@ cd %{oname}
%pyproject_install %pyproject_install
%pyproject_save_files %{pypi_name} %pyproject_save_files %{pypi_name}
cp -r build-aux/share %{buildroot}/usr/ cp -r build-aux/share %{buildroot}/usr/
cp -r build-aux/lib %{buildroot}/usr/
%files -n python3-%{pypi_name} -f %{pyproject_files} %files -n python3-%{pypi_name} -f %{pyproject_files}
%{_bindir}/%{pypi_name} %{_bindir}/%{pypi_name}
%{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg %{_datadir}/icons/hicolor/scalable/apps/ru.linux_gaming.PortProtonQt.svg
%{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml %{_metainfodir}/ru.linux_gaming.PortProtonQt.metainfo.xml
%{_udevrulesdir}/60-portprotonqt.rules
%{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop %{_datadir}/applications/ru.linux_gaming.PortProtonQt.desktop
%{bash_completions_dir}/portprotonqt %{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", "normalized_name": "farlight 84",
"status": "Supported" "status": "Denied"
}, },
{ {
"normalized_name": "riders republic", "normalized_name": "riders republic",
@@ -1436,8 +1436,8 @@
"status": "Broken" "status": "Broken"
}, },
{ {
"normalized_name": "blue protocol", "normalized_name": "blue protocol star resonance",
"status": "Broken" "status": "Running"
}, },
{ {
"normalized_name": "dark and darker", "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", "normalized_title": "dirt rally 2.0 game of the year",
"slug": "dirt-rally-2-0-game-of-the-year-edition" "slug": "dirt-rally-2-0-game-of-the-year-edition"
@@ -271,10 +375,6 @@
"normalized_title": "steins;gate the distant valhalla", "normalized_title": "steins;gate the distant valhalla",
"slug": "steins-gate-the-distant-valhalla" "slug": "steins-gate-the-distant-valhalla"
}, },
{
"normalized_title": "hogwarts legacy",
"slug": "hogwarts-legacy"
},
{ {
"normalized_title": "osu!", "normalized_title": "osu!",
"slug": "osu" "slug": "osu"

Binary file not shown.

View File

@@ -17,17 +17,31 @@ import json
class PySide6DependencyAnalyzer: class PySide6DependencyAnalyzer:
def __init__(self): def __init__(self, project_root: Path = None):
# Системные библиотеки, которые нужно всегда оставлять # Системные библиотеки, которые нужно всегда оставлять
self.system_libs = { self.system_libs = {
'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl', 'libQt6XcbQpa', 'libQt6Wayland', 'libQt6Egl',
'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus' 'libicudata', 'libicuuc', 'libicui18n', 'libQt6DBus',
'libQt6Svg'
}
self.critical_modules = {
'QtSvg',
} }
self.real_dependencies = {} self.real_dependencies = {}
self.used_modules_code = set() self.used_modules_code = set()
self.used_modules_ldd = set() self.used_modules_ldd = set()
self.all_required_modules = 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]: def find_python_files(self, directory: Path) -> List[Path]:
"""Находит все Python файлы в директории""" """Находит все Python файлы в директории"""
@@ -44,24 +58,61 @@ class PySide6DependencyAnalyzer:
"""Находит все PySide6 библиотеки (.so файлы)""" """Находит все PySide6 библиотеки (.so файлы)"""
libs = {} libs = {}
# Поиск в единственной локации # Ищем venv в корне проекта
search_path = Path("../.venv/lib/python3.10/site-packages/PySide6") venv_candidates = [
print(f"Поиск PySide6 библиотек в: {search_path}") self.venv_path, # .venv
self.project_root / "venv",
self.project_root / ".virtualenv",
]
if search_path.exists(): pyside6_path = None
# Ищем .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 в venv
for subdir in search_path.iterdir(): for venv in venv_candidates:
if subdir.is_dir() and subdir.name.startswith('Qt'): if venv.exists():
for so_file in subdir.glob("*.so*"): # Ищем Python версию
if 'Qt' in so_file.name: lib_path = venv / "lib"
libs[subdir.name] = so_file if lib_path.exists():
break 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 return libs
@@ -257,8 +308,10 @@ class PySide6DependencyAnalyzer:
# Модули для удаления # Модули для удаления
if removable_modules: if removable_modules:
modules_list = ','.join([f"{mod}*" for mod in sorted(removable_modules)]) removable_filtered = [m for m in removable_modules if m not in self.critical_modules]
cleanup_lines.append(f" - rm -f AppDir/usr/local/lib/python3.10/dist-packages/PySide6/{{{modules_list}}}") 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() 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})" f" - rm -rf AppDir/usr/local/lib/python3.10/dist-packages/PySide6/Qt/lib/!({keep_pattern})"
]) ])
# Заменяем блок очистки в рецепте
import re import re
# Ищем блок "# 5) чистим от ненужных модулей и бинарников" до следующего комментария или до AppDir: # Ищем весь блок команд очистки PySide6 (от первой rm до AppDir:)
pattern = r'( # 5\) чистим от ненужных модулей и бинарников\n).*?(?=\nAppDir:|\n # [0-9]+\)|$)' # Паттерн: после " - 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 return updated_recipe
def main(): def main():
parser = argparse.ArgumentParser(description='Анализ зависимостей PySide6 модулей с использованием ldd') 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('--appdir', help='Путь к AppDir для поиска PySide6 библиотек')
parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)') parser.add_argument('--output', '-o', help='Путь для сохранения результатов (JSON)')
parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод') parser.add_argument('--verbose', '-v', action='store_true', help='Подробный вывод')
parser.add_argument('--venv', help='Путь к виртуальному окружению (по умолчанию: .venv в корне проекта)')
args = parser.parse_args() args = parser.parse_args()
project_path = Path(args.project_path) project_path = Path(args.project_path).resolve()
if not project_path.exists(): if not project_path.exists():
print(f"Ошибка: путь {project_path} не существует") print(f"Ошибка: путь {project_path} не существует")
sys.exit(1) 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(): if appdir_path and not appdir_path.exists():
print(f"Предупреждение: AppDir путь {appdir_path} не существует") print(f"Предупреждение: AppDir путь {appdir_path} не существует")
appdir_path = None 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) results = analyzer.analyze_project(project_path, appdir_path)
# Сохраняем в анализатор для генерации команд # Сохраняем в анализатор для генерации команд
@@ -347,13 +443,13 @@ def main():
print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}") print(f"\nПотенциальная экономия места: {results['analysis_summary']['space_saving_potential']}")
if args.verbose and results['real_dependencies']: if args.verbose and results['real_dependencies']:
Devlin(f"\nРеальные зависимости (ldd):") print(f"\nРеальные зависимости (ldd):")
for module, deps in results['real_dependencies'].items(): for module, deps in results['real_dependencies'].items():
if deps: if deps:
print(f" {module}{', '.join(deps)}") print(f" {module}{', '.join(deps)}")
# Обновляем AppImage рецепт # Обновляем AppImage рецепт
recipe_path = Path("../build-aux/AppImageBuilder.yml") recipe_path = analyzer.build_path / "AppImageBuilder.yml"
if recipe_path.exists(): if recipe_path.exists():
updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path) updated_recipe = analyzer.generate_appimage_recipe(results['removable'], recipe_path)
if updated_recipe: if updated_recipe:

View File

@@ -21,9 +21,9 @@ Current translation status:
| Locale | Progress | Translated | | Locale | Progress | Translated |
| :----- | -------: | ---------: | | :----- | -------: | ---------: |
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 241 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 323 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 241 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 323 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 241 of 241 | | [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 из 241 | | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 323 |
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 241 | | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 323 |
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 241 из 241 | | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 323 из 323 |
--- ---

View File

@@ -1,40 +1,45 @@
import sys import sys
import os import os
import subprocess 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.QtWidgets import QApplication
from PySide6.QtGui import QIcon from PySide6.QtGui import QIcon
from PySide6.QtNetwork import QLocalServer, QLocalSocket
from portprotonqt.main_window import MainWindow from portprotonqt.main_window import MainWindow
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location from portprotonqt.config_utils import (
save_fullscreen_config,
read_fullscreen_config,
get_portproton_start_command
)
from portprotonqt.logger import get_logger, setup_logger from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.cli import parse_args from portprotonqt.cli import parse_args
__app_id__ = "ru.linux_gaming.PortProtonQt" __app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt" __app_name__ = "PortProtonQt"
__app_version__ = "0.1.7" __app_version__ = "0.1.8"
def get_version(): def get_version():
try: try:
commit = subprocess.check_output( commit = subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'], ["git", "rev-parse", "--short", "HEAD"],
stderr=subprocess.DEVNULL stderr=subprocess.DEVNULL,
).decode('utf-8').strip() ).decode("utf-8").strip()
return f"{__app_version__} ({commit})" return f"{__app_version__} ({commit})"
except (subprocess.CalledProcessError, FileNotFoundError, OSError): except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return __app_version__ return __app_version__
def main(): def main():
os.environ['PW_CLI'] = '1' os.environ["PW_CLI"] = "1"
os.environ['PROCESS_LOG'] = '1' os.environ["PROCESS_LOG"] = "1"
os.environ['START_FROM_STEAM'] = '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 return
script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh') subprocess.run(start_sh + ["cli", "--initial"])
subprocess.run([script_path, 'cli', '--initial'])
app = QApplication(sys.argv) app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__)) app.setWindowIcon(QIcon.fromTheme(__app_id__))
@@ -43,41 +48,116 @@ def main():
app.setApplicationVersion(__app_version__) app.setApplicationVersion(__app_version__)
args = parse_args() args = parse_args()
# Setup logger with specified debug level
setup_logger(args.debug_level) setup_logger(args.debug_level)
# Reinitialize logger after setup to ensure it uses the new configuration
logger = get_logger(__name__) 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() system_locale = QLocale.system()
qt_translator = QTranslator() qt_translator = QTranslator()
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath) translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
if qt_translator.load(system_locale, "qtbase", "_", translations_path): if qt_translator.load(system_locale, "qtbase", "_", translations_path):
app.installTranslator(qt_translator) app.installTranslator(qt_translator)
else: else:
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language") logger.warning(
f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
)
# --- Main Window ---
version = get_version() version = get_version()
window = MainWindow(app_name=__app_name__, version=version) window = MainWindow(app_name=__app_name__, version=version)
if args.fullscreen: # --- Handle incoming connections ---
logger.info("Launching in fullscreen mode due to --fullscreen flag") 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) save_fullscreen_config(True)
window.showFullScreen() window.showFullScreen()
else:
logger.info("Launching in normal mode")
save_fullscreen_config(False)
window.showNormal()
# --- Cleanup ---
def cleanup_on_exit(): def cleanup_on_exit():
nonlocal window try:
app.aboutToQuit.disconnect() local_server.close()
if window: QLocalServer.removeServer(server_name)
window.close() if window:
app.quit() window.close()
except Exception as e:
logger.warning(f"Cleanup error: {e}")
app.aboutToQuit.connect(cleanup_on_exit) app.aboutToQuit.connect(cleanup_on_exit)
window.show()
sys.exit(app.exec()) sys.exit(app.exec())
if __name__ == '__main__': if __name__ == "__main__":
main() main()

View File

@@ -1,11 +1,17 @@
import os import os
import configparser import configparser
import shutil import shutil
import subprocess
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
_portproton_location = None _portproton_location = None
_portproton_start_sh = None
# Configuration cache for performance optimization
_config_cache = {}
_config_last_modified = {}
# Paths to configuration files # Paths to configuration files
CONFIG_FILE = os.path.join( CONFIG_FILE = os.path.join(
@@ -26,13 +32,35 @@ THEMES_DIRS = [
] ]
def read_config_safely(config_file: str) -> configparser.ConfigParser | None: 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.""" """Safely reads a configuration file and returns a ConfigParser object or None if reading fails.
cp = configparser.ConfigParser() Uses caching to avoid repeated file reads for better performance.
"""
# Check if file exists
if not os.path.exists(config_file): if not os.path.exists(config_file):
logger.debug(f"Configuration file {config_file} not found") logger.debug(f"Configuration file {config_file} not found")
return None 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: try:
cp.read(config_file, encoding="utf-8") 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 return cp
except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e: except (configparser.DuplicateSectionError, configparser.DuplicateOptionError) as e:
logger.warning(f"Invalid configuration file format: {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}") logger.warning(f"Failed to read configuration file: {e}")
return None 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(): def read_config():
"""Reads the configuration file and returns a dictionary of parameters. """Reads the configuration file and returns a dictionary of parameters.
Example line in config (no sections): Example line in config (no sections):
@@ -75,6 +111,8 @@ def save_theme_to_config(theme_name):
cp["Appearance"]["theme"] = theme_name cp["Appearance"]["theme"] = theme_name
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_time_config(): def read_time_config():
"""Reads time settings from the [Time] section of the configuration file. """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 cp["Time"]["detail_level"] = detail_level
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_file_content(file_path): def read_file_content(file_path):
"""Reads the content of a file and returns it as a string.""" """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() return f.read().strip()
def get_portproton_location(): def get_portproton_location():
"""Returns the path to the PortProton directory. """Возвращает путь к PortProton каталогу (строку) или None."""
Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
If the path is invalid, uses the default directory.
"""
global _portproton_location global _portproton_location
if _portproton_location is not None: if _portproton_location is not None:
return _portproton_location return _portproton_location
location = None
if os.path.isfile(PORTPROTON_CONFIG_FILE): if os.path.isfile(PORTPROTON_CONFIG_FILE):
try: try:
location = read_file_content(PORTPROTON_CONFIG_FILE).strip() location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
@@ -116,19 +156,46 @@ def get_portproton_location():
_portproton_location = location _portproton_location = location
logger.info(f"PortProton path from configuration: {location}") logger.info(f"PortProton path from configuration: {location}")
return _portproton_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: 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") default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
if os.path.isdir(default_dir): if os.path.isdir(default_flatpak_dir):
_portproton_location = default_dir _portproton_location = default_flatpak_dir
logger.info(f"Using flatpak PortProton directory: {default_dir}") logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
return _portproton_location return _portproton_location
logger.warning("PortProton configuration and flatpak directory not found") logger.warning("PortProton configuration and Flatpak directory not found")
return None 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): def parse_desktop_entry(file_path):
"""Reads and parses a .desktop file using configparser. """Reads and parses a .desktop file using configparser.
Returns None if the [Desktop Entry] section is missing. 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) cp["Cards"]["card_width"] = str(card_width)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# 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(): def read_sort_method():
"""Reads the sort method from the [Games] section. """Reads the sort method from the [Games] section.
@@ -195,6 +286,8 @@ def save_sort_method(sort_method):
cp["Games"]["sort_method"] = sort_method cp["Games"]["sort_method"] = sort_method
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_display_filter(): def read_display_filter():
"""Reads the display_filter parameter from the [Games] section. """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 cp["Games"]["display_filter"] = filter_value
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_favorites(): def read_favorites():
"""Reads the list of favorite games from the [Favorites] section. """Reads the list of favorite games from the [Favorites] section.
@@ -239,6 +334,8 @@ def save_favorites(favorites):
cp["Favorites"]["games"] = f'"{fav_str}"' cp["Favorites"]["games"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_rumble_config(): def read_rumble_config():
"""Reads the gamepad rumble setting from the [Gamepad] section. """Reads the gamepad rumble setting from the [Gamepad] section.
@@ -258,6 +355,8 @@ def save_rumble_config(rumble_enabled):
cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled) cp["Gamepad"]["rumble_enabled"] = str(rumble_enabled)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_gamepad_type(): def read_gamepad_type():
"""Reads the gamepad type from the [Gamepad] section. """Reads the gamepad type from the [Gamepad] section.
@@ -277,6 +376,8 @@ def save_gamepad_type(gpad_type):
cp["Gamepad"]["type"] = gpad_type cp["Gamepad"]["type"] = gpad_type
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def ensure_default_proxy_config(): def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file. """Ensures the [Proxy] section exists in the configuration file.
@@ -321,6 +422,8 @@ def save_proxy_config(proxy_url="", proxy_user="", proxy_password=""):
cp["Proxy"]["proxy_password"] = proxy_password cp["Proxy"]["proxy_password"] = proxy_password
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_fullscreen_config(): def read_fullscreen_config():
"""Reads the fullscreen mode setting from the [Display] section. """Reads the fullscreen mode setting from the [Display] section.
@@ -340,6 +443,8 @@ def save_fullscreen_config(fullscreen):
cp["Display"]["fullscreen"] = str(fullscreen) cp["Display"]["fullscreen"] = str(fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_window_geometry() -> tuple[int, int]: def read_window_geometry() -> tuple[int, int]:
"""Reads the window width and height from the [MainWindow] section. """Reads the window width and height from the [MainWindow] section.
@@ -361,6 +466,8 @@ def save_window_geometry(width: int, height: int):
cp["MainWindow"]["height"] = str(height) cp["MainWindow"]["height"] = str(height)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def reset_config(): def reset_config():
"""Resets the configuration file by deleting it. """Resets the configuration file by deleting it.
@@ -370,6 +477,8 @@ def reset_config():
try: try:
os.remove(CONFIG_FILE) os.remove(CONFIG_FILE)
logger.info("Configuration file %s deleted", CONFIG_FILE) logger.info("Configuration file %s deleted", CONFIG_FILE)
# Invalidate cache after deletion
invalidate_config_cache()
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete configuration file: {e}") logger.warning(f"Failed to delete configuration file: {e}")
@@ -384,6 +493,9 @@ def clear_cache():
except Exception as e: except Exception as e:
logger.warning(f"Failed to delete cache: {e}") logger.warning(f"Failed to delete cache: {e}")
# Also clear our internal config cache
invalidate_config_cache()
def read_auto_fullscreen_gamepad(): def read_auto_fullscreen_gamepad():
"""Reads the auto-fullscreen setting for gamepad from the [Display] section. """Reads the auto-fullscreen setting for gamepad from the [Display] section.
Returns False if the parameter is missing. Returns False if the parameter is missing.
@@ -402,6 +514,8 @@ def save_auto_fullscreen_gamepad(auto_fullscreen):
cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen) cp["Display"]["auto_fullscreen_gamepad"] = str(auto_fullscreen)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# Invalidate cache after saving
invalidate_config_cache()
def read_favorite_folders(): def read_favorite_folders():
"""Reads the list of favorite folders from the [FavoritesFolders] section. """Reads the list of favorite folders from the [FavoritesFolders] section.
@@ -427,3 +541,26 @@ def save_favorite_folders(folders):
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"' cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile: with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile) cp.write(configfile)
# 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.QtCore import QUrl, QPoint, QObject, Signal, Qt
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
from portprotonqt.localization import _ from portprotonqt.localization import _
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, 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.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
@@ -406,16 +406,7 @@ class ContextMenuManager:
) )
return return
# Construct EGS launch command # Construct EGS launch command
wrapper = "flatpak run ru.linux_gaming.PortProton" wrapper = get_portproton_start_command()
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
exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"' exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
else: else:
exec_line = self._get_exec_line(game_card.name, game_card.exec_line) exec_line = self._get_exec_line(game_card.name, game_card.exec_line)

View File

@@ -2,12 +2,11 @@ import os
import tempfile import tempfile
import re import re
from typing import cast, TYPE_CHECKING 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 ( from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller, 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 PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
from icoextract import IconExtractor, IconExtractorError from icoextract import IconExtractor, IconExtractorError
from PIL import Image from PIL import Image
@@ -19,6 +18,7 @@ from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.virtual_keyboard import VirtualKeyboard from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.preloader import Preloader from portprotonqt.preloader import Preloader
from portprotonqt.settings_manager import get_toggle_settings, get_advanced_settings, ADVANCED_SETTING_KEYS
import psutil import psutil
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -91,6 +91,130 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
logger.error(f"Ошибка при сохранении миниатюры: {e}") logger.error(f"Ошибка при сохранении миниатюры: {e}")
return False 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
]
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): class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу file_selected = Signal(str) # Сигнал с путем к выбранному файлу
@@ -185,6 +309,7 @@ class FileExplorer(QDialog):
self.initial_path = initial_path # Store initial path if provided self.initial_path = initial_path # Store initial path if provided
self.thumbnail_cache = {} # Cache for loaded thumbnails self.thumbnail_cache = {} # Cache for loaded thumbnails
self.pending_thumbnails = set() # Track files pending thumbnail loading self.pending_thumbnails = set() # Track files pending thumbnail loading
self.main_window = None # Add reference to MainWindow
self.setup_ui() self.setup_ui()
# Window settings # Window settings
@@ -198,6 +323,7 @@ class FileExplorer(QDialog):
while parent: while parent:
if hasattr(parent, 'input_manager'): if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
if hasattr(parent, 'context_menu_manager'): if hasattr(parent, 'context_menu_manager'):
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
parent = parent.parent() parent = parent.parent()
@@ -214,6 +340,17 @@ class FileExplorer(QDialog):
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
self.update_file_list() 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 ThumbnailLoader(QRunnable):
"""Class for asynchronous thumbnail loading in a separate thread.""" """Class for asynchronous thumbnail loading in a separate thread."""
class Signals(QObject): class Signals(QObject):
@@ -897,8 +1034,8 @@ class AddGameDialog(QDialog):
"""Обработчик выбора файла в FileExplorer""" """Обработчик выбора файла в FileExplorer"""
self.exeEdit.setText(file_path) self.exeEdit.setText(file_path)
self.last_exe_path = file_path # Update last selected exe 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] game_name = os.path.splitext(os.path.basename(file_path))[0]
self.nameEdit.setText(game_name) self.nameEdit.setText(game_name)
@@ -1037,8 +1174,6 @@ Icon={icon_path}
return desktop_entry, desktop_path return desktop_entry, desktop_path
class WinetricksDialog(QDialog): 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): def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None):
super().__init__(parent) super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
@@ -1071,6 +1206,36 @@ class WinetricksDialog(QDialog):
self.setup_ui() self.setup_ui()
self.load_lists() 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): def update_winetricks(self):
"""Update the winetricks script.""" """Update the winetricks script."""
if not self.downloader.has_internet(): if not self.downloader.has_internet():
@@ -1143,15 +1308,15 @@ class WinetricksDialog(QDialog):
def setup_ui(self): def setup_ui(self):
"""Set up the user interface with tabs and tables.""" """Set up the user interface with tabs and tables."""
main_layout = QVBoxLayout(self) self.main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10) self.main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10) self.main_layout.setSpacing(10)
# Log output # Log output
self.log_output = QTextEdit() self.log_output = QTextEdit()
self.log_output.setReadOnly(True) self.log_output.setReadOnly(True)
self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE) self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
main_layout.addWidget(self.log_output) self.main_layout.addWidget(self.log_output)
# Tab widget # Tab widget
self.tab_widget = QTabWidget() self.tab_widget = QTabWidget()
@@ -1161,7 +1326,9 @@ class WinetricksDialog(QDialog):
# DLLs tab # DLLs tab
self.dll_table = QTableWidget() self.dll_table = QTableWidget()
self.dll_table.setAlternatingRowColors(True)
self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.dll_table.setColumnCount(3) self.dll_table.setColumnCount(3)
@@ -1192,7 +1359,9 @@ class WinetricksDialog(QDialog):
# Fonts tab # Fonts tab
self.fonts_table = QTableWidget() self.fonts_table = QTableWidget()
self.fonts_table.setAlternatingRowColors(True)
self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.fonts_table.setColumnCount(3) self.fonts_table.setColumnCount(3)
@@ -1223,7 +1392,9 @@ class WinetricksDialog(QDialog):
# Settings tab # Settings tab
self.settings_table = QTableWidget() self.settings_table = QTableWidget()
self.settings_table.setAlternatingRowColors(True)
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
# self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.NoSelection)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection) self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.settings_table.setColumnCount(3) self.settings_table.setColumnCount(3)
@@ -1258,7 +1429,7 @@ class WinetricksDialog(QDialog):
"settings": self.settings_container "settings": self.settings_container
} }
main_layout.addWidget(self.tab_widget) self.main_layout.addWidget(self.tab_widget)
# Buttons # Buttons
button_layout = QHBoxLayout() button_layout = QHBoxLayout()
@@ -1272,7 +1443,7 @@ class WinetricksDialog(QDialog):
button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.cancel_button)
button_layout.addWidget(self.force_button) button_layout.addWidget(self.force_button)
button_layout.addWidget(self.install_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.cancel_button.clicked.connect(self.reject)
self.force_button.clicked.connect(lambda: self.install_selected(force=True)) self.force_button.clicked.connect(lambda: self.install_selected(force=True))
@@ -1497,3 +1668,429 @@ class WinetricksDialog(QDialog):
"""Добавляет в лог.""" """Добавляет в лог."""
self.log_output.append(message) self.log_output.append(message)
self.log_output.moveCursor(QTextCursor.MoveOperation.End) 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)
# 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()
# 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)
# 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)
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 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 closeEvent(self, event):
super().closeEvent(event)
def reject(self):
super().reject()

View File

@@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.image_utils import load_pixmap_async 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.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 ( from portprotonqt.steam_api import (
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async, 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 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 return
# Determine wrapper # Determine wrapper
wrapper = "flatpak run ru.linux_gaming.PortProton" wrapper = get_portproton_start_command()
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
# Create launch script # Create launch script
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts") 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.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 PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
from collections.abc import Callable from collections.abc import Callable
from portprotonqt.image_utils import load_pixmap_async, round_corners from portprotonqt.image_utils import load_pixmap_async, round_corners
@@ -102,7 +102,7 @@ class GameCard(QFrame):
self.favoriteLabel = ClickableLabel(self.coverWidget) self.favoriteLabel = ClickableLabel(self.coverWidget)
self.favoriteLabel.clicked.connect(self.toggle_favorite) self.favoriteLabel.clicked.connect(self.toggle_favorite)
self.is_favorite = self.name in read_favorites() self.is_favorite = self.name in set(read_favorites())
self.update_favorite_icon() self.update_favorite_icon()
self.favoriteLabel.raise_() self.favoriteLabel.raise_()
@@ -203,7 +203,7 @@ class GameCard(QFrame):
self.update_cover_pixmap() self.update_cover_pixmap()
def update_cover_pixmap(self): 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_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) 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)) rounded_pixmap = round_corners(scaled_pixmap, int(15 * self._scale))
@@ -404,14 +404,22 @@ class GameCard(QFrame):
self.favoriteLabel.setText("") self.favoriteLabel.setText("")
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE) 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): def toggle_favorite(self):
favorites = read_favorites() favorites = read_favorites()
favorites_set = set(favorites)
if self.is_favorite: if self.is_favorite:
if self.name in favorites: if self.name in favorites_set:
favorites.remove(self.name) favorites.remove(self.name)
self.is_favorite = False self.is_favorite = False
else: else:
if self.name not in favorites: if self.name not in favorites_set:
favorites.append(self.name) favorites.append(self.name)
self.is_favorite = True self.is_favorite = True
save_favorites(favorites) save_favorites(favorites)

View File

@@ -33,6 +33,7 @@ class MainWindowProtocol(Protocol):
# Required attributes # Required attributes
searchEdit: CustomLineEdit searchEdit: CustomLineEdit
_last_card_width: int _last_card_width: int
card_width: int
current_hovered_card: GameCard | None current_hovered_card: GameCard | None
current_focused_card: GameCard | None current_focused_card: GameCard | None
gamesListWidget: QWidget | None gamesListWidget: QWidget | None
@@ -128,6 +129,8 @@ class GameLibraryManager:
self.card_width = self.sizeSlider.value() self.card_width = self.sizeSlider.value()
self.sizeSlider.setToolTip(f"{self.card_width} px") self.sizeSlider.setToolTip(f"{self.card_width} px")
save_card_size(self.card_width) 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(): for card in self.game_card_cache.values():
card.update_card_size(self.card_width) card.update_card_size(self.card_width)
self.update_game_grid() self.update_game_grid()
@@ -264,8 +267,9 @@ class GameLibraryManager:
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0) 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 # Quick partition: Sort favorites and non-favorites separately, then merge
fav_games = [g for g in games_list if g[0] in favorites] favorites_set = set(favorites) # Convert to set for O(1) lookup
non_fav_games = [g for g in games_list if g[0] not in favorites] 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_fav = sorted(fav_games, key=partition_sort_key)
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key) sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
sorted_games = sorted_fav + sorted_non_fav sorted_games = sorted_fav + sorted_non_fav

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() current_theme_name = read_theme_from_config()
def finish_with(pixmap: QPixmap): def finish_with(pixmap: QPixmap):
scaled = pixmap.scaled(width, height, Qt.AspectRatioMode.KeepAspectRatioByExpanding, Qt.TransformationMode.SmoothTransformation) # Check if pixmap is valid before attempting to scale it
x = (scaled.width() - width) // 2 if pixmap.isNull():
y = (scaled.height() - height) // 2 # Create a default placeholder pixmap instead of trying to scale a null pixmap
cropped = scaled.copy(x, y, width, height) placeholder_pixmap = QPixmap(width, height)
callback(cropped) 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")) 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") 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") local_path = os.path.join(image_folder, f"{appid}.jpg")
if os.path.exists(local_path): if os.path.exists(local_path):
pixmap = QPixmap(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) finish_with(pixmap)
return 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) placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path): if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path) pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else: else:
pixmap = QPixmap(width, height) pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333")) 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: except Exception as e:
logger.error(f"Ошибка обработки URL {cover}: {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: 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): if os.path.exists(local_path):
pixmap = QPixmap(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) finish_with(pixmap)
return 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) placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder_path and QFile.exists(placeholder_path): if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(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: else:
pixmap = QPixmap(width, height) pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333")) 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): if cover and QFile.exists(cover):
pixmap = QPixmap(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) finish_with(pixmap)
return return
@@ -122,6 +188,8 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
pixmap = QPixmap() pixmap = QPixmap()
if placeholder_path and QFile.exists(placeholder_path): if placeholder_path and QFile.exists(placeholder_path):
pixmap.load(placeholder_path) pixmap.load(placeholder_path)
if pixmap.isNull():
logger.warning(f"Failed to load placeholder image from {placeholder_path}")
else: else:
pixmap = QPixmap(width, height) pixmap = QPixmap(width, height)
pixmap.fill(QColor("#333333")) pixmap.fill(QColor("#333333"))
@@ -141,7 +209,15 @@ def round_corners(pixmap, radius):
""" """
if pixmap.isNull(): if pixmap.isNull():
return pixmap return pixmap
# Check if radius is valid to prevent issues
if radius <= 0:
return pixmap
size = pixmap.size() size = pixmap.size()
if size.width() <= 0 or size.height() <= 0:
return pixmap
rounded = QPixmap(size) rounded = QPixmap(size)
rounded.fill(QColor(0, 0, 0, 0)) rounded.fill(QColor(0, 0, 0, 0))
painter = QPainter(rounded) painter = QPainter(rounded)
@@ -244,20 +320,31 @@ class FullscreenDialog(QDialog):
QApplication.processEvents() QApplication.processEvents()
pixmap, caption = self.images[self.current_index] pixmap, caption = self.images[self.current_index]
# Учитываем devicePixelRatio для масштабирования высокого качества # Check if pixmap is valid before attempting to scale it
device_pixel_ratio = get_device_pixel_ratio() if pixmap.isNull():
target_width = int((self.FIXED_WIDTH - 80) * device_pixel_ratio) # Create a default placeholder pixmap instead of trying to scale a null pixmap
target_height = int(self.FIXED_HEIGHT * device_pixel_ratio) 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 # Масштабируем изображение из оригинального pixmap
scaled_pixmap = pixmap.scaled( scaled_pixmap = pixmap.scaled(
target_width, target_width,
target_height, target_height,
Qt.AspectRatioMode.KeepAspectRatio, Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation Qt.TransformationMode.SmoothTransformation
) )
scaled_pixmap.setDevicePixelRatio(device_pixel_ratio) scaled_pixmap.setDevicePixelRatio(device_pixel_ratio)
self.imageLabel.setPixmap(scaled_pixmap) self.imageLabel.setPixmap(scaled_pixmap)
self.captionLabel.setText(caption) self.captionLabel.setText(caption)
self.setWindowTitle(caption) self.setWindowTitle(caption)

View File

@@ -1,11 +1,12 @@
import time import time
import threading import threading
import os import os
import math
from typing import Protocol, cast from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff from evdev import InputDevice, InputEvent, UInput, ecodes, list_devices, ff
from enum import Enum from enum import Enum
from pyudev import Context, Monitor, MonitorObserver, Device from pyudev import Context, Monitor, Device, Devices
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView, QTableWidgetItem
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
from PySide6.QtGui import QKeyEvent, QMouseEvent from PySide6.QtGui import QKeyEvent, QMouseEvent
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
@@ -13,8 +14,9 @@ from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
from portprotonqt.game_card import GameCard from portprotonqt.game_card import GameCard
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config, read_gamepad_type
from portprotonqt.dialogs import AddGameDialog, WinetricksDialog from portprotonqt.dialogs import AddGameDialog
from portprotonqt.virtual_keyboard import VirtualKeyboard from portprotonqt.virtual_keyboard import VirtualKeyboard
import select
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -76,6 +78,7 @@ class InputManager(QObject):
button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release) button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release)
dpad_moved = Signal(int, int, float) # Signal for D-pad movements dpad_moved = Signal(int, int, float) # Signal for D-pad movements
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal) toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
gamepad_hotplug = Signal(str) # 'add' or 'remove'
def __init__( def __init__(
self, self,
@@ -114,6 +117,34 @@ class InputManager(QObject):
self.last_trigger_time = 0.0 self.last_trigger_time = 0.0
self.trigger_cooldown = 0.2 self.trigger_cooldown = 0.2
# Mouse emulation attributes
self.mouse_emulation_enabled = True # Enable by default as crutch for external apps
self.ui = None # UInput for virtual mouse
self.stick_x_raw = 0
self.stick_y_raw = 0
self.deadzone = 8000 # Deadzone for sticks
self.max_value = 32767 # Max stick value
self.sensitivity = 8.0 # Cursor sensitivity
self.scroll_accumulator = 0.0
self.scroll_sensitivity = 0.15 # Scroll sensitivity
self.scroll_threshold = 0.2 # Scroll threshold
self.last_update = time.time()
self.update_interval = 0.016 # ~60 FPS
self.emulation_active = False # Flag for external focus (updated in main thread)
self.emulation_triggered = False
self.start_held = False
self.guide_held = False
# Focus check timer for emulation flag (runs in main thread)
self.focus_check_timer = QTimer(self)
self.focus_check_timer.timeout.connect(self._update_emulation_flag)
self.focus_check_timer.start(100) # Check every 100ms
logger.info("EMUL: Mouse emulation initialized (enabled=%s)", self.mouse_emulation_enabled)
if self.mouse_emulation_enabled:
self.enable_mouse_emulation()
# FileExplorer specific attributes # FileExplorer specific attributes
self.file_explorer = None self.file_explorer = None
self.original_button_handler = None self.original_button_handler = None
@@ -150,6 +181,13 @@ class InputManager(QObject):
# Initialize evdev + hotplug # Initialize evdev + hotplug
self.init_gamepad() self.init_gamepad()
def _update_emulation_flag(self):
"""Update emulation_active flag based on Qt app focus (main thread only)."""
active = QApplication.activeWindow()
self.emulation_active = (active is None) # True for external windows (e.g., winefile)
if not self.emulation_active:
self.emulation_triggered = False
def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None: def _navigate_game_cards(self, container, tab_index: int, code: int, value: int) -> None:
"""Common navigation logic for game cards in a container.""" """Common navigation logic for game cards in a container."""
if container is None: if container is None:
@@ -455,6 +493,171 @@ class InputManager(QObject):
except Exception as e: except Exception as e:
logger.error("Error in FileExplorer dpad handler: %s", e) logger.error("Error in FileExplorer dpad handler: %s", e)
def enable_winetricks_mode(self, winetricks_dialog):
"""Setup gamepad handling for WinetricksDialog"""
try:
self.winetricks_dialog = winetricks_dialog
self.original_button_handler = self.handle_button_slot
self.original_dpad_handler = self.handle_dpad_slot
self.original_gamepad_state = self._gamepad_handling_enabled
self.handle_button_slot = self.handle_winetricks_button
self.handle_dpad_slot = self.handle_winetricks_dpad
self._gamepad_handling_enabled = True
# Reset dpad timer for table nav
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
logger.debug("Gamepad handling successfully connected for WinetricksDialog")
except Exception as e:
logger.error(f"Error connecting gamepad handlers for Winetricks: {e}")
def disable_winetricks_mode(self):
"""Restore original main window handlers"""
try:
if self.winetricks_dialog:
self.handle_button_slot = self.original_button_handler
self.handle_dpad_slot = self.original_dpad_handler
self._gamepad_handling_enabled = self.original_gamepad_state
self.winetricks_dialog = None
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
logger.debug("Gamepad handling successfully restored from Winetricks")
except Exception as e:
logger.error(f"Error restoring gamepad handlers from Winetricks: {e}")
def handle_winetricks_button(self, button_code, value):
if self.winetricks_dialog is None:
return
if value == 0: # Ignore releases
return
try:
# Always check for popups first, including QMessageBox
popup = QApplication.activePopupWidget()
if popup:
if isinstance(popup, QMessageBox):
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']:
popup.accept() # Close QMessageBox with A or B
return
elif isinstance(popup, QMenu):
if button_code in BUTTONS['confirm']: # A: Select menu item
focused = popup.activeAction()
if focused:
focused.trigger()
return
elif button_code in BUTTONS['back']: # B: Close menu
popup.close()
return
# Additional check for top-level QMessageBox (in case not active popup yet)
for widget in QApplication.topLevelWidgets():
if isinstance(widget, QMessageBox) and widget.isVisible():
if button_code in BUTTONS['confirm'] or button_code in BUTTONS['back']:
widget.accept()
return
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm']: # A: Toggle checkbox
if isinstance(focused, QTableWidget):
current_row = focused.currentRow()
if current_row >= 0:
checkbox_item = focused.item(current_row, 0)
if checkbox_item and isinstance(checkbox_item, QTableWidgetItem):
new_state = Qt.CheckState.Checked if checkbox_item.checkState() == Qt.CheckState.Unchecked else Qt.CheckState.Unchecked
checkbox_item.setCheckState(new_state)
return
elif button_code in BUTTONS['add_game']: # X: Install (no force)
self.winetricks_dialog.install_selected(force=False)
return
elif button_code in BUTTONS['prev_dir']: # Y: Force Install
self.winetricks_dialog.install_selected(force=True)
return
elif button_code in BUTTONS['back']: # B: Cancel
self.winetricks_dialog.reject()
return
elif button_code in BUTTONS['prev_tab']: # LB: Prev Tab
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
self._focus_first_row_in_current_table()
return
elif button_code in BUTTONS['next_tab']: # RB: Next Tab
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
self._focus_first_row_in_current_table()
return
# Fallback: Activate focused widget (e.g., buttons)
self._parent.activateFocusedWidget()
except Exception as e:
logger.error(f"Error in handle_winetricks_button: {e}")
def handle_winetricks_dpad(self, code, value, now):
if self.winetricks_dialog is None:
return
try:
if value == 0: # Release: Stop repeat
self.dpad_timer.stop()
self.current_dpad_code = None
self.current_dpad_value = 0
return
# Start/update repeat timer for hold navigation
if self.current_dpad_code != code or self.current_dpad_value != value:
self.dpad_timer.stop()
self.dpad_timer.setInterval(150 if self.dpad_timer.isActive() else 300) # Initial slower, then faster repeat
self.dpad_timer.start()
self.current_dpad_code = code
self.current_dpad_value = value
table = self._get_current_table()
if not table or table.rowCount() == 0:
return
current_row = table.currentRow()
if code == ecodes.ABS_HAT0Y: # Up/Down: Navigate rows
if value < 0: # Up
new_row = max(0, current_row - 1)
elif value > 0: # Down
new_row = min(table.rowCount() - 1, current_row + 1)
else:
return
if new_row != current_row:
table.setCurrentCell(new_row, 0) # Focus checkbox column
table.setFocus(Qt.FocusReason.OtherFocusReason)
elif code == ecodes.ABS_HAT0X: # Left/Right: Switch tabs
if value < 0: # Left: Prev tab
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = max(0, current_index - 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
elif value > 0: # Right: Next tab
current_index = self.winetricks_dialog.tab_widget.currentIndex()
new_index = min(self.winetricks_dialog.tab_widget.count() - 1, current_index + 1)
self.winetricks_dialog.tab_widget.setCurrentIndex(new_index)
self._focus_first_row_in_current_table()
except Exception as e:
logger.error(f"Error in handle_winetricks_dpad: {e}")
def _get_current_table(self):
"""Get the current visible table from the tab widget's stacked container."""
if self.winetricks_dialog is None:
return None
current_container = self.winetricks_dialog.tab_widget.currentWidget()
if current_container and isinstance(current_container, QStackedWidget):
current_table = current_container.widget(1) # Table is at index 1 (after preloader)
if isinstance(current_table, QTableWidget):
return current_table
return None
def _focus_first_row_in_current_table(self):
"""Focus the first row in the current table after tab switch."""
if self.winetricks_dialog is None:
return
table = self._get_current_table()
if table and table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
def handle_navigation_repeat(self): def handle_navigation_repeat(self):
"""Плавное повторение движения с переменной скоростью для FileExplorer""" """Плавное повторение движения с переменной скоростью для FileExplorer"""
try: try:
@@ -471,6 +674,116 @@ class InputManager(QObject):
except Exception as e: except Exception as e:
logger.error(f"Error in navigation repeat: {e}") logger.error(f"Error in navigation repeat: {e}")
def enable_mouse_emulation(self):
"""Enable mouse emulation mode (creates virtual mouse device)."""
if self.mouse_emulation_enabled and self.ui is not None:
logger.debug("EMUL: Mouse emulation already enabled, skipping")
return
try:
logger.info("EMUL: Attempting to create UInput virtual mouse...")
if not os.path.exists('/dev/uinput'):
logger.error("EMUL: /dev/uinput does not exist")
self.mouse_emulation_enabled = False
return
if not os.access('/dev/uinput', os.W_OK):
logger.error("EMUL: No write access to /dev/uinput")
self.mouse_emulation_enabled = False
return
self.ui = UInput({
ecodes.EV_KEY: [ecodes.BTN_LEFT, ecodes.BTN_RIGHT],
ecodes.EV_REL: [ecodes.REL_X, ecodes.REL_Y, ecodes.REL_WHEEL],
}, name="Virtual DPad Mouse")
self.mouse_emulation_enabled = True
logger.info("EMUL: Virtual mouse created successfully")
except PermissionError as e:
logger.error("EMUL: Permission denied for /dev/uinput: %s", e)
self.mouse_emulation_enabled = False
except Exception as ex:
logger.error(f"EMUL: Error creating virtual mouse: {ex}", exc_info=True)
self.mouse_emulation_enabled = False
def disable_mouse_emulation(self):
"""Disable mouse emulation mode (closes virtual mouse device)."""
logger.info("EMUL: Disabling mouse emulation...")
if self.ui:
try:
self.ui.close()
logger.info("EMUL: Virtual mouse closed")
except Exception as e:
logger.error("EMUL: Error closing virtual mouse: %s", e)
self.ui = None
self.mouse_emulation_enabled = False
self.stick_x_raw = 0
self.stick_y_raw = 0
self.scroll_accumulator = 0.0
def handle_scroll(self, raw_value):
"""Обработка прокрутки с правого стика Y"""
if not self.mouse_emulation_enabled or not self.emulation_active or not self.ui:
return
if abs(raw_value) < self.deadzone:
self.scroll_accumulator = 0.0
return
normalized = raw_value / self.max_value
self.scroll_accumulator += normalized * self.scroll_sensitivity
while abs(self.scroll_accumulator) >= self.scroll_threshold:
scroll_step = 1 if self.scroll_accumulator > 0 else -1
self.scroll_wheel(-scroll_step)
self.scroll_accumulator -= scroll_step * self.scroll_threshold
def update_mouse_position(self):
"""Постоянное обновление позиции мыши на основе состояния стика"""
if not self.ui or not self.emulation_active:
return
x = self.stick_x_raw
y = self.stick_y_raw
magnitude = math.sqrt(x * x + y * y)
if magnitude < self.deadzone:
return
norm_x = x / magnitude
norm_y = y / magnitude
adjusted_magnitude = max(0.0, min(1.0, (magnitude - self.deadzone) / (self.max_value - self.deadzone)))
adjusted_magnitude = math.pow(adjusted_magnitude, 1.5)
speed = adjusted_magnitude * self.sensitivity
dx = int(norm_x * speed)
dy = int(norm_y * speed)
if dx != 0 or dy != 0:
self.move_mouse(dx, dy)
def move_mouse(self, dx, dy):
"""Сдвиг системного курсора"""
if self.ui:
self.ui.write(ecodes.EV_REL, ecodes.REL_X, dx)
self.ui.write(ecodes.EV_REL, ecodes.REL_Y, dy)
self.ui.syn()
def scroll_wheel(self, steps):
"""Прокрутка колеса мыши"""
if self.ui:
self.ui.write(ecodes.EV_REL, ecodes.REL_WHEEL, steps)
self.ui.syn()
def click_left(self):
"""Клик левой кнопкой мыши"""
if self.ui:
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 1)
self.ui.syn()
self.ui.write(ecodes.EV_KEY, ecodes.BTN_LEFT, 0)
self.ui.syn()
def click_right(self):
"""Клик правой кнопкой мыши"""
if self.ui:
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 1)
self.ui.syn()
self.ui.write(ecodes.EV_KEY, ecodes.BTN_RIGHT, 0)
self.ui.syn()
@Slot(bool) @Slot(bool)
def handle_fullscreen_slot(self, enable: bool) -> None: def handle_fullscreen_slot(self, enable: bool) -> None:
try: try:
@@ -705,39 +1018,6 @@ class InputManager(QObject):
self._parent.toggleGame(self._parent.current_exec_line, None) self._parent.toggleGame(self._parent.current_exec_line, None)
return return
if isinstance(active, WinetricksDialog):
if button_code in BUTTONS['confirm']: # A button - toggle checkbox
current_table = active.tab_widget.currentWidget()
if isinstance(current_table, QTableWidget):
current_row = current_table.currentRow()
if current_row >= 0:
checkbox = current_table.item(current_row, 0)
if checkbox:
checkbox.setCheckState(
Qt.CheckState.Unchecked if checkbox.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked
)
return
elif button_code in BUTTONS['add_game']: # X button - install
active.install_selected(force=False)
return
elif button_code in BUTTONS['prev_dir']: # Y button - force install
active.install_selected(force=True)
return
elif button_code in BUTTONS['back']: # B button - close dialog
active.reject()
return
elif button_code in BUTTONS['prev_tab']: # LB - previous tab
current_idx = active.tab_widget.currentIndex()
new_idx = (current_idx - 1) % active.tab_widget.count()
active.tab_widget.setCurrentIndex(new_idx)
return
elif button_code in BUTTONS['next_tab']: # RB - next tab
current_idx = active.tab_widget.currentIndex()
new_idx = (current_idx + 1) % active.tab_widget.count()
active.tab_widget.setCurrentIndex(new_idx)
return
# Standard navigation # Standard navigation
if button_code in BUTTONS['confirm']: if button_code in BUTTONS['confirm']:
self._parent.activateFocusedWidget() self._parent.activateFocusedWidget()
@@ -1304,127 +1584,371 @@ class InputManager(QObject):
return super().eventFilter(obj, event) return super().eventFilter(obj, event)
def init_gamepad(self) -> None: def init_gamepad(self) -> None:
self.udev_context = Context()
self.Devices = Devices
self.monitor_ready = False
self.monitor_event = threading.Event()
# Подключаем сигнал hotplug к обработчику в главном потоке
self.gamepad_hotplug.connect(self._on_gamepad_hotplug)
# Debounce timer для отложенной проверки геймпада (в главном потоке Qt)
self.gamepad_check_timer = QTimer()
self.gamepad_check_timer.setSingleShot(True)
self.gamepad_check_timer.timeout.connect(self.check_gamepad)
# Первоначальная проверка
self.check_gamepad() self.check_gamepad()
# Запускаем udev monitor в отдельном потоке
threading.Thread(target=self.run_udev_monitor, daemon=True).start() threading.Thread(target=self.run_udev_monitor, daemon=True).start()
logger.info("Gamepad support initialized with hotplug (evdev + pyudev)") logger.info("Gamepad support initialized with hotplug (evdev + pyudev)")
def run_udev_monitor(self) -> None: def run_udev_monitor(self) -> None:
"""
Безопасный неблокирующий udev monitor для геймпадов.
Использует select.poll() вместо блокирующего monitor.poll().
"""
try: try:
context = Context() logger.info("Starting udev monitor...")
monitor = Monitor.from_netlink(context) monitor = Monitor.from_netlink(self.udev_context)
monitor.filter_by(subsystem='input') monitor.filter_by(subsystem='input')
observer = MonitorObserver(monitor, self.handle_udev_event)
observer.start() try:
monitor.start()
except Exception as e:
logger.error(f"Failed to start udev monitor: {e}")
return
fd = monitor.fileno()
poller = select.poll()
poller.register(fd, select.POLLIN)
# Короткий дренаж событий при запуске (0.5 сек)
drain_start = time.time()
drained_count = 0
while time.time() - drain_start < 0.5:
events = poller.poll(100)
if not events:
continue
try:
_ = monitor.poll(timeout=0) # просто читаем, не обрабатываем
drained_count += 1
except Exception:
break
self.monitor_ready = True
self.monitor_event.set()
logger.info(f"Drained {drained_count} initial events, now monitoring hotplug...")
# Основной цикл
while self.running: while self.running:
time.sleep(1) events = poller.poll(1000) # 1 сек таймаут
if not events:
continue # просто ждём, не блокируем
try:
device = monitor.poll(timeout=0)
except Exception as e:
logger.debug(f"Monitor poll failed: {e}")
continue
if not device:
continue
action = device.action
if action and self._is_joystick_device(device):
logger.info(f"Joystick hotplug event: {action} for {device.sys_name}")
# отправляем сигнал в Qt-поток
self.handle_udev_event(action, device)
logger.info("udev monitor stopped gracefully")
except Exception as e: except Exception as e:
logger.error(f"Error in udev monitor: {e}", exc_info=True) logger.error(f"Error in udev monitor: {e}", exc_info=True)
def _is_joystick_device(self, device: Device) -> bool:
"""
Быстрая проверка: является ли устройство джойстиком.
Проверяет ID_INPUT_JOYSTICK из udev базы данных.
"""
try:
# Проверяем свойство ID_INPUT_JOYSTICK
if device.get('ID_INPUT_JOYSTICK') == '1':
return True
# Дополнительно: проверяем родительские устройства
# (некоторые контроллеры имеют свойство только у родителя)
parent = device.parent
if parent and parent.get('ID_INPUT_JOYSTICK') == '1':
return True
return False
except Exception as e:
logger.debug(f"Error checking joystick device: {e}")
return False
def handle_udev_event(self, action: str, device: Device) -> None: def handle_udev_event(self, action: str, device: Device) -> None:
"""
Обработчик udev событий для джойстиков.
Отправляет сигнал в главный поток Qt вместо прямого вызова QTimer.
"""
try: try:
if action == 'add': if action == 'add':
time.sleep(0.1) # Отправляем сигнал в главный поток Qt
self.check_gamepad() # QTimer будет запущен там безопасно
logger.debug("Emitting gamepad add signal")
self.gamepad_hotplug.emit('add')
elif action == 'remove' and self.gamepad: elif action == 'remove' and self.gamepad:
if not any(self.gamepad.path == path for path in list_devices()): # Проверяем конкретно наш геймпад по пути устройства
logger.info("Gamepad disconnected") device_node = device.device_node # например, /dev/input/event3
self.stop_rumble()
self.gamepad = None if device_node and self.gamepad.path == device_node:
if self.gamepad_thread: logger.info(f"Connected gamepad disconnected: {device_node}")
self.gamepad_thread.join() # Отправляем сигнал в главный поток
if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): self.gamepad_hotplug.emit('remove')
self.toggle_fullscreen.emit(False)
except Exception as e: except Exception as e:
logger.error(f"Error handling udev event: {e}", exc_info=True) logger.error(f"Error handling udev event: {e}", exc_info=True)
def _on_gamepad_hotplug(self, action: str) -> None:
"""
Обработчик сигнала hotplug, выполняется в главном потоке Qt.
Безопасно работает с QTimer.
"""
try:
if action == 'add':
# Debounce: откладываем проверку на 200ms
# Множественные события за короткое время объединяются в один вызов
logger.debug("Scheduling gamepad check (debounced)")
self.gamepad_check_timer.start(200)
elif action == 'remove':
# Немедленная обработка отключения
self.stop_rumble()
self.gamepad = None
if self.gamepad_thread:
self.gamepad_thread.join(timeout=2.0)
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
self.toggle_fullscreen.emit(False)
except Exception as e:
logger.error(f"Error in hotplug handler: {e}", exc_info=True)
def check_gamepad(self) -> None: def check_gamepad(self) -> None:
"""
Проверка и подключение геймпада.
Вызывается из главного потока Qt через QTimer (debounced).
"""
try: try:
new_gamepad = self.find_gamepad() new_gamepad = self.find_gamepad()
if new_gamepad and new_gamepad != self.gamepad:
logger.info(f"Gamepad connected: {new_gamepad.name}") if new_gamepad:
if not self.gamepad or new_gamepad.path != self.gamepad.path:
logger.info(f"Gamepad connected: {new_gamepad.name} at {new_gamepad.path}")
self.stop_rumble()
self.gamepad = new_gamepad
if self.gamepad_thread and self.gamepad_thread.is_alive():
self.gamepad_thread.join(timeout=2.0)
def start_monitoring():
# Ожидание готовности udev monitor без busy-wait
if not self.monitor_event.wait(timeout=2.0):
logger.warning("Timeout waiting for udev monitor readiness")
self.monitor_gamepad()
self.gamepad_thread = threading.Thread(
target=start_monitoring,
daemon=True
)
self.gamepad_thread.start()
# Автоматический фуллскрин при подключении геймпада
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
self.toggle_fullscreen.emit(True)
elif self.gamepad and not any(self.gamepad.path == path for path in list_devices()):
logger.info("Gamepad no longer detected")
self.stop_rumble() self.stop_rumble()
self.gamepad = new_gamepad self.gamepad = None
if self.gamepad_thread:
self.gamepad_thread.join() if self.gamepad_thread and self.gamepad_thread.is_alive():
self.gamepad_thread = threading.Thread(target=self.monitor_gamepad, daemon=True) self.gamepad_thread.join(timeout=2.0)
self.gamepad_thread.start()
# Send signal for fullscreen mode only if:
# 1. auto_fullscreen_gamepad is enabled
# 2. fullscreen is not already enabled (to avoid conflict)
if read_auto_fullscreen_gamepad() and not read_fullscreen_config(): if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
self.toggle_fullscreen.emit(True) self.toggle_fullscreen.emit(False)
except Exception as e: except Exception as e:
logger.error(f"Error checking gamepad: {e}", exc_info=True) logger.error(f"Error checking gamepad: {e}", exc_info=True)
def find_gamepad(self) -> InputDevice | None: def find_gamepad(self) -> InputDevice | None:
"""
Находит первый доступный геймпад.
Оптимизирован: предварительная фильтрация по capabilities перед udev-запросами.
"""
try: try:
devices = [InputDevice(path) for path in list_devices()] devices = [InputDevice(path) for path in list_devices()]
if not devices:
return None
logger.debug(f"Checking {len(devices)} devices for gamepad...")
for device in devices: for device in devices:
# Skip ASRock LED controller (vendor ID: 26ce, product ID: 01a2) # Skip ASRock LED controller (известная проблема)
if device.info.vendor == 0x26ce and device.info.product == 0x01a2: if device.info.vendor == 0x26ce and device.info.product == 0x01a2:
logger.debug(f"Skipping ASRock LED controller: {device.name}")
continue continue
caps = device.capabilities()
if ecodes.EV_KEY in caps or ecodes.EV_ABS in caps: # Предварительная фильтрация: проверяем capabilities
return device # Джойстик должен иметь хотя бы оси (ABS) или кнопки (KEY)
# Это избегает udev-запросов для явно не-джойстиков
caps = device.capabilities(verbose=False)
has_abs_axes = ecodes.EV_ABS in caps
has_buttons = ecodes.EV_KEY in caps
if not (has_abs_axes or has_buttons):
continue
# Только для потенциальных джойстиков делаем udev-запрос
try:
udev_device = self.Devices.from_device_file(
self.udev_context,
device.path
)
is_joystick = udev_device.get('ID_INPUT_JOYSTICK')
if is_joystick == '1':
logger.info(f"Found gamepad: {device.name}")
return device
except Exception as e:
logger.debug(f"Could not check udev properties for {device.path}: {e}")
continue
logger.debug("No gamepad found")
return None return None
except Exception as e: except Exception as e:
logger.error(f"Error finding gamepad: {e}", exc_info=True) logger.error(f"Error finding gamepad: {e}", exc_info=True)
return None return None
def monitor_gamepad(self) -> None: def monitor_gamepad(self) -> None:
try: try:
if not self.gamepad: while self.running:
return current_time = time.time()
for event in self.gamepad.read_loop():
if not self.running:
break
if event.type not in (ecodes.EV_KEY, ecodes.EV_ABS):
continue
now = time.time()
# Проверка фокуса: игнорируем события, если окно не в фокусе if self.gamepad:
app = QApplication.instance() try:
active = QApplication.activeWindow() # Non-blocking read with short timeout
if not app or not active: events = []
continue r, w, x = select.select([self.gamepad.fd], [], [], 0.001)
if r:
events = list(self.gamepad.read())
if event.type == ecodes.EV_KEY: # Process events
# Emit on both press (1) and release (0) for event in events:
self.button_event.emit(event.code, event.value) if not self.running:
# Special handling for menu on press only break
if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.toggle_fullscreen.emit(not self._is_fullscreen) # UI signal handling (always, for internal app)
elif event.type == ecodes.EV_ABS: if event.type == ecodes.EV_KEY:
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}: if event.code == ecodes.BTN_START:
# Проверяем, достаточно ли времени прошло с последнего срабатывания self.start_held = (event.value == 1)
if now - self.last_trigger_time < self.trigger_cooldown:
continue if event.code in BUTTONS['guide']:
if event.code == ecodes.ABS_Z: # LT/L2 self.guide_held = (event.value == 1)
if event.value > 128 and not self.lt_pressed:
self.lt_pressed = True if event.value == 1:
self.button_event.emit(event.code, 1) # Emit as press if ((event.code in BUTTONS['guide'] and self.start_held) or
self.last_trigger_time = now (event.code == ecodes.BTN_START and self.guide_held)):
elif event.value <= 128 and self.lt_pressed: self.emulation_triggered = not self.emulation_triggered
self.lt_pressed = False
self.button_event.emit(event.code, 0) # Emit as release self.button_event.emit(event.code, event.value)
elif event.code == ecodes.ABS_RZ: # RT/R2 # Special handling for menu on press only
if event.value > 128 and not self.rt_pressed: if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session:
self.rt_pressed = True self.toggle_fullscreen.emit(not self._is_fullscreen)
self.button_event.emit(event.code, 1) # Emit as press elif event.type == ecodes.EV_ABS:
self.last_trigger_time = now if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
elif event.value <= 128 and self.rt_pressed: # Trigger handling for UI
self.rt_pressed = False if current_time - self.last_trigger_time < self.trigger_cooldown:
self.button_event.emit(event.code, 0) # Emit as release continue
else: if event.code == ecodes.ABS_Z: # LT/L2
self.dpad_moved.emit(event.code, event.value, now) if event.value > 128 and not self.lt_pressed:
except OSError as e: self.lt_pressed = True
if e.errno == 19: # ENODEV: No such device self.button_event.emit(event.code, 1)
logger.info("Gamepad disconnected during event loop") self.last_trigger_time = current_time
else: elif event.value <= 128 and self.lt_pressed:
logger.error(f"OSError in gamepad monitoring: {e}", exc_info=True) self.lt_pressed = False
self.button_event.emit(event.code, 0)
elif event.code == ecodes.ABS_RZ: # RT/R2
if event.value > 128 and not self.rt_pressed:
self.rt_pressed = True
self.button_event.emit(event.code, 1)
self.last_trigger_time = current_time
elif event.value <= 128 and self.rt_pressed:
self.rt_pressed = False
self.button_event.emit(event.code, 0)
else:
self.dpad_moved.emit(event.code, event.value, current_time)
# Mouse emulation (only for external windows + triggered)
if self.mouse_emulation_enabled and self.emulation_active and self.emulation_triggered:
if event.type == ecodes.EV_ABS:
if event.code == ecodes.ABS_HAT0X:
if event.value == -1:
self.move_mouse(-10, 0)
elif event.value == 1:
self.move_mouse(10, 0)
elif event.code == ecodes.ABS_HAT0Y:
if event.value == -1:
self.move_mouse(0, -10)
elif event.value == 1:
self.move_mouse(0, 10)
elif event.code == ecodes.ABS_X:
self.stick_x_raw = event.value
elif event.code == ecodes.ABS_Y:
self.stick_y_raw = event.value
elif event.code == ecodes.ABS_RY:
self.handle_scroll(event.value)
elif event.type == ecodes.EV_KEY:
if event.code in (ecodes.BTN_SOUTH, ecodes.BTN_A) and event.value == 1:
self.click_left()
elif event.code in (ecodes.BTN_EAST, ecodes.BTN_B) and event.value == 1:
self.click_right()
# Periodic mouse position update
if current_time - self.last_update >= self.update_interval:
self.update_mouse_position()
self.last_update = current_time
except OSError as e:
if e.errno == 19: # ENODEV
logger.info("Gamepad disconnected during monitoring")
else:
logger.error(f"IOError in gamepad monitoring: {e}")
self.gamepad = None
self.stick_x_raw = 0
self.stick_y_raw = 0
self.scroll_accumulator = 0.0
self.start_held = False
self.guide_held = False
self.emulation_triggered = False
break
except Exception as ex:
logger.error(f"Unexpected error in gamepad monitoring: {ex}")
break
else:
time.sleep(0.1)
if not self.running:
break
except Exception as e: except Exception as e:
logger.error(f"Error in gamepad monitoring: {e}", exc_info=True) logger.error(f"Error in gamepad monitoring thread: {e}", exc_info=True)
finally: finally:
if self.gamepad: if self.gamepad:
try: try:
@@ -1433,18 +1957,43 @@ class InputManager(QObject):
except Exception: except Exception:
pass pass
self.gamepad = None self.gamepad = None
self.start_held = False
self.guide_held = False
self.emulation_triggered = False
def cleanup(self) -> None: def cleanup(self) -> None:
"""
Корректное завершение работы с геймпадом и udev монитором.
"""
try: try:
# Mouse emulation cleanup
self.disable_mouse_emulation()
# Stop focus check timer
self.focus_check_timer.stop()
# Флаг для остановки udev monitor loop
self.running = False self.running = False
# Останавливаем все таймеры
if hasattr(self, 'gamepad_check_timer'):
self.gamepad_check_timer.stop()
self.dpad_timer.stop() self.dpad_timer.stop()
self.nav_timer.stop() self.nav_timer.stop()
# Очистка геймпада
self.stop_rumble() self.stop_rumble()
if self.gamepad_thread: if self.gamepad_thread:
self.gamepad_thread.join() self.gamepad_thread.join(timeout=2.0)
if self.gamepad: if self.gamepad:
self.gamepad.close() self.gamepad.close()
self.gamepad = None self.gamepad = None
self.gamepad_type = GamepadType.UNKNOWN self.gamepad_type = GamepadType.UNKNOWN
logger.info("Gamepad cleanup completed")
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True) logger.error(f"Error during cleanup: {e}", exc_info=True)

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-15 15:31+0500\n" "POT-Creation-Date: 2025-11-11 17:00+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n" "Language: de_DE\n"
@@ -76,10 +76,6 @@ msgstr ""
msgid "Legendary executable not found at {path}" msgid "Legendary executable not found at {path}"
msgstr "" msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success" msgid "Success"
msgstr "" msgstr ""
@@ -124,6 +120,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites" msgid "Removed '{game_name}' from favorites"
msgstr "" msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Launch game \"{name}\" with PortProton" msgid "Launch game \"{name}\" with PortProton"
msgstr "" msgstr ""
@@ -252,13 +252,37 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format msgid "Open"
msgid "Launching {0}" msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
@@ -326,12 +350,6 @@ msgstr ""
msgid "Settings" msgid "Settings"
msgstr "" msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -347,6 +365,39 @@ msgstr ""
msgid "Components installed successfully." msgid "Components installed successfully."
msgstr "" 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..." msgid "Loading Epic Games Store games..."
msgstr "" msgstr ""
@@ -494,14 +545,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?" msgid "Are you sure you want to clear prefix '{}'?"
msgstr "" msgstr ""
#, python-brace-format msgid "Clearing prefix..."
msgid "Prefix '{}' cleared successfully." msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "" msgid "Prefix clear failed with exit code {}."
"Prefix '{}' cleared with errors:\n" msgstr ""
"{}"
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr "" msgstr ""
msgid "Failed to start backup process." msgid "Failed to start backup process."
@@ -606,6 +664,12 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" msgstr ""
@@ -680,6 +744,10 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -738,6 +806,232 @@ msgstr ""
msgid "File not found: {0}" msgid "File not found: {0}"
msgstr "" 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" msgid "Reboot"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-15 15:31+0500\n" "POT-Creation-Date: 2025-11-11 17:00+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n" "Language: es_ES\n"
@@ -76,10 +76,6 @@ msgstr ""
msgid "Legendary executable not found at {path}" msgid "Legendary executable not found at {path}"
msgstr "" msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success" msgid "Success"
msgstr "" msgstr ""
@@ -124,6 +120,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites" msgid "Removed '{game_name}' from favorites"
msgstr "" msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Launch game \"{name}\" with PortProton" msgid "Launch game \"{name}\" with PortProton"
msgstr "" msgstr ""
@@ -252,13 +252,37 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format msgid "Open"
msgid "Launching {0}" msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
@@ -326,12 +350,6 @@ msgstr ""
msgid "Settings" msgid "Settings"
msgstr "" msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -347,6 +365,39 @@ msgstr ""
msgid "Components installed successfully." msgid "Components installed successfully."
msgstr "" 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..." msgid "Loading Epic Games Store games..."
msgstr "" msgstr ""
@@ -494,14 +545,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?" msgid "Are you sure you want to clear prefix '{}'?"
msgstr "" msgstr ""
#, python-brace-format msgid "Clearing prefix..."
msgid "Prefix '{}' cleared successfully." msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "" msgid "Prefix clear failed with exit code {}."
"Prefix '{}' cleared with errors:\n" msgstr ""
"{}"
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr "" msgstr ""
msgid "Failed to start backup process." msgid "Failed to start backup process."
@@ -606,6 +664,12 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" msgstr ""
@@ -680,6 +744,10 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -738,6 +806,232 @@ msgstr ""
msgid "File not found: {0}" msgid "File not found: {0}"
msgstr "" 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" msgid "Reboot"
msgstr "" msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n" "Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-15 15:31+0500\n" "POT-Creation-Date: 2025-11-11 17:00+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -74,10 +74,6 @@ msgstr ""
msgid "Legendary executable not found at {path}" msgid "Legendary executable not found at {path}"
msgstr "" msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
msgid "Success" msgid "Success"
msgstr "" msgstr ""
@@ -122,6 +118,10 @@ msgstr ""
msgid "Removed '{game_name}' from favorites" msgid "Removed '{game_name}' from favorites"
msgstr "" msgstr ""
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr ""
#, python-brace-format #, python-brace-format
msgid "Launch game \"{name}\" with PortProton" msgid "Launch game \"{name}\" with PortProton"
msgstr "" msgstr ""
@@ -250,13 +250,37 @@ msgstr ""
msgid "Select All" msgid "Select All"
msgstr "" msgstr ""
#, python-brace-format msgid "Open"
msgid "Launching {0}" msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr "" msgstr ""
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer" msgid "File Explorer"
msgstr "" msgstr ""
@@ -324,12 +348,6 @@ msgstr ""
msgid "Settings" msgid "Settings"
msgstr "" msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "" msgstr ""
@@ -345,6 +363,39 @@ msgstr ""
msgid "Components installed successfully." msgid "Components installed successfully."
msgstr "" 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..." msgid "Loading Epic Games Store games..."
msgstr "" msgstr ""
@@ -492,14 +543,21 @@ msgstr ""
msgid "Are you sure you want to clear prefix '{}'?" msgid "Are you sure you want to clear prefix '{}'?"
msgstr "" msgstr ""
#, python-brace-format msgid "Clearing prefix..."
msgid "Prefix '{}' cleared successfully." msgstr ""
msgid "Failed to start prefix clear process."
msgstr ""
msgid "Prefix cleared successfully."
msgstr "" msgstr ""
#, python-brace-format #, python-brace-format
msgid "" msgid "Prefix clear failed with exit code {}."
"Prefix '{}' cleared with errors:\n" msgstr ""
"{}"
#, python-brace-format
msgid "Failed to run clear prefix command: {}"
msgstr "" msgstr ""
msgid "Failed to start backup process." msgid "Failed to start backup process."
@@ -604,6 +662,12 @@ msgstr ""
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "" msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "" msgstr ""
@@ -678,6 +742,10 @@ msgstr ""
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "" msgstr ""
#, python-brace-format
msgid "Executable not found: {0}"
msgstr ""
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "" msgstr ""
@@ -736,6 +804,232 @@ msgstr ""
msgid "File not found: {0}" msgid "File not found: {0}"
msgstr "" 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" msgid "Reboot"
msgstr "" msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PROJECT VERSION\n" "Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-15 15:31+0500\n" "POT-Creation-Date: 2025-11-11 17:00+0500\n"
"PO-Revision-Date: 2025-10-15 15:31+0500\n" "PO-Revision-Date: 2025-11-11 17:00+0500\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language: ru_RU\n" "Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n" "Language-Team: ru_RU <LL@li.org>\n"
@@ -77,10 +77,6 @@ msgstr "Остановлен(а) '{game_name}'"
msgid "Legendary executable not found at {path}" msgid "Legendary executable not found at {path}"
msgstr "Legendary не найден по пути {path}" msgstr "Legendary не найден по пути {path}"
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr "start.sh не найден по адресу {path}"
msgid "Success" msgid "Success"
msgstr "Успешно" msgstr "Успешно"
@@ -127,6 +123,10 @@ msgstr "'{game_name}' был(а) добавлен(а) в избранное"
msgid "Removed '{game_name}' from favorites" msgid "Removed '{game_name}' from favorites"
msgstr "'{game_name}' был(а) удалён(а) из избранного" msgstr "'{game_name}' был(а) удалён(а) из избранного"
#, python-brace-format
msgid "start.sh not found at {path}"
msgstr "start.sh не найден по адресу {path}"
#, python-brace-format #, python-brace-format
msgid "Launch game \"{name}\" with PortProton" msgid "Launch game \"{name}\" with PortProton"
msgstr "Запустить игру \"{name}\" с помощью PortProton" msgstr "Запустить игру \"{name}\" с помощью PortProton"
@@ -259,13 +259,37 @@ msgstr "Удалить"
msgid "Select All" msgid "Select All"
msgstr "Выбрать всё" msgstr "Выбрать всё"
#, python-brace-format msgid "Open"
msgid "Launching {0}" msgstr "Открыть"
msgstr "Идёт запуск {0}"
msgid "Select Dir"
msgstr "Выбрать папку"
msgid "Prev Dir"
msgstr "Предыдущий каталог"
msgid "Cancel" msgid "Cancel"
msgstr "Отмена" msgstr "Отмена"
msgid "Toggle"
msgstr "Переключить"
msgid "Install"
msgstr "Установить"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Prev Tab"
msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "File Explorer" msgid "File Explorer"
msgstr "Проводник" msgstr "Проводник"
@@ -333,12 +357,6 @@ msgstr "Шрифты"
msgid "Settings" msgid "Settings"
msgstr "Настройки" msgstr "Настройки"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Install"
msgstr "Установить"
msgid "Winetricks not found. Please try again." msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку." msgstr "Winetricks не найден. Повторите попытку."
@@ -354,6 +372,39 @@ msgstr "Установка не удалась. Проверьте журнал
msgid "Components installed successfully." msgid "Components installed successfully."
msgstr "Компоненты успешно установлены." 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..." msgid "Loading Epic Games Store games..."
msgstr "Загрузка игр из Epic Games Store..." msgstr "Загрузка игр из Epic Games Store..."
@@ -501,17 +552,22 @@ msgstr "Подтвердите очистку"
msgid "Are you sure you want to clear prefix '{}'?" msgid "Are you sure you want to clear prefix '{}'?"
msgstr "Вы уверены, что хотите очистить префикс «{}»?" msgstr "Вы уверены, что хотите очистить префикс «{}»?"
#, python-brace-format msgid "Clearing prefix..."
msgid "Prefix '{}' cleared successfully." msgstr "Очистка префикса..."
msgstr "Префикс '{}' успешно удален."
msgid "Failed to start prefix clear process."
msgstr "Не удалось запустить процесс очистки префикса."
msgid "Prefix cleared successfully."
msgstr "Префикс удален успешно."
#, python-brace-format #, python-brace-format
msgid "" msgid "Prefix clear failed with exit code {}."
"Prefix '{}' cleared with errors:\n" msgstr "Очистка префикса завершилась с кодом завершения {}."
"{}"
msgstr "" #, python-brace-format
"Префикс '{}' очищен с ошибками:\n" msgid "Failed to run clear prefix command: {}"
"{}" msgstr "Не удалось выполнить команду очистки префикса: {}"
msgid "Failed to start backup process." msgid "Failed to start backup process."
msgstr "Не удалось запустить процесс резервного копирования." msgstr "Не удалось запустить процесс резервного копирования."
@@ -615,6 +671,12 @@ msgstr "Запуск приложения в полноэкранном режи
msgid "Application Fullscreen Mode:" msgid "Application Fullscreen Mode:"
msgstr "Режим полноэкранного отображения приложения:" msgstr "Режим полноэкранного отображения приложения:"
msgid "Minimize to tray on close"
msgstr "Сворачивать в трей при закрытии"
msgid "Application Close Mode:"
msgstr "Режим закрытия приложения:"
msgid "Auto Fullscreen on Gamepad connected" msgid "Auto Fullscreen on Gamepad connected"
msgstr "Режим полноэкранного отображения приложения при подключении геймпада" msgstr "Режим полноэкранного отображения приложения при подключении геймпада"
@@ -691,6 +753,10 @@ msgstr "Тема '{0}' применена успешно"
msgid "Error applying theme '{0}'" msgid "Error applying theme '{0}'"
msgstr "Ошибка при применение темы '{0}'" msgstr "Ошибка при применение темы '{0}'"
#, python-brace-format
msgid "Executable not found: {0}"
msgstr "Исполняемый файл не найден: {0}"
msgid "LAST LAUNCH" msgid "LAST LAUNCH"
msgstr "Последний запуск" msgstr "Последний запуск"
@@ -749,6 +815,288 @@ msgstr "Неправильный формат команды (flatpak)"
msgid "File not found: {0}" msgid "File not found: {0}"
msgstr "Файл не найден: {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" msgid "Reboot"
msgstr "Перезагрузить" msgstr "Перезагрузить"

View File

@@ -8,7 +8,7 @@ import psutil
import re import re
from portprotonqt.logger import get_logger 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.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout 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, 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_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
save_fullscreen_config, read_window_geometry, save_window_geometry, reset_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, read_gamepad_type, save_gamepad_type 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.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.howlongtobeat_api import HowLongToBeat from portprotonqt.howlongtobeat_api import HowLongToBeat
@@ -39,7 +40,7 @@ from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.virtual_keyboard import VirtualKeyboard from portprotonqt.virtual_keyboard import VirtualKeyboard
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, 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.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from typing import cast from typing import cast
@@ -63,6 +64,7 @@ class MainWindow(QMainWindow):
self.theme = self.theme_manager.apply_theme(selected_theme) self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name) self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size() self.card_width = read_card_size()
self.auto_card_width = read_auto_card_size()
self._last_card_width = self.card_width self._last_card_width = self.card_width
self.setWindowTitle(f"{app_name} {version}") self.setWindowTitle(f"{app_name} {version}")
self.setMinimumSize(800, 600) self.setMinimumSize(800, 600)
@@ -72,6 +74,7 @@ class MainWindow(QMainWindow):
self.target_exe = None self.target_exe = None
self.current_running_button = None self.current_running_button = None
self.portproton_location = get_portproton_location() self.portproton_location = get_portproton_location()
self.start_sh = get_portproton_start_command()
self.game_library_manager = GameLibraryManager(self, self.theme, None) self.game_library_manager = GameLibraryManager(self, self.theme, None)
@@ -260,6 +263,10 @@ class MainWindow(QMainWindow):
GamepadType.XBOX: "xbox_y", GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square", GamepadType.PLAYSTATION: "ps_square",
}, },
'prev_dir': {
GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square",
},
} }
return mappings.get(action, {}).get(gtype, "placeholder") return mappings.get(action, {}).get(gtype, "placeholder")
@@ -452,11 +459,11 @@ class MainWindow(QMainWindow):
self.current_install_script = script_name self.current_install_script = script_name
self.seen_progress = False self.seen_progress = False
self.current_percent = 0.0 self.current_percent = 0.0
start_sh = os.path.join(self.portproton_location or "", "data", "scripts", "start.sh") if self.portproton_location else "" start_sh = self.start_sh
if not os.path.exists(start_sh): if not start_sh:
self.installing = False self.installing = False
return return
cmd = [start_sh, "cli", "--autoinstall", script_name] cmd = start_sh + ["cli", "--autoinstall", script_name]
self.install_process = QProcess(self) self.install_process = QProcess(self)
self.install_process.finished.connect(self.on_install_finished) self.install_process.finished.connect(self.on_install_finished)
self.install_process.errorOccurred.connect(self.on_install_error) self.install_process.errorOccurred.connect(self.on_install_error)
@@ -558,6 +565,16 @@ class MainWindow(QMainWindow):
self.game_library_manager.set_games(games) self.game_library_manager.set_games(games)
self.progress_bar.setVisible(False) 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): def open_portproton_forum_topic(self, topic_name: str):
"""Open the PortProton forum topic or search page for this game.""" """Open the PortProton forum topic or search page for this game."""
result = self.portproton_api.get_forum_topic_slug(topic_name) result = self.portproton_api.get_forum_topic_slug(topic_name)
@@ -874,7 +891,16 @@ class MainWindow(QMainWindow):
self.addGameButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE) self.addGameButton.setStyleSheet(self.theme.ADDGAME_BACK_BUTTON_STYLE)
self.addGameButton.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.addGameButton.setFocusPolicy(Qt.FocusPolicy.NoFocus)
self.addGameButton.clicked.connect(self.openAddGameDialog) 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) self.searchEdit = CustomLineEdit(self, theme=self.theme)
icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search")) icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search"))
@@ -894,6 +920,32 @@ class MainWindow(QMainWindow):
layout.addWidget(self.searchEdit) layout.addWidget(self.searchEdit)
return self.container, 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): def on_search_text_changed(self, text: str):
"""Search text change handler with debounce.""" """Search text change handler with debounce."""
self.searchDebounceTimer.stop() self.searchDebounceTimer.stop()
@@ -1096,8 +1148,7 @@ class MainWindow(QMainWindow):
autoInstallPage = QWidget() autoInstallPage = QWidget()
autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE) autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
autoInstallLayout = QVBoxLayout(autoInstallPage) autoInstallLayout = QVBoxLayout(autoInstallPage)
autoInstallLayout.setContentsMargins(20, 0, 20, 0) autoInstallLayout.setSpacing(15)
autoInstallLayout.setSpacing(0)
# Верхняя панель с заголовком и поиском # Верхняя панель с заголовком и поиском
headerWidget = QWidget() headerWidget = QWidget()
@@ -1146,6 +1197,25 @@ class MainWindow(QMainWindow):
autoInstallLayout.addWidget(self.autoInstallScrollArea) 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.autoInstallGameCards = {}
self.allAutoInstallCards = [] self.allAutoInstallCards = []
@@ -1155,7 +1225,7 @@ class MainWindow(QMainWindow):
if exe_name in self.autoInstallGameCards and local_path: if exe_name in self.autoInstallGameCards and local_path:
card = self.autoInstallGameCards[exe_name] card = self.autoInstallGameCards[exe_name]
card.cover_path = local_path 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]): def on_autoinstall_games_loaded(games: list[tuple]):
@@ -1191,7 +1261,7 @@ class MainWindow(QMainWindow):
None, None, None, game_source, None, None, None, game_source,
select_callback=select_callback, select_callback=select_callback,
theme=self.theme, theme=self.theme,
card_width=self.card_width, card_width=self.auto_card_width,
parent=self.autoInstallContainer, parent=self.autoInstallContainer,
) )
@@ -1229,10 +1299,30 @@ class MainWindow(QMainWindow):
# Показываем прогресс # Показываем прогресс
self.autoInstallProgress.setVisible(True) self.autoInstallProgress.setVisible(True)
self.autoInstallProgress.setRange(0, 0) 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) 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): def filterAutoInstallGames(self):
"""Filter auto install game cards based on search text.""" """Filter auto install game cards based on search text."""
search_text = self.autoInstallSearchLineEdit.text().lower().strip() search_text = self.autoInstallSearchLineEdit.text().lower().strip()
@@ -1380,12 +1470,10 @@ class MainWindow(QMainWindow):
prefix = self.prefixCombo.currentText() prefix = self.prefixCombo.currentText()
if not wine or not prefix: if not wine or not prefix:
return return
if not self.portproton_location: if not self.portproton_location or not self.start_sh:
return return
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh") start_sh = self.start_sh
if not os.path.exists(start_sh): cmd = start_sh + ["cli", cli_arg, wine, prefix]
return
cmd = [start_sh, "cli", cli_arg, wine, prefix]
# Показываем прогресс-бар перед запуском # Показываем прогресс-бар перед запуском
self.wine_progress_bar.setVisible(True) self.wine_progress_bar.setVisible(True)
@@ -1464,12 +1552,13 @@ class MainWindow(QMainWindow):
QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}") QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
def clear_prefix(self): def clear_prefix(self):
"""Очистка префикса (позже удалить).""" """Очищает префикс"""
selected_prefix = self.prefixCombo.currentText() selected_prefix = self.prefixCombo.currentText()
selected_wine = self.wineCombo.currentText() selected_wine = self.wineCombo.currentText()
if not selected_prefix or not selected_wine: if not selected_prefix or not selected_wine:
return return
if not self.portproton_location: if not self.portproton_location or not self.start_sh:
return return
reply = QMessageBox.question( reply = QMessageBox.question(
@@ -1482,98 +1571,35 @@ class MainWindow(QMainWindow):
if reply != QMessageBox.StandardButton.Yes: if reply != QMessageBox.StandardButton.Yes:
return return
prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix) start_sh = self.start_sh
if not os.path.exists(prefix_dir):
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 return
success = True def _on_clear_prefix_finished(self, exitCode):
errors = [] self.wine_progress_bar.setVisible(False)
self.update_status_message.emit("", 0)
# Удаление файлов if exitCode == 0:
files_to_remove = [ QMessageBox.information(self, _("Success"), _("Prefix cleared successfully."))
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))
else: else:
error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5])) QMessageBox.warning(self, _("Error"), _("Prefix clear failed with exit code {}.").format(exitCode))
QMessageBox.warning(self, _("Warning"), error_msg)
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): def create_prefix_backup(self):
selected_prefix = self.prefixCombo.currentText() selected_prefix = self.prefixCombo.currentText()
@@ -1585,14 +1611,12 @@ class MainWindow(QMainWindow):
def _perform_backup(self, backup_dir, prefix_name): def _perform_backup(self, backup_dir, prefix_name):
os.makedirs(backup_dir, exist_ok=True) os.makedirs(backup_dir, exist_ok=True)
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 return
start_sh = self.start_sh
self.backup_process = QProcess(self) self.backup_process = QProcess(self)
self.backup_process.finished.connect(lambda exitCode, exitStatus: self._on_backup_finished(exitCode)) 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:]) self.backup_process.start(cmd[0], cmd[1:])
if not self.backup_process.waitForStarted(): if not self.backup_process.waitForStarted():
QMessageBox.warning(self, _("Error"), _("Failed to start backup process.")) QMessageBox.warning(self, _("Error"), _("Failed to start backup process."))
@@ -1605,14 +1629,12 @@ class MainWindow(QMainWindow):
def _perform_restore(self, file_path): def _perform_restore(self, file_path):
if not file_path or not os.path.exists(file_path): if not file_path or not os.path.exists(file_path):
return 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 return
start_sh = self.start_sh
self.restore_process = QProcess(self) self.restore_process = QProcess(self)
self.restore_process.finished.connect(lambda exitCode, exitStatus: self._on_restore_finished(exitCode)) 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:]) self.restore_process.start(cmd[0], cmd[1:])
if not self.restore_process.waitForStarted(): if not self.restore_process.waitForStarted():
QMessageBox.warning(self, _("Error"), _("Failed to start restore process.")) QMessageBox.warning(self, _("Error"), _("Failed to start restore process."))
@@ -1856,7 +1878,19 @@ class MainWindow(QMainWindow):
self.fullscreenCheckBox.setChecked(current_fullscreen) self.fullscreenCheckBox.setChecked(current_fullscreen)
formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox) formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox)
# 7. 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 = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -1868,7 +1902,7 @@ class MainWindow(QMainWindow):
self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen) self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox) formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
# 8. Gamepad haptic feedback config # 9. Gamepad haptic feedback config
self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback")) self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus) self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE) self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
@@ -2270,6 +2304,14 @@ class MainWindow(QMainWindow):
def darkenColor(self, color, factor=200): def darkenColor(self, color, factor=200):
return color.darker(factor) 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=""): 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() detailPage = QWidget()
self._animations = {} self._animations = {}
@@ -2572,8 +2614,6 @@ class MainWindow(QMainWindow):
clear_layout(hltbLayout) clear_layout(hltbLayout)
has_data = False has_data = False
if main_story_time is not None: if main_story_time is not None:
@@ -2647,6 +2687,7 @@ class MainWindow(QMainWindow):
file_to_check = entry_exec_split[0] file_to_check = entry_exec_split[0]
current_exe = os.path.basename(file_to_check) if file_to_check else None 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: if self.target_exe is not None and current_exe == self.target_exe:
playButton = AutoSizeButton(_("Stop"), icon=self.theme_manager.get_icon("stop")) playButton = AutoSizeButton(_("Stop"), icon=self.theme_manager.get_icon("stop"))
else: else:
@@ -2655,7 +2696,17 @@ class MainWindow(QMainWindow):
playButton.setFixedSize(120, 40) playButton.setFixedSize(120, 40)
playButton.setStyleSheet(self.theme.PLAY_BUTTON_STYLE) playButton.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
playButton.clicked.connect(lambda: self.toggleGame(exec_line, playButton)) 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) contentFrameLayout.addWidget(detailsWidget)
mainLayout.addStretch() mainLayout.addStretch()
@@ -2879,10 +2930,7 @@ class MainWindow(QMainWindow):
env_vars = os.environ.copy() env_vars = os.environ.copy()
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
wrapper = "flatpak run ru.linux_gaming.PortProton" wrapper = self.start_sh or ""
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
cmd = [wrapper, game_exe] cmd = [wrapper, game_exe]
@@ -2976,13 +3024,6 @@ class MainWindow(QMainWindow):
exe_name = os.path.splitext(current_exe)[0] exe_name = os.path.splitext(current_exe)[0]
env_vars = os.environ.copy() 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: try:
process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid) process = subprocess.Popen(entry_exec_split, env=env_vars, shell=False, preexec_fn=os.setsid)
@@ -3004,57 +3045,77 @@ class MainWindow(QMainWindow):
logger.error(f"Failed to launch game {exe_name}: {e}") logger.error(f"Failed to launch game {exe_name}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e))) QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
def closeEvent(self, event): def closeEvent(self, event):
"""Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход.""" """Обработчик закрытия окна: проверяет настройку minimize_to_tray.
if hasattr(self, 'is_exiting') and self.is_exiting: Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
# Принудительное закрытие: завершаем процессы и приложение """
for proc in self.game_processes: minimize_to_tray = read_minimize_to_tray()
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}")
self.game_processes = [] # Очищаем список процессов if minimize_to_tray:
# Просто сворачиваем в трей
# Очищаем таймеры
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()
event.ignore() 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 time
import glob import glob
import re import re
import hashlib
from collections.abc import Callable from collections.abc import Callable
from PySide6.QtCore import QThread, Signal
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.logger import get_logger from portprotonqt.logger import get_logger
from portprotonqt.config_utils import get_portproton_location from portprotonqt.config_utils import get_portproton_location
logger = get_logger(__name__) logger = get_logger(__name__)
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
AUTOINSTALL_CACHE_DURATION = 3600 # 1 hour for autoinstall cache
def normalize_name(s): def normalize_name(s):
""" """
@@ -59,6 +62,7 @@ class PortProtonAPI:
self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 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.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
self._topics_data = None self._topics_data = None
self._autoinstall_cache = None # New: In-memory cache
def _get_game_dir(self, exe_name: str) -> str: def _get_game_dir(self, exe_name: str) -> str:
game_dir = os.path.join(self.custom_data_dir, exe_name) 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}") logger.error(f"Failed to parse {file_path}: {e}")
return None, None return None, None
def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None: def _compute_scripts_signature(self, auto_dir: str) -> str:
"""Load auto-install games with user/builtin covers (no async download here).""" """Compute a hash-based signature of the autoinstall scripts to detect changes."""
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): if not os.path.exists(auto_dir):
callback(games) return ""
return
scripts = sorted(glob.glob(os.path.join(auto_dir, "*"))) scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
if not scripts: # Simple hash: concatenate sorted filenames and hash
callback(games) filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
return return hashlib.md5(filenames_str.encode()).hexdigest()
xdg_data_home = os.getenv("XDG_DATA_HOME", def _load_autoinstall_cache(self):
os.path.join(os.path.expanduser("~"), ".local", "share")) """Load cached autoinstall games if fresh and scripts unchanged."""
base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall") if self._autoinstall_cache is not None:
os.makedirs(base_autoinstall_dir, exist_ok=True) 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: def _save_autoinstall_cache(self, games):
display_name, exe_name = self.parse_autoinstall_script(script_path) """Save parsed autoinstall games to cache with scripts signature."""
script_name = os.path.splitext(os.path.basename(script_path))[0] 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): def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
continue """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 # No cache: Start background thread
user_game_folder = os.path.join(base_autoinstall_dir, exe_name) class AutoinstallWorker(QThread):
os.makedirs(user_game_folder, exist_ok=True) finished = Signal(list)
api: "PortProtonAPI"
portproton_location: str | None
# Поиск обложки def run(self):
cover_path = "" games = []
user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set() auto_dir = os.path.join(
for ext in [".jpg", ".png", ".jpeg", ".bmp"]: self.portproton_location or "", "data", "scripts", "pw_autoinstall"
candidate = f"cover{ext}" ) if self.portproton_location else ""
if candidate in user_files: if not os.path.exists(auto_dir):
cover_path = os.path.join(user_game_folder, candidate) self.finished.emit(games)
break return
if not cover_path: scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
logger.debug(f"No local cover found for autoinstall {exe_name}") if not scripts:
self.finished.emit(games)
return
# Формируем кортеж игры (добавлен exe_name в конец) xdg_data_home = os.getenv(
game_tuple = ( "XDG_DATA_HOME",
display_name, # name os.path.join(os.path.expanduser("~"), ".local", "share"),
"", # description )
cover_path, # cover base_autoinstall_dir = os.path.join(
"", # appid xdg_data_home, "PortProtonQt", "custom_data", "autoinstall"
f"autoinstall:{script_name}", # exec_line )
"", # controller_support os.makedirs(base_autoinstall_dir, exist_ok=True)
"Never", # last_launch
"0h 0m", # formatted_playtime
"", # protondb_tier
"", # anticheat_status
0, # last_played
0, # playtime_seconds
"autoinstall", # game_source
exe_name # exe_name
)
games.append(game_tuple)
callback(games) 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): def _load_topics_data(self):
"""Load and cache linux_gaming_topics_min.json from the archive.""" """Load and cache linux_gaming_topics_min.json from the archive."""

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.localization import get_steam_language
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.dialogs import generate_thumbnail 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 from collections.abc import Callable
import re import re
import shutil import shutil
@@ -23,6 +23,7 @@ import requests
import random import random
import base64 import base64
import glob import glob
import urllib.parse
downloader = Downloader() downloader = Downloader()
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -411,6 +412,39 @@ def save_app_details(app_id, data):
with open(cache_file, "wb") as f: with open(cache_file, "wb") as f:
f.write(orjson.dumps(data)) 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]): def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
""" """
Asynchronously fetches detailed app info from Steam API. 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", "")) title = decode_text(app_info.get("name", ""))
description = decode_text(app_info.get("short_description", "")) description = decode_text(app_info.get("short_description", ""))
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg" 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_protondb_tier(tier: str):
def on_anticheat_status(anticheat_status: 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() game_name = desktop_name or exe_name.capitalize()
if not matching_app: 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): def on_anticheat_status(anticheat_status: str):
callback({ callback({
"appid": "", "appid": "",
"name": decode_text(game_name), "name": decode_text(game_name),
"description": "", "description": "",
"cover": "", "cover": cover,
"controller_support": "", "controller_support": "",
"protondb_tier": "", "protondb_tier": "",
"steam_game": "false", "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)) title = decode_text(app_info.get("name", game_name))
description = decode_text(app_info.get("short_description", "")) description = decode_text(app_info.get("short_description", ""))
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg" 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", "") controller_support = app_info.get("controller_support", "")
def on_protondb_tier(tier: str): 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}") return (False, f"Executable file not found: {exe_path}")
portproton_dir = get_portproton_location() 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") logger.error("PortProton directory not found")
return (False, "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()) safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh") 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): if not os.path.exists(script_path):
script_content = f"""#!/usr/bin/env bash script_content = f"""#!/usr/bin/env bash
export LD_PRELOAD= export LD_PRELOAD=
export START_FROM_STEAM=1 export START_FROM_STEAM=1
"{start_sh_path}" "{exe_path}" "$@" "{start_sh}" "{exe_path}" "$@"
""" """
try: try:
with open(script_path, "w", encoding="utf-8") as f: 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 PySide6.QtGui import QIcon, QFontDatabase, QPixmap
from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo from portprotonqt.config_utils import save_theme_to_config, load_theme_metainfo
# Icon caching for performance optimization
_icon_cache = {}
logger = get_logger(__name__) logger = get_logger(__name__)
# Папка, где располагаются все дополнительные темы # Папка, где располагаются все дополнительные темы
@@ -232,6 +235,14 @@ class ThemeManager:
а если файл не найден, то из стандартной темы. а если файл не найден, то из стандартной темы.
Если as_path=True, возвращает путь к иконке вместо QIcon. Если 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 icon_path = None
theme_name = theme_name or self.current_theme_name theme_name = theme_name or self.current_theme_name
supported_extensions = ['.svg', '.png', '.jpg', '.jpeg'] supported_extensions = ['.svg', '.png', '.jpg', '.jpeg']
@@ -279,12 +290,20 @@ class ThemeManager:
# Если иконка всё равно не найдена # Если иконка всё равно не найдена
if not icon_path or not os.path.exists(icon_path): if not icon_path or not os.path.exists(icon_path):
logger.error(f"Warning: icon '{icon_name}' not found") logger.error(f"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: if as_path:
# Cache the path
_icon_cache[cache_key] = icon_path
return 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): 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""" WINETRICKS_TAB_STYLE = f"""
QTabWidget::pane {{ QTabWidget::pane {{
border: 1px solid {color_d}; border-top: 1px solid {color_c};
background: {color_b}; background: {color_h};
border-radius: {border_radius_a};
}} }}
QTabBar::tab {{ QTabBar::tab {{
background: {color_c}; background: {color_c};
@@ -985,15 +984,113 @@ QTabBar::tab:selected {{
color: {color_f}; color: {color_f};
}} }}
QTabBar::tab:hover {{ QTabBar::tab:hover {{
background: {color_e}; background: {color_a};
}} }}
""" """
WINETRICKS_TABBLE_STYLE = f""" WINETRICKS_TABBLE_STYLE = f"""
QTableWidget {{ QComboBox {{
background: {color_c}; background: {color_c};
border: {border_c} {color_g};
border-radius: {border_radius_a};
padding-left: 12px;
color: {color_f}; 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}; alternate-background-color: {color_d};
border: {border_a}; border: {border_a};
border-radius: {border_radius_a}; border-radius: {border_radius_a};
@@ -1009,39 +1106,68 @@ QHeaderView::section {{
}} }}
QTableWidget::item {{ QTableWidget::item {{
padding: 8px; 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}; background: {color_a};
color: {color_f}; color: {color_f};
selection-background-color: {color_a};
}} }}
QTableWidget::item:hover {{ QTableWidget::item:hover {{
background: {color_e}; background: {color_h};
}} }}
QTableWidget::indicator {{ QTableWidget::indicator {{
width: 24px; width: 24px;
height: 24px; height: 24px;
border: {border_b} {color_a}; border: {border_c} {color_h};
border-radius: {border_radius_a}; border-radius: {border_radius_a};
background: rgba(255, 255, 255, 0.1); background: {color_b};
}} }}
QTableWidget::indicator:unchecked {{ QTableWidget::indicator:unchecked {{
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
image: none; image: none;
}} }}
QTableWidget::indicator:checked {{ QTableWidget::indicator:checked {{
background: {color_a}; background: {color_b};
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)}); 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 {{ QTableWidget::indicator:hover {{
background: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.2);
border: {border_b} {color_a};
}}
QTableWidget::indicator:focus {{
border: {border_c} {color_a}; 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""" 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, from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
QSizePolicy, QWidget, QLineEdit) 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.keyboard_layouts import keyboard_layouts
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config from portprotonqt.config_utils import read_theme_from_config
@@ -43,6 +44,18 @@ class VirtualKeyboard(QFrame):
self.margins = 10 self.margins = 10
self.num_cols = 14 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.initUI()
self.hide() self.hide()
@@ -119,6 +132,34 @@ class VirtualKeyboard(QFrame):
self.buttons: dict[str, QPushButton] = {} self.buttons: dict[str, QPushButton] = {}
self.update_keyboard() 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): def update_keyboard(self):
coords = self._save_focused_coords() coords = self._save_focused_coords()
@@ -151,6 +192,9 @@ class VirtualKeyboard(QFrame):
button.setCheckable(True) button.setCheckable(True)
button.setChecked(self.shift_pressed) button.setChecked(self.shift_pressed)
button.clicked.connect(lambda checked: self.on_shift_click(checked)) 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: else:
button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k)) button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
@@ -163,6 +207,9 @@ class VirtualKeyboard(QFrame):
shift.setCheckable(True) shift.setCheckable(True)
shift.setChecked(self.shift_pressed) shift.setChecked(self.shift_pressed)
shift.clicked.connect(lambda checked: self.on_shift_click(checked)) 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) self.keyboard_layout.addWidget(shift, 3, 11, 1, 3)
button = QPushButton('CAPS') button = QPushButton('CAPS')
@@ -179,6 +226,9 @@ class VirtualKeyboard(QFrame):
backspace.setFixedSize(fixed_w, fixed_h) backspace.setFixedSize(fixed_w, fixed_h)
backspace.pressed.connect(self.on_backspace_pressed) backspace.pressed.connect(self.on_backspace_pressed)
backspace.released.connect(self.stop_backspace_repeat) 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) self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1)
enter = QPushButton('Enter') enter = QPushButton('Enter')
@@ -189,6 +239,9 @@ class VirtualKeyboard(QFrame):
lang = QPushButton('🌐') lang = QPushButton('🌐')
lang.setFixedSize(fixed_w, fixed_h) lang.setFixedSize(fixed_w, fixed_h)
lang.clicked.connect(self.on_lang_click) 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) self.keyboard_layout.addWidget(lang, 4, 0, 1, 1)
clear = QPushButton('Clear') clear = QPushButton('Clear')
@@ -219,6 +272,9 @@ class VirtualKeyboard(QFrame):
hide_button = QPushButton('Hide') hide_button = QPushButton('Hide')
hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
hide_button.clicked.connect(self.hide) 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) self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2)
if coords: if coords:

View File

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

View File

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

978
uv.lock generated

File diff suppressed because it is too large Load Diff