2 Commits

Author SHA1 Message Date
0a4284191b merge upstream 2025-10-04 16:59:34 +00:00
d05f2fccd6 Preloader for load games 2025-10-01 23:59:12 +03:00
67 changed files with 967 additions and 5125 deletions

View File

@@ -94,7 +94,7 @@ jobs:
name: Build Arch Package
runs-on: ubuntu-22.04
container:
image: archlinux:base-devel@sha256:87a967f07ba6319fc35c8c4e6ce6acdb4343b57aa817398a5d2db57bd8edc731
image: archlinux:base-devel@sha256:0589aa8f31d8f64c630a2d1cc0b4c3847a1a63c988abd63d78d3c9bd94764f64
volumes:
- /usr:/usr-host
- /opt:/opt-host

View File

@@ -8,7 +8,7 @@ on:
env:
# Common version, will be used for tagging the release
VERSION: 0.1.8
VERSION: 0.1.6
PKGDEST: "/tmp/portprotonqt"
PACKAGE: "portprotonqt"
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
@@ -180,12 +180,10 @@ 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: false
sha256sum: true

View File

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

View File

@@ -8,7 +8,7 @@ on:
jobs:
renovate:
runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:latest@sha256:17c8966ef38fc361e108a550ffe2dcedf73e846f9975a974aea3d48c66b107a6
container: ghcr.io/renovatebot/renovate:latest@sha256:dd5721b9a686a40d81687643e4b71b82a0ca31fb653fd727538af69104fd388d
steps:
- uses: https://gitea.com/actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5

View File

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

View File

