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>
This commit is contained in:
2025-10-08 21:00:31 +05:00
parent 8b727f64e1
commit e9128206b5
5 changed files with 901 additions and 3 deletions

View File

@@ -17,6 +17,7 @@ from portprotonqt.logger import get_logger
from portprotonqt.theme_manager import ThemeManager from portprotonqt.theme_manager import ThemeManager
from portprotonqt.custom_widgets import AutoSizeButton from portprotonqt.custom_widgets import AutoSizeButton
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from portprotonqt.virtual_keyboard import VirtualKeyboard, connect_keyboard_to_lineedit
from portprotonqt.preloader import Preloader from portprotonqt.preloader import Preloader
import psutil import psutil
@@ -817,6 +818,69 @@ class AddGameDialog(QDialog):
if edit_mode: if edit_mode:
self.updatePreview() self.updatePreview()
# Инициализация клавиатуры (отдельным методом вроде лучше)
self.init_keyboard()
# Устанавливаем фокус на первое поле при открытии
QTimer.singleShot(0, self.nameEdit.setFocus)
def init_keyboard(self):
"""Инициализация виртуальной клавиатуры"""
self.keyboard = VirtualKeyboard(self)
self.keyboard.hide()
# Устанавливаем минимальные размеры
self.keyboard.setMinimumWidth(574)
self.keyboard.setMinimumHeight(220)
# Подключаем клавиатуру к полям ввода
connect_keyboard_to_lineedit(self.keyboard, self.nameEdit)
connect_keyboard_to_lineedit(self.keyboard, self.exeEdit)
connect_keyboard_to_lineedit(self.keyboard, self.coverEdit)
def show_keyboard_for_widget(self, widget):
"""Показывает клавиатуру для указанного виджета"""
if not widget or not widget.isVisible():
return
# Устанавливаем текущий виджет ввода
self.keyboard.current_input_widget = widget
# Позиционирование клавиатуры
keyboard_height = 220
self.keyboard.setFixedWidth(self.width())
self.keyboard.setFixedHeight(keyboard_height)
self.keyboard.move(0, self.height() - keyboard_height)
# Показываем и поднимаем клавиатуру
self.keyboard.setParent(self)
self.keyboard.show()
self.keyboard.raise_()
# TODO: доработать.
# Устанавливаем фокус на первую кнопку клавиатуры
first_button = self.keyboard.findFirstFocusableButton()
if first_button:
QTimer.singleShot(50, lambda: first_button.setFocus())
def closeEvent(self, event):
"""Обработчик закрытия окна"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().closeEvent(event)
def reject(self):
"""Обработчик кнопки Cancel"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().reject()
def accept(self):
"""Обработчик кнопки Apply"""
if hasattr(self, 'keyboard'):
self.keyboard.hide()
super().accept()
def browseExe(self): def browseExe(self):
"""Открывает файловый менеджер для выбора exe-файла""" """Открывает файловый менеджер для выбора exe-файла"""
try: try:

View File

