From 23c669b3c6fb3bc805887cef00d6c37756e5c41e Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Wed, 3 Jun 2026 11:38:49 -0600 Subject: Sync Channels window with device/channel changes - Remove redundant Signals tab from Channels window; PipelineTab is now direct content - PipelineTab removes stale blocks when devices are removed or reconfigured - Devices>Signals tab refreshes automatically on device add/remove/configure - Channel name and unit edits in Devices>Signals propagate live to Channels pipeline block headers; missing blocks auto-added if channel is enabled Co-Authored-By: Claude Sonnet 4.6 --- ui/windows/signals_window.py | 289 ++++++++++++++++++++++++------------------- 1 file changed, 163 insertions(+), 126 deletions(-) (limited to 'ui/windows/signals_window.py') diff --git a/ui/windows/signals_window.py b/ui/windows/signals_window.py index 0bf9dba..eb146f9 100644 --- a/ui/windows/signals_window.py +++ b/ui/windows/signals_window.py @@ -143,6 +143,8 @@ class ChannelPipelineBlock(QFrame): 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 @@ -151,8 +153,8 @@ class ChannelPipelineBlock(QFrame): 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) + 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) @@ -198,6 +200,20 @@ class ChannelPipelineBlock(QFrame): 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) @@ -247,11 +263,12 @@ class ChannelPipelineBlock(QFrame): # ══════════════════════════════════════════════════════════════════════════════ -# Channels Tab (formerly Pipeline Tab) — user-curated channel list +# Pipeline Tab — filter pipelines per channel + virtual channel creation # ══════════════════════════════════════════════════════════════════════════════ class PipelineTab(QWidget): - changed = pyqtSignal() + changed = pyqtSignal() + derived_changed = pyqtSignal() def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): super().__init__() @@ -259,12 +276,14 @@ class PipelineTab(QWidget): 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): lay = QVBoxLayout(self); lay.setContentsMargins(0,0,0,0); lay.setSpacing(0) - # Add-channel bar at top + # Top bar: add physical channel + add virtual channel 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:")) @@ -273,6 +292,18 @@ class PipelineTab(QWidget): add_btn = QPushButton("+ Add Channel"); add_btn.setObjectName("addTraceBtn") add_btn.clicked.connect(self._on_add_channel) ab_lay.addWidget(add_btn) + + sep = QFrame(); sep.setFrameShape(QFrame.Shape.VLine) + sep.setObjectName("toolbarSep"); sep.setFixedWidth(6) + ab_lay.addWidget(sep) + + ab_lay.addWidget(QLabel("Virtual:")) + self._new_kind = QComboBox(); self._new_kind.setObjectName("channelPickerCb") + self._new_kind.addItems([_KIND_DISPLAY[k] for k in _ALL_KINDS]) + ab_lay.addWidget(self._new_kind) + add_virt = QPushButton("+ Add Virtual"); add_virt.setObjectName("addTraceBtn") + add_virt.clicked.connect(self._add_virtual) + ab_lay.addWidget(add_virt) lay.addWidget(add_bar) div = QFrame(); div.setFrameShape(QFrame.Shape.HLine) @@ -288,7 +319,6 @@ class PipelineTab(QWidget): scroll.setWidget(self._cont) lay.addWidget(scroll, 1) - # Populate with enabled device channels on startup for dev in self.registry.all_instances(): for ch in dev.info.channels: if ch.enabled: @@ -296,7 +326,7 @@ class PipelineTab(QWidget): self._refresh_avail_cb() - # ── Channel block management ────────────────────────────────────────────── + # ── 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: @@ -306,8 +336,9 @@ class PipelineTab(QWidget): block.changed.connect(self.changed) block.removed.connect(self._on_remove_block) self._blocks[(dev_id, ch_id)] = block - # Insert before the trailing stretch - self._inner.insertWidget(self._inner.count() - 1, block) + # Insert before derived blocks and trailing stretch + insert_at = self._inner.count() - 1 - len(self._derived_blocks) + self._inner.insertWidget(insert_at, block) def on_channel_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): key = (dev_id, ch_id) @@ -328,6 +359,48 @@ class PipelineTab(QWidget): 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: @@ -363,6 +436,44 @@ class PipelineTab(QWidget): 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"sig_{n}", name=f"Signal {n}", + kind=kind, color=color) + blk = self._make_derived_block(dc) + self._inner.insertWidget(self._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._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._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._inner.insertWidget(self._inner.count() - 1, blk) + + self._color_idx = len(self._derived_blocks) + # ══════════════════════════════════════════════════════════════════════════════ # Derived editor for one DerivedChannel @@ -544,12 +655,12 @@ class DerivedBlock(QFrame): # ══════════════════════════════════════════════════════════════════════════════ -# Signals Tab — flat unified list of all signals +# Signals List Tab — flat list of all signals with enable/disable # ══════════════════════════════════════════════════════════════════════════════ -class SignalsTab(QWidget): - changed = pyqtSignal() - channel_toggled = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled +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__() @@ -560,6 +671,7 @@ class SignalsTab(QWidget): 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) @@ -573,39 +685,37 @@ class SignalsTab(QWidget): 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(): 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, + 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.changed.emit(), + self.visibility_changed.emit(), self.channel_toggled.emit(d, ci, v), ), )) - # Thin divider before derived (only if both sections have entries) - if self.registry.all_instances() and self.processor.get_derived(): + 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) - # Derived signals - for dc in self.processor.get_derived(): + for dc in 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()), + 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() @@ -637,25 +747,21 @@ class SignalsTab(QWidget): # ══════════════════════════════════════════════════════════════════════════════ -# SignalsWindow — main +# ChannelsWindow — main # ══════════════════════════════════════════════════════════════════════════════ -class SignalsWindow(QWidget): - pipeline_changed = pyqtSignal() - derived_changed = pyqtSignal() - visibility_changed = pyqtSignal() - channel_toggled = pyqtSignal(str, str, bool) # dev_id, ch_id, enabled - closed = pyqtSignal() +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._derived_blocks: List[DerivedBlock] = [] - self._color_idx = 0 - self.setWindowTitle("Signals") + self.setWindowTitle("Channels") self.setMinimumSize(640, 520) self.resize(740, 640) self._build() @@ -663,109 +769,40 @@ class SignalsWindow(QWidget): 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) + 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) - tabs = QTabWidget(); tabs.setObjectName("signalBuilderTabs") - root.addWidget(tabs, 1) - - # 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, " Channels ") + self._pip_tab.derived_changed.connect(self.derived_changed) + root.addWidget(self._pip_tab, 1) - # Tab 2: Derived channels - tabs.addTab(self._build_derived_tab(), " Derived ") - - # Tab 3: Flat signals list - self._vis_tab = SignalsTab(self.registry, self.processor) - self._vis_tab.changed.connect(self.visibility_changed) - self._vis_tab.channel_toggled.connect(self._on_vis_tab_toggled) - 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) - - 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([_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() - 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 = _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"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 refresh_derived(self): + self._pip_tab.refresh_derived() - 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 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 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() + def on_device_removed(self, dev_id: str): + self._pip_tab.on_device_removed(dev_id) - 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) + def on_device_reconfigured(self, dev_id: str): + self._pip_tab.on_device_reconfigured(dev_id) - self._color_idx = len(self._derived_blocks) - self._vis_tab.refresh() + def on_device_added(self): + self._pip_tab.on_device_added() - def _on_vis_tab_toggled(self, dev_id: str, ch_id: str, enabled: bool): - """Signals tab toggled a channel — propagate to pipeline + chart without re-refreshing vis_tab.""" - self._pip_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) - self.channel_toggled.emit(dev_id, ch_id, enabled) + 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_enabled_changed(self, dev_id: str, ch_id: str, enabled: bool): - """Called from outside (Devices window) — update pipeline block and sync vis_tab checkboxes.""" - self._pip_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) - self._vis_tab.refresh() + 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() -- cgit v1.2.3