47 Commits

Author SHA1 Message Date
ef3f2d6e96 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 21:01:30 +05:00
657d7728a6 fix(gamepad): exit fullscreen on disconnect only if auto-fullscreen enabled and fullscreen disabled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 20:59:51 +05:00
9452bfda2e chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
7eb2db0d68 chore localization update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
6ef7a03366 feat: added search to controller hints
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
e5af354b56 fix(virtual-keyboard): turn off caps lock when disabling shift while caps is enabled
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
e6e5f6c8ea feat(virtual_keyboard): make keyboard bigger
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
84306bb31b feat(virtual_keyboard): added dpad reapeat movement
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
60af4d1482 feat(virtual_keyboard): press X to backspace
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
692e11b21d chore(virtual_keyboard): move styles to style.py
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
b1a804811e chore(keyboard): drop connect_keyboard_to_lineedit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
9a30cfaea7 chore(keyboard): drop unneded key events
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
5dd2f71f5e feat: added virtual keyboard
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 11:41:53 +00:00
dba172361b fix(ui): resolve layout issues during search filtering
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 12:52:34 +05:00
a9c70b8818 chore(winetricks): use curl for download
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 08:59:03 +05:00
135ace732f chore(deps): added Winetricks deps copied from upstream control
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-09 08:47:22 +05:00
8b727f64e1 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:26:21 +05:00
a8eb591da5 fix: update ControlHints and NavButtons together
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:23:58 +05:00
fe4ca1ee87 fix: revert signals to pyside 6.9.1
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:12:37 +05:00
ffe3e9d3d6 chore(deps): revert Pyside6 to 6.9.1
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:04:45 +05:00
49d39b5d61 chore(pyright): fix code for new version
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 18:37:31 +05:00
Renovate Bot
03566da704 fix(deps): lock file maintenance python dependencies 2025-10-08 18:21:53 +05:00
Renovate Bot
7f996ab6a0 chore(deps): update archlinux:base-devel docker digest to b380991 2025-10-08 12:09:42 +00:00
Renovate Bot
9e17978155 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 17c8966 2025-10-08 12:05:09 +00:00
5d0185b1b4 feat(winetricks): added preloader to tabble
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 16:41:32 +05:00
5c134be04e chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:54:05 +05:00
8c66695192 chore(winetricks): fix typo on translate and added forget icon
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:49:48 +05:00
7a141d8e46 fix(winetricks): resolve QProcess channel mode warning in install handler
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:43:16 +05:00
abb2377fb7 fix(winetricks): remove duplicate entries in winetricks.log
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:37:41 +05:00
75f4f346de chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:27:31 +05:00
87a9f85272 feat(wine settings): make winetricks work with gamepad
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 15:18:48 +05:00
240f685ece feat(wine settings): make winetricks work
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-07 12:06:35 +05:00
af4e3e95bb chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:57:52 +05:00
017d9a42cf feat(wine settings): make prefix and wine delete work
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:55:24 +05:00
18b7c4054b chore(localization): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:47:45 +05:00
dd7f71b70a feat(wine settings): make pfx_backup work
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 17:29:06 +05:00
8fd44c575b fix: expose gamesListWidget from GameLibraryManager to fix gamepad navigation
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 13:21:58 +05:00
65b43c1572 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 00:04:55 +05:00
f35276abfe fix: reject candidate if normalized name equals "game"
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-06 00:02:06 +05:00
6fea9a9a7e chore(wine settings): rework layout
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-05 20:01:00 +05:00
5189474631 feat(wine settings): initial introdouce
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-05 16:19:06 +05:00
Renovate Bot
416cc6a268 chore(deps): update archlinux:base-devel docker digest to 5d95edc 2025-10-05 08:20:07 +00:00
Renovate Bot
3b44ed5252 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to e459af1 2025-10-05 00:01:07 +00:00
c8c45dda06 chore(readme): drop Those Awesome Guys
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-04 20:54:57 +05:00
3f9f794e6f hint icons 2025-10-04 22:12:10 +07:00
ba9d8b76d8 chore(changelog): update
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:31:01 +05:00
e99c71c1f8 feat: optimize search
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-02 16:29:18 +05:00
77 changed files with 3353 additions and 701 deletions

View File

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

View File

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

View File

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

View File

@@ -7,13 +7,22 @@
### Added
- Возможность скроллинга библиотеки мышью или пальцем
- Импорт и экспорт бекапа префикса
- Диалог для управление Winetricks
- Кнопки для удаления префикса, wine или proton
- Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке
### Changed
- Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр
- В диалог выбора файлов в режиме directory_only (при выборе куда сохранить бекап префикса) добавлена кнопка ./ обозначающая нынешнюю папку
### Fixed
- Исправлен вылет диалога выбора файлов при выборе обложки если в папке более сотни изображений
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
- Исправлен выход из полноэкранного режима при отключении геймпада подключённого по USB даже если настройка "Режим полноэкранного отображения приложения при подключении геймпада" выключена
### Contributors
@@ -38,6 +47,7 @@
### Contributors
- @wmigor (Igor Akulov)
- @Vector_null
---

View File

