diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-20 16:55:57 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-20 16:55:57 -0600 |
| commit | d5acb04b88373d33b038bb59945fb5ab8b4f543b (patch) | |
| tree | 21c37f2b54258ef20edc1b9b7226327d5f5b1a3a /ui/windows | |
| parent | 425ba78ee1f760978b23a09fe8acbbc9b8b5dae4 (diff) | |
V8
Diffstat (limited to 'ui/windows')
| -rw-r--r-- | ui/windows/__init__.py | 0 | ||||
| -rw-r--r-- | ui/windows/__pycache__/__init__.cpython-314.pyc | bin | 0 -> 164 bytes | |||
| -rw-r--r-- | ui/windows/__pycache__/devices_window.cpython-314.pyc | bin | 0 -> 23245 bytes | |||
| -rw-r--r-- | ui/windows/__pycache__/plot_window.cpython-314.pyc | bin | 0 -> 46125 bytes | |||
| -rw-r--r-- | ui/windows/__pycache__/settings_window.cpython-314.pyc | bin | 0 -> 16406 bytes | |||
| -rw-r--r-- | ui/windows/__pycache__/signals_window.cpython-314.pyc | bin | 0 -> 53434 bytes | |||
| -rw-r--r-- | ui/windows/devices_window.py | 319 | ||||
| -rw-r--r-- | ui/windows/plot_window.py | 486 | ||||
| -rw-r--r-- | ui/windows/settings_window.py | 231 | ||||
| -rw-r--r-- | ui/windows/signals_window.py | 659 |
10 files changed, 1695 insertions, 0 deletions
diff --git a/ui/windows/__init__.py b/ui/windows/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/ui/windows/__init__.py diff --git a/ui/windows/__pycache__/__init__.cpython-314.pyc b/ui/windows/__pycache__/__init__.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..ea15dd5 --- /dev/null +++ b/ui/windows/__pycache__/__init__.cpython-314.pyc diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..88763e7 --- /dev/null +++ b/ui/windows/__pycache__/devices_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..ad53019 --- /dev/null +++ b/ui/windows/__pycache__/plot_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..0926445 --- /dev/null +++ b/ui/windows/__pycache__/settings_window.cpython-314.pyc diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc Binary files differnew file mode 100644 index 0000000..4194ac8 --- /dev/null +++ b/ui/windows/__pycache__/signals_window.cpython-314.pyc diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py new file mode 100644 index 0000000..fbaf9fa --- /dev/null +++ b/ui/windows/devices_window.py @@ -0,0 +1,319 @@ +""" +ui/windows/devices_window.py + +DEVICES window — toolbar section 2. + +Tabs: + Physical Devices — add / remove / connect devices; shows status cards + Channels — per-device channel list: rename, set unit, enable/disable +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem, + QHeaderView, QCheckBox, QLineEdit, QDoubleSpinBox, QComboBox, + QSizePolicy, QMessageBox, QAbstractItemView, +) +from PyQt6.QtCore import Qt, pyqtSignal, QTimer, QSize +from PyQt6.QtGui import QCloseEvent, QColor, QFont + +from devices.base_device import DeviceStatus +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + + +_DOT_COLOR = { + DeviceStatus.CONNECTED: "#22c55e", + DeviceStatus.SIMULATED: "#3b82f6", + DeviceStatus.DISCONNECTED: "#475569", + DeviceStatus.CONNECTING: "#f59e0b", + DeviceStatus.ERROR: "#ef4444", +} +_STATUS_TEXT = { + DeviceStatus.CONNECTED: "CONNECTED", + DeviceStatus.SIMULATED: "SIMULATED", + DeviceStatus.DISCONNECTED: "OFFLINE", + DeviceStatus.CONNECTING: "CONNECTING…", + DeviceStatus.ERROR: "ERROR", +} + + +class DeviceRow(QFrame): + configure_clicked = pyqtSignal(str) + remove_clicked = pyqtSignal(str) + + def __init__(self, device): + super().__init__() + self.device = device + self.setObjectName("deviceCard") + self._build() + + def _build(self): + lay = QHBoxLayout(self) + lay.setContentsMargins(10, 8, 10, 8) + lay.setSpacing(10) + + # Status dot + self._dot = QLabel("●") + self._dot.setFixedWidth(14) + self._set_dot_color() + lay.addWidget(self._dot) + + # Icon + name + icon = QLabel(self.device.info.icon) + icon.setObjectName("deviceIcon") + lay.addWidget(icon) + + info_col = QVBoxLayout() + info_col.setSpacing(1) + name = QLabel(self.device.info.name) + name.setObjectName("deviceName") + id_lbl = QLabel(f"{self.device.info.device_id} · {self.device.info.device_type}") + id_lbl.setObjectName("deviceSub") + info_col.addWidget(name) + info_col.addWidget(id_lbl) + lay.addLayout(info_col, 1) + + # Status + channel count + stat_col = QVBoxLayout() + stat_col.setSpacing(1) + self._status_lbl = QLabel(_STATUS_TEXT.get(self.device.status, "?")) + self._status_lbl.setObjectName("deviceSub") + self._set_status_style() + n = sum(1 for c in self.device.info.channels if c.enabled) + ch_lbl = QLabel(f"{n} ch") + ch_lbl.setObjectName("deviceChannelCount") + stat_col.addWidget(self._status_lbl) + stat_col.addWidget(ch_lbl) + lay.addLayout(stat_col) + + cfg_btn = QPushButton("Configure") + cfg_btn.setObjectName("configButton") + cfg_btn.clicked.connect(lambda: self.configure_clicked.emit(self.device.info.device_id)) + lay.addWidget(cfg_btn) + + rm_btn = QPushButton("✕") + rm_btn.setObjectName("traceRemoveBtn") + rm_btn.setFixedSize(24, 24) + rm_btn.clicked.connect(lambda: self.remove_clicked.emit(self.device.info.device_id)) + lay.addWidget(rm_btn) + + def refresh(self): + self._set_dot_color() + self._status_lbl.setText(_STATUS_TEXT.get(self.device.status, "?")) + self._set_status_style() + + def _set_dot_color(self): + c = _DOT_COLOR.get(self.device.status, "#475569") + self._dot.setStyleSheet(f"color:{c}; font-size:10px;") + + def _set_status_style(self): + c = _DOT_COLOR.get(self.device.status, "#475569") + self._status_lbl.setStyleSheet( + f"color:{c}; font-family:'IBM Plex Mono',monospace;" + f" font-size:10px; font-weight:700; letter-spacing:1px;" + ) + + +class ChannelsTab(QWidget): + """Shows all channels across all devices in an editable table.""" + + def __init__(self, registry: DeviceRegistry): + super().__init__() + self.registry = registry + self._build() + + def _build(self): + lay = QVBoxLayout(self) + lay.setContentsMargins(0, 0, 0, 0) + + self._table = QTableWidget() + self._table.setObjectName("channelTable") + self._table.setColumnCount(6) + self._table.setHorizontalHeaderLabels( + ["Device", "Channel ID", "Name", "Unit", "Min", "Max"] + ) + self._table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeMode.Stretch) + self._table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) + self._table.setAlternatingRowColors(True) + self._table.verticalHeader().setVisible(False) + lay.addWidget(self._table) + + def refresh(self): + self._table.setRowCount(0) + row = 0 + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + self._table.insertRow(row) + + dev_item = QTableWidgetItem(f"{dev.info.icon} {dev.info.device_id}") + dev_item.setFlags(dev_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + dev_item.setForeground(QColor("#64748b")) + self._table.setItem(row, 0, dev_item) + + ch_item = QTableWidgetItem(ch.channel_id) + ch_item.setFlags(ch_item.flags() & ~Qt.ItemFlag.ItemIsEditable) + ch_item.setForeground(QColor(ch.color)) + self._table.setItem(row, 1, ch_item) + + name_item = QTableWidgetItem(ch.name) + self._table.setItem(row, 2, name_item) + + unit_item = QTableWidgetItem(ch.unit) + self._table.setItem(row, 3, unit_item) + + min_item = QTableWidgetItem(str(ch.min_value)) + max_item = QTableWidgetItem(str(ch.max_value)) + self._table.setItem(row, 4, min_item) + self._table.setItem(row, 5, max_item) + + # Store references for saving + name_item.setData(Qt.ItemDataRole.UserRole, (dev.info.device_id, ch.channel_id)) + row += 1 + + self._table.cellChanged.connect(self._on_cell_changed) + + def _on_cell_changed(self, row: int, col: int): + item = self._table.item(row, 2) # name col + if item is None: + return + ref = item.data(Qt.ItemDataRole.UserRole) + if ref is None: + return + dev_id, ch_id = ref + dev = self.registry.get_instance(dev_id) + if not dev: + return + ch = dev.get_channel(ch_id) + if not ch: + return + if col == 2: + ch.name = self._table.item(row, 2).text() + elif col == 3: + ch.unit = self._table.item(row, 3).text() + elif col == 4: + try: + ch.min_value = float(self._table.item(row, 4).text()) + except ValueError: + pass + elif col == 5: + try: + ch.max_value = float(self._table.item(row, 5).text()) + except ValueError: + pass + + +class DevicesWindow(QWidget): + device_added = pyqtSignal() + device_removed = pyqtSignal(str) + device_reconfigured = pyqtSignal(str) + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, engine: AcquisitionEngine, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.engine = engine + self._rows: dict = {} + + self.setWindowTitle("Devices") + self.setMinimumSize(580, 480) + self.resize(640, 560) + self._build() + self.refresh() + + self._poll = QTimer(self) + self._poll.setInterval(2000) + self._poll.timeout.connect(self._refresh_status) + self._poll.start() + + def _build(self): + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14, 0, 10, 0) + title = QLabel("DEVICES"); title.setObjectName("devWindowTitle") + hl.addWidget(title, 1) + add_btn = QPushButton("+ Add Device") + add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._on_add) + hl.addWidget(add_btn) + root.addWidget(hdr) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") + root.addWidget(tabs, 1) + + # Tab 1: Physical devices + dev_tab = QWidget() + dev_lay = QVBoxLayout(dev_tab); dev_lay.setContentsMargins(0,0,0,0) + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._container = QWidget() + self._inner = QVBoxLayout(self._container) + self._inner.setContentsMargins(10,10,10,10); self._inner.setSpacing(6) + self._inner.addStretch() + scroll.setWidget(self._container) + dev_lay.addWidget(scroll) + tabs.addTab(dev_tab, " Physical Devices ") + + # Tab 2: Channels + self._ch_tab = ChannelsTab(self.registry) + tabs.addTab(self._ch_tab, " Channels ") + tabs.currentChanged.connect(lambda i: self._ch_tab.refresh() if i == 1 else None) + + def refresh(self): + for row in self._rows.values(): + self._inner.removeWidget(row); row.deleteLater() + self._rows.clear() + for dev in self.registry.all_instances(): + row = DeviceRow(dev) + row.configure_clicked.connect(self._on_configure) + row.remove_clicked.connect(self._on_remove) + self._inner.insertWidget(self._inner.count() - 1, row) + self._rows[dev.info.device_id] = row + + def _refresh_status(self): + for row in self._rows.values(): + row.refresh() + + def _on_add(self): + from ui.add_device_dialog import AddDeviceDialog + dlg = AddDeviceDialog(self.registry, self) + if dlg.exec(): + dev = dlg.created_device + if dev: + dev.connect() + self.registry.add_instance(dev) + self.engine.add_device(dev) + self.refresh() + self.device_added.emit() + + def _on_configure(self, device_id: str): + from ui.config_dialog import DeviceConfigDialog + dev = self.registry.get_instance(device_id) + if dev: + DeviceConfigDialog(dev, self).exec() + # Signal that device config may have changed (backend/simulate/port) + self.device_reconfigured.emit(device_id) + + def _on_remove(self, device_id: str): + reply = QMessageBox.question( + self, "Remove Device", + f"Remove '{device_id}'? This cannot be undone.", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + ) + if reply == QMessageBox.StandardButton.Yes: + self.engine.remove_device(device_id) + self.registry.remove_instance(device_id) + self.refresh() + self.device_removed.emit(device_id) + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py new file mode 100644 index 0000000..1be590a --- /dev/null +++ b/ui/windows/plot_window.py @@ -0,0 +1,486 @@ +""" +ui/windows/plot_window.py + +PLOT window — toolbar section 4. + +Lets operators: + • Choose layout: Stacked (rows) or Side-by-Side (columns) or Grid (N×M) + • Add/remove/reorder subplot panes + • Assign any channel (physical or derived) to any pane + • Set X-axis: Time (elapsed s) OR any channel (e.g. strain → stress vs strain) + • Name axes, set Y auto/fixed range, grid, relative size weight + • Per-trace: color, label, line style, width, visibility + • Export/Import JSON layout + +Emits layout_applied(LayoutConfig) → StripChartWidget.apply_layout() +""" + +from __future__ import annotations +import json +from copy import deepcopy +from dataclasses import dataclass, field, asdict +from typing import List, Optional, Tuple + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QLineEdit, QDoubleSpinBox, QSpinBox, + QCheckBox, QComboBox, QColorDialog, QToolButton, QButtonGroup, + QFileDialog, QMessageBox, QRadioButton, QGroupBox, QSizePolicy, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor, QCloseEvent + +from devices.device_registry import DeviceRegistry +from core.signal_processor import SignalProcessor + + +# ══════════════════════════════════════════════════════════════════════════════ +# Data model +# ══════════════════════════════════════════════════════════════════════════════ + +@dataclass +class TraceSpec: + device_id: str + channel_id: str + label: str = "" + color: str = "#00d4ff" + width: float = 1.8 + visible: bool = True + style: str = "solid" # solid | dash | dot + +@dataclass +class PaneSpec: + title: str = "Plot" + x_source: str = "time" # "time" | "dev_id/ch_id" + y_label: str = "" + x_label: str = "Elapsed (s)" + y_auto: bool = True + y_min: float = -10.0 + y_max: float = 10.0 + grid: bool = True + weight: int = 1 # relative size + traces: List[TraceSpec] = field(default_factory=list) + +@dataclass +class LayoutConfig: + panes: List[PaneSpec] = field(default_factory=list) + layout_mode: str = "stacked" # stacked | sidebyside | grid + grid_cols: int = 2 + time_window_s: float = 30.0 + link_x: bool = True + show_legend: bool = True + + def to_json(self) -> str: + return json.dumps(asdict(self), indent=2) + + @staticmethod + def from_json(s: str) -> "LayoutConfig": + d = json.loads(s) + panes = [] + for p in d.pop("panes", []): + traces = [TraceSpec(**t) for t in p.pop("traces", [])] + panes.append(PaneSpec(**p, traces=traces)) + return LayoutConfig(**d, panes=panes) + + +def build_default_layout(registry: DeviceRegistry, + processor: SignalProcessor) -> LayoutConfig: + cfg = LayoutConfig() + for dev in registry.all_instances(): + enabled = [ch for ch in dev.info.channels if ch.enabled] + if not enabled: continue + pane = PaneSpec(title=dev.info.name, + y_label=enabled[0].unit if enabled else "") + for ch in enabled: + pane.traces.append(TraceSpec( + device_id=dev.info.device_id, channel_id=ch.channel_id, + label=ch.name, color=ch.color, + )) + if pane.traces: cfg.panes.append(pane) + for dc in processor.get_derived(): + pane = PaneSpec(title=dc.name, y_label=dc.unit) + pane.traces.append(TraceSpec( + device_id="derived", channel_id=dc.channel_id, + label=dc.name, color=dc.color, + )) + cfg.panes.append(pane) + return cfg + + +# ══════════════════════════════════════════════════════════════════════════════ +# Color swatch +# ══════════════════════════════════════════════════════════════════════════════ + +class ColorSwatch(QPushButton): + color_changed = pyqtSignal(str) + def __init__(self, color="#00d4ff"): + super().__init__(); self._c = color + self.setFixedSize(26,20); self._paint(); self.clicked.connect(self._pick) + def _paint(self): + self.setStyleSheet(f"QPushButton{{background:{self._c};border:1px solid #2a3558;" + f"border-radius:3px;}}QPushButton:hover{{border-color:#3b82f6;}}") + def _pick(self): + c = QColorDialog.getColor(QColor(self._c), self) + if c.isValid(): self._c = c.name(); self._paint(); self.color_changed.emit(self._c) + @property + def color(self): return self._c + + +# ══════════════════════════════════════════════════════════════════════════════ +# Trace row +# ══════════════════════════════════════════════════════════════════════════════ + +class TraceRow(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + def __init__(self, tr: TraceSpec): + super().__init__(); self.tr = tr; self.setObjectName("traceRow"); self._build() + def _build(self): + lay = QHBoxLayout(self); lay.setContentsMargins(6,3,6,3); lay.setSpacing(6) + self._vis = QCheckBox(); self._vis.setChecked(self.tr.visible) + self._vis.toggled.connect(lambda v: (setattr(self.tr,"visible",v), self.changed.emit())) + lay.addWidget(self._vis) + self._sw = ColorSwatch(self.tr.color) + self._sw.color_changed.connect(lambda c: (setattr(self.tr,"color",c), self.changed.emit())) + lay.addWidget(self._sw) + src = QLabel(f"{self.tr.device_id} / {self.tr.channel_id}") + src.setObjectName("traceSource"); src.setMinimumWidth(130); lay.addWidget(src) + self._lbl = QLineEdit(self.tr.label); self._lbl.setObjectName("traceLabel") + self._lbl.setPlaceholderText("Label…") + self._lbl.textChanged.connect(lambda t: setattr(self.tr,"label",t)) + lay.addWidget(self._lbl,1) + self._sty = QComboBox(); self._sty.setObjectName("traceStyleCb") + self._sty.addItems(["solid","dash","dot"]); self._sty.setCurrentText(self.tr.style) + self._sty.setFixedWidth(64) + self._sty.currentTextChanged.connect(lambda s: setattr(self.tr,"style",s)) + lay.addWidget(self._sty) + self._w = QDoubleSpinBox(); self._w.setObjectName("traceWidthSpin") + self._w.setRange(0.5,6.0); self._w.setSingleStep(0.5) + self._w.setValue(self.tr.width); self._w.setFixedWidth(56) + self._w.valueChanged.connect(lambda v: setattr(self.tr,"width",v)) + lay.addWidget(self._w) + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) + lay.addWidget(rm) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Pane block +# ══════════════════════════════════════════════════════════════════════════════ + +class PaneBlock(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + move_up = pyqtSignal(object) + move_dn = pyqtSignal(object) + + def __init__(self, spec: PaneSpec, registry: DeviceRegistry, + processor: SignalProcessor): + super().__init__() + self.spec = spec; self.registry = registry; self.processor = processor + self._rows: List[TraceRow] = [] + self.setObjectName("plotBlock"); self._build() + + def _build(self): + outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4) + self._title = QLineEdit(self.spec.title); self._title.setObjectName("plotBlockTitle") + self._title.textChanged.connect(lambda t: setattr(self.spec,"title",t)) + hl.addWidget(self._title,1) + for txt, sig in [("▲",self.move_up),("▼",self.move_dn)]: + b = QToolButton(); b.setText(txt); b.setObjectName("plotMoveBtn") + b.setFixedSize(22,22); b.clicked.connect(lambda _,s=sig: s.emit(self)); hl.addWidget(b) + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)); hl.addWidget(rm) + outer.addWidget(hdr) + + # Settings row + sett = QWidget(); sett.setObjectName("plotBlockSettings") + sl = QHBoxLayout(sett); sl.setContentsMargins(8,6,8,6); sl.setSpacing(8) + + sl.addWidget(QLabel("X Axis:")) + self._x_cb = QComboBox(); self._x_cb.setObjectName("channelPickerCb") + self._x_cb.setMinimumWidth(150) + self._x_cb.addItem("⏱ Time (elapsed s)", userData="time") + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + self._x_cb.addItem(f"{dev.info.device_id}/{ch.channel_id} ({ch.name})", + userData=f"{dev.info.device_id}/{ch.channel_id}") + for dc in self.processor.get_derived(): + self._x_cb.addItem(f"[derived] {dc.channel_id}", + userData=f"derived/{dc.channel_id}") + # Select current + for i in range(self._x_cb.count()): + if self._x_cb.itemData(i) == self.spec.x_source: + self._x_cb.setCurrentIndex(i); break + self._x_cb.currentIndexChanged.connect( + lambda: setattr(self.spec,"x_source", self._x_cb.currentData() or "time")) + sl.addWidget(self._x_cb) + + sl.addWidget(QLabel("Y:")) + self._ylabel = QLineEdit(self.spec.y_label); self._ylabel.setObjectName("plotYLabelEdit") + self._ylabel.setPlaceholderText("Y label"); self._ylabel.setMaximumWidth(110) + self._ylabel.textChanged.connect(lambda t: setattr(self.spec,"y_label",t)) + sl.addWidget(self._ylabel) + + self._auto = QCheckBox("Auto Y"); self._auto.setChecked(self.spec.y_auto) + self._auto.toggled.connect(self._on_auto); sl.addWidget(self._auto) + + sl.addWidget(QLabel("Min:")) + self._ymin = QDoubleSpinBox(); self._ymin.setRange(-1e9,1e9) + self._ymin.setValue(self.spec.y_min); self._ymin.setFixedWidth(72) + self._ymin.setEnabled(not self.spec.y_auto) + self._ymin.valueChanged.connect(lambda v: setattr(self.spec,"y_min",v)) + sl.addWidget(self._ymin) + + sl.addWidget(QLabel("Max:")) + self._ymax = QDoubleSpinBox(); self._ymax.setRange(-1e9,1e9) + self._ymax.setValue(self.spec.y_max); self._ymax.setFixedWidth(72) + self._ymax.setEnabled(not self.spec.y_auto) + self._ymax.valueChanged.connect(lambda v: setattr(self.spec,"y_max",v)) + sl.addWidget(self._ymax) + + self._grid = QCheckBox("Grid"); self._grid.setChecked(self.spec.grid) + self._grid.toggled.connect(lambda v: setattr(self.spec,"grid",v)) + sl.addWidget(self._grid) + + sl.addWidget(QLabel("Size:")) + self._wt = QSpinBox(); self._wt.setRange(1,10); self._wt.setValue(self.spec.weight) + self._wt.setFixedWidth(44); self._wt.setToolTip("Relative pane size") + self._wt.valueChanged.connect(lambda v: setattr(self.spec,"weight",v)) + sl.addWidget(self._wt); sl.addStretch() + outer.addWidget(sett) + + # Traces + tr_w = QWidget(); tr_w.setObjectName("plotBlockTraces") + self._tlay = QVBoxLayout(tr_w) + self._tlay.setContentsMargins(4,2,4,4); self._tlay.setSpacing(2) + for tr in self.spec.traces: self._add_row(tr) + + pick_row = QHBoxLayout() + self._picker = self._make_picker(); pick_row.addWidget(self._picker,1) + ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn") + ab.clicked.connect(self._on_add); pick_row.addWidget(ab) + self._tlay.addLayout(pick_row) + outer.addWidget(tr_w) + + def _make_picker(self): + cb = QComboBox(); cb.setObjectName("channelPickerCb") + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", + userData=(dev.info.device_id, ch.channel_id, ch.name, ch.color)) + for dc in self.processor.get_derived(): + cb.addItem(f"[derived] {dc.channel_id} ({dc.name})", + userData=("derived", dc.channel_id, dc.name, dc.color)) + return cb + + def _add_row(self, tr: TraceSpec): + row = TraceRow(tr); row.removed.connect(self._rm_row) + row.changed.connect(self.changed) + self._rows.append(row) + self._tlay.insertWidget(self._tlay.count()-1, row) + + def _rm_row(self, row: TraceRow): + if row.tr in self.spec.traces: self.spec.traces.remove(row.tr) + self._rows.remove(row); self._tlay.removeWidget(row); row.deleteLater() + self.changed.emit() + + def _on_add(self): + data = self._picker.currentData() + if not data: return + d,c,n,col = data + tr = TraceSpec(device_id=d,channel_id=c,label=n,color=col) + self.spec.traces.append(tr); self._add_row(tr); self.changed.emit() + + def _on_auto(self, v): + self.spec.y_auto = v; self._ymin.setEnabled(not v); self._ymax.setEnabled(not v) + + +# ══════════════════════════════════════════════════════════════════════════════ +# PlotWindow +# ══════════════════════════════════════════════════════════════════════════════ + +class PlotWindow(QWidget): + layout_applied = pyqtSignal(object) # LayoutConfig + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, + current: Optional[LayoutConfig] = None, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.processor = processor + self.cfg = deepcopy(current) if current \ + else build_default_layout(registry, processor) + self.setWindowTitle("Plot Builder") + self.setMinimumSize(720, 560) + self.resize(820, 700) + self._blocks: List[PaneBlock] = [] + self._build() + self._populate() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,10,0) + hl.addWidget(QLabel("PLOT BUILDER").also(lambda w: w.setObjectName("devWindowTitle")),1) + root.addWidget(hdr) + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + # Global settings + glob = QWidget(); glob.setObjectName("cfgGlobalBar") + gl = QHBoxLayout(glob); gl.setContentsMargins(12,7,12,7); gl.setSpacing(12) + + # Layout mode + gl.addWidget(QLabel("Layout:")) + self._stacked_rb = QRadioButton("Stacked") + self._side_rb = QRadioButton("Side-by-Side") + self._grid_rb = QRadioButton("Grid") + mode_grp = QButtonGroup(self) + for rb in (self._stacked_rb, self._side_rb, self._grid_rb): + rb.setObjectName("cfgLiveChk"); mode_grp.addButton(rb) + mode_map = {"stacked": self._stacked_rb, + "sidebyside": self._side_rb, "grid": self._grid_rb} + mode_map.get(self.cfg.layout_mode, self._stacked_rb).setChecked(True) + for rb, key in [(self._stacked_rb,"stacked"),(self._side_rb,"sidebyside"),(self._grid_rb,"grid")]: + rb.toggled.connect(lambda v, k=key: self._set_mode(k) if v else None) + gl.addWidget(rb) + + gl.addWidget(QLabel("Cols:")) + self._cols = QSpinBox(); self._cols.setRange(1,6); self._cols.setValue(self.cfg.grid_cols) + self._cols.setFixedWidth(44); self._cols.setObjectName("traceWidthSpin") + self._cols.valueChanged.connect(lambda v: setattr(self.cfg,"grid_cols",v)) + gl.addWidget(self._cols) + + gl.addWidget(QLabel("Window:")) + self._win = QDoubleSpinBox(); self._win.setRange(1,3600) + self._win.setSuffix(" s"); self._win.setValue(self.cfg.time_window_s) + self._win.setObjectName("cfgGlobalSpin") + self._win.valueChanged.connect(lambda v: setattr(self.cfg,"time_window_s",v)) + gl.addWidget(self._win) + + self._linkx = QCheckBox("Link X"); self._linkx.setChecked(self.cfg.link_x) + self._linkx.setObjectName("cfgLiveChk") + self._linkx.toggled.connect(lambda v: setattr(self.cfg,"link_x",v)) + gl.addWidget(self._linkx) + + self._legend = QCheckBox("Legend"); self._legend.setChecked(self.cfg.show_legend) + self._legend.setObjectName("cfgLiveChk") + self._legend.toggled.connect(lambda v: setattr(self.cfg,"show_legend",v)) + gl.addWidget(self._legend) + + self._live = QCheckBox("Live preview"); self._live.setObjectName("cfgLiveChk") + gl.addWidget(self._live) + + gl.addStretch() + add_btn = QPushButton("+ Add Pane"); add_btn.setObjectName("addDeviceButton") + add_btn.clicked.connect(self._add_pane); gl.addWidget(add_btn) + root.addWidget(glob) + + div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) + div2.setObjectName("devWindowDivider"); root.addWidget(div2) + + # Scrollable pane blocks + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + self._cont = QWidget() + self._blay = QVBoxLayout(self._cont) + self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12) + self._blay.addStretch() + scroll.setWidget(self._cont) + root.addWidget(scroll,1) + + # Bottom + btm = QWidget(); btm.setObjectName("cfgBottomBar") + bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) + for lbl, fn in [("⟳ Rebuild from Channels",self._reset), + ("↓ Export JSON",self._export), + ("↑ Import JSON",self._import_json)]: + b = QPushButton(lbl); b.setObjectName("configButton"); b.clicked.connect(fn); bl.addWidget(b) + bl.addStretch() + ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply); bl.addWidget(ap) + root.addWidget(btm) + + def _set_mode(self, mode: str): + self.cfg.layout_mode = mode + self._maybe_live() + + def _populate(self): + for b in self._blocks: self._blay.removeWidget(b); b.deleteLater() + self._blocks.clear() + for spec in self.cfg.panes: self._insert(spec) + + def _insert(self, spec: PaneSpec): + blk = PaneBlock(spec, self.registry, self.processor) + blk.removed.connect(self._rm); blk.changed.connect(self._maybe_live) + blk.move_up.connect(self._mv_up); blk.move_dn.connect(self._mv_dn) + self._blocks.append(blk) + self._blay.insertWidget(self._blay.count()-1, blk) + + def _add_pane(self): + spec = PaneSpec(title=f"Plot {len(self.cfg.panes)+1}") + self.cfg.panes.append(spec); self._insert(spec); self._maybe_live() + + def _rm(self, blk): + if blk.spec in self.cfg.panes: self.cfg.panes.remove(blk.spec) + self._blocks.remove(blk); self._blay.removeWidget(blk); blk.deleteLater() + self._maybe_live() + + def _mv_up(self, blk): + i = self._blocks.index(blk) + if i==0: return + self.cfg.panes.insert(i-1,self.cfg.panes.pop(i)) + self._blocks.insert(i-1,self._blocks.pop(i)) + self._blay.removeWidget(blk); self._blay.insertWidget(i-1,blk) + self._maybe_live() + + def _mv_dn(self, blk): + i = self._blocks.index(blk) + if i>=len(self._blocks)-1: return + self.cfg.panes.insert(i+1,self.cfg.panes.pop(i)) + self._blocks.insert(i+1,self._blocks.pop(i)) + self._blay.removeWidget(blk); self._blay.insertWidget(i+1,blk) + self._maybe_live() + + def _maybe_live(self): + if self._live.isChecked(): self.layout_applied.emit(deepcopy(self.cfg)) + + def _apply(self): self.layout_applied.emit(deepcopy(self.cfg)) + + def _reset(self): + self.cfg = build_default_layout(self.registry, self.processor) + self._win.setValue(self.cfg.time_window_s) + self._populate(); self._maybe_live() + + def _export(self): + path, _ = QFileDialog.getSaveFileName(self,"Export","layout.json","JSON (*.json)") + if path: open(path,"w").write(self.cfg.to_json()) + + def _import_json(self): + path, _ = QFileDialog.getOpenFileName(self,"Import","","JSON (*.json)") + if path: + try: + self.cfg = LayoutConfig.from_json(open(path).read()) + self._win.setValue(self.cfg.time_window_s) + self._populate(); self._maybe_live() + except Exception as e: + QMessageBox.critical(self,"Import failed",str(e)) + + def refresh_channels(self): + self._populate() + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() + + +# monkey-patch QLabel.also for inline objectName setting +from PyQt6.QtWidgets import QLabel as _QLabel2 +def _also2(self, fn): fn(self); return self +if not hasattr(_QLabel2, "also"): _QLabel2.also = _also2 diff --git a/ui/windows/settings_window.py b/ui/windows/settings_window.py new file mode 100644 index 0000000..88093ff --- /dev/null +++ b/ui/windows/settings_window.py @@ -0,0 +1,231 @@ +""" +ui/windows/settings_window.py + +SETTINGS window — toolbar section 5. + +Tabs: + General — theme (dark/light/system), font sizes, polling rate + Acquisition — sample rate, buffer size, CSV log directory + Display — default time window, legend, grid defaults + Controls — configure output widget assignments per channel +""" + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QTabWidget, QFrame, QFormLayout, QGroupBox, QComboBox, + QSpinBox, QDoubleSpinBox, QCheckBox, QLineEdit, + QFileDialog, QScrollArea, QSizePolicy, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QCloseEvent, QFont + +from devices.device_registry import DeviceRegistry +from core.acquisition import AcquisitionEngine + + +class SettingsWindow(QWidget): + theme_changed = pyqtSignal(str) # "dark" | "light" + settings_changed = pyqtSignal(dict) + closed = pyqtSignal() + + # Shared settings dict — written on Apply, read by consumers + _defaults = { + "theme": "dark", + "poll_ms": 100, + "buffer_size": 20000, + "log_dir": "logs", + "time_window_s": 30.0, + "show_legend": True, + "show_grid": True, + "font_size": 12, + "antialias": True, + } + + def __init__(self, registry: DeviceRegistry, + engine: AcquisitionEngine, + current: dict = None, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.engine = engine + self.cfg = dict(self._defaults) + if current: + self.cfg.update(current) + + self.setWindowTitle("Settings") + self.setMinimumSize(520, 440) + self.resize(560, 520) + self._build() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0) + hl.addWidget(QLabel("SETTINGS").also(lambda w: w.setObjectName("devWindowTitle"))) + root.addWidget(hdr) + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") + root.addWidget(tabs, 1) + + tabs.addTab(self._general_tab(), " General ") + tabs.addTab(self._acquisition_tab(), " Acquisition ") + tabs.addTab(self._display_tab(), " Display ") + tabs.addTab(self._controls_tab(), " Controls ") + + # Bottom bar + btm = QWidget(); btm.setObjectName("cfgBottomBar") + bl = QHBoxLayout(btm); bl.setContentsMargins(12,8,12,8) + bl.addStretch() + ap = QPushButton("✓ Apply & Close"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply); bl.addWidget(ap) + root.addWidget(btm) + + # ── Tabs ────────────────────────────────────────────────────────────── + + def _general_tab(self): + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QFormLayout(cont) + lay.setContentsMargins(16,14,16,14); lay.setSpacing(10) + + self._theme_cb = QComboBox(); self._theme_cb.setObjectName("channelPickerCb") + self._theme_cb.addItems(["Dark", "Light"]) + self._theme_cb.setCurrentText(self.cfg["theme"].title()) + lay.addRow("Theme:", self._theme_cb) + + self._font_sp = QSpinBox(); self._font_sp.setRange(8,18) + self._font_sp.setValue(self.cfg["font_size"]); self._font_sp.setSuffix(" pt") + self._font_sp.setObjectName("traceWidthSpin") + lay.addRow("Base font size:", self._font_sp) + + self._aa_chk = QCheckBox(); self._aa_chk.setChecked(self.cfg["antialias"]) + lay.addRow("Anti-alias plots:", self._aa_chk) + + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + def _acquisition_tab(self): + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QFormLayout(cont) + lay.setContentsMargins(16,14,16,14); lay.setSpacing(10) + + self._poll_sp = QSpinBox(); self._poll_sp.setRange(10,5000) + self._poll_sp.setValue(self.cfg["poll_ms"]); self._poll_sp.setSuffix(" ms") + self._poll_sp.setObjectName("traceWidthSpin") + lay.addRow("Poll interval:", self._poll_sp) + + self._buf_sp = QSpinBox(); self._buf_sp.setRange(1000,500000) + self._buf_sp.setValue(self.cfg["buffer_size"]) + self._buf_sp.setObjectName("traceWidthSpin") + lay.addRow("Buffer size (samples):", self._buf_sp) + + log_row = QHBoxLayout() + self._log_edit = QLineEdit(self.cfg["log_dir"]) + self._log_edit.setObjectName("traceLabel"); log_row.addWidget(self._log_edit,1) + br = QPushButton("Browse"); br.setObjectName("configButton") + br.clicked.connect(self._browse_log); log_row.addWidget(br) + lay.addRow("Log directory:", log_row) + + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + def _display_tab(self): + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QFormLayout(cont) + lay.setContentsMargins(16,14,16,14); lay.setSpacing(10) + + self._tw_sp = QDoubleSpinBox(); self._tw_sp.setRange(1,3600) + self._tw_sp.setValue(self.cfg["time_window_s"]); self._tw_sp.setSuffix(" s") + self._tw_sp.setObjectName("cfgGlobalSpin") + lay.addRow("Default time window:", self._tw_sp) + + self._legend_chk = QCheckBox(); self._legend_chk.setChecked(self.cfg["show_legend"]) + lay.addRow("Show legend by default:", self._legend_chk) + + self._grid_chk = QCheckBox(); self._grid_chk.setChecked(self.cfg["show_grid"]) + lay.addRow("Show grid by default:", self._grid_chk) + + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + def _controls_tab(self): + """Output channel assignments — which device channel each control widget drives.""" + w = QWidget() + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + cont = QWidget(); lay = QVBoxLayout(cont) + lay.setContentsMargins(14,12,14,12); lay.setSpacing(8) + + info = QLabel( + "Configure which physical output channels are driven by each control widget.\n" + "Add output widget mappings below. Changes take effect on next app start." + ) + info.setObjectName("traceSource"); info.setWordWrap(True) + lay.addWidget(info) + + grp = QGroupBox("Output Assignments") + g_lay = QFormLayout(grp); g_lay.setSpacing(6) + + # Collect digital output channels + out_channels = ["— none —"] + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if ch.channel_id.startswith("do") or "out" in ch.channel_id.lower(): + out_channels.append(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})") + + self._out_combos = {} + for label in ["Pump Power", "Heater", "Motor Enable", "PWM Ch 1"]: + cb = QComboBox(); cb.setObjectName("channelPickerCb") + cb.addItems(out_channels) + g_lay.addRow(f"{label}:", cb) + self._out_combos[label] = cb + + lay.addWidget(grp) + lay.addStretch() + scroll.setWidget(cont) + root = QVBoxLayout(w); root.setContentsMargins(0,0,0,0); root.addWidget(scroll) + return w + + # ── Actions ─────────────────────────────────────────────────────────── + + def _browse_log(self): + path = QFileDialog.getExistingDirectory(self, "Select Log Directory") + if path: + self._log_edit.setText(path) + + def _apply(self): + theme = self._theme_cb.currentText().lower() + self.cfg.update({ + "theme": theme, + "font_size": self._font_sp.value(), + "antialias": self._aa_chk.isChecked(), + "poll_ms": self._poll_sp.value(), + "buffer_size": self._buf_sp.value(), + "log_dir": self._log_edit.text(), + "time_window_s": self._tw_sp.value(), + "show_legend": self._legend_chk.isChecked(), + "show_grid": self._grid_chk.isChecked(), + }) + self.theme_changed.emit(theme) + self.settings_changed.emit(dict(self.cfg)) + self.hide() + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() + + +# monkey-patch QLabel.also +from PyQt6.QtWidgets import QLabel as _QL +def _also3(self, fn): fn(self); return self +if not hasattr(_QL, "also"): _QL.also = _also3 diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py new file mode 100644 index 0000000..eb7730a --- /dev/null +++ b/ui/windows/signals_window.py @@ -0,0 +1,659 @@ +""" +ui/windows/signals_window.py + +SIGNALS window — toolbar section 3. + +Tabs: + Pipeline — per-channel filter stack (filters, calibration, scale/offset) + Derived — create virtual channels from physical ones + Channels — visibility & color management for all channels + +CALIBRATION: A dedicated calibration section inside Pipeline lets the operator +enter known input→output pairs and fits a linear (or polynomial) cal curve, +which is applied as a ScaleOffset filter. + +DERIVED includes: + - displacement from pot voltage (calibrated) + - velocity from displacement (backward difference) + - acceleration from velocity + - power (V*I) + - expression (x[0]*2 + 1) + - custom Python function/script +""" + +from __future__ import annotations +import math +from typing import List, Optional, Tuple + +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, + QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit, + QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox, + QToolButton, QSizePolicy, QTableWidget, QTableWidgetItem, + QHeaderView, QAbstractItemView, QFormLayout, QMessageBox, +) +from PyQt6.QtCore import Qt, pyqtSignal +from PyQt6.QtGui import QColor, QFont, QCloseEvent + +from devices.device_registry import DeviceRegistry +from core.signal_processor import ( + SignalProcessor, ChannelPipeline, DerivedChannel, + FilterBase, MovingAverageFilter, MedianFilter, LowPassFilter, + HighPassFilter, ScaleOffsetFilter, DerivativeFilter, IntegralFilter, + FILTER_CLASSES, +) + +_DERIVED_COLORS = [ + "#f72585","#7209b7","#4361ee","#4cc9f0", + "#f77f00","#d62828","#588157","#e9c46a", +] + +def _channel_combo(registry: DeviceRegistry, + processor: Optional[SignalProcessor] = None, + include_derived: bool = False) -> QComboBox: + cb = QComboBox(); cb.setObjectName("channelPickerCb") + for dev in registry.all_instances(): + for ch in dev.info.channels: + cb.addItem(f"{dev.info.device_id} / {ch.channel_id} ({ch.name})", + userData=(dev.info.device_id, ch.channel_id)) + if include_derived and processor: + for dc in processor.get_derived(): + cb.addItem(f"[derived] {dc.channel_id} ({dc.name})", + userData=("derived", dc.channel_id)) + return cb + + +# ══════════════════════════════════════════════════════════════════════════════ +# Filter row +# ══════════════════════════════════════════════════════════════════════════════ + +_PARAM_SPECS = { + "moving_average": [("window", "int", 10, 1, 5000)], + "median": [("window", "int", 5, 1, 500)], + "low_pass": [("alpha", "float", 0.1, 0.001, 1.0)], + "high_pass": [("alpha", "float", 0.9, 0.001, 1.0)], + "scale_offset": [("scale", "float", 1.0, -1e6, 1e6), + ("offset", "float", 0.0, -1e9, 1e9)], + "derivative": [], + "integral": [], +} + +class FilterRow(QFrame): + removed = pyqtSignal(object) + changed = pyqtSignal() + + def __init__(self, filt: FilterBase): + super().__init__() + self.filt = filt + self.setObjectName("traceRow") + self._build() + + def _build(self): + lay = QHBoxLayout(self); lay.setContentsMargins(6,4,6,4); lay.setSpacing(8) + + lbl = QLabel(self.filt.name.replace("_"," ").title()) + lbl.setObjectName("traceSource"); lbl.setMinimumWidth(120) + lay.addWidget(lbl) + + for pname, ptype, default, lo, hi in _PARAM_SPECS.get(self.filt.name, []): + lay.addWidget(QLabel(f"{pname}:").also(lambda w: w.setObjectName("pwmLabel"))) + if ptype == "int": + w = QSpinBox(); w.setRange(int(lo), int(hi)) + w.setValue(int(self.filt.params.get(pname, default))) + w.setFixedWidth(70); w.setObjectName("traceWidthSpin") + w.valueChanged.connect(lambda v, p=pname: self._upd(p, v)) + else: + w = QDoubleSpinBox(); w.setRange(lo, hi); w.setDecimals(4) + w.setValue(float(self.filt.params.get(pname, default))) + w.setFixedWidth(88); w.setObjectName("traceWidthSpin") + w.valueChanged.connect(lambda v, p=pname: self._upd(p, v)) + lay.addWidget(w) + + lay.addStretch() + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22, 22); rm.clicked.connect(lambda: self.removed.emit(self)) + lay.addWidget(rm) + + def _upd(self, name, value): + self.filt.params[name] = value + cls = FILTER_CLASSES.get(self.filt.name) + if cls: + nf = cls(**self.filt.params); self.filt.__dict__.update(nf.__dict__) + self.changed.emit() + + +# monkey-patch QLabel.also so we can set objectName inline above +from PyQt6.QtWidgets import QLabel as _QLabel +def _also(self, fn): fn(self); return self +_QLabel.also = _also + + +# ══════════════════════════════════════════════════════════════════════════════ +# Calibration widget +# ══════════════════════════════════════════════════════════════════════════════ + +class CalibrationWidget(QGroupBox): + """ + Linear calibration: enter N (raw, engineering) point pairs, + fit y = scale*x + offset, apply as ScaleOffsetFilter. + """ + cal_applied = pyqtSignal(float, float) # scale, offset + + def __init__(self, parent=None): + super().__init__("Calibration", parent) + self._points: List[Tuple[float,float]] = [] + self._build() + + def _build(self): + lay = QVBoxLayout(self); lay.setSpacing(4) + + # Table of raw→engineering pairs + self._tbl = QTableWidget(0, 2) + self._tbl.setHorizontalHeaderLabels(["Raw (sensor)", "Engineering"]) + self._tbl.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.Stretch) + self._tbl.setMaximumHeight(130) + self._tbl.setObjectName("channelTable") + lay.addWidget(self._tbl) + + btn_row = QHBoxLayout() + add_pt = QPushButton("+ Point"); add_pt.setObjectName("addTraceBtn") + add_pt.clicked.connect(self._add_row) + rm_pt = QPushButton("✕ Remove"); rm_pt.setObjectName("configButton") + rm_pt.clicked.connect(self._rm_row) + btn_row.addWidget(add_pt); btn_row.addWidget(rm_pt); btn_row.addStretch() + lay.addLayout(btn_row) + + # Result + self._result_lbl = QLabel("") + self._result_lbl.setObjectName("traceSource"); lay.addWidget(self._result_lbl) + + fit_btn = QPushButton("Fit & Apply"); fit_btn.setObjectName("applyButton") + fit_btn.clicked.connect(self._fit); lay.addWidget(fit_btn) + + def _add_row(self): + r = self._tbl.rowCount(); self._tbl.insertRow(r) + self._tbl.setItem(r, 0, QTableWidgetItem("0.0")) + self._tbl.setItem(r, 1, QTableWidgetItem("0.0")) + + def _rm_row(self): + rows = {i.row() for i in self._tbl.selectedItems()} + for r in sorted(rows, reverse=True): + self._tbl.removeRow(r) + + def _fit(self): + pts = [] + for r in range(self._tbl.rowCount()): + try: + x = float(self._tbl.item(r,0).text()) + y = float(self._tbl.item(r,1).text()) + pts.append((x,y)) + except Exception: + pass + if len(pts) < 2: + self._result_lbl.setText("⚠ Need ≥2 points"); return + xs = [p[0] for p in pts]; ys = [p[1] for p in pts] + n = len(pts) + sx = sum(xs); sy = sum(ys) + sxx = sum(x*x for x in xs); sxy = sum(x*y for x,y in pts) + denom = n*sxx - sx*sx + if abs(denom) < 1e-12: + self._result_lbl.setText("⚠ Singular — all raw values identical"); return + scale = (n*sxy - sx*sy) / denom + offset = (sy - scale*sx) / n + self._result_lbl.setText( + f"y = {scale:.5g} × x + {offset:.5g} (R²≈{self._r2(xs,ys,scale,offset):.4f})" + ) + self._result_lbl.setStyleSheet("color:#22c55e;") + self.cal_applied.emit(scale, offset) + + @staticmethod + def _r2(xs, ys, scale, offset): + y_mean = sum(ys)/len(ys) + ss_tot = sum((y-y_mean)**2 for y in ys) + ss_res = sum((y - (scale*x+offset))**2 for x,y in zip(xs,ys)) + return 1 - ss_res/ss_tot if ss_tot else 1.0 + + +# ══════════════════════════════════════════════════════════════════════════════ +# Pipeline Tab — filter stack + calibration for one channel +# ══════════════════════════════════════════════════════════════════════════════ + +class PipelineTab(QWidget): + changed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + self._rows: List[FilterRow] = [] + self._cur_pipeline: Optional[ChannelPipeline] = None + self._build() + + def _build(self): + lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) + + # Channel selector bar + sel_bar = QWidget(); sel_bar.setObjectName("cfgGlobalBar") + sb_lay = QHBoxLayout(sel_bar); sb_lay.setContentsMargins(10,7,10,7) + sb_lay.addWidget(QLabel("Channel:")) + self._ch_cb = _channel_combo(self.registry, self.processor, include_derived=True) + self._ch_cb.currentIndexChanged.connect(self._load_pipeline) + sb_lay.addWidget(self._ch_cb, 1) + lay.addWidget(sel_bar) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); lay.addWidget(div) + + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self._cont = QWidget() + self._inner = QVBoxLayout(self._cont) + self._inner.setContentsMargins(10,10,10,10); self._inner.setSpacing(6) + + # Enable toggle + self._en_chk = QCheckBox("Pipeline enabled"); self._en_chk.setChecked(True) + self._en_chk.toggled.connect(self._on_enable) + self._inner.addWidget(self._en_chk) + + # Filter rows area + self._filter_area = QWidget() + self._filter_lay = QVBoxLayout(self._filter_area) + self._filter_lay.setContentsMargins(0,0,0,0); self._filter_lay.setSpacing(3) + self._inner.addWidget(self._filter_area) + + # Add filter row + add_bar = QHBoxLayout() + self._filt_cb = QComboBox(); self._filt_cb.setObjectName("channelPickerCb") + self._filt_cb.addItems([k.replace("_"," ").title() for k in FILTER_CLASSES]) + add_bar.addWidget(self._filt_cb, 1) + add_btn = QPushButton("+ Add Filter"); add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._add_filter) + add_bar.addWidget(add_btn) + self._inner.addLayout(add_bar) + + # Calibration + self._cal_widget = CalibrationWidget() + self._cal_widget.cal_applied.connect(self._apply_calibration) + self._inner.addWidget(self._cal_widget) + self._inner.addStretch() + + scroll.setWidget(self._cont) + lay.addWidget(scroll, 1) + self._load_pipeline(0) + + def _load_pipeline(self, _=None): + data = self._ch_cb.currentData() + if not data: return + dev_id, ch_id = data + + # Clear filter rows + for r in self._rows: + self._filter_lay.removeWidget(r); r.deleteLater() + self._rows.clear() + + pipeline = self.processor.get_pipeline(dev_id, ch_id) + if pipeline is None: + pipeline = ChannelPipeline(device_id=dev_id, channel_id=ch_id) + self.processor.set_pipeline(pipeline) + self._cur_pipeline = pipeline + self._en_chk.setChecked(pipeline.enabled) + for f in pipeline.filters: + self._add_filter_row(f) + + def _add_filter(self): + key = list(FILTER_CLASSES.keys())[self._filt_cb.currentIndex()] + filt = FILTER_CLASSES[key]() + if self._cur_pipeline: + self._cur_pipeline.filters.append(filt) + self._add_filter_row(filt) + self._push() + + def _add_filter_row(self, filt: FilterBase): + row = FilterRow(filt) + row.removed.connect(self._remove_filter) + row.changed.connect(self._push) + self._rows.append(row) + self._filter_lay.addWidget(row) + + def _remove_filter(self, row: FilterRow): + if self._cur_pipeline and row.filt in self._cur_pipeline.filters: + self._cur_pipeline.filters.remove(row.filt) + self._rows.remove(row) + self._filter_lay.removeWidget(row); row.deleteLater() + self._push() + + def _on_enable(self, v: bool): + if self._cur_pipeline: + self._cur_pipeline.enabled = v; self._push() + + def _apply_calibration(self, scale: float, offset: float): + """Insert/replace a scale_offset filter at the END of the pipeline.""" + if self._cur_pipeline is None: return + # Remove existing scale_offset + self._cur_pipeline.filters = [ + f for f in self._cur_pipeline.filters if f.name != "scale_offset" + ] + cal_filt = ScaleOffsetFilter(scale=scale, offset=offset) + self._cur_pipeline.filters.append(cal_filt) + # Refresh rows + for r in self._rows: + self._filter_lay.removeWidget(r); r.deleteLater() + self._rows.clear() + for f in self._cur_pipeline.filters: + self._add_filter_row(f) + self._push() + + def _push(self): + if self._cur_pipeline: + self.processor.set_pipeline(self._cur_pipeline) + self.changed.emit() + + +# ══════════════════════════════════════════════════════════════════════════════ +# Derived editor for one DerivedChannel +# ══════════════════════════════════════════════════════════════════════════════ + +_BUILTIN = ["velocity","acceleration","power","rms","difference","sum"] +_SCRIPT = ["expression","function","custom_script"] +_ALL_KINDS = _BUILTIN + _SCRIPT + +_KIND_HELP = { + "velocity": "Backward difference dy/dt from 1 displacement source", + "acceleration": "Second derivative d²y/dt² from 1 displacement source", + "power": "V × I from 2 sources (voltage, current)", + "rms": "Rolling RMS from 1 source (set window below)", + "difference": "source[0] − source[1] from 2 sources", + "sum": "Σ all source values", + "expression": "x = list of source values, t = elapsed time (s)\nExample: x[0] * 3.14159 / 180", + "function": "def compute(x, t):\n # x = source values list\n return x[0] * 2.0", + "custom_script":"Full Python. Must define: def compute(x, t): ...", +} + +class DerivedBlock(QFrame): + removed = pyqtSignal(object) + applied = pyqtSignal(object) # DerivedChannel + + def __init__(self, dc: DerivedChannel, registry: DeviceRegistry, + processor: SignalProcessor): + super().__init__() + self.dc = dc; self.registry = registry; self.processor = processor + self.setObjectName("plotBlock") + self._src_combos: List[QComboBox] = [] + self._build() + + def _build(self): + outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0) + self._name = QLineEdit(self.dc.name); self._name.setObjectName("plotBlockTitle") + self._name.textChanged.connect(lambda t: setattr(self.dc,"name",t)) + hl.addWidget(self._name, 1) + self._en = QCheckBox(); self._en.setChecked(self.dc.enabled) + self._en.setToolTip("Enable"); self._en.toggled.connect(lambda v: setattr(self.dc,"enabled",v)) + hl.addWidget(self._en) + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("plotRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self.removed.emit(self)) + hl.addWidget(rm) + outer.addWidget(hdr) + + # Body + body = QWidget(); body.setObjectName("plotBlockSettings") + bl = QVBoxLayout(body); bl.setContentsMargins(10,8,10,10); bl.setSpacing(6) + + # ID / unit / color row + meta = QHBoxLayout() + meta.addWidget(QLabel("ID:")); self._id = QLineEdit(self.dc.channel_id) + self._id.setObjectName("traceLabel"); self._id.setFixedWidth(100) + self._id.textChanged.connect(lambda t: setattr(self.dc,"channel_id",t)) + meta.addWidget(self._id) + meta.addWidget(QLabel("Unit:")); self._unit = QLineEdit(self.dc.unit) + self._unit.setObjectName("traceLabel"); self._unit.setFixedWidth(60) + self._unit.textChanged.connect(lambda t: setattr(self.dc,"unit",t)) + meta.addWidget(self._unit); meta.addStretch() + bl.addLayout(meta) + + # Kind + help + k_row = QHBoxLayout(); k_row.addWidget(QLabel("Kind:")) + self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb") + self._kind_cb.addItems(_ALL_KINDS); self._kind_cb.setCurrentText(self.dc.kind) + self._kind_cb.currentTextChanged.connect(self._on_kind) + k_row.addWidget(self._kind_cb); k_row.addStretch(); bl.addLayout(k_row) + self._help = QLabel(_KIND_HELP.get(self.dc.kind,"")) + self._help.setObjectName("traceSource"); self._help.setWordWrap(True) + bl.addWidget(self._help) + + # RMS window (shown for rms) + self._win_row = QHBoxLayout() + self._win_row.addWidget(QLabel("Window samples:")) + self._win_sp = QSpinBox(); self._win_sp.setRange(2,10000) + self._win_sp.setValue(self.dc.params.get("window",20)) + self._win_sp.setObjectName("traceWidthSpin"); self._win_sp.setFixedWidth(80) + self._win_sp.valueChanged.connect(lambda v: self.dc.params.update({"window":v})) + self._win_row.addWidget(self._win_sp); self._win_row.addStretch() + self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row) + bl.addWidget(self._win_widget) + + # Sources + src_grp = QGroupBox("Sources"); src_grp.setObjectName("cfgGlobalBar") + src_lay = QVBoxLayout(src_grp); src_lay.setContentsMargins(6,4,6,4); src_lay.setSpacing(3) + self._src_cont = QWidget(); self._src_vlay = QVBoxLayout(self._src_cont) + self._src_vlay.setContentsMargins(0,0,0,0); self._src_vlay.setSpacing(3) + for s in self.dc.sources: self._add_src(s) + add_s = QPushButton("+ Add Source"); add_s.setObjectName("addTraceBtn") + add_s.clicked.connect(lambda: self._add_src()) + src_lay.addWidget(self._src_cont); src_lay.addWidget(add_s) + bl.addWidget(src_grp) + + # Code editor + self._code_grp = QGroupBox("Code") + code_lay = QVBoxLayout(self._code_grp); code_lay.setContentsMargins(6,4,6,4) + self._code = QTextEdit(); self._code.setObjectName("codeEditor") + self._code.setMinimumHeight(90); self._code.setMaximumHeight(180) + txt = self.dc.expression if self.dc.kind=="expression" else self.dc.script + self._code.setPlainText(txt) + code_lay.addWidget(self._code) + bl.addWidget(self._code_grp) + + # Status + apply + ap_row = QHBoxLayout() + self._st = QLabel(""); self._st.setObjectName("traceSource") + ap_row.addWidget(self._st, 1) + ap = QPushButton("✓ Apply"); ap.setObjectName("applyButton") + ap.clicked.connect(self._apply); ap_row.addWidget(ap) + bl.addLayout(ap_row) + + outer.addWidget(body) + self._update_visibility() + + def _add_src(self, src=None): + row = QHBoxLayout() + cb = _channel_combo(self.registry, include_derived=False) + cb.setObjectName("channelPickerCb") + if src: + for i in range(cb.count()): + if cb.itemData(i) == src: cb.setCurrentIndex(i); break + rm = QToolButton(); rm.setText("✕"); rm.setObjectName("traceRemoveBtn") + rm.setFixedSize(22,22); rm.clicked.connect(lambda: self._rm_src(row, cb)) + row.addWidget(cb,1); row.addWidget(rm) + self._src_vlay.addLayout(row) + self._src_combos.append(cb) + + def _rm_src(self, row, cb): + if cb in self._src_combos: self._src_combos.remove(cb) + while row.count(): + it = row.takeAt(0) + if it.widget(): it.widget().deleteLater() + + def _on_kind(self, k: str): + self.dc.kind = k + self._help.setText(_KIND_HELP.get(k,"")) + self._update_visibility() + + def _update_visibility(self): + is_script = self.dc.kind in _SCRIPT + is_rms = self.dc.kind == "rms" + self._code_grp.setVisible(is_script) + self._win_widget.setVisible(is_rms) + + def _apply(self): + self.dc.sources = [cb.currentData() for cb in self._src_combos if cb.currentData()] + if self.dc.kind == "expression": + self.dc.expression = self._code.toPlainText().strip() + elif self.dc.kind in ("function","custom_script"): + self.dc.script = self._code.toPlainText() + err = self.processor.add_derived(self.dc) + if err: + self._st.setText(f"⚠ {err[:100]}") + self._st.setStyleSheet("color:#ef4444;") + else: + self._st.setText("✓ Applied") + self._st.setStyleSheet("color:#22c55e;") + self.applied.emit(self.dc) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Channel Visibility Tab +# ══════════════════════════════════════════════════════════════════════════════ + +class ChannelVisTab(QWidget): + changed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + self._build() + + def _build(self): + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + self._cont = QWidget() + self._lay = QVBoxLayout(self._cont) + self._lay.setContentsMargins(10,10,10,10); self._lay.setSpacing(8) + scroll.setWidget(self._cont) + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0) + root.addWidget(scroll) + self.refresh() + + def refresh(self): + while self._lay.count(): + it = self._lay.takeAt(0) + if it.widget(): it.widget().deleteLater() + for dev in self.registry.all_instances(): + grp = QGroupBox(f"{dev.info.icon} {dev.info.name} [{dev.info.device_id}]") + g_lay = QVBoxLayout(grp); g_lay.setSpacing(3) + for ch in dev.info.channels: + row = QHBoxLayout() + chk = QCheckBox(f"{ch.channel_id} — {ch.name}") + chk.setChecked(ch.enabled) + chk.setStyleSheet(f"color:{ch.color};") + chk.toggled.connect(lambda v, c=ch: (setattr(c,"enabled",v), self.changed.emit())) + unit = QLabel(ch.unit); unit.setObjectName("traceSource") + row.addWidget(chk,1); row.addWidget(unit) + g_lay.addLayout(row) + self._lay.addWidget(grp) + self._lay.addStretch() + + +# ══════════════════════════════════════════════════════════════════════════════ +# SignalsWindow — main +# ══════════════════════════════════════════════════════════════════════════════ + +class SignalsWindow(QWidget): + pipeline_changed = pyqtSignal() + derived_changed = pyqtSignal() + visibility_changed = pyqtSignal() + closed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, + processor: SignalProcessor, parent=None): + super().__init__(parent, Qt.WindowType.Window | Qt.WindowType.Tool) + self.registry = registry + self.processor = processor + self._derived_blocks: List[DerivedBlock] = [] + self._color_idx = 0 + + self.setWindowTitle("Signals") + self.setMinimumSize(640, 520) + self.resize(740, 640) + self._build() + + def _build(self): + root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0) + + # Header + hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44) + hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0) + hl.addWidget(QLabel("SIGNALS").also(lambda w: w.setObjectName("devWindowTitle")), 1) + root.addWidget(hdr) + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); root.addWidget(div) + + tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") + root.addWidget(tabs, 1) + + # Tab 1: Pipeline / Filters / Calibration + self._pip_tab = PipelineTab(self.registry, self.processor) + self._pip_tab.changed.connect(self.pipeline_changed) + tabs.addTab(self._pip_tab, " Pipeline ") + + # Tab 2: Derived channels + tabs.addTab(self._build_derived_tab(), " Derived ") + + # Tab 3: Channel visibility + self._vis_tab = ChannelVisTab(self.registry, self.processor) + self._vis_tab.changed.connect(self.visibility_changed) + tabs.addTab(self._vis_tab, " Channels ") + + def _build_derived_tab(self): + w = QWidget() + lay = QVBoxLayout(w); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) + + # Toolbar + top = QWidget(); top.setObjectName("cfgGlobalBar") + tl = QHBoxLayout(top); tl.setContentsMargins(10,7,10,7) + tl.addWidget(QLabel("New:")) + self._new_kind = QComboBox(); self._new_kind.setObjectName("channelPickerCb") + self._new_kind.addItems(_ALL_KINDS); tl.addWidget(self._new_kind) + cr = QPushButton("+ Create Derived Channel") + cr.setObjectName("addDeviceButton"); cr.clicked.connect(self._add_derived) + tl.addWidget(cr); tl.addStretch() + lay.addWidget(top) + + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider"); lay.addWidget(div) + + scroll = QScrollArea(); scroll.setWidgetResizable(True) + scroll.setObjectName("deviceScroll") + self._d_cont = QWidget() + self._d_lay = QVBoxLayout(self._d_cont) + self._d_lay.setContentsMargins(10,10,10,10); self._d_lay.setSpacing(10) + self._d_lay.addStretch() + scroll.setWidget(self._d_cont) + lay.addWidget(scroll, 1) + return w + + def _add_derived(self): + kind = self._new_kind.currentText() + color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)] + self._color_idx += 1 + n = len(self._derived_blocks) + 1 + dc = DerivedChannel(channel_id=f"sig_{n}", name=f"Signal {n}", + kind=kind, color=color) + blk = DerivedBlock(dc, self.registry, self.processor) + blk.removed.connect(self._rm_derived) + blk.applied.connect(lambda _: self.derived_changed.emit()) + self._derived_blocks.append(blk) + self._d_lay.insertWidget(self._d_lay.count()-1, blk) + + def _rm_derived(self, blk: DerivedBlock): + self.processor.remove_derived(blk.dc.channel_id) + self._derived_blocks.remove(blk) + self._d_lay.removeWidget(blk); blk.deleteLater() + self.derived_changed.emit() + + def closeEvent(self, e: QCloseEvent): + self.closed.emit(); e.accept() |