@@ -14,6 +14,7 @@ from portprotonqt.custom_widgets import NavLabel, AutoSizeButton
from portprotonqt.game_card import GameCard from portprotonqt.game_card import GameCard
from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config from portprotonqt.config_utils import read_fullscreen_config, read_window_geometry, save_window_geometry, read_auto_fullscreen_gamepad, read_rumble_config
from portprotonqt.dialogs import AddGameDialog, WinetricksDialog from portprotonqt.dialogs import AddGameDialog, WinetricksDialog
from portprotonqt.virtual_keyboard import VirtualKeyboard
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -443,9 +444,30 @@ class InputManager(QObject):
@Slot(int) @Slot(int)
def handle_button_slot(self, button_code: int) -> None: 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: if not self._gamepad_handling_enabled:
return return
try: try:
app = QApplication.instance() app = QApplication.instance()
active = QApplication.activeWindow() active = QApplication.activeWindow()
focused = QApplication.focusWidget() focused = QApplication.focusWidget()
@@ -454,6 +476,21 @@ class InputManager(QObject):
if not app or not active: if not app or not active:
return return
if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit):
search_edit = getattr(self._parent, 'searchEdit', None)
if focused == search_edit:
keyboard = getattr(self._parent, 'keyboard', None)
if keyboard:
keyboard.show_for_widget(focused)
return
# Handle Y button to focus search
if button_code in BUTTONS['prev_dir']: # Y button
search_edit = getattr(self._parent, 'searchEdit', None)
if search_edit:
search_edit.setFocus()
return
# Handle Guide button to open system overlay # Handle Guide button to open system overlay
if button_code in BUTTONS['guide']: if button_code in BUTTONS['guide']:
if not popup and not isinstance(active, QDialog): if not popup and not isinstance(active, QDialog):
@@ -550,6 +587,7 @@ class InputManager(QObject):
self._parent.toggleGame(self._parent.current_exec_line, None) self._parent.toggleGame(self._parent.current_exec_line, None)
return return
if isinstance(active, WinetricksDialog): if isinstance(active, WinetricksDialog):
if button_code in BUTTONS['confirm']: # A button - toggle checkbox if button_code in BUTTONS['confirm']: # A button - toggle checkbox
current_table = active.tab_widget.currentWidget() current_table = active.tab_widget.currentWidget()
@@ -612,7 +650,6 @@ class InputManager(QObject):
new_value = max(size_slider.value() - 10, size_slider.minimum()) new_value = max(size_slider.value() - 10, size_slider.minimum())
size_slider.setValue(new_value) size_slider.setValue(new_value)
self._parent.on_slider_released() self._parent.on_slider_released()
except Exception as e: except Exception as e:
logger.error(f"Error in handle_button_slot: {e}", exc_info=True) logger.error(f"Error in handle_button_slot: {e}", exc_info=True)
@@ -627,6 +664,76 @@ class InputManager(QObject):
@Slot(int, int, float) @Slot(int, int, float)
def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None: def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None:
keyboard = None
active_window = QApplication.activeWindow()
# Проверяем клавиатуру в активном окне (AddGameDialog или главном окне)
if isinstance(active_window, AddGameDialog):
keyboard = getattr(active_window, 'keyboard', None)
else:
keyboard = getattr(self._parent, 'keyboard', None)
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: if not self._gamepad_handling_enabled:
return return
if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None: if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None:
@@ -641,6 +748,32 @@ class InputManager(QObject):
if not app or not active: if not app or not active:
return 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 # Update D-pad state
if value != 0: if value != 0:
self.current_dpad_code = code self.current_dpad_code = code
@@ -673,7 +806,7 @@ class InputManager(QObject):
elif value < 0: # Left elif value < 0: # Left
active.focusPreviousChild() active.focusPreviousChild()
return return
elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget): # Skip if focused on table elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 and not isinstance(focused, QTableWidget): # Keep up/down for other dialogs
if not focused or not active.focusWidget(): if not focused or not active.focusWidget():
# If no widget is focused, focus the first focusable widget # If no widget is focused, focus the first focusable widget
focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively) focusables = active.findChildren(QWidget, options=Qt.FindChildOption.FindChildrenRecursively)
@@ -726,6 +859,7 @@ class InputManager(QObject):
active.show_next() active.show_next()
return return
# Table navigation # Table navigation
if isinstance(focused, QTableWidget): if isinstance(focused, QTableWidget):
row_count = focused.rowCount() row_count = focused.rowCount()
@@ -1279,3 +1413,40 @@ class InputManager(QObject):
self.gamepad_type = GamepadType.UNKNOWN self.gamepad_type = GamepadType.UNKNOWN
except Exception as e: except Exception as e:
logger.error(f"Error during cleanup: {e}", exc_info=True) 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.downloader import Downloader
from portprotonqt.tray_manager import TrayManager from portprotonqt.tray_manager import TrayManager
from portprotonqt.game_library_manager import GameLibraryManager from portprotonqt.game_library_manager import GameLibraryManager
from portprotonqt.virtual_keyboard import VirtualKeyboard, connect_keyboard_to_lineedit
from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox,
QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout) QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout)
@@ -210,6 +210,10 @@ class MainWindow(QMainWindow):
self.restore_state() self.restore_state()
self.keyboard = VirtualKeyboard(self)
mainLayout.addWidget(self.keyboard)
connect_keyboard_to_lineedit(self.keyboard, self.searchEdit)
self.detail_animations = DetailPageAnimations(self, self.theme) self.detail_animations = DetailPageAnimations(self, self.theme)
QTimer.singleShot(0, self.loadGames) QTimer.singleShot(0, self.loadGames)

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 # Импортируем раскладки
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 keyPressEvent(self, event):
if event.key() == Qt.Key.Key_B or event.key() == Qt.Key.Key_Escape:
# Закрываем клавиатуру по нажатию B/Back
if isinstance(self._parent, QWidget):
self._parent.hide()
# Возвращаем фокус на диалог
if self._parent and self._parent.parent():
dialog_parent = self._parent.parent()
current_focused_field = getattr(dialog_parent, 'current_focused_field', None)
if current_focused_field:
current_focused_field.setFocus()
return
elif event.key() == Qt.Key.Key_X and not self.gamepad_x_pressed:
self.gamepad_x_pressed = True
self.on_backspace_pressed()
return
super().keyPressEvent(event)
def keyReleaseEvent(self, event):
# Обработка отпускания кнопки X геймпада
if event.key() == Qt.Key.Key_X:
self.gamepad_x_pressed = False
self.stop_backspace_repeat()
return
super().keyReleaseEvent(event)
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
def connect_keyboard_to_lineedit(keyboard: VirtualKeyboard, line_edit: QLineEdit):
"""Подключаем виртуальную клавиатуру к QLineEdit"""
original_mouse_press = line_edit.mousePressEvent
def custom_mouse_press(event):
# Сначала вызываем оригинальный обработчик
if original_mouse_press:
original_mouse_press(event)
# Затем показываем клавиатуру
keyboard.show_for_widget(line_edit)
line_edit.setFocus()
line_edit.mousePressEvent = custom_mouse_press
line_edit.setFocusPolicy(Qt.FocusPolicy.StrongFocus)