summaryrefslogtreecommitdiff
path: root/ui/main_window.py
diff options
context:
space:
mode:
Diffstat (limited to 'ui/main_window.py')
-rw-r--r--ui/main_window.py143
1 files changed, 140 insertions, 3 deletions
diff --git a/ui/main_window.py b/ui/main_window.py
index 0bb62f6..a4b62e9 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -43,6 +43,9 @@ from ui.windows.signals_window import SignalsWindow
from ui.windows.plot_window import PlotWindow, build_default_layout
from ui.windows.settings_window import SettingsWindow
+from plugins.plugin_manager import PluginManager
+from plugins.base_plugin import PluginContext
+
_DARK_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_dark.qss")
_LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_light.qss")
@@ -65,12 +68,20 @@ class MainWindow(QMainWindow):
self._win_plot = None
self._win_settings = None
+ _plugins_dir = os.path.join(os.path.dirname(os.path.dirname(
+ os.path.abspath(__file__))), "plugins")
+ self._plugin_mgr = PluginManager(_plugins_dir)
+ self._plugin_mgr.discover()
+ # {plugin_id: [QAction, ...]} toolbar actions to remove on unload
+ self._plugin_toolbar_actions: dict = {}
+
self._wheel_blocker = _WheelBlocker(self)
QApplication.instance().installEventFilter(self._wheel_blocker)
self._init_demo_devices()
self._build_ui()
self._connect_signals()
+ self._init_plugins()
# ── Demo ──────────────────────────────────────────────────────────────
@@ -89,6 +100,7 @@ class MainWindow(QMainWindow):
def _build_ui(self):
tb = QToolBar(); tb.setObjectName("mainToolbar"); tb.setMovable(False)
self.addToolBar(tb)
+ self._toolbar = tb
def _sep():
s = QFrame(); s.setFrameShape(QFrame.Shape.VLine)
@@ -138,11 +150,14 @@ class MainWindow(QMainWindow):
tb.addWidget(_sep())
+ # Plugin buttons are inserted here at runtime (between this sep and spacer)
+ self._plugin_sep_action = tb.addWidget(_sep())
+ self._plugin_sep_action.setVisible(False)
- # Spacer + clock
+ # Spacer + clock — plugin buttons insert before this action
spacer = QWidget()
spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
- tb.addWidget(spacer)
+ self._spacer_action = tb.addWidget(spacer)
self._time_lbl = QLabel("00:00:00"); self._time_lbl.setObjectName("timeLabel")
tb.addWidget(self._time_lbl)
@@ -185,6 +200,108 @@ class MainWindow(QMainWindow):
self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}"))
self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}"))
+ # ── Plugin lifecycle ──────────────────────────────────────────────────
+
+ def _make_plugin_context(self) -> PluginContext:
+ return PluginContext(
+ registry=self.registry,
+ engine=self.engine,
+ processor=self.processor,
+ main_window=self,
+ )
+
+ def _init_plugins(self):
+ ctx = self._make_plugin_context()
+ for plugin in self._plugin_mgr.load_enabled(ctx):
+ self._install_plugin(plugin)
+
+ def _install_plugin(self, plugin):
+ """Register a loaded plugin's devices, filters, and toolbar buttons."""
+ from core.signal_processor import FILTER_CLASSES
+
+ # Devices
+ for dev in plugin.get_devices():
+ try:
+ dev.connect()
+ self.registry.add_instance(dev)
+ self.engine.add_device(dev)
+ except Exception as exc:
+ print(f"[Plugin:{plugin.plugin_id}] Device error: {exc}")
+
+ # Custom filter classes
+ for name, cls in plugin.get_filter_classes().items():
+ FILTER_CLASSES[name] = cls
+
+ # Toolbar buttons
+ actions = plugin.get_toolbar_actions()
+ tb_actions = []
+ if actions:
+ self._plugin_sep_action.setVisible(True)
+ for pa in actions:
+ label = f"{pa.icon} {pa.label}" if pa.icon else pa.label
+ btn = QPushButton(label)
+ btn.setObjectName("toolbarSectionBtn")
+ btn.setCheckable(pa.checkable)
+ if pa.tooltip:
+ btn.setToolTip(pa.tooltip)
+ btn.clicked.connect(pa.callback)
+ action = self._toolbar.insertWidget(self._spacer_action, btn)
+ tb_actions.append(action)
+ self._plugin_toolbar_actions[plugin.plugin_id] = tb_actions
+
+ self._chart.refresh()
+ if self._win_plot:
+ self._win_plot.refresh_channels()
+ self._status.setText(f"Plugin enabled: {plugin.name}")
+
+ def _uninstall_plugin(self, plugin_id: str):
+ """Remove a plugin's toolbar buttons, devices, and filter classes."""
+ from core.signal_processor import FILTER_CLASSES
+
+ plugin = self._plugin_mgr.get_plugin(plugin_id)
+
+ # Remove toolbar buttons first (before unload)
+ for action in self._plugin_toolbar_actions.pop(plugin_id, []):
+ self._toolbar.removeAction(action)
+
+ # Hide plugin separator if no plugins remain
+ if not any(acts for acts in self._plugin_toolbar_actions.values()):
+ self._plugin_sep_action.setVisible(False)
+
+ if plugin is None:
+ return
+
+ # Remove custom filter classes
+ for name in plugin.get_filter_classes():
+ FILTER_CLASSES.pop(name, None)
+
+ # Remove devices contributed by this plugin
+ for dev in plugin.get_devices():
+ dev_id = dev.info.device_id
+ try:
+ dev.disconnect()
+ except Exception:
+ pass
+ self.registry.remove_instance(dev_id)
+ self.engine.remove_device(dev_id)
+
+ self._chart.refresh()
+ if self._win_plot:
+ self._win_plot.refresh_channels()
+ self._status.setText(f"Plugin disabled: {plugin.name}")
+
+ def plugin_enable(self, plugin_id: str):
+ """Called by SettingsWindow when user enables a plugin."""
+ ctx = self._make_plugin_context()
+ plugin = self._plugin_mgr.enable(plugin_id, ctx)
+ if plugin:
+ self._install_plugin(plugin)
+
+ def plugin_disable(self, plugin_id: str):
+ """Called by SettingsWindow when user disables a plugin."""
+ self._uninstall_plugin(plugin_id)
+ self._plugin_mgr.disable(plugin_id)
+
# ── Window management ─────────────────────────────────────────────────
def _toggle_win(self, name: str, checked: bool, btn: QPushButton):
@@ -239,9 +356,12 @@ class MainWindow(QMainWindow):
def _open_settings(self):
if self._win_settings is None:
self._win_settings = SettingsWindow(self.registry, self.engine,
- self._settings, self)
+ self._settings, self,
+ plugin_manager=self._plugin_mgr)
self._win_settings.theme_changed.connect(self._apply_theme)
self._win_settings.settings_changed.connect(self._on_settings)
+ self._win_settings.plugin_enable_requested.connect(self.plugin_enable)
+ self._win_settings.plugin_disable_requested.connect(self.plugin_disable)
self._win_settings.closed.connect(lambda: self._btn_settings.setChecked(False))
self._show_win(self._win_settings, "right")
@@ -297,10 +417,21 @@ class MainWindow(QMainWindow):
control_specs=self._ctrl.get_specs(),
settings=self._settings,
profile_name=name,
+ plugin_manager=self._plugin_mgr,
)
def _profile_apply(self, profile: Profile):
"""Restore state from a Profile object."""
+ # Reconcile plugin enabled state before the rest of apply runs,
+ # so plugin devices are present when channels/pipelines are restored.
+ if profile.plugins_enabled is not None:
+ wanted = set(profile.plugins_enabled)
+ current = set(self._plugin_mgr.get_enabled_ids())
+ for pid in current - wanted:
+ self.plugin_disable(pid)
+ for pid in wanted - current:
+ self.plugin_enable(pid)
+
plot_cfg = ProfileManager.apply(
profile=profile,
registry=self.registry,
@@ -308,6 +439,7 @@ class MainWindow(QMainWindow):
control_panel=self._ctrl,
settings_ref=self._settings,
engine=self.engine,
+ plugin_manager=self._plugin_mgr,
)
if plot_cfg:
self._chart.apply_layout(plot_cfg)
@@ -400,4 +532,9 @@ class MainWindow(QMainWindow):
for w in (self._win_devices, self._win_signals,
self._win_plot, self._win_settings):
if w: w.close()
+ for plugin in list(self._plugin_mgr.get_loaded()):
+ try:
+ plugin.on_unload()
+ except Exception:
+ pass
self.engine.stop(); event.accept()