一个欲儿的博客

一个欲儿的博客

mac 系统下的短语板
2026-06-22

一、环境部署

1.桌面新建snippet-panel 文件夹

2.首次需要创建虚拟环境和依赖

cd ~/Desktop/snippet-panel
python3 -m venv venv
source venv/bin/activate
pip install PyQt6 pyperclip

之后只需要

cd ~/Desktop/snippet-panel
source venv/bin/activate
python3 app.py

二、mac快捷启动

桌面新建start_snippet_panel.command

#!/bin/bash

cd ~/Desktop/snippet-panel

source venv/bin/activate

mkdir -p log

nohup python3 app.py >log/app.log 2>&1 &

之后每次启动,只需要双击该脚本即可。

三、代码

app.py

import sys
import json
import os
import pyperclip
import ctypes
import ctypes.util

from PyQt6.QtWidgets import (
QApplication,
QWidget,
QListWidget,
QListWidgetItem,
QVBoxLayout,
QPushButton,
QHBoxLayout,
QLineEdit,
QLabel,
QDialog,
QTextEdit,
QDialogButtonBox,
QMenu,
)
from PyQt6.QtCore import Qt, QSize, QEvent, pyqtSignal, QObject, QPoint, QTimer
from PyQt6.QtGui import QPainter, QFontMetrics, QAction


APP_DIR = os.path.expanduser("./data")
os.makedirs(APP_DIR, exist_ok=True)
DATA_FILE = os.path.join(APP_DIR, "data.json")


def load_data():
if not os.path.exists(DATA_FILE):
return []
try:
with open(DATA_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except:
return []


def save_data(data):
with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)


STYLE_SHEET = """
QWidget {
    background-color: #eef0f7;
    font-family: "PingFang SC", "Microsoft YaHei", sans-serif;
    font-size: 14px;
    color: #1f2329;
}

#titleLabel {
    font-size: 18px;
    font-weight: 600;
    color: #1f2329;
}

#searchBox {
    background-color: #ffffff;
    border: 1px solid #e3e6ee;
    border-radius: 8px;
    padding: 8px 12px;
}
#searchBox:focus {
    border: 1px solid #3a6df0;
}

#snippetList {
    background-color: transparent;
    border: none;
    outline: none;
}
#snippetList::item {
    background-color: #ffffff;
    border: 1px solid #e3e6ee;
    border-radius: 10px;
    padding: 0px;
}
#snippetList::item:selected {
    border: 2px solid #1f2329;
}

#copyTip {
    background-color: transparent;
    border: none;
    color: #00b42a;
    font-size: 13px;
    font-weight: 600;
}

#cardWidget {
    background-color: transparent;
    border: none;
}

#cardTitle {
    background-color: transparent;
    border: none;
    color: #1f2329;
    font-size: 15px;
    font-weight: 600;
}

#cardLabel {
    background-color: transparent;
    border: none;
    color: #646a73;
    font-size: 13px;
}

#cardActionBtn, #cardActionDangerBtn {
    background-color: transparent;
    border: none;
    border-radius: 6px;
    padding: 2px 8px;
    color: #1f2329;
    font-size: 12px;
}
#cardActionBtn:hover, #cardActionDangerBtn:hover {
    background-color: #f0f1f3;
    color: #1f2329;
}
#cardActionBtn:pressed, #cardActionDangerBtn:pressed {
    background-color: #e6e7ea;
    color: #1f2329;
}

#titleBarBtn {
    background-color: transparent;
    border: none;
    border-radius: 6px;
    padding: 4px 2px;
    color: #1f2329;
    font-size: 18px;
}
#titleBarBtn:hover {
    background-color: #f0f1f3;
}
#titleBarBtn:pressed {
    background-color: #e6e7ea;
}

QMenu {
    background-color: #eef0f7;
    border: 1px solid #d9dce8;
    border-radius: 5px;
    padding: 6px;
}
QMenu::item {
    background-color: transparent;
    padding: 8px 18px;
    border-radius: 6px;
    color: #1f2329;
    font-size: 14px;
}
QMenu::item:selected {
    background-color: #dfe2ee;
}

QPushButton {
    background-color: #ffffff;
    border: 1px solid #e3e6ee;
    border-radius: 8px;
    padding: 8px 14px;
}
QPushButton:hover {
    background-color: #f5f6fa;
}
QPushButton:pressed {
    background-color: #e9ebf2;
}

#dialogFieldLabel {
    background-color: transparent;
    border: none;
    color: #646a73;
    font-size: 13px;
    font-weight: 600;
}

#dialogTitleInput {
    background-color: #ffffff;
    border: 1px solid #e3e6ee;
    border-radius: 8px;
    padding: 8px 12px;
}
#dialogTitleInput:focus {
    border: 1px solid #3a6df0;
}

#dialogContentInput {
    background-color: #ffffff;
    border: 1px solid #e3e6ee;
    border-radius: 8px;
    padding: 8px 12px;
}
#dialogContentInput:focus {
    border: 1px solid #3a6df0;
}

#confirmDialog {
    background-color: #ffffff;
}
#confirmTitle {
    background-color: transparent;
    border: none;
    color: #1f2329;
    font-size: 16px;
    font-weight: 600;
}
#confirmMessage {
    background-color: transparent;
    border: none;
    color: #646a73;
    font-size: 14px;
}
#confirmCancelBtn {
    background-color: #ffffff;
    border: 1px solid #e3e6ee;
    border-radius: 8px;
    padding: 8px 18px;
    color: #1f2329;
}
#confirmCancelBtn:hover {
    background-color: #f5f6fa;
}
#confirmCancelBtn:pressed {
    background-color: #e9ebf2;
}
#confirmDangerBtn {
    background-color: #f53f3f;
    border: 1px solid #f53f3f;
    border-radius: 8px;
    padding: 8px 18px;
    color: #ffffff;
    font-weight: 600;
}
#confirmDangerBtn:hover {
    background-color: #e63333;
}
#confirmDangerBtn:pressed {
    background-color: #cc2d2d;
}
"""


