Skip to content

Commit d45dc8b

Browse files
authored
Merge pull request #61 from levelsoulove/pr60-plugins
feat(plugins): 引入插件系统与命令面板、全局快捷键基础设施
2 parents 0cb5bcd + 7e711d3 commit d45dc8b

7 files changed

Lines changed: 258 additions & 0 deletions

File tree

commands.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
"""Central command registry powering the command palette and shortcuts.
2+
3+
Core actions and plugin-provided actions register here under a unique id with a
4+
human title and a callable. The palette does fuzzy filtering over titles.
5+
"""
6+
from dataclasses import dataclass
7+
from typing import Callable, Dict, List
8+
9+
10+
@dataclass
11+
class Command:
12+
id: str
13+
title: str
14+
handler: Callable
15+
shortcut: str = ""
16+
17+
18+
class CommandRegistry:
19+
def __init__(self):
20+
self._commands: Dict[str, Command] = {}
21+
22+
def register(self, command_id: str, title: str, handler: Callable, shortcut: str = ""):
23+
if command_id in self._commands:
24+
raise ValueError(f"命令已注册: {command_id}")
25+
self._commands[command_id] = Command(command_id, title, handler, shortcut)
26+
27+
def unregister(self, command_id: str):
28+
self._commands.pop(command_id, None)
29+
30+
def get(self, command_id: str) -> Command:
31+
return self._commands[command_id]
32+
33+
def all(self) -> List[Command]:
34+
return list(self._commands.values())
35+
36+
def execute(self, command_id: str, *args, **kwargs):
37+
return self._commands[command_id].handler(*args, **kwargs)
38+
39+
def search(self, query: str) -> List[Command]:
40+
"""Subsequence (fuzzy) match over titles and ids, ranked by position."""
41+
q = query.lower().strip()
42+
if not q:
43+
return self.all()
44+
scored = []
45+
for cmd in self._commands.values():
46+
hay = (cmd.title + " " + cmd.id).lower()
47+
score = _subseq_score(q, hay)
48+
if score is not None:
49+
scored.append((score, cmd))
50+
scored.sort(key=lambda s: s[0])
51+
return [cmd for _s, cmd in scored]
52+
53+
54+
def _subseq_score(query: str, text: str):
55+
"""Return a score (lower=better) if query is a subsequence of text, else None."""
56+
pos = 0
57+
first = None
58+
for ch in query:
59+
idx = text.find(ch, pos)
60+
if idx == -1:
61+
return None
62+
if first is None:
63+
first = idx
64+
pos = idx + 1
65+
return (first, pos - first) # prefer earlier + tighter matches

config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,9 @@ def __init__(self):
8181
self.reminder_interval = 300 # seconds between scans
8282
self.reminder_lead_days = 1 # remind when due within N days
8383

84+
# Plugins directory (each *.py exposes register(context)).
85+
self.plugins_dir = self.data_dir / "plugins"
86+
8487
self.window_width = 1200
8588
self.window_height = 800
8689
self.theme_color = "#2c3e50"

examples/hello_plugin.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
"""Example SmartNotes plugin.
2+
3+
Drop a copy into your plugins directory. On load it registers a command that
4+
becomes available in the command palette (Ctrl+Shift+P).
5+
"""
6+
7+
8+
def register(context):
9+
registry = context["registry"]
10+
11+
def say_hello():
12+
app = context.get("app")
13+
msg = "你好,来自示例插件 👋"
14+
if app is not None and hasattr(app, "_set_status"):
15+
app._set_status(msg)
16+
return msg
17+
18+
registry.register("plugin.hello", "示例插件: 打招呼", say_hello)

gui.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,16 @@ def __init__(self, root):
4444
self.auto_save_job = None
4545
self.is_modified = False
4646

47+
from commands import CommandRegistry # noqa: PLC0415
48+
self.commands = CommandRegistry()
49+
4750
self.setup_styles()
4851
self.create_menu()
4952
self.create_widgets()
5053
self.load_notes_list()
5154
self.rebuild_search_index()
55+
self._register_core_commands()
56+
self._load_plugins()
5257