@@ -3,59 +3,20 @@
Все заметные изменения в этом проекте фиксируются в этом файле.
Формат основан на [Keep a Changelog](https://keepachangelog.com/) и придерживается принципов [Semantic Versioning](https://semver.org/).
## [0.1.8] - 2025-10-18
### Added
- В настройки добавлен пункт для выбора типа геймпада для подсказок по управлению
- В настройки добавлен пункт для выбора сворачивать ли приложение в трей или нет
- К диалогу добавления игры, Winetricks, диалогу выбора файлов и виртуальной клавиатуре добавлены подсказки по управлению с геймпада
- Во вкладку автоустановок добавлен слайдер изменения размера карточек (они со слайдером в библиотеке независимы)
### Changed
- При завершении автоустановки приложение больше не перезапускается
- Выбор exe в диалоге добавления игры больше не перезаписывает введенное в поле название
- Обновлены и дополнены скриншоты темы
### Fixed
- Исправлено наложение карточек при смене фильтра игр
- Исправлена невозможность запуска приложения без подключёного геймпада
- Исправлена невозможность установки компонентов Winetricks через геймпад
- Ресиверы и виртуальные устройства больше не считаются за геймпад
### Contributors
- @Vector_null
---
## [0.1.7] - 2025-10-12
## [Unreleased]
### Added
- Возможность скроллинга библиотеки мышью или пальцем
- Импорт и экспорт бекапа префикса
- Диалог для управление Winetricks
- Кнопки для удаления префикса, wine или proton
- Все настройки Wine с оригинального PortProton
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке и автоустановках
- Вкладка автоустановок
- В заголовке окна теперь отображается версия приложения и хеш коммита если запуск идёт с гита
### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
- В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку
### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
- При сохранении настроек теперь не меняется размер окна
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---
@@ -78,7 +39,6 @@
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---

15
TODO.md
View File

@@ -1,6 +1,6 @@
- [X] Адаптировать структуру проекта для поддержки инструментов сборки
- [X] Добавить возможность управления с геймпада
- [X] Добавить возможность управления с тачскрина (Формально и так есть)
- [ ] Добавить возможность управления с тачскрина
- [X] Добавить возможность управления с мыши и клавиатуры
- [X] Добавить систему тем [Документация](documentation/theme_guide)
- [X] Вынести все константы, такие как уровень закругления карточек, в темы (частично выполнено)
@@ -11,18 +11,18 @@
- [ ] Разработать адаптивный дизайн (за эталон берётся Steam Deck с разрешением 1280×800)
- [ ] Переделать скриншоты для соответствия [гайдлайнам Flathub](https://docs.flathub.org/docs/for-app-authors/metainfo-guidelines/quality-guidelines#screenshots)
- [X] Получать описания и названия игр из базы данных Steam
- [X] Получать обложки для игр из CDN Steam
- [X] Получать обложки для игр из SteamGridDB или CDN Steam
- [X] Оптимизировать работу со Steam API для ускорения времени запуска
- [X] Улучшить функцию поиска в Steam API для исправления некорректного определения ID (например, Graven определялся как ENGRAVEN или GRAVENFALL, Spore — как SporeBound или Spore Valley)
- [X] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [ ] Убрать логи Steam API в релизной версии, так как они замедляют выполнение кода
- [X] Решить проблему с ограничением Steam API в 50 тысяч игр за один запрос (иногда нужные игры не попадают в выборку и остаются без обложки)
- [X] Избавиться от вызовов yad
- [X] Реализовать собственный системный трей вместо использования трея PortProton
- [X] Добавить экранную клавиатуру в поиск
- [X] Добавить экранную клавиатуру в поиск (реализация собственной клавиатуры слишком затратна, поэтому используется встроенная в DE клавиатура: Maliit в KDE, gjs-osk в GNOME, Squeekboard в Phosh, клавиатура SteamOS и т.д.)
- [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,6 +62,7 @@
- [X] Исправить некорректную работу слайдера увеличения размера карточек([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63)
- [X] Исправить баг с наложением карточек друг на друга при изменении фильтра отображения ([Последствия регрессии после этого коммита](https://github.com/Boria138/PortProtonQt/commit/aebdd60b5537280f06a922ff80469cd4ab27bc63))
- [X] Скопировать логику управления с D-pad на стрелки с клавиатуры
- [X] Добавить подсказки к управлению с геймпада
- [ ] Доделать светлую тему
- [ ] Добавить подсказки к управлению с геймпада
- [X] Добавить миниатюры к выбору файлов в диалоге добавления игры
- [X] Добавить быстрый доступ к смонтированным дискам к выбору файлов в диалоге добавления игры

View File

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

View File

@@ -1,12 +1,12 @@
pkgname=portprotonqt
pkgver=0.1.8
pkgver=0.1.6
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' 'cabextract' 'unzip' 'curl' 'unrar')
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
makedepends=('python-'{'build','installer','setuptools','wheel'})
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
sha256sums=('SKIP')

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,9 +21,9 @@
| Локаль | Прогресс | Переведено |
| :----- | -------: | ---------: |
| [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 |
| [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 |
---

View File

@@ -1,41 +1,17 @@
import sys
import os
import subprocess
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QIcon
from portprotonqt.main_window import MainWindow
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location
from portprotonqt.config_utils import save_fullscreen_config
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.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__
__app_version__ = "0.1.6"
def main():
os.environ['PW_CLI'] = '1'
os.environ['PROCESS_LOG'] = '1'
os.environ['START_FROM_STEAM'] = '1'
portproton_path = get_portproton_location()
if portproton_path is None:
return
script_path = os.path.join(portproton_path, 'data', 'scripts', 'start.sh')
subprocess.run([script_path, 'cli', '--initial'])
app = QApplication(sys.argv)
app.setWindowIcon(QIcon.fromTheme(__app_id__))
app.setDesktopFileName(__app_id__)
@@ -58,8 +34,7 @@ def main():
else:
logger.warning(f"Qt translations for {system_locale.name()} not found in {translations_path}, using english language")
version = get_version()
window = MainWindow(app_name=__app_name__, version=version)
window = MainWindow(app_name=__app_name__)
if args.fullscreen:
logger.info("Launching in fullscreen mode due to --fullscreen flag")

View File

@@ -177,26 +177,6 @@ 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.
@@ -279,25 +259,6 @@ 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.
@@ -447,22 +408,3 @@ def save_favorite_folders(folders):
cp["FavoritesFolders"]["folders"] = f'"{fav_str}"'
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)
def read_minimize_to_tray():
"""Reads the minimize-to-tray setting from the [Display] section.
Returns True if the parameter is missing (default: minimize to tray).
"""
cp = read_config_safely(CONFIG_FILE)
if cp is None or not cp.has_section("Display") or not cp.has_option("Display", "minimize_to_tray"):
save_minimize_to_tray(True)
return True
return cp.getboolean("Display", "minimize_to_tray", fallback=True)
def save_minimize_to_tray(minimize_to_tray):
"""Saves the minimize-to-tray setting to the [Display] section."""
cp = read_config_safely(CONFIG_FILE) or configparser.ConfigParser()
if "Display" not in cp:
cp["Display"] = {}
cp["Display"]["minimize_to_tray"] = str(minimize_to_tray)
with open(CONFIG_FILE, "w", encoding="utf-8") as configfile:
cp.write(configfile)

View File

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 315 KiB

View File

Before

Width:  |  Height:  |  Size: 978 KiB

After

Width:  |  Height:  |  Size: 978 KiB

View File

Before

Width:  |  Height:  |  Size: 634 KiB

After

Width:  |  Height:  |  Size: 634 KiB

View File

Before

Width:  |  Height:  |  Size: 650 KiB

After

Width:  |  Height:  |  Size: 650 KiB

View File

Before

Width:  |  Height:  |  Size: 391 KiB

After

Width:  |  Height:  |  Size: 391 KiB

View File

Before

Width:  |  Height:  |  Size: 710 KiB

After

Width:  |  Height:  |  Size: 710 KiB

View File

Before

Width:  |  Height:  |  Size: 627 KiB

After

Width:  |  Height:  |  Size: 627 KiB

View File

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

View File

@@ -2,13 +2,11 @@ import os
import tempfile
import re
from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon, QTextCursor
from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller,
QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller
)
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
from icoextract import IconExtractor, IconExtractorError
from PIL import Image
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
@@ -17,8 +15,6 @@ from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.preloader import Preloader
import psutil
if TYPE_CHECKING:
@@ -91,130 +87,6 @@ def generate_thumbnail(inputfile, outfile, size=128, force_resize=True):
logger.error(f"Ошибка при сохранении миниатюры: {e}")
return False
def create_dialog_hints_widget(theme, main_window, input_manager, context='default'):
"""
Common function to create hints widget for all dialogs.
Uses main_window for get_button_icon/get_nav_icon, input_manager for gamepad detection.
"""
theme_manager = ThemeManager()
current_theme_name = read_theme_from_config()
hintsWidget = QWidget()
hintsWidget.setStyleSheet(theme.STATUS_BAR_STYLE)
hintsLayout = QHBoxLayout(hintsWidget)
hintsLayout.setContentsMargins(10, 0, 10, 0)
hintsLayout.setSpacing(20)
dialog_actions = []
# Context-specific actions (gamepad only, no keyboard)
if context == 'file_explorer':
dialog_actions = [
("confirm", _("Open")), # A / Cross
("add_game", _("Select Dir")), # X / Triangle
("prev_dir", _("Prev Dir")), # Y / Square
("back", _("Cancel")), # B / Circle
("context_menu", _("Menu")), # Start / Options
]
elif context == 'winetricks':
dialog_actions = [
("confirm", _("Toggle")), # A / Cross
("add_game", _("Install")), # X / Triangle
("prev_dir", _("Force Install")), # Y / Square
("back", _("Cancel")), # B / Circle
("prev_tab", _("Prev Tab")), # LB / L1
("next_tab", _("Next Tab")), # RB / R1
]
hints_labels = [] # Store for updates (returned for class storage)
def make_hint(icon_name, text, action=None):
container = QWidget()
hlayout = QHBoxLayout(container)
hlayout.setContentsMargins(0, 5, 0, 0)
hlayout.setSpacing(6)
icon_label = QLabel()
icon_label.setFixedSize(26, 26)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
hlayout.addWidget(icon_label)
text_label = QLabel(text)
text_label.setStyleSheet(theme.LAST_LAUNCH_VALUE_STYLE)
text_label.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
hlayout.addWidget(text_label)
# Initially hidden; show only if gamepad connected
container.setVisible(False)
hints_labels.append((container, icon_label, action))
hintsLayout.addWidget(container)
# Add gamepad hints only
for action, text in dialog_actions:
make_hint("placeholder", text, action)
hintsLayout.addStretch()
# Return widget and labels for class storage
return hintsWidget, hints_labels
def update_dialog_hints(hints_labels, main_window, input_manager, theme_manager, current_theme_name):
"""
Common function to update hints for any dialog.
"""
if not input_manager or not main_window:
# Hide all if no input_manager or main_window
for container, _, _ in hints_labels:
container.setVisible(False)
return
is_gamepad = input_manager.gamepad is not None
if not is_gamepad:
# Hide all hints if no gamepad
for container, _, _ in hints_labels:
container.setVisible(False)
return
gtype = input_manager.gamepad_type
gamepad_actions = ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir', 'prev_tab', 'next_tab']
for container, icon_label, action in hints_labels:
if action and action in gamepad_actions:
container.setVisible(True)
# Update icon using main_window methods
if action in ['confirm', 'back', 'context_menu', 'add_game', 'prev_dir']:
icon_name = main_window.get_button_icon(action, gtype)
else: # only prev_tab/next_tab (treat as nav)
direction = 'left' if action == 'prev_tab' else 'right'
icon_name = main_window.get_nav_icon(direction, gtype)
icon_path = theme_manager.get_theme_image(icon_name, current_theme_name)
pixmap = QPixmap()
if icon_path:
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
else:
# Fallback to placeholder
placeholder = theme_manager.get_theme_image("placeholder", current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
class FileSelectedSignal(QObject):
file_selected = Signal(str) # Сигнал с путем к выбранному файлу
@@ -309,7 +181,6 @@ class FileExplorer(QDialog):
self.initial_path = initial_path # Store initial path if provided
self.thumbnail_cache = {} # Cache for loaded thumbnails
self.pending_thumbnails = set() # Track files pending thumbnail loading
self.main_window = None # Add reference to MainWindow
self.setup_ui()
# Window settings
@@ -323,7 +194,6 @@ class FileExplorer(QDialog):
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
if hasattr(parent, 'context_menu_manager'):
self.context_menu_manager = cast("MainWindow", parent).context_menu_manager
parent = parent.parent()
@@ -340,17 +210,6 @@ class FileExplorer(QDialog):
self.current_path = os.path.expanduser("~") # Fallback to home if initial path is invalid
self.update_file_list()
# Create hints widget using common function
self.current_theme_name = read_theme_from_config()
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='file_explorer')
self.main_layout.addWidget(self.hints_widget)
# Connect signals
if self.input_manager:
self.input_manager.button_event.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
self.input_manager.dpad_moved.connect(lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name))
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
class ThumbnailLoader(QRunnable):
"""Class for asynchronous thumbnail loading in a separate thread."""
class Signals(QObject):
@@ -648,8 +507,8 @@ class FileExplorer(QDialog):
"""Update the list of mounted drives and favorite folders."""
for i in reversed(range(self.drives_layout.count())):
item = self.drives_layout.itemAt(i)
widget = item.widget() if item else None
if widget:
if item and item.widget():
widget = item.widget()
self.drives_layout.removeWidget(widget)
widget.deleteLater()
@@ -738,16 +597,6 @@ class FileExplorer(QDialog):
self.thumbnail_cache.clear() # Clear cache when changing directories
self.pending_thumbnails.clear() # Clear pending thumbnails
try:
if self.directory_only:
item = QListWidgetItem("./")
folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon
item.setIcon(folder_icon)
self.file_list.addItem(item)
if self.current_path != "/":
item = QListWidgetItem("../")
folder_icon = theme_manager.get_icon("folder")
@@ -955,60 +804,6 @@ class AddGameDialog(QDialog):
if edit_mode:
self.updatePreview()
# Инициализация клавиатуры (отдельным методом вроде лучше)
self.init_keyboard()
# Устанавливаем фокус на первое поле при открытии
QTimer.singleShot(0, self.nameEdit.setFocus)
def init_keyboard(self):
"""Инициализация виртуальной клавиатуры"""
self.keyboard = VirtualKeyboard(self, theme=self.theme, button_width=40)
self.keyboard.hide()
def show_keyboard_for_widget(self, widget):
"""Показывает клавиатуру для указанного виджета"""
if not widget or not widget.isVisible():
return
# Устанавливаем текущий виджет ввода
self.keyboard.current_input_widget = widget
# Позиционирование клавиатуры
keyboard_height = 220
self.keyboard.setFixedWidth(self.width())
self.keyboard.setFixedHeight(keyboard_height)
self.keyboard.move(0, self.height() - keyboard_height)
# Показываем и поднимаем клавиатуру
self.keyboard.setParent(self)
self.keyboard.show()
self.keyboard.raise_()
# TODO: доработать.
# Устанавливаем фокус на первую кнопку клавиатуры
first_button = self.keyboard.findFirstFocusableButton()
if first_button:
QTimer.singleShot(50, lambda: first_button.setFocus())
def closeEvent(self, event):
"""Обработчик закрытия окна"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().closeEvent(event)
def reject(self):
"""Обработчик кнопки Cancel"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().reject()
def accept(self):
"""Обработчик кнопки Apply"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().accept()
def browseExe(self):
"""Открывает файловый менеджер для выбора exe-файла"""
try:
@@ -1034,8 +829,8 @@ class AddGameDialog(QDialog):
"""Обработчик выбора файла в FileExplorer"""
self.exeEdit.setText(file_path)
self.last_exe_path = file_path # Update last selected exe path
if not self.edit_mode and not self.nameEdit.text().strip():
# Автоматически заполняем имя игры, если не в режиме редактирования или если оно не введено вручную
if not self.edit_mode:
# Автоматически заполняем имя игры, если не в режиме редактирования
game_name = os.path.splitext(os.path.basename(file_path))[0]
self.nameEdit.setText(game_name)
@@ -1172,505 +967,3 @@ Icon={icon_path}
"""
return desktop_entry, desktop_path
class WinetricksDialog(QDialog):
def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None):
super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.prefix_path: str | None = prefix_path
self.wine_use: str | None = wine_use
self.portproton_path = get_portproton_location()
if self.portproton_path is None:
logger.error("PortProton location not found")
return
self.tmp_path = os.path.join(self.portproton_path, "data", "tmp")
os.makedirs(self.tmp_path, exist_ok=True)
self.winetricks_path = os.path.join(self.tmp_path, "winetricks")
if self.prefix_path is None:
logger.error("Prefix path not provided")
return
self.log_path = os.path.join(self.prefix_path, "winetricks.log")
os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
if not os.path.exists(self.log_path):
open(self.log_path, 'a').close()
self.downloader = Downloader(max_workers=4)
self.apply_process: QProcess | None = None
self.setWindowTitle(_("Prefix Manager"))
self.setModal(True)
self.resize(700, 700)
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
self.update_winetricks()
self.setup_ui()
self.load_lists()
# Find input_manager and main_window
self.input_manager = None
self.main_window = None
parent = self.parent()
while parent:
if hasattr(parent, 'input_manager'):
self.input_manager = cast("MainWindow", parent).input_manager
self.main_window = parent
parent = parent.parent()
self.current_theme_name = read_theme_from_config()
# Enable Winetricks-specific mode
if self.input_manager:
self.input_manager.enable_winetricks_mode(self)
# Create hints widget using common function
self.hints_widget, self.hints_labels = create_dialog_hints_widget(self.theme, self.main_window, self.input_manager, context='winetricks')
self.main_layout.addWidget(self.hints_widget)
# Connect signals (use self.theme_manager)
if self.input_manager:
self.input_manager.button_event.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
self.input_manager.dpad_moved.connect(
lambda *args: update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
)
update_dialog_hints(self.hints_labels, self.main_window, self.input_manager, theme_manager, self.current_theme_name)
def update_winetricks(self):
"""Update the winetricks script."""
if not self.downloader.has_internet():
logger.warning("No internet connection, skipping winetricks update")
return
url = "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks"
temp_path = os.path.join(self.tmp_path, "winetricks_temp")
try:
self.downloader.download(url, temp_path)
with open(temp_path) as f:
ext_content = f.read()
ext_ver_match = re.search(r'WINETRICKS_VERSION\s*=\s*[\'"]?([^\'"\s]+)', ext_content)
ext_ver = ext_ver_match.group(1) if ext_ver_match else None
logger.info(f"External winetricks version: {ext_ver}")
except Exception as e:
logger.error(f"Failed to get external version: {e}")
ext_ver = None
if os.path.exists(temp_path):
os.remove(temp_path)
return
int_ver = None
if os.path.exists(self.winetricks_path):
try:
with open(self.winetricks_path) as f:
int_content = f.read()
int_ver_match = re.search(r'WINETRICKS_VERSION\s*=\s*[\'"]?([^\'"\s]+)', int_content)
int_ver = int_ver_match.group(1) if int_ver_match else None
logger.info(f"Internal winetricks version: {int_ver}")
except Exception as e:
logger.error(f"Failed to read internal winetricks version: {e}")
update_needed = not os.path.exists(self.winetricks_path) or (int_ver != ext_ver and ext_ver)
if update_needed:
try:
self.downloader.download(url, self.winetricks_path)
os.chmod(self.winetricks_path, 0o755)
logger.info(f"Winetricks updated to version {ext_ver}")
self.apply_modifications(self.winetricks_path)
except Exception as e:
logger.error(f"Failed to update winetricks: {e}")
elif os.path.exists(self.winetricks_path):
self.apply_modifications(self.winetricks_path)
if os.path.exists(temp_path):
os.remove(temp_path)
def apply_modifications(self, file_path):
"""Apply custom modifications to the winetricks script."""
if not os.path.exists(file_path):
return
try:
with open(file_path) as f:
content = f.read()
# Apply sed-like replacements
content = re.sub(r'w_metadata vcrun2015 dlls \\', r'w_metadata !dont_use_2015! dlls \\', content)
content = re.sub(r'w_metadata vcrun2017 dlls \\', r'w_metadata !dont_use_2017! dlls \\', content)
content = re.sub(r'w_metadata vcrun2019 dlls \\', r'w_metadata !dont_use_2019! dlls \\', content)
content = re.sub(r'w_set_winver win2k3', r'w_set_winver win7', content)
with open(file_path, 'w') as f:
f.write(content)
logger.info("Winetricks modifications applied")
except Exception as e:
logger.error(f"Error applying modifications to winetricks: {e}")
def setup_ui(self):
"""Set up the user interface with tabs and tables."""
self.main_layout = QVBoxLayout(self)
self.main_layout.setContentsMargins(10, 10, 10, 10)
self.main_layout.setSpacing(10)
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
self.main_layout.addWidget(self.log_output)
# Tab widget
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(self.theme.WINETRICKS_TAB_STYLE)
table_base_style = self.theme.WINETRICKS_TABBLE_STYLE
# DLLs tab
self.dll_table = QTableWidget()
self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.dll_table.setColumnCount(3)
self.dll_table.setHorizontalHeaderLabels([_("Set"), _("Libraries"), _("Information")])
self.dll_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.dll_table.horizontalHeader().resizeSection(0, 50)
self.dll_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.dll_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.dll_table.setStyleSheet(table_base_style)
self.dll_preloader = Preloader()
dll_preloader_container = QWidget()
dll_preloader_layout = QVBoxLayout(dll_preloader_container)
dll_preloader_layout.addStretch()
dll_preloader_hlayout = QHBoxLayout()
dll_preloader_hlayout.addStretch()
dll_preloader_hlayout.addWidget(self.dll_preloader)
dll_preloader_hlayout.addStretch()
dll_preloader_layout.addLayout(dll_preloader_hlayout)
dll_preloader_layout.addStretch()
dll_preloader_layout.setContentsMargins(0, 0, 0, 0)
dll_preloader_layout.setSpacing(0)
self.dll_container = QStackedWidget()
self.dll_container.addWidget(dll_preloader_container)
self.dll_container.addWidget(self.dll_table)
self.tab_widget.addTab(self.dll_container, "DLLs")
# Fonts tab
self.fonts_table = QTableWidget()
self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.fonts_table.setColumnCount(3)
self.fonts_table.setHorizontalHeaderLabels([_("Set"), _("Fonts"), _("Information")])
self.fonts_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.fonts_table.horizontalHeader().resizeSection(0, 50)
self.fonts_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.fonts_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.fonts_table.setStyleSheet(table_base_style)
self.fonts_preloader = Preloader()
fonts_preloader_container = QWidget()
fonts_preloader_layout = QVBoxLayout(fonts_preloader_container)
fonts_preloader_layout.addStretch()
fonts_preloader_hlayout = QHBoxLayout()
fonts_preloader_hlayout.addStretch()
fonts_preloader_hlayout.addWidget(self.fonts_preloader)
fonts_preloader_hlayout.addStretch()
fonts_preloader_layout.addLayout(fonts_preloader_hlayout)
fonts_preloader_layout.addStretch()
fonts_preloader_layout.setContentsMargins(0, 0, 0, 0)
fonts_preloader_layout.setSpacing(0)
self.fonts_container = QStackedWidget()
self.fonts_container.addWidget(fonts_preloader_container)
self.fonts_container.addWidget(self.fonts_table)
self.tab_widget.addTab(self.fonts_container, _("Fonts"))
# Settings tab
self.settings_table = QTableWidget()
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.settings_table.setColumnCount(3)
self.settings_table.setHorizontalHeaderLabels([_("Set"), _("Settings"), _("Information")])
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.settings_table.horizontalHeader().resizeSection(0, 50)
self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.settings_table.setStyleSheet(table_base_style)
self.settings_preloader = Preloader()
settings_preloader_container = QWidget()
settings_preloader_layout = QVBoxLayout(settings_preloader_container)
settings_preloader_layout.addStretch()
settings_preloader_hlayout = QHBoxLayout()
settings_preloader_hlayout.addStretch()
settings_preloader_hlayout.addWidget(self.settings_preloader)
settings_preloader_hlayout.addStretch()
settings_preloader_layout.addLayout(settings_preloader_hlayout)
settings_preloader_layout.addStretch()
settings_preloader_layout.setContentsMargins(0, 0, 0, 0)
settings_preloader_layout.setSpacing(0)
self.settings_container = QStackedWidget()
self.settings_container.addWidget(settings_preloader_container)
self.settings_container.addWidget(self.settings_table)
self.tab_widget.addTab(self.settings_container, _("Settings"))
self.containers = {
"dlls": self.dll_container,
"fonts": self.fonts_container,
"settings": self.settings_container
}
self.main_layout.addWidget(self.tab_widget)
# Buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.force_button = AutoSizeButton(_("Force Install"), icon=theme_manager.get_icon("apply"))
self.install_button = AutoSizeButton(_("Install"), icon=theme_manager.get_icon("apply"))
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.force_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.install_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button_layout.addWidget(self.cancel_button)
button_layout.addWidget(self.force_button)
button_layout.addWidget(self.install_button)
self.main_layout.addLayout(button_layout)
self.cancel_button.clicked.connect(self.reject)
self.force_button.clicked.connect(lambda: self.install_selected(force=True))
self.install_button.clicked.connect(lambda: self.install_selected(force=False))
def load_lists(self):
"""Load and populate the lists for DLLs, Fonts, and Settings"""
if not os.path.exists(self.winetricks_path):
QMessageBox.warning(self, _("Error"), _("Winetricks not found. Please try again."))
self.reject()
return
assert self.prefix_path is not None
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", self.prefix_path)
env.insert("WINETRICKS_DOWNLOADER", "curl")
if self.wine_use is not None:
env.insert("WINE", self.wine_use)
cwd = os.path.dirname(self.winetricks_path)
# DLLs
self.containers["dlls"].setCurrentIndex(0)
self._start_list_process("dlls", self.dll_table, self.get_dll_exclusions(), env, cwd)
# Fonts
self.containers["fonts"].setCurrentIndex(0)
self._start_list_process("fonts", self.fonts_table, self.get_fonts_exclusions(), env, cwd)
# Settings
self.containers["settings"].setCurrentIndex(0)
self._start_list_process("settings", self.settings_table, self.get_settings_exclusions(), env, cwd)
def _start_list_process(self, category, table, exclusion_pattern, env, cwd):
"""Запускает QProcess для списка."""
process = QProcess(self)
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
process.setProcessEnvironment(env)
process.finished.connect(lambda exit_code, exit_status: self._on_list_finished(category, table, exclusion_pattern, process, exit_code, exit_status))
process.start(self.winetricks_path, [category, "list"])
def _on_list_finished(self, category, table, exclusion_pattern, process: QProcess | None, exit_code, exit_status):
"""Обработчик завершения списка."""
if process is None:
logger.error(f"Process is None for {category}")
self.containers[category].setCurrentIndex(1)
return
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
self.populate_table(table, output, exclusion_pattern, self.log_path)
# Restore focus after populating
if table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
else:
error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
logger.error(f"Failed to list {category}: {error_output}")
self.containers[category].setCurrentIndex(1)
def get_dll_exclusions(self):
"""Get regex pattern for DLL exclusions."""
return r'(d3d|directx9|dont_use|dxvk|vkd3d|galliumnine|faudio1|Foundation)'
def get_fonts_exclusions(self):
"""Get regex pattern for Fonts exclusions."""
return r'dont_use'
def get_settings_exclusions(self):
"""Get regex pattern for Settings exclusions."""
return r'(vista|alldlls|autostart_|bad|good|win|videomemory|vd=|isolate_home)'
def populate_table(self, table, output, exclusion_pattern, log_path):
"""Populate the table with items from output, checking installation status."""
table.setRowCount(0)
table.verticalHeader().setVisible(False)
lines = output.strip().split('\n')
installed = set()
if os.path.exists(log_path):
with open(log_path) as f:
for line in f:
installed.add(line.strip())
# regex-парсинг (имя - первое слово, остальное - описание)
line_re = re.compile(r"^\s*(?:\[(.)]\s+)?([^\s]+)\s*(.*)")
for line in lines:
line = line.strip()
if not line or re.search(exclusion_pattern, line, re.I):
continue
line = line.split('(', 1)[0].strip()
match = line_re.match(line)
if not match:
continue
_status, name, info = match.groups()
# Очищаем info от мусора
info = re.sub(r'\[.*?\]', '', info).strip() # Удаляем [скачивания] и т.п.
# To match bash desc extraction: after name, substr(2) to trim leading space
if info.startswith(' '):
info = info[1:].lstrip()
# Фильтр служебных строк
if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'):
continue
checked = Qt.CheckState.Checked if name in installed else Qt.CheckState.Unchecked
row = table.rowCount()
table.insertRow(row)
# Checkbox
checkbox = QTableWidgetItem()
checkbox.setCheckState(checked)
table.setItem(row, 0, checkbox)
# Name
name_item = QTableWidgetItem(name)
table.setItem(row, 1, name_item)
# Info
info_item = QTableWidgetItem(info)
table.setItem(row, 2, info_item)
def install_selected(self, force=False):
"""Install selected components."""
selected = []
for table in [self.dll_table, self.fonts_table, self.settings_table]:
for row in range(table.rowCount()):
checkbox = table.item(row, 0)
if checkbox is not None and checkbox.checkState() == Qt.CheckState.Checked:
name_item = table.item(row, 1)
if name_item is not None:
name = name_item.text()
if name and name not in selected:
selected.append(name)
# Load installed
installed = set()
if os.path.exists(self.log_path):
with open(self.log_path) as f:
for line in f:
installed.add(line.strip())
# Filter to new selected
new_selected = [name for name in selected if name not in installed]
if not new_selected:
QMessageBox.information(self, _("Warning"), _("No components selected."))
return
self.install_button.setEnabled(False)
self.force_button.setEnabled(False)
self.cancel_button.setEnabled(False)
self._start_install_process(new_selected, force)
def _start_install_process(self, selected, force):
"""Запускает QProcess для установки."""
assert self.prefix_path is not None
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", self.prefix_path)
if self.wine_use is not None:
env.insert("WINE", self.wine_use)
self.apply_process = QProcess(self)
self.apply_process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
self.apply_process.setProcessEnvironment(env)
self.apply_process.readyReadStandardOutput.connect(self._on_ready_read)
self.apply_process.finished.connect(lambda exit_code, exit_status: self._on_install_finished(exit_code, exit_status, selected))
args = ["--unattended"] + (["--force"] if force else []) + selected
self.apply_process.start(self.winetricks_path, args)
def _on_ready_read(self):
"""Handle ready read for install process."""
if self.apply_process is None:
return
data = self.apply_process.readAllStandardOutput().data()
message = bytes(data).decode('utf-8', 'ignore').strip()
self._log(message)
def _on_install_finished(self, exit_code, exit_status, selected):
"""Обработчик завершения установки."""
error_message = ""
if self.apply_process is not None:
# Читаем вывод в зависимости от режима каналов
if self.apply_process.processChannelMode() == QProcess.ProcessChannelMode.MergedChannels:
# Если каналы объединены, читаем из StandardOutput
output_data = self.apply_process.readAllStandardOutput().data()
error_message = bytes(output_data).decode('utf-8', 'ignore')
else:
# Если каналы разделены, читаем из StandardError
error_data = self.apply_process.readAllStandardError().data()
error_message = bytes(error_data).decode('utf-8', 'ignore')
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
logger.error(f"Winetricks install failed: {error_message}")
QMessageBox.warning(self, _("Error"), _("Installation failed. Check logs."))
else:
if os.path.exists(self.log_path):
with open(self.log_path) as f:
existing = {line.strip() for line in f if line.strip()}
else:
existing = set()
with open(self.log_path, 'a') as f:
for name in selected:
if name not in existing:
f.write(f"{name}\n")
logger.info("Winetricks installation completed successfully.")
QMessageBox.information(self, _("Success"), _("Components installed successfully."))
self.load_lists()
# Разблокировка
self.install_button.setEnabled(True)
self.force_button.setEnabled(True)
self.cancel_button.setEnabled(True)
def _log(self, message):
"""Добавляет в лог."""
self.log_output.append(message)
self.log_output.moveCursor(QTextCursor.MoveOperation.End)
def closeEvent(self, event):
"""Disable mode on close."""
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().closeEvent(event)
def reject(self):
"""Disable mode on reject."""
if self.input_manager:
self.input_manager.disable_winetricks_mode()
super().reject()

View File

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

View File

@@ -33,10 +33,8 @@ 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):
@@ -129,8 +127,6 @@ 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()
@@ -220,16 +216,6 @@ 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:
@@ -359,8 +345,6 @@ 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):
@@ -378,9 +362,8 @@ class GameLibraryManager:
cover_path, width, height, callback = self.pending_images.pop(game_key)
load_pixmap_async(cover_path, width, height, callback)
# Force full relayout after visibility changes
# Force geometry update so FlowLayout accounts for hidden widgets
if self.gamesListLayout is not None:
self.gamesListLayout.invalidate() # Принудительно инвалидируем для пересчёта
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -191,10 +191,6 @@ 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 ""
@@ -252,37 +248,13 @@ msgstr ""
msgid "Select All"
msgstr ""
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
#, python-brace-format
msgid "Launching {0}"
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 ""
@@ -332,39 +304,6 @@ 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 ""
@@ -413,6 +352,9 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -428,28 +370,6 @@ 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 ""
@@ -462,106 +382,13 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "Compatibility tool:"
msgid "List of available emulators and their configuration..."
msgstr ""
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: {}"
msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Main PortProton parameters..."
@@ -597,9 +424,6 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -624,12 +448,6 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -191,10 +191,6 @@ 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 ""
@@ -252,37 +248,13 @@ msgstr ""
msgid "Select All"
msgstr ""
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
#, python-brace-format
msgid "Launching {0}"
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 ""
@@ -332,39 +304,6 @@ 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 ""
@@ -413,6 +352,9 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -428,28 +370,6 @@ 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 ""
@@ -462,106 +382,13 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "Compatibility tool:"
msgid "List of available emulators and their configuration..."
msgstr ""
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: {}"
msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Main PortProton parameters..."
@@ -597,9 +424,6 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -624,12 +448,6 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"POT-Creation-Date: 2025-09-23 22:23+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,10 +189,6 @@ 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 ""
@@ -250,37 +246,13 @@ msgstr ""
msgid "Select All"
msgstr ""
msgid "Open"
msgstr ""
msgid "Select Dir"
msgstr ""
msgid "Prev Dir"
#, python-brace-format
msgid "Launching {0}"
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 ""
@@ -330,39 +302,6 @@ 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 ""
@@ -411,6 +350,9 @@ msgstr ""
msgid "Auto Install"
msgstr ""
msgid "Emulators"
msgstr ""
msgid "Wine Settings"
msgstr ""
@@ -426,28 +368,6 @@ 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 ""
@@ -460,106 +380,13 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "Compatibility tool:"
msgid "List of available emulators and their configuration..."
msgstr ""
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: {}"
msgid "Various Wine parameters and versions..."
msgstr ""
msgid "Main PortProton parameters..."
@@ -595,9 +422,6 @@ msgstr ""
msgid "Games Display Filter:"
msgstr ""
msgid "Gamepad Type:"
msgstr ""
msgid "Proxy URL"
msgstr ""
@@ -622,12 +446,6 @@ msgstr ""
msgid "Application Fullscreen Mode:"
msgstr ""
msgid "Minimize to tray on close"
msgstr ""
msgid "Application Close Mode:"
msgstr ""
msgid "Auto Fullscreen on Gamepad connected"
msgstr ""

View File

@@ -9,17 +9,18 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-10-16 14:54+0500\n"
"PO-Revision-Date: 2025-10-16 14:54+0500\n"
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: 2025-09-23 22:23+0500\n"
"Last-Translator: \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"
"Language: ru_RU\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 "Ошибка"
@@ -86,11 +87,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}"
@@ -178,11 +179,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}"
@@ -196,10 +197,6 @@ 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 "Требуются название игры и путь к исполняемому файлу"
@@ -228,11 +225,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}"
@@ -259,37 +256,13 @@ msgstr "Удалить"
msgid "Select All"
msgstr "Выбрать всё"
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 "Cancel"
msgstr "Отмена"
msgid "File Explorer"
msgstr "Проводник"
@@ -301,7 +274,7 @@ msgstr "Путь: "
#, python-format
msgid "Access denied: %s"
msgstr "Доступ запрещён: %s"
msgstr "Доступ запрещен: %s"
msgid "Edit Game"
msgstr "Редактировать игру"
@@ -339,39 +312,6 @@ 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..."
@@ -420,6 +360,9 @@ msgstr "Библиотека"
msgid "Auto Install"
msgstr "Автоустановка"
msgid "Emulators"
msgstr "Эмуляторы"
msgid "Wine Settings"
msgstr "Настройки wine"
@@ -435,28 +378,6 @@ 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..."
@@ -469,109 +390,14 @@ msgstr "Игровая библиотека"
msgid "Find Games ..."
msgstr "Найти игры..."
#, python-brace-format
msgid "Added '{name}'"
msgstr "'{name}' добавлен(а)"
msgid "Here you can configure automatic game installation..."
msgstr "Здесь можно настроить автоматическую установку игр..."
msgid "Compatibility tool:"
msgstr "Инструмент совместимости:"
msgid "List of available emulators and their configuration..."
msgstr "Список доступных эмуляторов и их настройка..."
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 "Various Wine parameters and versions..."
msgstr "Различные параметры и версии wine..."
msgid "Main PortProton parameters..."
msgstr "Основные параметры PortProton..."
@@ -606,9 +432,6 @@ msgstr "все"
msgid "Games Display Filter:"
msgstr "Фильтр игр:"
msgid "Gamepad Type:"
msgstr "Тип геймпада:"
msgid "Proxy URL"
msgstr "Адрес прокси"
@@ -633,12 +456,6 @@ 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 "Режим полноэкранного отображения приложения при подключении геймпада"
@@ -665,8 +482,7 @@ msgstr "Подтвердите удаление"
msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr ""
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
"отменить."
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
msgid "Settings reset. Restarting..."
msgstr "Настройки сброшены. Перезапуск..."
@@ -838,4 +654,3 @@ msgstr "Нет избранных"
msgid "No recent games"
msgstr "Нет недавних игр"