class ElidedLabel(QLabel):
"""多行文本超出后以省略号结尾。"""

MAX_LINES = 2

def __init__(self, text="", parent=None, pad_x=0, pad_y=0):
super().__init__(parent)
self._full_text = text
self._pad_x = pad_x
self._pad_y = pad_y
self.setWordWrap(True)

def setText(self, text):
self._full_text = text
super().setText(text)

def paintEvent(self, event):
painter = QPainter(self)

pad_x, pad_y = self._pad_x, self._pad_y
rect = self.rect().adjusted(pad_x, pad_y, -pad_x, -pad_y)

metrics = QFontMetrics(self.font())
line_height = metrics.lineSpacing()

lines = []
current = ""
for ch in self._full_text:
if ch == "\n":
lines.append(current)
current = ""
continue
test = current + ch
if metrics.horizontalAdvance(test) > rect.width() and current:
lines.append(current)
current = ch
else:
current = test
if current:
lines.append(current)

if len(lines) > self.MAX_LINES:
lines = lines[:self.MAX_LINES]
lines[-1] = metrics.elidedText(
lines[-1] + "…",
Qt.TextElideMode.ElideRight,
rect.width(),
)

y = rect.top() + metrics.ascent()
for line in lines:
painter.drawText(rect.left(), y, line)
y += line_height

painter.end()


class SnippetDialog(QDialog):
"""新增/编辑弹窗:标题单行,内容多行(固定高度可滚动)。"""

def __init__(self, parent=None, window_title="新增", title="", content=""):
super().__init__(parent)

self.setWindowTitle(window_title)
self.setMinimumWidth(420)
self.setStyleSheet(STYLE_SHEET)

layout = QVBoxLayout(self)
layout.setContentsMargins(20, 20, 20, 20)
layout.setSpacing(8)

title_tip = QLabel("标题")
title_tip.setObjectName("dialogFieldLabel")
layout.addWidget(title_tip)

