60 Commits

Author SHA1 Message Date
7df6ad3b80 feat(autoinstalls): added slider
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 13:55:17 +05:00
464ad0fe9c chore: optimize and clean code
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 13:09:02 +05:00
cde92885d4 feat(virtual_keybord): added gamepad hint
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-17 00:04:47 +05:00
120c7b319c fix: improve gamepad detection using udev ID_INPUT_JOYSTICK property 2025-10-16 23:20:48 +05:00
596aed0077 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:54:30 +05:00
6fc6cb1e02 feat: added minimize to tray
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:53:08 +05:00
186e28a19b fix(gamepad): resolve MonitorObserver blocking issue causing application hang
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 14:43:49 +05:00
28e4d1e77c Revert "chore: broke autorelease for tasting purpose"
This reverts commit fff1f888c4.
2025-10-16 14:11:36 +05:00
fff1f888c4 chore: broke autorelease for tasting purpose
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 12:48:21 +05:00
fdd5a0a3d5 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 10:44:30 +05:00
792e52d981 feat(dialogs): added controller hints
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-16 10:39:24 +05:00
84d5e46a74 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 22:53:08 +05:00
4bc764d568 partially revert b1047ba18e
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 22:31:35 +05:00
9a18aa037e feat(autoinstall): no restart on autoinstall finished
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 21:58:40 +05:00
ed62d2d1c4 fix: resolve lambda variable capture issue in switchTab method
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 21:47:14 +05:00
accc9b18b6 chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 15:31:56 +05:00
82249d7eab feat(settings): Added Gamepad type settings
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 15:30:31 +05:00
476c896940 chore(TODO): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-15 12:44:01 +05:00
b1047ba18e fix: fix card overlap on display_filter change
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-13 12:14:54 +05:00
987199d8e6 chore(release): enable node experimental-fetch
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-13 11:52:43 +05:00
Renovate Bot
ef1acd4581 chore(deps): update archlinux:base-devel docker digest to 06ab929 2025-10-12 17:46:27 +00:00
96f884904c chore: bump ver to v0.1.7
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:33:56 +05:00
b856a2afae chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:33:12 +05:00
55ef0030e6 feat: added version and commit on WindowTitle
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:31:23 +05:00
8aaeaa4824 chore(localization): add localization for auto-install progress status message
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:14:06 +05:00
f55372b480 fix(autoinstall): fix scrollbar sticking to the right edge
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 17:10:44 +05:00
4d6f32f053 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 15:25:04 +05:00
a2f5141b20 chore localization update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 15:21:14 +05:00
e3cb2857e7 fix(pyright): fix pyright errors
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 15:14:02 +05:00
efe8a35832 feat(autoinstall): rework gamepad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 14:57:43 +05:00
61fae97dad fix(autoinstall): fix virtual keyboard open
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 14:45:52 +05:00
5442100f64 feat: use GameCard on autonstall tab
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 13:56:18 +05:00
2d6ef84798 chore: rename metadata to use pw_create_unique_exe
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 12:14:31 +05:00
Renovate Bot
f4aee15b5d chore(deps): update pre-commit hook astral-sh/ruff-pre-commit to v0.14.0 2025-10-12 00:01:35 +00:00
87a65108a5 feat(autoinstall): added covers
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-12 00:48:09 +05:00
bb617708ac feat: initial add of autoinstall tab
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-11 19:19:47 +05:00
1cf332cd87 feat(winetab): added progress bar
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-11 13:24:58 +05:00
577ad4d3a3 feat: adapt WineTab to new cli
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-10 23:07:48 +05:00
ef3f2d6e96 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 21:01:30 +05:00
657d7728a6 fix(gamepad): exit fullscreen on disconnect only if auto-fullscreen enabled and fullscreen disabled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 20:59:51 +05:00
9452bfda2e chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
7eb2db0d68 chore localization update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
6ef7a03366 feat: added search to controller hints
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
e5af354b56 fix(virtual-keyboard): turn off caps lock when disabling shift while caps is enabled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
e6e5f6c8ea feat(virtual_keyboard): make keyboard bigger
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
84306bb31b feat(virtual_keyboard): added dpad reapeat movement
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
60af4d1482 feat(virtual_keyboard): press X to backspace
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
692e11b21d chore(virtual_keyboard): move styles to style.py
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
b1a804811e chore(keyboard): drop connect_keyboard_to_lineedit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
9a30cfaea7 chore(keyboard): drop unneded key events
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
5dd2f71f5e feat: added virtual keyboard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
dba172361b fix(ui): resolve layout issues during search filtering
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 12:52:34 +05:00
a9c70b8818 chore(winetricks): use curl for download
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 08:59:03 +05:00
135ace732f chore(deps): added Winetricks deps copied from upstream control
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 08:47:22 +05:00
8b727f64e1 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:26:21 +05:00
a8eb591da5 fix: update ControlHints and NavButtons together
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:23:58 +05:00
fe4ca1ee87 fix: revert signals to pyside 6.9.1
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:12:37 +05:00
ffe3e9d3d6 chore(deps): revert Pyside6 to 6.9.1
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:04:45 +05:00
49d39b5d61 chore(pyright): fix code for new version
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 18:37:31 +05:00
Renovate Bot
03566da704 fix(deps): lock file maintenance python dependencies 2025-10-08 18:21:53 +05:00
51 changed files with 3622 additions and 979 deletions

View File

@@ -94,7 +94,7 @@ jobs:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel@sha256:b3809917ab5a7840d42237f5f92d92660cd036bd75ae343e7825e6a24401f166
image: archlinux:base-devel@sha256:06ab929f935145dd65994a89dd06651669ea28d43c812f3e24de990978511821
volumes:
- /usr:/usr-host
- /opt:/opt-host

View File

@@ -8,7 +8,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 0.1.6
VERSION: 0.1.7
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -180,6 +180,8 @@ jobs:
- name: Release
uses: https://gitea.com/actions/gitea-release-action@v1
env:
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
with:
body_path: changelog.txt
token: ${{ env.GITEA_TOKEN }}

View File

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

View File

@@ -16,7 +16,7 @@ repos:
- id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.13.2
rev: v0.14.0
hooks:
- id: ruff-check

View File

@@ -5,11 +5,32 @@
## [Unreleased]
### Added
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
### Changed
- При завершении автоустановки приложение больше не перезапускается
### Fixed
- Исправлено наложение карточек при смене фильтра игр
### Contributors
- @Vector_null
---
## [0.1.7] - 2025-10-12
### Added
- Возможность скроллинга библиотеки мышью или пальцем
- Импорт и экспорт бекапа префикса
- Диалог для управление Winetricks
- Кнопки для удаления префикса, wine или proton
- Все настройки Wine с оригинального PortProton
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
- Вкладка автоустановок
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
@@ -20,8 +41,13 @@
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
- При сохранении настроек теперь не меняется размер окна
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---
@@ -44,6 +70,7 @@
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---

15
TODO.md
View File

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

View File

@@ -36,7 +36,7 @@ AppDir:
id: ru.linux_gaming.PortProtonQt
name: PortProtonQt
icon: ru.linux_gaming.PortProtonQt
version: 0.1.6
version: 0.1.7
exec: usr/bin/python3
exec_args: "-m portprotonqt.app $@"
apt:
@@ -54,6 +54,11 @@ AppDir:
- libxcb-cursor0
- libimage-exiftool-perl
- xdg-utils
- cabextract
- curl
- 7zip
- unzip
- unrar
exclude:
- "*-doc"
- "*-man"

View File

@@ -1,12 +1,12 @@
pkgname=portprotonqt
pkgver=0.1.6
pkgver=0.1.7
pkgrel=1
pkgdesc="Modern GUI for managing and launching games from PortProton, Steam, and Epic Games Store"
arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP')

View File

@@ -6,7 +6,7 @@ arch=('any')
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
license=('GPL-3.0')
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
sha256sums=('SKIP')

View File

@@ -46,6 +46,11 @@ Requires: python3-pillow
Requires: perl-Image-ExifTool
Requires: xdg-utils
Requires: python3-beautifulsoup4
Requires: cabextract
Requires: gzip
Requires: unzip
Requires: curl
Requires: unrar
%description -n python3-%{pypi_name}-git
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.

View File

@@ -1,5 +1,5 @@
%global pypi_name portprotonqt
%global pypi_version 0.1.6
%global pypi_version 0.1.7
%global oname PortProtonQt
%global _python_no_extras_requires 1
@@ -43,6 +43,11 @@ Requires: python3-pillow
Requires: perl-Image-ExifTool
Requires: xdg-utils
Requires: python3-beautifulsoup4
Requires: cabextract
Requires: gzip
Requires: unzip
Requires: curl
Requires: unrar
%description -n python3-%{pypi_name}
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.

View File

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

View File

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

View File

@@ -1,17 +1,41 @@
import sys
import os
import subprocess
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow
from portprotonqt.config_utils import save_fullscreen_config
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location
from portprotonqt.logger import get_logger, setup_logger
from portprotonqt.cli import parse_args
__app_id__ = "ru.linux_gaming.PortProtonQt"
__app_name__ = "PortProtonQt"
__app_version__ = "0.1.6"
__app_version__ = "0.1.7"
def get_version():
try:
commit = subprocess.check_output(
['git', 'rev-parse', '--short', 'HEAD'],
stderr=subprocess.DEVNULL
).decode('utf-8').strip()
return f"{__app_version__} ({commit})"
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return __app_version__
def main():
os.environ['PW_CLI'] = '1'
os.environ['PROCESS_LOG'] = '1'
os.environ['START_FROM_STEAM'] = '1'
portproton_path = get_portproton_location()
if portproton_path is None:
return
script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh')
subprocess.run([script_path, 'cli', '--initial'])
app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__)
@@ -34,7 +58,8 @@ def main():
else:
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
window = MainWindow(app_name=__app_name__)
version = get_version()
window = MainWindow(app_name=__app_name__, version=version)
if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag")

View File

@@ -177,6 +177,26 @@ def save_card_size(card_width):
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
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)
def read_sort_method():
"""Reads the sort method from the [Games] section.
Returns 'last_launch' if the parameter is not set.
@@ -259,6 +279,25 @@ def save_rumble_config(rumble_enabled):
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_gamepad_type():
"""Reads the gamepad type from the [Gamepad] section.
Returns 'xbox' if the parameter is missing.
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Gamepad") or not cp.has_option("Gamepad", "type"):
save_gamepad_type("xbox")
return "xbox"
return cp.get("Gamepad", "type", fallback="xbox").lower()
def save_gamepad_type(gpad_type):
"""Saves the gamepad type to the [Gamepad] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Gamepad" not in cp:
cp["Gamepad"] = {}
cp["Gamepad"]["type"] = gpad_type
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def ensure_default_proxy_config():
"""Ensures the [Proxy] section exists in the configuration file.
Creates it with empty values if missing.
@@ -408,3 +447,22 @@ def save_favorite_folders(folders):
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
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)

View File

Before

Width:  |  Height:  |  Size: 634 KiB

After

Width:  |  Height:  |  Size: 634 KiB

View File

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 315 KiB

View File

Before

Width:  |  Height:  |  Size: 978 KiB

After

Width:  |  Height:  |  Size: 978 KiB

View File

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 650 KiB

View File

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 391 KiB

View File

Before

Width:  |  Height:  |  Size: 710 KiB

After

Width:  |  Height:  |  Size: 710 KiB

View File

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 627 KiB

View File