5358
if config.auto_save:
5459
self.start_auto_save()
@@ -188,6 +193,65 @@ def _build_view_menu(self, menubar):
188193
view_menu.add_command(label="历史版本", command=self.show_history)
189194
view_menu.add_command(label="全屏", accelerator="F11")
190195

196+
def _register_core_commands(self):
197+
actions = [
198+
("core.new_note", "新建笔记", self.create_note, "Ctrl+N"),
199+
("core.save_note", "保存笔记", self.save_current_note, "Ctrl+S"),
200+
("core.search", "搜索", self.show_search, "Ctrl+F"),
201+
("core.preview", "切换预览", self.toggle_preview, ""),
202+
("core.todo", "待办列表", self.show_todo_view, ""),
203+
("core.dashboard", "任务仪表盘", self.show_dashboard, ""),
204+
("core.settings", "设置", self.show_settings, ""),
205+
("core.rebuild_index", "重建索引", self.rebuild_search_index, ""),
206+
]
207+
for cid, title, handler, shortcut in actions:
208+
try:
209+
self.commands.register(cid, title, handler, shortcut)
210+
except ValueError: # noqa: PERF203 - idempotent registration guard
211+
pass # already registered
212+
213+
def _load_plugins(self):
214+
from plugins import loader # noqa: PLC0415
215+
context = {"registry": self.commands, "app": self}
216+
loaded = loader.load_all(config.plugins_dir, context)
217+
if loaded:
218+
self._set_status(f"已加载插件: {', '.join(loaded)}")
219+
220+
def show_command_palette(self, event=None):
221+
win = tk.Toplevel(self.root)
222+
win.title("命令面板")
223+
win.geometry("440x320")
224+
entry = tk.Entry(win, font=('Microsoft YaHei UI', 12))
225+
entry.pack(fill='x', padx=8, pady=8)
226+
listbox = tk.Listbox(win)
227+
listbox.pack(fill='both', expand=True, padx=8, pady=(0, 8))
228+
229+
state = {"cmds": []}
230+
231+
def refresh(*_):
232+
listbox.delete(0, tk.END)
233+
state["cmds"] = self.commands.search(entry.get())
234+
for cmd in state["cmds"]:
235+
sc = f" ({cmd.shortcut})" if cmd.shortcut else ""
236+
listbox.insert(tk.END, cmd.title + sc)
237+
if state["cmds"]:
238+
listbox.selection_set(0)
239+
240+
def run(_e=None):
241+
sel = listbox.curselection()
242+
idx = sel[0] if sel else 0
243+
if state["cmds"]:
244+
cmd = state["cmds"][idx]
245+
win.destroy()
246+
self.commands.execute(cmd.id)
247+
248+
entry.bind('<KeyRelease>', refresh)
249+
entry.bind('<Return>', run)
250+
listbox.bind('<Double-Button-1>', run)
251+
refresh()
252+
entry.focus()
253+
return "break"
254+
191255
def _bind_shortcuts(self):
192256
self.root.bind('<Control-n>', lambda e: self.create_note())
193257
self.root.bind('<Control-s>', lambda e: self.save_current_note())
@@ -198,6 +262,8 @@ def _bind_shortcuts(self):
198262
self.root.bind('<Control-b>', lambda e: self._md_wrap("bold"))
199263
self.root.bind('<Control-i>', lambda e: self._md_wrap("italic"))
200264
self.root.bind('<F9>', lambda e: self.toggle_focus_mode())
265+
self.root.bind('<Control-Shift-P>', self.show_command_palette)
266+
self.root.bind('<Control-Shift-p>', self.show_command_palette)
201267

202268
def create_widgets(self):
203269
main_container = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)

