Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
821 changes: 821 additions & 0 deletions plugin-dev-tutorial.md

Large diffs are not rendered by default.

59 changes: 28 additions & 31 deletions src/plugin_manager/event_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
事件分发器

负责将事件分发给订阅了该事件的插件
支持优先级排序和异常隔离
- 非阻塞:dispatch() 将事件投递到各插件的独立队列后立即返回
- 每个插件在自己的线程中串行消费事件(由 BasePlugin.run() 负责)
- 支持优先级排序和异常隔离
"""
from __future__ import annotations

Expand All @@ -29,13 +31,14 @@ class HandlerEntry:

class EventDispatcher:
"""
事件分发器
事件分发器(非阻塞投递模式)

功能:
- 管理事件订阅
- 按优先级分发事件
- 异常隔离(一个处理函数出错不影响其他)
- 线程安全
- dispatch() 不阻塞:将事件投递到各插件的队列,立即返回
- 异常隔离通过各插件线程自行处理
- 背压控制:队列满时丢弃事件并记录警告
"""

def __init__(self):
Expand All @@ -56,7 +59,7 @@ def subscribe(
event_type: 事件类型名称
handler: 事件处理函数
priority: 优先级(数值越小越先执行)
plugin: 所属插件(用于取消订阅
plugin: 所属插件(用于取消订阅和队列投递
"""
with self._lock:
entry = HandlerEntry(
Expand Down Expand Up @@ -104,7 +107,7 @@ def unsubscribe_all(self, plugin: BasePlugin) -> None:

def dispatch(self, event_type: str, event: Any) -> None:
"""
分发事件给所有订阅者
非阻塞分发事件:将事件投递到各插件的独立队列,立即返回

Args:
event_type: 事件类型名称
Expand All @@ -126,31 +129,25 @@ def dispatch(self, event_type: str, event: Any) -> None:
if entry.plugin and not entry.plugin.is_enabled:
continue

try:
entry.handler(event)
except Exception as e:
plugin_name = entry.plugin.name if entry.plugin else "unknown"
logger.error(
f"Handler error in plugin '{plugin_name}' "
f"for event '{event_type}': {e}",
exc_info=True,
)

def dispatch_async(self, event_type: str, event: Any) -> None:
"""
异步分发事件(在新线程中执行)

Args:
event_type: 事件类型名称
event: 事件数据
"""
thread = threading.Thread(
target=self.dispatch,
args=(event_type, event),
daemon=True,
)
thread.start()

if entry.plugin is not None:
# 投递到插件队列(非阻塞)
success = entry.plugin._enqueue_event(entry.handler, event)
if not success:
logger.warning(
f"Dropped event '{event_type}' for plugin "
f"'{entry.plugin.name}' (queue full)"
)
else:
# 无归属插件的 handler(兜底),在调用者线程同步执行
try:
entry.handler(event)
except Exception as e:
logger.error(
f"Handler error (no-plugin) for event "
f"'{event_type}': {e}",
exc_info=True,
)

def get_handlers(self, event_type: str) -> list[HandlerEntry]:
"""获取某事件的所有处理函数"""
with self._lock:
Expand Down
68 changes: 48 additions & 20 deletions src/plugin_manager/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from typing import TYPE_CHECKING

from PyQt5.QtCore import Qt, pyqtSignal, QPoint, QTimer
from PyQt5.QtGui import QMouseEvent, QIcon, QPixmap
from PyQt5.QtGui import QColor, QMouseEvent, QIcon, QPixmap
from PyQt5.QtWidgets import (
QApplication,
QCheckBox,
Expand All @@ -39,7 +39,7 @@
)

from .plugin_state import PluginStateManager, PluginState
from .plugin_base import WindowMode, LogLevel
from .plugin_base import PluginLifecycle, WindowMode, LogLevel
from .app_paths import get_data_dir

if TYPE_CHECKING:
Expand Down Expand Up @@ -214,6 +214,15 @@ def _detach_tab(self, index: int, name: str, pos: QPoint | None = None) -> None:

self.tab_detached.emit(name)

def _cleanup_detached(self, name: str) -> None:
"""安全清理 detached 窗口引用"""
if name in self._detached_windows:
w = self._detached_windows[name]
w.blockSignals(True)
w.close()
w.deleteLater()
del self._detached_windows[name]

def _attach_tab(self, name: str) -> None:
"""将弹出的窗口嵌回标签页"""
if name not in self._detached_windows:
Expand Down Expand Up @@ -444,6 +453,10 @@ def __init__(self, plugin_manager: PluginManager, parent=None):
# 应用已保存的状态到插件
self._apply_saved_states()

# 连接所有插件的 ready 信号,就绪后自动刷新列表
for p in self._manager.plugins.values():
p.ready.connect(lambda _p=p: self._on_plugin_ready(_p))

self._refresh_plugin_list()

# 定时刷新连接状态
Expand Down Expand Up @@ -672,6 +685,13 @@ def _stop_debug(self) -> None:

# ── 插件列表 ────────────────────────────────────────

def _on_plugin_ready(self, plugin) -> None:
"""插件初始化完成,刷新列表显示"""
self._refresh_plugin_list()
self.statusBar().showMessage(
self.tr("插件 {name} 就绪").format(name=plugin.name), 2000
)

def _refresh_plugin_list(self) -> None:
"""刷新插件列表和标签页"""
t = self._tab_widget
Expand All @@ -696,12 +716,23 @@ def _refresh_plugin_list(self) -> None:
li = QListWidgetItem(p.name)
li.setData(Qt.UserRole, name)
li.setIcon(p.plugin_icon)
# 已禁用的用灰色
if not p.is_enabled:
li.setForeground(Qt.gray)

lc = p.lifecycle

# 根据生命周期状态显示
if lc == PluginLifecycle.INITIALIZING:
li.setForeground(QColor("#f57c00")) # 橙色:初始化中
li.setText(f"{p.name} (⏳ 初始化中...)")
elif not p.is_enabled:
li.setForeground(Qt.gray) # 灰色:已禁用
elif lc == PluginLifecycle.SHUTTING_DOWN:
li.setForeground(QColor("#c62828")) # 红色:关闭中
li.setText(f"{p.name} (⏸ 关闭中...)")

lst.addItem(li)

if p.widget and name not in t._detached_windows and name not in self._closed_plugins:
# 只有就绪状态才创建窗口(INITIALIZING/STOPPED 不创建)
if p.lifecycle == PluginLifecycle.READY and p.widget and name not in t._detached_windows and name not in self._closed_plugins:
st = self._effective_state(name)
if st.window_mode == WindowMode.DETACHED:
t.add_detachable_tab(p.widget, name, icon=p.plugin_icon)
Expand Down Expand Up @@ -748,11 +779,13 @@ def _on_list_context_menu(self, pos) -> None:
menu = QMenu(self)
t = self._tab_widget

# 启用/禁用
# 启用/禁用(非就绪状态不可切换,但 STOPPED 可以重新启用)
lc = plugin.lifecycle
can_control = lc in (PluginLifecycle.READY, PluginLifecycle.STOPPED)
act_enable = menu.addAction("✅ " + self.tr("启用"))
act_disable = menu.addAction("❌ " + self.tr("禁用"))
act_enable.setEnabled(not plugin.is_enabled)
act_disable.setEnabled(plugin.is_enabled)
act_enable.setEnabled(can_control and not plugin.is_enabled)
act_disable.setEnabled(lc == PluginLifecycle.READY and plugin.is_enabled)
act_enable.triggered.connect(lambda: self._toggle_plugin(name, True))
act_disable.triggered.connect(lambda: self._toggle_plugin(name, False))

Expand All @@ -777,9 +810,10 @@ def _on_list_context_menu(self, pos) -> None:
act_open = menu.addAction("🖥 " + self.tr("打开窗口"))
act_close = menu.addAction("🚫 " + self.tr("关闭窗口"))

can_open = (has_closed or (not has_tab and plugin.widget is not None))
# 窗口操作只有就绪状态才可用
can_open = lc == PluginLifecycle.READY and (has_closed or (not has_tab and plugin.widget is not None))
act_open.setEnabled(can_open)
act_close.setEnabled(has_tab or has_detached)
act_close.setEnabled(lc == PluginLifecycle.READY and (has_tab or has_detached))

# 打开日志文件
act_log = menu.addAction("📋 " + self.tr("打开日志"))
Expand All @@ -801,6 +835,8 @@ def _toggle_plugin(self, name: str, enable: bool) -> None:
if enable:
self._manager.enable_plugin(name)
else:
# 禁用时先关闭窗口(处理 detached 窗口清理),再 shutdown
self._close_plugin_window(name)
self._manager.disable_plugin(name)
self._sync_state(name, enabled=enable)
self._refresh_plugin_list()
Expand Down Expand Up @@ -831,15 +867,6 @@ def _open_plugin_window(self, name: str) -> None:
self._closed_plugins.discard(name)
t.add_detachable_tab(plugin.widget, name, icon=plugin.plugin_icon)

def _cleanup_detached(self, name: str) -> None:
"""安全清理 detached 窗口引用"""
if name in self._detached_windows:
w = self._detached_windows[name]
w.blockSignals(True) # 阻止 closeEvent 再次触发 embed_requested
w.close()
w.deleteLater()
del self._detached_windows[name]

def _close_plugin_window(self, name: str) -> None:
"""关闭插件窗口(不销毁)"""
t = self._tab_widget
Expand Down Expand Up @@ -904,6 +931,7 @@ def _open_plugin_settings(self, name: str) -> None:
if new_state.enabled:
self._manager.enable_plugin(name)
else:
self._close_plugin_window(name)
self._manager.disable_plugin(name)
# 立即应用日志级别
plugin = self._manager.plugins.get(name)
Expand Down
Loading
Loading