@@ -54,8 +54,6 @@ PortProtonQt использует код и зависимости от след
- [Legendary](https://github.com/derrod/legendary) — инструмент для работы с Epic Games Store, лицензия [GPL-3.0](https://github.com/derrod/legendary/blob/master/LICENSE).
- [Icoextract](https://github.com/jlu5/icoextract) — библиотека для извлечения иконок, лицензия [MIT](https://github.com/jlu5/icoextract/blob/master/LICENSE).
- [HowLongToBeat Python API](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI) — библиотека для взаимодействия с HowLongToBeat, лицензия [MIT](https://github.com/ScrappyCocco/HowLongToBeat-PythonAPI/blob/master/LICENSE.md).
- [Those Awesome Guys: Gamepad prompts images](https://thoseawesomeguys.com/prompts/) - Набор подсказок для геймпада и клавиатур, лицензия [CC0](https://creativecommons.org/public-domain/cc0/)
Полный текст лицензий см. в файле [LICENSE](LICENSE).
> [!WARNING]

View File

@@ -54,6 +54,11 @@ AppDir:
- libxcb-cursor0
- libimage-exiftool-perl
- xdg-utils
- cabextract
- curl
- 7zip
- unzip
- unrar
exclude:
- "*-doc"
- "*-man"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,11 +2,13 @@ import os
import tempfile
import re
from typing import cast, TYPE_CHECKING
from PySide6.QtGui import QPixmap, QIcon
from PySide6.QtGui import QPixmap, QIcon, QTextCursor
from PySide6.QtWidgets import (
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller
QDialog, QFormLayout, QHBoxLayout, QLabel, QVBoxLayout, QListWidget, QScrollArea, QWidget, QListWidgetItem, QSizePolicy, QApplication, QProgressBar, QScroller,
QTabWidget, QTableWidget, QHeaderView, QMessageBox, QTableWidgetItem, QTextEdit, QAbstractItemView, QStackedWidget
)
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot
from PySide6.QtCore import Qt, QObject, Signal, QMimeDatabase, QTimer, QThreadPool, QRunnable, Slot, QProcess, QProcessEnvironment
from icoextract import IconExtractor, IconExtractorError
from PIL import Image
from portprotonqt.config_utils import get_portproton_location, read_favorite_folders, read_theme_from_config
@@ -15,6 +17,8 @@ from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.preloader import Preloader
import psutil
if TYPE_CHECKING:
@@ -507,8 +511,8 @@ class FileExplorer(QDialog):
"""Update the list of mounted drives and favorite folders."""
for i in reversed(range(self.drives_layout.count())):
item = self.drives_layout.itemAt(i)
if item and item.widget():
widget = item.widget()
widget = item.widget() if item else None
if widget:
self.drives_layout.removeWidget(widget)
widget.deleteLater()
@@ -597,6 +601,16 @@ class FileExplorer(QDialog):
self.thumbnail_cache.clear() # Clear cache when changing directories
self.pending_thumbnails.clear() # Clear pending thumbnails
try:
if self.directory_only:
item = QListWidgetItem("./")
folder_icon = theme_manager.get_icon("folder")
# Ensure the icon is a QIcon
if isinstance(folder_icon, str) and os.path.isfile(folder_icon):
folder_icon = QIcon(folder_icon)
elif not isinstance(folder_icon, QIcon):
folder_icon = QIcon() # Fallback to empty icon
item.setIcon(folder_icon)
self.file_list.addItem(item)
if self.current_path != "/":
item = QListWidgetItem("../")
folder_icon = theme_manager.get_icon("folder")
@@ -804,6 +818,60 @@ class AddGameDialog(QDialog):
if edit_mode:
self.updatePreview()
# Инициализация клавиатуры (отдельным методом вроде лучше)
self.init_keyboard()
# Устанавливаем фокус на первое поле при открытии
QTimer.singleShot(0, self.nameEdit.setFocus)
def init_keyboard(self):
"""Инициализация виртуальной клавиатуры"""
self.keyboard = VirtualKeyboard(self, theme=self.theme, button_width=40)
self.keyboard.hide()
def show_keyboard_for_widget(self, widget):
"""Показывает клавиатуру для указанного виджета"""
if not widget or not widget.isVisible():
return
# Устанавливаем текущий виджет ввода
self.keyboard.current_input_widget = widget
# Позиционирование клавиатуры
keyboard_height = 220
self.keyboard.setFixedWidth(self.width())
self.keyboard.setFixedHeight(keyboard_height)
self.keyboard.move(0, self.height() - keyboard_height)
# Показываем и поднимаем клавиатуру
self.keyboard.setParent(self)
self.keyboard.show()
self.keyboard.raise_()
# TODO: доработать.
# Устанавливаем фокус на первую кнопку клавиатуры
first_button = self.keyboard.findFirstFocusableButton()
if first_button:
QTimer.singleShot(50, lambda: first_button.setFocus())
def closeEvent(self, event):
"""Обработчик закрытия окна"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().closeEvent(event)
def reject(self):
"""Обработчик кнопки Cancel"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().reject()
def accept(self):
"""Обработчик кнопки Apply"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().accept()
def browseExe(self):
"""Открывает файловый менеджер для выбора exe-файла"""
try:
@@ -967,3 +1035,465 @@ Icon={icon_path}
"""
return desktop_entry, desktop_path
class WinetricksDialog(QDialog):
"""Dialog for managing Winetricks components in a prefix."""
def __init__(self, parent=None, theme=None, prefix_path: str | None = None, wine_use: str | None = None):
super().__init__(parent)
self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config())
self.prefix_path: str | None = prefix_path
self.wine_use: str | None = wine_use
self.portproton_path = get_portproton_location()
if self.portproton_path is None:
logger.error("PortProton location not found")
return
self.tmp_path = os.path.join(self.portproton_path, "data", "tmp")
os.makedirs(self.tmp_path, exist_ok=True)
self.winetricks_path = os.path.join(self.tmp_path, "winetricks")
if self.prefix_path is None:
logger.error("Prefix path not provided")
return
self.log_path = os.path.join(self.prefix_path, "winetricks.log")
os.makedirs(os.path.dirname(self.log_path), exist_ok=True)
if not os.path.exists(self.log_path):
open(self.log_path, 'a').close()
self.downloader = Downloader(max_workers=4)
self.apply_process: QProcess | None = None
self.setWindowTitle(_("Prefix Manager"))
self.setModal(True)
self.resize(700, 700)
self.setStyleSheet(self.theme.MAIN_WINDOW_STYLE + self.theme.MESSAGE_BOX_STYLE)
self.update_winetricks()
self.setup_ui()
self.load_lists()
def update_winetricks(self):
"""Update the winetricks script."""
if not self.downloader.has_internet():
logger.warning("No internet connection, skipping winetricks update")
return
url = "https://raw.githubusercontent.com/Winetricks/winetricks/master/src/winetricks"
temp_path = os.path.join(self.tmp_path, "winetricks_temp")
try:
self.downloader.download(url, temp_path)
with open(temp_path) as f:
ext_content = f.read()
ext_ver_match = re.search(r'WINETRICKS_VERSION\s*=\s*[\'"]?([^\'"\s]+)', ext_content)
ext_ver = ext_ver_match.group(1) if ext_ver_match else None
logger.info(f"External winetricks version: {ext_ver}")
except Exception as e:
logger.error(f"Failed to get external version: {e}")
ext_ver = None
if os.path.exists(temp_path):
os.remove(temp_path)
return
int_ver = None
if os.path.exists(self.winetricks_path):
try:
with open(self.winetricks_path) as f:
int_content = f.read()
int_ver_match = re.search(r'WINETRICKS_VERSION\s*=\s*[\'"]?([^\'"\s]+)', int_content)
int_ver = int_ver_match.group(1) if int_ver_match else None
logger.info(f"Internal winetricks version: {int_ver}")
except Exception as e:
logger.error(f"Failed to read internal winetricks version: {e}")
update_needed = not os.path.exists(self.winetricks_path) or (int_ver != ext_ver and ext_ver)
if update_needed:
try:
self.downloader.download(url, self.winetricks_path)
os.chmod(self.winetricks_path, 0o755)
logger.info(f"Winetricks updated to version {ext_ver}")
self.apply_modifications(self.winetricks_path)
except Exception as e:
logger.error(f"Failed to update winetricks: {e}")
elif os.path.exists(self.winetricks_path):
self.apply_modifications(self.winetricks_path)
if os.path.exists(temp_path):
os.remove(temp_path)
def apply_modifications(self, file_path):
"""Apply custom modifications to the winetricks script."""
if not os.path.exists(file_path):
return
try:
with open(file_path) as f:
content = f.read()
# Apply sed-like replacements
content = re.sub(r'w_metadata vcrun2015 dlls \\', r'w_metadata !dont_use_2015! dlls \\', content)
content = re.sub(r'w_metadata vcrun2017 dlls \\', r'w_metadata !dont_use_2017! dlls \\', content)
content = re.sub(r'w_metadata vcrun2019 dlls \\', r'w_metadata !dont_use_2019! dlls \\', content)
content = re.sub(r'w_set_winver win2k3', r'w_set_winver win7', content)
with open(file_path, 'w') as f:
f.write(content)
logger.info("Winetricks modifications applied")
except Exception as e:
logger.error(f"Error applying modifications to winetricks: {e}")
def setup_ui(self):
"""Set up the user interface with tabs and tables."""
main_layout = QVBoxLayout(self)
main_layout.setContentsMargins(10, 10, 10, 10)
main_layout.setSpacing(10)
# Log output
self.log_output = QTextEdit()
self.log_output.setReadOnly(True)
self.log_output.setStyleSheet(self.theme.WINETRICKS_LOG_STYLE)
main_layout.addWidget(self.log_output)
# Tab widget
self.tab_widget = QTabWidget()
self.tab_widget.setStyleSheet(self.theme.WINETRICKS_TAB_STYLE)
table_base_style = self.theme.WINETRICKS_TABBLE_STYLE
# DLLs tab
self.dll_table = QTableWidget()
self.dll_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.dll_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.dll_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.dll_table.setColumnCount(3)
self.dll_table.setHorizontalHeaderLabels([_("Set"), _("Libraries"), _("Information")])
self.dll_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.dll_table.horizontalHeader().resizeSection(0, 50)
self.dll_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.dll_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.dll_table.setStyleSheet(table_base_style)
self.dll_preloader = Preloader()
dll_preloader_container = QWidget()
dll_preloader_layout = QVBoxLayout(dll_preloader_container)
dll_preloader_layout.addStretch()
dll_preloader_hlayout = QHBoxLayout()
dll_preloader_hlayout.addStretch()
dll_preloader_hlayout.addWidget(self.dll_preloader)
dll_preloader_hlayout.addStretch()
dll_preloader_layout.addLayout(dll_preloader_hlayout)
dll_preloader_layout.addStretch()
dll_preloader_layout.setContentsMargins(0, 0, 0, 0)
dll_preloader_layout.setSpacing(0)
self.dll_container = QStackedWidget()
self.dll_container.addWidget(dll_preloader_container)
self.dll_container.addWidget(self.dll_table)
self.tab_widget.addTab(self.dll_container, "DLLs")
# Fonts tab
self.fonts_table = QTableWidget()
self.fonts_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.fonts_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.fonts_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.fonts_table.setColumnCount(3)
self.fonts_table.setHorizontalHeaderLabels([_("Set"), _("Fonts"), _("Information")])
self.fonts_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.fonts_table.horizontalHeader().resizeSection(0, 50)
self.fonts_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.fonts_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.fonts_table.setStyleSheet(table_base_style)
self.fonts_preloader = Preloader()
fonts_preloader_container = QWidget()
fonts_preloader_layout = QVBoxLayout(fonts_preloader_container)
fonts_preloader_layout.addStretch()
fonts_preloader_hlayout = QHBoxLayout()
fonts_preloader_hlayout.addStretch()
fonts_preloader_hlayout.addWidget(self.fonts_preloader)
fonts_preloader_hlayout.addStretch()
fonts_preloader_layout.addLayout(fonts_preloader_hlayout)
fonts_preloader_layout.addStretch()
fonts_preloader_layout.setContentsMargins(0, 0, 0, 0)
fonts_preloader_layout.setSpacing(0)
self.fonts_container = QStackedWidget()
self.fonts_container.addWidget(fonts_preloader_container)
self.fonts_container.addWidget(self.fonts_table)
self.tab_widget.addTab(self.fonts_container, _("Fonts"))
# Settings tab
self.settings_table = QTableWidget()
self.settings_table.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.settings_table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.settings_table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
self.settings_table.setColumnCount(3)
self.settings_table.setHorizontalHeaderLabels([_("Set"), _("Settings"), _("Information")])
self.settings_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.Fixed)
self.settings_table.horizontalHeader().resizeSection(0, 50)
self.settings_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch)
self.settings_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch)
self.settings_table.setStyleSheet(table_base_style)
self.settings_preloader = Preloader()
settings_preloader_container = QWidget()
settings_preloader_layout = QVBoxLayout(settings_preloader_container)
settings_preloader_layout.addStretch()
settings_preloader_hlayout = QHBoxLayout()
settings_preloader_hlayout.addStretch()
settings_preloader_hlayout.addWidget(self.settings_preloader)
settings_preloader_hlayout.addStretch()
settings_preloader_layout.addLayout(settings_preloader_hlayout)
settings_preloader_layout.addStretch()
settings_preloader_layout.setContentsMargins(0, 0, 0, 0)
settings_preloader_layout.setSpacing(0)
self.settings_container = QStackedWidget()
self.settings_container.addWidget(settings_preloader_container)
self.settings_container.addWidget(self.settings_table)
self.tab_widget.addTab(self.settings_container, _("Settings"))
self.containers = {
"dlls": self.dll_container,
"fonts": self.fonts_container,
"settings": self.settings_container
}
main_layout.addWidget(self.tab_widget)
# Buttons
button_layout = QHBoxLayout()
button_layout.setSpacing(10)
self.cancel_button = AutoSizeButton(_("Cancel"), icon=theme_manager.get_icon("cancel"))
self.force_button = AutoSizeButton(_("Force Install"), icon=theme_manager.get_icon("apply"))
self.install_button = AutoSizeButton(_("Install"), icon=theme_manager.get_icon("apply"))
self.cancel_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.force_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
self.install_button.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
button_layout.addWidget(self.cancel_button)
button_layout.addWidget(self.force_button)
button_layout.addWidget(self.install_button)
main_layout.addLayout(button_layout)
self.cancel_button.clicked.connect(self.reject)
self.force_button.clicked.connect(lambda: self.install_selected(force=True))
self.install_button.clicked.connect(lambda: self.install_selected(force=False))
def load_lists(self):
"""Load and populate the lists for DLLs, Fonts, and Settings"""
if not os.path.exists(self.winetricks_path):
QMessageBox.warning(self, _("Error"), _("Winetricks not found. Please try again."))
self.reject()
return
assert self.prefix_path is not None
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", self.prefix_path)
env.insert("WINETRICKS_DOWNLOADER", "curl")
if self.wine_use is not None:
env.insert("WINE", self.wine_use)
cwd = os.path.dirname(self.winetricks_path)
# DLLs
self.containers["dlls"].setCurrentIndex(0)
self._start_list_process("dlls", self.dll_table, self.get_dll_exclusions(), env, cwd)
# Fonts
self.containers["fonts"].setCurrentIndex(0)
self._start_list_process("fonts", self.fonts_table, self.get_fonts_exclusions(), env, cwd)
# Settings
self.containers["settings"].setCurrentIndex(0)
self._start_list_process("settings", self.settings_table, self.get_settings_exclusions(), env, cwd)
def _start_list_process(self, category, table, exclusion_pattern, env, cwd):
"""Запускает QProcess для списка."""
process = QProcess(self)
process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
process.setProcessEnvironment(env)
process.finished.connect(lambda exit_code, exit_status: self._on_list_finished(category, table, exclusion_pattern, process, exit_code, exit_status))
process.start(self.winetricks_path, [category, "list"])
def _on_list_finished(self, category, table, exclusion_pattern, process: QProcess | None, exit_code, exit_status):
"""Обработчик завершения списка."""
if process is None:
logger.error(f"Process is None for {category}")
self.containers[category].setCurrentIndex(1)
return
output = bytes(process.readAllStandardOutput().data()).decode('utf-8', 'ignore')
if exit_code == 0 and exit_status == QProcess.ExitStatus.NormalExit:
self.populate_table(table, output, exclusion_pattern, self.log_path)
# Restore focus after populating
if table.rowCount() > 0:
table.setCurrentCell(0, 0)
table.setFocus(Qt.FocusReason.OtherFocusReason)
else:
error_output = bytes(process.readAllStandardError().data()).decode('utf-8', 'ignore')
logger.error(f"Failed to list {category}: {error_output}")
self.containers[category].setCurrentIndex(1)
def get_dll_exclusions(self):
"""Get regex pattern for DLL exclusions."""
return r'(d3d|directx9|dont_use|dxvk|vkd3d|galliumnine|faudio1|Foundation)'
def get_fonts_exclusions(self):
"""Get regex pattern for Fonts exclusions."""
return r'dont_use'
def get_settings_exclusions(self):
"""Get regex pattern for Settings exclusions."""
return r'(vista|alldlls|autostart_|bad|good|win|videomemory|vd=|isolate_home)'
def populate_table(self, table, output, exclusion_pattern, log_path):
"""Populate the table with items from output, checking installation status."""
table.setRowCount(0)
table.verticalHeader().setVisible(False)
lines = output.strip().split('\n')
installed = set()
if os.path.exists(log_path):
with open(log_path) as f:
for line in f:
installed.add(line.strip())
# regex-парсинг (имя - первое слово, остальное - описание)
line_re = re.compile(r"^\s*(?:\[(.)]\s+)?([^\s]+)\s*(.*)")
for line in lines:
line = line.strip()
if not line or re.search(exclusion_pattern, line, re.I):
continue
line = line.split('(', 1)[0].strip()
match = line_re.match(line)
if not match:
continue
_status, name, info = match.groups()
# Очищаем info от мусора
info = re.sub(r'\[.*?\]', '', info).strip() # Удаляем [скачивания] и т.п.
# To match bash desc extraction: after name, substr(2) to trim leading space
if info.startswith(' '):
info = info[1:].lstrip()
# Фильтр служебных строк
if '/' in name or '\\' in name or name.lower() in ('executing', 'using', 'warning:') or name.endswith(':'):
continue
checked = Qt.CheckState.Checked if name in installed else Qt.CheckState.Unchecked
row = table.rowCount()
table.insertRow(row)
# Checkbox
checkbox = QTableWidgetItem()
checkbox.setCheckState(checked)
table.setItem(row, 0, checkbox)
# Name
name_item = QTableWidgetItem(name)
table.setItem(row, 1, name_item)
# Info
info_item = QTableWidgetItem(info)
table.setItem(row, 2, info_item)
def install_selected(self, force=False):
"""Install selected components."""
selected = []
for table in [self.dll_table, self.fonts_table, self.settings_table]:
for row in range(table.rowCount()):
checkbox = table.item(row, 0)
if checkbox is not None and checkbox.checkState() == Qt.CheckState.Checked:
name_item = table.item(row, 1)
if name_item is not None:
name = name_item.text()
if name and name not in selected:
selected.append(name)
# Load installed
installed = set()
if os.path.exists(self.log_path):
with open(self.log_path) as f:
for line in f:
installed.add(line.strip())
# Filter to new selected
new_selected = [name for name in selected if name not in installed]
if not new_selected:
QMessageBox.information(self, _("Warning"), _("No components selected."))
return
self.install_button.setEnabled(False)
self.force_button.setEnabled(False)
self.cancel_button.setEnabled(False)
self._start_install_process(new_selected, force)
def _start_install_process(self, selected, force):
"""Запускает QProcess для установки."""
assert self.prefix_path is not None
env = QProcessEnvironment.systemEnvironment()
env.insert("WINEPREFIX", self.prefix_path)
if self.wine_use is not None:
env.insert("WINE", self.wine_use)
self.apply_process = QProcess(self)
self.apply_process.setProcessChannelMode(QProcess.ProcessChannelMode.MergedChannels)
self.apply_process.setProcessEnvironment(env)
self.apply_process.readyReadStandardOutput.connect(self._on_ready_read)
self.apply_process.finished.connect(lambda exit_code, exit_status: self._on_install_finished(exit_code, exit_status, selected))
args = ["--unattended"] + (["--force"] if force else []) + selected
self.apply_process.start(self.winetricks_path, args)
def _on_ready_read(self):
"""Handle ready read for install process."""
if self.apply_process is None:
return
data = self.apply_process.readAllStandardOutput().data()
message = bytes(data).decode('utf-8', 'ignore').strip()
self._log(message)
def _on_install_finished(self, exit_code, exit_status, selected):
"""Обработчик завершения установки."""
error_message = ""
if self.apply_process is not None:
# Читаем вывод в зависимости от режима каналов
if self.apply_process.processChannelMode() == QProcess.ProcessChannelMode.MergedChannels:
# Если каналы объединены, читаем из StandardOutput
output_data = self.apply_process.readAllStandardOutput().data()
error_message = bytes(output_data).decode('utf-8', 'ignore')
else:
# Если каналы разделены, читаем из StandardError
error_data = self.apply_process.readAllStandardError().data()
error_message = bytes(error_data).decode('utf-8', 'ignore')
if exit_code != 0 or exit_status != QProcess.ExitStatus.NormalExit:
logger.error(f"Winetricks install failed: {error_message}")
QMessageBox.warning(self, _("Error"), _("Installation failed. Check logs."))
else:
if os.path.exists(self.log_path):
with open(self.log_path) as f:
existing = {line.strip() for line in f if line.strip()}
else:
existing = set()
with open(self.log_path, 'a') as f:
for name in selected:
if name not in existing:
f.write(f"{name}\n")
logger.info("Winetricks installation completed successfully.")
QMessageBox.information(self, _("Success"), _("Components installed successfully."))
self.load_lists()
# Разблокировка
self.install_button.setEnabled(True)
self.force_button.setEnabled(True)
self.cancel_button.setEnabled(True)
def _log(self, message):
"""Добавляет в лог."""
self.log_output.append(message)
self.log_output.moveCursor(QTextCursor.MoveOperation.End)

