""" 悬浮虚拟键盘 - 美式 QWERTY 键盘排列(字母 + 数字 + 常用符号) - Shift / Caps Lock / 退格 / 空格 / 回车 / 关闭 - 可拖拽悬浮,自动跟随焦点输入框 - 暗色主题,大按钮适合触屏 """ from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QApplication, QLineEdit, QTextEdit, QGridLayout ) from PyQt6.QtCore import Qt, QEvent, QPoint, QTimer, pyqtSignal from PyQt6.QtGui import QMouseEvent, QTextCursor # ─── 长按重复配置 ───────────────────────────────────────── LONG_PRESS_DELAY = 400 # 按住多少毫秒后开始重复 LONG_PRESS_REPEAT = 80 # 开始重复后每多少毫秒触发一次 # ─── 键盘布局定义 ────────────────────────────────────────────── KEY_ROWS = { "normal": [ ["q", "w", "e", "r", "t", "y", "u", "i", "o", "p"], ["a", "s", "d", "f", "g", "h", "j", "k", "l"], ["z", "x", "c", "v", "b", "n", "m"], ], "shift": [ ["Q", "W", "E", "R", "T", "Y", "U", "I", "O", "P"], ["A", "S", "D", "F", "G", "H", "J", "K", "L"], ["Z", "X", "C", "V", "B", "N", "M"], ], } NUMBER_ROW = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"] # 符号键:按 ?!(. 切换时替换数字行 SYMBOL_ROW = ["!", "@", "#", "$", "%", "^", "&", "*", "(", ")"] # 显示用(Qt 中 & 需要写 && 才能显示一个 &) SYMBOL_ROW_DISPLAY = ["!", "@", "#", "$", "%", "^", "&&", "*", "(", ")"] # 额外符号:切换到符号键盘后显示在字母区 EXTRA_SYMBOLS = [ ["-", "=", "[", "]", "\\", ";", "'", ",", ".", "/"], ["_", "+", "{", "}", "|", ":", "\"", "<", ">", "?"], ] # 常见后缀(放在符号键盘多余的字母位置上) COMMON_SUFFIXES = [".com", ".cn", ".org", ".net", ".edu", ".co"] KEY_STYLE = """ QPushButton { min-width: 52px; min-height: 52px; font-size: 22px; font-weight: 600; color: #f2f2f2; background-color: #4a4a4a; border: 2px solid #646464; border-radius: 8px; padding: 4px 4px; } QPushButton:hover { background-color: #5a5a5a; border-color: #888888; } QPushButton:pressed { background-color: #2f6f91; border-color: #5a9fcf; } """ CTRL_KEY_STYLE = """ QPushButton { min-width: 52px; min-height: 52px; font-size: 18px; font-weight: 600; color: #f2f2f2; background-color: #555555; border: 2px solid #707070; border-radius: 8px; padding: 4px 8px; } QPushButton:hover { background-color: #666666; border-color: #909090; } QPushButton:pressed { background-color: #2f6f91; border-color: #5a9fcf; } """ ACTIVE_CTRL_STYLE = """ QPushButton { min-width: 52px; min-height: 52px; font-size: 18px; font-weight: 600; color: #ffffff; background-color: #2f6f91; border: 2px solid #5a9fcf; border-radius: 8px; padding: 4px 8px; } QPushButton:hover { background-color: #3a85b3; border-color: #6fb8dd; } """ class FloatingKeyboard(QWidget): """可悬浮拖拽的虚拟键盘,自动跟随焦点输入框""" key_pressed = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self.setWindowFlags( Qt.WindowType.FramelessWindowHint | Qt.WindowType.WindowStaysOnTopHint | Qt.WindowType.Tool ) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) # 防止键盘窗口/按钮抢走输入框焦点 self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating) self.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.setStyleSheet("background: transparent;") self._drag_pos = None self._shift_on = False self._caps_on = False self._symbol_mode = False self._target_widget = None self.init_ui() # ── UI 构建 ────────────────────────────────────────────── def init_ui(self): outer = QWidget() outer.setStyleSheet( "background-color: #333333; border: 2px solid #555555; border-radius: 12px;" ) outer_layout = QVBoxLayout(outer) outer_layout.setContentsMargins(8, 8, 8, 8) outer_layout.setSpacing(4) # ── 顶部拖拽条 ── title_bar = QWidget() title_bar.setFixedHeight(28) title_bar.setStyleSheet("background: transparent;") title_bar.mousePressEvent = self._title_mouse_press title_bar.mouseMoveEvent = self._title_mouse_move title_layout = QHBoxLayout(title_bar) title_layout.setContentsMargins(8, 0, 8, 0) drag_label = QPushButton("≡ 键盘") drag_label.setFocusPolicy(Qt.FocusPolicy.NoFocus) drag_label.setStyleSheet(""" QPushButton { background: transparent; color: #aaaaaa; font-size: 16px; border: none; text-align: left; } """) title_layout.addWidget(drag_label) title_layout.addStretch() close_btn = QPushButton("✕") close_btn.setFixedSize(28, 28) close_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) close_btn.setStyleSheet(""" QPushButton { background: transparent; color: #aaaaaa; font-size: 18px; border: none; border-radius: 14px; } QPushButton:hover { background-color: #555555; color: #ffffff; } """) close_btn.clicked.connect(self.hide) title_layout.addWidget(close_btn) outer_layout.addWidget(title_bar) # ── 内容区:左侧 4 行按键 + 右侧功能键 ── content = QHBoxLayout() content.setSpacing(6) # ====== 左侧:QGridLayout 4行×10列,等宽列 ====== grid = QGridLayout() grid.setSpacing(4) for col in range(10): grid.setColumnStretch(col, 1) # Row 0: 数字行 self.num_buttons = [] for col, ch in enumerate(NUMBER_ROW): btn = self._make_key(ch, ch) self.num_buttons.append(btn) grid.addWidget(btn, 0, col) # Row 1: 字母行 q ~ p self._letter_buttons = [] for col, ch in enumerate(KEY_ROWS["normal"][0]): btn = self._make_key(ch, ch) self._letter_buttons.append(btn) grid.addWidget(btn, 1, col) # Row 2: 字母行 a ~ l (9个,第10列空位留给符号模式) for col, ch in enumerate(KEY_ROWS["normal"][1]): btn = self._make_key(ch, ch) self._letter_buttons.append(btn) grid.addWidget(btn, 2, col) # Row 3: [?!(.] + 字母 z ~ m + [⇧] + [Caps] self.sym_btn = self._make_ctrl_btn("?!(.", self._toggle_symbol) grid.addWidget(self.sym_btn, 3, 0) for i, ch in enumerate(KEY_ROWS["normal"][2]): btn = self._make_key(ch, ch) self._letter_buttons.append(btn) grid.addWidget(btn, 3, i + 1) self.shift_btn = self._make_ctrl_btn("⇧", self._toggle_shift) grid.addWidget(self.shift_btn, 3, 8) self.caps_btn = self._make_ctrl_btn("Caps", self._toggle_caps) grid.addWidget(self.caps_btn, 3, 9) # 各行等高分占布局 for row in range(4): grid.setRowStretch(row, 1) content.addLayout(grid) # ====== 右侧:4个竖向功能键,拉伸填满高度 ====== right_col = QVBoxLayout() right_col.setSpacing(4) RIGHT_BTN_STYLE = """ QPushButton { min-width: 80px; min-height: 52px; font-size: 18px; font-weight: 600; color: #f2f2f2; background-color: #555555; border: 2px solid #707070; border-radius: 8px; padding: 4px 8px; } QPushButton:hover { background-color: #666666; border-color: #909090; } QPushButton:pressed { background-color: #2f6f91; border-color: #5a9fcf; } """ backspace_btn = QPushButton("⌫ 退格") backspace_btn.setStyleSheet(RIGHT_BTN_STYLE) backspace_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) backspace_btn.clicked.connect(lambda: self._send_key("\b")) self._enable_long_press(backspace_btn, lambda: self._send_key("\b")) right_col.addWidget(backspace_btn, stretch=1) del_btn = QPushButton("Del") del_btn.setStyleSheet(RIGHT_BTN_STYLE) del_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) del_btn.clicked.connect(lambda: self._send_key("\x7f")) self._enable_long_press(del_btn, lambda: self._send_key("\x7f")) right_col.addWidget(del_btn, stretch=1) enter_btn = QPushButton("↵ 回车") enter_btn.setStyleSheet(RIGHT_BTN_STYLE) enter_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) enter_btn.clicked.connect(lambda: self._send_key("\n")) self._enable_long_press(enter_btn, lambda: self._send_key("\n")) right_col.addWidget(enter_btn, stretch=1) self.space_btn = QPushButton(" 空格") self.space_btn.setStyleSheet(RIGHT_BTN_STYLE) self.space_btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) self.space_btn.clicked.connect(lambda: self._send_key(" ")) self._enable_long_press(self.space_btn, lambda: self._send_key(" ")) right_col.addWidget(self.space_btn, stretch=1) content.addLayout(right_col) outer_layout.addLayout(content) outer_container = QVBoxLayout(self) outer_container.setContentsMargins(0, 0, 0, 0) outer_container.addWidget(outer) self.setFixedWidth(840) # ── 按键工厂 ── def _make_key(self, text, send_text=None): """创建按键,send_text 为实际发送字符(如 & 按钮显示 && 但发送 &)""" btn = QPushButton(text) btn.setStyleSheet(KEY_STYLE) btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) # 不抢输入框焦点 btn._real_text = send_text if send_text is not None else text btn.clicked.connect(lambda: self._send_key(btn._real_text)) # 普通按键自动加长按 self._enable_long_press(btn, lambda: self._send_key(btn._real_text)) return btn @staticmethod def _make_ctrl_btn(text, callback): """创建控制类按钮(?!(./⇧/Caps等)""" btn = QPushButton(text) btn.setStyleSheet(CTRL_KEY_STYLE) btn.setFocusPolicy(Qt.FocusPolicy.NoFocus) # 不抢输入框焦点 btn.clicked.connect(callback) return btn def _enable_long_press(self, btn, callback): """为按键添加长按连续触发功能(按住 LONG_PRESS_DELAY ms 后每 LONG_PRESS_REPEAT ms 重复) callback 为点击/重复时执行的 callable(无参数) """ timer = QTimer(btn) timer.setSingleShot(True) btn._repeat_timer = timer def on_pressed(): timer.setSingleShot(True) timer.setInterval(LONG_PRESS_DELAY) timer.start() def on_released(): timer.stop() def on_timer(): # 首次超时:进入快速重复模式 if timer.isSingleShot(): timer.setSingleShot(False) timer.setInterval(LONG_PRESS_REPEAT) timer.start() callback() btn.pressed.connect(on_pressed) btn.released.connect(on_released) timer.timeout.connect(on_timer) # ── 按键发送 ── def _send_key(self, text): """发送按键到目标控件""" if text == "\b": self._backspace() return if text == "\x7f": self._delete_forward() return if self._symbol_mode: self._toggle_symbol() widget = self._get_target() if widget is None: return if isinstance(widget, QLineEdit): cursor = widget.cursorPosition() current = widget.text() new_text = current[:cursor] + text + current[cursor:] widget.setText(new_text) widget.setCursorPosition(cursor + len(text)) elif isinstance(widget, QTextEdit): tc = widget.textCursor() tc.insertText(text) widget.setTextCursor(tc) # 上档键:激活一次后自动取消 if self._shift_on: self._toggle_shift() def _backspace(self): widget = self._get_target() if widget is None: return if isinstance(widget, QLineEdit): cursor = widget.cursorPosition() current = widget.text() if cursor > 0: new_text = current[:cursor - 1] + current[cursor:] widget.setText(new_text) widget.setCursorPosition(cursor - 1) elif isinstance(widget, QTextEdit): tc = widget.textCursor() if not tc.hasSelection(): pos = tc.position() if pos > 0: tc.setPosition(pos - 1) tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor) tc.removeSelectedText() def _delete_forward(self): """向前删除(删除光标后的字符)""" widget = self._get_target() if widget is None: return if isinstance(widget, QLineEdit): cursor = widget.cursorPosition() current = widget.text() if cursor < len(current): new_text = current[:cursor] + current[cursor + 1:] widget.setText(new_text) widget.setCursorPosition(cursor) elif isinstance(widget, QTextEdit): tc = widget.textCursor() if not tc.hasSelection(): tc.movePosition(QTextCursor.MoveOperation.Right, QTextCursor.MoveMode.KeepAnchor) tc.removeSelectedText() def _get_target(self): """获取当前有效的目标输入控件""" if self._target_widget and self._target_widget.hasFocus(): return self._target_widget w = QApplication.focusWidget() if isinstance(w, (QLineEdit, QTextEdit)): return w return None # ── Shift / Caps / Symbol 切换 ── def _toggle_shift(self): self._shift_on = not self._shift_on self._apply_shift_caps() self.shift_btn.setStyleSheet(ACTIVE_CTRL_STYLE if self._shift_on else CTRL_KEY_STYLE) def _toggle_caps(self): self._caps_on = not self._caps_on self._apply_shift_caps() self.caps_btn.setStyleSheet(ACTIVE_CTRL_STYLE if self._caps_on else CTRL_KEY_STYLE) def _toggle_symbol(self): self._symbol_mode = not self._symbol_mode if self._symbol_mode: # 进入符号键盘 # Row 0: 数字行 → SYMBOL_ROW for btn, ch, disp in zip(self.num_buttons, SYMBOL_ROW, SYMBOL_ROW_DISPLAY): btn.setText(disp) btn._real_text = ch # Row 1: 字母行 q-p (indices 0-9) → EXTRA_SYMBOLS[0] for i, ch in enumerate(EXTRA_SYMBOLS[0]): self._letter_buttons[i].setText(ch) self._letter_buttons[i]._real_text = ch # Row 2: 字母行 a-l (indices 10-18) → EXTRA_SYMBOLS[1][0:9] for i, ch in enumerate(EXTRA_SYMBOLS[1][:9]): self._letter_buttons[10 + i].setText(ch) self._letter_buttons[10 + i]._real_text = ch # Row 3: z-m (indices 19-25) → EXTRA_SYMBOLS[1][9] + 后缀 self._letter_buttons[19].setText(EXTRA_SYMBOLS[1][9]) self._letter_buttons[19]._real_text = EXTRA_SYMBOLS[1][9] for i, suffix in enumerate(COMMON_SUFFIXES): self._letter_buttons[20 + i].setText(suffix) self._letter_buttons[20 + i]._real_text = suffix self.sym_btn.setStyleSheet(ACTIVE_CTRL_STYLE) self.sym_btn.setText("ABC") else: # 退出符号键盘 → 恢复数字和字母 for btn, ch in zip(self.num_buttons, NUMBER_ROW): btn.setText(ch) btn._real_text = ch # 字母区根据当前 shift/caps 状态恢复 self._apply_shift_caps() self.sym_btn.setStyleSheet(CTRL_KEY_STYLE) self.sym_btn.setText("?!(.") def _apply_shift_caps(self): if self._symbol_mode: return # 符号键盘下不改变字母区显示 use_shift = self._shift_on != self._caps_on # XOR rows = KEY_ROWS["shift"] if use_shift else KEY_ROWS["normal"] flat = [ch for row in rows for ch in row] for btn, ch in zip(self._letter_buttons, flat): btn.setText(ch) btn._real_text = ch # ── 窗口拖拽 ── def _title_mouse_press(self, event: QMouseEvent): if event.button() == Qt.MouseButton.LeftButton: self._drag_pos = event.globalPosition().toPoint() - self.frameGeometry().topLeft() def _title_mouse_move(self, event: QMouseEvent): if event.buttons() & Qt.MouseButton.LeftButton and self._drag_pos is not None: self.move(event.globalPosition().toPoint() - self._drag_pos) # ── 目标绑定 ── def attach(self, widget): """绑定到一个输入控件,键盘输入将发送到此控件""" self._target_widget = widget def detach(self): self._target_widget = None def show_at(self, x, y): """在屏幕坐标 (x, y) 处显示""" self.move(x, y) self.show() self.raise_() def show_below(self, widget): """显示在指定控件下方""" pos = widget.mapToGlobal(QPoint(0, widget.height() + 4)) screen = QApplication.primaryScreen().availableGeometry() if pos.x() + self.width() > screen.width(): pos.setX(screen.width() - self.width() - 8) self.show_at(pos.x(), pos.y())