11 Commits

Author SHA1 Message Date
e1b4c44979 chore(keyboard): drop connect_keyboard_to_lineedit
All checks were successful
Code check / Check code (pull_request) Successful in 1m17s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 21:25:59 +05:00
70d837d89b chore(keyboard): drop unneded key events
All checks were successful
Code check / Check code (pull_request) Successful in 1m4s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 21:09:44 +05:00
e9128206b5 feat: added virtual keyboard
All checks were successful
Code check / Check code (pull_request) Successful in 1m11s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 21:00:31 +05:00
8b727f64e1 chore(changelog): update
All checks were successful
Code check / Check code (push) Successful in 1m8s
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
All checks were successful
Code check / Check code (push) Successful in 1m15s
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
Some checks failed
Code check / Check code (push) Failing after 1m21s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-10-08 19:04:45 +05:00
49d39b5d61 chore(pyright): fix code for new version
All checks were successful
Code check / Check code (push) Successful in 1m13s
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
All checks were successful
Code check / Check code (push) Successful in 1m3s
2025-10-08 12:09:42 +00:00
Renovate Bot
9e17978155 chore(deps): update ghcr.io/renovatebot/renovate:latest docker digest to 17c8966
Some checks failed
Code check / Check code (pull_request) Successful in 1m6s
Code check / Check code (push) Has been cancelled
2025-10-08 12:05:09 +00:00
11 changed files with 1413 additions and 483 deletions

View File

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

View File

@@ -20,6 +20,7 @@
- Исправлено зависание при добавлении или удалении игры в Wayland
- Исправлено зависание при поиске игр
- Исправлено ошибочное присвоение ID игры с названием "GAME", возникавшее, если исполняемый файл находился в подпапке `game/` (часто встречается у игр на Unity)
- Исправлена ошибка из-за которой подсказки по управлению снизу и сверху могли не совпадать с друг другом, из-за чего возле вкладок были стрелки клавиатуры, а снизу кнопки геймпада
### Contributors

View File

@@ -17,6 +17,7 @@ from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader
from portprotonqt.virtual_keyboard import VirtualKeyboard
from portprotonqt.preloader import Preloader
import psutil
@@ -510,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()
@@ -817,6 +818,64 @@ 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)
self.keyboard.hide()
# Устанавливаем минимальные размеры
self.keyboard.setMinimumWidth(574)
self.keyboard.setMinimumHeight(220)
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:

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

