forked from Boria138/PortProtonQt
Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
f4aee15b5d | ||
87a65108a5
|
|||
bb617708ac
|
|||
1cf332cd87
|
|||
577ad4d3a3
|
|||
ef3f2d6e96
|
|||
657d7728a6
|
|||
9452bfda2e | |||
7eb2db0d68 | |||
6ef7a03366 | |||
e5af354b56 | |||
e6e5f6c8ea | |||
84306bb31b | |||
60af4d1482 | |||
692e11b21d | |||
b1a804811e | |||
9a30cfaea7 | |||
5dd2f71f5e | |||
dba172361b
|
|||
a9c70b8818
|
|||
135ace732f
|
|||
8b727f64e1
|
|||
a8eb591da5
|
|||
fe4ca1ee87
|
|||
ffe3e9d3d6
|
|||
49d39b5d61
|
|||
|
03566da704
|
@@ -16,7 +16,7 @@ repos:
|
|||||||
- id: uv-lock
|
- id: uv-lock
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.13.2
|
rev: v0.14.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
|
|
||||||
|
@@ -10,6 +10,7 @@
|
|||||||
- Импорт и экспорт бекапа префикса
|
- Импорт и экспорт бекапа префикса
|
||||||
- Диалог для управление Winetricks
|
- Диалог для управление Winetricks
|
||||||
- Кнопки для удаления префикса, wine или proton
|
- Кнопки для удаления префикса, wine или proton
|
||||||
|
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
|
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
|
||||||
@@ -20,6 +21,8 @@
|
|||||||
- Исправлено зависание при добавлении или удалении игры в Wayland
|
- Исправлено зависание при добавлении или удалении игры в Wayland
|
||||||
- Исправлено зависание при поиске игр
|
- Исправлено зависание при поиске игр
|
||||||
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
|
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
|
||||||
|
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
|
||||||
|
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
|
||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@
|
|||||||
|
|
||||||
### Contributors
|
### Contributors
|
||||||
- @wmigor (Igor Akulov)
|
- @wmigor (Igor Akulov)
|
||||||
|
- @Vector_null
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -54,6 +54,11 @@ AppDir:
|
|||||||
- libxcb-cursor0
|
- libxcb-cursor0
|
||||||
- libimage-exiftool-perl
|
- libimage-exiftool-perl
|
||||||
- xdg-utils
|
- xdg-utils
|
||||||
|
- cabextract
|
||||||
|
- curl
|
||||||
|
- 7zip
|
||||||
|
- unzip
|
||||||
|
- unrar
|
||||||
exclude:
|
exclude:
|
||||||
- "*-doc"
|
- "*-doc"
|
||||||
- "*-man"
|
- "*-man"
|
||||||
|
@@ -6,7 +6,7 @@ arch=('any')
|
|||||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||||
license=('GPL-3.0')
|
license=('GPL-3.0')
|
||||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
|
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt#tag=v$pkgver")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
@@ -6,7 +6,7 @@ arch=('any')
|
|||||||
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
url="https://git.linux-gaming.ru/Boria138/PortProtonQt"
|
||||||
license=('GPL-3.0')
|
license=('GPL-3.0')
|
||||||
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
depends=('python-numpy' 'python-requests' 'python-babel' 'python-evdev' 'python-pyudev' 'python-orjson'
|
||||||
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client')
|
'python-psutil' 'python-tqdm' 'python-vdf' 'pyside6' 'icoextract' 'python-pillow' 'perl-image-exiftool' 'xdg-utils' 'python-beautifulsoup4' 'python-websocket-client' 'cabextract' 'unzip' 'curl' 'unrar')
|
||||||
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
makedepends=('python-'{'build','installer','setuptools','wheel'})
|
||||||
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
source=("git+https://git.linux-gaming.ru/Boria138/PortProtonQt.git")
|
||||||
sha256sums=('SKIP')
|
sha256sums=('SKIP')
|
||||||
|
@@ -46,6 +46,11 @@ Requires: python3-pillow
|
|||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
Requires: python3-beautifulsoup4
|
Requires: python3-beautifulsoup4
|
||||||
|
Requires: cabextract
|
||||||
|
Requires: gzip
|
||||||
|
Requires: unzip
|
||||||
|
Requires: curl
|
||||||
|
Requires: unrar
|
||||||
|
|
||||||
%description -n python3-%{pypi_name}-git
|
%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.
|
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.
|
||||||
|
@@ -43,6 +43,11 @@ Requires: python3-pillow
|
|||||||
Requires: perl-Image-ExifTool
|
Requires: perl-Image-ExifTool
|
||||||
Requires: xdg-utils
|
Requires: xdg-utils
|
||||||
Requires: python3-beautifulsoup4
|
Requires: python3-beautifulsoup4
|
||||||
|
Requires: cabextract
|
||||||
|
Requires: gzip
|
||||||
|
Requires: unzip
|
||||||
|
Requires: curl
|
||||||
|
Requires: unrar
|
||||||
|
|
||||||
%description -n python3-%{pypi_name}
|
%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.
|
This application provides a sleek, intuitive graphical interface for managing and launching games from PortProton, Steam, and Epic Games Store. It consolidates your game libraries into a single, user-friendly hub for seamless navigation and organization. Its lightweight structure and cross-platform support deliver a cohesive gaming experience, eliminating the need for multiple launchers. Unique PortProton integration enhances Linux gaming, enabling effortless play of Windows-based titles with minimal setup.
|
||||||
|
@@ -21,9 +21,9 @@ Current translation status:
|
|||||||
|
|
||||||
| Locale | Progress | Translated |
|
| Locale | Progress | Translated |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 232 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 233 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 232 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 233 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 232 of 232 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 of 233 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -21,9 +21,9 @@
|
|||||||
|
|
||||||
| Локаль | Прогресс | Переведено |
|
| Локаль | Прогресс | Переведено |
|
||||||
| :----- | -------: | ---------: |
|
| :----- | -------: | ---------: |
|
||||||
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 232 |
|
| [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 233 |
|
||||||
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 232 |
|
| [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 233 |
|
||||||
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 232 из 232 |
|
| [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 из 233 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
from PySide6.QtCore import QLocale, QTranslator, QLibraryInfo
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
from PySide6.QtGui import QIcon
|
from PySide6.QtGui import QIcon
|
||||||
from portprotonqt.main_window import MainWindow
|
from portprotonqt.main_window import MainWindow
|
||||||
from portprotonqt.config_utils import save_fullscreen_config
|
from portprotonqt.config_utils import save_fullscreen_config, get_portproton_location
|
||||||
from portprotonqt.logger import get_logger, setup_logger
|
from portprotonqt.logger import get_logger, setup_logger
|
||||||
from portprotonqt.cli import parse_args
|
from portprotonqt.cli import parse_args
|
||||||
|
|
||||||
@@ -12,6 +14,19 @@ __app_name__ = "PortProtonQt"
|
|||||||
__app_version__ = "0.1.6"
|
__app_version__ = "0.1.6"
|
||||||
|
|
||||||
def main():
|
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 = QApplication(sys.argv)
|
||||||
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
app.setWindowIcon(QIcon.fromTheme(__app_id__))
|
||||||
app.setDesktopFileName(__app_id__)
|
app.setDesktopFileName(__app_id__)
|
||||||
|
@@ -126,7 +126,21 @@ class FlowLayout(QLayout):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
def heightForWidth(self, width):
|
def heightForWidth(self, width):
|
||||||
return self.doLayout(QRect(0, 0, width, 0), True)
|
# Аналогично фильтруем видимые для тестового расчёта высоты
|
||||||
|
visible_items = []
|
||||||
|
nat_sizes = np.empty((0, 2), dtype=np.int32)
|
||||||
|
for item in self.itemList:
|
||||||
|
if item.widget() and item.widget().isVisible():
|
||||||
|
visible_items.append(item)
|
||||||
|
s = item.sizeHint()
|
||||||
|
new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
|
||||||
|
nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
|
||||||
|
|
||||||
|
if len(visible_items) == 0:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
_, total_height = compute_layout(nat_sizes, width, self._spacing, self._max_scale)
|
||||||
|
return total_height
|
||||||
|
|
||||||
def setGeometry(self, rect):
|
def setGeometry(self, rect):
|
||||||
super().setGeometry(rect)
|
super().setGeometry(rect)
|
||||||
@@ -145,26 +159,46 @@ class FlowLayout(QLayout):
|
|||||||
return size
|
return size
|
||||||
|
|
||||||
def doLayout(self, rect, testOnly):
|
def doLayout(self, rect, testOnly):
|
||||||
N = len(self.itemList)
|
N_total = len(self.itemList)
|
||||||
if N == 0:
|
if N_total == 0:
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
nat_sizes = np.empty((N, 2), dtype=np.int32)
|
# Фильтруем только видимые элементы
|
||||||
|
visible_items = []
|
||||||
|
visible_indices = [] # Индексы в оригинальном itemList для установки геометрии
|
||||||
|
nat_sizes = np.empty((0, 2), dtype=np.int32)
|
||||||
for i, item in enumerate(self.itemList):
|
for i, item in enumerate(self.itemList):
|
||||||
s = item.sizeHint()
|
if item.widget() and item.widget().isVisible():
|
||||||
nat_sizes[i, 0] = s.width()
|
visible_items.append(item)
|
||||||
nat_sizes[i, 1] = s.height()
|
visible_indices.append(i)
|
||||||
|
s = item.sizeHint()
|
||||||
|
new_row = np.array([[s.width(), s.height()]], dtype=np.int32)
|
||||||
|
nat_sizes = np.vstack([nat_sizes, new_row]) if len(nat_sizes) > 0 else new_row
|
||||||
|
|
||||||
|
N = len(visible_items)
|
||||||
|
if N == 0:
|
||||||
|
# Если все скрыты, устанавливаем нулевые геометрии для всех
|
||||||
|
if not testOnly:
|
||||||
|
for item in self.itemList:
|
||||||
|
item.setGeometry(QRect())
|
||||||
|
return 0
|
||||||
|
|
||||||
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
|
geom_array, total_height = compute_layout(nat_sizes, rect.width(), self._spacing, self._max_scale)
|
||||||
|
|
||||||
if not testOnly:
|
if not testOnly:
|
||||||
for i, item in enumerate(self.itemList):
|
# Устанавливаем геометрии только для видимых
|
||||||
x = geom_array[i, 0] + rect.x()
|
for idx, (_vis_idx, item) in enumerate(zip(visible_indices, visible_items, strict=True)):
|
||||||
y = geom_array[i, 1] + rect.y()
|
x = geom_array[idx, 0] + rect.x()
|
||||||
w = geom_array[i, 2]
|
y = geom_array[idx, 1] + rect.y()
|
||||||
h = geom_array[i, 3]
|
w = geom_array[idx, 2]
|
||||||
|
h = geom_array[idx, 3]
|
||||||
item.setGeometry(QRect(QPoint(x, y), QSize(w, h)))
|
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
|
return total_height
|
||||||
|
|
||||||
class ClickableLabel(QLabel):
|
class ClickableLabel(QLabel):
|
||||||
|
@@ -17,6 +17,7 @@ from portprotonqt.logger import get_logger
|
|||||||
from portprotonqt.theme_manager import ThemeManager
|
from portprotonqt.theme_manager import ThemeManager
|
||||||
from portprotonqt.custom_widgets import AutoSizeButton
|
from portprotonqt.custom_widgets import AutoSizeButton
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
|
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||||
from portprotonqt.preloader import Preloader
|
from portprotonqt.preloader import Preloader
|
||||||
import psutil
|
import psutil
|
||||||
|
|
||||||
@@ -510,8 +511,8 @@ class FileExplorer(QDialog):
|
|||||||
"""Update the list of mounted drives and favorite folders."""
|
"""Update the list of mounted drives and favorite folders."""
|
||||||
for i in reversed(range(self.drives_layout.count())):
|
for i in reversed(range(self.drives_layout.count())):
|
||||||
item = self.drives_layout.itemAt(i)
|
item = self.drives_layout.itemAt(i)
|
||||||
if item and item.widget():
|
widget = item.widget() if item else None
|
||||||
widget = item.widget()
|
if widget:
|
||||||
self.drives_layout.removeWidget(widget)
|
self.drives_layout.removeWidget(widget)
|
||||||
widget.deleteLater()
|
widget.deleteLater()
|
||||||
|
|
||||||
@@ -817,6 +818,60 @@ class AddGameDialog(QDialog):
|
|||||||
if edit_mode:
|
if edit_mode:
|
||||||
self.updatePreview()
|
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):
|
def browseExe(self):
|
||||||
"""Открывает файловый менеджер для выбора exe-файла"""
|
"""Открывает файловый менеджер для выбора exe-файла"""
|
||||||
try:
|
try:
|
||||||
@@ -1233,6 +1288,7 @@ class WinetricksDialog(QDialog):
|
|||||||
assert self.prefix_path is not None
|
assert self.prefix_path is not None
|
||||||
env = QProcessEnvironment.systemEnvironment()
|
env = QProcessEnvironment.systemEnvironment()
|
||||||
env.insert("WINEPREFIX", self.prefix_path)
|
env.insert("WINEPREFIX", self.prefix_path)
|
||||||
|
env.insert("WINETRICKS_DOWNLOADER", "curl")
|
||||||
if self.wine_use is not None:
|
if self.wine_use is not None:
|
||||||
env.insert("WINE", self.wine_use)
|
env.insert("WINE", self.wine_use)
|
||||||
|
|
||||||
|
@@ -12,6 +12,7 @@ from portprotonqt.downloader import Downloader
|
|||||||
from portprotonqt.animations import GameCardAnimations
|
from portprotonqt.animations import GameCardAnimations
|
||||||
from typing import cast
|
from typing import cast
|
||||||
|
|
||||||
|
|
||||||
class GameCard(QFrame):
|
class GameCard(QFrame):
|
||||||
borderWidthChanged = Signal()
|
borderWidthChanged = Signal()
|
||||||
gradientAngleChanged = Signal()
|
gradientAngleChanged = Signal()
|
||||||
@@ -447,6 +448,7 @@ class GameCard(QFrame):
|
|||||||
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
|
gradientAngle = Property(float, getGradientAngle, setGradientAngle, None, "", notify=cast(Callable[[], None], gradientAngleChanged))
|
||||||
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
|
scale = Property(float, getScale, setScale, None, "", notify=cast(Callable[[], None], scaleChanged))
|
||||||
|
|
||||||
|
|
||||||
def paintEvent(self, event):
|
def paintEvent(self, event):
|
||||||
super().paintEvent(event)
|
super().paintEvent(event)
|
||||||
self.animations.paint_border(QPainter(self))
|
self.animations.paint_border(QPainter(self))
|
||||||
|
@@ -363,8 +363,9 @@ class GameLibraryManager:
|
|||||||
cover_path, width, height, callback = self.pending_images.pop(game_key)
|
cover_path, width, height, callback = self.pending_images.pop(game_key)
|
||||||
load_pixmap_async(cover_path, width, height, callback)
|
load_pixmap_async(cover_path, width, height, callback)
|
||||||
|
|
||||||
# Force geometry update so FlowLayout accounts for hidden widgets
|
# Force full relayout after visibility changes
|
||||||
if self.gamesListLayout is not None:
|
if self.gamesListLayout is not None:
|
||||||
|
self.gamesListLayout.invalidate() # Принудительно инвалидируем для пересчёта
|
||||||
self.gamesListLayout.update()
|
self.gamesListLayout.update()
|
||||||
if self.gamesListWidget is not None:
|
if self.gamesListWidget is not None:
|
||||||
self.gamesListWidget.updateGeometry()
|
self.gamesListWidget.updateGeometry()
|
||||||
@@ -452,3 +453,11 @@ class GameLibraryManager:
|
|||||||
def filter_games_delayed(self):
|
def filter_games_delayed(self):
|
||||||
"""Filters games based on search text and updates the grid."""
|
"""Filters games based on search text and updates the grid."""
|
||||||
self.update_game_grid(is_filter=True)
|
self.update_game_grid(is_filter=True)
|
||||||
|
|
||||||
|
def calculate_columns(self, card_width: int) -> int:
|
||||||
|
"""Calculate the number of columns based on card width and assumed container width."""
|
||||||
|
# Assuming a typical container width; adjust as needed
|
||||||
|
available_width = 1200 # Example width, can be dynamic if widget access is added
|
||||||
|
spacing = 15 # Assumed spacing between cards
|
||||||
|
columns = max(1, (available_width - spacing) // (card_width + spacing))
|
||||||
|
return min(columns, 8) # Cap at reasonable max
|
||||||
|
@@ -14,6 +14,7 @@ from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
|
|||||||
from portprotonqt.game_card import GameCard
|
from portprotonqt.game_card import GameCard
|
||||||
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config
|
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config
|
||||||
from portprotonqt.dialogs import AddGameDialog, WinetricksDialog
|
from portprotonqt.dialogs import AddGameDialog, WinetricksDialog
|
||||||
|
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ class InputManager(QObject):
|
|||||||
for seamless UI interaction.
|
for seamless UI interaction.
|
||||||
"""
|
"""
|
||||||
# Signals for gamepad events
|
# Signals for gamepad events
|
||||||
button_pressed = Signal(int) # Signal for button presses
|
button_event = Signal(int, int) # Signal for button events: (code, value) where value=1 (press), 0 (release)
|
||||||
dpad_moved = Signal(int, int, float) # Signal for D-pad movements
|
dpad_moved = Signal(int, int, float) # Signal for D-pad movements
|
||||||
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
|
toggle_fullscreen = Signal(bool) # Signal for toggling fullscreen mode (True for fullscreen, False for normal)
|
||||||
|
|
||||||
@@ -130,7 +131,7 @@ class InputManager(QObject):
|
|||||||
self.current_dpad_value = 0 # Tracks the current D-pad direction value (e.g., -1, 1)
|
self.current_dpad_value = 0 # Tracks the current D-pad direction value (e.g., -1, 1)
|
||||||
|
|
||||||
# Connect signals to slots
|
# Connect signals to slots
|
||||||
self.button_pressed.connect(self.handle_button_slot)
|
self.button_event.connect(self.handle_button_slot)
|
||||||
self.dpad_moved.connect(self.handle_dpad_slot)
|
self.dpad_moved.connect(self.handle_dpad_slot)
|
||||||
self.toggle_fullscreen.connect(self.handle_fullscreen_slot)
|
self.toggle_fullscreen.connect(self.handle_fullscreen_slot)
|
||||||
|
|
||||||
@@ -201,7 +202,9 @@ class InputManager(QObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error restoring gamepad handlers: {e}")
|
logger.error(f"Error restoring gamepad handlers: {e}")
|
||||||
|
|
||||||
def handle_file_explorer_button(self, button_code):
|
def handle_file_explorer_button(self, button_code, value):
|
||||||
|
if value == 0: # Ignore releases
|
||||||
|
return
|
||||||
try:
|
try:
|
||||||
popup = QApplication.activePopupWidget()
|
popup = QApplication.activePopupWidget()
|
||||||
if isinstance(popup, QMenu):
|
if isinstance(popup, QMenu):
|
||||||
@@ -441,11 +444,37 @@ class InputManager(QObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping rumble: {e}", exc_info=True)
|
logger.error(f"Error stopping rumble: {e}", exc_info=True)
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int, int)
|
||||||
def handle_button_slot(self, button_code: int) -> None:
|
def handle_button_slot(self, button_code: int, value: int) -> None:
|
||||||
|
active_window = QApplication.activeWindow()
|
||||||
|
|
||||||
|
# Обработка виртуальной клавиатуры в AddGameDialog (handle both press and release)
|
||||||
|
if isinstance(active_window, AddGameDialog):
|
||||||
|
focused = QApplication.focusWidget()
|
||||||
|
if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit):
|
||||||
|
# Показываем клавиатуру при нажатии A на поле ввода (only on press)
|
||||||
|
active_window.show_keyboard_for_widget(focused)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Если клавиатура видима, обрабатываем её кнопки (including release)
|
||||||
|
if hasattr(active_window, 'keyboard') and active_window.keyboard.isVisible():
|
||||||
|
self.handle_virtual_keyboard(button_code, value)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Main window keyboard handling (including release)
|
||||||
|
keyboard = getattr(self._parent, 'keyboard', None)
|
||||||
|
if keyboard and keyboard.isVisible():
|
||||||
|
self.handle_virtual_keyboard(button_code, value)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Ignore releases for all other (non-keyboard) button handling
|
||||||
|
if value == 0:
|
||||||
|
return
|
||||||
|
|
||||||
if not self._gamepad_handling_enabled:
|
if not self._gamepad_handling_enabled:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
|
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
active = QApplication.activeWindow()
|
active = QApplication.activeWindow()
|
||||||
focused = QApplication.focusWidget()
|
focused = QApplication.focusWidget()
|
||||||
@@ -454,6 +483,21 @@ class InputManager(QObject):
|
|||||||
if not app or not active:
|
if not app or not active:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
|
||||||
|
search_edit = getattr(self._parent, 'searchEdit', None)
|
||||||
|
if focused == search_edit:
|
||||||
|
keyboard = getattr(self._parent, 'keyboard', None)
|
||||||
|
if keyboard:
|
||||||
|
keyboard.show_for_widget(focused)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Handle Y button to focus search
|
||||||
|
if button_code in BUTTONS['prev_dir']: # Y button
|
||||||
|
search_edit = getattr(self._parent, 'searchEdit', None)
|
||||||
|
if search_edit:
|
||||||
|
search_edit.setFocus()
|
||||||
|
return
|
||||||
|
|
||||||
# Handle Guide button to open system overlay
|
# Handle Guide button to open system overlay
|
||||||
if button_code in BUTTONS['guide']:
|
if button_code in BUTTONS['guide']:
|
||||||
if not popup and not isinstance(active, QDialog):
|
if not popup and not isinstance(active, QDialog):
|
||||||
@@ -550,6 +594,7 @@ class InputManager(QObject):
|
|||||||
self._parent.toggleGame(self._parent.current_exec_line, None)
|
self._parent.toggleGame(self._parent.current_exec_line, None)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
if isinstance(active, WinetricksDialog):
|
if isinstance(active, WinetricksDialog):
|
||||||
if button_code in BUTTONS['confirm']: # A button - toggle checkbox
|
if button_code in BUTTONS['confirm']: # A button - toggle checkbox
|
||||||
current_table = active.tab_widget.currentWidget()
|
current_table = active.tab_widget.currentWidget()
|
||||||
@@ -612,7 +657,6 @@ class InputManager(QObject):
|
|||||||
new_value = max(size_slider.value() - 10, size_slider.minimum())
|
new_value = max(size_slider.value() - 10, size_slider.minimum())
|
||||||
size_slider.setValue(new_value)
|
size_slider.setValue(new_value)
|
||||||
self._parent.on_slider_released()
|
self._parent.on_slider_released()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -627,6 +671,78 @@ class InputManager(QObject):
|
|||||||
|
|
||||||
@Slot(int, int, float)
|
@Slot(int, int, float)
|
||||||
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
|
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
|
||||||
|
keyboard = None
|
||||||
|
active_window = QApplication.activeWindow()
|
||||||
|
|
||||||
|
# Проверяем клавиатуру в активном окне (AddGameDialog или главном окне)
|
||||||
|
if isinstance(active_window, AddGameDialog):
|
||||||
|
keyboard = getattr(active_window, 'keyboard', None)
|
||||||
|
else:
|
||||||
|
keyboard = getattr(self._parent, 'keyboard', None)
|
||||||
|
|
||||||
|
# Handle release early
|
||||||
|
if value == 0:
|
||||||
|
self.current_dpad_code = None
|
||||||
|
self.current_dpad_value = 0
|
||||||
|
self.axis_moving = False
|
||||||
|
self.current_axis_delay = self.initial_axis_move_delay
|
||||||
|
self.dpad_timer.stop()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update D-pad state for continuous movement
|
||||||
|
self.current_dpad_code = code
|
||||||
|
self.current_dpad_value = value
|
||||||
|
if not self.axis_moving:
|
||||||
|
self.axis_moving = True
|
||||||
|
self.last_move_time = current_time
|
||||||
|
self.current_axis_delay = self.initial_axis_move_delay
|
||||||
|
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))
|
||||||
|
|
||||||
|
if keyboard and keyboard.isVisible():
|
||||||
|
# Обработка горизонтального перемещения (LEFT/RIGHT)
|
||||||
|
if code in (ecodes.ABS_HAT0X, ecodes.ABS_X):
|
||||||
|
normalized_value = 0
|
||||||
|
if code == ecodes.ABS_X: # Левый стик
|
||||||
|
# Применяем мертвую зону
|
||||||
|
if abs(value) < self.dead_zone:
|
||||||
|
self.current_dpad_code = None
|
||||||
|
self.current_dpad_value = 0
|
||||||
|
self.axis_moving = False
|
||||||
|
self.dpad_timer.stop()
|
||||||
|
return
|
||||||
|
normalized_value = 1 if value > self.dead_zone else -1
|
||||||
|
else: # D-pad
|
||||||
|
normalized_value = value # D-pad уже дает -1, 0, 1
|
||||||
|
|
||||||
|
if normalized_value != 0:
|
||||||
|
if normalized_value > 0: # Вправо
|
||||||
|
keyboard.move_focus_right()
|
||||||
|
elif normalized_value < 0: # Влево
|
||||||
|
keyboard.move_focus_left()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Обработка вертикального перемещения (UP/DOWN)
|
||||||
|
elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y):
|
||||||
|
normalized_value = 0
|
||||||
|
if code == ecodes.ABS_Y: # Левый стик
|
||||||
|
# Применяем мертвую зону
|
||||||
|
if abs(value) < self.dead_zone:
|
||||||
|
self.current_dpad_code = None
|
||||||
|
self.current_dpad_value = 0
|
||||||
|
self.axis_moving = False
|
||||||
|
self.dpad_timer.stop()
|
||||||
|
return
|
||||||
|
normalized_value = 1 if value > self.dead_zone else -1
|
||||||
|
else: # D-pad
|
||||||
|
normalized_value = value # D-pad уже дает -1, 0, 1
|
||||||
|
|
||||||
|
if normalized_value != 0:
|
||||||
|
if normalized_value > 0: # Вниз
|
||||||
|
keyboard.move_focus_down()
|
||||||
|
elif normalized_value < 0: # Вверх
|
||||||
|
keyboard.move_focus_up()
|
||||||
|
return
|
||||||
|
|
||||||
if not self._gamepad_handling_enabled:
|
if not self._gamepad_handling_enabled:
|
||||||
return
|
return
|
||||||
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
|
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
|
||||||
@@ -641,22 +757,31 @@ class InputManager(QObject):
|
|||||||
if not app or not active:
|
if not app or not active:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update D-pad state
|
# Новый код: обработка перехода на поле поиска
|
||||||
if value != 0:
|
if code == ecodes.ABS_HAT0Y and value < 0: # Only D-pad up
|
||||||
self.current_dpad_code = code
|
if isinstance(focused, GameCard):
|
||||||
self.current_dpad_value = value
|
# Get all visible game cards
|
||||||
if not self.axis_moving:
|
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
|
||||||
self.axis_moving = True
|
if not game_cards:
|
||||||
self.last_move_time = current_time
|
return
|
||||||
self.current_axis_delay = self.initial_axis_move_delay
|
|
||||||
self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000)) # Start timer (in milliseconds)
|
# Find the current card's position
|
||||||
else:
|
current_card_pos = focused.pos()
|
||||||
self.current_dpad_code = None
|
current_row_y = current_card_pos.y()
|
||||||
self.current_dpad_value = 0
|
|
||||||
self.axis_moving = False
|
# Check if this is the first row (no cards above)
|
||||||
self.current_axis_delay = self.initial_axis_move_delay
|
is_first_row = True
|
||||||
self.dpad_timer.stop() # Stop timer when D-pad is released
|
for card in game_cards:
|
||||||
return
|
if card.pos().y() < current_row_y and card.isVisible():
|
||||||
|
is_first_row = False
|
||||||
|
break
|
||||||
|
|
||||||
|
# Only move to search if on first row
|
||||||
|
if is_first_row:
|
||||||
|
search_edit = getattr(self._parent, 'searchEdit', None)
|
||||||
|
if search_edit:
|
||||||
|
search_edit.setFocus()
|
||||||
|
return
|
||||||
|
|
||||||
# Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad
|
# Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad
|
||||||
if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0:
|
if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0:
|
||||||
@@ -673,7 +798,7 @@ class InputManager(QObject):
|
|||||||
elif value < 0: # Left
|
elif value < 0: # Left
|
||||||
active.focusPreviousChild()
|
active.focusPreviousChild()
|
||||||
return
|
return
|
||||||
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget): # Skip if focused on table
|
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget): # Keep up/down for other dialogs
|
||||||
if not focused or not active.focusWidget():
|
if not focused or not active.focusWidget():
|
||||||
# If no widget is focused, focus the first focusable widget
|
# If no widget is focused, focus the first focusable widget
|
||||||
focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
|
focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
|
||||||
@@ -726,6 +851,7 @@ class InputManager(QObject):
|
|||||||
active.show_next()
|
active.show_next()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
# Table navigation
|
# Table navigation
|
||||||
if isinstance(focused, QTableWidget):
|
if isinstance(focused, QTableWidget):
|
||||||
row_count = focused.rowCount()
|
row_count = focused.rowCount()
|
||||||
@@ -917,6 +1043,52 @@ class InputManager(QObject):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True)
|
logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def handle_virtual_keyboard(self, button_code: int, value: int) -> None:
|
||||||
|
# Проверяем клавиатуру в активном окне
|
||||||
|
active_window = QApplication.activeWindow()
|
||||||
|
keyboard = None
|
||||||
|
|
||||||
|
# Сначала проверяем AddGameDialog
|
||||||
|
if isinstance(active_window, AddGameDialog):
|
||||||
|
keyboard = getattr(active_window, 'keyboard', None)
|
||||||
|
else:
|
||||||
|
# Если это не AddGameDialog, проверяем клавиатуру в главном окне
|
||||||
|
keyboard = getattr(self._parent, 'keyboard', None)
|
||||||
|
|
||||||
|
if not keyboard or not isinstance(keyboard, VirtualKeyboard) or not keyboard.isVisible():
|
||||||
|
return
|
||||||
|
|
||||||
|
# Обработка кнопок геймпада
|
||||||
|
if button_code in BUTTONS['confirm']: # Кнопка A/Cross - подтверждение
|
||||||
|
if value == 1:
|
||||||
|
keyboard.activateFocusedKey()
|
||||||
|
elif button_code in BUTTONS['back']: # Кнопка B/Circle - скрыть клавиатуру
|
||||||
|
if value == 1:
|
||||||
|
keyboard.hide()
|
||||||
|
# Возвращаем фокус на поле ввода
|
||||||
|
if keyboard.current_input_widget:
|
||||||
|
keyboard.current_input_widget.setFocus()
|
||||||
|
elif button_code in BUTTONS['prev_tab']: # LB/L1 - переключение раскладки
|
||||||
|
if value == 1:
|
||||||
|
keyboard.on_lang_click()
|
||||||
|
elif button_code in BUTTONS['next_tab']: # RB/R1 - переключение Shift
|
||||||
|
if value == 1:
|
||||||
|
keyboard.on_shift_click(not keyboard.shift_pressed)
|
||||||
|
elif button_code in BUTTONS['context_menu']: # Кнопка Start - подтверждение
|
||||||
|
if value == 1:
|
||||||
|
keyboard.activateFocusedKey()
|
||||||
|
elif button_code in BUTTONS['menu']: # Кнопка Select - скрыть клавиатуру
|
||||||
|
if value == 1:
|
||||||
|
keyboard.hide()
|
||||||
|
# Возвращаем фокус на поле ввода
|
||||||
|
if keyboard.current_input_widget:
|
||||||
|
keyboard.current_input_widget.setFocus()
|
||||||
|
elif button_code in BUTTONS['add_game']: # Кнопка X - Backspace (now holdable)
|
||||||
|
if value == 1:
|
||||||
|
keyboard.on_backspace_pressed()
|
||||||
|
elif value == 0:
|
||||||
|
keyboard.stop_backspace_repeat()
|
||||||
|
|
||||||
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
def eventFilter(self, obj: QObject, event: QEvent) -> bool:
|
||||||
app = QApplication.instance()
|
app = QApplication.instance()
|
||||||
if not app:
|
if not app:
|
||||||
@@ -1164,8 +1336,8 @@ class InputManager(QObject):
|
|||||||
self.gamepad = None
|
self.gamepad = None
|
||||||
if self.gamepad_thread:
|
if self.gamepad_thread:
|
||||||
self.gamepad_thread.join()
|
self.gamepad_thread.join()
|
||||||
# Signal to exit fullscreen mode
|
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
|
||||||
self.toggle_fullscreen.emit(False)
|
self.toggle_fullscreen.emit(False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error handling udev event: {e}", exc_info=True)
|
logger.error(f"Error handling udev event: {e}", exc_info=True)
|
||||||
|
|
||||||
@@ -1223,11 +1395,12 @@ class InputManager(QObject):
|
|||||||
if not app or not active:
|
if not app or not active:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if event.type == ecodes.EV_KEY and event.value == 1:
|
if event.type == ecodes.EV_KEY:
|
||||||
if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
|
# Emit on both press (1) and release (0)
|
||||||
|
self.button_event.emit(event.code, event.value)
|
||||||
|
# Special handling for menu on press only
|
||||||
|
if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session:
|
||||||
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
self.toggle_fullscreen.emit(not self._is_fullscreen)
|
||||||
else:
|
|
||||||
self.button_pressed.emit(event.code)
|
|
||||||
elif event.type == ecodes.EV_ABS:
|
elif event.type == ecodes.EV_ABS:
|
||||||
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
|
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
|
||||||
# Проверяем, достаточно ли времени прошло с последнего срабатывания
|
# Проверяем, достаточно ли времени прошло с последнего срабатывания
|
||||||
@@ -1236,17 +1409,19 @@ class InputManager(QObject):
|
|||||||
if event.code == ecodes.ABS_Z: # LT/L2
|
if event.code == ecodes.ABS_Z: # LT/L2
|
||||||
if event.value > 128 and not self.lt_pressed:
|
if event.value > 128 and not self.lt_pressed:
|
||||||
self.lt_pressed = True
|
self.lt_pressed = True
|
||||||
self.button_pressed.emit(event.code)
|
self.button_event.emit(event.code, 1) # Emit as press
|
||||||
self.last_trigger_time = now
|
self.last_trigger_time = now
|
||||||
elif event.value <= 128 and self.lt_pressed:
|
elif event.value <= 128 and self.lt_pressed:
|
||||||
self.lt_pressed = False
|
self.lt_pressed = False
|
||||||
|
self.button_event.emit(event.code, 0) # Emit as release
|
||||||
elif event.code == ecodes.ABS_RZ: # RT/R2
|
elif event.code == ecodes.ABS_RZ: # RT/R2
|
||||||
if event.value > 128 and not self.rt_pressed:
|
if event.value > 128 and not self.rt_pressed:
|
||||||
self.rt_pressed = True
|
self.rt_pressed = True
|
||||||
self.button_pressed.emit(event.code)
|
self.button_event.emit(event.code, 1) # Emit as press
|
||||||
self.last_trigger_time = now
|
self.last_trigger_time = now
|
||||||
elif event.value <= 128 and self.rt_pressed:
|
elif event.value <= 128 and self.rt_pressed:
|
||||||
self.rt_pressed = False
|
self.rt_pressed = False
|
||||||
|
self.button_event.emit(event.code, 0) # Emit as release
|
||||||
else:
|
else:
|
||||||
self.dpad_moved.emit(event.code, event.value, now)
|
self.dpad_moved.emit(event.code, event.value, now)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
|
73
portprotonqt/keyboard_layouts.py
Normal file
73
portprotonqt/keyboard_layouts.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# keyboard_layouts.py
|
||||||
|
keyboard_layouts = {
|
||||||
|
'en': {
|
||||||
|
'normal': [
|
||||||
|
['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
|
||||||
|
['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'],
|
||||||
|
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"],
|
||||||
|
['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/']
|
||||||
|
],
|
||||||
|
'shift': [
|
||||||
|
['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'],
|
||||||
|
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'],
|
||||||
|
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'],
|
||||||
|
['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'ru': {
|
||||||
|
'normal': [
|
||||||
|
['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='],
|
||||||
|
['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'],
|
||||||
|
['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'],
|
||||||
|
['⬆', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.']
|
||||||
|
],
|
||||||
|
'shift': [
|
||||||
|
['Ё', '!', '"', '№', ';', '%', ':', '?', '*', '(', ')', '_', '+'],
|
||||||
|
['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'],
|
||||||
|
['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'],
|
||||||
|
['⬆', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ',']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'fr': {
|
||||||
|
'normal': [
|
||||||
|
['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='],
|
||||||
|
['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'],
|
||||||
|
['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'],
|
||||||
|
['⬆', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!']
|
||||||
|
],
|
||||||
|
'shift': [
|
||||||
|
['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'],
|
||||||
|
['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'],
|
||||||
|
['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'],
|
||||||
|
['⬆', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'es': {
|
||||||
|
'normal': [
|
||||||
|
['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'],
|
||||||
|
['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'],
|
||||||
|
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'],
|
||||||
|
['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
|
||||||
|
],
|
||||||
|
'shift': [
|
||||||
|
['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'],
|
||||||
|
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'],
|
||||||
|
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'],
|
||||||
|
['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
|
||||||
|
]
|
||||||
|
},
|
||||||
|
'de': {
|
||||||
|
'normal': [
|
||||||
|
['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'],
|
||||||
|
['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'],
|
||||||
|
['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'],
|
||||||
|
['⬆', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-']
|
||||||
|
],
|
||||||
|
'shift': [
|
||||||
|
['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'],
|
||||||
|
['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''],
|
||||||
|
['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'],
|
||||||
|
['⬆', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-10-07 15:45+0500\n"
|
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: de_DE\n"
|
"Language: de_DE\n"
|
||||||
@@ -413,6 +413,9 @@ msgstr ""
|
|||||||
msgid "Fullscreen"
|
msgid "Fullscreen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Steam games..."
|
msgid "Loading Steam games..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
Binary file not shown.
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-10-07 15:45+0500\n"
|
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language: es_ES\n"
|
"Language: es_ES\n"
|
||||||
@@ -413,6 +413,9 @@ msgstr ""
|
|||||||
msgid "Fullscreen"
|
msgid "Fullscreen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Steam games..."
|
msgid "Loading Steam games..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
@@ -9,7 +9,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
"Project-Id-Version: PortProtonQt 0.1.1\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-10-07 15:45+0500\n"
|
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -411,6 +411,9 @@ msgstr ""
|
|||||||
msgid "Fullscreen"
|
msgid "Fullscreen"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
msgid "Search"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
msgid "Loading Steam games..."
|
msgid "Loading Steam games..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
Binary file not shown.
@@ -9,8 +9,8 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PROJECT VERSION\n"
|
"Project-Id-Version: PROJECT VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||||
"POT-Creation-Date: 2025-10-07 15:45+0500\n"
|
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
|
||||||
"PO-Revision-Date: 2025-10-07 15:44+0500\n"
|
"PO-Revision-Date: 2025-10-09 16:37+0500\n"
|
||||||
"Last-Translator: \n"
|
"Last-Translator: \n"
|
||||||
"Language: ru_RU\n"
|
"Language: ru_RU\n"
|
||||||
"Language-Team: ru_RU <LL@li.org>\n"
|
"Language-Team: ru_RU <LL@li.org>\n"
|
||||||
@@ -420,6 +420,9 @@ msgstr "Назад"
|
|||||||
msgid "Fullscreen"
|
msgid "Fullscreen"
|
||||||
msgstr "Полный экран"
|
msgstr "Полный экран"
|
||||||
|
|
||||||
|
msgid "Search"
|
||||||
|
msgstr "Поиск"
|
||||||
|
|
||||||
msgid "Loading Steam games..."
|
msgid "Loading Steam games..."
|
||||||
msgstr "Загрузка игр из Steam..."
|
msgstr "Загрузка игр из Steam..."
|
||||||
|
|
||||||
|
@@ -5,6 +5,7 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import psutil
|
import psutil
|
||||||
|
import re
|
||||||
|
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
|
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
|
||||||
@@ -35,10 +36,10 @@ from portprotonqt.howlongtobeat_api import HowLongToBeat
|
|||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
from portprotonqt.tray_manager import TrayManager
|
from portprotonqt.tray_manager import TrayManager
|
||||||
from portprotonqt.game_library_manager import GameLibraryManager
|
from portprotonqt.game_library_manager import GameLibraryManager
|
||||||
|
from portprotonqt.virtual_keyboard import VirtualKeyboard
|
||||||
|
|
||||||
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
|
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
|
||||||
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout)
|
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout, QScrollArea)
|
||||||
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
|
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
|
||||||
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
|
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
|
||||||
from typing import cast
|
from typing import cast
|
||||||
@@ -129,6 +130,11 @@ class MainWindow(QMainWindow):
|
|||||||
self.update_progress.connect(self.progress_bar.setValue)
|
self.update_progress.connect(self.progress_bar.setValue)
|
||||||
self.update_status_message.connect(self.statusBar().showMessage)
|
self.update_status_message.connect(self.statusBar().showMessage)
|
||||||
|
|
||||||
|
self.installing = False
|
||||||
|
self.current_install_script = None
|
||||||
|
self.install_process = None
|
||||||
|
self.install_monitor_timer = None
|
||||||
|
|
||||||
# Центральный виджет и основной layout
|
# Центральный виджет и основной layout
|
||||||
centralWidget = QWidget()
|
centralWidget = QWidget()
|
||||||
self.setCentralWidget(centralWidget)
|
self.setCentralWidget(centralWidget)
|
||||||
@@ -145,7 +151,7 @@ class MainWindow(QMainWindow):
|
|||||||
headerLayout.addStretch()
|
headerLayout.addStretch()
|
||||||
|
|
||||||
self.input_manager = InputManager(self) # type: ignore
|
self.input_manager = InputManager(self) # type: ignore
|
||||||
self.input_manager.button_pressed.connect(self.updateControlHints)
|
self.input_manager.button_event.connect(self.updateControlHints)
|
||||||
self.input_manager.dpad_moved.connect(self.updateControlHints)
|
self.input_manager.dpad_moved.connect(self.updateControlHints)
|
||||||
|
|
||||||
# 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
|
# 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
|
||||||
@@ -166,7 +172,6 @@ class MainWindow(QMainWindow):
|
|||||||
tabs = [
|
tabs = [
|
||||||
_("Library"),
|
_("Library"),
|
||||||
_("Auto Install"),
|
_("Auto Install"),
|
||||||
_("Emulators"),
|
|
||||||
_("Wine Settings"),
|
_("Wine Settings"),
|
||||||
_("PortProton Settings"),
|
_("PortProton Settings"),
|
||||||
_("Themes")
|
_("Themes")
|
||||||
@@ -198,7 +203,6 @@ class MainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.createInstalledTab()
|
self.createInstalledTab()
|
||||||
self.createAutoInstallTab()
|
self.createAutoInstallTab()
|
||||||
self.createEmulatorsTab()
|
|
||||||
self.createWineTab()
|
self.createWineTab()
|
||||||
self.createPortProtonTab()
|
self.createPortProtonTab()
|
||||||
self.createThemeTab()
|
self.createThemeTab()
|
||||||
@@ -206,8 +210,12 @@ class MainWindow(QMainWindow):
|
|||||||
self.controlHintsWidget = self.createControlHintsWidget()
|
self.controlHintsWidget = self.createControlHintsWidget()
|
||||||
mainLayout.addWidget(self.controlHintsWidget)
|
mainLayout.addWidget(self.controlHintsWidget)
|
||||||
|
|
||||||
|
self.updateControlHints()
|
||||||
|
|
||||||
self.restore_state()
|
self.restore_state()
|
||||||
|
|
||||||
|
self.keyboard = VirtualKeyboard(self, self.theme)
|
||||||
|
|
||||||
self.detail_animations = DetailPageAnimations(self, self.theme)
|
self.detail_animations = DetailPageAnimations(self, self.theme)
|
||||||
QTimer.singleShot(0, self.loadGames)
|
QTimer.singleShot(0, self.loadGames)
|
||||||
|
|
||||||
@@ -248,6 +256,10 @@ class MainWindow(QMainWindow):
|
|||||||
GamepadType.XBOX: "xbox_view",
|
GamepadType.XBOX: "xbox_view",
|
||||||
GamepadType.PLAYSTATION: "ps_share",
|
GamepadType.PLAYSTATION: "ps_share",
|
||||||
},
|
},
|
||||||
|
'search': {
|
||||||
|
GamepadType.XBOX: "xbox_y",
|
||||||
|
GamepadType.PLAYSTATION: "ps_square",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
return mappings.get(action, {}).get(gtype, "placeholder")
|
return mappings.get(action, {}).get(gtype, "placeholder")
|
||||||
|
|
||||||
@@ -286,6 +298,7 @@ class MainWindow(QMainWindow):
|
|||||||
("add_game", _("Add Game")),
|
("add_game", _("Add Game")),
|
||||||
("context_menu", _("Menu")),
|
("context_menu", _("Menu")),
|
||||||
("menu", _("Fullscreen")),
|
("menu", _("Fullscreen")),
|
||||||
|
("search", _("Search")),
|
||||||
]
|
]
|
||||||
|
|
||||||
keyboard_hints = [
|
keyboard_hints = [
|
||||||
@@ -398,7 +411,7 @@ class MainWindow(QMainWindow):
|
|||||||
gtype = self.input_manager.gamepad_type
|
gtype = self.input_manager.gamepad_type
|
||||||
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
|
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
|
||||||
|
|
||||||
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu']
|
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu', 'search']
|
||||||
|
|
||||||
for container, icon_label, action in self.hintsLabels:
|
for container, icon_label, action in self.hintsLabels:
|
||||||
if action in gamepad_actions: # Gamepad hint
|
if action in gamepad_actions: # Gamepad hint
|
||||||
@@ -430,6 +443,103 @@ class MainWindow(QMainWindow):
|
|||||||
# Update navigation buttons
|
# Update navigation buttons
|
||||||
self.updateNavButtons()
|
self.updateNavButtons()
|
||||||
|
|
||||||
|
def launch_autoinstall(self, script_name: str):
|
||||||
|
"""Launch auto-install script."""
|
||||||
|
if self.installing:
|
||||||
|
QMessageBox.warning(self, _("Warning"), _("Installation already in progress."))
|
||||||
|
return
|
||||||
|
self.installing = True
|
||||||
|
self.current_install_script = script_name
|
||||||
|
self.seen_progress = False
|
||||||
|
self.current_percent = 0.0
|
||||||
|
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||||
|
if not os.path.exists(start_sh):
|
||||||
|
self.installing = False
|
||||||
|
QMessageBox.warning(self, _("Error"), _("start.sh not found."))
|
||||||
|
return
|
||||||
|
cmd = [start_sh, "cli", "--autoinstall", script_name]
|
||||||
|
print(cmd)
|
||||||
|
self.install_process = QProcess(self)
|
||||||
|
self.install_process.finished.connect(self.on_install_finished)
|
||||||
|
self.install_process.errorOccurred.connect(self.on_install_error)
|
||||||
|
self.install_process.start(cmd[0], cmd[1:])
|
||||||
|
if not self.install_process.waitForStarted(5000):
|
||||||
|
self.installing = False
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Failed to start installation."))
|
||||||
|
return
|
||||||
|
self.progress_bar.setVisible(True)
|
||||||
|
self.progress_bar.setRange(0, 0) # Indeterminate
|
||||||
|
self.update_status_message.emit(f"Processed {script_name} installation...", 0)
|
||||||
|
self.install_monitor_timer = QTimer(self)
|
||||||
|
self.install_monitor_timer.timeout.connect(self.monitor_install_progress)
|
||||||
|
self.install_monitor_timer.start(2000) # Start monitoring after 2s
|
||||||
|
|
||||||
|
def monitor_install_progress(self):
|
||||||
|
"""Monitor /tmp/PortProton_$USER/process.log for progress."""
|
||||||
|
user = os.getenv('USER', 'unknown')
|
||||||
|
log_file = f"/tmp/PortProton_{user}/process.log"
|
||||||
|
if not os.path.exists(log_file):
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(log_file, encoding='utf-8') as f:
|
||||||
|
content = f.read()
|
||||||
|
# Extract all percentage matches, including .0% as 0.0
|
||||||
|
matches = re.findall(r'([0-9]*\.?[0-9]+)%', content)
|
||||||
|
if matches:
|
||||||
|
try:
|
||||||
|
percent = float(matches[-1])
|
||||||
|
if percent > 0:
|
||||||
|
self.seen_progress = True
|
||||||
|
self.current_percent = percent
|
||||||
|
elif self.seen_progress and percent == 0:
|
||||||
|
self.current_percent = 100.0
|
||||||
|
self.install_monitor_timer.stop()
|
||||||
|
# Update progress bar to determinate if not already
|
||||||
|
if self.progress_bar.maximum() == 0:
|
||||||
|
self.progress_bar.setRange(0, 100)
|
||||||
|
self.progress_bar.setFormat("%p") # Show percentage
|
||||||
|
self.progress_bar.setValue(int(self.current_percent))
|
||||||
|
if self.current_percent >= 100:
|
||||||
|
self.install_monitor_timer.stop()
|
||||||
|
except ValueError:
|
||||||
|
pass # Ignore invalid floats
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error monitoring log: {e}")
|
||||||
|
|
||||||
|
@Slot(int, int)
|
||||||
|
def on_install_finished(self, exit_code: int, exit_status: int):
|
||||||
|
"""Handle installation finish."""
|
||||||
|
self.installing = False
|
||||||
|
if self.install_monitor_timer:
|
||||||
|
self.install_monitor_timer.stop()
|
||||||
|
self.install_monitor_timer.deleteLater()
|
||||||
|
self.install_monitor_timer = None
|
||||||
|
self.progress_bar.setRange(0, 100)
|
||||||
|
self.progress_bar.setValue(100)
|
||||||
|
if exit_code == 0:
|
||||||
|
self.update_status_message.emit(_("Installation completed successfully."), 5000)
|
||||||
|
# Reload library after delay
|
||||||
|
QTimer.singleShot(3000, self.loadGames)
|
||||||
|
else:
|
||||||
|
self.update_status_message.emit(_("Installation failed."), 5000)
|
||||||
|
QMessageBox.warning(self, _("Error"), f"Installation failed (code: {exit_code}).")
|
||||||
|
self.progress_bar.setVisible(False)
|
||||||
|
self.current_install_script = None
|
||||||
|
if self.install_process:
|
||||||
|
self.install_process.deleteLater()
|
||||||
|
self.install_process = None
|
||||||
|
|
||||||
|
def on_install_error(self, error: QProcess.ProcessError):
|
||||||
|
"""Handle installation error."""
|
||||||
|
self.installing = False
|
||||||
|
if self.install_monitor_timer:
|
||||||
|
self.install_monitor_timer.stop()
|
||||||
|
self.install_monitor_timer.deleteLater()
|
||||||
|
self.install_monitor_timer = None
|
||||||
|
self.update_status_message.emit(_("Installation error."), 5000)
|
||||||
|
QMessageBox.warning(self, _("Error"), f"Process error: {error}")
|
||||||
|
self.progress_bar.setVisible(False)
|
||||||
|
|
||||||
@Slot(list)
|
@Slot(list)
|
||||||
def on_games_loaded(self, games: list[tuple]):
|
def on_games_loaded(self, games: list[tuple]):
|
||||||
self.game_library_manager.set_games(games)
|
self.game_library_manager.set_games(games)
|
||||||
@@ -951,52 +1061,118 @@ class MainWindow(QMainWindow):
|
|||||||
get_steam_game_info_async(final_name, exec_line, on_steam_info)
|
get_steam_game_info_async(final_name, exec_line, on_steam_info)
|
||||||
|
|
||||||
def createAutoInstallTab(self):
|
def createAutoInstallTab(self):
|
||||||
"""Вкладка 'Auto Install'."""
|
"""Create the Auto Install tab with flow layout of simple game cards (cover, name, install button)."""
|
||||||
self.autoInstallWidget = QWidget()
|
from portprotonqt.localization import _
|
||||||
self.autoInstallWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
tab = QWidget()
|
||||||
self.autoInstallWidget.setObjectName("otherPage")
|
layout = QVBoxLayout(tab)
|
||||||
layout = QVBoxLayout(self.autoInstallWidget)
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
layout.setContentsMargins(10, 18, 10, 10)
|
layout.setSpacing(20)
|
||||||
|
|
||||||
self.autoInstallTitle = QLabel(_("Auto Install"))
|
# Header label
|
||||||
self.autoInstallTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE)
|
header = QLabel(_("Auto Install Games"))
|
||||||
self.autoInstallTitle.setObjectName("tabTitle")
|
header.setStyleSheet(self.theme.DETAIL_PAGE_TITLE_STYLE)
|
||||||
layout.addWidget(self.autoInstallTitle)
|
layout.addWidget(header)
|
||||||
|
|
||||||
self.autoInstallContent = QLabel(_("Here you can configure automatic game installation..."))
|
# Scroll area for games
|
||||||
self.autoInstallContent.setStyleSheet(self.theme.CONTENT_STYLE)
|
scroll = QScrollArea()
|
||||||
self.autoInstallContent.setObjectName("tabContent")
|
scroll.setWidgetResizable(True)
|
||||||
layout.addWidget(self.autoInstallContent)
|
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
||||||
layout.addStretch(1)
|
scroll_widget = QWidget()
|
||||||
|
from portprotonqt.custom_widgets import FlowLayout
|
||||||
|
self.auto_install_flow_layout = FlowLayout(scroll_widget) # Store reference for potential updates
|
||||||
|
self.auto_install_flow_layout.setSpacing(15)
|
||||||
|
self.auto_install_flow_layout.setContentsMargins(0, 0, 0, 0)
|
||||||
|
|
||||||
self.stackedWidget.addWidget(self.autoInstallWidget)
|
# Load games asynchronously (though now sync inside, but callback for consistency)
|
||||||
|
def on_autoinstall_games_loaded(games: list[tuple]):
|
||||||
|
# Clear existing widgets
|
||||||
|
while self.auto_install_flow_layout.count():
|
||||||
|
child = self.auto_install_flow_layout.takeAt(0)
|
||||||
|
if child.widget():
|
||||||
|
child.widget().deleteLater()
|
||||||
|
|
||||||
def createEmulatorsTab(self):
|
for game in games:
|
||||||
"""Вкладка 'Emulators'."""
|
name = game[0]
|
||||||
self.emulatorsWidget = QWidget()
|
description = game[1]
|
||||||
self.emulatorsWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
cover_path = game[2]
|
||||||
self.emulatorsWidget.setObjectName("otherPage")
|
exec_line = game[4]
|
||||||
layout = QVBoxLayout(self.emulatorsWidget)
|
script_name = exec_line.split("autoinstall:")[1] if exec_line.startswith("autoinstall:") else ""
|
||||||
layout.setContentsMargins(10, 18, 10, 10)
|
|
||||||
|
|
||||||
self.emulatorsTitle = QLabel(_("Emulators"))
|
# Create simple card frame
|
||||||
self.emulatorsTitle.setStyleSheet(self.theme.TAB_TITLE_STYLE)
|
card_frame = QFrame()
|
||||||
self.emulatorsTitle.setObjectName("tabTitle")
|
card_frame.setFixedWidth(self.card_width)
|
||||||
layout.addWidget(self.emulatorsTitle)
|
card_frame.setStyleSheet(self.theme.GAME_CARD_STYLE if hasattr(self.theme, 'GAME_CARD_STYLE') else "")
|
||||||
|
card_layout = QVBoxLayout(card_frame)
|
||||||
|
card_layout.setContentsMargins(10, 10, 10, 10)
|
||||||
|
card_layout.setSpacing(10)
|
||||||
|
|
||||||
self.emulatorsContent = QLabel(_("List of available emulators and their configuration..."))
|
# Cover image
|
||||||
self.emulatorsContent.setStyleSheet(self.theme.CONTENT_STYLE)
|
cover_label = QLabel()
|
||||||
self.emulatorsContent.setObjectName("tabContent")
|
cover_label.setFixedHeight(120)
|
||||||
layout.addWidget(self.emulatorsContent)
|
cover_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
layout.addStretch(1)
|
pixmap = QPixmap()
|
||||||
|
if cover_path and os.path.exists(cover_path) and pixmap.load(cover_path):
|
||||||
|
scaled_pix = pixmap.scaled(self.card_width - 40, 120, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||||||
|
cover_label.setPixmap(scaled_pix)
|
||||||
|
else:
|
||||||
|
# Placeholder
|
||||||
|
placeholder_icon = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
|
||||||
|
if placeholder_icon:
|
||||||
|
pixmap.load(str(placeholder_icon))
|
||||||
|
scaled_pix = pixmap.scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
||||||
|
cover_label.setPixmap(scaled_pix)
|
||||||
|
card_layout.addWidget(cover_label)
|
||||||
|
|
||||||
self.stackedWidget.addWidget(self.emulatorsWidget)
|
# Name label
|
||||||
|
name_label = QLabel(name)
|
||||||
|
name_label.setWordWrap(True)
|
||||||
|
name_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
name_label.setStyleSheet(self.theme.CARD_TITLE_STYLE if hasattr(self.theme, 'CARD_TITLE_STYLE') else "")
|
||||||
|
card_layout.addWidget(name_label)
|
||||||
|
|
||||||
|
# Optional short description
|
||||||
|
if description:
|
||||||
|
desc_label = QLabel(description[:100] + "..." if len(description) > 100 else description)
|
||||||
|
desc_label.setWordWrap(True)
|
||||||
|
desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
desc_label.setStyleSheet(self.theme.CARD_DESC_STYLE if hasattr(self.theme, 'CARD_DESC_STYLE') else "")
|
||||||
|
card_layout.addWidget(desc_label)
|
||||||
|
|
||||||
|
# Install button
|
||||||
|
install_btn = AutoSizeButton(_("Install"))
|
||||||
|
install_btn.setStyleSheet(self.theme.PLAY_BUTTON_STYLE)
|
||||||
|
install_btn.clicked.connect(lambda checked, s=script_name: self.launch_autoinstall(s))
|
||||||
|
card_layout.addWidget(install_btn)
|
||||||
|
|
||||||
|
card_layout.addStretch()
|
||||||
|
|
||||||
|
# Add to flow layout
|
||||||
|
self.auto_install_flow_layout.addWidget(card_frame)
|
||||||
|
|
||||||
|
scroll.setWidget(scroll_widget)
|
||||||
|
layout.addWidget(scroll)
|
||||||
|
|
||||||
|
# Trigger load
|
||||||
|
self.portproton_api.get_autoinstall_games_async(on_autoinstall_games_loaded)
|
||||||
|
|
||||||
|
self.stackedWidget.addWidget(tab)
|
||||||
|
|
||||||
|
|
||||||
|
def on_auto_install_search_changed(self, text: str):
|
||||||
|
"""Filter auto-install games based on search text."""
|
||||||
|
filtered_games = [g for g in self.auto_install_games if text.lower() in g[0].lower() or text.lower() in g[1].lower()]
|
||||||
|
self.populate_auto_install_grid(filtered_games)
|
||||||
|
self.auto_install_clear_search_button.setVisible(bool(text))
|
||||||
|
|
||||||
|
def clear_auto_install_search(self):
|
||||||
|
"""Clear the auto-install search and repopulate grid."""
|
||||||
|
self.auto_install_search_line.clear()
|
||||||
|
self.populate_auto_install_grid(self.auto_install_games)
|
||||||
|
|
||||||
def createWineTab(self):
|
def createWineTab(self):
|
||||||
"""Вкладка 'Wine Settings'."""
|
"""Вкладка 'Wine Settings'."""
|
||||||
self.wineWidget = QWidget()
|
self.wineWidget = QWidget()
|
||||||
self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
self.wineWidget.setStyleSheet(self.theme.OTHER_PAGES_WIDGET_STYLE)
|
||||||
self.wineWidget.setObjectName("otherPage")
|
|
||||||
layout = QVBoxLayout(self.wineWidget)
|
layout = QVBoxLayout(self.wineWidget)
|
||||||
layout.setContentsMargins(10, 18, 10, 10)
|
layout.setContentsMargins(10, 18, 10, 10)
|
||||||
|
|
||||||
@@ -1052,21 +1228,20 @@ class MainWindow(QMainWindow):
|
|||||||
tools_grid.setSpacing(6)
|
tools_grid.setSpacing(6)
|
||||||
|
|
||||||
tools = [
|
tools = [
|
||||||
("winecfg", _("Wine Configuration")),
|
("--winecfg", _("Wine Configuration")),
|
||||||
("regedit", _("Registry Editor")),
|
("--winereg", _("Registry Editor")),
|
||||||
("control", _("Control Panel")),
|
("--winefile", _("File Explorer")),
|
||||||
("taskmgr", _("Task Manager")),
|
("--winecmd", _("Command Prompt")),
|
||||||
("explorer", _("File Explorer")),
|
("--wine_uninstaller", _("Uninstaller")),
|
||||||
("cmd", _("Command Prompt")),
|
|
||||||
("uninstaller", _("Uninstaller")),
|
|
||||||
]
|
]
|
||||||
|
|
||||||
for i, (_tool_cmd, tool_name) in enumerate(tools):
|
for i, (tool_cmd, tool_name) in enumerate(tools):
|
||||||
row = i // 3
|
row = i // 3
|
||||||
col = i % 3
|
col = i % 3
|
||||||
btn = AutoSizeButton(tool_name, update_size=False)
|
btn = AutoSizeButton(tool_name, update_size=False)
|
||||||
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
|
||||||
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
||||||
|
btn.clicked.connect(lambda checked, t=tool_cmd: self.launch_generic_tool(t))
|
||||||
tools_grid.addWidget(btn, row, col)
|
tools_grid.addWidget(btn, row, col)
|
||||||
|
|
||||||
for col in range(3):
|
for col in range(3):
|
||||||
@@ -1084,7 +1259,7 @@ class MainWindow(QMainWindow):
|
|||||||
(_("Load Prefix Backup"), self.load_prefix_backup),
|
(_("Load Prefix Backup"), self.load_prefix_backup),
|
||||||
(_("Delete Compatibility Tool"), self.delete_compat_tool),
|
(_("Delete Compatibility Tool"), self.delete_compat_tool),
|
||||||
(_("Delete Prefix"), self.delete_prefix),
|
(_("Delete Prefix"), self.delete_prefix),
|
||||||
(_("Clear Prefix"), None),
|
(_("Clear Prefix"), self.clear_prefix),
|
||||||
]
|
]
|
||||||
|
|
||||||
for i, (text, callback) in enumerate(additional_buttons):
|
for i, (text, callback) in enumerate(additional_buttons):
|
||||||
@@ -1105,8 +1280,221 @@ class MainWindow(QMainWindow):
|
|||||||
additional_grid.setContentsMargins(10, 6, 10, 0)
|
additional_grid.setContentsMargins(10, 6, 10, 0)
|
||||||
layout.addStretch(1)
|
layout.addStretch(1)
|
||||||
|
|
||||||
|
self.wine_progress_bar = QProgressBar(self.wineWidget)
|
||||||
|
self.wine_progress_bar.setStyleSheet(self.theme.PROGRESS_BAR_STYLE)
|
||||||
|
self.wine_progress_bar.setMaximumWidth(200)
|
||||||
|
self.wine_progress_bar.setTextVisible(True)
|
||||||
|
self.wine_progress_bar.setVisible(False)
|
||||||
|
self.wine_progress_bar.setRange(0, 0)
|
||||||
|
|
||||||
|
wine_progress_layout = QHBoxLayout()
|
||||||
|
wine_progress_layout.addStretch(1)
|
||||||
|
wine_progress_layout.addWidget(self.wine_progress_bar)
|
||||||
|
layout.addLayout(wine_progress_layout)
|
||||||
|
|
||||||
self.stackedWidget.addWidget(self.wineWidget)
|
self.stackedWidget.addWidget(self.wineWidget)
|
||||||
|
|
||||||
|
def launch_generic_tool(self, cli_arg):
|
||||||
|
wine = self.wineCombo.currentText()
|
||||||
|
prefix = self.prefixCombo.currentText()
|
||||||
|
if not wine or not prefix:
|
||||||
|
return
|
||||||
|
if not self.portproton_location:
|
||||||
|
return
|
||||||
|
start_sh = os.path.join(self.portproton_location, "data", "scripts", "start.sh")
|
||||||
|
if not os.path.exists(start_sh):
|
||||||
|
return
|
||||||
|
cmd = [start_sh, "cli", cli_arg, wine, prefix]
|
||||||
|
|
||||||
|
# Показываем прогресс-бар перед запуском
|
||||||
|
self.wine_progress_bar.setVisible(True)
|
||||||
|
self.update_status_message.emit(_("Launching tool..."), 0)
|
||||||
|
|
||||||
|
proc = QProcess(self)
|
||||||
|
proc.finished.connect(lambda exitCode, exitStatus: self._on_wine_tool_finished(exitCode, cli_arg))
|
||||||
|
proc.errorOccurred.connect(lambda error: self._on_wine_tool_error(error, cli_arg))
|
||||||
|
proc.start(cmd[0], cmd[1:])
|
||||||
|
|
||||||
|
if not proc.waitForStarted(5000):
|
||||||
|
self.wine_progress_bar.setVisible(False)
|
||||||
|
self.update_status_message.emit("", 0)
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Failed to start process."))
|
||||||
|
return
|
||||||
|
|
||||||
|
self._start_wine_process_monitor(cli_arg)
|
||||||
|
|
||||||
|
def _start_wine_process_monitor(self, cli_arg):
|
||||||
|
"""Запускает таймер для мониторинга запуска Wine утилиты."""
|
||||||
|
self.wine_monitor_timer = QTimer(self)
|
||||||
|
self.wine_monitor_timer.setInterval(500)
|
||||||
|
self.wine_monitor_timer.timeout.connect(lambda: self._check_wine_process(cli_arg))
|
||||||
|
self.wine_monitor_timer.start()
|
||||||
|
|
||||||
|
def _check_wine_process(self, cli_arg):
|
||||||
|
"""Проверяет, запустился ли целевой .exe процесс."""
|
||||||
|
exe_map = {
|
||||||
|
"--winecfg": "winecfg.exe",
|
||||||
|
"--winereg": "regedit.exe",
|
||||||
|
"--winefile": "winefile.exe",
|
||||||
|
"--winecmd": "cmd.exe",
|
||||||
|
"--wine_uninstaller": "uninstaller.exe",
|
||||||
|
}
|
||||||
|
target_exe = exe_map.get(cli_arg, "")
|
||||||
|
if not target_exe:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Проверяем процессы через psutil
|
||||||
|
for proc in psutil.process_iter(attrs=["name"]):
|
||||||
|
if proc.info["name"].lower() == target_exe.lower():
|
||||||
|
# Процесс запустился — скрываем прогресс-бар и останавливаем мониторинг
|
||||||
|
self.wine_progress_bar.setVisible(False)
|
||||||
|
self.update_status_message.emit("", 0)
|
||||||
|
if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None:
|
||||||
|
self.wine_monitor_timer.stop()
|
||||||
|
self.wine_monitor_timer.deleteLater()
|
||||||
|
self.wine_monitor_timer = None
|
||||||
|
logger.info(f"Wine tool {target_exe} started successfully")
|
||||||
|
return
|
||||||
|
|
||||||
|
def _on_wine_tool_finished(self, exitCode, cli_arg):
|
||||||
|
"""Обработчик завершения Wine утилиты."""
|
||||||
|
self.wine_progress_bar.setVisible(False)
|
||||||
|
self.update_status_message.emit("", 0)
|
||||||
|
# Останавливаем мониторинг, если он активен
|
||||||
|
if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None:
|
||||||
|
self.wine_monitor_timer.stop()
|
||||||
|
self.wine_monitor_timer.deleteLater()
|
||||||
|
self.wine_monitor_timer = None
|
||||||
|
if exitCode == 0:
|
||||||
|
logger.info(f"Wine tool {cli_arg} finished successfully")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Wine tool {cli_arg} finished with exit code {exitCode}")
|
||||||
|
|
||||||
|
def _on_wine_tool_error(self, error, cli_arg):
|
||||||
|
"""Обработчик ошибки запуска Wine утилиты."""
|
||||||
|
self.wine_progress_bar.setVisible(False)
|
||||||
|
self.update_status_message.emit("", 0)
|
||||||
|
# Останавливаем мониторинг, если он активен
|
||||||
|
if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None:
|
||||||
|
self.wine_monitor_timer.stop()
|
||||||
|
self.wine_monitor_timer.deleteLater()
|
||||||
|
self.wine_monitor_timer = None
|
||||||
|
logger.error(f"Wine tool {cli_arg} error: {error}")
|
||||||
|
QMessageBox.warning(self, _("Error"), f"Failed to launch tool: {error}")
|
||||||
|
|
||||||
|
def clear_prefix(self):
|
||||||
|
"""Очистка префикса (позже удалить)."""
|
||||||
|
selected_prefix = self.prefixCombo.currentText()
|
||||||
|
selected_wine = self.wineCombo.currentText()
|
||||||
|
if not selected_prefix or not selected_wine:
|
||||||
|
return
|
||||||
|
if not self.portproton_location:
|
||||||
|
return
|
||||||
|
|
||||||
|
reply = QMessageBox.question(
|
||||||
|
self,
|
||||||
|
_("Confirm Clear"),
|
||||||
|
_("Are you sure you want to clear prefix '{}'?").format(selected_prefix),
|
||||||
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||||
|
QMessageBox.StandardButton.No
|
||||||
|
)
|
||||||
|
if reply != QMessageBox.StandardButton.Yes:
|
||||||
|
return
|
||||||
|
|
||||||
|
prefix_dir = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix)
|
||||||
|
if not os.path.exists(prefix_dir):
|
||||||
|
QMessageBox.warning(self, _("Error"), _("Prefix '{}' does not exist.").format(selected_prefix))
|
||||||
|
return
|
||||||
|
|
||||||
|
success = True
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
# Удаление файлов
|
||||||
|
files_to_remove = [
|
||||||
|
os.path.join(prefix_dir, "*.dot*"),
|
||||||
|
os.path.join(prefix_dir, "*.prog*"),
|
||||||
|
os.path.join(prefix_dir, ".wine_ver"),
|
||||||
|
os.path.join(prefix_dir, "system.reg"),
|
||||||
|
os.path.join(prefix_dir, "user.reg"),
|
||||||
|
os.path.join(prefix_dir, "userdef.reg"),
|
||||||
|
os.path.join(prefix_dir, "winetricks.log"),
|
||||||
|
os.path.join(prefix_dir, ".update-timestamp"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", ".windows-serial"),
|
||||||
|
]
|
||||||
|
|
||||||
|
import glob
|
||||||
|
for pattern in files_to_remove:
|
||||||
|
if "*" in pattern: # Глобальный паттерн
|
||||||
|
matches = glob.glob(pattern)
|
||||||
|
for file_path in matches:
|
||||||
|
try:
|
||||||
|
if os.path.exists(file_path):
|
||||||
|
os.remove(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
errors.append(str(e))
|
||||||
|
else: # Конкретный файл
|
||||||
|
try:
|
||||||
|
if os.path.exists(pattern):
|
||||||
|
os.remove(pattern)
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
# Удаление директорий
|
||||||
|
dirs_to_remove = [
|
||||||
|
os.path.join(prefix_dir, "drive_c", "windows"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "ProgramData", "Setup"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "ProgramData", "Windows"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "ProgramData", "WindowsTask"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "ProgramData", "Package Cache"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Microsoft"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Application Data", "Temp"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "Public", "Local Settings", "Temporary Internet Files"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "Microsoft"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "Public", "Application Data", "wine_gecko"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "Public", "Temp"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Microsoft"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Application Data", "Temp"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Local Settings", "Temporary Internet Files"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "Microsoft"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Application Data", "wine_gecko"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "users", "steamuser", "Temp"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "Program Files", "Internet Explorer"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "Program Files", "Windows Media Player"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "Program Files", "Windows NT"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Internet Explorer"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows Media Player"),
|
||||||
|
os.path.join(prefix_dir, "drive_c", "Program Files (x86)", "Windows NT"),
|
||||||
|
]
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
for dir_path in dirs_to_remove:
|
||||||
|
try:
|
||||||
|
if os.path.exists(dir_path):
|
||||||
|
shutil.rmtree(dir_path)
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
tmp_path = os.path.join(self.portproton_location, "data", "tmp")
|
||||||
|
if os.path.exists(tmp_path):
|
||||||
|
import glob
|
||||||
|
bin_files = glob.glob(os.path.join(tmp_path, "*.bin"))
|
||||||
|
foz_files = glob.glob(os.path.join(tmp_path, "*.foz"))
|
||||||
|
for file_path in bin_files + foz_files:
|
||||||
|
try:
|
||||||
|
os.remove(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
success = False
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
if success:
|
||||||
|
QMessageBox.information(self, _("Success"), _("Prefix '{}' cleared successfully.").format(selected_prefix))
|
||||||
|
else:
|
||||||
|
error_msg = _("Prefix '{}' cleared with errors:\n{}").format(selected_prefix, "\n".join(errors[:5]))
|
||||||
|
QMessageBox.warning(self, _("Warning"), error_msg)
|
||||||
|
|
||||||
def create_prefix_backup(self):
|
def create_prefix_backup(self):
|
||||||
selected_prefix = self.prefixCombo.currentText()
|
selected_prefix = self.prefixCombo.currentText()
|
||||||
if not selected_prefix:
|
if not selected_prefix:
|
||||||
@@ -1187,8 +1575,9 @@ class MainWindow(QMainWindow):
|
|||||||
QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix))
|
QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix))
|
||||||
# обновляем список
|
# обновляем список
|
||||||
self.prefixCombo.clear()
|
self.prefixCombo.clear()
|
||||||
self.prefixes = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "prefixes"))
|
prefixes_path = os.path.join(self.portproton_location, "data", "prefixes")
|
||||||
if os.path.isdir(os.path.join(self.portproton_location, "data", "prefixes", d))]
|
self.prefixes = [d for d in os.listdir(prefixes_path)
|
||||||
|
if os.path.isdir(os.path.join(prefixes_path, d))]
|
||||||
self.prefixCombo.addItems(self.prefixes)
|
self.prefixCombo.addItems(self.prefixes)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e)))
|
QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e)))
|
||||||
@@ -2066,10 +2455,19 @@ class MainWindow(QMainWindow):
|
|||||||
completionist_time = hltb.format_game_time(game, "completionist")
|
completionist_time = hltb.format_game_time(game, "completionist")
|
||||||
|
|
||||||
# Очищаем layout перед добавлением новых элементов
|
# Очищаем layout перед добавлением новых элементов
|
||||||
while hltbLayout.count():
|
def clear_layout(layout):
|
||||||
child = hltbLayout.takeAt(0)
|
while layout.count():
|
||||||
if child.widget():
|
item = layout.takeAt(0)
|
||||||
child.widget().deleteLater()
|
widget = item.widget()
|
||||||
|
sublayout = item.layout()
|
||||||
|
if widget:
|
||||||
|
widget.deleteLater()
|
||||||
|
elif sublayout:
|
||||||
|
clear_layout(sublayout)
|
||||||
|
|
||||||
|
clear_layout(hltbLayout)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
has_data = False
|
has_data = False
|
||||||
|
|
||||||
@@ -2315,6 +2713,11 @@ class MainWindow(QMainWindow):
|
|||||||
QDesktopServices.openUrl(url)
|
QDesktopServices.openUrl(url)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if exec_line.startswith("autoinstall:"):
|
||||||
|
script_name = exec_line.split("autoinstall:")[1]
|
||||||
|
self.launch_autoinstall(script_name)
|
||||||
|
return
|
||||||
|
|
||||||
# Обработка EGS-игр
|
# Обработка EGS-игр
|
||||||
if exec_line.startswith("legendary:launch:"):
|
if exec_line.startswith("legendary:launch:"):
|
||||||
app_name = exec_line.split("legendary:launch:")[1]
|
app_name = exec_line.split("legendary:launch:")[1]
|
||||||
@@ -2374,9 +2777,7 @@ class MainWindow(QMainWindow):
|
|||||||
else:
|
else:
|
||||||
# Запускаем игру через PortProton
|
# Запускаем игру через PortProton
|
||||||
env_vars = os.environ.copy()
|
env_vars = os.environ.copy()
|
||||||
env_vars['START_FROM_STEAM'] = '1'
|
|
||||||
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
|
env_vars['LEGENDARY_CONFIG_PATH'] = self.legendary_config_path
|
||||||
env_vars['PROCESS_LOG'] = '1'
|
|
||||||
|
|
||||||
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
wrapper = "flatpak run ru.linux_gaming.PortProton"
|
||||||
if self.portproton_location is not None and ".var" not in self.portproton_location:
|
if self.portproton_location is not None and ".var" not in self.portproton_location:
|
||||||
@@ -2537,10 +2938,14 @@ class MainWindow(QMainWindow):
|
|||||||
self.settingsDebounceTimer.stop()
|
self.settingsDebounceTimer.stop()
|
||||||
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
|
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
|
||||||
self.searchDebounceTimer.stop()
|
self.searchDebounceTimer.stop()
|
||||||
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
|
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive():
|
||||||
self.checkProcessTimer.stop()
|
self.checkProcessTimer.stop()
|
||||||
self.checkProcessTimer.deleteLater()
|
self.checkProcessTimer.deleteLater()
|
||||||
self.checkProcessTimer = None
|
self.checkProcessTimer = None
|
||||||
|
if hasattr(self, 'wine_monitor_timer') and self.wine_monitor_timer is not None:
|
||||||
|
self.wine_monitor_timer.stop()
|
||||||
|
self.wine_monitor_timer.deleteLater()
|
||||||
|
self.wine_monitor_timer = None
|
||||||
|
|
||||||
# Сохраняем настройки окна
|
# Сохраняем настройки окна
|
||||||
if not read_fullscreen_config():
|
if not read_fullscreen_config():
|
||||||
|
@@ -4,9 +4,12 @@ import orjson
|
|||||||
import requests
|
import requests
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import time
|
import time
|
||||||
|
import glob
|
||||||
|
import re
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from portprotonqt.downloader import Downloader
|
from portprotonqt.downloader import Downloader
|
||||||
from portprotonqt.logger import get_logger
|
from portprotonqt.logger import get_logger
|
||||||
|
from portprotonqt.config_utils import get_portproton_location
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
|
CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds
|
||||||
@@ -52,6 +55,9 @@ class PortProtonAPI:
|
|||||||
self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share"))
|
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")
|
self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data")
|
||||||
os.makedirs(self.custom_data_dir, exist_ok=True)
|
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._topics_data = None
|
||||||
|
|
||||||
def _get_game_dir(self, exe_name: str) -> str:
|
def _get_game_dir(self, exe_name: str) -> str:
|
||||||
@@ -68,40 +74,6 @@ class PortProtonAPI:
|
|||||||
logger.debug(f"Failed to check file at {url}: {e}")
|
logger.debug(f"Failed to check file at {url}: {e}")
|
||||||
return False
|
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:
|
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)
|
game_dir = self._get_game_dir(exe_name)
|
||||||
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
|
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
|
||||||
@@ -163,6 +135,171 @@ class PortProtonAPI:
|
|||||||
if callback:
|
if callback:
|
||||||
callback(results)
|
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 (no metadata)."""
|
||||||
|
xdg_data_home = os.getenv("XDG_DATA_HOME",
|
||||||
|
os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||||
|
user_game_folder = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall", exe_name)
|
||||||
|
os.makedirs(user_game_folder, exist_ok=True)
|
||||||
|
|
||||||
|
cover_extensions = [".png", ".jpg", ".jpeg", ".bmp"]
|
||||||
|
results: str | None = None
|
||||||
|
pending_downloads = 0
|
||||||
|
|
||||||
|
def on_cover_downloaded(local_path: str | None, ext: str):
|
||||||
|
nonlocal pending_downloads, results
|
||||||
|
if local_path:
|
||||||
|
logger.info(f"Async autoinstall cover downloaded for {exe_name}: {local_path}")
|
||||||
|
results = local_path
|
||||||
|
else:
|
||||||
|
logger.debug(f"No autoinstall cover downloaded for {exe_name} with extension {ext}")
|
||||||
|
pending_downloads -= 1
|
||||||
|
if pending_downloads == 0 and callback:
|
||||||
|
callback(results)
|
||||||
|
|
||||||
|
for ext in cover_extensions:
|
||||||
|
cover_url = f"{self.base_url}/{exe_name}/cover{ext}"
|
||||||
|
if self._check_file_exists(cover_url, timeout):
|
||||||
|
local_cover_path = os.path.join(user_game_folder, f"cover{ext}")
|
||||||
|
pending_downloads += 1
|
||||||
|
self.downloader.download_async(
|
||||||
|
cover_url,
|
||||||
|
local_cover_path,
|
||||||
|
timeout=timeout,
|
||||||
|
callback=lambda path, ext=ext: on_cover_downloaded(path, ext)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
if pending_downloads == 0:
|
||||||
|
logger.debug(f"No autoinstall covers 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_exe= --- (многострочный, сложный вариант)
|
||||||
|
portwine_match = re.search(
|
||||||
|
r'portwine_exe\s*=\s*(?:["\']?\$\(.+?\)[\'"]?|["\'].*?\.exe["\']|[^\n]+)',
|
||||||
|
content,
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
if portwine_match:
|
||||||
|
exe_expr = portwine_match.group(0).split("=", 1)[1].strip()
|
||||||
|
exe_expr = exe_expr.strip("'\" ")
|
||||||
|
|
||||||
|
# --- Найти .exe внутри выражения (разрешаем точки в имени) ---
|
||||||
|
exe_candidates = re.findall(r'[-\w\s/\\\.]+\.exe', exe_expr)
|
||||||
|
if exe_candidates:
|
||||||
|
exe_name = os.path.basename(exe_candidates[-1].strip())
|
||||||
|
|
||||||
|
# Fallback
|
||||||
|
if not display_name and exe_name:
|
||||||
|
display_name = exe_name
|
||||||
|
|
||||||
|
return display_name, exe_name
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to parse {file_path}: {e}")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
def get_autoinstall_games_async(self, callback: Callable[[list[tuple]], None]) -> None:
|
||||||
|
"""Load auto-install games with user/builtin covers and async autoinstall cover download."""
|
||||||
|
games = []
|
||||||
|
auto_dir = os.path.join(self.portproton_location, "data", "scripts", "pw_autoinstall")
|
||||||
|
if not os.path.exists(auto_dir):
|
||||||
|
callback(games)
|
||||||
|
return
|
||||||
|
|
||||||
|
scripts = sorted(glob.glob(os.path.join(auto_dir, "*")))
|
||||||
|
if not scripts:
|
||||||
|
callback(games)
|
||||||
|
return
|
||||||
|
|
||||||
|
xdg_data_home = os.getenv("XDG_DATA_HOME",
|
||||||
|
os.path.join(os.path.expanduser("~"), ".local", "share"))
|
||||||
|
base_autoinstall_dir = os.path.join(xdg_data_home, "PortProtonQt", "custom_data", "autoinstall")
|
||||||
|
os.makedirs(base_autoinstall_dir, exist_ok=True)
|
||||||
|
|
||||||
|
for script_path in scripts:
|
||||||
|
display_name, exe_name = self.parse_autoinstall_script(script_path)
|
||||||
|
script_name = os.path.splitext(os.path.basename(script_path))[0]
|
||||||
|
|
||||||
|
if not (display_name and exe_name):
|
||||||
|
continue
|
||||||
|
|
||||||
|
exe_name = os.path.splitext(exe_name)[0] # Без .exe
|
||||||
|
user_game_folder = os.path.join(base_autoinstall_dir, exe_name)
|
||||||
|
os.makedirs(user_game_folder, exist_ok=True)
|
||||||
|
|
||||||
|
# Поиск обложки
|
||||||
|
cover_path = ""
|
||||||
|
user_files = set(os.listdir(user_game_folder)) if os.path.exists(user_game_folder) else set()
|
||||||
|
for ext in [".jpg", ".png", ".jpeg", ".bmp"]:
|
||||||
|
candidate = f"cover{ext}"
|
||||||
|
if candidate in user_files:
|
||||||
|
cover_path = os.path.join(user_game_folder, candidate)
|
||||||
|
break
|
||||||
|
|
||||||
|
# Если обложки нет — пытаемся скачать
|
||||||
|
if not cover_path:
|
||||||
|
logger.debug(f"No local cover found for autoinstall {exe_name}, trying to download...")
|
||||||
|
def on_cover_downloaded(path):
|
||||||
|
if path:
|
||||||
|
logger.info(f"Downloaded autoinstall cover for {exe_name}: {path}")
|
||||||
|
self.download_autoinstall_cover_async(exe_name, timeout=5, callback=on_cover_downloaded)
|
||||||
|
|
||||||
|
# Формируем кортеж игры
|
||||||
|
game_tuple = (
|
||||||
|
display_name, # name
|
||||||
|
"", # description
|
||||||
|
cover_path, # cover
|
||||||
|
"", # appid
|
||||||
|
f"autoinstall:{script_name}", # exec_line
|
||||||
|
"", # controller_support
|
||||||
|
"Never", # last_launch
|
||||||
|
"0h 0m", # formatted_playtime
|
||||||
|
"", # protondb_tier
|
||||||
|
"", # anticheat_status
|
||||||
|
0, # last_played
|
||||||
|
0, # playtime_seconds
|
||||||
|
"autoinstall" # game_source
|
||||||
|
)
|
||||||
|
games.append(game_tuple)
|
||||||
|
|
||||||
|
callback(games)
|
||||||
|
|
||||||
def _load_topics_data(self):
|
def _load_topics_data(self):
|
||||||
"""Load and cache linux_gaming_topics_min.json from the archive."""
|
"""Load and cache linux_gaming_topics_min.json from the archive."""
|
||||||
if self._topics_data is not None:
|
if self._topics_data is not None:
|
||||||
|
1
portprotonqt/themes/standart/images/ps_square.svg
Normal file
1
portprotonqt/themes/standart/images/ps_square.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg>
|
After Width: | Height: | Size: 682 B |
1
portprotonqt/themes/standart/images/xbox_y.svg
Normal file
1
portprotonqt/themes/standart/images/xbox_y.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.438 26.092-7.6218-12.616h5.7406l4.4433 8.238 4.4109-8.238h5.7731l-7.6866 12.552v8.4974h-5.0595z" fill="#3f424d" stroke-width="1.0811" aria-label="Y"/></svg>
|
After Width: | Height: | Size: 559 B |
@@ -217,6 +217,56 @@ CONTEXT_MENU_STYLE = f"""
|
|||||||
}}
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
VIRTUAL_KEYBOARD_STYLE = """
|
||||||
|
VirtualKeyboard {
|
||||||
|
background-color: rgba(30, 30, 30, 200);
|
||||||
|
border-radius: 0px;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
QPushButton {
|
||||||
|
font-size: 14px;
|
||||||
|
border: 1px solid #555;
|
||||||
|
border-top-color: #666;
|
||||||
|
border-left-color: #666;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-width: 30px;
|
||||||
|
min-height: 30px;
|
||||||
|
padding: 4px;
|
||||||
|
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #505050, stop:1 #404040);
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
QPushButton:hover {
|
||||||
|
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #606060, stop:1 #505050);
|
||||||
|
border: 1px solid #666;
|
||||||
|
border-top-color: #777;
|
||||||
|
border-left-color: #777;
|
||||||
|
}
|
||||||
|
QPushButton:focus {
|
||||||
|
border: 2px solid #4a90e2;
|
||||||
|
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5a5a5a, stop:1 #454545);
|
||||||
|
}
|
||||||
|
QPushButton:pressed {
|
||||||
|
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a3a3a, stop:1 #303030);
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-bottom-color: #555;
|
||||||
|
border-right-color: #555;
|
||||||
|
padding-top: 5px;
|
||||||
|
padding-bottom: 3px;
|
||||||
|
padding-left: 5px;
|
||||||
|
padding-right: 3px;
|
||||||
|
}
|
||||||
|
QPushButton[checked="true"] {
|
||||||
|
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a90e2, stop:1 #3a7ad2);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid #2a6ac2;
|
||||||
|
border-top-color: #5aa0f2;
|
||||||
|
border-left-color: #5aa0f2;
|
||||||
|
}
|
||||||
|
QPushButton[checked="true"]:focus {
|
||||||
|
border: 2px solid #6aa3f5;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
|
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
|
||||||
MAIN_WINDOW_STYLE = f"""
|
MAIN_WINDOW_STYLE = f"""
|
||||||
QWidget {{
|
QWidget {{
|
||||||
|
586
portprotonqt/virtual_keyboard.py
Normal file
586
portprotonqt/virtual_keyboard.py
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
from typing import cast
|
||||||
|
from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout,
|
||||||
|
QSizePolicy, QWidget, QLineEdit)
|
||||||
|
from PySide6.QtCore import Qt, Signal, QProcess
|
||||||
|
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
|
||||||
|
|
||||||
|
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 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))
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
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
|
@@ -27,19 +27,19 @@ classifiers = [
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"babel>=2.17.0",
|
"babel>=2.17.0",
|
||||||
"beautifulsoup4>=4.13.5",
|
"beautifulsoup4>=4.14.2",
|
||||||
"evdev>=1.9.2",
|
"evdev>=1.9.2",
|
||||||
"icoextract>=0.2.0",
|
"icoextract>=0.2.0",
|
||||||
"numpy>=2.2.4",
|
"numpy>=2.2.4",
|
||||||
"orjson>=3.11.2",
|
"orjson>=3.11.3",
|
||||||
"pillow>=11.3.0",
|
"pillow>=11.3.0",
|
||||||
"psutil>=7.0.0",
|
"psutil>=7.1.0",
|
||||||
"pyside6>=6.9.1",
|
"pyside6==6.9.1",
|
||||||
"pyudev>=0.24.3",
|
"pyudev>=0.24.3",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
"tqdm>=4.67.1",
|
"tqdm>=4.67.1",
|
||||||
"vdf>=3.4",
|
"vdf>=3.4",
|
||||||
"websocket-client>=1.8.0",
|
"websocket-client>=1.9.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
@@ -105,5 +105,5 @@ ignore = [
|
|||||||
dev = [
|
dev = [
|
||||||
"pre-commit>=4.3.0",
|
"pre-commit>=4.3.0",
|
||||||
"pyaspeller>=2.0.2",
|
"pyaspeller>=2.0.2",
|
||||||
"pyright>=1.1.404",
|
"pyright>=1.1.406",
|
||||||
]
|
]
|
||||||
|
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:best-practices"],
|
"extends": [
|
||||||
|
"config:best-practices"
|
||||||
|
],
|
||||||
"rebaseWhen": "never",
|
"rebaseWhen": "never",
|
||||||
"lockFileMaintenance": {
|
"lockFileMaintenance": {
|
||||||
"enabled": true
|
"enabled": true
|
||||||
@@ -9,6 +11,23 @@
|
|||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
"packageRules": [
|
"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"],
|
"matchUpdateTypes": ["minor", "patch"],
|
||||||
"automerge": true
|
"automerge": true
|
||||||
@@ -33,7 +52,7 @@
|
|||||||
"groupName": "Python dependencies"
|
"groupName": "Python dependencies"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"matchPackageNames": ["numpy", "setuptools", "python"],
|
"matchPackageNames": ["numpy", "setuptools", "python", "pyside6"],
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
|
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
|
||||||
},
|
},
|
||||||
|
Reference in New Issue
Block a user