summaryrefslogtreecommitdiff
path: root/ui/windows
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-08-13 13:39:25 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-08-13 13:39:25 -0600
commit34c12b2b5b280206890cd03153b1c95116acc85e (patch)
tree9b3dee7e0818d94c0bae87a35e7bf930a612ca2c /ui/windows
parenta6e812dcbf3f2fc27565e52b6c0c6f9b7e4363ee (diff)
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 <noreply@anthropic.com>
Diffstat (limited to 'ui/windows')
-rw-r--r--ui/windows/channels_window.py77
-rw-r--r--ui/windows/config_window.py4
-rw-r--r--ui/windows/plot_window.py40
3 files changed, 99 insertions, 22 deletions
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;")
diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py
index 3bd3dc6..34cc789 100644
--- a/ui/windows/config_window.py
+++ b/ui/windows/config_window.py
@@ -34,7 +34,7 @@ from ui.windows.devices_window import DeviceRow, ChannelsTab
from ui.windows.channels_window import PipelineTab, _slug_id, _dedupe_name
from ui.windows.plot_window import (
PlotWindow, LayoutConfig, LayoutCanvas, PaneBlock,
- build_default_layout, _PlotDropContainer,
+ build_default_layout, _DragReorderContainer,
)
from ui.namespace_panel import NamespacePanel
@@ -242,7 +242,7 @@ class _PlotTab(PlotWindow):
scroll = QScrollArea(); scroll.setWidgetResizable(True)
scroll.setObjectName("deviceScroll")
- self._cont = _PlotDropContainer(self)
+ self._cont = _DragReorderContainer(self)
self._blay = QVBoxLayout(self._cont)
self._blay.setContentsMargins(10, 10, 10, 10); self._blay.setSpacing(12)
self._blay.addStretch()
diff --git a/ui/windows/plot_window.py b/ui/windows/plot_window.py
index 93ce776..e810365 100644
--- a/ui/windows/plot_window.py
+++ b/ui/windows/plot_window.py
@@ -377,42 +377,45 @@ class TraceChannelPickerDialog(QDialog):
# ══════════════════════════════════════════════════════════════════════════════
# Drag-to-reorder — same mechanics as ControlPanel (ui/control_panel.py):
# a small drag handle starts a QDrag carrying the source index, the config
-# list container accepts the drop and reorders. The pane title here is an
-# editable QLineEdit (unlike ControlPanel's static label), so the whole
-# header can't be made transparent-for-mouse — a dedicated grip is used
-# instead of the whole header.
+# list container accepts the drop and reorders. Reused by PipelineTab
+# (ui/windows/channels_window.py) for the Channels list — owner just needs
+# to implement _index_of / _drop_index / _indicator_y_for / _reorder.
+# A dedicated grip is used instead of making the whole header a drag
+# source (unlike ControlPanel) because these headers hold an editable
+# QLineEdit title, which needs real mouse focus to edit.
# ══════════════════════════════════════════════════════════════════════════════
-class _PaneDragHandle(QLabel):
+class _DragHandle(QLabel):
def __init__(self):
super().__init__("⠿")
self._owner = None
- self._block = None
+ self._item = None
self.setObjectName("paneDragHandle")
self.setFixedWidth(18)
self.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.setCursor(Qt.CursorShape.OpenHandCursor)
self.setToolTip("Drag to reorder")
- def set_drag_source(self, owner: "PlotWindow", block: "PaneBlock"):
+ def set_drag_source(self, owner, item):
self._owner = owner
- self._block = block
+ self._item = item
def mousePressEvent(self, event):
if event.button() == Qt.MouseButton.LeftButton and self._owner is not None:
drag = QDrag(self)
mime = QMimeData()
- mime.setText(str(self._owner._blocks.index(self._block)))
+ mime.setText(str(self._owner._index_of(self._item)))
drag.setMimeData(mime)
drag.exec(Qt.DropAction.MoveAction)
super().mousePressEvent(event)
-class _PlotDropContainer(QWidget):
- """Holds the pane config blocks; drops reorder them — same drag-to-reorder
- mechanics as ControlPanel's _DropContainer."""
+class _DragReorderContainer(QWidget):
+ """Holds a vertical list of config blocks; drops reorder them — same
+ drag-to-reorder mechanics as ControlPanel's _DropContainer. Owner must
+ implement _drop_index(y), _indicator_y_for(idx), _reorder(src, dst)."""
- def __init__(self, owner: "PlotWindow"):
+ def __init__(self, owner):
super().__init__()
self._owner = owner
self._indicator_y = None
@@ -438,7 +441,7 @@ class _PlotDropContainer(QWidget):
src_idx = int(event.mimeData().text())
except ValueError:
return
- self._owner._reorder_panes(src_idx, self._owner._drop_index(event.position().y()))
+ self._owner._reorder(src_idx, self._owner._drop_index(event.position().y()))
event.acceptProposedAction()
def _show_indicator(self, y: float):
@@ -474,7 +477,7 @@ class PaneBlock(QFrame):
hdr = QWidget(); hdr.setObjectName("plotBlockHeader"); hdr.setFixedHeight(32)
hl = QHBoxLayout(hdr); hl.setContentsMargins(8,0,6,0); hl.setSpacing(4)
- self._drag_handle = _PaneDragHandle()
+ self._drag_handle = _DragHandle()
hl.addWidget(self._drag_handle)
self._title = QLineEdit(self.spec.title); self._title.setObjectName("plotBlockTitle")
self._title.textChanged.connect(lambda t: (
@@ -1075,7 +1078,7 @@ class PlotWindow(QWidget):
scroll = QScrollArea(); scroll.setWidgetResizable(True)
scroll.setObjectName("deviceScroll")
- self._cont = _PlotDropContainer(self)
+ self._cont = _DragReorderContainer(self)
self._blay = QVBoxLayout(self._cont)
self._blay.setContentsMargins(10,10,10,10); self._blay.setSpacing(12)
self._blay.addStretch()
@@ -1173,7 +1176,10 @@ class PlotWindow(QWidget):
nxt = self._blay.itemAt(idx).widget()
return (prev.y() + prev.height() + nxt.y()) // 2
- def _reorder_panes(self, src_idx: int, dst_idx: int):
+ def _index_of(self, block: "PaneBlock") -> int:
+ return self._blocks.index(block)
+
+ def _reorder(self, src_idx: int, dst_idx: int):
"""Move the pane at src_idx to dst_idx (drop-target index, pre-removal)
— same semantics as ControlPanel._reorder(). Also walks the BSP tree's
leaf pane-index labels through the same chain of adjacent swaps so