@@ -126,7 +126,21 @@ class FlowLayout(QLayout):
return True
def heightForWidth(self, width):
return self.doLayout(QRect(0, 0, width, 0), True)
# Аналогично фильтруем видимые для тестового расчёта высоты
visible_items = []
nat_sizes = np.empty((0, 2), dtype=np.int32)
for item in self.itemList:
if item.widget() and item.widget().isVisible():
visible_items.append(item)
s = item.sizeHint()
new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
if len(visible_items) == 0:
return 0
_, total_height = compute_layout(nat_sizes, width, self._spacing, self._max_scale)
return total_height
def setGeometry(self, rect):
super().setGeometry(rect)
@@ -145,26 +159,46 @@ class FlowLayout(QLayout):
return size
def doLayout(self, rect, testOnly):
N = len(self.itemList)
if N == 0:
N_total = len(self.itemList)
if N_total == 0:
return 0
nat_sizes = np.empty((N, 2), dtype=np.int32)
# Фильтруем только видимые элементы
visible_items = []
visible_indices = [] # Индексы в оригинальном itemList для установки геометрии
nat_sizes = np.empty((0, 2), dtype=np.int32)
for i, item in enumerate(self.itemList):
s = item.sizeHint()
nat_sizes[i, 0] = s.width()
nat_sizes[i, 1] = s.height()
if item.widget() and item.widget().isVisible():
visible_items.append(item)
visible_indices.append(i)
s = item.sizeHint()
new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
N = len(visible_items)
if N == 0:
# Если все скрыты, устанавливаем нулевые геометрии для всех
if not testOnly:
for item in self.itemList:
item.setGeometry(QRect())
return 0
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
if not testOnly:
for i, item in enumerate(self.itemList):
x = geom_array[i, 0] + rect.x()
y = geom_array[i, 1] + rect.y()
w = geom_array[i, 2]
h = geom_array[i, 3]
# Устанавливаем геометрии только для видимых
for idx, (_vis_idx, item) in enumerate(zip(visible_indices, visible_items, strict=True)):
x = geom_array[idx, 0] + rect.x()
y = geom_array[idx, 1] + rect.y()
w = geom_array[idx, 2]
h = geom_array[idx, 3]
item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
# Для невидимых — нулевая геометрия
for i in range(N_total):
if i not in visible_indices:
self.itemList[i].setGeometry(QRect())
return total_height
class ClickableLabel(QLabel):

View File

