Compare commits
106 Commits
c8c45dda06
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
0231073b19
|
|||
|
dec24429f5
|
|||
|
4a758f3b3c
|
|||
|
0853dd1579
|
|||
|
bbb87c0455
|
|||
|
b32a71a125
|
|||
|
|
bddf9f850a | ||
|
|
a9c3cfa167 | ||
|
7675bc4cdc
|
|||
|
ffa203f019
|
|||
|
3eed25ecee
|
|||
|
3736bb279e
|
|||
|
|
b59ee5ae8e | ||
|
33176590fd
|
|||
|
8046065929
|
|||
|
|
fbad5add6c | ||
|
438e9737ea
|
|||
|
2d39a4c740
|
|||
|
567203b0b0
|
|||
|
502cbc5030
|
|||
|
9b61215152
|
|||
|
10d3fe8ab4
|
|||
|
a568ad9ef8
|
|||
|
f074843fc8
|
|||
|
4ab078b93e
|
|||
|
7df6ad3b80
|
|||
|
464ad0fe9c
|
|||
|
cde92885d4
|
|||
|
120c7b319c
|
|||
|
596aed0077
|
|||
|
6fc6cb1e02
|
|||
|
186e28a19b
|
|||
|
28e4d1e77c
|
|||
|
fff1f888c4
|
|||
|
fdd5a0a3d5
|
|||
|
792e52d981
|
|||
|
84d5e46a74
|
|||
|
4bc764d568
|
|||
|
9a18aa037e
|
|||
|
ed62d2d1c4
|
|||
|
accc9b18b6
|
|||
|
82249d7eab
|
|||
|
476c896940
|
|||
|
b1047ba18e
|
|||
|
987199d8e6
|
|||
|
|
ef1acd4581 | ||
|
96f884904c
|
|||
|
b856a2afae
|
|||
|
55ef0030e6
|
|||
|
8aaeaa4824
|
|||
|
f55372b480
|
|||
|
4d6f32f053
|
|||
|
a2f5141b20
|
|||
|
e3cb2857e7
|
|||
|
efe8a35832
|
|||
|
61fae97dad
|
|||
|
5442100f64
|
|||
|
2d6ef84798
|
|||
|
|
f4aee15b5d | ||
|
87a65108a5
|
|||
|
bb617708ac
|
|||
|
1cf332cd87
|
|||
|
577ad4d3a3
|
|||
|
ef3f2d6e96
|
|||
|
657d7728a6
|
|||
| 9452bfda2e | |||
| 7eb2db0d68 | |||
| 6ef7a03366 | |||
| e5af354b56 | |||
| e6e5f6c8ea | |||
| 84306bb31b | |||
| 60af4d1482 | |||
| 692e11b21d | |||
| b1a804811e | |||
| 9a30cfaea7 | |||
| 5dd2f71f5e | |||
|
dba172361b
|
|||
|
a9c70b8818
|
|||
|
135ace732f
|
|||
|
8b727f64e1
|
|||
|
a8eb591da5
|
|||
|
fe4ca1ee87
|
|||
|
ffe3e9d3d6
|
|||
|
49d39b5d61
|
|||
|
|
03566da704
|
||
|
|
7f996ab6a0 | ||
|
|
9e17978155 | ||
|
5d0185b1b4
|
|||
|
5c134be04e
|
|||
|
8c66695192
|
|||
|
7a141d8e46
|
|||
|
abb2377fb7
|
|||
|
75f4f346de
|
|||
|
87a9f85272
|
|||
|
240f685ece
|
|||
|
af4e3e95bb
|
|||
|
017d9a42cf
|
|||
|
18b7c4054b
|
|||
|
dd7f71b70a
|
|||
|
8fd44c575b
|
|||
|
65b43c1572
|
|||
|
f35276abfe
|
|||
|
6fea9a9a7e
|
|||
|
5189474631
|
|||
|
|
416cc6a268 | ||
|
|
3b44ed5252 |
@@ -94,7 +94,7 @@ jobs:
|
||||
name: Build Arch Package
|
||||
runs-on: ubuntu-22.04
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
|
||||
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
env:
|
||||
# Common version, will be used for tagging the release
|
||||
VERSION: 0.1.6
|
||||
VERSION: 0.1.8
|
||||
PKGDEST: "/tmp/portprotonqt"
|
||||
PACKAGE: "portprotonqt"
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
@@ -180,10 +180,12 @@ jobs:
|
||||
|
||||
- name: Release
|
||||
uses: https://gitea.com/actions/gitea-release-action@v1
|
||||
env:
|
||||
NODE_OPTIONS: '--experimental-fetch' # if nodejs < 18
|
||||
with:
|
||||
body_path: changelog.txt
|
||||
token: ${{ env.GITEA_TOKEN }}
|
||||
tag_name: v${{ env.VERSION }}
|
||||
prerelease: true
|
||||
files: release/**/*
|
||||
sha256sum: true
|
||||
sha256sum: false
|
||||
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.arch == 'true' || github.event_name == 'workflow_dispatch'
|
||||
container:
|
||||
image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
|
||||
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
|
||||
volumes:
|
||||
- /usr:/usr-host
|
||||
- /opt:/opt-host
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
jobs:
|
||||
renovate:
|
||||
runs-on: ubuntu-latest
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:dd5721b9a686a40d81687643e4b71b82a0ca31fb653fd727538af69104fd388d
|
||||
container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
|
||||
steps:
|
||||
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ repos:
|
||||
- id: check-yaml
|
||||
|
||||
- repo: https://github.com/astral-sh/uv-pre-commit
|
||||
rev: 0.8.22
|
||||
rev: 0.9.5
|
||||
hooks:
|
||||
- id: uv-lock
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.13.2
|
||||
rev: v0.14.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
|
||||
|
||||
42
CHANGELOG.md
@@ -3,20 +3,59 @@
|
||||
Все заметные изменения в этом проекте фиксируются в этом файле.
|
||||
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
## [0.1.8] - 2025-10-18
|
||||
|
||||
### Added
|
||||
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
|
||||
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
|
||||
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
|
||||
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
|
||||
|
||||
### Changed
|
||||
- При завершении автоустановки приложение больше не перезапускается
|
||||
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
|
||||
- Обновлены и дополнены скриншоты темы
|
||||
|
||||
### Fixed
|
||||
- Исправлено наложение карточек при смене фильтра игр
|
||||
- Исправлена невозможность запуска приложения без подключёного геймпада
|
||||
- Исправлена невозможность установки компонентов Winetricks через геймпад
|
||||
- Ресиверы и виртуальные устройства больше не считаются за геймпад
|
||||
|
||||
|
||||
### Contributors
|
||||
- @Vector_null
|
||||
|
||||
---
|
||||
|
||||
## [0.1.7] - 2025-10-12
|
||||
|
||||
### Added
|
||||
- Возможность скроллинга библиотеки мышью или пальцем
|
||||
- Импорт и экспорт бекапа префикса
|
||||
- Диалог для управление Winetricks
|
||||
- Кнопки для удаления префикса, wine или proton
|
||||
- Все настройки Wine с оригинального PortProton
|
||||
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
|
||||
- Вкладка автоустановок
|
||||
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
|
||||
|
||||
### Changed
|
||||
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
|
||||
- В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку
|
||||
|
||||
### Fixed
|
||||
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
|
||||
- Исправлено зависание при добавлении или удалении игры в Wayland
|
||||
- Исправлено зависание при поиске игр
|
||||
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
|
||||
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
|
||||
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
|
||||
- При сохранении настроек теперь не меняется размер окна
|
||||
|
||||
### Contributors
|
||||
- @wmigor (Igor Akulov)
|
||||
- @Vector_null
|
||||
|
||||
---
|
||||
|
||||
@@ -39,6 +78,7 @@
|
||||
|
||||
### Contributors
|
||||
- @wmigor (Igor Akulov)
|
||||
- @Vector_null
|
||||
|
||||
---
|
||||
|
||||
|
||||
15
TODO.md
@@ -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] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры
|
||||
|
||||
@@ -36,7 +36,7 @@ AppDir:
|
||||
id: ru.linux_gaming.PortProtonQt
|
||||
name: PortProtonQt
|
||||
icon: ru.linux_gaming.PortProtonQt
|
||||
version: 0.1.6
|
||||
version: 0.1.8
|
||||
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"
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
pkgname=portprotonqt
|
||||
pkgver=0.1.6
|
||||
pkgver=0.1.8
|
||||
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')
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
%global pypi_name portprotonqt
|
||||
%global pypi_version 0.1.6
|
||||
%global pypi_version 0.1.8
|
||||
%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.
|
||||
|
||||
@@ -21,9 +21,9 @@ Current translation status:
|
||||
|
||||
| Locale | Progress | Translated |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 193 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 of 193 |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@
|
||||
|
||||
| Локаль | Прогресс | Переведено |
|
||||
| :----- | -------: | ---------: |
|
||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
|
||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 193 |
|
||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 193 из 193 |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
import sys
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||
import os
|
||||
import subprocess
|
||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo, QTimer, Qt
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from PySide6.QtGui import QIcon
|
||||
from PySide6.QtNetwork import QLocalServer, QLocalSocket
|
||||
|
||||
from portprotonqt.main_window import MainWindow
|
||||
from portprotonqt.config_utils import save_fullscreen_config
|
||||
from portprotonqt.config_utils import (
|
||||
save_fullscreen_config,
|
||||
read_fullscreen_config,
|
||||
get_portproton_start_command
|
||||
)
|
||||
from portprotonqt.logger import get_logger, setup_logger
|
||||
from portprotonqt.cli import parse_args
|
||||
|
||||
__app_id__ = "ru.linux_gaming.PortProtonQt"
|
||||
__app_name__ = "PortProtonQt"
|
||||
__app_version__ = "0.1.6"
|
||||
__app_version__ = "0.1.8"
|
||||
|
||||
def get_version():
|
||||
try:
|
||||
commit = subprocess.check_output(
|
||||
["git", "rev-parse", "--short", "HEAD"],
|
||||
stderr=subprocess.DEVNULL,
|
||||
).decode("utf-8").strip()
|
||||
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"
|
||||
|
||||
start_sh = get_portproton_start_command()
|
||||
|
||||
if start_sh is None:
|
||||
return
|
||||
|
||||
subprocess.run(start_sh + ["cli", "--initial"])
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
||||
app.setDesktopFileName(__app_id__)
|
||||
@@ -19,40 +48,116 @@ def main():
|
||||
app.setApplicationVersion(__app_version__)
|
||||
|
||||
args = parse_args()
|
||||
|
||||
# Setup logger with specified debug level
|
||||
setup_logger(args.debug_level)
|
||||
|
||||
# Reinitialize logger after setup to ensure it uses the new configuration
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# --- Single-instance logic ---
|
||||
server_name = __app_id__
|
||||
socket = QLocalSocket()
|
||||
socket.connectToServer(server_name)
|
||||
|
||||
if socket.waitForConnected(200):
|
||||
# Второй экземпляр — передаём команду первому
|
||||
fullscreen = args.fullscreen or read_fullscreen_config()
|
||||
msg = b"show:fullscreen" if fullscreen else b"show"
|
||||
socket.write(msg)
|
||||
socket.flush()
|
||||
socket.waitForBytesWritten(500)
|
||||
socket.disconnectFromServer()
|
||||
logger.info("Restored existing instance from tray")
|
||||
return
|
||||
|
||||
# Если старый сокет остался — удалить
|
||||
QLocalServer.removeServer(server_name)
|
||||
|
||||
local_server = QLocalServer()
|
||||
if not local_server.listen(server_name):
|
||||
logger.warning(f"Failed to start local server: {local_server.errorString()}")
|
||||
return
|
||||
|
||||
# --- Qt translations ---
|
||||
system_locale = QLocale.system()
|
||||
qt_translator = QTranslator()
|
||||
translations_path = QLibraryInfo.path(QLibraryInfo.LibraryPath.TranslationsPath)
|
||||
if qt_translator.load(system_locale, "qtbase", "_", translations_path):
|
||||
app.installTranslator(qt_translator)
|
||||
else:
|
||||
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
|
||||
logger.warning(
|
||||
f"Qt translations for {system_locale.name()} not found in {translations_path}, using English"
|
||||
)
|
||||
|
||||
window = MainWindow(app_name=__app_name__)
|
||||
# --- Main Window ---
|
||||
version = get_version()
|
||||
window = MainWindow(app_name=__app_name__, version=version)
|
||||
|
||||
if args.fullscreen:
|
||||
logger.info("Launching in fullscreen mode due to --fullscreen flag")
|
||||
# --- Handle incoming connections ---
|
||||
def handle_new_connection():
|
||||
conn = local_server.nextPendingConnection()
|
||||
if not conn:
|
||||
return
|
||||
|
||||
if conn.waitForReadyRead(1000):
|
||||
data = conn.readAll().data()
|
||||
msg = bytes(data).decode("utf-8", errors="ignore")
|
||||
logger.info(f"IPC message received: {msg}")
|
||||
|
||||
def restore_window():
|
||||
try:
|
||||
if msg.startswith("show"):
|
||||
if hasattr(window, "restore_from_tray"):
|
||||
window.restore_from_tray() # type: ignore[attr-defined]
|
||||
else:
|
||||
window.showNormal()
|
||||
window.raise_()
|
||||
window.activateWindow()
|
||||
window.setWindowState(
|
||||
window.windowState() & ~Qt.WindowState.WindowMinimized | Qt.WindowState.WindowActive
|
||||
)
|
||||
|
||||
if ":fullscreen" in msg:
|
||||
logger.info("Switching to fullscreen via IPC")
|
||||
save_fullscreen_config(True)
|
||||
window.showFullScreen()
|
||||
else:
|
||||
logger.info("Switching to normal window via IPC")
|
||||
save_fullscreen_config(False)
|
||||
window.showNormal()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to restore window: {e}")
|
||||
|
||||
# Выполняем в основном потоке
|
||||
QTimer.singleShot(0, restore_window)
|
||||
|
||||
conn.disconnectFromServer()
|
||||
|
||||
local_server.newConnection.connect(handle_new_connection)
|
||||
|
||||
# --- Initial fullscreen state ---
|
||||
launch_fullscreen = args.fullscreen or read_fullscreen_config()
|
||||
if launch_fullscreen:
|
||||
logger.info(
|
||||
f"Launching in fullscreen mode ({'--fullscreen' if args.fullscreen else 'config'})"
|
||||
)
|
||||
save_fullscreen_config(True)
|
||||
window.showFullScreen()
|
||||
else:
|
||||
logger.info("Launching in normal mode")
|
||||
save_fullscreen_config(False)
|
||||
window.showNormal()
|
||||
|
||||
# --- Cleanup ---
|
||||
def cleanup_on_exit():
|
||||
nonlocal window
|
||||
app.aboutToQuit.disconnect()
|
||||
try:
|
||||
local_server.close()
|
||||
QLocalServer.removeServer(server_name)
|
||||
if window:
|
||||
window.close()
|
||||
app.quit()
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup error: {e}")
|
||||
|
||||
app.aboutToQuit.connect(cleanup_on_exit)
|
||||
|
||||
window.show()
|
||||
|
||||
sys.exit(app.exec())
|
||||
|
||||
if __name__ == '__main__':
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
import configparser
|
||||
import shutil
|
||||
import subprocess
|
||||
from portprotonqt.logger import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_portproton_location = None
|
||||
_portproton_start_sh = None
|
||||
|
||||
# Paths to configuration files
|
||||
CONFIG_FILE = os.path.join(
|
||||
@@ -101,14 +103,14 @@ def read_file_content(file_path):
|
||||
return f.read().strip()
|
||||
|
||||
def get_portproton_location():
|
||||
"""Returns the path to the PortProton directory.
|
||||
Checks the cached path first. If not set, reads from PORTPROTON_CONFIG_FILE.
|
||||
If the path is invalid, uses the default directory.
|
||||
"""
|
||||
"""Возвращает путь к PortProton каталогу (строку) или None."""
|
||||
global _portproton_location
|
||||
|
||||
if _portproton_location is not None:
|
||||
return _portproton_location
|
||||
|
||||
location = None
|
||||
|
||||
if os.path.isfile(PORTPROTON_CONFIG_FILE):
|
||||
try:
|
||||
location = read_file_content(PORTPROTON_CONFIG_FILE).strip()
|
||||
@@ -116,19 +118,46 @@ def get_portproton_location():
|
||||
_portproton_location = location
|
||||
logger.info(f"PortProton path from configuration: {location}")
|
||||
return _portproton_location
|
||||
logger.warning(f"Invalid PortProton path in configuration: {location}, using default path")
|
||||
logger.warning(f"Invalid PortProton path in configuration: {location}, using defaults")
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(f"Failed to read PortProton configuration file: {e}, using default path")
|
||||
logger.warning(f"Failed to read PortProton configuration file: {e}")
|
||||
|
||||
default_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||
if os.path.isdir(default_dir):
|
||||
_portproton_location = default_dir
|
||||
logger.info(f"Using flatpak PortProton directory: {default_dir}")
|
||||
default_flatpak_dir = os.path.join(os.path.expanduser("~"), ".var", "app", "ru.linux_gaming.PortProton")
|
||||
if os.path.isdir(default_flatpak_dir):
|
||||
_portproton_location = default_flatpak_dir
|
||||
logger.info(f"Using Flatpak PortProton directory: {default_flatpak_dir}")
|
||||
return _portproton_location
|
||||
|
||||
logger.warning("PortProton configuration and flatpak directory not found")
|
||||
logger.warning("PortProton configuration and Flatpak directory not found")
|
||||
return None
|
||||
|
||||
def get_portproton_start_command():
|
||||
"""Возвращает список команд для запуска PortProton (start.sh или flatpak run)."""
|
||||
portproton_path = get_portproton_location()
|
||||
if not portproton_path:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["flatpak", "list"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False
|
||||
)
|
||||
if "ru.linux_gaming.PortProton" in result.stdout:
|
||||
logger.info("Detected Flatpak installation")
|
||||
return ["flatpak", "run", "ru.linux_gaming.PortProton"]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
start_sh_path = os.path.join(portproton_path, "data", "scripts", "start.sh")
|
||||
if os.path.exists(start_sh_path):
|
||||
return [start_sh_path]
|
||||
|
||||
logger.warning("Neither flatpak nor start.sh found for PortProton")
|
||||
return None
|
||||
|
||||
|
||||
def parse_desktop_entry(file_path):
|
||||
"""Reads and parses a .desktop file using configparser.
|
||||
Returns None if the [Desktop Entry] section is missing.
|
||||
@@ -177,6 +206,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 +308,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 +476,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)
|
||||
|
||||
@@ -11,7 +11,7 @@ from PySide6.QtWidgets import QMessageBox, QDialog, QMenu, QLineEdit, QApplicati
|
||||
from PySide6.QtCore import QUrl, QPoint, QObject, Signal, Qt
|
||||
from PySide6.QtGui import QDesktopServices, QIcon, QKeySequence
|
||||
from portprotonqt.localization import _
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders
|
||||
from portprotonqt.config_utils import parse_desktop_entry, read_favorites, save_favorites, read_favorite_folders, save_favorite_folders, get_portproton_start_command
|
||||
from portprotonqt.steam_api import is_game_in_steam, add_to_steam, remove_from_steam
|
||||
from portprotonqt.egs_api import add_egs_to_steam, get_egs_executable, remove_egs_from_steam
|
||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, generate_thumbnail
|
||||
@@ -406,16 +406,7 @@ class ContextMenuManager:
|
||||
)
|
||||
return
|
||||
# Construct EGS launch command
|
||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||
start_sh_path = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||
if self.portproton_location and ".var" not in self.portproton_location:
|
||||
wrapper = start_sh_path
|
||||
if not os.path.exists(start_sh_path):
|
||||
self.signals.show_warning_dialog.emit(
|
||||
_("Error"),
|
||||
_("start.sh not found at {path}").format(path=start_sh_path)
|
||||
)
|
||||
return
|
||||
wrapper = get_portproton_start_command()
|
||||
exec_line = f'"{self.legendary_path}" launch {game_card.appid} --no-wine --wrapper "env START_FROM_STEAM=1 {wrapper}"'
|
||||
else:
|
||||
exec_line = self._get_exec_line(game_card.name, game_card.exec_line)
|
||||
|
||||
|
Before Width: | Height: | Size: 634 KiB After Width: | Height: | Size: 634 KiB |
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 315 KiB |
|
Before Width: | Height: | Size: 978 KiB After Width: | Height: | Size: 978 KiB |
|
Before Width: | Height: | Size: 650 KiB After Width: | Height: | Size: 650 KiB |
|
Before Width: | Height: | Size: 391 KiB After Width: | Height: | Size: 391 KiB |
|
Before Width: | Height: | Size: 710 KiB After Width: | Height: | Size: 710 KiB |
|
Before Width: | Height: | Size: 627 KiB After Width: | Height: | Size: 627 KiB |
@@ -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):
|
||||
if item.widget() and item.widget().isVisible():
|
||||
visible_items.append(item)
|
||||
visible_indices.append(i)
|
||||
s = item.sizeHint()
|
||||
nat_sizes[i, 0] = s.width()
|
||||
nat_sizes[i, 1] = s.height()
|
||||
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):
|
||||
|
||||
@@ -13,7 +13,7 @@ from portprotonqt.localization import get_egs_language, _
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.image_utils import load_pixmap_async
|
||||
from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_last_launch, get_last_launch_timestamp
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
|
||||
from portprotonqt.steam_api import (
|
||||
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
|
||||
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
|
||||
@@ -254,14 +254,7 @@ def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callba
|
||||
return
|
||||
|
||||
# Determine wrapper
|
||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||
start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
|
||||
if portproton_dir is not None and ".var" not in portproton_dir:
|
||||
wrapper = start_sh_path
|
||||
if not os.path.exists(start_sh_path):
|
||||
logger.error(f"start.sh not found at {start_sh_path}")
|
||||
callback((False, f"start.sh not found at {start_sh_path}"))
|
||||
return
|
||||
wrapper = get_portproton_start_command()
|
||||
|
||||
# Create launch script
|
||||
steam_scripts_dir = os.path.join(portproton_dir, "steam_scripts")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from PySide6.QtGui import QPainter, QColor, QDesktopServices
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl
|
||||
from PySide6.QtCore import Signal, Property, Qt, QUrl, QTimer
|
||||
from PySide6.QtWidgets import QFrame, QGraphicsDropShadowEffect, QVBoxLayout, QWidget, QStackedLayout, QLabel
|
||||
from collections.abc import Callable
|
||||
from portprotonqt.image_utils import load_pixmap_async, round_corners
|
||||
@@ -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()
|
||||
@@ -403,6 +404,13 @@ class GameCard(QFrame):
|
||||
self.favoriteLabel.setText("☆")
|
||||
self.favoriteLabel.setStyleSheet(self.theme.FAVORITE_LABEL_STYLE)
|
||||
|
||||
parent = self.parent()
|
||||
while parent:
|
||||
if hasattr(parent, 'game_library_manager'):
|
||||
QTimer.singleShot(0, parent.game_library_manager.update_game_grid) # type: ignore[attr-defined]
|
||||
break
|
||||
parent = parent.parent()
|
||||
|
||||
def toggle_favorite(self):
|
||||
favorites = read_favorites()
|
||||
if self.is_favorite:
|
||||
@@ -447,6 +455,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))
|
||||
|
||||
@@ -33,8 +33,10 @@ class MainWindowProtocol(Protocol):
|
||||
# Required attributes
|
||||
searchEdit: CustomLineEdit
|
||||
_last_card_width: int
|
||||
card_width: int
|
||||
current_hovered_card: GameCard | None
|
||||
current_focused_card: GameCard | None
|
||||
gamesListWidget: QWidget | None
|
||||
|
||||
class GameLibraryManager:
|
||||
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
|
||||
@@ -127,6 +129,8 @@ class GameLibraryManager:
|
||||
self.card_width = self.sizeSlider.value()
|
||||
self.sizeSlider.setToolTip(f"{self.card_width} px")
|
||||
save_card_size(self.card_width)
|
||||
self.main_window.card_width = self.card_width
|
||||
self.main_window._last_card_width = self.card_width
|
||||
for card in self.game_card_cache.values():
|
||||
card.update_card_size(self.card_width)
|
||||
self.update_game_grid()
|
||||
@@ -216,6 +220,16 @@ class GameLibraryManager:
|
||||
else:
|
||||
self._update_game_grid_immediate()
|
||||
|
||||
def force_update_cards_library(self):
|
||||
if self.gamesListWidget and self.gamesListLayout:
|
||||
self.gamesListLayout.invalidate()
|
||||
self.gamesListWidget.updateGeometry()
|
||||
widget = self.gamesListWidget
|
||||
QTimer.singleShot(0, lambda: (
|
||||
widget.adjustSize(),
|
||||
widget.updateGeometry()
|
||||
))
|
||||
|
||||
def _update_game_grid_immediate(self):
|
||||
"""Updates the game grid with the provided or current game list."""
|
||||
if self.gamesListLayout is None or self.gamesListWidget is None:
|
||||
@@ -345,6 +359,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):
|
||||
@@ -362,8 +378,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()
|
||||
|
||||
@@ -83,6 +83,43 @@ def load_pixmap_async(cover: str, width: int, height: int, callback: Callable[[Q
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки URL {cover}: {e}")
|
||||
|
||||
# SteamGridDB (SGDB)
|
||||
if cover and cover.startswith("https://cdn2.steamgriddb.com"):
|
||||
try:
|
||||
parts = cover.split("/")
|
||||
filename = parts[-1] if parts else "sgdb_cover.png"
|
||||
# SGDB ссылки содержат уникальный хеш в названии — используем как имя
|
||||
local_path = os.path.join(image_folder, filename)
|
||||
|
||||
if os.path.exists(local_path):
|
||||
pixmap = QPixmap(local_path)
|
||||
finish_with(pixmap)
|
||||
return
|
||||
|
||||
def on_downloaded(result: str | None):
|
||||
pixmap = QPixmap()
|
||||
if result and os.path.exists(result):
|
||||
pixmap.load(result)
|
||||
if pixmap.isNull():
|
||||
placeholder_path = theme_manager.get_theme_image("placeholder", current_theme_name)
|
||||
if placeholder_path and QFile.exists(placeholder_path):
|
||||
pixmap.load(placeholder_path)
|
||||
else:
|
||||
pixmap = QPixmap(width, height)
|
||||
pixmap.fill(QColor("#333333"))
|
||||
painter = QPainter(pixmap)
|
||||
painter.setPen(QPen(QColor("white")))
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "No Image")
|
||||
painter.end()
|
||||
finish_with(pixmap)
|
||||
|
||||
logger.info("Downloading SGDB cover for %s -> %s", app_name or "unknown", filename)
|
||||
downloader.download_async(cover, local_path, timeout=5, callback=on_downloaded)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка обработки SGDB URL {cover}: {e}")
|
||||
|
||||
if cover and cover.startswith(("http://", "https://")):
|
||||
try:
|
||||
local_path = os.path.join(image_folder, f"{app_name}.jpg")
|
||||
|
||||
73
portprotonqt/keyboard_layouts.py
Normal 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', ';', ':', '_']
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-09-23 22:23+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"
|
||||
@@ -191,6 +191,10 @@ msgstr ""
|
||||
msgid "Failed to delete custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Added '{game_name}' successfully"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
@@ -248,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 ""
|
||||
|
||||
@@ -304,6 +332,39 @@ msgstr ""
|
||||
msgid "No cover selected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix Manager"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set"
|
||||
msgstr ""
|
||||
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "No components selected."
|
||||
msgstr ""
|
||||
|
||||
msgid "Installation failed. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Components installed successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -352,9 +413,6 @@ msgstr ""
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -370,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 ""
|
||||
|
||||
@@ -382,13 +462,106 @@ msgstr ""
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
#, python-brace-format
|
||||
msgid "Added '{name}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgid "Compatibility tool:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Registry Editor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Command Prompt"
|
||||
msgstr ""
|
||||
|
||||
msgid "Uninstaller"
|
||||
msgstr ""
|
||||
|
||||
msgid "Create Prefix Backup"
|
||||
msgstr ""
|
||||
|
||||
msgid "Load Prefix Backup"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Compatibility Tool"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Prefix"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear Prefix"
|
||||
msgstr ""
|
||||
|
||||
msgid "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 ""
|
||||
|
||||
msgid "Failed to start restore process."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix backup completed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix backup failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix restore completed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix restore failed."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete prefix '{}'?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix '{}' deleted."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete prefix: {}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Compatibility tool '{}' deleted."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete compatibility tool: {}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Main PortProton parameters..."
|
||||
@@ -424,6 +597,9 @@ msgstr ""
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -448,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 ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-09-23 22:23+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"
|
||||
@@ -191,6 +191,10 @@ msgstr ""
|
||||
msgid "Failed to delete custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Added '{game_name}' successfully"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
@@ -248,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 ""
|
||||
|
||||
@@ -304,6 +332,39 @@ msgstr ""
|
||||
msgid "No cover selected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix Manager"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set"
|
||||
msgstr ""
|
||||
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "No components selected."
|
||||
msgstr ""
|
||||
|
||||
msgid "Installation failed. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Components installed successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -352,9 +413,6 @@ msgstr ""
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -370,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 ""
|
||||
|
||||
@@ -382,13 +462,106 @@ msgstr ""
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
#, python-brace-format
|
||||
msgid "Added '{name}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgid "Compatibility tool:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Registry Editor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Command Prompt"
|
||||
msgstr ""
|
||||
|
||||
msgid "Uninstaller"
|
||||
msgstr ""
|
||||
|
||||
msgid "Create Prefix Backup"
|
||||
msgstr ""
|
||||
|
||||
msgid "Load Prefix Backup"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Compatibility Tool"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Prefix"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear Prefix"
|
||||
msgstr ""
|
||||
|
||||
msgid "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 ""
|
||||
|
||||
msgid "Failed to start restore process."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix backup completed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix backup failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix restore completed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix restore failed."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete prefix '{}'?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix '{}' deleted."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete prefix: {}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Compatibility tool '{}' deleted."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete compatibility tool: {}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Main PortProton parameters..."
|
||||
@@ -424,6 +597,9 @@ msgstr ""
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -448,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 ""
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-09-23 22:23+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"
|
||||
@@ -189,6 +189,10 @@ msgstr ""
|
||||
msgid "Failed to delete custom data: {error}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Added '{game_name}' successfully"
|
||||
msgstr ""
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr ""
|
||||
|
||||
@@ -246,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 ""
|
||||
|
||||
@@ -302,6 +330,39 @@ msgstr ""
|
||||
msgid "No cover selected"
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix Manager"
|
||||
msgstr ""
|
||||
|
||||
msgid "Set"
|
||||
msgstr ""
|
||||
|
||||
msgid "Libraries"
|
||||
msgstr ""
|
||||
|
||||
msgid "Information"
|
||||
msgstr ""
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr ""
|
||||
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr ""
|
||||
|
||||
msgid "Warning"
|
||||
msgstr ""
|
||||
|
||||
msgid "No components selected."
|
||||
msgstr ""
|
||||
|
||||
msgid "Installation failed. Check logs."
|
||||
msgstr ""
|
||||
|
||||
msgid "Components installed successfully."
|
||||
msgstr ""
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr ""
|
||||
|
||||
@@ -350,9 +411,6 @@ msgstr ""
|
||||
msgid "Auto Install"
|
||||
msgstr ""
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr ""
|
||||
|
||||
@@ -368,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 ""
|
||||
|
||||
@@ -380,13 +460,106 @@ msgstr ""
|
||||
msgid "Find Games ..."
|
||||
msgstr ""
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
#, python-brace-format
|
||||
msgid "Added '{name}'"
|
||||
msgstr ""
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgid "Compatibility tool:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgid "Prefix:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgid "Registry Editor"
|
||||
msgstr ""
|
||||
|
||||
msgid "Command Prompt"
|
||||
msgstr ""
|
||||
|
||||
msgid "Uninstaller"
|
||||
msgstr ""
|
||||
|
||||
msgid "Create Prefix Backup"
|
||||
msgstr ""
|
||||
|
||||
msgid "Load Prefix Backup"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Compatibility Tool"
|
||||
msgstr ""
|
||||
|
||||
msgid "Delete Prefix"
|
||||
msgstr ""
|
||||
|
||||
msgid "Clear Prefix"
|
||||
msgstr ""
|
||||
|
||||
msgid "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 ""
|
||||
|
||||
msgid "Failed to start restore process."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix backup completed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix backup failed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix restore completed."
|
||||
msgstr ""
|
||||
|
||||
msgid "Prefix restore failed."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete prefix '{}'?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix '{}' deleted."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete prefix: {}"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Compatibility tool '{}' deleted."
|
||||
msgstr ""
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete compatibility tool: {}"
|
||||
msgstr ""
|
||||
|
||||
msgid "Main PortProton parameters..."
|
||||
@@ -422,6 +595,9 @@ msgstr ""
|
||||
msgid "Games Display Filter:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr ""
|
||||
|
||||
@@ -446,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 ""
|
||||
|
||||
|
||||
@@ -9,18 +9,17 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PROJECT VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
|
||||
"PO-Revision-Date: 2025-09-23 22:23+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-Team: ru_RU <LL@li.org>\n"
|
||||
"Language: ru_RU\n"
|
||||
"Language-Team: ru_RU <LL@li.org>\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
|
||||
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=utf-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 "
|
||||
"&& (n%100<10 || n%100>=20) ? 1 : 2);\n"
|
||||
"Generated-By: Babel 2.17.0\n"
|
||||
"X-Generator: Poedit 3.6\n"
|
||||
|
||||
msgid "Error"
|
||||
msgstr "Ошибка"
|
||||
@@ -87,11 +86,11 @@ msgstr "Успешно"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"'{game_name}' was added to Steam. Please restart Steam for changes to take "
|
||||
"effect."
|
||||
"'{game_name}' was added to Steam. Please restart Steam for changes to "
|
||||
"take effect."
|
||||
msgstr ""
|
||||
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите Steam, "
|
||||
"чтобы изменения вступили в силу."
|
||||
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите "
|
||||
"Steam, чтобы изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Executable not found for game: {game_name}"
|
||||
@@ -179,11 +178,11 @@ msgstr "Подтвердите удаление"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"Are you sure you want to delete '{game_name}'? This will remove the .desktop "
|
||||
"file and custom data."
|
||||
"Are you sure you want to delete '{game_name}'? This will remove the "
|
||||
".desktop file and custom data."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению файла ."
|
||||
"desktop и пользовательских данных."
|
||||
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению "
|
||||
"файла .desktop и пользовательских данных."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete .desktop file: {error}"
|
||||
@@ -197,6 +196,10 @@ msgstr "'{game_name}' был(а) успешно удалён(а)"
|
||||
msgid "Failed to delete custom data: {error}"
|
||||
msgstr "Не удалось удалить пользовательские данные: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Added '{game_name}' successfully"
|
||||
msgstr "'{game_name}' успешно добавлен(а)"
|
||||
|
||||
msgid "Game name and executable path are required"
|
||||
msgstr "Требуются название игры и путь к исполняемому файлу"
|
||||
|
||||
@@ -225,11 +228,11 @@ msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid ""
|
||||
"'{game_name}' was removed from Steam. Please restart Steam for changes to take "
|
||||
"effect."
|
||||
"'{game_name}' was removed from Steam. Please restart Steam for changes to"
|
||||
" take effect."
|
||||
msgstr ""
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam, чтобы "
|
||||
"изменения вступили в силу."
|
||||
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
|
||||
" чтобы изменения вступили в силу."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to remove game '{game_name}' from Steam: {error}"
|
||||
@@ -256,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 "Проводник"
|
||||
|
||||
@@ -274,7 +301,7 @@ msgstr "Путь: "
|
||||
|
||||
#, python-format
|
||||
msgid "Access denied: %s"
|
||||
msgstr "Доступ запрещен: %s"
|
||||
msgstr "Доступ запрещён: %s"
|
||||
|
||||
msgid "Edit Game"
|
||||
msgstr "Редактировать игру"
|
||||
@@ -312,6 +339,39 @@ msgstr "Скачивание обложки..."
|
||||
msgid "No cover selected"
|
||||
msgstr "Обложка не выбрана"
|
||||
|
||||
msgid "Prefix Manager"
|
||||
msgstr "Менеджер префиксов"
|
||||
|
||||
msgid "Set"
|
||||
msgstr "Выбор"
|
||||
|
||||
msgid "Libraries"
|
||||
msgstr "Библиотеки"
|
||||
|
||||
msgid "Information"
|
||||
msgstr "Описание"
|
||||
|
||||
msgid "Fonts"
|
||||
msgstr "Шрифты"
|
||||
|
||||
msgid "Settings"
|
||||
msgstr "Настройки"
|
||||
|
||||
msgid "Winetricks not found. Please try again."
|
||||
msgstr "Winetricks не найден. Повторите попытку."
|
||||
|
||||
msgid "Warning"
|
||||
msgstr "Предупреждение"
|
||||
|
||||
msgid "No components selected."
|
||||
msgstr "Не выбрано ни одного компонента."
|
||||
|
||||
msgid "Installation failed. Check logs."
|
||||
msgstr "Установка не удалась. Проверьте журналы."
|
||||
|
||||
msgid "Components installed successfully."
|
||||
msgstr "Компоненты успешно установлены."
|
||||
|
||||
msgid "Loading Epic Games Store games..."
|
||||
msgstr "Загрузка игр из Epic Games Store..."
|
||||
|
||||
@@ -360,9 +420,6 @@ msgstr "Библиотека"
|
||||
msgid "Auto Install"
|
||||
msgstr "Автоустановка"
|
||||
|
||||
msgid "Emulators"
|
||||
msgstr "Эмуляторы"
|
||||
|
||||
msgid "Wine Settings"
|
||||
msgstr "Настройки wine"
|
||||
|
||||
@@ -378,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..."
|
||||
|
||||
@@ -390,14 +469,109 @@ msgstr "Игровая библиотека"
|
||||
msgid "Find Games ..."
|
||||
msgstr "Найти игры..."
|
||||
|
||||
msgid "Here you can configure automatic game installation..."
|
||||
msgstr "Здесь можно настроить автоматическую установку игр..."
|
||||
#, python-brace-format
|
||||
msgid "Added '{name}'"
|
||||
msgstr "'{name}' добавлен(а)"
|
||||
|
||||
msgid "List of available emulators and their configuration..."
|
||||
msgstr "Список доступных эмуляторов и их настройка..."
|
||||
msgid "Compatibility tool:"
|
||||
msgstr "Инструмент совместимости:"
|
||||
|
||||
msgid "Various Wine parameters and versions..."
|
||||
msgstr "Различные параметры и версии wine..."
|
||||
msgid "Prefix:"
|
||||
msgstr "Префикс:"
|
||||
|
||||
msgid "Wine Configuration"
|
||||
msgstr "Конфигурация Wine"
|
||||
|
||||
msgid "Registry Editor"
|
||||
msgstr "Редактор реестра"
|
||||
|
||||
msgid "Command Prompt"
|
||||
msgstr "Командная строка"
|
||||
|
||||
msgid "Uninstaller"
|
||||
msgstr "Удаление программ"
|
||||
|
||||
msgid "Create Prefix Backup"
|
||||
msgstr "Создать резервную копию префикса"
|
||||
|
||||
msgid "Load Prefix Backup"
|
||||
msgstr "Загрузить резервную копию префикса"
|
||||
|
||||
msgid "Delete Compatibility Tool"
|
||||
msgstr "Удалить Инструмент совместимости"
|
||||
|
||||
msgid "Delete Prefix"
|
||||
msgstr "Удалить Префикс"
|
||||
|
||||
msgid "Clear Prefix"
|
||||
msgstr "Очистить Префикс"
|
||||
|
||||
msgid "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 "Не удалось запустить процесс резервного копирования."
|
||||
|
||||
msgid "Failed to start restore process."
|
||||
msgstr "Не удалось запустить процесс восстановления."
|
||||
|
||||
msgid "Prefix backup completed."
|
||||
msgstr "Резервное копирование префикса завершено."
|
||||
|
||||
msgid "Prefix backup failed."
|
||||
msgstr "Сбой резервного копирования префикса."
|
||||
|
||||
msgid "Prefix restore completed."
|
||||
msgstr "Восстановление префикса завершено."
|
||||
|
||||
msgid "Prefix restore failed."
|
||||
msgstr "Восстановление префикса не удалось."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete prefix '{}'?"
|
||||
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Prefix '{}' deleted."
|
||||
msgstr "Префикс «{}» удален."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete prefix: {}"
|
||||
msgstr "Не удалось удалить префикс: {}"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Are you sure you want to delete compatibility tool '{}'?"
|
||||
msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?"
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Compatibility tool '{}' deleted."
|
||||
msgstr "Инструмент совместимости «{}» удален."
|
||||
|
||||
#, python-brace-format
|
||||
msgid "Failed to delete compatibility tool: {}"
|
||||
msgstr "Не удалось удалить инструмент совместимости: {}"
|
||||
|
||||
msgid "Main PortProton parameters..."
|
||||
msgstr "Основные параметры PortProton..."
|
||||
@@ -432,6 +606,9 @@ msgstr "все"
|
||||
msgid "Games Display Filter:"
|
||||
msgstr "Фильтр игр:"
|
||||
|
||||
msgid "Gamepad Type:"
|
||||
msgstr "Тип геймпада:"
|
||||
|
||||
msgid "Proxy URL"
|
||||
msgstr "Адрес прокси"
|
||||
|
||||
@@ -456,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 "Режим полноэкранного отображения приложения при подключении геймпада"
|
||||
|
||||
@@ -482,7 +665,8 @@ msgstr "Подтвердите удаление"
|
||||
|
||||
msgid "Are you sure you want to reset all settings? This action cannot be undone."
|
||||
msgstr ""
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
|
||||
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
|
||||
"отменить."
|
||||
|
||||
msgid "Settings reset. Restarting..."
|
||||
msgstr "Настройки сброшены. Перезапуск..."
|
||||
@@ -654,3 +838,4 @@ msgstr "Нет избранных"
|
||||
|
||||
msgid "No recent games"
|
||||
msgstr "Нет недавних игр"
|
||||
|
||||
|
||||
@@ -4,12 +4,18 @@ import orjson
|
||||
import requests
|
||||
import urllib.parse
|
||||
import time
|
||||
import glob
|
||||
import re
|
||||
import hashlib
|
||||
from collections.abc import Callable
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.logger import get_logger
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
|
||||
logger = get_logger(__name__)
|
||||
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
|
||||
AUTOINSTALL_CACHE_DURATION = 3600 # 1 hour for autoinstall cache
|
||||
|
||||
def normalize_name(s):
|
||||
"""
|
||||
@@ -52,7 +58,11 @@ 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
|
||||
self._autoinstall_cache = None # New: In-memory cache
|
||||
|
||||
def _get_game_dir(self, exe_name: str) -> str:
|
||||
game_dir = os.path.join(self.custom_data_dir, exe_name)
|
||||
@@ -68,40 +78,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 +139,236 @@ 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 _compute_scripts_signature(self, auto_dir: str) -> str:
|
||||
"""Compute a hash-based signature of the autoinstall scripts to detect changes."""
|
||||
if not os.path.exists(auto_dir):
|
||||
return ""
|
||||
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
|
||||
# Simple hash: concatenate sorted filenames and hash
|
||||
filenames_str = "".join(sorted([os.path.basename(s) for s in scripts]))
|
||||
return hashlib.md5(filenames_str.encode()).hexdigest()
|
||||
|
||||
def _load_autoinstall_cache(self):
|
||||
"""Load cached autoinstall games if fresh and scripts unchanged."""
|
||||
if self._autoinstall_cache is not None:
|
||||
return self._autoinstall_cache
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
|
||||
if os.path.exists(cache_file):
|
||||
try:
|
||||
mod_time = os.path.getmtime(cache_file)
|
||||
if time.time() - mod_time < AUTOINSTALL_CACHE_DURATION:
|
||||
with open(cache_file, "rb") as f:
|
||||
data = orjson.loads(f.read())
|
||||
# Check signature
|
||||
cached_signature = data.get("scripts_signature", "")
|
||||
current_signature = self._compute_scripts_signature(
|
||||
os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
|
||||
)
|
||||
if cached_signature != current_signature:
|
||||
logger.info("Scripts signature mismatch; invalidating cache")
|
||||
return None
|
||||
self._autoinstall_cache = data["games"]
|
||||
logger.info(f"Loaded {len(self._autoinstall_cache)} cached autoinstall games")
|
||||
return self._autoinstall_cache
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load autoinstall cache: {e}")
|
||||
return None
|
||||
|
||||
def _save_autoinstall_cache(self, games):
|
||||
"""Save parsed autoinstall games to cache with scripts signature."""
|
||||
try:
|
||||
cache_dir = get_cache_dir()
|
||||
cache_file = os.path.join(cache_dir, "autoinstall_games_cache.json")
|
||||
auto_dir = os.path.join(self.portproton_location or "", "data", "scripts", "pw_autoinstall")
|
||||
scripts_signature = self._compute_scripts_signature(auto_dir)
|
||||
data = {"games": games, "scripts_signature": scripts_signature, "timestamp": time.time()}
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(orjson.dumps(data))
|
||||
logger.debug(f"Saved {len(games)} autoinstall games to cache with signature {scripts_signature}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save autoinstall cache: {e}")
|
||||
|
||||
def start_autoinstall_games_load(self, callback: Callable[[list[tuple]], None]) -> QThread | None:
|
||||
"""Start loading auto-install games in a background thread. Returns the thread for management."""
|
||||
# Check cache first (sync, fast)
|
||||
cached_games = self._load_autoinstall_cache()
|
||||
if cached_games is not None:
|
||||
# Emit via callback immediately if cached
|
||||
QThread.msleep(0) # Yield to Qt event loop
|
||||
callback(cached_games)
|
||||
return None # No thread needed
|
||||
|
||||
# No cache: Start background thread
|
||||
class AutoinstallWorker(QThread):
|
||||
finished = Signal(list)
|
||||
api: "PortProtonAPI"
|
||||
portproton_location: str | None
|
||||
|
||||
def run(self):
|
||||
games = []
|
||||
auto_dir = os.path.join(
|
||||
self.portproton_location or "", "data", "scripts", "pw_autoinstall"
|
||||
) if self.portproton_location else ""
|
||||
if not os.path.exists(auto_dir):
|
||||
self.finished.emit(games)
|
||||
return
|
||||
|
||||
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
|
||||
if not scripts:
|
||||
self.finished.emit(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.api.parse_autoinstall_script(script_path)
|
||||
script_name = os.path.splitext(os.path.basename(script_path))[0]
|
||||
|
||||
if not (display_name and exe_name):
|
||||
continue
|
||||
|
||||
exe_name = os.path.splitext(exe_name)[0]
|
||||
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
|
||||
os.makedirs(user_game_folder, exist_ok=True)
|
||||
|
||||
# Find cover
|
||||
cover_path = ""
|
||||
user_files = (
|
||||
set(os.listdir(user_game_folder))
|
||||
if os.path.exists(user_game_folder)
|
||||
else set()
|
||||
)
|
||||
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
|
||||
candidate = f"cover{ext}"
|
||||
if candidate in user_files:
|
||||
cover_path = os.path.join(user_game_folder, candidate)
|
||||
break
|
||||
|
||||
if not cover_path:
|
||||
logger.debug(f"No local cover found for autoinstall {exe_name}")
|
||||
|
||||
game_tuple = (
|
||||
display_name, "", cover_path, "", f"autoinstall:{script_name}",
|
||||
"", "Never", "0h 0m", "", "", 0, 0, "autoinstall", exe_name
|
||||
)
|
||||
games.append(game_tuple)
|
||||
|
||||
self.api._save_autoinstall_cache(games)
|
||||
self.api._autoinstall_cache = games
|
||||
self.finished.emit(games)
|
||||
|
||||
worker = AutoinstallWorker()
|
||||
worker.api = self
|
||||
worker.portproton_location = self.portproton_location
|
||||
worker.finished.connect(lambda games: callback(games))
|
||||
worker.start()
|
||||
logger.info("Started background load of autoinstall games")
|
||||
return worker
|
||||
|
||||
def _load_topics_data(self):
|
||||
"""Load and cache linux_gaming_topics_min.json from the archive."""
|
||||
if self._topics_data is not None:
|
||||
|
||||
49
portprotonqt/preloader.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import time
|
||||
|
||||
from PySide6.QtCore import QRect
|
||||
from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient
|
||||
from PySide6.QtWidgets import QWidget
|
||||
|
||||
class Preloader(QWidget):
|
||||
def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFixedSize(150, 150)
|
||||
self._speed = speed
|
||||
self._line_width = line_line_width
|
||||
self._color1 = color
|
||||
self._color2 = QColor(color.red(), color.green(), color.blue(), 0)
|
||||
self._start_time = time.time()
|
||||
|
||||
def showEvent(self, event):
|
||||
self._start_time = time.time()
|
||||
|
||||
def paintEvent(self, event):
|
||||
rect = self._get_preloader_rect()
|
||||
center = rect.center()
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
painter.setPen(self._get_pen())
|
||||
painter.translate(center)
|
||||
painter.rotate(self._get_angle())
|
||||
painter.translate(-center)
|
||||
painter.drawArc(rect, 0, 270 * 16)
|
||||
self.update()
|
||||
|
||||
def _get_pen(self) -> QPen:
|
||||
gradient = QConicalGradient()
|
||||
gradient.setCenter(self.rect().center())
|
||||
gradient.setColorAt(0, self._color1)
|
||||
gradient.setColorAt(1, self._color2)
|
||||
pen = QPen(QBrush(gradient), self._line_width)
|
||||
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
|
||||
return pen
|
||||
|
||||
def _get_angle(self) -> float:
|
||||
duration = time.time() - self._start_time
|
||||
return (self._speed * duration) % 360.0
|
||||
|
||||
def _get_preloader_rect(self) -> QRect:
|
||||
size = self._line_width // 2
|
||||
rect = self.rect()
|
||||
rect.adjust(size, size, -size, -size)
|
||||
return rect
|
||||
@@ -13,7 +13,7 @@ from portprotonqt.logger import get_logger
|
||||
from portprotonqt.localization import get_steam_language
|
||||
from portprotonqt.downloader import Downloader
|
||||
from portprotonqt.dialogs import generate_thumbnail
|
||||
from portprotonqt.config_utils import get_portproton_location
|
||||
from portprotonqt.config_utils import get_portproton_location, get_portproton_start_command
|
||||
from collections.abc import Callable
|
||||
import re
|
||||
import shutil
|
||||
@@ -23,6 +23,7 @@ import requests
|
||||
import random
|
||||
import base64
|
||||
import glob
|
||||
import urllib.parse
|
||||
|
||||
downloader = Downloader()
|
||||
logger = get_logger(__name__)
|
||||
@@ -211,14 +212,28 @@ def normalize_name(s):
|
||||
|
||||
def is_valid_candidate(candidate):
|
||||
"""
|
||||
Checks if a candidate contains forbidden substrings:
|
||||
- win32
|
||||
- win64
|
||||
- gamelauncher
|
||||
Additionally checks the string without spaces.
|
||||
Returns True if the candidate is valid, otherwise False.
|
||||
Determines whether a given candidate string is valid for use as a game name.
|
||||
|
||||
The function performs the following checks:
|
||||
1. Normalizes the candidate using `normalize_name()`.
|
||||
2. Rejects the candidate if the normalized name is exactly "game"
|
||||
(to avoid overly generic names).
|
||||
3. Removes spaces and checks for forbidden substrings:
|
||||
- "win32"
|
||||
- "win64"
|
||||
- "gamelauncher"
|
||||
These are checked in the space-free version of the string.
|
||||
4. Returns True only if none of the forbidden conditions are met.
|
||||
|
||||
Args:
|
||||
candidate (str): The candidate string to validate.
|
||||
|
||||
Returns:
|
||||
bool: True if the candidate is valid, False otherwise.
|
||||
"""
|
||||
normalized_candidate = normalize_name(candidate)
|
||||
if normalized_candidate == "game":
|
||||
return False
|
||||
normalized_no_space = normalized_candidate.replace(" ", "")
|
||||
forbidden = ["win32", "win64", "gamelauncher"]
|
||||
for token in forbidden:
|
||||
@@ -397,6 +412,39 @@ def save_app_details(app_id, data):
|
||||
with open(cache_file, "wb") as f:
|
||||
f.write(orjson.dumps(data))
|
||||
|
||||
def fetch_sgdb_cover(game_name: str) -> str:
|
||||
"""
|
||||
Fetch a cover image URL from steamgrid.usebottles.com for the given game.
|
||||
The API returns a single string (quoted URL).
|
||||
"""
|
||||
try:
|
||||
encoded = urllib.parse.quote(game_name)
|
||||
url = f"https://steamgrid.usebottles.com/api/search/{encoded}"
|
||||
resp = requests.get(url, timeout=5)
|
||||
if resp.status_code != 200:
|
||||
logger.warning("SGDB request failed for %s: %s", game_name, resp.status_code)
|
||||
return ""
|
||||
text = resp.text.strip()
|
||||
# Убираем возможные кавычки вокруг строки
|
||||
if text.startswith('"') and text.endswith('"'):
|
||||
text = text[1:-1]
|
||||
if text:
|
||||
logger.info("Fetched SGDB cover for %s: %s", game_name, text)
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.warning("Failed to fetch SGDB cover for %s: %s", game_name, e)
|
||||
return ""
|
||||
|
||||
|
||||
def check_url_exists(url: str) -> bool:
|
||||
"""Check whether a URL returns HTTP 200."""
|
||||
try:
|
||||
r = requests.head(url, timeout=3)
|
||||
return r.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def fetch_app_info_async(app_id: int, callback: Callable[[dict | None], None]):
|
||||
"""
|
||||
Asynchronously fetches detailed app info from Steam API.
|
||||
@@ -615,6 +663,11 @@ def get_full_steam_game_info_async(appid: int, callback: Callable[[dict], None])
|
||||
title = decode_text(app_info.get("name", ""))
|
||||
description = decode_text(app_info.get("short_description", ""))
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
if not check_url_exists(cover):
|
||||
logger.info("Steam cover not found for %s, trying SGDB", title)
|
||||
alt_cover = fetch_sgdb_cover(title)
|
||||
if alt_cover:
|
||||
cover = alt_cover
|
||||
|
||||
def on_protondb_tier(tier: str):
|
||||
def on_anticheat_status(anticheat_status: str):
|
||||
@@ -708,12 +761,15 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
game_name = desktop_name or exe_name.capitalize()
|
||||
|
||||
if not matching_app:
|
||||
cover = fetch_sgdb_cover(game_name) or ""
|
||||
logger.info("Using SGDB cover for non-Steam game '%s': %s", game_name, cover)
|
||||
|
||||
def on_anticheat_status(anticheat_status: str):
|
||||
callback({
|
||||
"appid": "",
|
||||
"name": decode_text(game_name),
|
||||
"description": "",
|
||||
"cover": "",
|
||||
"cover": cover,
|
||||
"controller_support": "",
|
||||
"protondb_tier": "",
|
||||
"steam_game": "false",
|
||||
@@ -744,6 +800,11 @@ def get_steam_game_info_async(desktop_name: str, exec_line: str, callback: Calla
|
||||
title = decode_text(app_info.get("name", game_name))
|
||||
description = decode_text(app_info.get("short_description", ""))
|
||||
cover = f"https://steamcdn-a.akamaihd.net/steam/apps/{appid}/library_600x900_2x.jpg"
|
||||
if not check_url_exists(cover):
|
||||
logger.info("Steam cover not found for %s, trying SGDB", title)
|
||||
alt_cover = fetch_sgdb_cover(title)
|
||||
if alt_cover:
|
||||
cover = alt_cover
|
||||
controller_support = app_info.get("controller_support", "")
|
||||
|
||||
def on_protondb_tier(tier: str):
|
||||
@@ -943,7 +1004,8 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
return (False, f"Executable file not found: {exe_path}")
|
||||
|
||||
portproton_dir = get_portproton_location()
|
||||
if not portproton_dir:
|
||||
start_sh = get_portproton_start_command()
|
||||
if not portproton_dir or not start_sh:
|
||||
logger.error("PortProton directory not found")
|
||||
return (False, "PortProton directory not found")
|
||||
|
||||
@@ -952,17 +1014,12 @@ def add_to_steam(game_name: str, exec_line: str, cover_path: str) -> tuple[bool,
|
||||
|
||||
safe_game_name = re.sub(r'[<>:"/\\|?*]', '_', game_name.strip())
|
||||
script_path = os.path.join(steam_scripts_dir, f"{safe_game_name}.sh")
|
||||
start_sh_path = os.path.join(portproton_dir, "data", "scripts", "start.sh")
|
||||
|
||||
if not os.path.exists(start_sh_path):
|
||||
logger.error(f"start.sh not found at {start_sh_path}")
|
||||
return (False, f"start.sh not found at {start_sh_path}")
|
||||
|
||||
if not os.path.exists(script_path):
|
||||
script_content = f"""#!/usr/bin/env bash
|
||||
export LD_PRELOAD=
|
||||
export START_FROM_STEAM=1
|
||||
"{start_sh_path}" "{exe_path}" "$@"
|
||||
"{start_sh}" "{exe_path}" "$@"
|
||||
"""
|
||||
try:
|
||||
with open(script_path, "w", encoding="utf-8") as f:
|
||||
|
||||
1
portprotonqt/themes/standart/images/icons/settings.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="16" height="16" version="1.1" viewBox="0 0 16 16" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m8.0005 1c-0.38761 0-0.77522 0.0327-1.1588 0.0979-0.16351 0.0281-0.30273 0.13627-0.37209 0.28935l-0.39088 0.86264c-0.49378 0.16682-0.96454 0.39759-1.4007 0.68616 2.5e-4 0-0.90672-0.2272-0.90672-0.2272-0.161-0.0403-0.33098 3e-3 -0.45442 0.11569-0.57867 0.5285-1.0672 1.1514-1.4451 1.8432-0.0804 0.14721-0.0841 0.32549-0.01 0.47628l0.41938 0.84865c-0.17954 0.49666-0.29567 1.0147-0.346 1.5417l-0.73995 0.57946c-0.13121 0.10289-0.20407 0.26514-0.19431 0.4335 0.0453 0.78981 0.21961 1.5666 0.51558 2.2983 0.0631 0.15587 0.1978 0.27003 0.36005 0.30467l0.91397 0.19559c0.26993 0.45234 0.59572 0.86802 0.96931 1.2363l-0.0161 0.94973c-3e-3 0.16861 0.0766 0.32755 0.21183 0.42484 0.63551 0.45642 1.3414 0.80207 2.0884 1.0229 0.15926 0.0471 0.33077 0.0109 0.45872-0.0963l0.72016-0.60485c0.51582 0.0674 1.0384 0.0674 1.5544 0l0.72016 0.60485c0.12796 0.10722 0.29946 0.14343 0.45872 0.0963 0.74693-0.22083 1.4528-0.56648 2.0883-1.0229 0.13521-0.0973 0.21465-0.25623 0.21189-0.42484l-0.0161-0.94973c0.37359-0.36829 0.69939-0.78372 0.96932-1.2363l0.91396-0.19559c0.16226-0.0347 0.29695-0.1488 0.36005-0.30467 0.29597-0.73174 0.47026-1.5085 0.51558-2.2983 0.01-0.16836-0.0631-0.33061-0.1943-0.4335l-0.73996-0.57946c-0.0501-0.52671-0.16652-1.045-0.34606-1.5417l0.41944-0.84865c0.0746-0.15079 0.0709-0.32907-0.01-0.47628-0.37785-0.69176-0.86638-1.3147-1.445-1.8432-0.12345-0.11258-0.29343-0.15594-0.45443-0.11569l-0.90697 0.2272c-0.43594-0.28857-0.9067-0.51908-1.4005-0.68616l-0.39088-0.86264c-0.0694-0.15308-0.20858-0.26132-0.37209-0.28935-0.38361-0.0653-0.77121-0.0979-1.1588-0.0979zm0 4.1365a2.8152 2.8635 0 0 1 2.8152 2.8636 2.8152 2.8635 0 0 1-2.8152 2.8635 2.8152 2.8635 0 0 1-2.8152-2.8635 2.8152 2.8635 0 0 1 2.8152-2.8636z" fill="#fff" stroke-width=".25254"/></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
1
portprotonqt/themes/standart/images/ps_square.svg
Normal 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 |
|
After Width: | Height: | Size: 232 KiB |
BIN
portprotonqt/themes/standart/images/screenshots/Библиотека.jpg
Normal file
|
After Width: | Height: | Size: 225 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
BIN
portprotonqt/themes/standart/images/screenshots/Карточка.jpg
Normal file
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 364 KiB |
|
Before Width: | Height: | Size: 430 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 1.0 MiB |
BIN
portprotonqt/themes/standart/images/screenshots/Темы.jpg
Normal file
|
After Width: | Height: | Size: 93 KiB |
1
portprotonqt/themes/standart/images/xbox_y.svg
Normal 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 |
@@ -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 {{
|
||||
@@ -916,6 +966,96 @@ SETTINGS_CHECKBOX_STYLE = f"""
|
||||
}}
|
||||
"""
|
||||
|
||||
WINETRICKS_TAB_STYLE = f"""
|
||||
QTabWidget::pane {{
|
||||
border: 1px solid {color_d};
|
||||
background: {color_b};
|
||||
border-radius: {border_radius_a};
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {color_c};
|
||||
color: {color_f};
|
||||
padding: 8px 16px;
|
||||
border-top-left-radius: {border_radius_a};
|
||||
border-top-right-radius: {border_radius_a};
|
||||
margin-right: 2px;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QTabBar::tab:hover {{
|
||||
background: {color_e};
|
||||
}}
|
||||
"""
|
||||
|
||||
WINETRICKS_TABBLE_STYLE = f"""
|
||||
QTableWidget {{
|
||||
background: {color_c};
|
||||
color: {color_f};
|
||||
gridline-color: {color_d};
|
||||
alternate-background-color: {color_d};
|
||||
border: {border_a};
|
||||
border-radius: {border_radius_a};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
}}
|
||||
QHeaderView::section {{
|
||||
background: {color_d};
|
||||
color: {color_f};
|
||||
padding: 5px;
|
||||
border: {border_a};
|
||||
font-weight: bold;
|
||||
}}
|
||||
QTableWidget::item {{
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid {color_d};
|
||||
}}
|
||||
QTableWidget::item:selected {{
|
||||
background: {color_a};
|
||||
color: {color_f};
|
||||
}}
|
||||
QTableWidget::item:hover {{
|
||||
background: {color_e};
|
||||
}}
|
||||
QTableWidget::indicator {{
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: {border_b} {color_a};
|
||||
border-radius: {border_radius_a};
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}}
|
||||
QTableWidget::indicator:unchecked {{
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
image: none;
|
||||
}}
|
||||
QTableWidget::indicator:checked {{
|
||||
background: {color_a};
|
||||
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
|
||||
border: {border_b} {color_f};
|
||||
}}
|
||||
QTableWidget::indicator:hover {{
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
border: {border_b} {color_a};
|
||||
}}
|
||||
QTableWidget::indicator:focus {{
|
||||
border: {border_c} {color_a};
|
||||
}}
|
||||
{SCROLL_AREA_STYLE}
|
||||
"""
|
||||
|
||||
WINETRICKS_LOG_STYLE = f"""
|
||||
QTextEdit {{
|
||||
background: {color_c};
|
||||
border: {border_a};
|
||||
border-radius: {border_radius_a};
|
||||
color: {color_f};
|
||||
font-family: '{font_family}';
|
||||
font-size: {font_size_a};
|
||||
padding: 5px;
|
||||
}}
|
||||
"""
|
||||
|
||||
FILE_EXPLORER_STYLE = f"""
|
||||
QListView {{
|
||||
font-size: {font_size_a};
|
||||
|
||||
642
portprotonqt/virtual_keyboard.py
Normal 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
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "portprotonqt"
|
||||
version = "0.1.6"
|
||||
version = "0.1.8"
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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)"
|
||||
},
|
||||
|
||||