From 34c12b2b5b280206890cd03153b1c95116acc85e Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Thu, 13 Aug 2026 13:39:25 -0600 Subject: Channels tab drag-to-reorder, Namespace absolute references, fix stale Channel ID renames Generalized the Plot pane drag-to-reorder (_PaneDragHandle/_PlotDropContainer -> _DragHandle/_DragReorderContainer) and reused it for the Channels tab's derived-channel list; reordering also updates SignalProcessor's evaluation order (list order matters for chained Channels) via new reorder_derived(). Namespace panel: added an ID column (bare channel_id, matching the actual channels.get(...) script convention) and fixed the Source column for Channels to show resolved absolute references (dev_id/ch_id for a Signal, bare id for a chained Channel) instead of the operation kind, so a chain is traceable by eye. Fixed a real bug this surfaced: renaming a Channel's ID never propagated to other Channels' sources, leaving dangling ("derived", old_id) references that silently stopped updating. Added SignalProcessor.rename_derived() to fix up sources plus carry the ring buffer/latest-value cache to the new key, wired to DerivedBlock's Apply. Co-Authored-By: Claude Sonnet 5 --- ui/windows/channels_window.py | 77 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 3 deletions(-) (limited to 'ui/windows/channels_window.py') diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py index 2388b83..87d7840 100644 --- a/ui/windows/channels_window.py +++ b/ui/windows/channels_window.py @@ -38,6 +38,7 @@ from core.signal_processor import ( SignalProcessor, ChannelPipeline, DerivedChannel, FilterBase, FILTER_CLASSES, ) +from ui.windows.plot_window import _DragHandle, _DragReorderContainer _DERIVED_COLORS = [ "#f72585","#7209b7","#4361ee","#4cc9f0", @@ -473,7 +474,7 @@ class PipelineTab(QWidget): virt_scroll = QScrollArea(); virt_scroll.setWidgetResizable(True) virt_scroll.setObjectName("deviceScroll") virt_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) - self._virt_cont = QWidget() + self._virt_cont = _DragReorderContainer(self) self._virt_inner = QVBoxLayout(self._virt_cont) self._virt_inner.setContentsMargins(10, 10, 10, 10); self._virt_inner.setSpacing(6) self._virt_inner.addStretch() @@ -645,6 +646,8 @@ class PipelineTab(QWidget): blk = DerivedBlock(dc, self.registry, self.processor) blk.removed.connect(self._rm_virtual) blk.applied.connect(lambda _: self.derived_changed.emit()) + blk.id_renamed.connect(self.refresh_derived) + blk._drag_handle.set_drag_source(self, blk) self._derived_blocks.append(blk) return blk @@ -654,6 +657,56 @@ class PipelineTab(QWidget): self._virt_inner.removeWidget(blk); blk.deleteLater() self.derived_changed.emit() + # ── Drag-to-reorder — see _DragReorderContainer in ui/windows/plot_window.py + + def _index_of(self, block: DerivedBlock) -> int: + return self._derived_blocks.index(block) + + def _drop_index(self, y: float) -> int: + """Insertion index for a drop at container-relative y.""" + n = self._virt_inner.count() - 1 # exclude trailing stretch + for i in range(n): + w = self._virt_inner.itemAt(i).widget() + if y < w.y() + w.height() / 2: + return i + return n + + def _indicator_y_for(self, idx: int) -> int: + """Container-relative y for the drop-line at insertion index idx.""" + n = self._virt_inner.count() - 1 # exclude trailing stretch + spacing = self._virt_inner.spacing() + if n == 0: + return self._virt_inner.contentsMargins().top() + if idx <= 0: + w = self._virt_inner.itemAt(0).widget() + return w.y() - spacing // 2 + if idx >= n: + w = self._virt_inner.itemAt(n - 1).widget() + return w.y() + w.height() + spacing // 2 + prev = self._virt_inner.itemAt(idx - 1).widget() + nxt = self._virt_inner.itemAt(idx).widget() + return (prev.y() + prev.height() + nxt.y()) // 2 + + def _reorder(self, src_idx: int, dst_idx: int): + """Move the Channel at src_idx to dst_idx (drop-target index, + pre-removal) — same semantics as ControlPanel._reorder(). Also + reorders processor._derived so evaluation order stays matched to + the visual order: a Channel sourced from another Channel needs that + source evaluated first in the same pass (SignalProcessor. + _evaluate_derived), or it lags by one sample.""" + if dst_idx > src_idx: + dst_idx -= 1 + if dst_idx == src_idx: + return + + blk = self._derived_blocks.pop(src_idx) + self._derived_blocks.insert(dst_idx, blk) + self._virt_inner.removeWidget(blk) + self._virt_inner.insertWidget(dst_idx, blk) + + self.processor.reorder_derived(src_idx, dst_idx) + self.derived_changed.emit() + def refresh_derived(self): """Rebuild DerivedBlock widgets from processor state (called after profile load).""" for blk in self._derived_blocks: @@ -754,8 +807,10 @@ def _sub_xrefs(code: str, names: list) -> str: class DerivedBlock(QFrame): - removed = pyqtSignal(object) - applied = pyqtSignal(object) # DerivedChannel + removed = pyqtSignal(object) + applied = pyqtSignal(object) # DerivedChannel + id_renamed = pyqtSignal() # this Channel's ID changed — other blocks' + # source pickers may now be stale def __init__(self, dc: DerivedChannel, registry: DeviceRegistry, processor: SignalProcessor): @@ -764,6 +819,7 @@ class DerivedBlock(QFrame): self.setObjectName("plotBlock") self._src_combos: List[QComboBox] = [] self._expanded = False + self._orig_id = dc.channel_id self._build() def _build(self): @@ -772,6 +828,8 @@ class DerivedBlock(QFrame): # Header hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32) hl = QHBoxLayout(hdr); hl.setContentsMargins(8, 0, 6, 0) + self._drag_handle = _DragHandle() + hl.addWidget(self._drag_handle) 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) @@ -1133,6 +1191,14 @@ class DerivedBlock(QFrame): self._st.setText(f"⚠ ID '{channel_id}' is already used by another channel") self._st.setStyleSheet("color:#ef4444;") return + renamed = channel_id != self._orig_id + if renamed: + # Other channels sourced from this one under the old ID would + # otherwise keep pointing at a reference that no longer exists — + # fix their sources up front, carry the ring buffer + latest + # value over so the rename doesn't reset their plotted history. + self.processor.rename_derived(self._orig_id, channel_id) + self._orig_id = channel_id 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" @@ -1149,6 +1215,11 @@ class DerivedBlock(QFrame): elif self.dc.kind in ("function", "custom_script"): self.dc.script = self._code.toPlainText() err = self.processor.add_derived(self.dc) + # The rename above already committed regardless of whether this + # channel's own expression/script compiles — other blocks' source + # pickers need to refresh either way. + if renamed: + self.id_renamed.emit() if err: self._st.setText(f"⚠ {err[:100]}") self._st.setStyleSheet("color:#ef4444;") -- cgit v1.2.3