diff options
| author | Christian Kolset <christian.kolset@gmail.com> | 2026-04-22 14:50:13 -0600 |
|---|---|---|
| committer | Christian Kolset <christian.kolset@gmail.com> | 2026-04-22 14:50:13 -0600 |
| commit | 99445c6fd2fd4f15df828c79600b96b80fa1cfa6 (patch) | |
| tree | 22c9933fc6bb942172a1f456bef7a35ec4be1726 /ui/windows | |
| parent | 445403fdc61f4e65736dc65a959f119fc096f4c0 (diff) | |
Added derived signal to be sources of other derived signals.
Fixed derived signal saving profile. Renamed Signals>Pipeline to Signas>Channels
and adding/removing channels feature.
Diffstat (limited to 'ui/windows')
| -rw-r--r-- | ui/windows/__pycache__/plot_window.cpython-312.pyc | bin | 44551 -> 44550 bytes | |||
| -rw-r--r-- | ui/windows/__pycache__/signals_window.cpython-312.pyc | bin | 42907 -> 53973 bytes | |||
| -rw-r--r-- | ui/windows/plot_window.py | 2 | ||||
| -rw-r--r-- | ui/windows/signals_window.py | 415 |
4 files changed, 294 insertions, 123 deletions
diff --git a/ui/windows/__pycache__/plot_window.cpython-312.pyc b/ui/windows/__pycache__/plot_window.cpython-312.pyc Binary files differindex 288e026..c72d74b 100644 --- a/ui/windows/__pycache__/plot_window.cpython-312.pyc +++ b/ui/windows/__pycache__/plot_window.cpython-312.pyc diff --git a/ui/windows/__pycache__/signals_window.cpython-312.pyc b/ui/windows/__pycache__/signals_window.cpython-312.pyc Binary files differindex f057963..8b0db88 100644 --- a/ui/windows/__pycache__/signals_window.cpython-312.pyc +++ b/ui/windows/__pycache__/signals_window.cpython-312.pyc diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py index 1be590a..ea1ba1c 100644 --- a/ui/windows/plot_window.py +++ b/ui/windows/plot_window.py @@ -262,7 +262,7 @@ class PaneBlock(QFrame): pick_row = QHBoxLayout() self._picker = self._make_picker(); pick_row.addWidget(self._picker,1) - ab = QPushButton("+ Add Channel"); ab.setObjectName("addTraceBtn") + ab = QPushButton("+ Add Signal"); ab.setObjectName("addTraceBtn") ab.clicked.connect(self._on_add); pick_row.addWidget(ab) self._tlay.addLayout(pick_row) outer.addWidget(tr_w) diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py index 68cceda..ff863af 100644 --- a/ui/windows/signals_window.py +++ b/ui/windows/signals_window.py @@ -4,37 +4,27 @@ ui/windows/signals_window.py SIGNALS window — toolbar section 3. Tabs: - Pipeline — per-channel filter stack (filters, scale/offset) - Derived — create virtual channels from physical ones - Channels — visibility & color management for all channels - -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 + Channels — per-channel filter stack; user-curated list with add/remove + Derived — create virtual channels from physical or derived ones + Signals — flat combined list of all physical + derived signals """ from __future__ import annotations -from typing import List, Optional +from typing import List, Optional, Set, Tuple from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit, QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox, - QToolButton, QSizePolicy, QFormLayout, QMessageBox, + QToolButton, ) from PyQt6.QtCore import Qt, pyqtSignal -from PyQt6.QtGui import QColor, QFont, QCloseEvent +from PyQt6.QtGui import QCloseEvent from devices.device_registry import DeviceRegistry from core.signal_processor import ( SignalProcessor, ChannelPipeline, DerivedChannel, - FilterBase, MovingAverageFilter, MedianFilter, LowPassFilter, - HighPassFilter, DerivativeFilter, IntegralFilter, - FILTER_CLASSES, + FilterBase, FILTER_CLASSES, ) _DERIVED_COLORS = [ @@ -42,18 +32,24 @@ _DERIVED_COLORS = [ "#f77f00","#d62828","#588157","#e9c46a", ] + def _channel_combo(registry: DeviceRegistry, processor: Optional[SignalProcessor] = None, - include_derived: bool = False) -> QComboBox: + include_derived: bool = False, + show_unit: 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)) + label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" + if show_unit and ch.unit: + label += f" [{ch.unit}]" + cb.addItem(label, 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)) + label = f"[derived] {dc.channel_id} ({dc.name})" + if show_unit and dc.unit: + label += f" [{dc.unit}]" + cb.addItem(label, userData=("derived", dc.channel_id)) return cb @@ -90,7 +86,8 @@ class FilterRow(QFrame): 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"))) + pl = QLabel(f"{pname}:"); pl.setObjectName("pwmLabel") + lay.addWidget(pl) if ptype == "int": w = QSpinBox(); w.setRange(int(lo), int(hi)) w.setValue(int(self.filt.params.get(pname, default))) @@ -116,61 +113,71 @@ class FilterRow(QFrame): self.changed.emit() -# monkey-patch QLabel.also so we can set objectName inline above +# monkey-patch QLabel.also for inline objectName from PyQt6.QtWidgets import QLabel as _QLabel def _also(self, fn): fn(self); return self _QLabel.also = _also # ══════════════════════════════════════════════════════════════════════════════ -# Pipeline Tab — filter stack + calibration for one channel +# Channel Pipeline Block — collapsible filter stack for one channel # ══════════════════════════════════════════════════════════════════════════════ -class PipelineTab(QWidget): +class ChannelPipelineBlock(QFrame): changed = pyqtSignal() + removed = pyqtSignal(object) # emits self - def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str, + processor: SignalProcessor): super().__init__() - self.registry = registry + self.dev_id = dev_id + self.ch_id = ch_id 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) + self._expanded = False + self.setObjectName("plotBlock") + self._build(ch_name, unit) + self._load() - # 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) + def _build(self, ch_name: str, unit: str): + outer = QVBoxLayout(self); outer.setContentsMargins(0,0,0,0); outer.setSpacing(0) - div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) - div.setObjectName("devWindowDivider"); lay.addWidget(div) + # Header + hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) + hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0) + title = f"{self.dev_id} / {self.ch_id} ({ch_name})" + if unit: + title += f" [{unit}]" + lbl = QLabel(title); lbl.setObjectName("traceSource") + hl.addWidget(lbl, 1) + + rm_btn = QToolButton(); rm_btn.setText("✕") + rm_btn.setObjectName("traceRemoveBtn"); rm_btn.setFixedSize(22, 22) + rm_btn.clicked.connect(lambda: self.removed.emit(self)) + hl.addWidget(rm_btn) + + self._toggle_btn = QToolButton(); self._toggle_btn.setText("▶") + self._toggle_btn.setObjectName("traceRemoveBtn"); self._toggle_btn.setFixedSize(22, 22) + self._toggle_btn.clicked.connect(self._toggle) + hl.addWidget(self._toggle_btn) + outer.addWidget(hdr) + hdr.mousePressEvent = lambda e: self._toggle() - 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) + # Body + self._body = QWidget(); self._body.setObjectName("plotBlockSettings") + bl = QVBoxLayout(self._body) + bl.setContentsMargins(10,8,10,10); bl.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) + bl.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) + bl.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]) @@ -178,34 +185,26 @@ class PipelineTab(QWidget): 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) + bl.addLayout(add_bar) - # Apply button - ap_row = QHBoxLayout() - ap_row.addStretch() - ap_btn = QPushButton("✓ Apply Pipeline"); ap_btn.setObjectName("applyButton") + ap_row = QHBoxLayout(); ap_row.addStretch() + ap_btn = QPushButton("✓ Apply"); ap_btn.setObjectName("applyButton") ap_btn.clicked.connect(self._push) ap_row.addWidget(ap_btn) - self._inner.addLayout(ap_row) - self._inner.addStretch() + bl.addLayout(ap_row) - scroll.setWidget(self._cont) - lay.addWidget(scroll, 1) - self._load_pipeline(0) + outer.addWidget(self._body) + self._body.setVisible(False) - def _load_pipeline(self, _=None): - data = self._ch_cb.currentData() - if not data: return - dev_id, ch_id = data + def _toggle(self): + self._expanded = not self._expanded + self._body.setVisible(self._expanded) + self._toggle_btn.setText("▼" if self._expanded else "▶") - # 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) + def _load(self): + pipeline = self.processor.get_pipeline(self.dev_id, self.ch_id) if pipeline is None: - pipeline = ChannelPipeline(device_id=dev_id, channel_id=ch_id) + pipeline = ChannelPipeline(device_id=self.dev_id, channel_id=self.ch_id) self.processor.set_pipeline(pipeline) self._cur_pipeline = pipeline self._en_chk.setChecked(pipeline.enabled) @@ -236,7 +235,8 @@ class PipelineTab(QWidget): def _on_enable(self, v: bool): if self._cur_pipeline: - self._cur_pipeline.enabled = v; self._push() + self._cur_pipeline.enabled = v + self._push() def _push(self): if self._cur_pipeline: @@ -245,16 +245,110 @@ class PipelineTab(QWidget): # ══════════════════════════════════════════════════════════════════════════════ +# Channels Tab (formerly Pipeline Tab) — user-curated channel list +# ══════════════════════════════════════════════════════════════════════════════ + +class PipelineTab(QWidget): + changed = pyqtSignal() + + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + self._shown: Set[Tuple[str,str]] = set() # (dev_id, ch_id) + self._build() + + def _build(self): + lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) + + # Add-channel bar at top + add_bar = QWidget(); add_bar.setObjectName("cfgGlobalBar") + ab_lay = QHBoxLayout(add_bar); ab_lay.setContentsMargins(10,7,10,7); ab_lay.setSpacing(6) + ab_lay.addWidget(QLabel("Add:")) + self._avail_cb = QComboBox(); self._avail_cb.setObjectName("channelPickerCb") + ab_lay.addWidget(self._avail_cb, 1) + add_btn = QPushButton("+ Add Channel"); add_btn.setObjectName("addTraceBtn") + add_btn.clicked.connect(self._on_add_channel) + ab_lay.addWidget(add_btn) + lay.addWidget(add_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) + self._inner.addStretch() + scroll.setWidget(self._cont) + lay.addWidget(scroll, 1) + + # Populate with all device channels on startup + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + self._add_block(dev.info.device_id, ch.channel_id, ch.name, ch.unit) + + self._refresh_avail_cb() + + # ── Channel block management ────────────────────────────────────────────── + + def _add_block(self, dev_id: str, ch_id: str, ch_name: str, unit: str): + if (dev_id, ch_id) in self._shown: + return + self._shown.add((dev_id, ch_id)) + block = ChannelPipelineBlock(dev_id, ch_id, ch_name, unit, self.processor) + block.changed.connect(self.changed) + block.removed.connect(self._on_remove_block) + # Insert before the trailing stretch + self._inner.insertWidget(self._inner.count() - 1, block) + + def _on_add_channel(self): + data = self._avail_cb.currentData() + if not data: + return + dev_id, ch_id = data + dev = self.registry.get_instance(dev_id) + if not dev: + return + ch = dev.get_channel(ch_id) + if not ch: + return + self._add_block(dev_id, ch_id, ch.name, ch.unit) + self._refresh_avail_cb() + + def _on_remove_block(self, block: ChannelPipelineBlock): + self._shown.discard((block.dev_id, block.ch_id)) + self._inner.removeWidget(block) + block.deleteLater() + self._refresh_avail_cb() + + def _refresh_avail_cb(self): + self._avail_cb.clear() + for dev in self.registry.all_instances(): + for ch in dev.info.channels: + if (dev.info.device_id, ch.channel_id) not in self._shown: + label = f"{dev.info.device_id} / {ch.channel_id} ({ch.name})" + if ch.unit: + label += f" [{ch.unit}]" + self._avail_cb.addItem(label, + userData=(dev.info.device_id, ch.channel_id)) + + +# ══════════════════════════════════════════════════════════════════════════════ # Derived editor for one DerivedChannel # ══════════════════════════════════════════════════════════════════════════════ -_BUILTIN = ["velocity","acceleration","power","rms","difference","sum"] -_SCRIPT = ["expression","function","custom_script"] +_BUILTIN = ["derivative", "second_derivative", "power", "rms", "difference", "sum"] +_SCRIPT = ["expression", "function", "custom_script"] _ALL_KINDS = _BUILTIN + _SCRIPT +_KIND_DISPLAY = {k: k.replace("_", " ").title() for k in _ALL_KINDS} + _KIND_HELP = { - "velocity": "Backward difference dy/dt from 1 displacement source", - "acceleration": "Second derivative d²y/dt² from 1 displacement source", + "derivative": "Backward difference dy/dt from 1 source", + "second_derivative": "Second derivative d²y/dt² from 1 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", @@ -264,6 +358,7 @@ _KIND_HELP = { "custom_script":"Full Python. Must define: def compute(x, t): ...", } + class DerivedBlock(QFrame): removed = pyqtSignal(object) applied = pyqtSignal(object) # DerivedChannel @@ -286,7 +381,8 @@ class DerivedBlock(QFrame): 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)) + 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)) @@ -297,29 +393,34 @@ class DerivedBlock(QFrame): body = QWidget(); body.setObjectName("plotBlockSettings") bl = QVBoxLayout(body); bl.setContentsMargins(10,8,10,10); bl.setSpacing(6) - # ID / unit / color row + # ID / unit row meta = QHBoxLayout() - meta.addWidget(QLabel("ID:")); self._id = QLineEdit(self.dc.channel_id) + 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) + 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 + # Kind combo + 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._kind_cb.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS]) + cur_idx = _ALL_KINDS.index(self.dc.kind) if self.dc.kind in _ALL_KINDS else 0 + self._kind_cb.setCurrentIndex(cur_idx) + self._kind_cb.currentIndexChanged.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) + # RMS window self._win_row = QHBoxLayout() self._win_row.addWidget(QLabel("Window samples:")) self._win_sp = QSpinBox(); self._win_sp.setRange(2,10000) @@ -330,12 +431,13 @@ class DerivedBlock(QFrame): self._win_widget = QWidget(); self._win_widget.setLayout(self._win_row) bl.addWidget(self._win_widget) - # Sources + # Sources — include derived channels as valid 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) + 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) @@ -356,7 +458,8 @@ class DerivedBlock(QFrame): 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) + ap.clicked.connect(self._apply) + ap_row.addWidget(ap) bl.addLayout(ap_row) outer.addWidget(body) @@ -364,11 +467,14 @@ class DerivedBlock(QFrame): def _add_src(self, src=None): row = QHBoxLayout() - cb = _channel_combo(self.registry, include_derived=False) + # Include derived channels as valid sources (enables chaining) + cb = _channel_combo(self.registry, self.processor, + include_derived=True, show_unit=True) cb.setObjectName("channelPickerCb") if src: for i in range(cb.count()): - if cb.itemData(i) == src: cb.setCurrentIndex(i); break + 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) @@ -376,24 +482,25 @@ class DerivedBlock(QFrame): self._src_combos.append(cb) def _rm_src(self, row, cb): - if cb in self._src_combos: self._src_combos.remove(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): + def _on_kind(self, idx: int): + k = _ALL_KINDS[idx] 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) + self._code_grp.setVisible(self.dc.kind in _SCRIPT) + self._win_widget.setVisible(self.dc.kind == "rms") def _apply(self): - self.dc.sources = [cb.currentData() for cb in self._src_combos if cb.currentData()] + 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"): @@ -409,10 +516,10 @@ class DerivedBlock(QFrame): # ══════════════════════════════════════════════════════════════════════════════ -# Channel Visibility Tab +# Signals Tab — flat unified list of all signals # ══════════════════════════════════════════════════════════════════════════════ -class ChannelVisTab(QWidget): +class SignalsTab(QWidget): changed = pyqtSignal() def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): @@ -426,7 +533,7 @@ class ChannelVisTab(QWidget): scroll.setObjectName("deviceScroll") self._cont = QWidget() self._lay = QVBoxLayout(self._cont) - self._lay.setContentsMargins(10,10,10,10); self._lay.setSpacing(8) + self._lay.setContentsMargins(8,8,8,8); self._lay.setSpacing(2) scroll.setWidget(self._cont) root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0) root.addWidget(scroll) @@ -436,21 +543,63 @@ class ChannelVisTab(QWidget): while self._lay.count(): it = self._lay.takeAt(0) if it.widget(): it.widget().deleteLater() + + # Physical channels — flat list across all devices 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.addWidget(self._make_row( + color = ch.color, + name = f"{ch.channel_id} — {ch.name}", + unit = ch.unit, + source = dev.info.device_id, + enabled = ch.enabled, + on_toggle = lambda v, c=ch: (setattr(c,"enabled",v), self.changed.emit()), + )) + + # Thin divider before derived (only if both sections have entries) + if self.registry.all_instances() and self.processor.get_derived(): + div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) + div.setObjectName("devWindowDivider") + self._lay.addWidget(div) + + # Derived signals + for dc in self.processor.get_derived(): + self._lay.addWidget(self._make_row( + color = dc.color, + name = f"{dc.channel_id} — {dc.name}", + unit = dc.unit, + source = "derived", + enabled = dc.enabled, + on_toggle = lambda v, d=dc: (setattr(d,"enabled",v), self.changed.emit()), + )) + self._lay.addStretch() + def _make_row(self, color: str, name: str, unit: str, source: str, + enabled: bool, on_toggle) -> QFrame: + row = QFrame(); row.setObjectName("traceRow") + lay = QHBoxLayout(row); lay.setContentsMargins(8,4,8,4); lay.setSpacing(8) + + dot = QLabel("●"); dot.setStyleSheet(f"color:{color};"); dot.setFixedWidth(14) + lay.addWidget(dot) + + chk = QCheckBox(name); chk.setChecked(enabled) + chk.toggled.connect(on_toggle) + lay.addWidget(chk, 1) + + if unit: + ul = QLabel(unit); ul.setObjectName("traceSource") + ul.setFixedWidth(52) + ul.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + lay.addWidget(ul) + + sl = QLabel(source); sl.setObjectName("traceSource") + sl.setFixedWidth(72) + sl.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter) + lay.addWidget(sl) + + return row + # ══════════════════════════════════════════════════════════════════════════════ # SignalsWindow — main @@ -489,29 +638,35 @@ class SignalsWindow(QWidget): tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") root.addWidget(tabs, 1) - # Tab 1: Pipeline / Filters / Calibration + # Tab 1: Channels (filter pipelines per channel) self._pip_tab = PipelineTab(self.registry, self.processor) self._pip_tab.changed.connect(self.pipeline_changed) - tabs.addTab(self._pip_tab, " Pipeline ") + tabs.addTab(self._pip_tab, " Channels ") # Tab 2: Derived channels tabs.addTab(self._build_derived_tab(), " Derived ") - # Tab 3: Channel visibility - self._vis_tab = ChannelVisTab(self.registry, self.processor) + # Tab 3: Flat signals list + self._vis_tab = SignalsTab(self.registry, self.processor) self._vis_tab.changed.connect(self.visibility_changed) - tabs.addTab(self._vis_tab, " Channels ") + tabs.addTab(self._vis_tab, " Signals ") + + # Keep Signals tab in sync when derived channels are added/removed + self.derived_changed.connect(self._vis_tab.refresh) + + # Populate from processor state (handles opening window after profile load) + self.refresh_derived() 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) + self._new_kind.addItems([_KIND_DISPLAY[k] for k in _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() @@ -531,7 +686,7 @@ class SignalsWindow(QWidget): return w def _add_derived(self): - kind = self._new_kind.currentText() + kind = _ALL_KINDS[self._new_kind.currentIndex()] color = _DERIVED_COLORS[self._color_idx % len(_DERIVED_COLORS)] self._color_idx += 1 n = len(self._derived_blocks) + 1 @@ -549,5 +704,21 @@ class SignalsWindow(QWidget): self._d_lay.removeWidget(blk); blk.deleteLater() self.derived_changed.emit() + def refresh_derived(self): + """Rebuild DerivedBlock widgets from processor state (called after profile load).""" + for blk in self._derived_blocks: + self._d_lay.removeWidget(blk); blk.deleteLater() + self._derived_blocks.clear() + + for dc in self.processor.get_derived(): + 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) + + self._color_idx = len(self._derived_blocks) + self._vis_tab.refresh() + def closeEvent(self, e: QCloseEvent): self.closed.emit(); e.accept() |
