summaryrefslogtreecommitdiff
path: root/ui/windows/signals_window.py
diff options
context:
space:
mode:
authorChristian Kolset <christian.kolset@gmail.com>2026-06-03 12:35:32 -0600
committerChristian Kolset <christian.kolset@gmail.com>2026-06-03 12:35:32 -0600
commit0355099aa21918e8f6f7fcd61aba8e3014bf8171 (patch)
treeaa3842431dca5d03444ebaa507679bc341d44ba3 /ui/windows/signals_window.py
parent74b2ebb0727fcd38b71196e3d79a119ce85d6833 (diff)
Rename signals_window.py → channels_window.py; update docstring
Reflects current design: split Physical/Virtual Channels layout, no tabs, no legacy Signals list as primary UI. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'ui/windows/signals_window.py')
-rw-r--r--ui/windows/signals_window.py878
1 files changed, 0 insertions, 878 deletions
diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py
deleted file mode 100644
index 8a63bf1..0000000
--- a/ui/windows/signals_window.py
+++ /dev/null
@@ -1,878 +0,0 @@
-"""
-ui/windows/signals_window.py
-
-SIGNALS window — toolbar section 3.
-
-Tabs:
- 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, Set, Tuple
-
-from PyQt6.QtWidgets import (
- QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
- QScrollArea, QFrame, QTabWidget, QComboBox, QLineEdit,
- QDoubleSpinBox, QSpinBox, QCheckBox, QTextEdit, QGroupBox,
- QToolButton, QSplitter,
-)
-from PyQt6.QtCore import Qt, pyqtSignal
-from PyQt6.QtGui import QCloseEvent
-
-from devices.device_registry import DeviceRegistry
-from core.signal_processor import (
- SignalProcessor, ChannelPipeline, DerivedChannel,
- FilterBase, 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,
- show_unit: bool = False) -> QComboBox:
- cb = QComboBox(); cb.setObjectName("channelPickerCb")
- for dev in registry.all_instances():
- for ch in dev.info.channels:
- if not ch.enabled:
- continue
- 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():
- 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
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# 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, []):
- 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)))
- 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 for inline objectName
-from PyQt6.QtWidgets import QLabel as _QLabel
-def _also(self, fn): fn(self); return self
-_QLabel.also = _also
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Channel Pipeline Block — collapsible filter stack for one channel
-# ══════════════════════════════════════════════════════════════════════════════
-
-class ChannelPipelineBlock(QFrame):
- changed = pyqtSignal()
- removed = pyqtSignal(object) # emits self
-
- def __init__(self, dev_id: str, ch_id: str, ch_name: str, unit: str,
- processor: SignalProcessor):
- super().__init__()
- self.dev_id = dev_id
- self.ch_id = ch_id
- self.processor = processor
- self._rows: List[FilterRow] = []
- self._cur_pipeline: Optional[ChannelPipeline] = None
- self._expanded = False
- self.setObjectName("plotBlock")
- self._build(ch_name, unit)
- self._load()
-
- def _build(self, ch_name: str, unit: str):
- self._ch_name = ch_name
- self._unit = unit
- 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)
- title = f"{self.dev_id} / {self.ch_id} ({ch_name})"
- if unit:
- title += f" [{unit}]"
- self._title_lbl = QLabel(title); self._title_lbl.setObjectName("traceSource")
- hl.addWidget(self._title_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()
-
- # Body
- self._body = QWidget(); self._body.setObjectName("plotBlockSettings")
- bl = QVBoxLayout(self._body)
- bl.setContentsMargins(10,8,10,10); bl.setSpacing(6)
-
- self._en_chk = QCheckBox("Pipeline enabled"); self._en_chk.setChecked(True)
- self._en_chk.toggled.connect(self._on_enable)
- bl.addWidget(self._en_chk)
-
- self._filter_area = QWidget()
- self._filter_lay = QVBoxLayout(self._filter_area)
- self._filter_lay.setContentsMargins(0,0,0,0); self._filter_lay.setSpacing(3)
- bl.addWidget(self._filter_area)
-
- 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)
- bl.addLayout(add_bar)
-
- 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)
- bl.addLayout(ap_row)
-
- outer.addWidget(self._body)
- self._body.setVisible(False)
-
- def _refresh_title(self):
- title = f"{self.dev_id} / {self.ch_id} ({self._ch_name})"
- if self._unit:
- title += f" [{self._unit}]"
- self._title_lbl.setText(title)
-
- def update_name(self, ch_name: str):
- self._ch_name = ch_name
- self._refresh_title()
-
- def update_unit(self, unit: str):
- self._unit = unit
- self._refresh_title()
-
- def _toggle(self):
- self._expanded = not self._expanded
- self._body.setVisible(self._expanded)
- self._toggle_btn.setText("▼" if self._expanded else "▶")
-
- def _load(self):
- pipeline = self.processor.get_pipeline(self.dev_id, self.ch_id)
- if pipeline is None:
- 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)
- 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 _push(self):
- if self._cur_pipeline:
- self.processor.set_pipeline(self._cur_pipeline)
- self.changed.emit()
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Pipeline Tab — filter pipelines per channel + virtual channel creation
-# ══════════════════════════════════════════════════════════════════════════════
-
-class PipelineTab(QWidget):
- changed = pyqtSignal()
- derived_changed = pyqtSignal()
-
- def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
- super().__init__()
- self.registry = registry
- self.processor = processor
- self._shown: Set[Tuple[str,str]] = set()
- self._blocks: dict[Tuple[str,str], ChannelPipelineBlock] = {}
- self._derived_blocks: List[DerivedBlock] = []
- self._color_idx = 0
- self._build()
-
- def _build(self):
- root = QVBoxLayout(self); root.setContentsMargins(0,0,0,0); root.setSpacing(0)
-
- splitter = QSplitter(Qt.Orientation.Vertical)
- splitter.setObjectName("channelSplitter")
- root.addWidget(splitter)
-
- # ── Physical Channels pane ──────────────────────────────────────────────
- phys_w = QWidget()
- phys_l = QVBoxLayout(phys_w); phys_l.setContentsMargins(0,0,0,0); phys_l.setSpacing(0)
-
- phys_bar = QWidget(); phys_bar.setObjectName("cfgGlobalBar")
- pb_lay = QHBoxLayout(phys_bar); pb_lay.setContentsMargins(10,7,10,7); pb_lay.setSpacing(6)
- pb_lbl = QLabel("Physical Channels"); pb_lbl.setObjectName("devWindowTitle")
- pb_lay.addWidget(pb_lbl, 1)
- self._avail_cb = QComboBox(); self._avail_cb.setObjectName("channelPickerCb")
- pb_lay.addWidget(self._avail_cb)
- add_btn = QPushButton("+ Add"); add_btn.setObjectName("addTraceBtn")
- add_btn.clicked.connect(self._on_add_channel)
- pb_lay.addWidget(add_btn)
- phys_l.addWidget(phys_bar)
-
- div1 = QFrame(); div1.setFrameShape(QFrame.Shape.HLine)
- div1.setObjectName("devWindowDivider"); phys_l.addWidget(div1)
-
- phys_scroll = QScrollArea(); phys_scroll.setWidgetResizable(True)
- phys_scroll.setObjectName("deviceScroll")
- phys_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- self._phys_cont = QWidget()
- self._phys_inner = QVBoxLayout(self._phys_cont)
- self._phys_inner.setContentsMargins(10,10,10,10); self._phys_inner.setSpacing(6)
- self._phys_inner.addStretch()
- phys_scroll.setWidget(self._phys_cont)
- phys_l.addWidget(phys_scroll, 1)
- splitter.addWidget(phys_w)
-
- # ── Virtual Channels pane ───────────────────────────────────────────────
- virt_w = QWidget()
- virt_l = QVBoxLayout(virt_w); virt_l.setContentsMargins(0,0,0,0); virt_l.setSpacing(0)
-
- virt_bar = QWidget(); virt_bar.setObjectName("cfgGlobalBar")
- vb_lay = QHBoxLayout(virt_bar); vb_lay.setContentsMargins(10,7,10,7); vb_lay.setSpacing(6)
- vb_lbl = QLabel("Virtual Channels"); vb_lbl.setObjectName("devWindowTitle")
- vb_lay.addWidget(vb_lbl, 1)
- self._new_kind = QComboBox(); self._new_kind.setObjectName("channelPickerCb")
- self._new_kind.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS])
- vb_lay.addWidget(self._new_kind)
- add_virt = QPushButton("+ Add Virtual"); add_virt.setObjectName("addTraceBtn")
- add_virt.clicked.connect(self._add_virtual)
- vb_lay.addWidget(add_virt)
- virt_l.addWidget(virt_bar)
-
- div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine)
- div2.setObjectName("devWindowDivider"); virt_l.addWidget(div2)
-
- virt_scroll = QScrollArea(); virt_scroll.setWidgetResizable(True)
- virt_scroll.setObjectName("deviceScroll")
- virt_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- self._virt_cont = QWidget()
- self._virt_inner = QVBoxLayout(self._virt_cont)
- self._virt_inner.setContentsMargins(10,10,10,10); self._virt_inner.setSpacing(6)
- self._virt_inner.addStretch()
- virt_scroll.setWidget(self._virt_cont)
- virt_l.addWidget(virt_scroll, 1)
- splitter.addWidget(virt_w)
-
- splitter.setSizes([300, 300])
-
- for dev in self.registry.all_instances():
- for ch in dev.info.channels:
- if ch.enabled:
- self._add_block(dev.info.device_id, ch.channel_id, ch.name, ch.unit)
-
- self._refresh_avail_cb()
-
- # ── Physical 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)
- self._blocks[(dev_id, ch_id)] = block
- self._phys_inner.insertWidget(self._phys_inner.count() - 1, block)
-
- def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool):
- key = (dev_id, ch_id)
- if enabled:
- if key not in self._shown:
- dev = self.registry.get_instance(dev_id)
- if dev:
- ch = dev.get_channel(ch_id)
- if ch:
- self._add_block(dev_id, ch_id, ch.name, ch.unit)
- else:
- block = self._blocks.get(key)
- if block:
- block.setVisible(True)
- else:
- block = self._blocks.get(key)
- if block:
- block.setVisible(False)
- self._refresh_avail_cb()
-
- def on_device_removed(self, dev_id: str):
- keys = [(d, c) for (d, c) in list(self._shown) if d == dev_id]
- for key in keys:
- block = self._blocks.get(key)
- if block:
- self._on_remove_block(block)
- self._refresh_avail_cb()
-
- def on_device_reconfigured(self, dev_id: str):
- dev = self.registry.get_instance(dev_id)
- if dev is None:
- self.on_device_removed(dev_id)
- return
- valid = {ch.channel_id for ch in dev.info.channels}
- keys = [(d, c) for (d, c) in list(self._shown)
- if d == dev_id and c not in valid]
- for key in keys:
- block = self._blocks.get(key)
- if block:
- self._on_remove_block(block)
- self._refresh_avail_cb()
-
- def on_device_added(self):
- self._refresh_avail_cb()
-
- def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str):
- key = (dev_id, ch_id)
- if key in self._blocks:
- self._blocks[key].update_name(ch_name)
- else:
- dev = self.registry.get_instance(dev_id)
- if dev:
- ch = dev.get_channel(ch_id)
- if ch and ch.enabled:
- self._add_block(dev_id, ch_id, ch_name, ch.unit)
- self._refresh_avail_cb()
-
- def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str):
- key = (dev_id, ch_id)
- if key in self._blocks:
- self._blocks[key].update_unit(unit)
-
- 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):
- key = (block.dev_id, block.ch_id)
- self._shown.discard(key)
- self._blocks.pop(key, None)
- self._phys_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 not ch.enabled:
- continue
- 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))
-
- # ── Virtual channel management ────────────────────────────────────────────
-
- def _add_virtual(self):
- 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
- dc = DerivedChannel(channel_id=f"CH_V{n}", name=f"Virtual Channel {n}",
- kind=kind, color=color)
- blk = self._make_derived_block(dc)
- self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
-
- def _make_derived_block(self, dc: DerivedChannel) -> DerivedBlock:
- blk = DerivedBlock(dc, self.registry, self.processor)
- blk.removed.connect(self._rm_virtual)
- blk.applied.connect(lambda _: self.derived_changed.emit())
- self._derived_blocks.append(blk)
- return blk
-
- def _rm_virtual(self, blk: DerivedBlock):
- self.processor.remove_derived(blk.dc.channel_id)
- self._derived_blocks.remove(blk)
- self._virt_inner.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._virt_inner.removeWidget(blk); blk.deleteLater()
- self._derived_blocks.clear()
- self._color_idx = 0
-
- for dc in self.processor.get_derived():
- blk = self._make_derived_block(dc)
- self._virt_inner.insertWidget(self._virt_inner.count() - 1, blk)
-
- self._color_idx = len(self._derived_blocks)
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Derived editor for one DerivedChannel
-# ══════════════════════════════════════════════════════════════════════════════
-
-_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 = {
- "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",
- "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 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 combo + help
- k_row = QHBoxLayout(); k_row.addWidget(QLabel("Operation:"))
- self._kind_cb = QComboBox(); self._kind_cb.setObjectName("channelPickerCb")
- 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
- 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 — 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)
- _is_deriv = self.dc.kind in ("derivative", "second_derivative")
- src_init = [self.dc.sources[0]] if (_is_deriv and self.dc.sources) else self.dc.sources
- for s in src_init:
- self._add_src(s)
- self._add_src_btn = QPushButton("+ Add Source"); self._add_src_btn.setObjectName("addTraceBtn")
- self._add_src_btn.clicked.connect(lambda: self._add_src())
- src_lay.addWidget(self._src_cont); src_lay.addWidget(self._add_src_btn)
- bl.addWidget(src_grp)
-
- # W.r.t. — only for derivative/second_derivative
- self._wrt_grp = QGroupBox("With Respect To"); self._wrt_grp.setObjectName("cfgGlobalBar")
- wrt_lay = QVBoxLayout(self._wrt_grp); wrt_lay.setContentsMargins(6,6,6,6); wrt_lay.setSpacing(4)
- wrt_top = QHBoxLayout(); wrt_top.addWidget(QLabel("Variable:"))
- self._wrt_cb = QComboBox(); self._wrt_cb.setObjectName("channelPickerCb")
- self._wrt_cb.addItems(["Time", "Channel"])
- _cur_wrt = self.dc.params.get("deriv_wrt", "time")
- self._wrt_cb.setCurrentIndex(1 if _cur_wrt == "channel" else 0)
- self._wrt_cb.currentIndexChanged.connect(self._on_wrt_changed)
- wrt_top.addWidget(self._wrt_cb); wrt_top.addStretch()
- wrt_lay.addLayout(wrt_top)
- self._wrt_x_row = QWidget()
- wx_lay = QHBoxLayout(self._wrt_x_row); wx_lay.setContentsMargins(0,0,0,0); wx_lay.setSpacing(6)
- wx_lay.addWidget(QLabel("Channel (x):"))
- self._wrt_x_cb = _channel_combo(self.registry, self.processor,
- include_derived=True, show_unit=True)
- self._wrt_x_cb.setObjectName("channelPickerCb")
- if _cur_wrt == "channel" and len(self.dc.sources) > 1:
- x_src = self.dc.sources[1]
- for i in range(self._wrt_x_cb.count()):
- if self._wrt_x_cb.itemData(i) == x_src:
- self._wrt_x_cb.setCurrentIndex(i); break
- wx_lay.addWidget(self._wrt_x_cb, 1)
- wrt_lay.addWidget(self._wrt_x_row)
- bl.addWidget(self._wrt_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()
- # 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
- 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, idx: int):
- k = _ALL_KINDS[idx]
- self.dc.kind = k
- self._help.setText(_KIND_HELP.get(k,""))
- self._update_visibility()
-
- def _on_wrt_changed(self, idx: int):
- self._wrt_x_row.setVisible(idx == 1)
-
- def _update_visibility(self):
- is_deriv = self.dc.kind in ("derivative", "second_derivative")
- self._code_grp.setVisible(self.dc.kind in _SCRIPT)
- self._win_widget.setVisible(self.dc.kind == "rms")
- self._wrt_grp.setVisible(is_deriv)
- self._add_src_btn.setVisible(not is_deriv)
- if is_deriv:
- self._wrt_x_row.setVisible(self._wrt_cb.currentIndex() == 1)
-
- def _apply(self):
- sources = [cb.currentData() for cb in self._src_combos if cb.currentData()]
- if self.dc.kind in ("derivative", "second_derivative"):
- wrt = "channel" if self._wrt_cb.currentIndex() == 1 else "time"
- self.dc.params["deriv_wrt"] = wrt
- if wrt == "channel":
- x_src = self._wrt_x_cb.currentData()
- if x_src:
- sources.append(x_src)
- self.dc.sources = sources
- 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)
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# Signals List Tab — flat list of all signals with enable/disable
-# ══════════════════════════════════════════════════════════════════════════════
-
-class SignalsListTab(QWidget):
- visibility_changed = pyqtSignal()
- channel_toggled = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled
-
- 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")
- scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
- self._cont = QWidget()
- self._lay = QVBoxLayout(self._cont)
- 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)
- 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():
- for ch in dev.info.channels:
- dev_id_cap = dev.info.device_id
- ch_id_cap = ch.channel_id
- self._lay.addWidget(self._make_row(
- color = ch.color,
- name = f"{ch.channel_id} — {ch.name}",
- unit = ch.unit,
- source = dev_id_cap,
- enabled = ch.enabled,
- on_toggle = lambda v, c=ch, d=dev_id_cap, ci=ch_id_cap: (
- setattr(c, "enabled", v),
- self.visibility_changed.emit(),
- self.channel_toggled.emit(d, ci, v),
- ),
- ))
-
- derived = self.processor.get_derived()
- if self.registry.all_instances() and derived:
- div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
- div.setObjectName("devWindowDivider")
- self._lay.addWidget(div)
-
- for dc in derived:
- self._lay.addWidget(self._make_row(
- color = dc.color,
- name = f"{dc.channel_id} — {dc.name}",
- unit = dc.unit,
- source = "virtual",
- enabled = dc.enabled,
- on_toggle = lambda v, d=dc: (setattr(d, "enabled", v), self.visibility_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
-
-
-# ══════════════════════════════════════════════════════════════════════════════
-# ChannelsWindow — main
-# ══════════════════════════════════════════════════════════════════════════════
-
-class ChannelsWindow(QWidget):
- pipeline_changed = pyqtSignal()
- derived_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.setWindowTitle("Channels")
- 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)
-
- hdr = QWidget(); hdr.setObjectName("devWindowTitleBar"); hdr.setFixedHeight(44)
- hl = QHBoxLayout(hdr); hl.setContentsMargins(14,0,14,0)
- hl.addWidget(QLabel("CHANNELS").also(lambda w: w.setObjectName("devWindowTitle")), 1)
- root.addWidget(hdr)
- div = QFrame(); div.setFrameShape(QFrame.Shape.HLine)
- div.setObjectName("devWindowDivider"); root.addWidget(div)
-
- self._pip_tab = PipelineTab(self.registry, self.processor)
- self._pip_tab.changed.connect(self.pipeline_changed)
- self._pip_tab.derived_changed.connect(self.derived_changed)
- root.addWidget(self._pip_tab, 1)
-
- self.refresh_derived()
-
- def refresh_derived(self):
- self._pip_tab.refresh_derived()
-
- def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool):
- self._pip_tab.on_channel_enabled_changed(dev_id, ch_id, enabled)
-
- def on_device_removed(self, dev_id: str):
- self._pip_tab.on_device_removed(dev_id)
-
- def on_device_reconfigured(self, dev_id: str):
- self._pip_tab.on_device_reconfigured(dev_id)
-
- def on_device_added(self):
- self._pip_tab.on_device_added()
-
- def on_channel_name_changed(self, dev_id: str, ch_id: str, ch_name: str):
- self._pip_tab.on_channel_name_changed(dev_id, ch_id, ch_name)
-
- def on_channel_unit_changed(self, dev_id: str, ch_id: str, unit: str):
- self._pip_tab.on_channel_unit_changed(dev_id, ch_id, unit)
-
- def closeEvent(self, e: QCloseEvent):
- self.closed.emit(); e.accept()