Files
AIO_3D_Print_Local_Screen/utils/floating_keyboard.py
2026-05-11 00:21:16 +08:00

530 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
悬浮虚拟键盘
- 美式 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())