diff --git a/portprotonqt/dialogs.py b/portprotonqt/dialogs.py index 7dd6df2..4b6e2b4 100644 --- a/portprotonqt/dialogs.py +++ b/portprotonqt/dialogs.py @@ -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, connect_keyboard_to_lineedit from portprotonqt.preloader import Preloader import psutil @@ -817,6 +818,69 @@ 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) + + # Подключаем клавиатуру к полям ввода + 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): """Открывает файловый менеджер для выбора exe-файла""" try: diff --git a/portprotonqt/input_manager.py b/portprotonqt/input_manager.py index d9325f6..a8b1813 100644 --- a/portprotonqt/input_manager.py +++ b/portprotonqt/input_manager.py @@ -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() diff --git a/portprotonqt/keyboard_layouts.py b/portprotonqt/keyboard_layouts.py new file mode 100644 index 0000000..b2385dc --- /dev/null +++ b/portprotonqt/keyboard_layouts.py @@ -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', ';', ':', '_'] + ] + } +} diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 3bde398..c56e3ef 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -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, connect_keyboard_to_lineedit from PySide6.QtWidgets import (QLineEdit, QMainWindow, QStatusBar, QWidget, QVBoxLayout, QLabel, QHBoxLayout, QStackedWidget, QComboBox, QDialog, QFormLayout, QFrame, QGraphicsDropShadowEffect, QMessageBox, QApplication, QPushButton, QProgressBar, QCheckBox, QSizePolicy, QGridLayout) @@ -210,6 +210,10 @@ class MainWindow(QMainWindow): 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) QTimer.singleShot(0, self.loadGames) diff --git a/portprotonqt/virtual_keyboard.py b/portprotonqt/virtual_keyboard.py new file mode 100644 index 0000000..2388538 --- /dev/null +++ b/portprotonqt/virtual_keyboard.py @@ -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)