View File

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

View File

@@ -35,6 +35,7 @@ class MainWindowProtocol(Protocol):
_last_card_width: int
current_hovered_card: GameCard | None
current_focused_card: GameCard | None
gamesListWidget: QWidget | None
class GameLibraryManager:
def __init__(self, main_window: MainWindowProtocol, theme, context_menu_manager: ContextMenuManager | None):
@@ -51,8 +52,9 @@ class GameLibraryManager:
self.sizeSlider: QSlider | None = None
self._update_timer: QTimer | None = None
self._pending_update = False
self.pending_deletions = deque() # Queue for deferred widget deletion
self.dirty = False # Flag for when full resort is needed
self.pending_deletions = deque()
self.is_filtering = False
self.dirty = False
def create_games_library_widget(self):
"""Creates the games library widget with search, grid, and slider."""
@@ -201,10 +203,13 @@ class GameLibraryManager:
self._pending_update = False
self._update_game_grid_immediate()
def update_game_grid(self, games_list: list[tuple] | None = None):
def update_game_grid(self, games_list: list[tuple] | None = None, is_filter: bool = False):
"""Schedules a game grid update with debouncing."""
if games_list is not None:
self.filtered_games = games_list
if not is_filter:
if games_list is not None:
self.filtered_games = games_list
self.dirty = True # Full rebuild only for non-filter
self.is_filtering = is_filter
self._pending_update = True
if self._update_timer is not None:
@@ -217,123 +222,158 @@ class GameLibraryManager:
if self.gamesListLayout is None or self.gamesListWidget is None:
return
games_list = self.filtered_games if self.filtered_games else self.games
search_text = self.main_window.searchEdit.text().strip().lower()
favorites = read_favorites()
sort_method = read_sort_method()
# Batch layout updates (extended scope)
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
if self.is_filtering:
# Filter mode: do not change layout, only hide/show cards
self._apply_filter_visibility(search_text)
else:
# Full update: sorting, removal/addition, reorganization
games_list = self.filtered_games if self.filtered_games else self.games
favorites = read_favorites()
sort_method = read_sort_method()
try:
# Optimized sorting: Partition favorites first, then sort subgroups
def partition_sort_key(game):
name = game[0]
is_fav = name in favorites
fav_order = 0 if is_fav else 1
if sort_method == "playtime":
return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0)
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
# Batch layout updates (extended scope)
self.gamesListWidget.setUpdatesEnabled(False)
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(False) # Disable layout during batch
try:
# Optimized sorting: Partition favorites first, then sort subgroups
def partition_sort_key(game):
name = game[0]
is_fav = name in favorites
fav_order = 0 if is_fav else 1
if sort_method == "playtime":
return (fav_order, -game[11] if game[11] else 0, -game[10] if game[10] else 0)
elif sort_method == "alphabetical":
return (fav_order, name.lower())
elif sort_method == "favorites":
return (fav_order,)
else:
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
# Quick partition: Sort favorites and non-favorites separately, then merge
fav_games = [g for g in games_list if g[0] in favorites]
non_fav_games = [g for g in games_list if g[0] not in favorites]
sorted_fav = sorted(fav_games, key=partition_sort_key)
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
sorted_games = sorted_fav + sorted_non_fav
# Build set of current game keys for faster lookup
current_game_keys = {(game[0], game[4]) for game in sorted_games}
# Remove cards that no longer exist (batch)
cards_to_remove = []
for card_key in list(self.game_card_cache.keys()):
if card_key not in current_game_keys:
cards_to_remove.append(card_key)
for card_key in cards_to_remove:
card = self.game_card_cache.pop(card_key)
if self.gamesListLayout is not None:
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer
if card_key in self.pending_images:
del self.pending_images[card_key]
# Track current layout order (only if dirty/full update needed)
if self.dirty and self.gamesListLayout is not None:
current_layout_order = []
for i in range(self.gamesListLayout.count()):
item = self.gamesListLayout.itemAt(i)
if item is not None:
widget = item.widget()
if widget:
for key, card in self.game_card_cache.items():
if card == widget:
current_layout_order.append(key)
break
else:
return (fav_order, -game[10] if game[10] else 0, -game[11] if game[11] else 0)
current_layout_order = None # Skip reorg if not dirty
# Quick partition: Sort favorites and non-favorites separately, then merge
fav_games = [g for g in games_list if g[0] in favorites]
non_fav_games = [g for g in games_list if g[0] not in favorites]
sorted_fav = sorted(fav_games, key=partition_sort_key)
sorted_non_fav = sorted(non_fav_games, key=partition_sort_key)
sorted_games = sorted_fav + sorted_non_fav
new_card_order = []
cards_to_add = []
# Build set of current game keys for faster lookup
current_game_keys = {(game[0], game[4]) for game in sorted_games}
for game_data in sorted_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
should_be_visible = not search_text or search_text in game_name.lower()
# Remove cards that no longer exist (batch)
cards_to_remove = []
for card_key in list(self.game_card_cache.keys()):
if card_key not in current_game_keys:
cards_to_remove.append(card_key)
if game_key in self.game_card_cache:
card = self.game_card_cache[game_key]
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
new_card_order.append(game_key)
else:
if self.context_menu_manager is None:
continue
for card_key in cards_to_remove:
card = self.game_card_cache.pop(card_key)
if self.gamesListLayout is not None:
self.gamesListLayout.removeWidget(card)
self.pending_deletions.append(card) # Defer
if card_key in self.pending_images:
del self.pending_images[card_key]
# Track current layout order (only if dirty/full update needed)
if self.dirty and self.gamesListLayout is not None:
current_layout_order = []
for i in range(self.gamesListLayout.count()):
item = self.gamesListLayout.itemAt(i)
if item is not None:
widget = item.widget()
if widget:
for key, card in self.game_card_cache.items():
if card == widget:
current_layout_order.append(key)
break
else:
current_layout_order = None # Skip reorg if not dirty
new_card_order = []
cards_to_add = []
for game_data in sorted_games:
game_name = game_data[0]
exec_line = game_data[4]
game_key = (game_name, exec_line)
should_be_visible = not search_text or search_text in game_name.lower()
if game_key in self.game_card_cache:
card = self.game_card_cache[game_key]
if card.isVisible() != should_be_visible:
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(should_be_visible)
new_card_order.append(game_key)
else:
if self.context_menu_manager is None:
continue
new_card_order.append(game_key)
cards_to_add.append((game_key, card))
card = self._create_game_card(game_data)
self.game_card_cache[game_key] = card
card.setVisible(should_be_visible)
new_card_order.append(game_key)
cards_to_add.append((game_key, card))
# Only reorganize if order changed AND dirty
if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order):
# Remove all widgets from layout (batch)
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
# Only reorganize if order changed AND dirty
if self.dirty and self.gamesListLayout is not None and (current_layout_order is None or new_card_order != current_layout_order):
# Remove all widgets from layout (batch)
while self.gamesListLayout.count():
self.gamesListLayout.takeAt(0)
# Add widgets in new order (batch)
for game_key in new_card_order:
card = self.game_card_cache[game_key]
self.gamesListLayout.addWidget(card)
# Add widgets in new order (batch)
for game_key in new_card_order:
card = self.game_card_cache[game_key]
self.gamesListLayout.addWidget(card)
self.dirty = False # Reset flag
self.dirty = False # Reset flag
# Deferred deletions (run in timer to avoid stack overflow)
if self.pending_deletions:
QTimer.singleShot(0, lambda: self._flush_deletions())
# Deferred deletions (run in timer to avoid stack overflow)
if self.pending_deletions:
QTimer.singleShot(0, lambda: self._flush_deletions())
# Load visible images for new cards only
if cards_to_add:
self.load_visible_images()
# Load visible images for new cards only
if cards_to_add:
self.load_visible_images()
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
finally:
if self.gamesListLayout is not None:
self.gamesListLayout.setEnabled(True)
self.gamesListWidget.setUpdatesEnabled(True)
if self.gamesListLayout is not None:
self.gamesListLayout.update()
self.is_filtering = False # Reset flag in any case
def _apply_filter_visibility(self, search_text: str):
"""Applies visibility to cards based on search, without changing the layout."""
visible_count = 0
for game_key, card in self.game_card_cache.items():
game_name = card.name # Assume GameCard has 'name' attribute
should_be_visible = not search_text or search_text in game_name.lower()
if card.isVisible() != should_be_visible:
card.setVisible(should_be_visible)
if should_be_visible:
visible_count += 1
# Load image only for newly visible cards
if game_key in self.pending_images:
cover_path, width, height, callback = self.pending_images.pop(game_key)
load_pixmap_async(cover_path, width, height, callback)
# Force full relayout after visibility changes
if self.gamesListLayout is not None:
self.gamesListLayout.invalidate() # Принудительно инвалидируем для пересчёта
self.gamesListLayout.update()
if self.gamesListWidget is not None:
self.gamesListWidget.updateGeometry()
self.main_window._last_card_width = self.card_width
self.main_window._last_card_width = self.card_width
# If search is empty, load images for visible ones
if not search_text:
self.load_visible_images()
def _create_game_card(self, game_data: tuple) -> GameCard:
"""Creates a new game card with all necessary connections."""
@@ -412,9 +452,4 @@ class GameLibraryManager:
def filter_games_delayed(self):
"""Filters games based on search text and updates the grid."""
text = self.main_window.searchEdit.text().strip().lower()
if text == "":
self.filtered_games = self.games
else:
self.filtered_games = [game for game in self.games if text in game[0].lower()]
self.update_game_grid(self.filtered_games)
self.update_game_grid(is_filter=True)

View File

