530 lines
18 KiB
Python
530 lines
18 KiB
Python
"""
|
||
悬浮虚拟键盘
|
||
- 美式 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())
|