@@ -14,6 +14,7 @@ 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, WinetricksDialog
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__)
@@ -443,9 +444,30 @@ class InputManager(QObject):
@Slot(int)
def handle_button_slot(self, button_code: int) -> None:
active_window = QApplication.activeWindow()
# Обработка виртуальной клавиатуры в AddGameDialog
if isinstance(active_window, AddGameDialog):
focused = QApplication.focusWidget()
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
# Показываем клавиатуру при нажатии A на поле ввода
active_window.show_keyboard_for_widget(focused)
return
# Если клавиатура видима, обрабатываем её кнопки
if hasattr(active_window, 'keyboard') and active_window.keyboard.isVisible():
self.handle_virtual_keyboard(button_code)
return
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard and keyboard.isVisible():
self.handle_virtual_keyboard(button_code)
return
if not self._gamepad_handling_enabled:
return
try:
app = QApplication.instance()
active = QApplication.activeWindow()
focused = QApplication.focusWidget()
@@ -454,6 +476,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):
@@ -550,6 +587,7 @@ 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()
@@ -612,7 +650,6 @@ class InputManager(QObject):
new_value = max(size_slider.value() - 10, size_slider.minimum())
size_slider.setValue(new_value)
self._parent.on_slider_released()
except Exception as e:
logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
@@ -627,6 +664,76 @@ 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)
if keyboard and keyboard.isVisible():
# Обработка горизонтального перемещения (LEFT/RIGHT)
if code in (ecodes.ABS_HAT0X, ecodes.ABS_X):
if code == ecodes.ABS_X: # Левый стик
# Применяем мертвую зону
if abs(value) < self.dead_zone:
self.current_dpad_code = None
self.current_dpad_value = 0
self.dpad_timer.stop()
return
# Нормализуем значение стика (-1, 0, 1)
normalized_value = 1 if value > self.dead_zone else (-1 if value < -self.dead_zone else 0)
else: # D-pad
normalized_value = value # D-pad уже дает -1, 0, 1
if normalized_value != 0:
# Ограничиваем частоту перемещений
now = time.time()
if now - self.last_move_time < self.current_axis_delay:
return
self.last_move_time = now
self.current_axis_delay = self.repeat_axis_move_delay # Уменьшаем задержку после первого перемещения
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):
if code == ecodes.ABS_Y: # Левый стик
# Применяем мертвую зону
if abs(value) < self.dead_zone:
self.current_dpad_code = None
self.current_dpad_value = 0
self.dpad_timer.stop()
return
# Нормализуем значение стика (-1, 0, 1)
normalized_value = 1 if value > self.dead_zone else (-1 if value < -self.dead_zone else 0)
else: # D-pad
normalized_value = value # D-pad уже дает -1, 0, 1
if normalized_value != 0:
# Ограничиваем частоту перемещений
now = time.time()
if now - self.last_move_time < self.current_axis_delay:
return
self.last_move_time = now
self.current_axis_delay = self.repeat_axis_move_delay # Уменьшаем задержку после первого перемещения
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:
@@ -641,6 +748,32 @@ class InputManager(QObject):
if not app or not active:
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
# Update D-pad state
if value != 0:
self.current_dpad_code = code
@@ -673,7 +806,7 @@ class InputManager(QObject):
elif value < 0: # Left
active.focusPreviousChild()
return
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget): # Skip if focused on table
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget): # Keep up/down for other dialogs
if not focused or not active.focusWidget():
# If no widget is focused, focus the first focusable widget
focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
@@ -726,6 +859,7 @@ class InputManager(QObject):
active.show_next()
return
# Table navigation
if isinstance(focused, QTableWidget):
row_count = focused.rowCount()
@@ -1279,3 +1413,40 @@ class InputManager(QObject):
self.gamepad_type = GamepadType.UNKNOWN
except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True)
def handle_virtual_keyboard(self, button_code: 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 - подтверждение
keyboard.activateFocusedKey()
elif button_code in BUTTONS['back']: # Кнопка B/Circle - скрыть клавиатуру
keyboard.hide()
# Возвращаем фокус на поле ввода
if keyboard.current_input_widget:
keyboard.current_input_widget.setFocus()
elif button_code in BUTTONS['prev_tab']: # LB/L1 - переключение раскладки
keyboard.on_lang_click()
elif button_code in BUTTONS['next_tab']: # RB/R1 - переключение Shift
keyboard.on_shift_click(not keyboard.shift_pressed)
elif button_code in BUTTONS['context_menu']: # Кнопка Start - подтверждение
keyboard.activateFocusedKey()
elif button_code in BUTTONS['menu']: # Кнопка Select - скрыть клавиатуру
keyboard.hide()
# Возвращаем фокус на поле ввода
if keyboard.current_input_widget:
keyboard.current_input_widget.setFocus()
elif button_code in BUTTONS['add_game']: # Кнопка X - Backspace
keyboard.on_backspace_click()

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

@@ -35,7 +35,7 @@ from portprotonqt.howlongtobeat_api import HowLongToBeat
from portprotonqt.downloader import Downloader
from portprotonqt.tray_manager import TrayManager
from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.virtual_keyboard import VirtualKeyboard
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout)
@@ -206,8 +206,13 @@ class MainWindow(QMainWindow):
self.controlHintsWidget = self.createControlHintsWidget()
mainLayout.addWidget(self.controlHintsWidget)
self.updateControlHints()
self.restore_state()
self.keyboard = VirtualKeyboard(self)
mainLayout.addWidget(self.keyboard)
self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames)
@@ -430,6 +435,7 @@ class MainWindow(QMainWindow):
# Update navigation buttons
self.updateNavButtons()
@Slot(list)
def on_games_loaded(self, games: list[tuple]):
self.game_library_manager.set_games(games)
@@ -2066,10 +2072,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
@@ -2537,7 +2552,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

