""" ui/main_window.py Toolbar (left→right): [▶ RUN] [⬤ LOG] | [⊞ Devices] | [⚗ Signals] | [📐 Plot] | [⚙ Settings] spacer | timer | [📁 File] """ from PyQt6.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QDockWidget, QStatusBar, QLabel, QPushButton, QToolBar, QSizePolicy, QApplication, QFrame, QAbstractSpinBox, QComboBox, QMenu, ) from PyQt6.QtCore import Qt, QTimer, QObject, QEvent, QRect, QByteArray, pyqtSignal, pyqtSlot from PyQt6.QtGui import QPainter, QColor, QFont, QAction, QPalette import logging import base64 import os class _WheelBlocker(QObject): """App-level event filter: blocks accidental scroll-wheel changes on spin boxes, combo boxes and sliders that don't have keyboard focus.""" def eventFilter(self, obj, event): if event.type() == QEvent.Type.Wheel: if isinstance(obj, (QAbstractSpinBox, QComboBox)): if not obj.hasFocus(): event.ignore() return True return super().eventFilter(obj, event) class _DockTabStrip(QWidget): """Thin strip shown on an edge when its dock is hidden. Clicking it restores the dock. Safety feature — operator can never lose the panel.""" clicked = pyqtSignal() def __init__(self, label: str, color: str = "#3b82f6", arrow: str = "▶", parent=None): super().__init__(parent) self._label = label self._color = color self._arrow = arrow self.setObjectName("ctrlTabStrip") self.setFixedWidth(22) self.setCursor(Qt.CursorShape.PointingHandCursor) self.setToolTip(f"Show {label.title()}") def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: self.clicked.emit() super().mousePressEvent(event) def paintEvent(self, _event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) p.fillRect(self.rect(), QColor(self._color)) p.setPen(QColor("#ffffff")) font = QFont() font.setPointSize(8) font.setBold(True) p.setFont(font) p.translate(self.width() / 2.0, self.height() / 2.0) p.rotate(-90.0) text_rect = QRect(-self.height() // 2, -self.width() // 2, self.height(), self.width()) p.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, f"{self._label} {self._arrow}") p.end() class _DockTitleBar(QWidget): """Custom title bar for a QDockWidget. Replaces the native title bar; provides an optional Add button plus a Close button.""" def __init__(self, dock: QDockWidget, title: str, add_callback=None, parent=None): super().__init__(parent) self.setObjectName("controlPanelHeader") self.setFixedHeight(30) lay = QHBoxLayout(self) lay.setContentsMargins(8, 0, 4, 0); lay.setSpacing(4) lbl = QLabel(title); lbl.setObjectName("panelHeader") lay.addWidget(lbl, 1) if add_callback is not None: add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn") add_btn.setFixedSize(24, 22); add_btn.setToolTip(f"Add to {title.title()}") add_btn.clicked.connect(add_callback) lay.addWidget(add_btn) close_btn = QPushButton("✕"); close_btn.setObjectName("devicesSmallBtn") close_btn.setFixedSize(22, 22); close_btn.setToolTip(f"Hide {title.title()}") close_btn.clicked.connect(dock.hide) lay.addWidget(close_btn) from devices.device_registry import DeviceRegistry from core.acquisition import AcquisitionEngine from core.signal_processor import SignalProcessor from core.profile import Profile, ProfileManager from ui.control_panel import ControlPanel from ui.namespace_panel import NamespacePanel from ui.strip_chart import StripChartWidget from ui.profile_manager_ui import ProfileButton from ui.windows.config_window import ConfigWindow from ui.windows.plot_window import build_default_layout from ui.windows.settings_window import SettingsWindow from plugins.plugin_manager import PluginManager from plugins.base_plugin import PluginContext from core.app_settings import load_settings, save_settings, set_developer_mode _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") class MainWindow(QMainWindow): # Bump when dock/toolbar structure changes in a way that would make an # old saved dock_state blob meaningless (e.g. a dock renamed/removed) — # restoreState() rejects a mismatched version and just no-ops instead # of applying garbage. _DOCK_STATE_VERSION = 1 def __init__(self): super().__init__() self.setWindowTitle("LabUI") self.setMinimumSize(1000, 640) self.registry = DeviceRegistry() self.engine = AcquisitionEngine(poll_interval_ms=100) self.processor = SignalProcessor() self._settings = load_settings(SettingsWindow._defaults) self._elapsed = 0 self._win_config = None self._win_settings = None import sys as _sys _user_plugins = os.path.join(os.path.expanduser("~"), ".labui", "plugins") _extra_dirs = [] if not getattr(_sys, "frozen", False): _project_plugins = os.path.join(os.path.dirname(os.path.dirname( os.path.abspath(__file__))), "plugins") _extra_dirs = [_project_plugins] self._plugin_mgr = PluginManager(_user_plugins, extra_scan_dirs=_extra_dirs) 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._build_ui() self._connect_signals() self._init_plugins() self._apply_settings_on_startup() # ── UI ──────────────────────────────────────────────────────────────── 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) s.setObjectName("toolbarSep") s.setFixedWidth(6); return s # ── 📁 File button (profiles) ───────────────────────────────────── self._file_btn = ProfileButton( on_new=self._profile_new, get_profile=self._profile_capture, apply_profile=self._profile_apply, ) tb.addWidget(self._file_btn) # ── ⊞ View menu ─────────────────────────────────────────────────── view_btn = QPushButton("⊞ View ▾"); view_btn.setObjectName("toolbarSectionBtn") view_btn.setToolTip("Show/hide the Control Panel and Namespace docks") self._view_menu = QMenu(self) self._act_ctrl_panel = QAction("Control Panel", self) self._act_ctrl_panel.setCheckable(True); self._act_ctrl_panel.setChecked(True) self._act_ctrl_panel.triggered.connect(self._on_view_ctrl_panel) self._view_menu.addAction(self._act_ctrl_panel) self._act_namespace = QAction("Namespace", self) self._act_namespace.setCheckable(True); self._act_namespace.setChecked(False) self._act_namespace.triggered.connect(self._on_view_namespace) self._view_menu.addAction(self._act_namespace) self._view_plugin_sep = self._view_menu.addSeparator() self._view_plugin_sep.setVisible(False) view_btn.clicked.connect( lambda: self._view_menu.exec(view_btn.mapToGlobal(view_btn.rect().bottomLeft())) ) tb.addWidget(view_btn) config_btn = QPushButton("⚙ Configure"); config_btn.setObjectName("toolbarSectionBtn") config_btn.setToolTip("Open Configuration — Devices, Channels, and Plot layout") config_btn.clicked.connect(lambda: self._open_config(0)) tb.addWidget(config_btn) tb.addWidget(_sep()) self._run_btn = QPushButton("▶ RUN") self._run_btn.setToolTip("Start/stop acquisition") self._run_btn.setObjectName("runButton"); self._run_btn.setCheckable(True) self._run_btn.clicked.connect(self._toggle_run); tb.addWidget(self._run_btn) self._log_btn = QPushButton("⬤ LOG") self._log_btn.setToolTip("Start/stop logging data to CSV (only available while running)") self._log_btn.setObjectName("logButton"); self._log_btn.setCheckable(True) self._log_btn.setEnabled(False); self._log_btn.clicked.connect(self._toggle_log) tb.addWidget(self._log_btn) self._clear_btn = QPushButton("⌫ Clear") self._clear_btn.setObjectName("toolbarSectionBtn") self._clear_btn.setToolTip("Clear plot history") self._clear_btn.clicked.connect(self._clear_history) tb.addWidget(self._clear_btn) #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 — plugin buttons insert before this action spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) self._spacer_action = tb.addWidget(spacer) self._time_lbl = QLabel("00:00:00"); self._time_lbl.setObjectName("timeLabel") tb.addWidget(self._time_lbl) tb.addWidget(_sep()) set_btn = QPushButton("⚙ Settings") set_btn.setToolTip("App settings, theme, plugins") set_btn.setObjectName("toolbarSectionBtn"); set_btn.setCheckable(True) set_btn.clicked.connect(lambda c: self._open_settings()) tb.addWidget(set_btn); self._btn_settings = set_btn # ── Central ─────────────────────────────────────────────────────── central = QWidget(); self.setCentralWidget(central) root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0) # Side tab strip — shown when Control Panel dock is hidden self._ctrl_tab = _DockTabStrip("CONTROL PANEL") self._ctrl_tab.setVisible(False) self._ctrl_tab.clicked.connect(self._show_ctrl_panel) root.addWidget(self._ctrl_tab) self._chart = StripChartWidget(self.engine, self.registry, self.processor) root.addWidget(self._chart, 1) # No collapse-strip for Namespace — it's a configuration/reference # tool, not something the operator needs a can't-lose-it affordance # for during a run (unlike Control Panel). View menu brings it back. # ── Control Panel dock ──────────────────────────────────────────── self._ctrl = ControlPanel(self.registry, processor=self.processor, engine=self.engine) self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340) self._ctrl_dock = QDockWidget("Control Panel", self) self._ctrl_dock.setObjectName("controlPanelDock") self._ctrl_dock.setWidget(self._ctrl) self._ctrl_dock.setFeatures( QDockWidget.DockWidgetFeature.DockWidgetMovable | QDockWidget.DockWidgetFeature.DockWidgetClosable ) self._ctrl_dock.setAllowedAreas( Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea ) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self._ctrl_dock) self._ctrl_dock.setTitleBarWidget(_DockTitleBar(self._ctrl_dock, "CONTROL PANEL", self._ctrl._on_add)) self._ctrl_dock.visibilityChanged.connect(self._on_ctrl_dock_visibility) # ── Namespace dock ─────────────────────────────────────────────── self._namespace = NamespacePanel(self.registry, self.processor) self._namespace.setMinimumWidth(260) self._namespace_dock = QDockWidget("Namespace", self) self._namespace_dock.setObjectName("namespaceDock") self._namespace_dock.setWidget(self._namespace) self._namespace_dock.setFeatures( QDockWidget.DockWidgetFeature.DockWidgetMovable | QDockWidget.DockWidgetFeature.DockWidgetClosable ) self._namespace_dock.setAllowedAreas( Qt.DockWidgetArea.LeftDockWidgetArea | Qt.DockWidgetArea.RightDockWidgetArea ) self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self._namespace_dock) self._namespace_dock.setTitleBarWidget(_DockTitleBar(self._namespace_dock, "NAMESPACE")) self._namespace_dock.visibilityChanged.connect(self._on_namespace_dock_visibility) self._namespace_dock.hide() # closed by default — opt-in config tool, not shown on startup sb = QStatusBar(); self.setStatusBar(sb) self._status = QLabel("Ready"); sb.addWidget(self._status) self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl) from core.version import __version__ _ver_lbl = QLabel(f"v{__version__}") _ver_lbl.setObjectName("versionLabel") sb.addPermanentWidget(_ver_lbl) self._clock = QTimer(self); self._clock.setInterval(1000) self._clock.timeout.connect(self._tick) self._rec_blink = QTimer(self); self._rec_blink.setInterval(600) self._rec_blink.timeout.connect(self._tick_rec_blink) def _on_ctrl_dock_visibility(self, visible: bool): self._ctrl_tab.setVisible(not visible) if hasattr(self, "_act_ctrl_panel"): self._act_ctrl_panel.setChecked(visible) def _on_view_ctrl_panel(self, checked: bool): if checked: self._ctrl_dock.show(); self._ctrl_dock.raise_() else: self._ctrl_dock.hide() def _show_ctrl_panel(self): self._ctrl_dock.show() self._ctrl_dock.raise_() def _on_namespace_dock_visibility(self, visible: bool): if hasattr(self, "_act_namespace"): self._act_namespace.setChecked(visible) def _on_view_namespace(self, checked: bool): if checked: self._namespace_dock.show(); self._namespace_dock.raise_() else: self._namespace_dock.hide() def _show_namespace_panel(self): self._namespace_dock.show() self._namespace_dock.raise_() def _connect_signals(self): self.engine.new_data.connect(self.processor.on_raw_batch) self.processor.processed_data.connect(self._chart.on_new_data) self.processor.processed_data.connect(self._namespace.on_processed_data) self.engine.channel_unit_changed.connect(self._chart.refresh_pane_unit) 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) if pa.button_ref_callback: pa.button_ref_callback(btn) 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_config: self._win_config.refresh_plot() 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_config: self._win_config.refresh_plot() self._status.setText(f"Plugin disabled: {plugin.name}") def plugin_enable(self, plugin_id: str): """Called by SettingsWindow when user enables a plugin.""" missing = self._plugin_mgr.get_missing_dependencies(plugin_id) if missing: from PyQt6.QtWidgets import QMessageBox reply = QMessageBox.question( self, "Missing Plugin Dependencies", f"This plugin needs packages that aren't installed:\n\n" f" {', '.join(missing)}\n\n" f"Install them now with pip?", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No, ) if reply == QMessageBox.StandardButton.Yes: if self._pip_install(missing): self._enable_plugin_now(plugin_id) else: self._enable_plugin_now(plugin_id) if self._win_settings: self._win_settings.sync_plugin_button(plugin_id) def _enable_plugin_now(self, plugin_id: str): ctx = self._make_plugin_context() plugin = self._plugin_mgr.enable(plugin_id, ctx) if plugin: self._install_plugin(plugin) def _pip_install(self, requirements: list) -> bool: """Blocking pip install of the given requirement strings. In a frozen (PyInstaller) app sys.executable is the exe itself, so we locate a real Python interpreter and install into ~/.labui/plugin_packages/ which is added to sys.path at startup (see main.py). In dev mode the normal sys.executable + site-packages path is used instead. Returns True on success; shows a result dialog either way. """ import shutil import subprocess import sys as _sys from PyQt6.QtWidgets import QMessageBox frozen = getattr(_sys, "frozen", False) if frozen: python = shutil.which("python3") or shutil.which("python") if not python: QMessageBox.critical( self, "Install Failed", "Could not find a Python interpreter on PATH.\n" "Install the required packages manually:\n\n" + "\n".join(f" pip install {r}" for r in requirements) ) return False pkg_dir = os.path.join(os.path.expanduser("~"), ".labui", "plugin_packages") os.makedirs(pkg_dir, exist_ok=True) cmd = [python, "-m", "pip", "install", "--target", pkg_dir, *requirements] else: python = _sys.executable cmd = [python, "-m", "pip", "install", *requirements] QApplication.setOverrideCursor(Qt.CursorShape.WaitCursor) try: result = subprocess.run(cmd, capture_output=True, text=True) finally: QApplication.restoreOverrideCursor() if result.returncode == 0: if frozen: pkg_dir = os.path.join(os.path.expanduser("~"), ".labui", "plugin_packages") if pkg_dir not in _sys.path: _sys.path.insert(0, pkg_dir) QMessageBox.information( self, "Install Complete", f"Installed: {', '.join(requirements)}" ) return True QMessageBox.critical( self, "Install Failed", f"pip install failed for: {', '.join(requirements)}\n\n" f"{result.stderr.strip()[-1500:]}" ) return False 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 _open_config(self, tab: int = 0): if self._win_config is None: self._win_config = ConfigWindow( self.registry, self.engine, self.processor, self._chart._cfg, self, ) self._win_config.device_added.connect(self._on_device_added) self._win_config.device_removed.connect(self._on_device_removed) self._win_config.device_reconfigured.connect(self._on_device_reconfigured) self._win_config.channel_visibility_changed.connect(self._on_channel_visibility_changed) self._win_config.channel_name_changed.connect(self._on_channel_name_changed) self._win_config.channel_unit_changed.connect(self._on_channel_unit_changed) self._win_config.pipeline_changed.connect(lambda: None) self._win_config.derived_changed.connect(self._on_derived_changed) self._win_config.channel_renamed.connect(self._on_channel_renamed) self._win_config.layout_applied.connect(self._chart.apply_layout) self._win_config.tabs.setCurrentIndex(tab) self._show_win(self._win_config, "right") def _open_settings(self): if self._win_settings is None: self._win_settings = SettingsWindow(self.registry, self.engine, 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") def _show_win(self, win: QWidget, position: str = "right"): if not win.isVisible(): screen = QApplication.screenAt(self.geometry().center()) if screen is None: screen = QApplication.primaryScreen() avail = screen.availableGeometry() # Use normalGeometry so maximized windows don't push child off-screen geo = self.normalGeometry() win.adjustSize() w, h = win.width(), win.height() if position == "right": x = geo.right() + 8 y = geo.top() + 40 else: x = geo.left() y = geo.bottom() + 8 # Clamp to available screen area x = max(avail.left(), min(x, avail.right() - w)) y = max(avail.top(), min(y, avail.bottom() - h)) win.move(x, y) win.show(); win.raise_(); win.activateWindow() # ── Profile callbacks ───────────────────────────────────────────────── def load_profile_path(self, path: str) -> bool: """Load a .labui file from an explicit path — used when one is opened from the OS (double-click / CLI argv), see main.py.""" return self._file_btn.load_path(path) def _profile_new(self): """Reset to a blank slate.""" # Clear devices for dev in list(self.registry.all_instances()): try: dev.disconnect() except Exception: pass self.registry.remove_instance(dev.info.device_id) self.engine.remove_device(dev.info.device_id) # Clear signal pipelines self.processor._pipelines.clear() # Clear derived channels for dc in list(self.processor.get_derived()): self.processor.remove_derived(dc.channel_id) # Clear controls self._ctrl.clear_widgets() if self._win_config: self._win_config.refresh_devices() self._win_config.refresh_derived() self._win_config.refresh_plot() self._chart.apply_layout(build_default_layout(self.registry, self.processor)) self._clear_history() self._status.setText("New profile — blank slate.") def _profile_capture(self, name: str = "Profile") -> Profile: """Serialise current state into a Profile object.""" self._capture_dock_state() return ProfileManager.capture( registry=self.registry, processor=self.processor, plot_cfg=self._chart._cfg, control_specs=self._ctrl.get_specs(), settings=self._settings, profile_name=name, plugin_manager=self._plugin_mgr, ) def _check_profile_plugins(self, profile: Profile) -> bool: """Detect missing/broken plugins and prompt user to fix them. Returns False if the user cancels the profile load. Two cases are caught: 'plugin' — plugin not installed at all 'deps' — plugin installed but its dependencies are absent """ if not profile.plugins_enabled: return True installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()} missing = [] for pid in profile.plugins_enabled: pm_info = next( (m for m in profile.plugins_manifest if m.get("plugin_id") == pid), {"plugin_id": pid, "name": pid, "version": "unknown", "description": "", "requires": []}, ) if pid not in installed_ids: missing.append({**pm_info, "kind": "plugin"}) else: absent_deps = self._plugin_mgr.get_missing_dependencies(pid) if absent_deps: missing.append({**pm_info, "kind": "deps", "missing_deps": absent_deps}) if not missing: return True from PyQt6.QtWidgets import QDialog from ui.windows.missing_plugins_dialog import MissingPluginsDialog dlg = MissingPluginsDialog(missing, self._plugin_mgr, self) return dlg.exec() == QDialog.DialogCode.Accepted def _profile_apply(self, profile: Profile): """Restore state from a Profile object.""" if not self._check_profile_plugins(profile): return # Reconcile plugin enabled state before the rest of apply runs, # so plugin devices are present when channels/pipelines are restored. # Only enable plugins that are actually installed — missing ones were # handled (or skipped) by _check_profile_plugins. if profile.plugins_enabled is not None: installed_ids = {m.plugin_id for m in self._plugin_mgr.get_manifests()} wanted = set(profile.plugins_enabled) & installed_ids 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, processor=self.processor, 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) else: self._chart.refresh() if self._win_config: self._win_config.refresh_devices() self._win_config.refresh_derived() self._win_config.refresh_plot(self._chart._cfg) QTimer.singleShot(0, self._apply_dock_state) self._status.setText(f"Profile loaded: {profile.name}") # ── Device / signal events ──────────────────────────────────────────── def _on_device_added(self): self._chart.refresh() if self._win_config: self._win_config.on_device_added() self._win_config.refresh_plot() self._status.setText("Device added.") def _on_device_removed(self, dev_id: str): self._chart.refresh() if self._win_config: self._win_config.on_device_removed(dev_id) self._status.setText(f"Device '{dev_id}' removed.") def _on_device_reconfigured(self, dev_id: str): dev = self.registry.get_instance(dev_id) if dev is not None: current_ids = {s.signal_id for s in dev.info.signals} removed_ids = self.processor.on_device_reconfigured(dev_id, current_ids) if removed_ids: from core.reference_registry import find_references for sid in removed_ids: refs = find_references(self, (dev_id, sid)) if refs: print(f"[Device '{dev_id}'] signal '{sid}' removed by reconfigure — " f"still referenced by: {', '.join(refs)}") self.engine.on_device_reconfigured(dev_id) self._chart.refresh() if self._win_config: self._win_config.on_device_reconfigured(dev_id) self._win_config.refresh_plot() self._status.setText(f"Device '{dev_id}' reconfigured.") def _on_channel_visibility_changed(self, dev_id: str, ch_id: str, enabled: bool): self._chart.on_channel_enabled_changed(dev_id, ch_id, enabled) if self._win_config: self._win_config.on_channel_enabled_changed(dev_id, ch_id, enabled) self._win_config.refresh_plot() def _on_channel_name_changed(self, dev_id: str, ch_id: str, name: str): if self._win_config: self._win_config.on_channel_name_changed(dev_id, ch_id, name) def _on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str): if self._win_config: self._win_config.on_channel_unit_changed(dev_id, ch_id, unit) def _on_derived_changed(self): self._chart.refresh() if self._win_config: self._win_config.refresh_plot(self._chart._cfg) def _on_channel_renamed(self, old_dev: str, old_id: str, new_dev: str, new_id: str): """A Signal or Channel's id changed — rewrite every plot trace, control target, and filter pipeline that still points at the old (device_id, channel_id) pair so it doesn't silently stop updating.""" from core.reference_registry import propagate_rename propagate_rename(self, (old_dev, old_id), (new_dev, new_id)) # ── Run / Log ───────────────────────────────────────────────────────── def _toggle_run(self, c: bool): if c: self.engine.start() self._run_btn.setText("⏹ STOP"); self._log_btn.setEnabled(True) self._clock.start(); self._status.setText("Acquiring…") else: self._ctrl.safe_stop_all() # master switch — stop outputs before halting acquisition self.engine.stop() self._run_btn.setText("▶ RUN") if self._log_btn.isChecked(): self._log_btn.setChecked(False) self._toggle_log(False) # setChecked() alone won't fire clicked — stop blink/logging explicitly self._log_btn.setEnabled(False); self._clock.stop() self._status.setText("Stopped") def _clear_history(self): self.engine.clear_history() self.processor.clear_history() self._chart.refresh() self._status.setText("History cleared.") def _toggle_log(self, c: bool): if c: p = self.engine.start_logging( os.path.join(self._settings.get("log_dir", "logs"), "")) self._log_btn.setText("⏹ LOGGING") self._status.setText(f"Logging → {p}") self._rec_blink.start() else: self.engine.stop_logging(); self._log_btn.setText("⬤ LOG") self._rec_blink.stop() self._log_btn.setProperty("recording", False) self._log_btn.style().unpolish(self._log_btn); self._log_btn.style().polish(self._log_btn) def _tick_rec_blink(self): """Pulse the Log button's background while a recording is active.""" on = not self._log_btn.property("recording") self._log_btn.setProperty("recording", on) self._log_btn.style().unpolish(self._log_btn); self._log_btn.style().polish(self._log_btn) # ── Theme / settings ────────────────────────────────────────────────── def _apply_theme(self, theme: str): qss_file = _DARK_QSS if theme == "dark" else _LIGHT_QSS if os.path.exists(qss_file): with open(qss_file) as f: QApplication.instance().setStyleSheet(f.read()) self._apply_palette(theme) self._chart.set_theme(theme) def _apply_palette(self, theme: str): """QSS alone doesn't reach every Qt-drawn pixel — a splitter/dock separator's grip-dot decoration, a QMenu's checkmark tick, etc. are drawn from the app's QPalette, not stylesheet background-color. Without ever setting one, those stay Qt's default light palette regardless of the dark stylesheet, showing up as stray white dots/ slivers on top of otherwise-correctly-dark widgets.""" app = QApplication.instance() if theme == "dark": pal = QPalette() pal.setColor(QPalette.ColorRole.Window, QColor("#0b0e13")) pal.setColor(QPalette.ColorRole.WindowText, QColor("#c7d2fe")) pal.setColor(QPalette.ColorRole.Base, QColor("#111620")) pal.setColor(QPalette.ColorRole.AlternateBase, QColor("#161d2e")) pal.setColor(QPalette.ColorRole.Text, QColor("#c7d2fe")) pal.setColor(QPalette.ColorRole.Button, QColor("#161d2e")) pal.setColor(QPalette.ColorRole.ButtonText, QColor("#c7d2fe")) pal.setColor(QPalette.ColorRole.Highlight, QColor("#3b82f6")) pal.setColor(QPalette.ColorRole.HighlightedText, QColor("#0b0e13")) pal.setColor(QPalette.ColorRole.ToolTipBase, QColor("#161d2e")) pal.setColor(QPalette.ColorRole.ToolTipText, QColor("#c7d2fe")) pal.setColor(QPalette.ColorRole.Light, QColor("#2a3558")) pal.setColor(QPalette.ColorRole.Midlight, QColor("#2a3558")) pal.setColor(QPalette.ColorRole.Dark, QColor("#060810")) pal.setColor(QPalette.ColorRole.Mid, QColor("#1c2540")) pal.setColor(QPalette.ColorRole.Shadow, QColor("#000000")) app.setPalette(pal) else: app.setPalette(app.style().standardPalette()) def _apply_settings_on_startup(self): self._apply_theme(self._settings.get("theme", "dark")) self.engine._interval = self._settings.get("poll_ms", 100) / 1000.0 self.engine.set_buffer_size(self._settings.get("buffer_size", 20000)) self._apply_developer_mode(self._settings.get("developer_mode", False)) QTimer.singleShot(0, self._apply_dock_state) def _capture_dock_state(self): """Snapshot Control Panel / Namespace dock area, floating geometry, visibility, size and order via Qt's own QMainWindow.saveState() — one call covers everything a QDockWidget can have moved, instead of hand-tracking each attribute. Stored as base64 in self._settings, which both app_settings.json (save_settings()) and .labui profiles (Profile.settings — see ProfileManager.capture()) already round-trip verbatim, so this needs no schema changes on either side, just needs to run before whichever save happens.""" raw = bytes(self.saveState(self._DOCK_STATE_VERSION)) self._settings["dock_state"] = base64.b64encode(raw).decode("ascii") def _apply_dock_state(self): """Restore saved dock state — deferred one event-loop tick (via QTimer.singleShot(0, ...) at call sites) since restoreState() before the main window's initial layout has settled doesn't stick, the same reason splitter sizing elsewhere in this codebase (e.g. plot_window.py's _init_split) defers similarly.""" encoded = self._settings.get("dock_state") if not encoded: return raw = QByteArray(base64.b64decode(encoded)) self.restoreState(raw, self._DOCK_STATE_VERSION) def _apply_developer_mode(self, enabled: bool): level = logging.DEBUG if enabled else logging.WARNING logging.getLogger().setLevel(level) set_developer_mode(enabled) def _on_settings(self, cfg: dict): self._settings.update(cfg) self.engine._interval = cfg.get("poll_ms", 100) / 1000.0 self.engine.set_buffer_size(cfg.get("buffer_size", 20000)) self._apply_developer_mode(cfg.get("developer_mode", False)) save_settings(self._settings) def _tick(self): self._elapsed += 1 h=self._elapsed//3600; m=(self._elapsed%3600)//60; s=self._elapsed%60 self._time_lbl.setText(f"{h:02d}:{m:02d}:{s:02d}") def closeEvent(self, event): for w in (self._win_config, 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()