@@ -17,6 +17,7 @@ from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.preloader import Preloader
import psutil
@@ -90,6 +91,130 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
logger.error(f"Ошибка при сохранении миниатюры: {e}")
return False
def create_dialog_hints_widget(theme, main_window, input_manager, context='default'):
"""
Common function to create hints widget for all dialogs.
Uses main_window for get_button_icon/get_nav_icon, input_manager for gamepad detection.
"""
theme_manager = ThemeManager()
current_theme_name = read_theme_from_config()
hintsWidget = QWidget()
hintsWidget.setStyleSheet(theme.STATUS_BAR_STYLE)
hintsLayout = QHBoxLayout(hintsWidget)
hintsLayout.setContentsMargins(10, 0, 10, 0)
hintsLayout.setSpacing(20)
dialog_actions = []
# Context-specific actions (gamepad only, no keyboard)
if context == 'file_explorer':
dialog_actions = [
("confirm", _("Open")), # A / Cross
("add_game", _("Select Dir")), # X / Triangle
("prev_dir", _("Prev Dir")), # Y / Square
("back", _("Cancel")), # B / Circle
("context_menu", _("Menu")), # Start / Options
]
elif context == 'winetricks':
dialog_actions = [
("confirm", _("Toggle")), # A / Cross
("add_game", _("Install")), # X / Triangle
("prev_dir", _("Force Install")), # Y / Square
("back", _("Cancel")), # B / Circle
("prev_tab", _("Prev Tab")), # LB / L1
("next_tab", _("Next Tab")), # RB / R1
]
hints_labels = [] # Store for updates (returned for class storage)
def make_hint(icon_name, text, action=None):
container = QWidget()
hlayout = QHBoxLayout(container)
hlayout.setContentsMargins(0, 5, 0, 0)
hlayout.setSpacing(6)
icon_label = QLabel()
icon_label.setFixedSize(26, 26)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
hlayout.addWidget(icon_label)
text_label = QLabel(text)
text_label.setStyleSheet(theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
hlayout.addWidget(text_label)
# Initially hidden; show only if gamepad connected
container.setVisible(False)
hints_labels.append((container, icon_label, action))
hintsLayout.addWidget(container)
# Add gamepad hints only
for action, text in dialog_actions:
make_hint("placeholder", text, action)
hintsLayout.addStretch()
# Return widget and labels for class storage
return hintsWidget, hints_labels
def update_dialog_hints(hints_labels, main_window, input_manager, theme_manager, current_theme_name):
"""
Common function to update hints for any dialog.
"""
if not input_manager or not main_window:
# Hide all if no input_manager or main_window
for container, _, _ in hints_labels:
container.setVisible(False)
return
is_gamepad = input_manager.gamepad is not None
if not is_gamepad:
# Hide all hints if no gamepad
for container, _, _ in hints_labels:
container.setVisible(False)
return
gtype = input_manager.gamepad_type
gamepad_actions = ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir', 'prev_tab', 'next_tab']
for container, icon_label, action in hints_labels:
if action and action in gamepad_actions:
container.setVisible(True)
# Update icon using main_window methods
if action in ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir']:
icon_name = main_window.get_button_icon(action, gtype)
else: # only prev_tab/next_tab (treat as nav)
direction = 'left' if action == 'prev_tab' else 'right'
icon_name = main_window.get_nav_icon(direction, gtype)
icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# Fallback to placeholder
placeholder = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу
@@ -184,6 +309,7 @@ class FileExplorer(QDialog):
self.initial_path = initial_path # Store initial path if provided
self.thumbnail_cache = {} # Cache for loaded thumbnails
self.pending_thumbnails = set() # Track files pending thumbnail loading
self.main_window = None # Add reference to MainWindow
self.setup_ui()
# Window settings
@@ -197,6 +323,7 @@ class FileExplorer(QDialog):
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
if hasattr(parent, 'context_menu_manager'):
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
parent = parent.parent()
@@ -213,6 +340,17 @@ class FileExplorer(QDialog):
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
self.update_file_list()
# Create hints widget using common function
self.current_theme_name = read_theme_from_config()
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='file_explorer')
self.main_layout.addWidget(self.hints_widget)
# Connect signals
if self.input_manager:
self.input_manager.button_event.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
self.input_manager.dpad_moved.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
class ThumbnailLoader(QRunnable):
"""Class for asynchronous thumbnail loading in a separate thread."""
class Signals(QObject):
@@ -510,8 +648,8 @@ class FileExplorer(QDialog):
"""Update the list of mounted drives and favorite folders."""
for i in reversed(range(self.drives_layout.count())):
item = self.drives_layout.itemAt(i)
if item and item.widget():
widget = item.widget()
widget = item.widget() if item else None
if widget:
self.drives_layout.removeWidget(widget)
widget.deleteLater()
@@ -817,6 +955,60 @@ class AddGameDialog(QDialog):
if edit_mode:
self.updatePreview()
# Инициализация клавиатуры (отдельным методом вроде лучше)
self.init_keyboard()
# Устанавливаем фокус на первое поле при открытии
QTimer.singleShot(0, self.nameEdit.setFocus)
def init_keyboard(self):
"""Инициализация виртуальной клавиатуры"""
self.keyboard = VirtualKeyboard(self, theme=self.theme, button_width=40)
self.keyboard.hide()
def show_keyboard_for_widget(self, widget):
"""Показывает клавиатуру для указанного виджета"""
if not widget or not widget.isVisible():
return
# Устанавливаем текущий виджет ввода
self.keyboard.current_input_widget = widget
# Позиционирование клавиатуры
keyboard_height = 220
self.keyboard.setFixedWidth(self.width())
self.keyboard.setFixedHeight(keyboard_height)
self.keyboard.move(0, self.height() - keyboard_height)
# Показываем и поднимаем клавиатуру
self.keyboard.setParent(self)
self.keyboard.show()
self.keyboard.raise_()
# TODO: доработать.
# Устанавливаем фокус на первую кнопку клавиатуры
first_button = self.keyboard.findFirstFocusableButton()
if first_button:
QTimer.singleShot(50, lambda: first_button.setFocus())
def closeEvent(self, event):
"""Обработчик закрытия окна"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().closeEvent(event)
def reject(self):
"""Обработчик кнопки Cancel"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().reject()
def accept(self):
"""Обработчик кнопки Apply"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().accept()
def browseExe(self):
"""Открывает файловый менеджер для выбора exe-файла"""
try:
@@ -982,8 +1174,6 @@ Icon={icon_path}
return desktop_entry, desktop_path
class WinetricksDialog(QDialog):
"""Dialog for managing Winetricks components in a prefix."""
def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None):
super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
@@ -1016,6 +1206,36 @@ class WinetricksDialog(QDialog):
self.setup_ui()
self.load_lists()
# Find input_manager and main_window
self.input_manager = None
self.main_window = None
parent = self.parent()
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
parent = parent.parent()
self.current_theme_name = read_theme_from_config()
# Enable Winetricks-specific mode
if self.input_manager:
self.input_manager.enable_winetricks_mode(self)
# Create hints widget using common function
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='winetricks')
self.main_layout.addWidget(self.hints_widget)
# Connect signals (use self.theme_manager)
if self.input_manager:
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def update_winetricks(self):
"""Update the winetricks script."""
if not self.downloader.has_internet():
@@ -1088,15 +1308,15 @@ class WinetricksDialog(QDialog):
def setup_ui(self):
"""Set up the user interface with tabs and tables."""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(10, 10, 10, 10)
self.main_layout.setSpacing(10)
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
main_layout.addWidget(self.log_output)
self.main_layout.addWidget(self.log_output)
# Tab widget
self.tab_widget = QTabWidget()
@@ -1203,7 +1423,7 @@ class WinetricksDialog(QDialog):
"settings": self.settings_container
}
main_layout.addWidget(self.tab_widget)
self.main_layout.addWidget(self.tab_widget)
# Buttons
button_layout = QHBoxLayout()
@@ -1217,7 +1437,7 @@ class WinetricksDialog(QDialog):
button_layout.addWidget(self.cancel_button)
button_layout.addWidget(self.force_button)
button_layout.addWidget(self.install_button)
main_layout.addLayout(button_layout)
self.main_layout.addLayout(button_layout)
self.cancel_button.clicked.connect(self.reject)
self.force_button.clicked.connect(lambda: self.install_selected(force=True))
@@ -1233,6 +1453,7 @@ class WinetricksDialog(QDialog):
assert self.prefix_path is not None
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", self.prefix_path)
env.insert("WINETRICKS_DOWNLOADER", "curl")
if self.wine_use is not None:
env.insert("WINE", self.wine_use)
@@ -1441,3 +1662,15 @@ class WinetricksDialog(QDialog):
"""Добавляет в лог."""
self.log_output.append(message)
self.log_output.moveCursor(QTextCursor.MoveOperation.End)
def closeEvent(self, event):
"""Disable mode on close."""
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().closeEvent(event)
def reject(self):
"""Disable mode on reject."""
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().reject()

View File

@@ -12,6 +12,7 @@ from portprotonqt.downloader import Downloader
from portprotonqt.animations import GameCardAnimations
from typing import cast
class GameCard(QFrame):
borderWidthChanged = Signal()
gradientAngleChanged = Signal()
@@ -447,6 +448,7 @@ class GameCard(QFrame):
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
def paintEvent(self, event):
super().paintEvent(event)
self.animations.paint_border(QPainter(self))

View File

@@ -217,6 +217,16 @@ class GameLibraryManager:
else:
self._update_game_grid_immediate()
def force_update_cards_library(self):
if self.gamesListWidget and self.gamesListLayout:
self.gamesListLayout.invalidate()
self.gamesListWidget.updateGeometry()
widget = self.gamesListWidget
QTimer.singleShot(0, lambda: (
widget.adjustSize(),
widget.updateGeometry()
))
def _update_game_grid_immediate(self):
"""Updates the game grid with the provided or current game list."""
if self.gamesListLayout is None or self.gamesListWidget is None:
@@ -346,6 +356,8 @@ class GameLibraryManager:
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
self.force_update_cards_library()
self.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str):
@@ -363,8 +375,9 @@ class GameLibraryManager:
cover_path, width, height, callback = self.pending_images.pop(game_key)
load_pixmap_async(cover_path, width, height, callback)
# Force geometry update so FlowLayout accounts for hidden widgets
# Force full relayout after visibility changes
if self.gamesListLayout is not None:
self.gamesListLayout.invalidate() # Принудительно инвалидируем для пересчёта
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,73 @@
# keyboard_layouts.py
keyboard_layouts = {
'en': {
'normal': [
['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"],
['', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/']
],
'shift': [
['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'],
['', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?']
]
},
'ru': {
'normal': [
['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'],
['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'],
['', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.']
],
'shift': [
['Ё', '!', '"', '', ';', '%', ':', '?', '*', '(', ')', '_', '+'],
['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'],
['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'],
['', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ',']
]
},
'fr': {
'normal': [
['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='],
['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'],
['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'],
['', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!']
],
'shift': [
['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'],
['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'],
['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'],
['', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§']
]
},
'es': {
'normal': [
['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'],
['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'],
['', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
],
'shift': [
['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'],
['', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
]
},
'de': {
'normal': [
['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'],
['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'],
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'],
['', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
],
'shift': [
['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'],
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''],
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'],
['', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
]
}
}

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-07 15:45+0500\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -252,13 +252,37 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer"
msgstr ""
@@ -326,12 +350,6 @@ msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -395,9 +413,6 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -413,6 +428,28 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -429,12 +466,6 @@ msgstr ""
msgid "Added '{name}'"
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Compatibility tool:"
msgstr ""
@@ -447,12 +478,6 @@ msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
@@ -474,6 +499,29 @@ msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
@@ -549,6 +597,9 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -573,6 +624,12 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-07 15:45+0500\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -252,13 +252,37 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer"
msgstr ""
@@ -326,12 +350,6 @@ msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -395,9 +413,6 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -413,6 +428,28 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -429,12 +466,6 @@ msgstr ""
msgid "Added '{name}'"
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Compatibility tool:"
msgstr ""
@@ -447,12 +478,6 @@ msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
@@ -474,6 +499,29 @@ msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
@@ -549,6 +597,9 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -573,6 +624,12 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-07 15:45+0500\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -250,13 +250,37 @@ msgstr ""
msgid "Select All"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
msgstr ""
msgid "Cancel"
msgstr ""
msgid "Toggle"
msgstr ""
msgid "Install"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Prev Tab"
msgstr ""
msgid "Next Tab"
msgstr ""
#, python-brace-format
msgid "Launching {0}"
msgstr ""
msgid "File Explorer"
msgstr ""
@@ -324,12 +348,6 @@ msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
@@ -393,9 +411,6 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -411,6 +426,28 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Installation already in progress."
msgstr ""
msgid "Failed to start installation."
msgstr ""
#, python-brace-format
msgid "Processed {} installation..."
msgstr ""
msgid "Installation completed successfully."
msgstr ""
msgid "Installation failed."
msgstr ""
msgid "Installation error."
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -427,12 +464,6 @@ msgstr ""
msgid "Added '{name}'"
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Compatibility tool:"
msgstr ""
@@ -445,12 +476,6 @@ msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
@@ -472,6 +497,29 @@ msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Launching tool..."
msgstr ""
msgid "Failed to start process."
msgstr ""
msgid "Confirm Clear"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr ""
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
@@ -547,6 +595,9 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -571,6 +622,12 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""

View File

@@ -9,8 +9,8 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-07 15:45+0500\n"
"PO-Revision-Date: 2025-10-07 15:44+0500\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"PO-Revision-Date: 2025-10-16 14:54+0500\n"
"Last-Translator: \n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
@@ -259,13 +259,37 @@ msgstr "Удалить"
msgid "Select All"
msgstr "Выбрать всё"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "Open"
msgstr "Открыть"
msgid "Select Dir"
msgstr "Выбрать папку"
msgid "Prev Dir"
msgstr "Предыдущий каталог"
msgid "Cancel"
msgstr "Отмена"
msgid "Toggle"
msgstr "Переключить"
msgid "Install"
msgstr "Установить"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Prev Tab"
msgstr "Предыдущая вкладка"
msgid "Next Tab"
msgstr "Следующая вкладка"
#, python-brace-format
msgid "Launching {0}"
msgstr "Идёт запуск {0}"
msgid "File Explorer"
msgstr "Проводник"
@@ -333,12 +357,6 @@ msgstr "Шрифты"
msgid "Settings"
msgstr "Настройки"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Install"
msgstr "Установить"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку."
@@ -402,9 +420,6 @@ msgstr "Библиотека"
msgid "Auto Install"
msgstr "Автоустановка"
msgid "Emulators"
msgstr "Эмуляторы"
msgid "Wine Settings"
msgstr "Настройки wine"
@@ -420,6 +435,28 @@ msgstr "Назад"
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Search"
msgstr "Поиск"
msgid "Installation already in progress."
msgstr "Установка уже выполняется."
msgid "Failed to start installation."
msgstr "Не удалось запустить установку."
#, python-brace-format
msgid "Processed {} installation..."
msgstr "В процессе установки {}..."
msgid "Installation completed successfully."
msgstr "Установка завершена успешно."
msgid "Installation failed."
msgstr "Установка не удалась."
msgid "Installation error."
msgstr "Ошибка установки."
msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..."
@@ -436,12 +473,6 @@ msgstr "Найти игры..."
msgid "Added '{name}'"
msgstr "'{name}' добавлен(а)"
msgid "Here you can configure automatic game installation..."
msgstr "Здесь можно настроить автоматическую установку игр..."
msgid "List of available emulators and their configuration..."
msgstr "Список доступных эмуляторов и их настройка..."
msgid "Compatibility tool:"
msgstr "Инструмент совместимости:"
@@ -454,12 +485,6 @@ msgstr "Конфигурация Wine"
msgid "Registry Editor"
msgstr "Редактор реестра"
msgid "Control Panel"
msgstr "Панель управления"
msgid "Task Manager"
msgstr "Диспетчер задач"
msgid "Command Prompt"
msgstr "Командная строка"
@@ -481,6 +506,31 @@ msgstr "Удалить Префикс"
msgid "Clear Prefix"
msgstr "Очистить Префикс"
msgid "Launching tool..."
msgstr "Запуск инструмента..."
msgid "Failed to start process."
msgstr "Не удалось запустить процесс."
msgid "Confirm Clear"
msgstr "Подтвердите очистку"
#, python-brace-format
msgid "Are you sure you want to clear prefix '{}'?"
msgstr "Вы уверены, что хотите очистить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' cleared successfully."
msgstr "Префикс '{}' успешно удален."
#, python-brace-format
msgid ""
"Prefix '{}' cleared with errors:\n"
"{}"
msgstr ""
"Префикс '{}' очищен с ошибками:\n"
"{}"
msgid "Failed to start backup process."
msgstr "Не удалось запустить процесс резервного копирования."
@@ -556,6 +606,9 @@ msgstr "все"
msgid "Games Display Filter:"
msgstr "Фильтр игр:"
msgid "Gamepad Type:"
msgstr "Тип геймпада:"
msgid "Proxy URL"
msgstr "Адрес прокси"
@@ -580,6 +633,12 @@ msgstr "Запуск приложения в полноэкранном режи
msgid "Application Fullscreen Mode:"
msgstr "Режим полноэкранного отображения приложения:"
msgid "Minimize to tray on close"
msgstr "Сворачивать в трей при закрытии"
msgid "Application Close Mode:"
msgstr "Режим закрытия приложения:"
msgid "Auto Fullscreen on Gamepad connected"
msgstr "Режим полноэкранного отображения приложения при подключении геймпада"

View File

@@ -5,12 +5,13 @@ import signal
import subprocess
import sys
import psutil
import re
from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel, FlowLayout
from portprotonqt.portproton_api import PortProtonAPI
from portprotonqt.input_manager import InputManager
from portprotonqt.context_menu_manager import ContextMenuManager, CustomLineEdit
@@ -28,17 +29,18 @@ from portprotonqt.config_utils import (
read_display_filter, read_favorites, save_favorites, save_time_config, save_sort_method,
save_display_filter, save_proxy_config, read_proxy_config, read_fullscreen_config,
save_fullscreen_config, read_window_geometry, save_window_geometry, reset_config,
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config
clear_cache, read_auto_fullscreen_gamepad, save_auto_fullscreen_gamepad, read_rumble_config, save_rumble_config, read_gamepad_type, save_gamepad_type, read_minimize_to_tray, save_minimize_to_tray,
read_auto_card_size, save_auto_card_size
)
from portprotonqt.localization import _, get_egs_language, read_metadata_translations
from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout)
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea, QScroller, QSlider)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from typing import cast
@@ -53,7 +55,7 @@ class MainWindow(QMainWindow):
update_progress = Signal(int)
update_status_message = Signal(str, int)
def __init__(self, app_name: str):
def __init__(self, app_name: str, version: str):
super().__init__()
self.theme_manager = ThemeManager()
self.is_exiting = False
@@ -62,8 +64,9 @@ class MainWindow(QMainWindow):
self.theme = self.theme_manager.apply_theme(selected_theme)
self.tray_manager = TrayManager(self, app_name, self.current_theme_name)
self.card_width = read_card_size()
self.auto_card_width = read_auto_card_size()
self._last_card_width = self.card_width
self.setWindowTitle(app_name)
self.setWindowTitle(f"{app_name} {version}")
self.setMinimumSize(800, 600)
self.games = []
@@ -129,6 +132,11 @@ class MainWindow(QMainWindow):
self.update_progress.connect(self.progress_bar.setValue)
self.update_status_message.connect(self.statusBar().showMessage)
self.installing = False
self.current_install_script = None
self.install_process = None
self.install_monitor_timer = None
# Центральный виджет и основной layout
centralWidget = QWidget()
self.setCentralWidget(centralWidget)
@@ -145,7 +153,7 @@ class MainWindow(QMainWindow):
headerLayout.addStretch()
self.input_manager = InputManager(self) # type: ignore
self.input_manager.button_pressed.connect(self.updateControlHints)
self.input_manager.button_event.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints)
# 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
@@ -166,7 +174,6 @@ class MainWindow(QMainWindow):
tabs = [
_("Library"),
_("Auto Install"),
_("Emulators"),
_("Wine Settings"),
_("PortProton Settings"),
_("Themes")
@@ -198,7 +205,6 @@ class MainWindow(QMainWindow):
self.createInstalledTab()
self.createAutoInstallTab()
self.createEmulatorsTab()
self.createWineTab()
self.createPortProtonTab()
self.createThemeTab()
@@ -206,8 +212,12 @@ class MainWindow(QMainWindow):
self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget)
self.updateControlHints()
self.restore_state()
self.keyboard = VirtualKeyboard(self, self.theme)
self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames)
@@ -248,6 +258,14 @@ class MainWindow(QMainWindow):
GamepadType.XBOX: "xbox_view",
GamepadType.PLAYSTATION: "ps_share",
},
'search': {
GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square",
},
'prev_dir': {
GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
@@ -286,6 +304,7 @@ class MainWindow(QMainWindow):
("add_game", _("Add Game")),
("context_menu", _("Menu")),
("menu", _("Fullscreen")),
("search", _("Search")),
]
keyboard_hints = [
@@ -398,7 +417,7 @@ class MainWindow(QMainWindow):
gtype = self.input_manager.gamepad_type
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu']
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu', 'search']
for container, icon_label, action in self.hintsLabels:
if action in gamepad_actions: # Gamepad hint
@@ -430,6 +449,116 @@ class MainWindow(QMainWindow):
# Update navigation buttons
self.updateNavButtons()
def launch_autoinstall(self, script_name: str):
"""Launch auto-install script."""
if self.installing:
QMessageBox.warning(self, _("Warning"), _("Installation already in progress."))
return
self.installing = True
self.current_install_script = script_name
self.seen_progress = False
self.current_percent = 0.0
start_sh = os.path.join(self.portproton_location or "", "data", "scripts", "start.sh") if self.portproton_location else ""
if not os.path.exists(start_sh):
self.installing = False
return
cmd = [start_sh, "cli", "--autoinstall", script_name]
self.install_process = QProcess(self)
self.install_process.finished.connect(self.on_install_finished)
self.install_process.errorOccurred.connect(self.on_install_error)
self.install_process.start(cmd[0], cmd[1:])
if not self.install_process.waitForStarted(5000):
self.installing = False
QMessageBox.warning(self, _("Error"), _("Failed to start installation."))
return
self.progress_bar.setVisible(True)
self.progress_bar.setRange(0, 0) # Indeterminate
self.update_status_message.emit(_("Processed {} installation...").format(script_name), 0)
self.install_monitor_timer = QTimer(self)
self.install_monitor_timer.timeout.connect(self.monitor_install_progress)
self.install_monitor_timer.start(2000) # Start monitoring after 2s
def monitor_install_progress(self):
"""Monitor /tmp/PortProton_$USER/process.log for progress."""
user = os.getenv('USER', 'unknown')
log_file = f"/tmp/PortProton_{user}/process.log"
if not os.path.exists(log_file):
return
try:
with open(log_file, encoding='utf-8') as f:
content = f.read()
# Extract all percentage matches, including .0% as 0.0
matches = re.findall(r'([0-9]*\.?[0-9]+)%', content)
if matches:
try:
percent = float(matches[-1])
if percent > 0:
self.seen_progress = True
self.current_percent = percent
elif self.seen_progress and percent == 0:
self.current_percent = 100.0
if self.install_monitor_timer is not None:
self.install_monitor_timer.stop()
# Update progress bar to determinate if not already
if self.progress_bar.maximum() == 0:
self.progress_bar.setRange(0, 100)
self.progress_bar.setFormat("%p") # Show percentage
self.progress_bar.setValue(int(self.current_percent))
if self.current_percent >= 100:
if self.install_monitor_timer is not None:
self.install_monitor_timer.stop()
except ValueError:
pass # Ignore invalid floats
except Exception as e:
logger.error(f"Error monitoring log: {e}")
@Slot(int, int)
def on_install_finished(self, exit_code: int, exit_status: int):
"""Handle installation finish."""
self.installing = False
if self.install_monitor_timer is not None:
self.install_monitor_timer.stop()
self.install_monitor_timer.deleteLater()
self.install_monitor_timer = None
self.progress_bar.setRange(0, 100)
self.progress_bar.setValue(100)
if exit_code == 0:
self.update_status_message.emit(_("Installation completed successfully."), 5000)
desktop_dir = self.portproton_location or ""
new_desktops = [e.path for e in os.scandir(desktop_dir) if e.name.endswith(".desktop")]
if new_desktops:
latest = max(new_desktops, key=os.path.getmtime)
self._process_desktop_file_async(
latest,
lambda result: (
self.game_library_manager.add_game_incremental(result)
if result else None
)
)
else:
self.update_status_message.emit(_("Installation failed."), 5000)
QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).")
self.progress_bar.setVisible(False)
self.current_install_script = None
if self.install_process:
self.install_process.deleteLater()
self.install_process = None
def on_install_error(self, error: QProcess.ProcessError):
"""Handle installation error."""
self.installing = False
if self.install_monitor_timer is not None:
self.install_monitor_timer.stop()
self.install_monitor_timer.deleteLater()
self.install_monitor_timer = None
self.update_status_message.emit(_("Installation error."), 5000)
QMessageBox.warning(self, _("Error"), f"Process error: {error}")
self.progress_bar.setVisible(False)
@Slot(list)
def on_games_loaded(self, games: list[tuple]):
self.game_library_manager.set_games(games)
@@ -711,6 +840,25 @@ class MainWindow(QMainWindow):
for i, btn in self.tabButtons.items():
btn.setChecked(i == index)
self.stackedWidget.setCurrentIndex(index)
if hasattr(self, "game_library_manager"):
mgr = self.game_library_manager
if mgr.gamesListWidget and mgr.gamesListLayout:
games_layout = mgr.gamesListLayout
games_widget = mgr.gamesListWidget
QTimer.singleShot(0, lambda: (
games_layout.invalidate(),
games_widget.adjustSize(),
games_widget.updateGeometry()
))
if hasattr(self, "autoInstallContainer") and hasattr(self, "autoInstallContainerLayout"):
auto_layout = self.autoInstallContainerLayout
auto_widget = self.autoInstallContainer
QTimer.singleShot(0, lambda: (
auto_layout.invalidate(),
auto_widget.adjustSize(),
auto_widget.updateGeometry()
))
def openSystemOverlay(self):
"""Opens the system overlay dialog."""
@@ -951,52 +1099,197 @@ class MainWindow(QMainWindow):
get_steam_game_info_async(final_name, exec_line, on_steam_info)
def createAutoInstallTab(self):
"""Вкладка 'Auto Install'."""
self.autoInstallWidget = QWidget()
self.autoInstallWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
self.autoInstallWidget.setObjectName("otherPage")
layout = QVBoxLayout(self.autoInstallWidget)
layout.setContentsMargins(10, 18, 10, 10)
autoInstallPage = QWidget()
autoInstallPage.setStyleSheet(self.theme.LIBRARY_WIDGET_STYLE)
autoInstallLayout = QVBoxLayout(autoInstallPage)
autoInstallLayout.setSpacing(15)
self.autoInstallTitle = QLabel(_("Auto Install"))
self.autoInstallTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE)
self.autoInstallTitle.setObjectName("tabTitle")
layout.addWidget(self.autoInstallTitle)
# Верхняя панель с заголовком и поиском
headerWidget = QWidget()
headerLayout = QHBoxLayout(headerWidget)
headerLayout.setContentsMargins(0, 10, 0, 10)
headerLayout.setSpacing(10)
self.autoInstallContent = QLabel(_("Here you can configure automatic game installation..."))
self.autoInstallContent.setStyleSheet(self.theme.CONTENT_STYLE)
self.autoInstallContent.setObjectName("tabContent")
layout.addWidget(self.autoInstallContent)
layout.addStretch(1)
# Заголовок
titleLabel = QLabel(_("Auto Install"))
titleLabel.setStyleSheet(self.theme.TAB_TITLE_STYLE)
titleLabel.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
headerLayout.addWidget(titleLabel)
self.stackedWidget.addWidget(self.autoInstallWidget)
headerLayout.addStretch()
def createEmulatorsTab(self):
"""Вкладка 'Emulators'."""
self.emulatorsWidget = QWidget()
self.emulatorsWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
self.emulatorsWidget.setObjectName("otherPage")
layout = QVBoxLayout(self.emulatorsWidget)
layout.setContentsMargins(10, 18, 10, 10)
# Поисковая строка
self.autoInstallSearchLineEdit = CustomLineEdit(self, theme=self.theme)
icon: QIcon = cast(QIcon, self.theme_manager.get_icon("search"))
action_pos = QLineEdit.ActionPosition.LeadingPosition
self.search_action = self.autoInstallSearchLineEdit.addAction(icon, action_pos)
self.autoInstallSearchLineEdit.setMaximumWidth(200)
self.autoInstallSearchLineEdit.setPlaceholderText(_("Find Games ..."))
self.autoInstallSearchLineEdit.setClearButtonEnabled(True)
self.autoInstallSearchLineEdit.setStyleSheet(self.theme.SEARCH_EDIT_STYLE)
self.autoInstallSearchLineEdit.textChanged.connect(self.filterAutoInstallGames)
headerLayout.addWidget(self.autoInstallSearchLineEdit)
self.emulatorsTitle = QLabel(_("Emulators"))
self.emulatorsTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE)
self.emulatorsTitle.setObjectName("tabTitle")
layout.addWidget(self.emulatorsTitle)
autoInstallLayout.addWidget(headerWidget)
self.emulatorsContent = QLabel(_("List of available emulators and their configuration..."))
self.emulatorsContent.setStyleSheet(self.theme.CONTENT_STYLE)
self.emulatorsContent.setObjectName("tabContent")
layout.addWidget(self.emulatorsContent)
layout.addStretch(1)
# Прогресс-бар
self.autoInstallProgress = QProgressBar()
self.autoInstallProgress.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
self.autoInstallProgress.setVisible(False)
autoInstallLayout.addWidget(self.autoInstallProgress)
self.stackedWidget.addWidget(self.emulatorsWidget)
# Скролл
self.autoInstallScrollArea = QScrollArea()
self.autoInstallScrollArea.setWidgetResizable(True)
self.autoInstallScrollArea.setStyleSheet(self.theme.SCROLL_AREA_STYLE)
QScroller.grabGesture(self.autoInstallScrollArea.viewport(), QScroller.ScrollerGestureType.LeftMouseButtonGesture)
self.autoInstallContainer = QWidget()
self.autoInstallContainerLayout = FlowLayout(self.autoInstallContainer)
self.autoInstallContainer.setLayout(self.autoInstallContainerLayout)
self.autoInstallScrollArea.setWidget(self.autoInstallContainer)
autoInstallLayout.addWidget(self.autoInstallScrollArea)
# Slider for card size
sliderLayout = QHBoxLayout()
sliderLayout.setSpacing(0)
sliderLayout.setContentsMargins(0, 0, 0, 0)
sliderLayout.addStretch()
self.auto_size_slider = QSlider(Qt.Orientation.Horizontal)
self.auto_size_slider.setMinimum(200)
self.auto_size_slider.setMaximum(250)
self.auto_size_slider.setValue(self.auto_card_width)
self.auto_size_slider.setTickInterval(10)
self.auto_size_slider.setFixedWidth(150)
self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
self.auto_size_slider.setStyleSheet(self.theme.SLIDER_SIZE_STYLE)
self.auto_size_slider.sliderReleased.connect(self.on_auto_slider_released)
sliderLayout.addWidget(self.auto_size_slider)
autoInstallLayout.addLayout(sliderLayout)
# Хранение карточек
self.autoInstallGameCards = {}
self.allAutoInstallCards = []
# Обновление обложки
def on_autoinstall_cover_updated(exe_name, local_path):
if exe_name in self.autoInstallGameCards and local_path:
card = self.autoInstallGameCards[exe_name]
card.cover_path = local_path
load_pixmap_async(local_path, self.auto_card_width, int(self.auto_card_width * 1.5), card.on_cover_loaded)
# Загрузка игр
def on_autoinstall_games_loaded(games: list[tuple]):
self.autoInstallProgress.setVisible(False)
# Очистка
while self.autoInstallContainerLayout.count():
child = self.autoInstallContainerLayout.takeAt(0)
if child:
child.widget().deleteLater()
self.autoInstallGameCards.clear()
self.allAutoInstallCards.clear()
if not games:
return
# Callback для запуска установки
def select_callback(name, description, cover_path, appid, exec_line, controller_support, *_):
if not exec_line or not exec_line.startswith("autoinstall:"):
logger.warning(f"Invalid exec_line for autoinstall: {exec_line}")
return
script_name = exec_line[11:].lstrip(':').strip()
self.launch_autoinstall(script_name)
# Создаём карточки
for game_tuple in games:
name, description, cover_path, appid, controller_support, exec_line, *_ , game_source, exe_name = game_tuple
card = GameCard(
name, description, cover_path, appid, controller_support,
exec_line, None, None, None,
None, None, None, game_source,
select_callback=select_callback,
theme=self.theme,
card_width=self.auto_card_width,
parent=self.autoInstallContainer,
)
# Hide badges and favorite button
if hasattr(card, 'steamLabel'):
card.steamLabel.setVisible(False)
if hasattr(card, 'egsLabel'):
card.egsLabel.setVisible(False)
if hasattr(card, 'portprotonLabel'):
card.portprotonLabel.setVisible(False)
if hasattr(card, 'protondbLabel'):
card.protondbLabel.setVisible(False)
if hasattr(card, 'anticheatLabel'):
card.anticheatLabel.setVisible(False)
if hasattr(card, 'favoriteLabel'):
card.favoriteLabel.setVisible(False)
self.autoInstallGameCards[exe_name] = card
self.allAutoInstallCards.append(card)
self.autoInstallContainerLayout.addWidget(card)
# Загружаем недостающие обложки
for game_tuple in games:
name, _, cover_path, *_ , game_source, exe_name = game_tuple
if not cover_path:
self.portproton_api.download_autoinstall_cover_async(
exe_name, timeout=5,
callback=lambda path, ex=exe_name: on_autoinstall_cover_updated(ex, path)
)
self.autoInstallContainer.updateGeometry()
self.autoInstallScrollArea.updateGeometry()
self.filterAutoInstallGames()
# Показываем прогресс
self.autoInstallProgress.setVisible(True)
self.autoInstallProgress.setRange(0, 0)
self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded)
self.stackedWidget.addWidget(autoInstallPage)
def on_auto_slider_released(self):
"""Handles auto-install slider release to update card size."""
if hasattr(self, 'auto_size_slider') and self.auto_size_slider:
self.auto_card_width = self.auto_size_slider.value()
self.auto_size_slider.setToolTip(f"{self.auto_card_width} px")
save_auto_card_size(self.auto_card_width)
for card in self.allAutoInstallCards:
card.update_card_size(self.auto_card_width)
self.autoInstallContainerLayout.invalidate()
self.autoInstallContainer.updateGeometry()
self.autoInstallScrollArea.updateGeometry()
def filterAutoInstallGames(self):
"""Filter auto install game cards based on search text."""
search_text = self.autoInstallSearchLineEdit.text().lower().strip()
visible_count = 0
for card in self.allAutoInstallCards:
if search_text in card.name.lower():
card.setVisible(True)
visible_count += 1
else:
card.setVisible(False)
# Re-layout the container
self.autoInstallContainerLayout.invalidate()
self.autoInstallContainer.updateGeometry()
self.autoInstallScrollArea.updateGeometry()
def createWineTab(self):
"""Вкладка 'Wine Settings'."""
self.wineWidget = QWidget()
self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
self.wineWidget.setObjectName("otherPage")
layout = QVBoxLayout(self.wineWidget)
layout.setContentsMargins(10, 18, 10, 10)
@@ -1052,21 +1345,20 @@ class MainWindow(QMainWindow):
tools_grid.setSpacing(6)
tools = [
("winecfg", _("Wine Configuration")),
("regedit", _("Registry Editor")),
("control", _("Control Panel")),
("taskmgr", _("Task Manager")),
("explorer", _("File Explorer")),
("cmd", _("Command Prompt")),
("uninstaller", _("Uninstaller")),
("--winecfg", _("Wine Configuration")),
("--winereg", _("Registry Editor")),
("--winefile", _("File Explorer")),
("--winecmd", _("Command Prompt")),
("--wine_uninstaller", _("Uninstaller")),
]
for i, (_tool_cmd, tool_name) in enumerate(tools):
for i, (tool_cmd, tool_name) in enumerate(tools):
row = i // 3
col = i % 3
btn = AutoSizeButton(tool_name, update_size=False)
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t))
tools_grid.addWidget(btn, row, col)
for col in range(3):
@@ -1084,7 +1376,7 @@ class MainWindow(QMainWindow):
(_("Load Prefix Backup"), self.load_prefix_backup),
(_("Delete Compatibility Tool"), self.delete_compat_tool),
(_("Delete Prefix"), self.delete_prefix),
(_("Clear Prefix"), None),
(_("Clear Prefix"), self.clear_prefix),
]
for i, (text, callback) in enumerate(additional_buttons):
@@ -1105,8 +1397,220 @@ class MainWindow(QMainWindow):
additional_grid.setContentsMargins(10, 6, 10, 0)
layout.addStretch(1)
self.wine_progress_bar = QProgressBar(self.wineWidget)
self.wine_progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
self.wine_progress_bar.setMaximumWidth(200)
self.wine_progress_bar.setTextVisible(True)
self.wine_progress_bar.setVisible(False)
self.wine_progress_bar.setRange(0, 0)
wine_progress_layout = QHBoxLayout()
wine_progress_layout.addStretch(1)
wine_progress_layout.addWidget(self.wine_progress_bar)
layout.addLayout(wine_progress_layout)
self.stackedWidget.addWidget(self.wineWidget)
def launch_generic_tool(self, cli_arg):
wine = self.wineCombo.currentText()
prefix = self.prefixCombo.currentText()
if not wine or not prefix:
return
if not self.portproton_location:
return
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
if not os.path.exists(start_sh):
return
cmd = [start_sh, "cli", cli_arg, wine, prefix]
# Показываем прогресс-бар перед запуском
self.wine_progress_bar.setVisible(True)
self.update_status_message.emit(_("Launching tool..."), 0)
proc = QProcess(self)
proc.finished.connect(lambda exitCode, exitStatus: self._on_wine_tool_finished(exitCode, cli_arg))
proc.errorOccurred.connect(lambda error: self._on_wine_tool_error(error, cli_arg))
proc.start(cmd[0], cmd[1:])
if not proc.waitForStarted(5000):
self.wine_progress_bar.setVisible(False)
self.update_status_message.emit("", 0)
QMessageBox.warning(self, _("Error"), _("Failed to start process."))
return
self._start_wine_process_monitor(cli_arg)
def _start_wine_process_monitor(self, cli_arg):
"""Запускает таймер для мониторинга запуска Wine утилиты."""
self.wine_monitor_timer = QTimer(self)
self.wine_monitor_timer.setInterval(500)
self.wine_monitor_timer.timeout.connect(lambda: self._check_wine_process(cli_arg))
self.wine_monitor_timer.start()
def _check_wine_process(self, cli_arg):
"""Проверяет, запустился ли целевой .exe процесс."""
exe_map = {
"--winecfg": "winecfg.exe",
"--winereg": "regedit.exe",
"--winefile": "winefile.exe",
"--winecmd": "cmd.exe",
"--wine_uninstaller": "uninstaller.exe",
}
target_exe = exe_map.get(cli_arg, "")
if not target_exe:
return
# Проверяем процессы через psutil
for proc in psutil.process_iter(attrs=["name"]):
if proc.info["name"].lower() == target_exe.lower():
# Процесс запустился — скрываем прогресс-бар и останавливаем мониторинг
self.wine_progress_bar.setVisible(False)
self.update_status_message.emit("", 0)
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
logger.info(f"Wine tool {target_exe} started successfully")
return
def _on_wine_tool_finished(self, exitCode, cli_arg):
"""Обработчик завершения Wine утилиты."""
self.wine_progress_bar.setVisible(False)
self.update_status_message.emit("", 0)
# Останавливаем мониторинг, если он активен
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 exitCode == 0:
logger.info(f"Wine tool {cli_arg} finished successfully")
else:
logger.warning(f"Wine tool {cli_arg} finished with exit code {exitCode}")
def _on_wine_tool_error(self, error, cli_arg):
"""Обработчик ошибки запуска Wine утилиты."""
self.wine_progress_bar.setVisible(False)
self.update_status_message.emit("", 0)
# Останавливаем мониторинг, если он активен
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
logger.error(f"Wine tool {cli_arg} error: {error}")
QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
def clear_prefix(self):
"""Очистка префикса (позже удалить)."""
selected_prefix = self.prefixCombo.currentText()
selected_wine = self.wineCombo.currentText()
if not selected_prefix or not selected_wine:
return
if not self.portproton_location:
return
reply = QMessageBox.question(
self,
_("Confirm Clear"),
_("Are you sure you want to clear prefix '{}'?").format(selected_prefix),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply != QMessageBox.StandardButton.Yes:
return
prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix)
if not os.path.exists(prefix_dir):
return
success = True
errors = []
# Удаление файлов
files_to_remove = [
os.path.join(prefix_dir, "*.dot*"),
os.path.join(prefix_dir, "*.prog*"),
os.path.join(prefix_dir, ".wine_ver"),
os.path.join(prefix_dir, "system.reg"),
os.path.join(prefix_dir, "user.reg"),
os.path.join(prefix_dir, "userdef.reg"),
os.path.join(prefix_dir, "winetricks.log"),
os.path.join(prefix_dir, ".update-timestamp"),
os.path.join(prefix_dir, "drive_c", ".windows-serial"),
]
import glob
for pattern in files_to_remove:
if "*" in pattern: # Глобальный паттерн
matches = glob.glob(pattern)
for file_path in matches:
try:
if os.path.exists(file_path):
os.remove(file_path)
except Exception as e:
success = False
errors.append(str(e))
else: # Конкретный файл
try:
if os.path.exists(pattern):
os.remove(pattern)
except Exception as e:
success = False
errors.append(str(e))
# Удаление директорий
dirs_to_remove = [
os.path.join(prefix_dir, "drive_c", "windows"),
os.path.join(prefix_dir, "drive_c", "ProgramData", "Setup"),
os.path.join(prefix_dir, "drive_c", "ProgramData", "Windows"),
os.path.join(prefix_dir, "drive_c", "ProgramData", "WindowsTask"),
os.path.join(prefix_dir, "drive_c", "ProgramData", "Package Cache"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Microsoft"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Temp"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Temporary Internet Files"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "Microsoft"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "wine_gecko"),
os.path.join(prefix_dir, "drive_c", "users", "Public", "Temp"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Microsoft"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Temp"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Temporary Internet Files"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "Microsoft"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "wine_gecko"),
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Temp"),
os.path.join(prefix_dir, "drive_c", "Program Files", "Internet Explorer"),
os.path.join(prefix_dir, "drive_c", "Program Files", "Windows Media Player"),
os.path.join(prefix_dir, "drive_c", "Program Files", "Windows NT"),
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Internet Explorer"),
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows Media Player"),
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows NT"),
]
import shutil
for dir_path in dirs_to_remove:
try:
if os.path.exists(dir_path):
shutil.rmtree(dir_path)
except Exception as e:
success = False
errors.append(str(e))
tmp_path = os.path.join(self.portproton_location, "data", "tmp")
if os.path.exists(tmp_path):
import glob
bin_files = glob.glob(os.path.join(tmp_path, "*.bin"))
foz_files = glob.glob(os.path.join(tmp_path, "*.foz"))
for file_path in bin_files + foz_files:
try:
os.remove(file_path)
except Exception as e:
success = False
errors.append(str(e))
if success:
QMessageBox.information(self, _("Success"), _("Prefix '{}' cleared successfully.").format(selected_prefix))
else:
error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5]))
QMessageBox.warning(self, _("Warning"), error_msg)
def create_prefix_backup(self):
selected_prefix = self.prefixCombo.currentText()
if not selected_prefix:
@@ -1187,8 +1691,9 @@ class MainWindow(QMainWindow):
QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix))
# обновляем список
self.prefixCombo.clear()
self.prefixes = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "prefixes"))
if os.path.isdir(os.path.join(self.portproton_location, "data", "prefixes", d))]
prefixes_path = os.path.join(self.portproton_location, "data", "prefixes")
self.prefixes = [d for d in os.listdir(prefixes_path)
if os.path.isdir(os.path.join(prefixes_path, d))]
self.prefixCombo.addItems(self.prefixes)
except Exception as e:
QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e)))
@@ -1329,7 +1834,22 @@ class MainWindow(QMainWindow):
self.gamesDisplayCombo.setCurrentIndex(idx)
formLayout.addRow(self.gamesDisplayTitle, self.gamesDisplayCombo)
# 4. Proxy settings
# 4 Gamepad Type
self.gamepadTypeCombo = QComboBox()
self.gamepadTypeCombo.addItems(["Xbox", "PlayStation"])
self.gamepadTypeCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.gamepadTypeCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
self.gamepadTypeTitle = QLabel(_("Gamepad Type:"))
self.gamepadTypeTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.gamepadTypeTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
current_type_str = read_gamepad_type()
if current_type_str == "playstation":
self.gamepadTypeCombo.setCurrentText("PlayStation")
else:
self.gamepadTypeCombo.setCurrentText("Xbox")
formLayout.addRow(self.gamepadTypeTitle, self.gamepadTypeCombo)
# 5. Proxy settings
self.proxyUrlEdit = CustomLineEdit(self, theme=self.theme)
self.proxyUrlEdit.setPlaceholderText(_("Proxy URL"))
self.proxyUrlEdit.setStyleSheet(self.theme.PROXY_INPUT_STYLE)
@@ -1361,7 +1881,7 @@ class MainWindow(QMainWindow):
self.proxyPasswordTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
formLayout.addRow(self.proxyPasswordTitle, self.proxyPasswordEdit)
# 5. Fullscreen setting for application
# 6. Fullscreen setting for application
self.fullscreenCheckBox = QCheckBox(_("Launch Application in Fullscreen"))
self.fullscreenCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.fullscreenCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -1372,7 +1892,19 @@ class MainWindow(QMainWindow):
self.fullscreenCheckBox.setChecked(current_fullscreen)
formLayout.addRow(self.fullscreenTitle, self.fullscreenCheckBox)
# 6. Automatic fullscreen on gamepad connection
# 7. Minimize to tray setting
self.minimizeToTrayCheckBox = QCheckBox(_("Minimize to tray on close"))
self.minimizeToTrayCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.minimizeToTrayCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.minimizeToTrayTitle = QLabel(_("Application Close Mode:"))
self.minimizeToTrayTitle.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.minimizeToTrayTitle.setFocusPolicy(Qt.FocusPolicy.NoFocus)
current_minimize_to_tray = read_minimize_to_tray()
self.minimizeToTrayCheckBox.setChecked(current_minimize_to_tray)
self.minimizeToTrayCheckBox.toggled.connect(lambda checked: save_minimize_to_tray(checked))
formLayout.addRow(self.minimizeToTrayTitle, self.minimizeToTrayCheckBox)
# 8. Automatic fullscreen on gamepad connection
self.autoFullscreenGamepadCheckBox = QCheckBox(_("Auto Fullscreen on Gamepad connected"))
self.autoFullscreenGamepadCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
self.autoFullscreenGamepadCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -1384,7 +1916,7 @@ class MainWindow(QMainWindow):
self.autoFullscreenGamepadCheckBox.setChecked(current_auto_fullscreen)
formLayout.addRow(self.autoFullscreenGamepadTitle, self.autoFullscreenGamepadCheckBox)
# 7. Gamepad haptic feedback config
# 9. Gamepad haptic feedback config
self.gamepadRumbleCheckBox = QCheckBox(_("Gamepad haptic feedback"))
self.gamepadRumbleCheckBox.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.gamepadRumbleCheckBox.setStyleSheet(self.theme.SETTINGS_CHECKBOX_STYLE)
@@ -1395,10 +1927,10 @@ class MainWindow(QMainWindow):
self.gamepadRumbleCheckBox.setChecked(current_rumble_state)
formLayout.addRow(self.gamepadRumbleTitle, self.gamepadRumbleCheckBox)
# # 8. Legendary Authentication
# # 9. Legendary Authentication
# self.legendaryAuthButton = AutoSizeButton(
# _("Open Legendary Login"),
# icon=self.theme_manager.get_icon("login")
# icon=self.theme_manager.get_icon("login")self.theme_manager.get_icon("login")
# )
# self.legendaryAuthButton.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
# self.legendaryAuthButton.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
@@ -1575,6 +2107,19 @@ class MainWindow(QMainWindow):
rumble_enabled = self.gamepadRumbleCheckBox.isChecked()
save_rumble_config(rumble_enabled)
gamepad_type_text = self.gamepadTypeCombo.currentText()
gpad_type = "playstation" if gamepad_type_text == "PlayStation" else "xbox"
save_gamepad_type(gpad_type)
if hasattr(self, 'input_manager'):
if gpad_type == "playstation":
self.input_manager.gamepad_type = GamepadType.PLAYSTATION
elif gpad_type == "xbox":
self.input_manager.gamepad_type = GamepadType.XBOX
else:
self.input_manager.gamepad_type = GamepadType.UNKNOWN
self.updateControlHints()
for card in self.game_library_manager.game_card_cache.values():
card.update_badge_visibility(filter_key)
@@ -1591,9 +2136,6 @@ class MainWindow(QMainWindow):
gamepad_connected = self.input_manager.find_gamepad() is not None
if fullscreen or (auto_fullscreen_gamepad and gamepad_connected):
self.showFullScreen()
else:
self.showNormal()
self.resize(*read_window_geometry())
self.statusBar().showMessage(_("Settings saved"), 3000)
@@ -2066,10 +2608,19 @@ class MainWindow(QMainWindow):
completionist_time = hltb.format_game_time(game, "completionist")
# Очищаем layout перед добавлением новых элементов
while hltbLayout.count():
child = hltbLayout.takeAt(0)
if child.widget():
child.widget().deleteLater()
def clear_layout(layout):
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
sublayout = item.layout()
if widget:
widget.deleteLater()
elif sublayout:
clear_layout(sublayout)
clear_layout(hltbLayout)
has_data = False
@@ -2374,9 +2925,7 @@ class MainWindow(QMainWindow):
else:
# Запускаем игру через PortProton
env_vars = os.environ.copy()
env_vars['START_FROM_STEAM'] = '1'
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
env_vars['PROCESS_LOG'] = '1'
wrapper = "flatpak run ru.linux_gaming.PortProton"
if self.portproton_location is not None and ".var" not in self.portproton_location:
@@ -2503,10 +3052,12 @@ class MainWindow(QMainWindow):
logger.error(f"Failed to launch game {exe_name}: {e}")
QMessageBox.warning(self, _("Error"), _("Failed to launch game: {0}").format(str(e)))
def closeEvent(self, event):
"""Обработчик закрытия окна: сворачивает приложение в трей, если не требуется принудительный выход."""
if hasattr(self, 'is_exiting') and self.is_exiting:
"""Обработчик закрытия окна: проверяет настройку minimize_to_tray.
Если True — сворачиваем в трей (по умолчанию). Иначе — полностью закрываем.
"""
minimize_to_tray = read_minimize_to_tray() # Импорт read_minimize_to_tray из config_utils
if hasattr(self, 'is_exiting') and self.is_exiting or not minimize_to_tray:
# Принудительное закрытие: завершаем процессы и приложение
for proc in self.game_processes:
try:
@@ -2531,22 +3082,27 @@ class MainWindow(QMainWindow):
self.game_processes = [] # Очищаем список процессов
# Очищаем таймеры
if hasattr(self, 'games_load_timer') and self.games_load_timer.isActive():
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.isActive():
if hasattr(self, 'settingsDebounceTimer') and self.settingsDebounceTimer is not None and self.settingsDebounceTimer.isActive():
self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer is not None and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
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)
save_auto_card_size(self.auto_card_width)
event.accept()
else:

View File

@@ -4,9 +4,12 @@ import orjson
import requests
import urllib.parse
import time
import glob
import re
from collections.abc import Callable
from portprotonqt.downloader import Downloader
from portprotonqt.logger import get_logger
from portprotonqt.config_utils import get_portproton_location
logger = get_logger(__name__)
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
@@ -52,6 +55,9 @@ class PortProtonAPI:
self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
os.makedirs(self.custom_data_dir, exist_ok=True)
self.portproton_location = get_portproton_location()
self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data")
self._topics_data = None
def _get_game_dir(self, exe_name: str) -> str:
@@ -68,40 +74,6 @@ class PortProtonAPI:
logger.debug(f"Failed to check file at {url}: {e}")
return False
def download_game_assets(self, exe_name: str, timeout: int = 5) -> dict[str, str | None]:
game_dir = self._get_game_dir(exe_name)
results: dict[str, str | None] = {"cover": None, "metadata": None}
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
cover_url_base = f"{self.base_url}/{exe_name}/cover"
metadata_url = f"{self.base_url}/{exe_name}/metadata.txt"
for ext in cover_extensions:
cover_url = f"{cover_url_base}{ext}"
if self._check_file_exists(cover_url, timeout):
local_cover_path = os.path.join(game_dir, f"cover{ext}")
result = self.downloader.download(cover_url, local_cover_path, timeout=timeout)
if result:
results["cover"] = result
logger.info(f"Downloaded cover for {exe_name} to {result}")
break
else:
logger.error(f"Failed to download cover for {exe_name} from {cover_url}")
else:
logger.debug(f"No cover found for {exe_name} with extension {ext}")
if self._check_file_exists(metadata_url, timeout):
local_metadata_path = os.path.join(game_dir, "metadata.txt")
result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout)
if result:
results["metadata"] = result
logger.info(f"Downloaded metadata for {exe_name} to {result}")
else:
logger.error(f"Failed to download metadata for {exe_name} from {metadata_url}")
else:
logger.debug(f"No metadata found for {exe_name}")
return results
def download_game_assets_async(self, exe_name: str, timeout: int = 5, callback: Callable[[dict[str, str | None]], None] | None = None) -> None:
game_dir = self._get_game_dir(exe_name)
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
@@ -163,6 +135,164 @@ class PortProtonAPI:
if callback:
callback(results)
def download_autoinstall_cover_async(self, exe_name: str, timeout: int = 5, callback: Callable[[str | None], None] | None = None) -> None:
"""Download only autoinstall cover image (PNG only, no metadata)."""
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
autoinstall_root = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
user_game_folder = os.path.join(autoinstall_root, exe_name)
if not os.path.isdir(user_game_folder):
try:
os.mkdir(user_game_folder)
except FileExistsError:
pass
cover_url = f"{self.base_url}/{exe_name}/cover.png"
local_cover_path = os.path.join(user_game_folder, "cover.png")
def on_cover_downloaded(local_path: str | None):
if local_path:
logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
else:
logger.debug(f"No autoinstall cover downloaded for {exe_name}")
if callback:
callback(local_path)
if self._check_file_exists(cover_url, timeout):
self.downloader.download_async(
cover_url,
local_cover_path,
timeout=timeout,
callback=on_cover_downloaded
)
else:
logger.debug(f"No autoinstall cover found for {exe_name}")
if callback:
callback(None)
def parse_autoinstall_script(self, file_path: str) -> tuple[str | None, str | None]:
"""Extract display_name from # name comment and exe_name from autoinstall bash script."""
try:
with open(file_path, encoding='utf-8') as f:
content = f.read()
# Skip emulators
if re.search(r'#\s*type\s*:\s*emulators', content, re.IGNORECASE):
return None, None
display_name = None
exe_name = None
# Extract display_name from "# name:" comment
name_match = re.search(r'#\s*name\s*:\s*(.+)', content, re.IGNORECASE)
if name_match:
display_name = name_match.group(1).strip()
# --- pw_create_unique_exe ---
pw_match = re.search(r'pw_create_unique_exe(?:\s+["\']([^"\']+)["\'])?', content)
if pw_match:
arg = pw_match.group(1)
if arg:
exe_name = arg.strip()
if not exe_name.lower().endswith(".exe"):
exe_name += ".exe"
else:
export_match = re.search(
r'export\s+PORTWINE_CREATE_SHORTCUT_NAME\s*=\s*["\']([^"\']+)["\']',
content, re.IGNORECASE)
if export_match:
exe_name = f"{export_match.group(1).strip()}.exe"
else:
portwine_match = None
for line in content.splitlines():
stripped = line.strip()
if stripped.startswith("#"):
continue
if "portwine_exe" in stripped and "=" in stripped:
portwine_match = stripped
break
if portwine_match:
exe_expr = portwine_match.split("=", 1)[1].strip().strip("'\" ")
exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr)
if exe_candidates:
exe_name = os.path.basename(exe_candidates[-1].strip())
# Fallback
if not display_name and exe_name:
display_name = exe_name
return display_name, exe_name
except Exception as e:
logger.error(f"Failed to parse {file_path}: {e}")
return None, None
def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
"""Load auto-install games with user/builtin covers (no async download here)."""
games = []
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall") if self.portproton_location else ""
if not os.path.exists(auto_dir):
callback(games)
return
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
if not scripts:
callback(games)
return
xdg_data_home = os.getenv("XDG_DATA_HOME",
os.path.join(os.path.expanduser("~"), ".local", "share"))
base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
os.makedirs(base_autoinstall_dir, exist_ok=True)
for script_path in scripts:
display_name, exe_name = self.parse_autoinstall_script(script_path)
script_name = os.path.splitext(os.path.basename(script_path))[0]
if not (display_name and exe_name):
continue
exe_name = os.path.splitext(exe_name)[0] # Без .exe
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
os.makedirs(user_game_folder, exist_ok=True)
# Поиск обложки
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}")
# Формируем кортеж игры (добавлен exe_name в конец)
game_tuple = (
display_name, # name
"", # description
cover_path, # cover
"", # appid
f"autoinstall:{script_name}", # exec_line
"", # controller_support
"Never", # last_launch
"0h 0m", # formatted_playtime
"", # protondb_tier
"", # anticheat_status
0, # last_played
0, # playtime_seconds
"autoinstall", # game_source
exe_name # exe_name
)
games.append(game_tuple)
callback(games)
def _load_topics_data(self):
"""Load and cache linux_gaming_topics_min.json from the archive."""
if self._topics_data is not None:

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg>