self.title_input = QLineEdit(title)
self.title_input.setObjectName("dialogTitleInput")
self.title_input.setPlaceholderText("请输入标题...")
layout.addWidget(self.title_input)

layout.addSpacing(6)

content_tip = QLabel("内容")
content_tip.setObjectName("dialogFieldLabel")
layout.addWidget(content_tip)

self.content_input = QTextEdit()
self.content_input.setObjectName("dialogContentInput")
self.content_input.setPlaceholderText("请输入内容...")
self.content_input.setPlainText(content)
self.content_input.setFixedHeight(220)
layout.addWidget(self.content_input)

layout.addSpacing(10)

button_box = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok
| QDialogButtonBox.StandardButton.Cancel
)
button_box.button(QDialogButtonBox.StandardButton.Ok).setText("确定")
button_box.button(QDialogButtonBox.StandardButton.Cancel).setText("取消")
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)

self.title_input.setFocus()

def get_values(self):
return (
self.title_input.text().strip(),
self.content_input.toPlainText().strip(),
)


class ConfirmDialog(QDialog):
"""与应用风格一致的确认弹窗:纯文本,无图标。"""

def __init__(
self,
parent=None,
title="确认",
message="",
ok_text="确定",
cancel_text="取消",
):
super().__init__(parent)

self.setObjectName("confirmDialog")
self.setWindowTitle(title)
self.setMinimumWidth(320)
self.setStyleSheet(STYLE_SHEET)

layout = QVBoxLayout(self)
layout.setContentsMargins(24, 22, 24, 20)
layout.setSpacing(10)

title_label = QLabel(title)
title_label.setObjectName("confirmTitle")
layout.addWidget(title_label)

message_label = QLabel(message)
message_label.setObjectName("confirmMessage")
message_label.setWordWrap(True)
layout.addWidget(message_label)

layout.addSpacing(8)

btn_row = QHBoxLayout()
btn_row.setSpacing(10)
btn_row.addStretch()

cancel_btn = QPushButton(cancel_text)
cancel_btn.setObjectName("confirmCancelBtn")
cancel_btn.clicked.connect(self.reject)

ok_btn = QPushButton(ok_text)
ok_btn.setObjectName("confirmDangerBtn")
ok_btn.clicked.connect(self.accept)

btn_row.addWidget(cancel_btn)
btn_row.addWidget(ok_btn)
layout.addLayout(btn_row)

cancel_btn.setFocus()


# 全局快捷键:使用 macOS Carbon RegisterEventHotKey 仅监听 Cmd+Y,
# 避免 pynput 全局事件 tap 在切换输入法时崩溃,且无需辅助功能权限。

class HotkeySignals(QObject):
trigger_show = pyqtSignal()


HOTKEY_SIGNALS = HotkeySignals()

try:
_CARBON = ctypes.CDLL(ctypes.util.find_library("Carbon"))
except Exception:
_CARBON = None


class _EventHotKeyID(ctypes.Structure):
_fields_ = [
("signature", ctypes.c_uint32),
("id", ctypes.c_uint32),
]


class _EventTypeSpec(ctypes.Structure):
_fields_ = [
("eventClass", ctypes.c_uint32),
("eventKind", ctypes.c_uint32),
]


_kEventClassKeyboard = 0x6B657962
_kEventHotKeyPressed = 5
_cmdKey = 0x0100
_kVK_ANSI_Y = 0x10
_HOTKEY_SIGNATURE = 0x736E7079
_HOTKEY_ID = 1

_HOTKEY_HANDLER_PROTO = ctypes.CFUNCTYPE(
ctypes.c_int32,
ctypes.c_void_p,
ctypes.c_void_p,
ctypes.c_void_p,
)

# 模块级全局引用,防止被 GC 回收导致回调悬挂/崩溃
_HOTKEY_REF = None
_HOTKEY_HANDLER_REF = None
_HOTKEY_CALLBACK = None
_HOTKEY_INSTALLED = False


def _configure_carbon_signatures():
"""显式设置 Carbon 函数签名,避免 64 位指针被截断。"""
_CARBON.GetApplicationEventTarget.restype = ctypes.c_void_p
_CARBON.GetApplicationEventTarget.argtypes = []

