summaryrefslogtreecommitdiff
path: root/ui/windows
diff options
context:
space:
mode:
Diffstat (limited to 'ui/windows')
-rw-r--r--ui/windows/__init__.py0
-rw-r--r--ui/windows/__pycache__/__init__.cpython-314.pycbin0 -> 164 bytes
-rw-r--r--ui/windows/__pycache__/devices_window.cpython-314.pycbin0 -> 23245 bytes
-rw-r--r--ui/windows/__pycache__/plot_window.cpython-314.pycbin0 -> 46125 bytes
-rw-r--r--ui/windows/__pycache__/settings_window.cpython-314.pycbin0 -> 16406 bytes
-rw-r--r--ui/windows/__pycache__/signals_window.cpython-314.pycbin0 -> 53434 bytes
-rw-r--r--ui/windows/devices_window.py319
-rw-r--r--ui/windows/plot_window.py486
-rw-r--r--ui/windows/settings_window.py231
-rw-r--r--ui/windows/signals_window.py659
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
new file mode 100644
index 0000000..ea15dd5
--- /dev/null
+++ b/ui/windows/__pycache__/__init__.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/devices_window.cpython-314.pyc b/ui/windows/__pycache__/devices_window.cpython-314.pyc
new file mode 100644
index 0000000..88763e7
--- /dev/null
+++ b/ui/windows/__pycache__/devices_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/plot_window.cpython-314.pyc b/ui/windows/__pycache__/plot_window.cpython-314.pyc
new file mode 100644
index 0000000..ad53019
--- /dev/null
+++ b/ui/windows/__pycache__/plot_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/settings_window.cpython-314.pyc b/ui/windows/__pycache__/settings_window.cpython-314.pyc
new file mode 100644
index 0000000..0926445
--- /dev/null
+++ b/ui/windows/__pycache__/settings_window.cpython-314.pyc
Binary files differ
diff --git a/ui/windows/__pycache__/signals_window.cpython-314.pyc b/ui/windows/__pycache__/signals_window.cpython-314.pyc
new file mode 100644
index 0000000..4194ac8
--- /dev/null
+++ b/ui/windows/__pycache__/signals_window.cpython-314.pyc
Binary files differ
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()