View File

@@ -0,0 +1,542 @@
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 # Импортируем раскладки
class VirtualKeyboard(QFrame):
keyPressed = Signal(str)
def __init__(self, parent: QWidget | None = None):
super().__init__(parent)
self._parent: QWidget | None = parent
self.available_layouts: list[str] = self.get_layouts_setxkbmap()
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.initUI()
self.hide()
self.setStyleSheet("""
VirtualKeyboard {
background-color: rgba(0, 0, 0, 200); /* Полупрозрачный серый */
border-radius: 5px;
border: 1px solid #ccc;
}
QPushButton {
font-size: 14px;
border: 1px solid #888;
border-radius: 3px;
min-width: 30px;
min-height: 30px;
padding: 0px;
}
QPushButton:pressed {
background-color: #d0d0d0;
}
QPushButton[checked="true"] {
background-color: #a0c4ff;
border: 1px solid #4a90e2;
}
QPushButton[checked="true"] {
background-color: #4a90e2;
color: white;
border: 2px solid #1a73e8;
}
""")
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(1)
self.keyboard_layout.setContentsMargins(0, 0, 0, 0)
self.create_keyboard()
keyboard_container = QWidget()
keyboard_container.setLayout(self.keyboard_layout)
# keyboard_container.setFixedSize(660, 220)
keyboard_container.setMinimumWidth(574)
keyboard_container.setMinimumHeight(220)
keyboard_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
layout.addWidget(keyboard_container, 0, Qt.AlignmentFlag.AlignHCenter)
self.setLayout(layout)
# self.setMinimumHeight(240)
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 = 40
fixed_h = 40
# Выбираем текущую раскладку (обычная или с 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, 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.setFixedHeight(fixed_h)
space.clicked.connect(lambda: self.on_button_click(' '))
self.keyboard_layout.addWidget(space, 4, 1, 1, 5)
backspace = QPushButton('')
backspace.setFixedSize(fixed_h, fixed_w)
# backspace.clicked.connect(self.on_backspace_click)
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_h*2) + 2, fixed_w)
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.setFixedHeight(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)
right = QPushButton('Hide')
right.setFixedSize((fixed_w*2) + 2, fixed_h)
right.clicked.connect(self.hide)
self.keyboard_layout.addWidget(right, 4, 12, 1, 2)
if coords:
# print('изменились координаты')
row, col = coords
# print(row, col)
item = self.keyboard_layout.itemAtPosition(row, col)
# print(item)
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
# Сохраняем состояние перед обновлением
# was_shift = self.shift_pressed
self.update_keyboard()
if key_to_restore and key_to_restore in self.buttons:
self.buttons[key_to_restore].setFocus()
# Восстанавливаем фокус
# if focused_button and focused_button in self.buttons.values():
# focused_button.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()
# available_layouts = list(keyboard_layouts.keys())
# 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]
# self.update_keyboard()
def on_shift_click(self, checked):
self.shift_pressed = checked
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.click()
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
# Поиск следующей кнопки
if direction == "right":
next_col = current_col + col_span
next_row = current_row
max_attempts = self.keyboard_layout.columnCount() - next_col
elif direction == "left":
next_col = current_col - 1
next_row = current_row
max_attempts = next_col + 1
elif direction == "down":
next_col = current_col
next_row = current_row + row_span
max_attempts = self.keyboard_layout.rowCount() - next_row
elif direction == "up":
next_col = current_col
next_row = current_row - 1
max_attempts = next_row + 1
else:
return
next_button = None
attempts = 0
while attempts < max_attempts:
item = self.keyboard_layout.itemAtPosition(next_row, next_col)
if item and item.widget() and item.widget().isEnabled():
next_button = cast(QPushButton, item.widget())
break
if direction == "right":
next_col += 1
elif direction == "left":
next_col -= 1
elif direction == "down":
next_row += 1
elif direction == "up":
next_row -= 1
attempts += 1
if next_button:
next_button.setFocus()
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