@@ -5,7 +5,7 @@ from typing import Protocol, cast
from evdev import InputDevice, InputEvent, ecodes, list_devices, ff
from enum import Enum
from pyudev import Context, Monitor, MonitorObserver, Device
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget
from PySide6.QtWidgets import QWidget, QStackedWidget, QApplication, QScrollArea, QLineEdit, QDialog, QMenu, QComboBox, QListView, QMessageBox, QListWidget, QTableWidget, QAbstractItemView
from PySide6.QtCore import Qt, QObject, QEvent, QPoint, Signal, Slot, QTimer
from PySide6.QtGui import QKeyEvent, QMouseEvent
from portprotonqt.logger import get_logger
@@ -13,7 +13,8 @@ from portprotonqt.image_utils import FullscreenDialog
from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
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.dialogs import AddGameDialog
from portprotonqt.dialogs import AddGameDialog, WinetricksDialog
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
@@ -71,7 +72,7 @@ class InputManager(QObject):
for seamless UI interaction.
"""
# 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
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)
# 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.toggle_fullscreen.connect(self.handle_fullscreen_slot)
@@ -201,7 +202,9 @@ class InputManager(QObject):
except Exception as 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:
popup = QApplication.activePopupWidget()
if isinstance(popup, QMenu):
@@ -441,8 +444,33 @@ class InputManager(QObject):
except Exception as e:
logger.error(f"Error stopping rumble: {e}", exc_info=True)
@Slot(int)
def handle_button_slot(self, button_code: int) -> None:
@Slot(int, int)
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:
return
try:
@@ -455,6 +483,21 @@ class InputManager(QObject):
if not app or not active:
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
if button_code in BUTTONS['guide']:
if not popup and not isinstance(active, QDialog):
@@ -551,6 +594,39 @@ class InputManager(QObject):
self._parent.toggleGame(self._parent.current_exec_line, None)
return
if isinstance(active, WinetricksDialog):
if button_code in BUTTONS['confirm']: # A button - toggle checkbox
current_table = active.tab_widget.currentWidget()
if isinstance(current_table, QTableWidget):
current_row = current_table.currentRow()
if current_row >= 0:
checkbox = current_table.item(current_row, 0)
if checkbox:
checkbox.setCheckState(
Qt.CheckState.Unchecked if checkbox.checkState() == Qt.CheckState.Checked else Qt.CheckState.Checked
)
return
elif button_code in BUTTONS['add_game']: # X button - install
active.install_selected(force=False)
return
elif button_code in BUTTONS['prev_dir']: # Y button - force install
active.install_selected(force=True)
return
elif button_code in BUTTONS['back']: # B button - close dialog
active.reject()
return
elif button_code in BUTTONS['prev_tab']: # LB - previous tab
current_idx = active.tab_widget.currentIndex()
new_idx = (current_idx - 1) % active.tab_widget.count()
active.tab_widget.setCurrentIndex(new_idx)
return
elif button_code in BUTTONS['next_tab']: # RB - next tab
current_idx = active.tab_widget.currentIndex()
new_idx = (current_idx + 1) % active.tab_widget.count()
active.tab_widget.setCurrentIndex(new_idx)
return
# Standard navigation
if button_code in BUTTONS['confirm']:
self._parent.activateFocusedWidget()
@@ -595,8 +671,83 @@ class InputManager(QObject):
@Slot(int, int, float)
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:
return
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
logger.error("gamesListWidget not available yet, skipping D-pad navigation")
return
try:
app = QApplication.instance()
@@ -606,22 +757,31 @@ class InputManager(QObject):
if not app or not active:
return
# Update D-pad state
if value != 0:
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)) # Start timer (in milliseconds)
else:
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() # Stop timer when D-pad is released
return
# Новый код: обработка перехода на поле поиска
if code == ecodes.ABS_HAT0Y and value < 0: # Only D-pad up
if isinstance(focused, GameCard):
# Get all visible game cards
game_cards = self._parent.gamesListWidget.findChildren(GameCard)
if not game_cards:
return
# Find the current card's position
current_card_pos = focused.pos()
current_row_y = current_card_pos.y()
# Check if this is the first row (no cards above)
is_first_row = True
for card in game_cards:
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
if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0:
@@ -638,7 +798,7 @@ class InputManager(QObject):
elif value < 0: # Left
active.focusPreviousChild()
return
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0: # Keep up/down for other dialogs
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 no widget is focused, focus the first focusable widget
focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
@@ -691,6 +851,53 @@ class InputManager(QObject):
active.show_next()
return
# Table navigation
if isinstance(focused, QTableWidget):
row_count = focused.rowCount()
if row_count <= 0:
return
current_row = focused.currentRow()
if current_row < 0:
current_row = 0
focused.setCurrentCell(0, 0)
if code == ecodes.ABS_HAT0Y and value != 0:
# Vertical navigation
if value > 0: # Down
new_row = min(current_row + 1, row_count - 1)
elif value < 0: # Up
new_row = max(current_row - 1, 0)
else:
return
focused.setCurrentCell(new_row, focused.currentColumn())
item = focused.item(new_row, focused.currentColumn())
if item:
focused.scrollToItem(
item,
QAbstractItemView.ScrollHint.PositionAtCenter
)
focused.setFocus(Qt.FocusReason.OtherFocusReason)
return
elif code == ecodes.ABS_HAT0X and value != 0:
# Horizontal navigation
col_count = focused.columnCount()
current_col = focused.currentColumn()
if current_col < 0:
current_col = 0
if value < 0: # Left
new_col = max(current_col - 1, 0)
elif value > 0: # Right
new_col = min(current_col + 1, col_count - 1)
else:
return
focused.setCurrentCell(focused.currentRow(), new_col)
focused.setFocus(Qt.FocusReason.OtherFocusReason)
return
# Library tab navigation (index 0)
if self._parent.stackedWidget.currentIndex() == 0 and code in (ecodes.ABS_HAT0X, ecodes.ABS_HAT0Y):
focused = QApplication.focusWidget()
@@ -836,6 +1043,52 @@ class InputManager(QObject):
except Exception as e:
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:
app = QApplication.instance()
if not app:
@@ -1083,8 +1336,8 @@ class InputManager(QObject):
self.gamepad = None
if self.gamepad_thread:
self.gamepad_thread.join()
# Signal to exit fullscreen mode
self.toggle_fullscreen.emit(False)
if read_auto_fullscreen_gamepad() and not read_fullscreen_config():
self.toggle_fullscreen.emit(False)
except Exception as e:
logger.error(f"Error handling udev event: {e}", exc_info=True)
@@ -1142,11 +1395,12 @@ class InputManager(QObject):
if not app or not active:
continue
if event.type == ecodes.EV_KEY and event.value == 1:
if event.code in BUTTONS['menu'] and not self._is_gamescope_session:
if event.type == ecodes.EV_KEY:
# 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)
else:
self.button_pressed.emit(event.code)
elif event.type == ecodes.EV_ABS:
if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}:
# Проверяем, достаточно ли времени прошло с последнего срабатывания
@@ -1155,17 +1409,19 @@ class InputManager(QObject):
if event.code == ecodes.ABS_Z: # LT/L2
if event.value > 128 and not self.lt_pressed:
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
elif event.value <= 128 and self.lt_pressed:
self.lt_pressed = False
self.button_event.emit(event.code, 0) # Emit as release
elif event.code == ecodes.ABS_RZ: # RT/R2
if event.value > 128 and not self.rt_pressed:
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
elif event.value <= 128 and self.rt_pressed:
self.rt_pressed = False
self.button_event.emit(event.code, 0) # Emit as release
else:
self.dpad_moved.emit(event.code, event.value, now)
except OSError as e:

View File

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

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: de_DE\n"
@@ -191,6 +191,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -304,6 +308,45 @@ msgstr ""
msgid "No cover selected"
msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -370,6 +413,9 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -382,13 +428,95 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Various Wine parameters and versions..."
msgid "Compatibility tool:"
msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: es_ES\n"
@@ -191,6 +191,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -304,6 +308,45 @@ msgstr ""
msgid "No cover selected"
msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -370,6 +413,9 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -382,13 +428,95 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Various Wine parameters and versions..."
msgid "Compatibility tool:"
msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."

View File

@@ -9,7 +9,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PortProtonQt 0.1.1\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -189,6 +189,10 @@ msgstr ""
msgid "Failed to delete custom data: {error}"
msgstr ""
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr ""
msgid "Game name and executable path are required"
msgstr ""
@@ -302,6 +306,45 @@ msgstr ""
msgid "No cover selected"
msgstr ""
msgid "Prefix Manager"
msgstr ""
msgid "Set"
msgstr ""
msgid "Libraries"
msgstr ""
msgid "Information"
msgstr ""
msgid "Fonts"
msgstr ""
msgid "Settings"
msgstr ""
msgid "Force Install"
msgstr ""
msgid "Install"
msgstr ""
msgid "Winetricks not found. Please try again."
msgstr ""
msgid "Warning"
msgstr ""
msgid "No components selected."
msgstr ""
msgid "Installation failed. Check logs."
msgstr ""
msgid "Components installed successfully."
msgstr ""
msgid "Loading Epic Games Store games..."
msgstr ""
@@ -368,6 +411,9 @@ msgstr ""
msgid "Fullscreen"
msgstr ""
msgid "Search"
msgstr ""
msgid "Loading Steam games..."
msgstr ""
@@ -380,13 +426,95 @@ msgstr ""
msgid "Find Games ..."
msgstr ""
#, python-brace-format
msgid "Added '{name}'"
msgstr ""
msgid "Here you can configure automatic game installation..."
msgstr ""
msgid "List of available emulators and their configuration..."
msgstr ""
msgid "Various Wine parameters and versions..."
msgid "Compatibility tool:"
msgstr ""
msgid "Prefix:"
msgstr ""
msgid "Wine Configuration"
msgstr ""
msgid "Registry Editor"
msgstr ""
msgid "Control Panel"
msgstr ""
msgid "Task Manager"
msgstr ""
msgid "Command Prompt"
msgstr ""
msgid "Uninstaller"
msgstr ""
msgid "Create Prefix Backup"
msgstr ""
msgid "Load Prefix Backup"
msgstr ""
msgid "Delete Compatibility Tool"
msgstr ""
msgid "Delete Prefix"
msgstr ""
msgid "Clear Prefix"
msgstr ""
msgid "Failed to start backup process."
msgstr ""
msgid "Failed to start restore process."
msgstr ""
msgid "Prefix backup completed."
msgstr ""
msgid "Prefix backup failed."
msgstr ""
msgid "Prefix restore completed."
msgstr ""
msgid "Prefix restore failed."
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr ""
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr ""
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr ""
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr ""
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr ""
msgid "Main PortProton parameters..."

View File

@@ -9,18 +9,17 @@ msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-09-23 22:23+0500\n"
"PO-Revision-Date: 2025-09-23 22:23+0500\n"
"POT-Creation-Date: 2025-10-09 16:37+0500\n"
"PO-Revision-Date: 2025-10-09 16:37+0500\n"
"Last-Translator: \n"
"Language-Team: ru_RU <LL@li.org>\n"
"Language: ru_RU\n"
"Language-Team: ru_RU <LL@li.org>\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 "
"&& (n%100<10 || n%100>=20) ? 1 : 2);\n"
"Generated-By: Babel 2.17.0\n"
"X-Generator: Poedit 3.6\n"
msgid "Error"
msgstr "Ошибка"
@@ -87,11 +86,11 @@ msgstr "Успешно"
#, python-brace-format
msgid ""
"'{game_name}' was added to Steam. Please restart Steam for changes to take "
"effect."
"'{game_name}' was added to Steam. Please restart Steam for changes to "
"take effect."
msgstr ""
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите Steam, "
"чтобы изменения вступили в силу."
"'{game_name}' был(а) добавлен(а) в Steam. Пожалуйста, перезапустите "
"Steam, чтобы изменения вступили в силу."
#, python-brace-format
msgid "Executable not found for game: {game_name}"
@@ -179,11 +178,11 @@ msgstr "Подтвердите удаление"
#, python-brace-format
msgid ""
"Are you sure you want to delete '{game_name}'? This will remove the .desktop "
"file and custom data."
"Are you sure you want to delete '{game_name}'? This will remove the "
".desktop file and custom data."
msgstr ""
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению файла ."
"desktop и пользовательских данных."
"Вы уверены, что хотите удалить '{game_name}'? Это приведёт к удалению "
"файла .desktop и пользовательских данных."
#, python-brace-format
msgid "Failed to delete .desktop file: {error}"
@@ -197,6 +196,10 @@ msgstr "'{game_name}' был(а) успешно удалён(а)"
msgid "Failed to delete custom data: {error}"
msgstr "Не удалось удалить пользовательские данные: {error}"
#, python-brace-format
msgid "Added '{game_name}' successfully"
msgstr "'{game_name}' успешно добавлен(а)"
msgid "Game name and executable path are required"
msgstr "Требуются название игры и путь к исполняемому файлу"
@@ -225,11 +228,11 @@ msgstr "Не удалось добавить '{game_name}' в Steam: {error}"
#, python-brace-format
msgid ""
"'{game_name}' was removed from Steam. Please restart Steam for changes to take "
"effect."
"'{game_name}' was removed from Steam. Please restart Steam for changes to"
" take effect."
msgstr ""
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam, чтобы "
"изменения вступили в силу."
"'{game_name}' был(а) удалён(а) из Steam. Пожалуйста, перезапустите Steam,"
" чтобы изменения вступили в силу."
#, python-brace-format
msgid "Failed to remove game '{game_name}' from Steam: {error}"
@@ -274,7 +277,7 @@ msgstr "Путь: "
#, python-format
msgid "Access denied: %s"
msgstr "Доступ запрещен: %s"
msgstr "Доступ запрещён: %s"
msgid "Edit Game"
msgstr "Редактировать игру"
@@ -312,6 +315,45 @@ msgstr "Скачивание обложки..."
msgid "No cover selected"
msgstr "Обложка не выбрана"
msgid "Prefix Manager"
msgstr "Менеджер префиксов"
msgid "Set"
msgstr "Выбор"
msgid "Libraries"
msgstr "Библиотеки"
msgid "Information"
msgstr "Описание"
msgid "Fonts"
msgstr "Шрифты"
msgid "Settings"
msgstr "Настройки"
msgid "Force Install"
msgstr "Принудительно установить"
msgid "Install"
msgstr "Установить"
msgid "Winetricks not found. Please try again."
msgstr "Winetricks не найден. Повторите попытку."
msgid "Warning"
msgstr "Предупреждение"
msgid "No components selected."
msgstr "Не выбрано ни одного компонента."
msgid "Installation failed. Check logs."
msgstr "Установка не удалась. Проверьте журналы."
msgid "Components installed successfully."
msgstr "Компоненты успешно установлены."
msgid "Loading Epic Games Store games..."
msgstr "Загрузка игр из Epic Games Store..."
@@ -378,6 +420,9 @@ msgstr "Назад"
msgid "Fullscreen"
msgstr "Полный экран"
msgid "Search"
msgstr "Поиск"
msgid "Loading Steam games..."
msgstr "Загрузка игр из Steam..."
@@ -390,14 +435,96 @@ msgstr "Игровая библиотека"
msgid "Find Games ..."
msgstr "Найти игры..."
#, python-brace-format
msgid "Added '{name}'"
msgstr "'{name}' добавлен(а)"
msgid "Here you can configure automatic game installation..."
msgstr "Здесь можно настроить автоматическую установку игр..."
msgid "List of available emulators and their configuration..."
msgstr "Список доступных эмуляторов и их настройка..."
msgid "Various Wine parameters and versions..."
msgstr "Различные параметры и версии wine..."
msgid "Compatibility tool:"
msgstr "Инструмент совместимости:"
msgid "Prefix:"
msgstr "Префикс:"
msgid "Wine Configuration"
msgstr "Конфигурация Wine"
msgid "Registry Editor"
msgstr "Редактор реестра"
msgid "Control Panel"
msgstr "Панель управления"
msgid "Task Manager"
msgstr "Диспетчер задач"
msgid "Command Prompt"
msgstr "Командная строка"
msgid "Uninstaller"
msgstr "Удаление программ"
msgid "Create Prefix Backup"
msgstr "Создать резервную копию префикса"
msgid "Load Prefix Backup"
msgstr "Загрузить резервную копию префикса"
msgid "Delete Compatibility Tool"
msgstr "Удалить Инструмент совместимости"
msgid "Delete Prefix"
msgstr "Удалить Префикс"
msgid "Clear Prefix"
msgstr "Очистить Префикс"
msgid "Failed to start backup process."
msgstr "Не удалось запустить процесс резервного копирования."
msgid "Failed to start restore process."
msgstr "Не удалось запустить процесс восстановления."
msgid "Prefix backup completed."
msgstr "Резервное копирование префикса завершено."
msgid "Prefix backup failed."
msgstr "Сбой резервного копирования префикса."
msgid "Prefix restore completed."
msgstr "Восстановление префикса завершено."
msgid "Prefix restore failed."
msgstr "Восстановление префикса не удалось."
#, python-brace-format
msgid "Are you sure you want to delete prefix '{}'?"
msgstr "Вы уверены, что хотите удалить префикс «{}»?"
#, python-brace-format
msgid "Prefix '{}' deleted."
msgstr "Префикс «{}» удален."
#, python-brace-format
msgid "Failed to delete prefix: {}"
msgstr "Не удалось удалить префикс: {}"
#, python-brace-format
msgid "Are you sure you want to delete compatibility tool '{}'?"
msgstr "Вы уверены, что хотите удалить инструмент совместимости «{}»?"
#, python-brace-format
msgid "Compatibility tool '{}' deleted."
msgstr "Инструмент совместимости «{}» удален."
#, python-brace-format
msgid "Failed to delete compatibility tool: {}"
msgstr "Не удалось удалить инструмент совместимости: {}"
msgid "Main PortProton parameters..."
msgstr "Основные параметры PortProton..."
@@ -482,7 +609,8 @@ msgstr "Подтвердите удаление"
msgid "Are you sure you want to reset all settings? This action cannot be undone."
msgstr ""
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить."
"Вы уверены, что хотите сбросить все настройки? Это действие нельзя "
"отменить."
msgid "Settings reset. Restarting..."
msgstr "Настройки сброшены. Перезапуск..."
@@ -654,3 +782,4 @@ msgstr "Нет избранных"
msgid "No recent games"
msgstr "Нет недавних игр"

View File

@@ -7,7 +7,7 @@ import sys
import psutil
from portprotonqt.logger import get_logger
from portprotonqt.dialogs import AddGameDialog, FileExplorer
from portprotonqt.dialogs import AddGameDialog, FileExplorer, WinetricksDialog
from portprotonqt.game_card import GameCard
from portprotonqt.animations import DetailPageAnimations
from portprotonqt.custom_widgets import ClickableLabel, AutoSizeButton, NavLabel
@@ -35,11 +35,11 @@ from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout)
from PySide6.QtCore import Qt, QAbstractAnimation, QUrl, Signal, QTimer, Slot, QProcess
from PySide6.QtGui import QIcon, QPixmap, QColor, QDesktopServices
from typing import cast
from collections.abc import Callable
@@ -145,7 +145,7 @@ class MainWindow(QMainWindow):
headerLayout.addStretch()
self.input_manager = InputManager(self) # type: ignore
self.input_manager.button_pressed.connect(self.updateControlHints)
self.input_manager.button_event.connect(self.updateControlHints)
self.input_manager.dpad_moved.connect(self.updateControlHints)
# 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК)
@@ -206,8 +206,12 @@ class MainWindow(QMainWindow):
self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget)
self.updateControlHints()
self.restore_state()
self.keyboard = VirtualKeyboard(self, self.theme)
self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames)
@@ -248,6 +252,10 @@ class MainWindow(QMainWindow):
GamepadType.XBOX: "xbox_view",
GamepadType.PLAYSTATION: "ps_share",
},
'search': {
GamepadType.XBOX: "xbox_y",
GamepadType.PLAYSTATION: "ps_square",
},
}
return mappings.get(action, {}).get(gtype, "placeholder")
@@ -286,6 +294,7 @@ class MainWindow(QMainWindow):
("add_game", _("Add Game")),
("context_menu", _("Menu")),
("menu", _("Fullscreen")),
("search", _("Search")),
]
keyboard_hints = [
@@ -301,12 +310,12 @@ class MainWindow(QMainWindow):
def makeHint(icon_name: str, action_text: str, is_gamepad: bool, action: str | None = None,):
container = QWidget()
layout = QHBoxLayout(container)
layout.setContentsMargins(0, 0, 0, 0)
layout.setContentsMargins(0, 5, 0, 0)
layout.setSpacing(6)
# иконка кнопки
icon_label = QLabel()
icon_label.setFixedSize(32, 32)
icon_label.setFixedSize(26, 26)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
pixmap = QPixmap()
@@ -319,7 +328,7 @@ class MainWindow(QMainWindow):
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
@@ -398,7 +407,7 @@ class MainWindow(QMainWindow):
gtype = self.input_manager.gamepad_type
logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value)
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu']
gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu', 'search']
for container, icon_label, action in self.hintsLabels:
if action in gamepad_actions: # Gamepad hint
@@ -412,7 +421,7 @@ class MainWindow(QMainWindow):
pixmap.load(str(icon_path))
if not pixmap.isNull():
icon_label.setPixmap(pixmap.scaled(
32, 32,
26, 26,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation
))
@@ -421,7 +430,7 @@ class MainWindow(QMainWindow):
placeholder = self.theme_manager.get_theme_image("placeholder", self.current_theme_name)
if placeholder:
pixmap.load(str(placeholder))
icon_label.setPixmap(pixmap.scaled(32, 32, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
icon_label.setPixmap(pixmap.scaled(26, 26, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation))
else:
container.setVisible(False)
else: # Keyboard hint
@@ -747,17 +756,29 @@ class MainWindow(QMainWindow):
self.searchDebounceTimer = QTimer(self)
self.searchDebounceTimer.setSingleShot(True)
self.searchDebounceTimer.setInterval(300)
self.searchDebounceTimer.timeout.connect(self.game_library_manager.filter_games_delayed)
self.searchDebounceTimer.timeout.connect(self.on_search_changed)
layout.addWidget(self.searchEdit)
return self.container, self.searchEdit
def on_search_text_changed(self, text: str):
"""Search text change handler with debounce."""
self.searchDebounceTimer.stop()
self.searchDebounceTimer.start()
@Slot()
def on_search_changed(self):
"""Triggers filtering with delay."""
if hasattr(self, 'game_library_manager'):
self.game_library_manager.filter_games_delayed()
def startSearchDebounce(self, text):
self.searchDebounceTimer.start()
def createInstalledTab(self):
self.gamesLibraryWidget = self.game_library_manager.create_games_library_widget()
self.stackedWidget.addWidget(self.gamesLibraryWidget)
self.gamesListWidget = self.game_library_manager.gamesListWidget
self.game_library_manager.update_game_grid()
def resizeEvent(self, event):
@@ -935,7 +956,6 @@ class MainWindow(QMainWindow):
# Trigger visible images load
QTimer.singleShot(200, self.game_library_manager.load_visible_images)
self.update_status_message.emit(_("Enriching from Steam..."), 3000)
from portprotonqt.steam_api import get_steam_game_info_async
get_steam_game_info_async(final_name, exec_line, on_steam_info)
@@ -994,14 +1014,245 @@ class MainWindow(QMainWindow):
self.wineTitle.setObjectName("tabTitle")
layout.addWidget(self.wineTitle)
self.wineContent = QLabel(_("Various Wine parameters and versions..."))
self.wineContent.setStyleSheet(self.theme.CONTENT_STYLE)
self.wineContent.setObjectName("tabContent")
layout.addWidget(self.wineContent)
if self.portproton_location is None:
return
dist_path = os.path.join(self.portproton_location, "data", "dist")
prefixes_path = os.path.join(self.portproton_location, "data", "prefixes")
if not os.path.exists(dist_path):
return
formLayout = QFormLayout()
formLayout.setContentsMargins(0, 10, 0, 0)
formLayout.setSpacing(10)
formLayout.setLabelAlignment(Qt.AlignmentFlag.AlignLeft)
self.wine_versions = [d for d in os.listdir(dist_path) if os.path.isdir(os.path.join(dist_path, d))]
self.wineCombo = QComboBox()
self.wineCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.wineCombo.addItems(self.wine_versions)
self.wineCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
self.wineCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.wineTitleLabel = QLabel(_("Compatibility tool:"))
self.wineTitleLabel.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.wineTitleLabel.setFocusPolicy(Qt.FocusPolicy.NoFocus)
if self.wine_versions:
self.wineCombo.setCurrentIndex(0)
formLayout.addRow(self.wineTitleLabel, self.wineCombo)
self.prefixes = [d for d in os.listdir(prefixes_path) if os.path.isdir(os.path.join(prefixes_path, d))] if os.path.exists(prefixes_path) else []
self.prefixCombo = QComboBox()
self.prefixCombo.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self.prefixCombo.addItems(self.prefixes)
self.prefixCombo.setStyleSheet(self.theme.SETTINGS_COMBO_STYLE)
self.prefixCombo.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
self.prefixTitleLabel = QLabel(_("Prefix:"))
self.prefixTitleLabel.setStyleSheet(self.theme.PARAMS_TITLE_STYLE)
self.prefixTitleLabel.setFocusPolicy(Qt.FocusPolicy.NoFocus)
if self.prefixes:
self.prefixCombo.setCurrentIndex(0)
formLayout.addRow(self.prefixTitleLabel, self.prefixCombo)
layout.addLayout(formLayout)
# --- Wine Tools ---
tools_grid = QGridLayout()
tools_grid.setSpacing(6)
tools = [
("winecfg", _("Wine Configuration")),
("regedit", _("Registry Editor")),
("control", _("Control Panel")),
("taskmgr", _("Task Manager")),
("explorer", _("File Explorer")),
("cmd", _("Command Prompt")),
("uninstaller", _("Uninstaller")),
]
for i, (_tool_cmd, tool_name) in enumerate(tools):
row = i // 3
col = i % 3
btn = AutoSizeButton(tool_name, update_size=False)
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
tools_grid.addWidget(btn, row, col)
for col in range(3):
tools_grid.setColumnStretch(col, 1)
layout.addLayout(tools_grid)
# --- Additional Tools ---
additional_grid = QGridLayout()
additional_grid.setSpacing(6)
additional_buttons = [
("Winetricks", self.open_winetricks),
(_("Create Prefix Backup"), self.create_prefix_backup),
(_("Load Prefix Backup"), self.load_prefix_backup),
(_("Delete Compatibility Tool"), self.delete_compat_tool),
(_("Delete Prefix"), self.delete_prefix),
(_("Clear Prefix"), None),
]
for i, (text, callback) in enumerate(additional_buttons):
row = i // 3
col = i % 3
btn = AutoSizeButton(text, update_size=False)
btn.setStyleSheet(self.theme.ACTION_BUTTON_STYLE)
btn.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
if callback:
btn.clicked.connect(callback)
additional_grid.addWidget(btn, row, col)
for col in range(3):
additional_grid.setColumnStretch(col, 1)
layout.addLayout(additional_grid)
tools_grid.setContentsMargins(10, 4, 10, 0)
additional_grid.setContentsMargins(10, 6, 10, 0)
layout.addStretch(1)
self.stackedWidget.addWidget(self.wineWidget)
def create_prefix_backup(self):
selected_prefix = self.prefixCombo.currentText()
if not selected_prefix:
return
file_explorer = FileExplorer(self, directory_only=True)
file_explorer.file_signal.file_selected.connect(lambda path: self._perform_backup(path, selected_prefix))
file_explorer.exec()
def _perform_backup(self, backup_dir, prefix_name):
os.makedirs(backup_dir, exist_ok=True)
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
self.backup_process = QProcess(self)
self.backup_process.finished.connect(lambda exitCode, exitStatus: self._on_backup_finished(exitCode))
cmd = [start_sh, "--backup-prefix", prefix_name, backup_dir]
self.backup_process.start(cmd[0], cmd[1:])
if not self.backup_process.waitForStarted():
QMessageBox.warning(self, _("Error"), _("Failed to start backup process."))
def load_prefix_backup(self):
file_explorer = FileExplorer(self, file_filter='.ppack')
file_explorer.file_signal.file_selected.connect(self._perform_restore)
file_explorer.exec()
def _perform_restore(self, file_path):
if not file_path or not os.path.exists(file_path):
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
self.restore_process = QProcess(self)
self.restore_process.finished.connect(lambda exitCode, exitStatus: self._on_restore_finished(exitCode))
cmd = [start_sh, "--restore-prefix", file_path]
self.restore_process.start(cmd[0], cmd[1:])
if not self.restore_process.waitForStarted():
QMessageBox.warning(self, _("Error"), _("Failed to start restore process."))
def _on_backup_finished(self, exitCode):
if exitCode == 0:
QMessageBox.information(self, _("Success"), _("Prefix backup completed."))
else:
QMessageBox.warning(self, _("Error"), _("Prefix backup failed."))
def _on_restore_finished(self, exitCode):
if exitCode == 0:
QMessageBox.information(self, _("Success"), _("Prefix restore completed."))
else:
QMessageBox.warning(self, _("Error"), _("Prefix restore failed."))
def delete_prefix(self):
selected_prefix = self.prefixCombo.currentText()
if not self.portproton_location:
return
if not selected_prefix:
return
prefix_path = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix)
if not os.path.exists(prefix_path):
return
reply = QMessageBox.question(
self,
_("Confirm Deletion"),
_("Are you sure you want to delete prefix '{}'?").format(selected_prefix),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
try:
shutil.rmtree(prefix_path)
QMessageBox.information(self, _("Success"), _("Prefix '{}' deleted.").format(selected_prefix))
# обновляем список
self.prefixCombo.clear()
self.prefixes = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "prefixes"))
if os.path.isdir(os.path.join(self.portproton_location, "data", "prefixes", d))]
self.prefixCombo.addItems(self.prefixes)
except Exception as e:
QMessageBox.warning(self, _("Error"), _("Failed to delete prefix: {}").format(str(e)))
def delete_compat_tool(self):
"""Удаляет выбранный Wine/Proton дистрибутив из каталога dist."""
if not self.portproton_location:
return
selected_tool = self.wineCombo.currentText()
if not selected_tool:
return
tool_path = os.path.join(self.portproton_location, "data", "dist", selected_tool)
if not os.path.exists(tool_path):
return
reply = QMessageBox.question(
self,
_("Confirm Deletion"),
_("Are you sure you want to delete compatibility tool '{}'?").format(selected_tool),
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No
)
if reply == QMessageBox.StandardButton.Yes:
try:
shutil.rmtree(tool_path)
QMessageBox.information(self, _("Success"), _("Compatibility tool '{}' deleted.").format(selected_tool))
# обновляем список
self.wineCombo.clear()
self.wine_versions = [d for d in os.listdir(os.path.join(self.portproton_location, "data", "dist"))
if os.path.isdir(os.path.join(self.portproton_location, "data", "dist", d))]
self.wineCombo.addItems(self.wine_versions)
except Exception as e:
QMessageBox.warning(self, _("Error"), _("Failed to delete compatibility tool: {}").format(str(e)))
def open_winetricks(self):
"""Open the Winetricks dialog for the selected prefix and wine."""
selected_prefix = self.prefixCombo.currentText()
if not selected_prefix:
return
selected_wine = self.wineCombo.currentText()
if not selected_wine:
return
assert self.portproton_location is not None
prefix_path = os.path.join(self.portproton_location, "data", "prefixes", selected_prefix)
wine_path = os.path.join(self.portproton_location, "data", "dist", selected_wine, "bin", "wine")
# Open Winetricks dialog
dialog = WinetricksDialog(self, self.theme, prefix_path, wine_path)
dialog.exec()
def createPortProtonTab(self):
"""Вкладка 'PortProton Settings'."""
self.portProtonWidget = QWidget()
@@ -1824,10 +2075,19 @@ class MainWindow(QMainWindow):
completionist_time = hltb.format_game_time(game, "completionist")
# Очищаем layout перед добавлением новых элементов
while hltbLayout.count():
child = hltbLayout.takeAt(0)
if child.widget():
child.widget().deleteLater()
def clear_layout(layout):
while layout.count():
item = layout.takeAt(0)
widget = item.widget()
sublayout = item.layout()
if widget:
widget.deleteLater()
elif sublayout:
clear_layout(sublayout)
clear_layout(hltbLayout)
has_data = False
@@ -2295,7 +2555,7 @@ class MainWindow(QMainWindow):
self.settingsDebounceTimer.stop()
if hasattr(self, 'searchDebounceTimer') and self.searchDebounceTimer.isActive():
self.searchDebounceTimer.stop()
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer and self.checkProcessTimer.isActive():
if hasattr(self, 'checkProcessTimer') and self.checkProcessTimer is not None and self.checkProcessTimer.isActive():
self.checkProcessTimer.stop()
self.checkProcessTimer.deleteLater()
self.checkProcessTimer = None

49
portprotonqt/preloader.py Normal file
View File

@@ -0,0 +1,49 @@
import time
from PySide6.QtCore import QRect
from PySide6.QtGui import QPainter, QPen, QBrush, Qt, QColor, QConicalGradient
from PySide6.QtWidgets import QWidget
class Preloader(QWidget):
def __init__(self, speed=180.0, line_line_width=20, color=QColor(0, 120, 215), parent=None):
super().__init__(parent)
self.setFixedSize(150, 150)
self._speed = speed
self._line_width = line_line_width
self._color1 = color
self._color2 = QColor(color.red(), color.green(), color.blue(), 0)
self._start_time = time.time()
def showEvent(self, event):
self._start_time = time.time()
def paintEvent(self, event):
rect = self._get_preloader_rect()
center = rect.center()
painter = QPainter(self)
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
painter.setPen(self._get_pen())
painter.translate(center)
painter.rotate(self._get_angle())
painter.translate(-center)
painter.drawArc(rect, 0, 270 * 16)
self.update()
def _get_pen(self) -> QPen:
gradient = QConicalGradient()
gradient.setCenter(self.rect().center())
gradient.setColorAt(0, self._color1)
gradient.setColorAt(1, self._color2)
pen = QPen(QBrush(gradient), self._line_width)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
return pen
def _get_angle(self) -> float:
duration = time.time() - self._start_time
return (self._speed * duration) % 360.0
def _get_preloader_rect(self) -> QRect:
size = self._line_width // 2
rect = self.rect()
rect.adjust(size, size, -size, -size)
return rect

View File

@@ -211,14 +211,28 @@ def normalize_name(s):
def is_valid_candidate(candidate):
"""
Checks if a candidate contains forbidden substrings:
- win32
- win64
- gamelauncher
Additionally checks the string without spaces.
Returns True if the candidate is valid, otherwise False.
Determines whether a given candidate string is valid for use as a game name.
The function performs the following checks:
1. Normalizes the candidate using `normalize_name()`.
2. Rejects the candidate if the normalized name is exactly "game"
(to avoid overly generic names).
3. Removes spaces and checks for forbidden substrings:
- "win32"
- "win64"
- "gamelauncher"
These are checked in the space-free version of the string.
4. Returns True only if none of the forbidden conditions are met.
Args:
candidate (str): The candidate string to validate.
Returns:
bool: True if the candidate is valid, False otherwise.
"""
normalized_candidate = normalize_name(candidate)
if normalized_candidate == "game":
return False
normalized_no_space = normalized_candidate.replace(" ", "")
forbidden = ["win32", "win64", "gamelauncher"]
for token in forbidden:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 880 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><g><rect x="1" y="6" width="46" height="36" rx="5" ry="5" fill="#3f424d" stroke-width="1.1506"/><rect x="4.2329" y="8.5301" width="39.534" height="30.94" rx="4.2972" ry="4.2972" fill="#fff" stroke-width=".98888"/><path d="m23.24 22.785c-0.67917 0.69059-0.67818 1.807 0 2.4913l8.0309 8.1037c1.8756 1.8787 4.6892-0.93962 2.8136-2.8183l-3.5038-3.5097c-0.58434-0.58533-0.39618-1.0598 0.44066-1.0598h9.6139c1.0992 0 1.9895-0.89179 1.9895-1.9928 0-1.1005-0.89028-1.9928-1.9895-1.9928h-9.6139c-0.82771 0-1.0277-0.47176-0.44066-1.0597l3.5038-3.5093c1.8756-1.8787-0.93803-4.6971-2.8136-2.8183z" fill="#3f424d" fill-rule="evenodd"/></g></svg>

After

Width:  |  Height:  |  Size: 751 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="48"
height="48"
version="1.1"
viewBox="0 0 48 48"
xml:space="preserve"
id="svg2"
sodipodi:docname="key_context.svg"
inkscape:version="1.4.2 (ebf0e940d0, 2025-05-08)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs2" /><sodipodi:namedview
id="namedview2"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="8.6915209"
inkscape:cx="72.311855"
inkscape:cy="22.780823"
inkscape:window-width="2560"
inkscape:window-height="1406"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg2" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.554217;enable-background:accumulate;stop-color:#000000"
d="m 17.400964,38.281601 -0.04068,-15.381724 c -0.0087,-3.288656 2.401967,-6.020242 5.542168,-6.550475 V 7.4098472 C 11.174091,7.9874382 1.8422139,17.678792 1.8422139,29.550445 v 8.911269 c 3.429133,2.844892 11.5678151,2.890776 15.5587501,-0.180113 z"
id="path10"
sodipodi:nodetypes="csccscc" /><path
fill="#000000"
d="m 23.956256,40.5905 h -9e-6 c -2.438553,0 -4.433731,-1.995178 -4.433731,-4.43373 V 25.072424 c 0,-2.438552 1.995178,-4.433731 4.433731,-4.433731 h 9e-6 c 2.438552,0 4.43373,1.995179 4.43373,4.433731 V 36.15677 c 0,2.438552 -1.995178,4.43373 -4.43373,4.43373 z"
id="path2"
style="fill:#686e7e;fill-opacity:1;stroke-width:0.554217" /><g
id="g15"
transform="matrix(0.97480136,0,0,0.99852328,1.4840752,1.6593149)"><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#ffffff;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 30.231637,35.990171 0.03878,-14.663865 c 0.0083,-3.135176 -2.289868,-5.73928 -5.283518,-6.244767 V 6.5591888 C 36.167905,7.1098239 45.209208,16.349815 45.064267,27.666494 l -0.109685,8.563937 c -3.269097,2.712122 -10.918265,2.687312 -14.722945,-0.24026 z"
id="path14" /><path
style="baseline-shift:baseline;display:inline;overflow:visible;vector-effect:none;fill:#686e7e;fill-opacity:1;stroke-width:0.95333;enable-background:accumulate;stop-color:#000000"
d="m 24.224126,5.7586892 v 9.9671448 l 0.634933,0.107994 c 2.632815,0.444559 4.656653,2.729598 4.649348,5.490959 l -0.04096,15.03916 0.299778,0.230885 c 2.097287,1.613791 5.093143,2.357986 8.017658,2.392636 2.924514,0.03465 5.796042,-0.625772 7.656435,-2.169199 l 0.271848,-0.2253 0.113581,-8.91699 C 45.976953,15.94787 36.604257,6.3680498 25.024774,5.7977906 Z m 1.524956,1.6795 C 36.150995,8.3658717 44.437912,17.028984 44.301786,27.65736 l -0.104271,8.114479 c -1.445908,1.069255 -3.851487,1.720797 -6.394017,1.690673 -2.543438,-0.03013 -5.090881,-0.734663 -6.807375,-1.934591 l 0.03724,-14.199409 c 0.0087,-3.271088 -2.263607,-5.953645 -5.284281,-6.771998 z"
id="path15" /></g></svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 874 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m17.977 16.26h11.807v2.6476h-8.086v3.554h7.2989v2.6476h-7.2989v3.9834h8.3245v2.6476h-12.046z" fill="#3f424d" stroke-width=".4977" aria-label="E"/></svg>

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6 6h36c2.77 0 5 2.23 5 5v26c0 2.77-2.23 5-5 5h-36c-2.77 0-5-2.23-5-5v-26c0-2.77 2.23-5 5-5z" fill="#3f424d" stroke-width="1.1506"/><path d="m8.5301 8.5301h30.94c2.3806 0 4.2972 1.9166 4.2972 4.2972v22.346c0 2.3806-1.9166 4.2972-4.2972 4.2972h-30.94c-2.3806 0-4.2972-1.9166-4.2972-4.2972v-22.346c0-2.3806 1.9166-4.2972 4.2972-4.2972z" fill="#fff" stroke-width=".98888"/><path d="m8.2952 18.538h8.3321v1.8684h-5.7063v2.5081h5.1508v1.8684h-5.1508v2.811h5.8746v1.8684h-8.5005zm10.268 0h2.6596l5.2854 7.4568v-7.4568h2.3397v10.924h-2.6596l-5.2854-7.5747v7.5747h-2.3397zm15.166 1.8684h-3.3665v-1.8684h9.3421v1.8684h-3.3497v9.0559h-2.6259z" fill="#3f424d" stroke-width=".35123" aria-label="ENT"/></svg>

After

Width:  |  Height:  |  Size: 823 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 943 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m11.139 18.538h8.5005v1.8684h-5.8746v2.6764h5.3191v1.8684h-5.3191v4.5111h-2.6259zm13.5 2.5754-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576zm9.7629 0-2.9794 1.6496v-2.0704l3.4507-2.1546h1.9862v10.924h-2.4576z" fill="#3f424d" stroke-width=".35123" aria-label="F11"/></svg>

After

Width:  |  Height:  |  Size: 857 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 933 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m26.619 34a1.9874 1.9874 0 0 1-1.3812-0.55623l-7.5143-7.2497a3.0457 3.0457 0 0 1 0-4.3873l7.5143-7.2497a1.9882 1.9882 0 0 1 2.7603 2.8624l-6.8226 6.581 6.8226 6.581a1.9874 1.9874 0 0 1-1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

After

Width:  |  Height:  |  Size: 865 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 956 B

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><defs><style>.cls-1{fill:none;}</style></defs><path d="m12 6c-3.3238 0-5.9998 2.6763-5.9998 5.9998v24c0 3.3238 2.6763 5.9998 5.9998 5.9998h24c3.3238 0 5.9998-2.6763 5.9998-5.9998v-24c0-3.3238-2.6763-5.9998-5.9998-5.9998z" fill="#3f424d" stroke-width="2.5714"/><path d="m13.697 8.5452c-2.8537 0-5.1515 2.2979-5.1515 5.1515v20.606c0 2.8537 2.2979 5.1515 5.1515 5.1515h20.606c2.8537 0 5.1515-2.2979 5.1515-5.1515v-20.606c0-2.8537-2.2979-5.1515-5.1515-5.1515z" fill="#fff" stroke-width="2.2078"/><path d="m20.778 34a1.9874 1.9874 0 0 0 1.3812-0.55623l7.5143-7.2497a3.0457 3.0457 0 0 0 0-4.3873l-7.5143-7.2497a1.9882 1.9882 0 0 0-2.7603 2.8624l6.8226 6.581-6.8226 6.581a1.9874 1.9874 0 0 0 1.3791 3.4186z" fill="#3f424d" stroke-width=".20832"/></svg>

After

Width:  |  Height:  |  Size: 864 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m24 13.476c-5.7918 0-10.524 4.7162-10.524 10.524 0 5.7918 4.7162 10.524 10.524 10.524 5.7918 0 10.524-4.7162 10.524-10.524 0-5.7918-4.7162-10.524-10.524-10.524zm0 18.037c-4.137 0-7.5128-3.3758-7.5128-7.5128s3.3758-7.5128 7.5128-7.5128 7.5128 3.3758 7.5128 7.5128-3.3592 7.5128-7.5128 7.5128z" fill="#3f424d" stroke-width="1.6548"/></svg>

After

Width:  |  Height:  |  Size: 736 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m34.076 13.91c-0.57906-0.57906-1.5387-0.57906-2.1177 0l-7.958 7.958-7.958-7.958c-0.57906-0.57906-1.5387-0.57906-2.1177 0-0.57906 0.57906-0.57906 1.5387 0 2.1177l7.958 7.958-7.958 7.958c-0.57906 0.57906-0.57906 1.5387 0 2.1177 0.2978 0.2978 0.67833 0.44671 1.0589 0.44671 0.38053 0 0.76106-0.1489 1.0589-0.44671l7.958-7.9415 7.958 7.958c0.2978 0.2978 0.67833 0.44671 1.0589 0.44671s0.76106-0.1489 1.0589-0.44671c0.57906-0.57906 0.57906-1.5387 0-2.1177l-7.958-7.958 7.958-7.958c0.57906-0.59561 0.57906-1.5387 0-2.1343z" fill="#3f424d" stroke-width="1.6545"/></svg>

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m10.465 39.437c4.1391 1.4258 20.596 4.9156 31.79 2.551 2.7034-0.57104 4.7508-3.32 4.744-6.0831l-0.057386-23.467c-0.009676-3.9677-4.6895-7.2319-7.5124-7.2255-12.075 0.0276-22.278-0.0068827-33.557 1.5493-2.7371 0.37765-4.8753 4.0033-4.8727 6.7663l0.016807 17.988c0.00451 4.8315 6.0288 6.743 9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m12.394 37.236c3.5492 1.2226 17.661 4.2149 27.259 2.1874 2.3181-0.48964 4.0736-2.8468 4.0678-5.216l-0.049207-20.123c-0.008279-3.4022-4.0211-6.2011-6.4416-6.1956-10.354 0.023666-19.103-0.0059052-28.774 1.3285-2.347 0.32383-4.1804 3.4327-4.1782 5.802l0.014412 15.424c0.00387 4.1428 5.1694 5.7819 8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m13.833 16.812h3.4556v11.917h7.0662v2.4588h-10.522zm17.101 3.3891-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="L1"/></svg>

After

Width:  |  Height:  |  Size: 1015 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m18.047 46.216-2.1e-5 -5e-6c-5.4306-1.4551-8.6833-7.089-7.2282-12.52l6.6143-24.685c1.4551-5.4306 7.089-8.6833 12.52-7.2282l2.1e-5 5.5e-6c5.4306 1.4551 8.6833 7.089 7.2282 12.52l-6.6143 24.685c-1.4551 5.4306-7.089 8.6833-12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m19.229 41.807-1.7e-5 -4e-6c-4.3529-1.1664-6.9601-5.6821-5.7937-10.035l5.3016-19.786c1.1664-4.3529 5.6821-6.9601 10.035-5.7937l1.7e-5 4.4e-6c4.3529 1.1664 6.9601 5.6821 5.7937 10.035l-5.3016 19.786c-1.1664 4.3529-5.6821 6.9601-10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m19.502 18.291c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114 0 0.6153 0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114 0-0.6153-0.69187-1.114-1.5459-1.114zm0 4.595c-0.854 0-1.547 0.49867-1.547 1.114s0.69299 1.114 1.547 1.114h8.997c0.854 0 1.5459-0.49867 1.5459-1.114s-0.69187-1.114-1.5459-1.114z" fill="#3f424d" fill-rule="evenodd" stroke-width=".11455"/></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m37.535 39.437c-4.1391 1.4258-20.596 4.9156-31.79 2.551-2.7034-0.57104-4.7508-3.32-4.744-6.0831l0.057386-23.467c0.00968-3.9677 4.6895-7.2319 7.5124-7.2255 12.075 0.0276 22.278-0.00688 33.557 1.5493 2.7371 0.37765 4.8753 4.0033 4.8727 6.7663l-0.01681 17.988c-0.0045 4.8315-6.0288 6.743-9.4485 7.921z" fill="#3f424d" stroke-width="1.1477"/><path d="m35.606 37.236c-3.5492 1.2226-17.661 4.2149-27.259 2.1874-2.3181-0.48964-4.0736-2.8468-4.0678-5.216l0.049207-20.123c0.00828-3.4022 4.0211-6.2011 6.4416-6.1956 10.354 0.023666 19.103-0.00591 28.774 1.3285 2.347 0.32383 4.1804 3.4327 4.1782 5.802l-0.01441 15.424c-0.0039 4.1428-5.1694 5.7819-8.1017 6.792z" fill="#fff" stroke-width=".98413"/><path d="m12.858 16.812h6.4681q2.8796 0 4.1644 0.70883 1.2848 0.68668 1.2848 2.3259v2.5252q0 1.2626-0.90819 1.9936-0.88604 0.70883-2.3702 0.90819l4.1644 5.9143h-3.9872l-3.7657-5.6485h-1.5949v5.6485h-3.4556zm6.4238 6.4459q1.2183 0 1.6613-0.31011 0.44302-0.33226 0.44302-1.2626v-1.0189q0-0.79744-0.48732-1.0854-0.46517-0.31011-1.617-0.31011h-2.9682v3.9872zm12.626-3.0568-3.9207 2.1708v-2.7246l4.541-2.8353h2.6138v14.376h-3.234z" fill="#3f424d" stroke-width=".4622" aria-label="R1"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m29.953 46.216 2.1e-5 -5e-6c5.4306-1.4551 8.6833-7.089 7.2282-12.52l-6.6143-24.685c-1.4551-5.4306-7.089-8.6833-12.52-7.2282l-2.1e-5 5.5e-6c-5.4306 1.4551-8.6833 7.089-7.2282 12.52l6.6143 24.685c1.4551 5.4306 7.089 8.6833 12.52 7.2282z" fill="#3f424d" stroke-width="1.2778"/><path d="m28.771 41.807 1.7e-5 -4e-6c4.3529-1.1664 6.9601-5.6821 5.7937-10.035l-5.3016-19.786c-1.1664-4.3529-5.6821-6.9601-10.035-5.7937l-1.7e-5 4.4e-6c-4.3529 1.1664-6.9601 5.6821-5.7937 10.035l5.3016 19.786c1.1664 4.3529 5.6821 6.9601 10.035 5.7937z" fill="#fff" stroke-width="1.0242"/><path d="m24.034 20.416c-0.54232 0-0.98296 0.41005-0.98296 0.91636v5.3348c0 0.50632 0.44064 0.91636 0.98296 0.91636s0.98124-0.41005 0.98124-0.91636v-5.3348c0-0.50632-0.43892-0.91636-0.98124-0.91636zm-5.9615 0.72033c-0.15955 0.0017-0.31975 0.03855-0.46652 0.11513-0.46966 0.24506-0.62269 0.79993-0.34257 1.2384l2.9506 4.6191c0.28012 0.43848 0.88858 0.59512 1.3582 0.35005 0.46966-0.24506 0.62269-0.79837 0.34257-1.2369l-2.9506-4.6192c-0.19258-0.30146-0.5407-0.4705-0.89172-0.46674zm11.856 0c-0.35102-0.0037-0.69914 0.16528-0.89172 0.46674l-2.9506 4.6191c-0.28011 0.43848-0.12709 0.99179 0.34257 1.2369 0.46967 0.24506 1.0781 0.08843 1.3582-0.35005l2.9506-4.6191c0.28011-0.43848 0.12709-0.99335-0.34257-1.2384-0.14677-0.07658-0.30696-0.11342-0.46652-0.11513z" fill="#3f424d" fill-rule="evenodd" stroke-width=".082805"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m13.766 32.511h20.449c0.60033 0 1.1631-0.31892 1.4821-0.84421 0.30016-0.52529 0.30016-1.1819 0-1.7072l-10.224-17.71c-0.60033-1.0506-2.345-1.0506-2.9454 0l-10.224 17.71c-0.30016 0.52529-0.30016 1.1819 0 1.7072s0.86297 0.84421 1.4633 0.84421zm10.224-15.984 7.2602 12.588h-14.539z" fill="#3f424d" stroke-width="1.876"/></svg>

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.016 13.475h6.1623l7.5893 21.049h-5.1244l-1.8811-5.546h-7.6866l-1.8487 5.546h-4.9947zm5.6433 12.13-2.6595-7.9137h-0.12973l-2.6595 7.9137z" fill="#3f424d" stroke-width=".67675" aria-label="A"/></svg>

After

Width:  |  Height:  |  Size: 600 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m15.973 13.476h8.5299q3.0163 0 4.6379 0.45406 1.6541 0.42163 2.3352 1.3946 0.68109 0.94056 0.68109 2.6595v2.5946q0 0.87569-0.71353 1.6541-0.68109 0.77839-1.6216 1.0703v0.16216q1.2325 0.12973 2.2379 1.0703 1.0379 0.90812 1.0379 2.0433v3.2433q0 2.5622-2.0433 3.6325t-6.3244 1.0703h-8.7569zm8.5299 8.5623q1.2 0 1.7838-0.1946t0.77839-0.61623q0.22703-0.45406 0.22703-1.2973v-1.0379q0-0.74596-0.1946-1.1027-0.1946-0.3892-0.81082-0.55136-0.58379-0.16216-1.8811-0.16216h-3.373v4.9622zm0.12973 8.8866q1.8487 0 2.6271-0.42163t0.77839-1.3622v-1.6865q0-1.1676-0.61623-1.6541-0.58379-0.4865-2.1081-0.4865h-4.2812v5.6109z" fill="#3f424d" stroke-width=".67675" aria-label="B"/></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m13.757 18h2.8844v9.9476h5.8983v2.0524h-8.7827zm10.724 0h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.0169-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="LB"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m6.3645 12.915h35.271c2.9719 0 5.3645 2.3926 5.3645 5.3645v11.442c0 2.9719-2.3926 5.3645-5.3645 5.3645h-35.271c-2.9719 0-5.3645-2.3926-5.3645-5.3645v-11.442c0-2.9719 2.3926-5.3645 5.3645-5.3645z" fill="#3f424d"/><path d="m9.2118 14.704h29.576c2.4921 0 4.4983 2.0062 4.4983 4.4983v9.5944c0 2.4921-2.0062 4.4983-4.4983 4.4983h-29.576c-2.4921 0-4.4983-2.0062-4.4983-4.4983v-9.5944c0-2.4921 2.0062-4.4983 4.4983-4.4983z" fill="#fff"/><path d="m12.943 18h5.3991q2.4037 0 3.4761 0.59168 1.0724 0.57319 1.0724 1.9414v2.1079q0 1.0539-0.75809 1.6641-0.7396 0.59168-1.9784 0.75809l3.4761 4.9368h-3.3282l-3.1433-4.7149h-1.3313v4.7149h-2.8844zm5.3621 5.3806q1.0169 0 1.3867-0.25886 0.3698-0.27735 0.3698-1.0539v-0.85054q0-0.66564-0.40678-0.90601-0.38829-0.25886-1.3498-0.25886h-2.4777v3.3282zm6.9892-5.3806h4.8629q1.7196 0 2.6441 0.25886 0.94299 0.24037 1.3313 0.79507 0.38829 0.53621 0.38829 1.5162v1.4792q0 0.49923-0.40678 0.94299-0.38829 0.44376-0.9245 0.61017v0.09245q0.70262 0.07396 1.2758 0.61017 0.59168 0.51772 0.59168 1.1649v1.849q0 1.4607-1.1649 2.0709-1.1649 0.61017-3.6055 0.61017h-4.9923zm4.8629 4.8814q0.68413 0 1.017-0.11094 0.33282-0.11094 0.44376-0.35131 0.12943-0.25886 0.12943-0.7396v-0.59168q0-0.42527-0.11094-0.62866-0.11094-0.22188-0.46225-0.31433-0.33282-0.09245-1.0724-0.09245h-1.923v2.829zm0.07396 5.0663q1.0539 0 1.4977-0.24037 0.44376-0.24037 0.44376-0.77658v-0.96148q0-0.66564-0.35131-0.94299-0.33282-0.27735-1.2018-0.27735h-2.4407v3.1988z" fill="#3f424d" stroke-width=".38581" aria-label="RB"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m16.169 14.061c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395zm0 8c-1.4868 0-2.6934 0.8682-2.6934 1.9395 0 1.0713 1.2065 1.9395 2.6934 1.9395h15.664c1.4868 0 2.6914-0.8682 2.6914-1.9395 0-1.0713-1.2046-1.9395-2.6914-1.9395z" fill="#3f424d" fill-rule="evenodd" stroke-width=".19943"/></svg>

After

Width:  |  Height:  |  Size: 958 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m12.75 14.492c-0.62128 0-1.1257 0.38721-1.1257 0.86456v12.1c0 0.47737 0.50442 0.86456 1.1257 0.86456h3.3753v-1.7274h-2.2496v-10.373h13.498v1.7291h2.2496v-2.5937c0-0.47735-0.50268-0.86456-1.1239-0.86456zm6.7489 5.1874c-0.62128 0-1.1239 0.38721-1.1239 0.86456v12.1c0 0.47737 0.50266 0.86456 1.1239 0.86456h15.749c0.62125 0 1.1239-0.3872 1.1239-0.86456v-12.1c0-0.47735-0.50268-0.86456-1.1239-0.86456zm1.1257 1.7291h13.498v10.371h-13.498z" clip-rule="evenodd" fill="#3f424d" fill-rule="evenodd" stroke-width=".98604"/></svg>

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

View File

@@ -0,0 +1 @@
<svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.097 23.789-7.4272-10.314h5.8379l4.5082 7.0055 4.4758-7.0055h5.8379l-7.4272 10.314 7.7839 10.735h-5.8704l-4.8001-7.4596-4.8325 7.4596h-5.8379z" fill="#3f424d" stroke-width=".67675" aria-label="X"/></svg>

After

Width:  |  Height:  |  Size: 605 B

View File

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

After

Width:  |  Height:  |  Size: 559 B

View File

@@ -217,6 +217,56 @@ CONTEXT_MENU_STYLE = f"""
}}
"""
VIRTUAL_KEYBOARD_STYLE = """
VirtualKeyboard {
background-color: rgba(30, 30, 30, 200);
border-radius: 0px;
border: none;
}
QPushButton {
font-size: 14px;
border: 1px solid #555;
border-top-color: #666;
border-left-color: #666;
border-radius: 3px;
min-width: 30px;
min-height: 30px;
padding: 4px;
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #505050, stop:1 #404040);
color: #e0e0e0;
}
QPushButton:hover {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #606060, stop:1 #505050);
border: 1px solid #666;
border-top-color: #777;
border-left-color: #777;
}
QPushButton:focus {
border: 2px solid #4a90e2;
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5a5a5a, stop:1 #454545);
}
QPushButton:pressed {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a3a3a, stop:1 #303030);
border: 1px solid #444;
border-bottom-color: #555;
border-right-color: #555;
padding-top: 5px;
padding-bottom: 3px;
padding-left: 5px;
padding-right: 3px;
}
QPushButton[checked="true"] {
background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a90e2, stop:1 #3a7ad2);
color: white;
border: 1px solid #2a6ac2;
border-top-color: #5aa0f2;
border-left-color: #5aa0f2;
}
QPushButton[checked="true"]:focus {
border: 2px solid #6aa3f5;
}
"""
# ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК
MAIN_WINDOW_STYLE = f"""
QWidget {{
@@ -916,6 +966,96 @@ SETTINGS_CHECKBOX_STYLE = f"""
}}
"""
WINETRICKS_TAB_STYLE = f"""
QTabWidget::pane {{
border: 1px solid {color_d};
background: {color_b};
border-radius: {border_radius_a};
}}
QTabBar::tab {{
background: {color_c};
color: {color_f};
padding: 8px 16px;
border-top-left-radius: {border_radius_a};
border-top-right-radius: {border_radius_a};
margin-right: 2px;
}}
QTabBar::tab:selected {{
background: {color_a};
color: {color_f};
}}
QTabBar::tab:hover {{
background: {color_e};
}}
"""
WINETRICKS_TABBLE_STYLE = f"""
QTableWidget {{
background: {color_c};
color: {color_f};
gridline-color: {color_d};
alternate-background-color: {color_d};
border: {border_a};
border-radius: {border_radius_a};
font-family: '{font_family}';
font-size: {font_size_a};
}}
QHeaderView::section {{
background: {color_d};
color: {color_f};
padding: 5px;
border: {border_a};
font-weight: bold;
}}
QTableWidget::item {{
padding: 8px;
border-bottom: 1px solid {color_d};
}}
QTableWidget::item:selected {{
background: {color_a};
color: {color_f};
}}
QTableWidget::item:hover {{
background: {color_e};
}}
QTableWidget::indicator {{
width: 24px;
height: 24px;
border: {border_b} {color_a};
border-radius: {border_radius_a};
background: rgba(255, 255, 255, 0.1);
}}
QTableWidget::indicator:unchecked {{
background: rgba(255, 255, 255, 0.1);
image: none;
}}
QTableWidget::indicator:checked {{
background: {color_a};
image: url({theme_manager.get_icon("check", current_theme_name, as_path=True)});
border: {border_b} {color_f};
}}
QTableWidget::indicator:hover {{
background: rgba(255, 255, 255, 0.2);
border: {border_b} {color_a};
}}
QTableWidget::indicator:focus {{
border: {border_c} {color_a};
}}
{SCROLL_AREA_STYLE}
"""
WINETRICKS_LOG_STYLE = f"""
QTextEdit {{
background: {color_c};
border: {border_a};
border-radius: {border_radius_a};
color: {color_f};
font-family: '{font_family}';
font-size: {font_size_a};
padding: 5px;
}}
"""
FILE_EXPLORER_STYLE = f"""
QListView {{
font-size: {font_size_a};

View 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

View File

@@ -27,19 +27,19 @@ classifiers = [
requires-python = ">=3.10"
dependencies = [
"babel>=2.17.0",
"beautifulsoup4>=4.13.5",
"beautifulsoup4>=4.14.2",
"evdev>=1.9.2",
"icoextract>=0.2.0",
"numpy>=2.2.4",
"orjson>=3.11.2",
"orjson>=3.11.3",
"pillow>=11.3.0",
"psutil>=7.0.0",
"pyside6>=6.9.1",
"psutil>=7.1.0",
"pyside6==6.9.1",
"pyudev>=0.24.3",
"requests>=2.32.5",
"tqdm>=4.67.1",
"vdf>=3.4",
"websocket-client>=1.8.0",
"websocket-client>=1.9.0",
]
[project.scripts]
@@ -105,5 +105,5 @@ ignore = [
dev = [
"pre-commit>=4.3.0",
"pyaspeller>=2.0.2",
"pyright>=1.1.404",
"pyright>=1.1.406",
]

View File

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

976
uv.lock generated

File diff suppressed because it is too large Load Diff