plugins/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""Plugin system for SmartNotes."""

plugins/loader.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""Discover and load plugins.
2+
3+
A plugin is a Python module exposing ``register(context)``, where context gives
4+
access to the command registry (and, in the running app, the main window). We
5+
discover ``*.py`` files in a plugins directory and import them.
6+
"""
7+
import importlib.util
8+
import logging
9+
from pathlib import Path
10+
from typing import List
11+
12+
logger = logging.getLogger(__name__)
13+
14+
15+
def discover(plugins_dir: Path) -> List[Path]:
16+
plugins_dir = Path(plugins_dir)
17+
if not plugins_dir.exists():
18+
return []
19+
return sorted(p for p in plugins_dir.glob("*.py") if not p.name.startswith("_"))
20+
21+
22+
def load_module(path: Path):
23+
spec = importlib.util.spec_from_file_location(f"smartnotes_plugin_{path.stem}", path)
24+
module = importlib.util.module_from_spec(spec)
25+
spec.loader.exec_module(module)
26+
return module
27+
28+
29+
def load_all(plugins_dir: Path, context) -> List[str]:
30+
"""Import each plugin and call its register(context). Returns loaded names."""
31+
loaded = []
32+
for path in discover(plugins_dir):
33+
try:
34+
module = load_module(path)
35+
if hasattr(module, "register"):
36+
module.register(context)
37+
loaded.append(path.stem)
38+
else:
39+
logger.warning("插件 %s 缺少 register(context)", path.name)
40+
except Exception: # noqa: PERF203 - isolate per-plugin failures
41+
logger.exception("加载插件失败: %s", path.name)
42+
return loaded

tests/test_commands.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Command registry: register/execute, fuzzy search, and plugin loading."""
2+
import pathlib
3+
4+
from commands import CommandRegistry
5+
from plugins import loader
6+
7+
8+
def test_register_and_execute():
9+
reg = CommandRegistry()
10+
calls = []
11+
reg.register("a.b", "Do Thing", lambda: calls.append(1))
12+
reg.execute("a.b")
13+
assert calls == [1]
14+
15+
16+
def test_duplicate_registration_rejected():
17+
reg = CommandRegistry()
18+
reg.register("x", "X", lambda: None)
19+
try:
20+
reg.register("x", "X again", lambda: None)
21+
assert False, "expected ValueError"
22+
except ValueError:
23+
pass
24+
25+
26+
def test_fuzzy_search_subsequence():
27+
reg = CommandRegistry()
28+
reg.register("core.new_note", "新建笔记", lambda: None)
29+
reg.register("core.save_note", "保存笔记", lambda: None)
30+
reg.register("core.settings", "设置", lambda: None)
31+
titles = [c.title for c in reg.search("note")]
32+
assert "新建笔记" in titles and "保存笔记" in titles
33+
# empty query returns everything
34+
assert len(reg.search("")) == 3
35+
36+
37+
def test_unregister():
38+
reg = CommandRegistry()
39+
reg.register("x", "X", lambda: None)
40+
reg.unregister("x")
41+
assert reg.all() == []
42+
43+
44+
def test_plugin_discovery_and_load(tmp_path):
45+
plugin = tmp_path / "myplugin.py"
46+
plugin.write_text(
47+
"def register(context):\n"
48+
" context['registry'].register('p.hi', 'Plugin Hi', lambda: 'hi')\n",
49+
encoding="utf-8",
50+
)
51+
reg = CommandRegistry()
52+
loaded = loader.load_all(tmp_path, {"registry": reg})
53+
assert "myplugin" in loaded
54+
assert reg.execute("p.hi") == "hi"
55+
56+
57+
def test_example_plugin_registers(tmp_path):
58+
# the shipped example plugin should load and register its command
59+
example = pathlib.Path(loader.__file__).resolve().parent.parent / "examples" / "hello_plugin.py"
60+
reg = CommandRegistry()
61+
mod = loader.load_module(example)
62+
mod.register({"registry": reg, "app": None})
63+
assert any(c.id == "plugin.hello" for c in reg.all())

0 commit comments

Comments
 (0)