After

Width:  |  Height:  |  Size: 682 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.438 26.092-7.6218-12.616h5.7406l4.4433 8.238 4.4109-8.238h5.7731l-7.6866 12.552v8.4974h-5.0595z" fill="#3f424d" stroke-width="1.0811" aria-label="Y"/></svg>

After

Width:  |  Height:  |  Size: 559 B

View File

@@ -217,6 +217,56 @@ CONTEXT_MENU_STYLE = f"""
}}
"""
VIRTUAL_KEYBOARD_STYLE = """
VirtualKeyboard {
background-color: rgba(30, 30, 30, 200);
border-radius: 0px;
border: none;
}
QPushButton {
font-size: 14px;
border: 1px solid #555;
border-top-color: #666;
border-left-color: #666;
border-radius: 3px;
min-width: 30px;
min-height: 30px;
padding: 4px;
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #505050, stop:1 #404040);
color: #e0e0e0;
}
QPushButton:hover {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #606060, stop:1 #505050);
border: 1px solid #666;
border-top-color: #777;
border-left-color: #777;
}
QPushButton:focus {
border: 2px solid #4a90e2;
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5a5a5a, stop:1 #454545);
}
QPushButton:pressed {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a3a3a, stop:1 #303030);
border: 1px solid #444;
border-bottom-color: #555;
border-right-color: #555;
padding-top: 5px;
padding-bottom: 3px;
padding-left: 5px;
padding-right: 3px;
}
QPushButton[checked="true"] {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a90e2, stop:1 #3a7ad2);
color: white;
border: 1px solid #2a6ac2;
border-top-color: #5aa0f2;
border-left-color: #5aa0f2;
}
QPushButton[checked="true"]:focus {
border: 2px solid #6aa3f5;
}
"""
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
MAIN_WINDOW_STYLE = f"""
QWidget {{

View File

@@ -0,0 +1,642 @@
from typing import cast, Any
from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
QSizePolicy, QWidget, QLineEdit)
from PySide6.QtCore import Qt, Signal, QProcess, QSize
from PySide6.QtGui import QPixmap, QIcon
from portprotonqt.keyboard_layouts import keyboard_layouts
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.config_utils import read_theme_from_config
theme_manager = ThemeManager()
class VirtualKeyboard(QFrame):
keyPressed = Signal(str)
def __init__(self, parent: QWidget | None = None, theme=None, button_width: int = 80):
super().__init__(parent)
self._parent: QWidget | None = parent
self.available_layouts: list[str] = self.get_layouts_setxkbmap()
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
if not self.available_layouts:
self.available_layouts.append('en')
self.current_layout: str = self.available_layouts[0]
self.focus_timer = None
self.focus_delay = 150 # Задержка между перемещениями в мс
self.last_focus_time = 0
self.backspace_pressed = False
self.backspace_timer = None
self.backspace_initial_delay = 500
self.backspace_repeat_delay = 50
self.gamepad_x_pressed = False
self.caps_lock = False
self.shift_pressed = False
self.current_input_widget = None
self.cursor_visible = True
self.last_focused_button = None
self.base_button_width = 40
self.base_min_width = 574
self.button_width = button_width
self.button_height = 40
self.spacing = 4
self.margins = 10
self.num_cols = 14
# Find input_manager and main_window
self.input_manager: Any = None
self.main_window: Any = None
parent_widget: QWidget | None = self._parent
while parent_widget:
if hasattr(parent_widget, 'input_manager'):
self.input_manager = cast(Any, parent_widget).input_manager
self.main_window = cast(Any, parent_widget)
parent_widget = cast(QWidget | None, parent_widget.parent())
self.current_theme_name = read_theme_from_config()
self.initUI()
self.hide()
self.setStyleSheet(self.theme.VIRTUAL_KEYBOARD_STYLE)
def highlight_cursor_position(self):
"""Подсвечиваем текущую позицию курсора"""
if not self.current_input_widget or not isinstance(self.current_input_widget, QLineEdit):
return
# Просто устанавливаем курсор на нужную позицию без выделения
self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition())
def initUI(self):
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
self.keyboard_layout = QGridLayout()
self.keyboard_layout.setSpacing(self.spacing)
self.keyboard_layout.setContentsMargins(self.margins // 2, self.margins // 2, self.margins // 2, self.margins // 2)
self.create_keyboard()
self.keyboard_container = QWidget()
self.keyboard_container.setLayout(self.keyboard_layout)
ratio = self.button_width / self.base_button_width
self.keyboard_container.setMinimumWidth(int(self.base_min_width * ratio))
self.keyboard_container.setMinimumHeight(220)
self.keyboard_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout.addWidget(self.keyboard_container, 0, Qt.AlignmentFlag.AlignHCenter)
self.setLayout(layout)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
def run_shell_command(self, cmd: str) -> str | None:
process = QProcess(self)
process.start("sh", ["-c", cmd])
process.waitForFinished(-1)
if process.exitCode() == 0:
output_bytes = process.readAllStandardOutput().data()
if isinstance(output_bytes, memoryview):
output_str = output_bytes.tobytes().decode('utf-8').strip()
else:
output_str = output_bytes.decode('utf-8').strip()
return output_str
else:
return None
def get_layouts_setxkbmap(self) -> list[str]:
"""Получаем раскладки, которые используются в системе, возвращаем список вида ['us', 'ru'] и т.п."""
cmd = r'''localectl status | awk -F: '/X11 Layout/ {gsub(/^[ \t]+/, "", $2); print $2}' '''
output = self.run_shell_command(cmd)
if output:
layouts = [lang.strip() for lang in output.split(',') if lang.strip()]
return layouts if layouts else ['en']
else:
return ['en']
def create_keyboard(self):
# TODO: сделать нормальное описание (сейчас лень)
# Основные раскладки с учетом Shift
# Фильтруем доступные раскладки
LAYOUT_MAP = {'us': 'en'}
# Assume keyboard_layouts is dict[str, dict[str, list[list[str]]]]
self.layouts: dict[str, dict[str, list[list[str]]]] = {
lang: keyboard_layouts.get(LAYOUT_MAP.get(lang, lang), keyboard_layouts['en'])
for lang in self.available_layouts
}
self.current_layout = (self.current_layout if self.current_layout in self.layouts else next(iter(self.layouts.keys()), None) or 'en')
self.buttons: dict[str, QPushButton] = {}
self.update_keyboard()
def set_gamepad_icon(self, button, icon_type, gtype=''):
"""Set gamepad icon on button based on type"""
if icon_type in ['back', 'add_game']:
icon_name = self.main_window.get_button_icon(icon_type, gtype)
else: # nav left/right
if icon_type in ['left', 'right']:
direction = icon_type
icon_name = self.main_window.get_nav_icon(direction, gtype)
else:
direction = 'left' if icon_type == 'left' else 'right'
icon_name = self.main_window.get_nav_icon(direction, gtype)
icon_path = theme_manager.get_theme_image(icon_name, self.current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
button.setIcon(QIcon(pixmap))
button.setIconSize(QSize(20, 20))
return
else:
# Fallback to placeholder
placeholder = theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
button.setIcon(QIcon(placeholder))
button.setIconSize(QSize(20, 20))
return
def update_keyboard(self):
coords = self._save_focused_coords()
# Очищаем предыдущие кнопки
while self.keyboard_layout.count():
item = self.keyboard_layout.takeAt(0)
if item.widget():
item.widget().deleteLater()
fixed_w = self.button_width
fixed_h = self.button_height
# Выбираем текущую раскладку (обычная или с shift)
layout_mode = 'shift' if self.shift_pressed else 'normal'
layout_data = self.layouts.get(self.current_layout, {})
buttons: list[list[str]] = layout_data.get(layout_mode, [])
# Добавляем основные кнопки
for row_idx, row in enumerate(buttons):
for col_idx, key in enumerate(row):
button = QPushButton(key)
button.setFixedSize(fixed_w, fixed_h)
# Обработчики для CAPS и левого Shift
if key == 'CAPS':
button.setCheckable(True)
button.setChecked(self.caps_lock)
button.clicked.connect(self.on_caps_click)
elif key == '': # Левый Shift
button.setCheckable(True)
button.setChecked(self.shift_pressed)
button.clicked.connect(lambda checked: self.on_shift_click(checked))
# Add gamepad icon for Shift (RB/R)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(button, 'right', gtype)
else:
button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k))
self.keyboard_layout.addWidget(button, row_idx, col_idx)
self.buttons[key] = button
# Нижний ряд (специальные кнопки)
shift = QPushButton('')
shift.setFixedSize(fixed_w * 3 + 2 * self.spacing, fixed_h)
shift.setCheckable(True)
shift.setChecked(self.shift_pressed)
shift.clicked.connect(lambda checked: self.on_shift_click(checked))
# Add gamepad icon for Shift (RB/R)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(shift, 'right', gtype)
self.keyboard_layout.addWidget(shift, 3, 11, 1, 3)
button = QPushButton('CAPS')
button.setCheckable(True)
button.setChecked(self.caps_lock)
button.clicked.connect(self.on_caps_click)
space = QPushButton('Space')
space.setFixedSize(fixed_w * 5 + 4 * self.spacing, fixed_h)
space.clicked.connect(lambda: self.on_button_click(' '))
self.keyboard_layout.addWidget(space, 4, 1, 1, 5)
backspace = QPushButton('')
backspace.setFixedSize(fixed_w, fixed_h)
backspace.pressed.connect(self.on_backspace_pressed)
backspace.released.connect(self.stop_backspace_repeat)
# Add gamepad icon for Backspace (X/Triangle)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(backspace, 'add_game', gtype)
self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1)
enter = QPushButton('Enter')
enter.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
enter.clicked.connect(self.on_enter_click)
self.keyboard_layout.addWidget(enter, 2, 12, 1, 2)
lang = QPushButton('🌐')
lang.setFixedSize(fixed_w, fixed_h)
lang.clicked.connect(self.on_lang_click)
# Add gamepad icon for Lang (LB/L)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(lang, 'left', gtype)
self.keyboard_layout.addWidget(lang, 4, 0, 1, 1)
clear = QPushButton('Clear')
clear.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
clear.clicked.connect(self.on_clear_click)
self.keyboard_layout.addWidget(clear, 4, 10, 1, 2)
up = QPushButton('')
up.setFixedSize(fixed_w, fixed_h)
up.clicked.connect(self.up_key) # Обработка клика мышью - управление курсором
self.keyboard_layout.addWidget(up, 4, 6, 1, 1)
down = QPushButton('')
down.setFixedSize(fixed_w, fixed_h)
down.clicked.connect(self.down_key)
self.keyboard_layout.addWidget(down, 4, 7, 1, 1)
left = QPushButton('')
left.setFixedSize(fixed_w, fixed_h)
left.clicked.connect(self.left_key)
self.keyboard_layout.addWidget(left, 4, 8, 1, 1)
right = QPushButton('')
right.setFixedSize(fixed_w, fixed_h)
right.clicked.connect(self.right_key)
self.keyboard_layout.addWidget(right, 4, 9, 1, 1)
hide_button = QPushButton('Hide')
hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h)
hide_button.clicked.connect(self.hide)
# Add gamepad icon for Hide (B/Circle)
gtype = self.input_manager.gamepad_type
self.set_gamepad_icon(hide_button, 'back', gtype)
self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2)
if coords:
row, col = coords
item = self.keyboard_layout.itemAtPosition(row, col)
if item and item.widget():
item.widget().setFocus()
def up_key(self):
"""Перемещает курсор в QLineEdit вверх/в начало, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
self.current_input_widget.setCursorPosition(0)
self.current_input_widget.setFocus()
def down_key(self):
"""Перемещает курсор в QLineEdit вниз/в конец, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
self.current_input_widget.setCursorPosition(len(self.current_input_widget.text()))
self.current_input_widget.setFocus()
def left_key(self):
"""Перемещает курсор в QLineEdit влево, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
pos = self.current_input_widget.cursorPosition()
if pos > 0:
self.current_input_widget.setCursorPosition(pos - 1)
self.current_input_widget.setFocus()
def right_key(self):
"""Перемещает курсор в QLineEdit вправо, если клавиатура видима"""
if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit):
pos = self.current_input_widget.cursorPosition()
text_len = len(self.current_input_widget.text())
if pos < text_len:
self.current_input_widget.setCursorPosition(pos + 1)
self.current_input_widget.setFocus()
def move_focus_up(self):
"""Перемещает фокус по кнопкам клавиатуры вверх с фиксированной скоростью"""
current_time = self.get_current_time()
if current_time - self.last_focus_time >= self.focus_delay:
self.focusNextKey("up")
self.last_focus_time = current_time
def move_focus_down(self):
"""Перемещает фокус по кнопкам клавиатуры вниз с фиксированной скоростью"""
current_time = self.get_current_time()
if current_time - self.last_focus_time >= self.focus_delay:
self.focusNextKey("down")
self.last_focus_time = current_time
def move_focus_left(self):
"""Перемещает фокус по кнопкам клавиатуры влево с фиксированной скоростью"""
current_time = self.get_current_time()
if current_time - self.last_focus_time >= self.focus_delay:
self.focusNextKey("left")
self.last_focus_time = current_time
def move_focus_right(self):
"""Перемещает фокус по кнопкам клавиатуры вправо с фиксированной скоростью"""
current_time = self.get_current_time()
if current_time - self.last_focus_time >= self.focus_delay:
self.focusNextKey("right")
self.last_focus_time = current_time
def get_current_time(self):
"""Возвращает текущее время в миллисекундах"""
from time import time
return int(time() * 1000)
def _save_focused_coords(self) -> tuple[int, int] | None:
"""Возвращает (row, col) кнопки с фокусом или None"""
current = self.focusWidget()
if not current:
return None
idx = self.keyboard_layout.indexOf(current)
if idx == -1:
return None
position = cast(tuple[int, int, int, int], self.keyboard_layout.getItemPosition(idx))
return position[:2] # row, col
def on_button_click(self, key):
if key in ['TAB', 'CAPS', '']:
if key == 'TAB':
self.on_tab_click()
elif key == 'CAPS':
self.on_caps_click()
elif key == '':
self.on_shift_click(not self.shift_pressed)
self.highlight_cursor_position()
elif self.current_input_widget is not None:
# Сохраняем текущую кнопку с фокусом
focused_button = self.focusWidget()
key_to_restore = None
if isinstance(focused_button, QPushButton) and focused_button in self.buttons.values():
key_to_restore = next((k for k, btn in self.buttons.items() if btn == focused_button), None)
key = "&" if key == "&&" else key
cursor_pos = self.current_input_widget.cursorPosition()
text = self.current_input_widget.text()
new_text = text[:cursor_pos] + key + text[cursor_pos:]
self.current_input_widget.setText(new_text)
self.current_input_widget.setCursorPosition(cursor_pos + len(key))
self.keyPressed.emit(key)
self.highlight_cursor_position()
# Если был нажат SHIFT, но не CapsLock, отключаем его после ввода символа
if self.shift_pressed and not self.caps_lock:
self.shift_pressed = False
self.update_keyboard()
if key_to_restore and key_to_restore in self.buttons:
self.buttons[key_to_restore].setFocus()
def on_tab_click(self):
if self.current_input_widget is not None:
self.current_input_widget.insert('\t')
self.keyPressed.emit('Tab')
self.current_input_widget.setFocus()
self.highlight_cursor_position()
def on_caps_click(self):
"""Включаем/выключаем CapsLock"""
self.caps_lock = not self.caps_lock
self.shift_pressed = self.caps_lock
self.update_keyboard()
# ---------- таймерное событие ----------
def timerEvent(self, event):
if event.timerId() == self.backspace_timer:
self.on_backspace_click() # стираем ещё один символ
# первое срабатывание прошло ускоряем
if self.backspace_timer:
self.killTimer(self.backspace_timer)
self.backspace_timer = self.startTimer(self.backspace_repeat_delay)
def on_backspace_click(self):
"""Обработка одного нажатия Backspace"""
if self.current_input_widget is not None:
cursor_pos = self.current_input_widget.cursorPosition()
text = self.current_input_widget.text()
if cursor_pos > 0:
new_text = text[:cursor_pos - 1] + text[cursor_pos:]
self.current_input_widget.setText(new_text)
self.current_input_widget.setCursorPosition(cursor_pos - 1)
self.keyPressed.emit('Backspace')
self.highlight_cursor_position()
def on_backspace_pressed(self):
"""Обработка зажатого Backspace"""
self.backspace_pressed = True
self.start_backspace_repeat()
def start_backspace_repeat(self):
"""Запуск автоповтора нажатия Backspace"""
self.on_backspace_click() # Первое нажатие
self.backspace_timer = self.startTimer(self.backspace_initial_delay)
def stop_backspace_repeat(self):
"""Остановка автоповтора нажатия Backspace"""
if self.backspace_timer:
self.killTimer(self.backspace_timer)
self.backspace_timer = None
self.backspace_pressed = False
def on_enter_click(self):
"""Обработка действия кнопки Enter"""
# TODO: тут подумать, как обрабатывать нажатие.
# Пока болванка перехода на новую строку, в QlineEdit работает как нажатие пробела
if self.current_input_widget is not None:
self.current_input_widget.insert('\n')
self.keyPressed.emit('Enter')
def on_clear_click(self):
"""Чистим строку от введённого текста"""
if self.current_input_widget is not None:
self.current_input_widget.clear()
self.keyPressed.emit('Clear')
self.highlight_cursor_position()
def on_lang_click(self):
"""Переключение раскладки"""
if not self.available_layouts:
return
try:
current_index = self.available_layouts.index(self.current_layout)
next_index = (current_index + 1) % len(self.available_layouts)
self.current_layout = self.available_layouts[next_index]
except ValueError:
# Если текущей раскладки нет в available_layouts
self.current_layout = self.available_layouts[0] if self.available_layouts else 'en'
self.update_keyboard()
def on_shift_click(self, checked):
self.shift_pressed = checked
if not checked and self.caps_lock:
self.caps_lock = False
self.update_keyboard()
def show_for_widget(self, widget):
self.current_input_widget = widget
if widget:
widget.setFocus()
self.highlight_cursor_position()
# Позиционирование клавиатуры внизу родительского виджета
if self._parent and isinstance(self._parent, QWidget):
keyboard_height = 220
self.setFixedWidth(self._parent.width())
self.setFixedHeight(keyboard_height)
self.move(0, self._parent.height() - keyboard_height)
self.show()
self.raise_()
# Установить фокус на первую кнопку, если нет фокуса на виджете ввода
if not widget:
first_button: QPushButton | None = next((cast(QPushButton, btn) for btn in self.buttons.values()), None)
if first_button:
first_button.setFocus()
def activateFocusedKey(self):
"""Активирует текущую выделенную кнопку на клавиатуре"""
focused = self.focusWidget()
if isinstance(focused, QPushButton):
focused.animateClick()
def focusNextKey(self, direction: str):
"""Перемещает фокус на следующую кнопку в указанном направлении с обертыванием"""
current = self.focusWidget()
if not current:
first_button = self.findFirstFocusableButton()
if first_button:
first_button.setFocus()
return
current_idx = self.keyboard_layout.indexOf(current)
if current_idx == -1:
return
position = cast(tuple[int, int, int, int], self.keyboard_layout.getItemPosition(current_idx))
current_row, current_col, row_span, col_span = position
num_rows = self.keyboard_layout.rowCount()
num_cols = self.keyboard_layout.columnCount()
found = False
if direction == "right":
# Сначала ищем в той же строке вправо
search_row = current_row
search_col = current_col + col_span
while search_col < num_cols:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
next_button.setFocus()
found = True
break
search_col += 1
if not found:
# Переходим к следующей строке, начиная с col 0
search_row = (current_row + 1) % num_rows
search_col = 0
# Ищем первую кнопку в этой строке
while search_col < num_cols:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
next_button.setFocus()
found = True
break
search_col += 1
elif direction == "left":
# Сначала ищем в той же строке влево
search_row = current_row
search_col = current_col - 1
while search_col >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
next_button.setFocus()
found = True
break
search_col -= 1
if not found:
# Переходим к предыдущей строке, начиная с последнего столбца
search_row = (current_row - 1) % num_rows
search_col = num_cols - 1
# Ищем последнюю кнопку в этой строке
while search_col >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
next_button.setFocus()
found = True
break
search_col -= 1
elif direction == "down":
# Сначала ищем в том же столбце вниз
search_col = current_col
search_row = current_row + row_span
while search_row < num_rows:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
next_button.setFocus()
found = True
break
search_row += 1
if not found:
# Переходим к следующему столбцу, начиная с row 0
search_col = (current_col + col_span) % num_cols
search_row = 0
# Ищем первую кнопку в этом столбце
while search_row < num_rows:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
next_button.setFocus()
found = True
break
search_row += 1
elif direction == "up":
# Сначала ищем в том же столбце вверх
search_col = current_col
search_row = current_row - 1
while search_row >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
next_button.setFocus()
found = True
break
search_row -= 1
if not found:
# Переходим к предыдущему столбцу, начиная с последней строки
search_col = (current_col - 1) % num_cols
search_row = num_rows - 1
# Ищем последнюю кнопку в этом столбце
while search_row >= 0:
item = self.keyboard_layout.itemAtPosition(search_row, search_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
next_button.setFocus()
found = True
break
search_row -= 1
def findFirstFocusableButton(self) -> QPushButton | None:
"""Находит первую фокусируемую кнопку на клавиатуре"""
for row in range(self.keyboard_layout.rowCount()):
for col in range(self.keyboard_layout.columnCount()):
item = self.keyboard_layout.itemAtPosition(row, col)
if item and item.widget() and item.widget().isEnabled():
return cast(QPushButton, item.widget())
return None

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portprotonqt"
version = "0.1.6"
version = "0.1.7"
description = "A project to rewrite PortProton (PortWINE) using PySide"
readme = "README.md"
license = { text = "GPL-3.0" }
@@ -27,19 +27,19 @@ classifiers = [
requires-python = ">=3.10"
dependencies = [
"babel>=2.17.0",
"beautifulsoup4>=4.13.5",
"beautifulsoup4>=4.14.2",
"evdev>=1.9.2",
"icoextract>=0.2.0",
"numpy>=2.2.4",
"orjson>=3.11.2",
"orjson>=3.11.3",
"pillow>=11.3.0",
"psutil>=7.0.0",
"pyside6>=6.9.1",
"psutil>=7.1.0",
"pyside6==6.9.1",
"pyudev>=0.24.3",
"requests>=2.32.5",
"tqdm>=4.67.1",
"vdf>=3.4",
"websocket-client>=1.8.0",
"websocket-client>=1.9.0",
]
[project.scripts]
@@ -105,5 +105,5 @@ ignore = [
dev = [
"pre-commit>=4.3.0",
"pyaspeller>=2.0.2",
"pyright>=1.1.404",
"pyright>=1.1.406",
]

View File

@@ -1,6 +1,8 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:best-practices"],
"extends": [
"config:best-practices"
],
"rebaseWhen": "never",
"lockFileMaintenance": {
"enabled": true
@@ -9,6 +11,23 @@
"enabled": true
},
"packageRules": [
{
"description": "Update renovate only weekly",
"matchDepNames": ["ghcr.io/renovatebot/renovate"],
"extends": ["schedule:weekly"]
},
{
"description": "Automerge renovate updates",
"matchPackageNames": [
"ghcr.io/renovatebot/renovate"
],
"matchUpdateTypes": [
"minor",
"patch",
"digest"
],
"automerge": true
},
{
"matchUpdateTypes": ["minor", "patch"],
"automerge": true
@@ -33,7 +52,7 @@
"groupName": "Python dependencies"
},
{
"matchPackageNames": ["numpy", "setuptools", "python"],
"matchPackageNames": ["numpy", "setuptools", "python", "pyside6"],
"enabled": false,
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
},

978
uv.lock generated

File diff suppressed because it is too large Load Diff