feat: added virtual keyboard #57
| @@ -10,6 +10,7 @@ | ||||
| - Импорт и экспорт бекапа префикса | ||||
| - Диалог для управление Winetricks | ||||
| - Кнопки для удаления префикса, wine или proton | ||||
| - Виртуальная клавиатура в диалог добавления игры и поиск по библиотеке | ||||
|  | ||||
| ### Changed | ||||
| - Проведён рефакторинг и оптимизация всего что связано с карточками и библиотекой игр | ||||
| @@ -45,6 +46,7 @@ | ||||
|  | ||||
| ### Contributors | ||||
| - @wmigor (Igor Akulov) | ||||
| - @Vector_null | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ Current translation status: | ||||
|  | ||||
| | Locale | Progress | Translated | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 232 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 232 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 232 of 232 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 of 233 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 of 233 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 of 233 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -21,9 +21,9 @@ | ||||
|  | ||||
| | Локаль | Прогресс | Переведено | | ||||
| | :----- | -------: | ---------: | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 232 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 232 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 232 из 232 | | ||||
| | [de_DE](./de_DE/LC_MESSAGES/messages.po) | 0% | 0 из 233 | | ||||
| | [es_ES](./es_ES/LC_MESSAGES/messages.po) | 0% | 0 из 233 | | ||||
| | [ru_RU](./ru_RU/LC_MESSAGES/messages.po) | 100% | 233 из 233 | | ||||
|  | ||||
| --- | ||||
|  | ||||
|   | ||||
| @@ -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 | ||||
|  | ||||
| @@ -817,6 +818,60 @@ class AddGameDialog(QDialog): | ||||
|         if edit_mode: | ||||
|             self.updatePreview() | ||||
|  | ||||
|         # Инициализация клавиатуры (отдельным методом вроде лучше) | ||||
|         self.init_keyboard() | ||||
|  | ||||
|         # Устанавливаем фокус на первое поле при открытии | ||||
|         QTimer.singleShot(0, self.nameEdit.setFocus) | ||||
|  | ||||
|     def init_keyboard(self): | ||||
|         """Инициализация виртуальной клавиатуры""" | ||||
|         self.keyboard = VirtualKeyboard(self, theme=self.theme, button_width=40) | ||||
|         self.keyboard.hide() | ||||
|  | ||||
|     def show_keyboard_for_widget(self, widget): | ||||
|         """Показывает клавиатуру для указанного виджета""" | ||||
|         if not widget or not widget.isVisible(): | ||||
|             return | ||||
|  | ||||
|         # Устанавливаем текущий виджет ввода | ||||
|         self.keyboard.current_input_widget = widget | ||||
|  | ||||
|         # Позиционирование клавиатуры | ||||
|         keyboard_height = 220 | ||||
|         self.keyboard.setFixedWidth(self.width()) | ||||
|         self.keyboard.setFixedHeight(keyboard_height) | ||||
|         self.keyboard.move(0, self.height() - keyboard_height) | ||||
|  | ||||
|         # Показываем и поднимаем клавиатуру | ||||
|         self.keyboard.setParent(self) | ||||
|         self.keyboard.show() | ||||
|         self.keyboard.raise_() | ||||
|  | ||||
|         # TODO: доработать. | ||||
|         # Устанавливаем фокус на первую кнопку клавиатуры | ||||
|         first_button = self.keyboard.findFirstFocusableButton() | ||||
|         if first_button: | ||||
|             QTimer.singleShot(50, lambda: first_button.setFocus()) | ||||
|  | ||||
|     def closeEvent(self, event): | ||||
|         """Обработчик закрытия окна""" | ||||
|         if hasattr(self, 'keyboard'): | ||||
|             self.keyboard.hide() | ||||
|         super().closeEvent(event) | ||||
|  | ||||
|     def reject(self): | ||||
|         """Обработчик кнопки Cancel""" | ||||
|         if hasattr(self, 'keyboard'): | ||||
|             self.keyboard.hide() | ||||
|         super().reject() | ||||
|  | ||||
|     def accept(self): | ||||
|         """Обработчик кнопки Apply""" | ||||
|         if hasattr(self, 'keyboard'): | ||||
|             self.keyboard.hide() | ||||
|         super().accept() | ||||
|  | ||||
|     def browseExe(self): | ||||
|         """Открывает файловый менеджер для выбора exe-файла""" | ||||
|         try: | ||||
|   | ||||
| @@ -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__) | ||||
|  | ||||
| @@ -71,7 +72,7 @@ class InputManager(QObject): | ||||
|     for seamless UI interaction. | ||||
|     """ | ||||
|     # Signals for gamepad events | ||||
|     button_pressed = Signal(int)  # Signal for button presses | ||||
|     button_event = Signal(int, int)  # Signal for button events: (code, value) where value=1 (press), 0 (release) | ||||
|     dpad_moved = Signal(int, int, float)  # Signal for D-pad movements | ||||
|     toggle_fullscreen = Signal(bool)  # Signal for toggling fullscreen mode (True for fullscreen, False for normal) | ||||
|  | ||||
| @@ -130,7 +131,7 @@ class InputManager(QObject): | ||||
|         self.current_dpad_value = 0    # Tracks the current D-pad direction value (e.g., -1, 1) | ||||
|  | ||||
|         # Connect signals to slots | ||||
|         self.button_pressed.connect(self.handle_button_slot) | ||||
|         self.button_event.connect(self.handle_button_slot) | ||||
|         self.dpad_moved.connect(self.handle_dpad_slot) | ||||
|         self.toggle_fullscreen.connect(self.handle_fullscreen_slot) | ||||
|  | ||||
| @@ -201,7 +202,9 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error restoring gamepad handlers: {e}") | ||||
|  | ||||
|     def handle_file_explorer_button(self, button_code): | ||||
|     def handle_file_explorer_button(self, button_code, value): | ||||
|         if value == 0:  # Ignore releases | ||||
|                     return | ||||
|         try: | ||||
|             popup = QApplication.activePopupWidget() | ||||
|             if isinstance(popup, QMenu): | ||||
| @@ -441,11 +444,37 @@ class InputManager(QObject): | ||||
|             except Exception as e: | ||||
|                 logger.error(f"Error stopping rumble: {e}", exc_info=True) | ||||
|  | ||||
|     @Slot(int) | ||||
|     def handle_button_slot(self, button_code: int) -> None: | ||||
|     @Slot(int, int) | ||||
|     def handle_button_slot(self, button_code: int, value: int) -> None: | ||||
|         active_window = QApplication.activeWindow() | ||||
|  | ||||
|         # Обработка виртуальной клавиатуры в AddGameDialog (handle both press and release) | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             focused = QApplication.focusWidget() | ||||
|             if button_code in BUTTONS['confirm'] and value == 1 and isinstance(focused, QLineEdit): | ||||
|                 # Показываем клавиатуру при нажатии A на поле ввода (only on press) | ||||
|                 active_window.show_keyboard_for_widget(focused) | ||||
|                 return | ||||
|  | ||||
|             # Если клавиатура видима, обрабатываем её кнопки (including release) | ||||
|             if hasattr(active_window, 'keyboard') and active_window.keyboard.isVisible(): | ||||
|                 self.handle_virtual_keyboard(button_code, value) | ||||
|                 return | ||||
|  | ||||
|         # Main window keyboard handling (including release) | ||||
|         keyboard = getattr(self._parent, 'keyboard', None) | ||||
|         if keyboard and keyboard.isVisible(): | ||||
|             self.handle_virtual_keyboard(button_code, value) | ||||
|             return | ||||
|  | ||||
|         # Ignore releases for all other (non-keyboard) button handling | ||||
|         if value == 0: | ||||
|             return | ||||
|  | ||||
|         if not self._gamepad_handling_enabled: | ||||
|             return | ||||
|         try: | ||||
|  | ||||
|             app = QApplication.instance() | ||||
|             active = QApplication.activeWindow() | ||||
|             focused = QApplication.focusWidget() | ||||
| @@ -454,6 +483,21 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             if button_code in BUTTONS['confirm'] and isinstance(focused, QLineEdit): | ||||
|                 search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 if focused == search_edit: | ||||
|                     keyboard = getattr(self._parent, 'keyboard', None) | ||||
|                     if keyboard: | ||||
|                         keyboard.show_for_widget(focused) | ||||
|                         return | ||||
|  | ||||
|                 # Handle Y button to focus search | ||||
|             if button_code in BUTTONS['prev_dir']:  # Y button | ||||
|                 search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                 if search_edit: | ||||
|                     search_edit.setFocus() | ||||
|                     return | ||||
|  | ||||
|             # Handle Guide button to open system overlay | ||||
|             if button_code in BUTTONS['guide']: | ||||
|                 if not popup and not isinstance(active, QDialog): | ||||
| @@ -550,6 +594,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 +657,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 +671,78 @@ class InputManager(QObject): | ||||
|  | ||||
|     @Slot(int, int, float) | ||||
|     def handle_dpad_slot(self, code: int, value: int, current_time: float) -> None: | ||||
|         keyboard = None | ||||
|         active_window = QApplication.activeWindow() | ||||
|  | ||||
|         # Проверяем клавиатуру в активном окне (AddGameDialog или главном окне) | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             keyboard = getattr(active_window, 'keyboard', None) | ||||
|         else: | ||||
|             keyboard = getattr(self._parent, 'keyboard', None) | ||||
|  | ||||
|         # Handle release early | ||||
|         if value == 0: | ||||
|             self.current_dpad_code = None | ||||
|             self.current_dpad_value = 0 | ||||
|             self.axis_moving = False | ||||
|             self.current_axis_delay = self.initial_axis_move_delay | ||||
|             self.dpad_timer.stop() | ||||
|             return | ||||
|  | ||||
|         # Update D-pad state for continuous movement | ||||
|         self.current_dpad_code = code | ||||
|         self.current_dpad_value = value | ||||
|         if not self.axis_moving: | ||||
|             self.axis_moving = True | ||||
|             self.last_move_time = current_time | ||||
|             self.current_axis_delay = self.initial_axis_move_delay | ||||
|             self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000)) | ||||
|  | ||||
|         if keyboard and keyboard.isVisible(): | ||||
|             # Обработка горизонтального перемещения (LEFT/RIGHT) | ||||
|             if code in (ecodes.ABS_HAT0X, ecodes.ABS_X): | ||||
|                 normalized_value = 0 | ||||
|                 if code == ecodes.ABS_X:  # Левый стик | ||||
|                     # Применяем мертвую зону | ||||
|                     if abs(value) < self.dead_zone: | ||||
|                         self.current_dpad_code = None | ||||
|                         self.current_dpad_value = 0 | ||||
|                         self.axis_moving = False | ||||
|                         self.dpad_timer.stop() | ||||
|                         return | ||||
|                     normalized_value = 1 if value > self.dead_zone else -1 | ||||
|                 else:  # D-pad | ||||
|                     normalized_value = value  # D-pad уже дает -1, 0, 1 | ||||
|  | ||||
|                 if normalized_value != 0: | ||||
|                     if normalized_value > 0:  # Вправо | ||||
|                         keyboard.move_focus_right() | ||||
|                     elif normalized_value < 0:  # Влево | ||||
|                         keyboard.move_focus_left() | ||||
|                 return | ||||
|  | ||||
|             # Обработка вертикального перемещения (UP/DOWN) | ||||
|             elif code in (ecodes.ABS_HAT0Y, ecodes.ABS_Y): | ||||
|                 normalized_value = 0 | ||||
|                 if code == ecodes.ABS_Y:  # Левый стик | ||||
|                     # Применяем мертвую зону | ||||
|                     if abs(value) < self.dead_zone: | ||||
|                         self.current_dpad_code = None | ||||
|                         self.current_dpad_value = 0 | ||||
|                         self.axis_moving = False | ||||
|                         self.dpad_timer.stop() | ||||
|                         return | ||||
|                     normalized_value = 1 if value > self.dead_zone else -1 | ||||
|                 else:  # D-pad | ||||
|                     normalized_value = value  # D-pad уже дает -1, 0, 1 | ||||
|  | ||||
|                 if normalized_value != 0: | ||||
|                     if normalized_value > 0:  # Вниз | ||||
|                         keyboard.move_focus_down() | ||||
|                     elif normalized_value < 0:  # Вверх | ||||
|                         keyboard.move_focus_up() | ||||
|                 return | ||||
|  | ||||
|         if not self._gamepad_handling_enabled: | ||||
|             return | ||||
|         if not hasattr(self._parent, 'gamesListWidget') or self._parent.gamesListWidget is None: | ||||
| @@ -641,22 +757,31 @@ class InputManager(QObject): | ||||
|             if not app or not active: | ||||
|                 return | ||||
|  | ||||
|             # Update D-pad state | ||||
|             if value != 0: | ||||
|                 self.current_dpad_code = code | ||||
|                 self.current_dpad_value = value | ||||
|                 if not self.axis_moving: | ||||
|                     self.axis_moving = True | ||||
|                     self.last_move_time = current_time | ||||
|                     self.current_axis_delay = self.initial_axis_move_delay | ||||
|                     self.dpad_timer.start(int(self.repeat_axis_move_delay * 1000))  # Start timer (in milliseconds) | ||||
|             else: | ||||
|                 self.current_dpad_code = None | ||||
|                 self.current_dpad_value = 0 | ||||
|                 self.axis_moving = False | ||||
|                 self.current_axis_delay = self.initial_axis_move_delay | ||||
|                 self.dpad_timer.stop()  # Stop timer when D-pad is released | ||||
|                 return | ||||
|             # Новый код: обработка перехода на поле поиска | ||||
|             if code == ecodes.ABS_HAT0Y and value < 0:  # Only D-pad up | ||||
|                 if isinstance(focused, GameCard): | ||||
|                     # Get all visible game cards | ||||
|                     game_cards = self._parent.gamesListWidget.findChildren(GameCard) | ||||
|                     if not game_cards: | ||||
|                         return | ||||
|  | ||||
|                     # Find the current card's position | ||||
|                     current_card_pos = focused.pos() | ||||
|                     current_row_y = current_card_pos.y() | ||||
|  | ||||
|                     # Check if this is the first row (no cards above) | ||||
|                     is_first_row = True | ||||
|                     for card in game_cards: | ||||
|                         if card.pos().y() < current_row_y and card.isVisible(): | ||||
|                             is_first_row = False | ||||
|                             break | ||||
|  | ||||
|                     # Only move to search if on first row | ||||
|                     if is_first_row: | ||||
|                         search_edit = getattr(self._parent, 'searchEdit', None) | ||||
|                         if search_edit: | ||||
|                             search_edit.setFocus() | ||||
|                             return | ||||
|  | ||||
|             # Handle SystemOverlay, AddGameDialog, or QMessageBox navigation with D-pad | ||||
|             if isinstance(active, QDialog) and code == ecodes.ABS_HAT0X and value != 0: | ||||
| @@ -673,7 +798,7 @@ class InputManager(QObject): | ||||
|                     elif value < 0:  # Left | ||||
|                         active.focusPreviousChild() | ||||
|                     return | ||||
|             elif isinstance(active, QDialog) and code == ecodes.ABS_HAT0Y and value != 0 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 +851,7 @@ class InputManager(QObject): | ||||
|                     active.show_next() | ||||
|                 return | ||||
|  | ||||
|  | ||||
|             # Table navigation | ||||
|             if isinstance(focused, QTableWidget): | ||||
|                 row_count = focused.rowCount() | ||||
| @@ -917,6 +1043,52 @@ class InputManager(QObject): | ||||
|         except Exception as e: | ||||
|             logger.error(f"Error in handle_dpad_slot: {e}", exc_info=True) | ||||
|  | ||||
|     def handle_virtual_keyboard(self, button_code: int, value: int) -> None: | ||||
|         # Проверяем клавиатуру в активном окне | ||||
|         active_window = QApplication.activeWindow() | ||||
|         keyboard = None | ||||
|  | ||||
|         # Сначала проверяем AddGameDialog | ||||
|         if isinstance(active_window, AddGameDialog): | ||||
|             keyboard = getattr(active_window, 'keyboard', None) | ||||
|         else: | ||||
|             # Если это не AddGameDialog, проверяем клавиатуру в главном окне | ||||
|             keyboard = getattr(self._parent, 'keyboard', None) | ||||
|  | ||||
|         if not keyboard or not isinstance(keyboard, VirtualKeyboard) or not keyboard.isVisible(): | ||||
|             return | ||||
|  | ||||
|         # Обработка кнопок геймпада | ||||
|         if button_code in BUTTONS['confirm']:  # Кнопка A/Cross - подтверждение | ||||
|             if value == 1: | ||||
|                 keyboard.activateFocusedKey() | ||||
|         elif button_code in BUTTONS['back']:  # Кнопка B/Circle - скрыть клавиатуру | ||||
|             if value == 1: | ||||
|                 keyboard.hide() | ||||
|                 # Возвращаем фокус на поле ввода | ||||
|                 if keyboard.current_input_widget: | ||||
|                     keyboard.current_input_widget.setFocus() | ||||
|         elif button_code in BUTTONS['prev_tab']:  # LB/L1 - переключение раскладки | ||||
|             if value == 1: | ||||
|                 keyboard.on_lang_click() | ||||
|         elif button_code in BUTTONS['next_tab']:  # RB/R1 - переключение Shift | ||||
|             if value == 1: | ||||
|                 keyboard.on_shift_click(not keyboard.shift_pressed) | ||||
|         elif button_code in BUTTONS['context_menu']:  # Кнопка Start - подтверждение | ||||
|             if value == 1: | ||||
|                 keyboard.activateFocusedKey() | ||||
|         elif button_code in BUTTONS['menu']:  # Кнопка Select - скрыть клавиатуру | ||||
|             if value == 1: | ||||
|                 keyboard.hide() | ||||
|                 # Возвращаем фокус на поле ввода | ||||
|                 if keyboard.current_input_widget: | ||||
|                     keyboard.current_input_widget.setFocus() | ||||
|         elif button_code in BUTTONS['add_game']:  # Кнопка X - Backspace (now holdable) | ||||
|             if value == 1: | ||||
|                 keyboard.on_backspace_pressed() | ||||
|             elif value == 0: | ||||
|                 keyboard.stop_backspace_repeat() | ||||
|  | ||||
|     def eventFilter(self, obj: QObject, event: QEvent) -> bool: | ||||
|         app = QApplication.instance() | ||||
|         if not app: | ||||
| @@ -1223,11 +1395,12 @@ class InputManager(QObject): | ||||
|                 if not app or not active: | ||||
|                     continue | ||||
|  | ||||
|                 if event.type == ecodes.EV_KEY and event.value == 1: | ||||
|                     if event.code in BUTTONS['menu'] and not self._is_gamescope_session: | ||||
|                 if event.type == ecodes.EV_KEY: | ||||
|                     # Emit on both press (1) and release (0) | ||||
|                     self.button_event.emit(event.code, event.value) | ||||
|                     # Special handling for menu on press only | ||||
|                     if event.value == 1 and event.code in BUTTONS['menu'] and not self._is_gamescope_session: | ||||
|                         self.toggle_fullscreen.emit(not self._is_fullscreen) | ||||
|                     else: | ||||
|                         self.button_pressed.emit(event.code) | ||||
|                 elif event.type == ecodes.EV_ABS: | ||||
|                     if event.code in {ecodes.ABS_Z, ecodes.ABS_RZ}: | ||||
|                         # Проверяем, достаточно ли времени прошло с последнего срабатывания | ||||
| @@ -1236,17 +1409,19 @@ class InputManager(QObject): | ||||
|                         if event.code == ecodes.ABS_Z:  # LT/L2 | ||||
|                             if event.value > 128 and not self.lt_pressed: | ||||
|                                 self.lt_pressed = True | ||||
|                                 self.button_pressed.emit(event.code) | ||||
|                                 self.button_event.emit(event.code, 1)  # Emit as press | ||||
|                                 self.last_trigger_time = now | ||||
|                             elif event.value <= 128 and self.lt_pressed: | ||||
|                                 self.lt_pressed = False | ||||
|                                 self.button_event.emit(event.code, 0)  # Emit as release | ||||
|                         elif event.code == ecodes.ABS_RZ:  # RT/R2 | ||||
|                             if event.value > 128 and not self.rt_pressed: | ||||
|                                 self.rt_pressed = True | ||||
|                                 self.button_pressed.emit(event.code) | ||||
|                                 self.button_event.emit(event.code, 1)  # Emit as press | ||||
|                                 self.last_trigger_time = now | ||||
|                             elif event.value <= 128 and self.rt_pressed: | ||||
|                                 self.rt_pressed = False | ||||
|                                 self.button_event.emit(event.code, 0)  # Emit as release | ||||
|                     else: | ||||
|                         self.dpad_moved.emit(event.code, event.value, now) | ||||
|         except OSError as e: | ||||
|   | ||||
							
								
								
									
										73
									
								
								portprotonqt/keyboard_layouts.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								portprotonqt/keyboard_layouts.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,73 @@ | ||||
| # keyboard_layouts.py | ||||
| keyboard_layouts = { | ||||
|     'en': { | ||||
|         'normal': [ | ||||
|             ['`', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '[', ']', '\\'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';', "'"], | ||||
|             ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '/'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['~', '!', '@', '#', '$', '%', '^', '&&', '*', '(', ')', '_', '+'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '{', '}', '|'], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':', '"'], | ||||
|             ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', '<', '>', '?'] | ||||
|         ] | ||||
|     }, | ||||
|     'ru': { | ||||
|         'normal': [ | ||||
|             ['ё', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '='], | ||||
|             ['TAB', 'й', 'ц', 'у', 'к', 'е', 'н', 'г', 'ш', 'щ', 'з', 'х', 'ъ', '\\'], | ||||
|             ['CAPS', 'ф', 'ы', 'в', 'а', 'п', 'р', 'о', 'л', 'д', 'ж', 'э'], | ||||
|             ['⬆', 'я', 'ч', 'с', 'м', 'и', 'т', 'ь', 'б', 'ю', '.'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['Ё', '!', '"', '№', ';', '%', ':', '?', '*', '(', ')', '_', '+'], | ||||
|             ['TAB', 'Й', 'Ц', 'У', 'К', 'Е', 'Н', 'Г', 'Ш', 'Щ', 'З', 'Х', 'Ъ', '/'], | ||||
|             ['CAPS', 'Ф', 'Ы', 'В', 'А', 'П', 'Р', 'О', 'Л', 'Д', 'Ж', 'Э'], | ||||
|             ['⬆', 'Я', 'Ч', 'С', 'М', 'И', 'Т', 'Ь', 'Б', 'Ю', ','] | ||||
|         ] | ||||
|     }, | ||||
|     'fr': { | ||||
|         'normal': [ | ||||
|             ['²', '&', 'é', '"', "'", '(', '-', 'è', '_', 'ç', 'à', ')', '='], | ||||
|             ['TAB', 'a', 'z', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '^', '$', '*'], | ||||
|             ['CAPS', 'q', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'm', 'ù'], | ||||
|             ['⬆', 'w', 'x', 'c', 'v', 'b', 'n', ',', ';', ':', '!'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '°', '+'], | ||||
|             ['TAB', 'A', 'Z', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '¨', '£', 'µ'], | ||||
|             ['CAPS', 'Q', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'M', '%'], | ||||
|             ['⬆', 'W', 'X', 'C', 'V', 'B', 'N', '?', '.', '/', '§'] | ||||
|         ] | ||||
|     }, | ||||
|     'es': { | ||||
|         'normal': [ | ||||
|             ['º', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', "'", '¡'], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p', '`', '+', '\\'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ñ', "'", 'ç'], | ||||
|             ['⬆', 'z', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['ª', '!', '"', '·', '$', '%', '&', '/', '(', ')', '=', '?', '¿'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '^', '*', '|'], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ñ', '"', 'Ç'], | ||||
|             ['⬆', 'Z', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_'] | ||||
|         ] | ||||
|     }, | ||||
|     'de': { | ||||
|         'normal': [ | ||||
|             ['^', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'ß', '´'], | ||||
|             ['TAB', 'q', 'w', 'e', 'r', 't', 'z', 'u', 'i', 'o', 'p', 'ü', '+', '#'], | ||||
|             ['CAPS', 'a', 's', 'd', 'f', 'g', 'h', 'j', 'k', 'l', 'ö', 'ä'], | ||||
|             ['⬆', '<', 'y', 'x', 'c', 'v', 'b', 'n', 'm', ',', '.', '-'] | ||||
|         ], | ||||
|         'shift': [ | ||||
|             ['°', '!', '"', '§', '$', '%', '&', '/', '(', ')', '=', '?', '`'], | ||||
|             ['TAB', 'Q', 'W', 'E', 'R', 'T', 'Z', 'U', 'I', 'O', 'P', 'Ü', '*', '\''], | ||||
|             ['CAPS', 'A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', 'Ö', 'Ä'], | ||||
|             ['⬆', '>', 'Y', 'X', 'C', 'V', 'B', 'N', 'M', ';', ':', '_'] | ||||
|         ] | ||||
|     } | ||||
| } | ||||
										
											Binary file not shown.
										
									
								
							| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-07 15:45+0500\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: de_DE\n" | ||||
| @@ -413,6 +413,9 @@ msgstr "" | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-07 15:45+0500\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language: es_ES\n" | ||||
| @@ -413,6 +413,9 @@ msgstr "" | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
| @@ -9,7 +9,7 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PortProtonQt 0.1.1\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-07 15:45+0500\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" | ||||
| "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" | ||||
| "Language-Team: LANGUAGE <LL@li.org>\n" | ||||
| @@ -411,6 +411,9 @@ msgstr "" | ||||
| msgid "Fullscreen" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "" | ||||
|  | ||||
|   | ||||
										
											Binary file not shown.
										
									
								
							| @@ -9,8 +9,8 @@ msgid "" | ||||
| msgstr "" | ||||
| "Project-Id-Version: PROJECT VERSION\n" | ||||
| "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" | ||||
| "POT-Creation-Date: 2025-10-07 15:45+0500\n" | ||||
| "PO-Revision-Date: 2025-10-07 15:44+0500\n" | ||||
| "POT-Creation-Date: 2025-10-09 16:37+0500\n" | ||||
| "PO-Revision-Date: 2025-10-09 16:37+0500\n" | ||||
| "Last-Translator: \n" | ||||
| "Language: ru_RU\n" | ||||
| "Language-Team: ru_RU <LL@li.org>\n" | ||||
| @@ -420,6 +420,9 @@ msgstr "Назад" | ||||
| msgid "Fullscreen" | ||||
| msgstr "Полный экран" | ||||
|  | ||||
| msgid "Search" | ||||
| msgstr "Поиск" | ||||
|  | ||||
| msgid "Loading Steam games..." | ||||
| msgstr "Загрузка игр из Steam..." | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
| @@ -145,7 +145,7 @@ class MainWindow(QMainWindow): | ||||
|         headerLayout.addStretch() | ||||
|  | ||||
|         self.input_manager = InputManager(self) # type: ignore | ||||
|         self.input_manager.button_pressed.connect(self.updateControlHints) | ||||
|         self.input_manager.button_event.connect(self.updateControlHints) | ||||
|         self.input_manager.dpad_moved.connect(self.updateControlHints) | ||||
|  | ||||
|         # 2. НАВИГАЦИЯ (КНОПКИ ВКЛАДОК) | ||||
| @@ -210,6 +210,8 @@ class MainWindow(QMainWindow): | ||||
|  | ||||
|         self.restore_state() | ||||
|  | ||||
|         self.keyboard = VirtualKeyboard(self, self.theme) | ||||
|  | ||||
|         self.detail_animations = DetailPageAnimations(self, self.theme) | ||||
|         QTimer.singleShot(0, self.loadGames) | ||||
|  | ||||
| @@ -250,6 +252,10 @@ class MainWindow(QMainWindow): | ||||
|                 GamepadType.XBOX: "xbox_view", | ||||
|                 GamepadType.PLAYSTATION: "ps_share", | ||||
|             }, | ||||
|             'search': { | ||||
|                 GamepadType.XBOX: "xbox_y", | ||||
|                 GamepadType.PLAYSTATION: "ps_square", | ||||
|             }, | ||||
|         } | ||||
|         return mappings.get(action, {}).get(gtype, "placeholder") | ||||
|  | ||||
| @@ -288,6 +294,7 @@ class MainWindow(QMainWindow): | ||||
|             ("add_game", _("Add Game")), | ||||
|             ("context_menu", _("Menu")), | ||||
|             ("menu", _("Fullscreen")), | ||||
|             ("search", _("Search")), | ||||
|         ] | ||||
|  | ||||
|         keyboard_hints = [ | ||||
| @@ -400,7 +407,7 @@ class MainWindow(QMainWindow): | ||||
|         gtype = self.input_manager.gamepad_type | ||||
|         logger.debug("Updating control hints, gamepad connected: %s, type: %s", is_gamepad_connected, gtype.value) | ||||
|  | ||||
|         gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu'] | ||||
|         gamepad_actions = ['confirm', 'back', 'add_game', 'context_menu', 'menu', 'search'] | ||||
|  | ||||
|         for container, icon_label, action in self.hintsLabels: | ||||
|             if action in gamepad_actions:  # Gamepad hint | ||||
| @@ -432,7 +439,6 @@ 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) | ||||
|   | ||||
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/ps_square.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								portprotonqt/themes/standart/images/ps_square.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m33.019 13.476h-18.037c-0.8274 0-1.5059 0.67847-1.5059 1.5059v18.037c0 0.8274 0.67847 1.5059 1.5059 1.5059h18.037c0.8274 0 1.5059-0.67847 1.5059-1.5059v-18.037c0-0.8274-0.66192-1.5059-1.5059-1.5059zm-1.4893 18.037h-15.026v-15.026h15.026z" fill="#3f424d" stroke-width="1.6548"/></svg> | ||||
| After Width: | Height: | Size: 682 B | 
							
								
								
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_y.svg
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								portprotonqt/themes/standart/images/xbox_y.svg
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| <svg width="48" height="48" version="1.1" viewBox="0 0 48 48" xml:space="preserve" xmlns="http://www.w3.org/2000/svg"><path d="m47 24a23 23 0 0 1-23 23 23 23 0 0 1-23-23 23 23 0 0 1 23-23 23 23 0 0 1 23 23z" fill="#3f424d"/><path d="M 42.513,24 A 18.513,18.513 0 0 1 24,42.513 18.513,18.513 0 0 1 5.4869995,24 18.513,18.513 0 0 1 24,5.4869995 18.513,18.513 0 0 1 42.513,24 Z" fill="#fff"/><path d="m21.438 26.092-7.6218-12.616h5.7406l4.4433 8.238 4.4109-8.238h5.7731l-7.6866 12.552v8.4974h-5.0595z" fill="#3f424d" stroke-width="1.0811" aria-label="Y"/></svg> | ||||
| After Width: | Height: | Size: 559 B | 
| @@ -217,6 +217,56 @@ CONTEXT_MENU_STYLE = f""" | ||||
|     }} | ||||
| """ | ||||
|  | ||||
| VIRTUAL_KEYBOARD_STYLE = """ | ||||
| VirtualKeyboard { | ||||
|     background-color: rgba(30, 30, 30, 200); | ||||
|     border-radius: 0px; | ||||
|     border: none; | ||||
| } | ||||
| QPushButton { | ||||
|     font-size: 14px; | ||||
|     border: 1px solid #555; | ||||
|     border-top-color: #666; | ||||
|     border-left-color: #666; | ||||
|     border-radius: 3px; | ||||
|     min-width: 30px; | ||||
|     min-height: 30px; | ||||
|     padding: 4px; | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #505050, stop:1 #404040); | ||||
|     color: #e0e0e0; | ||||
| } | ||||
| QPushButton:hover { | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #606060, stop:1 #505050); | ||||
|     border: 1px solid #666; | ||||
|     border-top-color: #777; | ||||
|     border-left-color: #777; | ||||
| } | ||||
| QPushButton:focus { | ||||
|     border: 2px solid #4a90e2; | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #5a5a5a, stop:1 #454545); | ||||
| } | ||||
| QPushButton:pressed { | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #3a3a3a, stop:1 #303030); | ||||
|     border: 1px solid #444; | ||||
|     border-bottom-color: #555; | ||||
|     border-right-color: #555; | ||||
|     padding-top: 5px; | ||||
|     padding-bottom: 3px; | ||||
|     padding-left: 5px; | ||||
|     padding-right: 3px; | ||||
| } | ||||
| QPushButton[checked="true"] { | ||||
|     background-color: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 #4a90e2, stop:1 #3a7ad2); | ||||
|     color: white; | ||||
|     border: 1px solid #2a6ac2; | ||||
|     border-top-color: #5aa0f2; | ||||
|     border-left-color: #5aa0f2; | ||||
| } | ||||
| QPushButton[checked="true"]:focus { | ||||
|     border: 2px solid #6aa3f5; | ||||
| } | ||||
| """ | ||||
|  | ||||
| # ГЛОБАЛЬНЫЙ СТИЛЬ ДЛЯ ОКНА (ФОН), ЛЭЙБЛОВ, КНОПОК | ||||
| MAIN_WINDOW_STYLE = f""" | ||||
|     QWidget {{ | ||||
|   | ||||
							
								
								
									
										586
									
								
								portprotonqt/virtual_keyboard.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										586
									
								
								portprotonqt/virtual_keyboard.py
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,586 @@ | ||||
| from typing import cast | ||||
| from PySide6.QtWidgets import (QFrame, QVBoxLayout, QPushButton, QGridLayout, | ||||
|                                QSizePolicy, QWidget, QLineEdit) | ||||
| from PySide6.QtCore import Qt, Signal, QProcess | ||||
| from portprotonqt.keyboard_layouts import keyboard_layouts | ||||
| from portprotonqt.theme_manager import ThemeManager | ||||
| from portprotonqt.config_utils import read_theme_from_config | ||||
|  | ||||
| theme_manager = ThemeManager() | ||||
|  | ||||
| class VirtualKeyboard(QFrame): | ||||
|     keyPressed = Signal(str) | ||||
|  | ||||
|     def __init__(self, parent: QWidget | None = None, theme=None, button_width: int = 80): | ||||
|         super().__init__(parent) | ||||
|         self._parent: QWidget | None = parent | ||||
|         self.available_layouts: list[str] = self.get_layouts_setxkbmap() | ||||
|         self.theme = theme if theme else theme_manager.apply_theme(read_theme_from_config()) | ||||
|         if not self.available_layouts: | ||||
|             self.available_layouts.append('en') | ||||
|         self.current_layout: str = self.available_layouts[0] | ||||
|  | ||||
|         self.focus_timer = None | ||||
|         self.focus_delay = 150  # Задержка между перемещениями в мс | ||||
|         self.last_focus_time = 0 | ||||
|  | ||||
|         self.backspace_pressed = False | ||||
|         self.backspace_timer = None | ||||
|         self.backspace_initial_delay = 500 | ||||
|         self.backspace_repeat_delay = 50 | ||||
|         self.gamepad_x_pressed = False | ||||
|         self.caps_lock = False | ||||
|         self.shift_pressed = False | ||||
|         self.current_input_widget = None | ||||
|         self.cursor_visible = True | ||||
|         self.last_focused_button = None | ||||
|  | ||||
|         self.base_button_width = 40 | ||||
|         self.base_min_width = 574 | ||||
|         self.button_width = button_width | ||||
|         self.button_height = 40 | ||||
|         self.spacing = 4 | ||||
|         self.margins = 10 | ||||
|         self.num_cols = 14 | ||||
|  | ||||
|         self.initUI() | ||||
|         self.hide() | ||||
|  | ||||
|         self.setStyleSheet(self.theme.VIRTUAL_KEYBOARD_STYLE) | ||||
|  | ||||
|     def highlight_cursor_position(self): | ||||
|         """Подсвечиваем текущую позицию курсора""" | ||||
|         if not self.current_input_widget or not isinstance(self.current_input_widget, QLineEdit): | ||||
|             return | ||||
|  | ||||
|         # Просто устанавливаем курсор на нужную позицию без выделения | ||||
|         self.current_input_widget.setCursorPosition(self.current_input_widget.cursorPosition()) | ||||
|  | ||||
|     def initUI(self): | ||||
|         layout = QVBoxLayout() | ||||
|         layout.setContentsMargins(0, 0, 0, 0) | ||||
|         layout.setSpacing(0) | ||||
|  | ||||
|         self.keyboard_layout = QGridLayout() | ||||
|         self.keyboard_layout.setSpacing(self.spacing) | ||||
|         self.keyboard_layout.setContentsMargins(self.margins // 2, self.margins // 2, self.margins // 2, self.margins // 2) | ||||
|         self.create_keyboard() | ||||
|  | ||||
|         self.keyboard_container = QWidget() | ||||
|         self.keyboard_container.setLayout(self.keyboard_layout) | ||||
|         ratio = self.button_width / self.base_button_width | ||||
|         self.keyboard_container.setMinimumWidth(int(self.base_min_width * ratio)) | ||||
|         self.keyboard_container.setMinimumHeight(220) | ||||
|         self.keyboard_container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) | ||||
|  | ||||
|         layout.addWidget(self.keyboard_container, 0, Qt.AlignmentFlag.AlignHCenter) | ||||
|         self.setLayout(layout) | ||||
|         self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) | ||||
|  | ||||
|     def run_shell_command(self, cmd: str) -> str | None: | ||||
|         process = QProcess(self) | ||||
|         process.start("sh", ["-c", cmd]) | ||||
|         process.waitForFinished(-1) | ||||
|         if process.exitCode() == 0: | ||||
|             output_bytes = process.readAllStandardOutput().data() | ||||
|             if isinstance(output_bytes, memoryview): | ||||
|                 output_str = output_bytes.tobytes().decode('utf-8').strip() | ||||
|             else: | ||||
|                 output_str = output_bytes.decode('utf-8').strip() | ||||
|             return output_str | ||||
|         else: | ||||
|             return None | ||||
|  | ||||
|     def get_layouts_setxkbmap(self) -> list[str]: | ||||
|         """Получаем раскладки, которые используются в системе, возвращаем список вида ['us', 'ru'] и т.п.""" | ||||
|         cmd = r'''localectl status | awk -F: '/X11 Layout/ {gsub(/^[ \t]+/, "", $2); print $2}' ''' | ||||
|         output = self.run_shell_command(cmd) | ||||
|         if output: | ||||
|             layouts = [lang.strip() for lang in output.split(',') if lang.strip()] | ||||
|             return layouts if layouts else ['en'] | ||||
|         else: | ||||
|             return ['en'] | ||||
|  | ||||
|     def create_keyboard(self): | ||||
|         # TODO: сделать нормальное описание (сейчас лень) | ||||
|         # Основные раскладки с учетом Shift | ||||
|         # Фильтруем доступные раскладки | ||||
|  | ||||
|         LAYOUT_MAP = {'us': 'en'} | ||||
|  | ||||
|         # Assume keyboard_layouts is dict[str, dict[str, list[list[str]]]] | ||||
|         self.layouts: dict[str, dict[str, list[list[str]]]] = { | ||||
|             lang: keyboard_layouts.get(LAYOUT_MAP.get(lang, lang), keyboard_layouts['en']) | ||||
|             for lang in self.available_layouts | ||||
|         } | ||||
|  | ||||
|         self.current_layout = (self.current_layout if self.current_layout in self.layouts else next(iter(self.layouts.keys()), None) or 'en') | ||||
|  | ||||
|         self.buttons: dict[str, QPushButton] = {} | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     def update_keyboard(self): | ||||
|         coords = self._save_focused_coords() | ||||
|  | ||||
|         # Очищаем предыдущие кнопки | ||||
|         while self.keyboard_layout.count(): | ||||
|             item = self.keyboard_layout.takeAt(0) | ||||
|             if item.widget(): | ||||
|                 item.widget().deleteLater() | ||||
|  | ||||
|         fixed_w = self.button_width | ||||
|         fixed_h = self.button_height | ||||
|  | ||||
|         # Выбираем текущую раскладку (обычная или с shift) | ||||
|         layout_mode = 'shift' if self.shift_pressed else 'normal' | ||||
|         layout_data = self.layouts.get(self.current_layout, {}) | ||||
|         buttons: list[list[str]] = layout_data.get(layout_mode, []) | ||||
|  | ||||
|         # Добавляем основные кнопки | ||||
|         for row_idx, row in enumerate(buttons): | ||||
|             for col_idx, key in enumerate(row): | ||||
|                 button = QPushButton(key) | ||||
|                 button.setFixedSize(fixed_w, fixed_h) | ||||
|  | ||||
|                 # Обработчики для CAPS и левого Shift | ||||
|                 if key == 'CAPS': | ||||
|                     button.setCheckable(True) | ||||
|                     button.setChecked(self.caps_lock) | ||||
|                     button.clicked.connect(self.on_caps_click) | ||||
|                 elif key == '⬆':  # Левый Shift | ||||
|                     button.setCheckable(True) | ||||
|                     button.setChecked(self.shift_pressed) | ||||
|                     button.clicked.connect(lambda checked: self.on_shift_click(checked)) | ||||
|                 else: | ||||
|                     button.clicked.connect(lambda checked=False, k=key: self.on_button_click(k)) | ||||
|  | ||||
|                 self.keyboard_layout.addWidget(button, row_idx, col_idx) | ||||
|                 self.buttons[key] = button | ||||
|  | ||||
|         # Нижний ряд (специальные кнопки) | ||||
|         shift = QPushButton('⬆') | ||||
|         shift.setFixedSize(fixed_w * 3 + 2 * self.spacing, fixed_h) | ||||
|         shift.setCheckable(True) | ||||
|         shift.setChecked(self.shift_pressed) | ||||
|         shift.clicked.connect(lambda checked: self.on_shift_click(checked)) | ||||
|         self.keyboard_layout.addWidget(shift, 3, 11, 1, 3) | ||||
|  | ||||
|         button = QPushButton('CAPS') | ||||
|         button.setCheckable(True) | ||||
|         button.setChecked(self.caps_lock) | ||||
|         button.clicked.connect(self.on_caps_click) | ||||
|  | ||||
|         space = QPushButton('Space') | ||||
|         space.setFixedSize(fixed_w * 5 + 4 * self.spacing, fixed_h) | ||||
|         space.clicked.connect(lambda: self.on_button_click(' ')) | ||||
|         self.keyboard_layout.addWidget(space, 4, 1, 1, 5) | ||||
|  | ||||
|         backspace = QPushButton('⌫') | ||||
|         backspace.setFixedSize(fixed_w, fixed_h) | ||||
|         backspace.pressed.connect(self.on_backspace_pressed) | ||||
|         backspace.released.connect(self.stop_backspace_repeat) | ||||
|         self.keyboard_layout.addWidget(backspace, 0, 13, 1, 1) | ||||
|  | ||||
|         enter = QPushButton('Enter') | ||||
|         enter.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) | ||||
|         enter.clicked.connect(self.on_enter_click) | ||||
|         self.keyboard_layout.addWidget(enter, 2, 12, 1, 2) | ||||
|  | ||||
|         lang = QPushButton('🌐') | ||||
|         lang.setFixedSize(fixed_w, fixed_h) | ||||
|         lang.clicked.connect(self.on_lang_click) | ||||
|         self.keyboard_layout.addWidget(lang, 4, 0, 1, 1) | ||||
|  | ||||
|         clear = QPushButton('Clear') | ||||
|         clear.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) | ||||
|         clear.clicked.connect(self.on_clear_click) | ||||
|         self.keyboard_layout.addWidget(clear, 4, 10, 1, 2) | ||||
|  | ||||
|         up = QPushButton('▲') | ||||
|         up.setFixedSize(fixed_w, fixed_h) | ||||
|         up.clicked.connect(self.up_key)  # Обработка клика мышью - управление курсором | ||||
|         self.keyboard_layout.addWidget(up, 4, 6, 1, 1) | ||||
|  | ||||
|         down = QPushButton('▼') | ||||
|         down.setFixedSize(fixed_w, fixed_h) | ||||
|         down.clicked.connect(self.down_key) | ||||
|         self.keyboard_layout.addWidget(down, 4, 7, 1, 1) | ||||
|  | ||||
|         left = QPushButton('◄') | ||||
|         left.setFixedSize(fixed_w, fixed_h) | ||||
|         left.clicked.connect(self.left_key) | ||||
|         self.keyboard_layout.addWidget(left, 4, 8, 1, 1) | ||||
|  | ||||
|         right = QPushButton('►') | ||||
|         right.setFixedSize(fixed_w, fixed_h) | ||||
|         right.clicked.connect(self.right_key) | ||||
|         self.keyboard_layout.addWidget(right, 4, 9, 1, 1) | ||||
|  | ||||
|         hide_button = QPushButton('Hide') | ||||
|         hide_button.setFixedSize(fixed_w * 2 + self.spacing, fixed_h) | ||||
|         hide_button.clicked.connect(self.hide) | ||||
|         self.keyboard_layout.addWidget(hide_button, 4, 12, 1, 2) | ||||
|  | ||||
|         if coords: | ||||
|             row, col = coords | ||||
|             item = self.keyboard_layout.itemAtPosition(row, col) | ||||
|             if item and item.widget(): | ||||
|                 item.widget().setFocus() | ||||
|  | ||||
|     def up_key(self): | ||||
|         """Перемещает курсор в QLineEdit вверх/в начало, если клавиатура видима""" | ||||
|         if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): | ||||
|             self.current_input_widget.setCursorPosition(0) | ||||
|             self.current_input_widget.setFocus() | ||||
|  | ||||
|     def down_key(self): | ||||
|         """Перемещает курсор в QLineEdit вниз/в конец, если клавиатура видима""" | ||||
|         if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): | ||||
|             self.current_input_widget.setCursorPosition(len(self.current_input_widget.text())) | ||||
|             self.current_input_widget.setFocus() | ||||
|  | ||||
|     def left_key(self): | ||||
|         """Перемещает курсор в QLineEdit влево, если клавиатура видима""" | ||||
|         if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): | ||||
|             pos = self.current_input_widget.cursorPosition() | ||||
|             if pos > 0: | ||||
|                 self.current_input_widget.setCursorPosition(pos - 1) | ||||
|             self.current_input_widget.setFocus() | ||||
|  | ||||
|     def right_key(self): | ||||
|         """Перемещает курсор в QLineEdit вправо, если клавиатура видима""" | ||||
|         if self.current_input_widget and isinstance(self.current_input_widget, QLineEdit): | ||||
|             pos = self.current_input_widget.cursorPosition() | ||||
|             text_len = len(self.current_input_widget.text()) | ||||
|             if pos < text_len: | ||||
|                 self.current_input_widget.setCursorPosition(pos + 1) | ||||
|             self.current_input_widget.setFocus() | ||||
|  | ||||
|     def move_focus_up(self): | ||||
|         """Перемещает фокус по кнопкам клавиатуры вверх с фиксированной скоростью""" | ||||
|         current_time = self.get_current_time() | ||||
|         if current_time - self.last_focus_time >= self.focus_delay: | ||||
|             self.focusNextKey("up") | ||||
|             self.last_focus_time = current_time | ||||
|  | ||||
|     def move_focus_down(self): | ||||
|         """Перемещает фокус по кнопкам клавиатуры вниз с фиксированной скоростью""" | ||||
|         current_time = self.get_current_time() | ||||
|         if current_time - self.last_focus_time >= self.focus_delay: | ||||
|             self.focusNextKey("down") | ||||
|             self.last_focus_time = current_time | ||||
|  | ||||
|     def move_focus_left(self): | ||||
|         """Перемещает фокус по кнопкам клавиатуры влево с фиксированной скоростью""" | ||||
|         current_time = self.get_current_time() | ||||
|         if current_time - self.last_focus_time >= self.focus_delay: | ||||
|             self.focusNextKey("left") | ||||
|             self.last_focus_time = current_time | ||||
|  | ||||
|     def move_focus_right(self): | ||||
|         """Перемещает фокус по кнопкам клавиатуры вправо с фиксированной скоростью""" | ||||
|         current_time = self.get_current_time() | ||||
|         if current_time - self.last_focus_time >= self.focus_delay: | ||||
|             self.focusNextKey("right") | ||||
|             self.last_focus_time = current_time | ||||
|  | ||||
|     def get_current_time(self): | ||||
|         """Возвращает текущее время в миллисекундах""" | ||||
|         from time import time | ||||
|         return int(time() * 1000) | ||||
|  | ||||
|     def _save_focused_coords(self) -> tuple[int, int] | None: | ||||
|         """Возвращает (row, col) кнопки с фокусом или None""" | ||||
|         current = self.focusWidget() | ||||
|         if not current: | ||||
|             return None | ||||
|         idx = self.keyboard_layout.indexOf(current) | ||||
|         if idx == -1: | ||||
|             return None | ||||
|         position = cast(tuple[int, int, int, int], self.keyboard_layout.getItemPosition(idx)) | ||||
|         return position[:2]  # row, col | ||||
|  | ||||
|     def on_button_click(self, key): | ||||
|         if key in ['TAB', 'CAPS', '⬆']: | ||||
|             if key == 'TAB': | ||||
|                 self.on_tab_click() | ||||
|             elif key == 'CAPS': | ||||
|                 self.on_caps_click() | ||||
|             elif key == '⬆': | ||||
|                 self.on_shift_click(not self.shift_pressed) | ||||
|             self.highlight_cursor_position() | ||||
|         elif self.current_input_widget is not None: | ||||
|             # Сохраняем текущую кнопку с фокусом | ||||
|             focused_button = self.focusWidget() | ||||
|             key_to_restore = None | ||||
|             if isinstance(focused_button, QPushButton) and focused_button in self.buttons.values(): | ||||
|                 key_to_restore = next((k for k, btn in self.buttons.items() if btn == focused_button), None) | ||||
|  | ||||
|             key = "&" if key == "&&" else key | ||||
|             cursor_pos = self.current_input_widget.cursorPosition() | ||||
|             text = self.current_input_widget.text() | ||||
|             new_text = text[:cursor_pos] + key + text[cursor_pos:] | ||||
|             self.current_input_widget.setText(new_text) | ||||
|             self.current_input_widget.setCursorPosition(cursor_pos + len(key)) | ||||
|             self.keyPressed.emit(key) | ||||
|             self.highlight_cursor_position() | ||||
|  | ||||
|             # Если был нажат SHIFT, но не CapsLock, отключаем его после ввода символа | ||||
|             if self.shift_pressed and not self.caps_lock: | ||||
|                 self.shift_pressed = False | ||||
|                 self.update_keyboard() | ||||
|                 if key_to_restore and key_to_restore in self.buttons: | ||||
|                     self.buttons[key_to_restore].setFocus() | ||||
|  | ||||
|     def on_tab_click(self): | ||||
|         if self.current_input_widget is not None: | ||||
|             self.current_input_widget.insert('\t') | ||||
|             self.keyPressed.emit('Tab') | ||||
|             self.current_input_widget.setFocus() | ||||
|             self.highlight_cursor_position() | ||||
|  | ||||
|     def on_caps_click(self): | ||||
|         """Включаем/выключаем CapsLock""" | ||||
|         self.caps_lock = not self.caps_lock | ||||
|         self.shift_pressed = self.caps_lock | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     # ---------- таймерное событие ---------- | ||||
|     def timerEvent(self, event): | ||||
|         if event.timerId() == self.backspace_timer: | ||||
|             self.on_backspace_click()  # стираем ещё один символ | ||||
|             # первое срабатывание прошло – ускоряем | ||||
|             if self.backspace_timer: | ||||
|                 self.killTimer(self.backspace_timer) | ||||
|                 self.backspace_timer = self.startTimer(self.backspace_repeat_delay) | ||||
|     def on_backspace_click(self): | ||||
|         """Обработка одного нажатия Backspace""" | ||||
|         if self.current_input_widget is not None: | ||||
|             cursor_pos = self.current_input_widget.cursorPosition() | ||||
|             text = self.current_input_widget.text() | ||||
|  | ||||
|             if cursor_pos > 0: | ||||
|                 new_text = text[:cursor_pos - 1] + text[cursor_pos:] | ||||
|                 self.current_input_widget.setText(new_text) | ||||
|                 self.current_input_widget.setCursorPosition(cursor_pos - 1) | ||||
|                 self.keyPressed.emit('Backspace') | ||||
|                 self.highlight_cursor_position() | ||||
|  | ||||
|     def on_backspace_pressed(self): | ||||
|         """Обработка зажатого Backspace""" | ||||
|         self.backspace_pressed = True | ||||
|         self.start_backspace_repeat() | ||||
|  | ||||
|     def start_backspace_repeat(self): | ||||
|         """Запуск автоповтора нажатия Backspace""" | ||||
|         self.on_backspace_click()  # Первое нажатие | ||||
|         self.backspace_timer = self.startTimer(self.backspace_initial_delay) | ||||
|  | ||||
|     def stop_backspace_repeat(self): | ||||
|         """Остановка автоповтора нажатия Backspace""" | ||||
|         if self.backspace_timer: | ||||
|             self.killTimer(self.backspace_timer) | ||||
|             self.backspace_timer = None | ||||
|         self.backspace_pressed = False | ||||
|  | ||||
|     def on_enter_click(self): | ||||
|         """Обработка действия кнопки Enter""" | ||||
|         # TODO: тут подумать, как обрабатывать нажатие. | ||||
|         #  Пока болванка перехода на новую строку, в QlineEdit работает как нажатие пробела | ||||
|         if self.current_input_widget is not None: | ||||
|             self.current_input_widget.insert('\n') | ||||
|             self.keyPressed.emit('Enter') | ||||
|  | ||||
|     def on_clear_click(self): | ||||
|         """Чистим строку от введённого текста""" | ||||
|         if self.current_input_widget is not None: | ||||
|             self.current_input_widget.clear() | ||||
|             self.keyPressed.emit('Clear') | ||||
|             self.highlight_cursor_position() | ||||
|  | ||||
|     def on_lang_click(self): | ||||
|         """Переключение раскладки""" | ||||
|         if not self.available_layouts: | ||||
|             return | ||||
|  | ||||
|         try: | ||||
|             current_index = self.available_layouts.index(self.current_layout) | ||||
|             next_index = (current_index + 1) % len(self.available_layouts) | ||||
|             self.current_layout = self.available_layouts[next_index] | ||||
|         except ValueError: | ||||
|             # Если текущей раскладки нет в available_layouts | ||||
|             self.current_layout = self.available_layouts[0] if self.available_layouts else 'en' | ||||
|  | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     def on_shift_click(self, checked): | ||||
|         self.shift_pressed = checked | ||||
|         if not checked and self.caps_lock: | ||||
|             self.caps_lock = False | ||||
|         self.update_keyboard() | ||||
|  | ||||
|     def show_for_widget(self, widget): | ||||
|         self.current_input_widget = widget | ||||
|         if widget: | ||||
|             widget.setFocus() | ||||
|             self.highlight_cursor_position() | ||||
|  | ||||
|         # Позиционирование клавиатуры внизу родительского виджета | ||||
|         if self._parent and isinstance(self._parent, QWidget): | ||||
|             keyboard_height = 220 | ||||
|             self.setFixedWidth(self._parent.width()) | ||||
|             self.setFixedHeight(keyboard_height) | ||||
|             self.move(0, self._parent.height() - keyboard_height) | ||||
|  | ||||
|         self.show() | ||||
|         self.raise_() | ||||
|  | ||||
|         # Установить фокус на первую кнопку, если нет фокуса на виджете ввода | ||||
|         if not widget: | ||||
|             first_button: QPushButton | None = next((cast(QPushButton, btn) for btn in self.buttons.values()), None) | ||||
|             if first_button: | ||||
|                 first_button.setFocus() | ||||
|  | ||||
|     def activateFocusedKey(self): | ||||
|         """Активирует текущую выделенную кнопку на клавиатуре""" | ||||
|         focused = self.focusWidget() | ||||
|         if isinstance(focused, QPushButton): | ||||
|             focused.animateClick() | ||||
|  | ||||
|     def focusNextKey(self, direction: str): | ||||
|         """Перемещает фокус на следующую кнопку в указанном направлении с обертыванием""" | ||||
|         current = self.focusWidget() | ||||
|         if not current: | ||||
|             first_button = self.findFirstFocusableButton() | ||||
|             if first_button: | ||||
|                 first_button.setFocus() | ||||
|             return | ||||
|  | ||||
|         current_idx = self.keyboard_layout.indexOf(current) | ||||
|         if current_idx == -1: | ||||
|             return | ||||
|  | ||||
|         position = cast(tuple[int, int, int, int], self.keyboard_layout.getItemPosition(current_idx)) | ||||
|         current_row, current_col, row_span, col_span = position | ||||
|  | ||||
|         num_rows = self.keyboard_layout.rowCount() | ||||
|         num_cols = self.keyboard_layout.columnCount() | ||||
|  | ||||
|         found = False | ||||
|  | ||||
|         if direction == "right": | ||||
|             # Сначала ищем в той же строке вправо | ||||
|             search_row = current_row | ||||
|             search_col = current_col + col_span | ||||
|             while search_col < num_cols: | ||||
|                 item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     next_button = cast(QPushButton, item.widget()) | ||||
|                     next_button.setFocus() | ||||
|                     found = True | ||||
|                     break | ||||
|                 search_col += 1 | ||||
|  | ||||
|             if not found: | ||||
|                 # Переходим к следующей строке, начиная с col 0 | ||||
|                 search_row = (current_row + 1) % num_rows | ||||
|                 search_col = 0 | ||||
|                 # Ищем первую кнопку в этой строке | ||||
|                 while search_col < num_cols: | ||||
|                     item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                     if item and item.widget() and item.widget().isEnabled(): | ||||
|                         next_button = cast(QPushButton, item.widget()) | ||||
|                         next_button.setFocus() | ||||
|                         found = True | ||||
|                         break | ||||
|                     search_col += 1 | ||||
|  | ||||
|         elif direction == "left": | ||||
|             # Сначала ищем в той же строке влево | ||||
|             search_row = current_row | ||||
|             search_col = current_col - 1 | ||||
|             while search_col >= 0: | ||||
|                 item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     next_button = cast(QPushButton, item.widget()) | ||||
|                     next_button.setFocus() | ||||
|                     found = True | ||||
|                     break | ||||
|                 search_col -= 1 | ||||
|  | ||||
|             if not found: | ||||
|                 # Переходим к предыдущей строке, начиная с последнего столбца | ||||
|                 search_row = (current_row - 1) % num_rows | ||||
|                 search_col = num_cols - 1 | ||||
|                 # Ищем последнюю кнопку в этой строке | ||||
|                 while search_col >= 0: | ||||
|                     item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                     if item and item.widget() and item.widget().isEnabled(): | ||||
|                         next_button = cast(QPushButton, item.widget()) | ||||
|                         next_button.setFocus() | ||||
|                         found = True | ||||
|                         break | ||||
|                     search_col -= 1 | ||||
|  | ||||
|         elif direction == "down": | ||||
|             # Сначала ищем в том же столбце вниз | ||||
|             search_col = current_col | ||||
|             search_row = current_row + row_span | ||||
|             while search_row < num_rows: | ||||
|                 item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     next_button = cast(QPushButton, item.widget()) | ||||
|                     next_button.setFocus() | ||||
|                     found = True | ||||
|                     break | ||||
|                 search_row += 1 | ||||
|  | ||||
|             if not found: | ||||
|                 # Переходим к следующему столбцу, начиная с row 0 | ||||
|                 search_col = (current_col + col_span) % num_cols | ||||
|                 search_row = 0 | ||||
|                 # Ищем первую кнопку в этом столбце | ||||
|                 while search_row < num_rows: | ||||
|                     item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                     if item and item.widget() and item.widget().isEnabled(): | ||||
|                         next_button = cast(QPushButton, item.widget()) | ||||
|                         next_button.setFocus() | ||||
|                         found = True | ||||
|                         break | ||||
|                     search_row += 1 | ||||
|  | ||||
|         elif direction == "up": | ||||
|             # Сначала ищем в том же столбце вверх | ||||
|             search_col = current_col | ||||
|             search_row = current_row - 1 | ||||
|             while search_row >= 0: | ||||
|                 item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     next_button = cast(QPushButton, item.widget()) | ||||
|                     next_button.setFocus() | ||||
|                     found = True | ||||
|                     break | ||||
|                 search_row -= 1 | ||||
|  | ||||
|             if not found: | ||||
|                 # Переходим к предыдущему столбцу, начиная с последней строки | ||||
|                 search_col = (current_col - 1) % num_cols | ||||
|                 search_row = num_rows - 1 | ||||
|                 # Ищем последнюю кнопку в этом столбце | ||||
|                 while search_row >= 0: | ||||
|                     item = self.keyboard_layout.itemAtPosition(search_row, search_col) | ||||
|                     if item and item.widget() and item.widget().isEnabled(): | ||||
|                         next_button = cast(QPushButton, item.widget()) | ||||
|                         next_button.setFocus() | ||||
|                         found = True | ||||
|                         break | ||||
|                     search_row -= 1 | ||||
|  | ||||
|     def findFirstFocusableButton(self) -> QPushButton | None: | ||||
|         """Находит первую фокусируемую кнопку на клавиатуре""" | ||||
|         for row in range(self.keyboard_layout.rowCount()): | ||||
|             for col in range(self.keyboard_layout.columnCount()): | ||||
|                 item = self.keyboard_layout.itemAtPosition(row, col) | ||||
|                 if item and item.widget() and item.widget().isEnabled(): | ||||
|                     return cast(QPushButton, item.widget()) | ||||
|         return None | ||||
		Reference in New Issue
	
	Block a user