_CARBON.InstallEventHandler.restype = ctypes.c_int32
_CARBON.InstallEventHandler.argtypes = [
ctypes.c_void_p,
_HOTKEY_HANDLER_PROTO,
ctypes.c_uint32,
ctypes.POINTER(_EventTypeSpec),
ctypes.c_void_p,
ctypes.POINTER(ctypes.c_void_p),
]

_CARBON.RegisterEventHotKey.restype = ctypes.c_int32
_CARBON.RegisterEventHotKey.argtypes = [
ctypes.c_uint32,
ctypes.c_uint32,
_EventHotKeyID,
ctypes.c_void_p,
ctypes.c_uint32,
ctypes.POINTER(ctypes.c_void_p),
]

_CARBON.UnregisterEventHotKey.restype = ctypes.c_int32
_CARBON.UnregisterEventHotKey.argtypes = [ctypes.c_void_p]

_CARBON.RemoveEventHandler.restype = ctypes.c_int32
_CARBON.RemoveEventHandler.argtypes = [ctypes.c_void_p]


def _on_hotkey(_next_handler, _event, _user_data):
try:
HOTKEY_SIGNALS.trigger_show.emit()
except Exception:
pass
return 0


def start_hotkey():
"""注册全局热键(Cmd+Y),幂等。成功返回 True。"""
global _HOTKEY_REF, _HOTKEY_HANDLER_REF, _HOTKEY_CALLBACK
global _HOTKEY_INSTALLED

if _HOTKEY_INSTALLED:
return True
if _CARBON is None:
return False

try:
_configure_carbon_signatures()

target = _CARBON.GetApplicationEventTarget()
if not target:
return False

_HOTKEY_CALLBACK = _HOTKEY_HANDLER_PROTO(_on_hotkey)
event_spec = _EventTypeSpec(_kEventClassKeyboard, _kEventHotKeyPressed)
handler_ref = ctypes.c_void_p()
status = _CARBON.InstallEventHandler(
target,
_HOTKEY_CALLBACK,
1,
ctypes.byref(event_spec),
None,
ctypes.byref(handler_ref),
)
if status != 0:
_HOTKEY_CALLBACK = None
return False
_HOTKEY_HANDLER_REF = handler_ref

hotkey_id = _EventHotKeyID(_HOTKEY_SIGNATURE, _HOTKEY_ID)
hotkey_ref = ctypes.c_void_p()
status = _CARBON.RegisterEventHotKey(
_kVK_ANSI_Y,
_cmdKey,
hotkey_id,
target,
0,
ctypes.byref(hotkey_ref),
)
if status != 0:
try:
_CARBON.RemoveEventHandler(_HOTKEY_HANDLER_REF)
except Exception:
pass
_HOTKEY_HANDLER_REF = None
_HOTKEY_CALLBACK = None
return False
_HOTKEY_REF = hotkey_ref

_HOTKEY_INSTALLED = True
return True
except Exception:
return False


def stop_hotkey():
"""注销全局热键并移除事件处理器。"""
global _HOTKEY_REF, _HOTKEY_HANDLER_REF, _HOTKEY_CALLBACK
global _HOTKEY_INSTALLED

if _CARBON is None:
return

try:
if _HOTKEY_REF is not None:
_CARBON.UnregisterEventHotKey(_HOTKEY_REF)
except Exception:
pass

try:
if _HOTKEY_HANDLER_REF is not None:
_CARBON.RemoveEventHandler(_HOTKEY_HANDLER_REF)
except Exception:
pass

_HOTKEY_REF = None
_HOTKEY_HANDLER_REF = None
_HOTKEY_CALLBACK = None
_HOTKEY_INSTALLED = False


class SnippetWindow(QWidget):

def __init__(self):
super().__init__()

self.data = load_data()
self._drag_pos = None

self.setWindowTitle("Snippet Panel")
self.setWindowFlags(
Qt.WindowType.Tool
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint
)
self.resize(420, 560)
self.init_ui()