File diff suppressed because it is too large Load Diff

View File

@@ -4,18 +4,12 @@ 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):
"""
@@ -58,11 +52,7 @@ 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)
@@ -78,6 +68,40 @@ 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"]
@@ -139,236 +163,6 @@ 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:

View File

@@ -4,6 +4,7 @@ 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)

View File

@@ -211,28 +211,14 @@ def normalize_name(s):
def is_valid_candidate(candidate):
"""
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.
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.
"""
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:

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

View File

@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 559 B

View File

@@ -217,56 +217,6 @@ 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 {{
@@ -966,96 +916,6 @@ 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};

View File

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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "portprotonqt"
version = "0.1.8"
version = "0.1.6"
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.14.2",
"beautifulsoup4>=4.13.5",
"evdev>=1.9.2",
"icoextract>=0.2.0",
"numpy>=2.2.4",
"orjson>=3.11.3",
"orjson>=3.11.2",
"pillow>=11.3.0",
"psutil>=7.1.0",
"pyside6==6.9.1",
"psutil>=7.0.0",
"pyside6>=6.9.1",
"pyudev>=0.24.3",
"requests>=2.32.5",
"tqdm>=4.67.1",
"vdf>=3.4",
"websocket-client>=1.9.0",
"websocket-client>=1.8.0",
]
[project.scripts]
@@ -105,5 +105,5 @@ ignore = [
dev = [
"pre-commit>=4.3.0",
"pyaspeller>=2.0.2",
"pyright>=1.1.406",
"pyright>=1.1.404",
]

View File

@@ -1,8 +1,6 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:best-practices"
],
"extends": ["config:best-practices"],
"rebaseWhen": "never",
"lockFileMaintenance": {
"enabled": true
@@ -11,23 +9,6 @@
"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
@@ -52,7 +33,7 @@
"groupName": "Python dependencies"
},
{
"matchPackageNames": ["numpy", "setuptools", "python", "pyside6"],
"matchPackageNames": ["numpy", "setuptools", "python"],
"enabled": false,
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
},

978
uv.lock generated

File diff suppressed because it is too large Load Diff