HOTKEY_SIGNALS.trigger_show.connect(self.force_show)

def init_ui(self):
layout = QVBoxLayout()
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)

self.title_bar = QWidget()
self.title_bar.setObjectName("titleBar")

top_bar = QHBoxLayout(self.title_bar)
top_bar.setContentsMargins(0, 0, 0, 0)

title_label = QLabel("短语板")
title_label.setObjectName("titleLabel")

self.copy_tip = QLabel("")
self.copy_tip.setObjectName("copyTip")
self.copy_tip.setMaximumWidth(180)
self.copy_tip.hide()

self._copy_tip_timer = QTimer(self)
self._copy_tip_timer.setSingleShot(True)
self._copy_tip_timer.timeout.connect(self.copy_tip.hide)

self.add_btn = QPushButton("+")
self.add_btn.setObjectName("titleBarBtn")
self.add_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.add_btn.clicked.connect(self.add_item)

self.menu_btn = QPushButton("···")
self.menu_btn.setObjectName("titleBarBtn")
self.menu_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.menu_btn.clicked.connect(self._show_menu)

top_bar.addWidget(title_label)
top_bar.addStretch()
top_bar.addWidget(self.copy_tip)
top_bar.addStretch()
top_bar.addWidget(self.add_btn)
top_bar.addWidget(self.menu_btn)

layout.addWidget(self.title_bar)

self.search = QLineEdit()
self.search.setObjectName("searchBox")
self.search.setPlaceholderText("搜索...")
self.search.textChanged.connect(self.refresh_list)
layout.addWidget(self.search)

self.list_widget = QListWidget()
self.list_widget.setObjectName("snippetList")
self.list_widget.setSpacing(8)
self.list_widget.itemDoubleClicked.connect(self._on_item_double_clicked)
layout.addWidget(self.list_widget)

self.setLayout(layout)
self.setStyleSheet(STYLE_SHEET)

self.refresh_list()

def refresh_list(self):
keyword = self.search.text().lower()
self.list_widget.clear()

for item in self.data:
if (
keyword in item["title"].lower()
or keyword in item["content"].lower()
):
self._add_card(item)

def _add_card(self, item):
title = item["title"]
content = item["content"]

list_item = QListWidgetItem(self.list_widget)
list_item.setSizeHint(QSize(0, 124))

card = QWidget()
card.setObjectName("cardWidget")

card_layout = QVBoxLayout(card)
card_layout.setContentsMargins(16, 12, 16, 8)
card_layout.setSpacing(6)

title_label = QLabel(title)
title_label.setObjectName("cardTitle")

content_label = ElidedLabel(content)
content_label.setObjectName("cardLabel")

card_layout.addWidget(title_label)
card_layout.addWidget(content_label)

action_row = QHBoxLayout()
action_row.setContentsMargins(0, 0, 0, 0)
action_row.setSpacing(4)
action_row.addStretch()

copy_btn = QPushButton("复制")
copy_btn.setObjectName("cardActionBtn")
copy_btn.setCursor(Qt.CursorShape.PointingHandCursor)
copy_btn.clicked.connect(lambda _=False, it=item: self._copy_item(it))

del_btn = QPushButton("删除")
del_btn.setObjectName("cardActionDangerBtn")
del_btn.setCursor(Qt.CursorShape.PointingHandCursor)
del_btn.clicked.connect(lambda _=False, it=item: self._delete_item(it))

action_row.addWidget(copy_btn)
action_row.addWidget(del_btn)
card_layout.addLayout(action_row)

self.list_widget.addItem(list_item)
self.list_widget.setItemWidget(list_item, card)

def get_selected_index(self):
row = self.list_widget.currentRow()
if row < 0:
return None

keyword = self.search.text().lower()
filtered = [
item for item in self.data
if keyword in item["title"].lower()
or keyword in item["content"].lower()
]
return self.data.index(filtered[row])

def add_item(self):
dialog = SnippetDialog(self, window_title="新增")
if dialog.exec() != QDialog.DialogCode.Accepted:
return

title, content = dialog.get_values()
if not title:
return

self.data.append({"title": title, "content": content})
save_data(self.data)
self.refresh_list()

def edit_item(self):
idx = self.get_selected_index()
if idx is None:
return

item = self.data[idx]
dialog = SnippetDialog(
self,
window_title="编辑",
title=item["title"],
content=item["content"],
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return

title, content = dialog.get_values()
if not title:
return

self.data[idx] = {"title": title, "content": content}
save_data(self.data)
self.refresh_list()

def _delete_item(self, item):
try:
idx = self.data.index(item)
except ValueError:
return
self._delete_by_index(idx)

def _delete_by_index(self, idx):
dialog = ConfirmDialog(
self,
title="删除",
message="确定要删除这条短语吗?",
ok_text="删除",
cancel_text="取消",
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return

del self.data[idx]
save_data(self.data)
self.refresh_list()

def _on_item_double_clicked(self, list_item):
self.list_widget.setCurrentItem(list_item)
self.edit_item()

def _copy_item(self, item):
try:
idx = self.data.index(item)
except ValueError:
return
self._copy_by_index(idx)

def _copy_by_index(self, idx):
pyperclip.copy(self.data[idx]["content"])
self._show_copy_tip(self.data[idx]["title"])

def _show_copy_tip(self, title):
text = f"复制成功:{title}"
metrics = QFontMetrics(self.copy_tip.font())
elided = metrics.elidedText(
text,
Qt.TextElideMode.ElideRight,
self.copy_tip.maximumWidth(),
)
self.copy_tip.setText(elided)
self.copy_tip.show()
self._copy_tip_timer.start(1000)

def _show_menu(self):
menu = QMenu(self)
menu.setObjectName("titleMenu")

quit_action = QAction("结束进程", self)
quit_action.triggered.connect(self.force_quit)
menu.addAction(quit_action)

pos = self.menu_btn.mapToGlobal(self.menu_btn.rect().bottomRight())
menu.exec(pos - QPoint(menu.sizeHint().width(), 0))

def force_quit(self):
dialog = ConfirmDialog(
self,
title="强制结束",
message="确定要强制结束进程吗?应用将完全退出。",
ok_text="强制结束",
cancel_text="取消",
)
if dialog.exec() != QDialog.DialogCode.Accepted:
return

stop_hotkey()
os._exit(0)

def _close_child_dialogs(self):
"""关闭所有由主窗口打开的弹窗。"""
for dlg in self.findChildren(QDialog):
dlg.reject()
dlg.close()

def force_show(self):
self._close_child_dialogs()
self.show()
self.raise_()
self.activateWindow()
self.search.setFocus()

def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton:
pos = event.position().toPoint()
if pos.y() < self.title_bar.geometry().top():
self._drag_pos = (
event.globalPosition().toPoint()
- self.frameGeometry().topLeft()
)
event.accept()
return
super().mousePressEvent(event)

def mouseMoveEvent(self, event):
if (
self._drag_pos is not None
and event.buttons() & Qt.MouseButton.LeftButton
):
self.move(event.globalPosition().toPoint() - self._drag_pos)
event.accept()
return
super().mouseMoveEvent(event)

def mouseReleaseEvent(self, event):
self._drag_pos = None
super().mouseReleaseEvent(event)

def changeEvent(self, event):
if event.type() == QEvent.Type.ActivationChange:
if not self.isActiveWindow():
self.hide()
super().changeEvent(event)


if __name__ == "__main__":
app = QApplication(sys.argv)

window = SnippetWindow()
window.hide()

if not start_hotkey():
ConfirmDialog(
window,
title="快捷键注册失败",
message=(
"全局快捷键 Cmd+Y 注册失败,可能已被其他应用占用。\n\n"
"请关闭占用该组合键的应用后重启本程序。"
),
ok_text="我知道了",
cancel_text="关闭",
).exec()

sys.exit(app.exec())

截屏2026-06-22 20.15.00.png


发表评论: