summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/namespace_panel.py48
-rw-r--r--ui/windows/channels_window.py77
-rw-r--r--ui/windows/config_window.py4
-rw-r--r--ui/windows/plot_window.py40
4 files changed, 134 insertions, 35 deletions
diff --git a/ui/namespace_panel.py b/ui/namespace_panel.py
index 7d8c42d..44750c6 100644
--- a/ui/namespace_panel.py
+++ b/ui/namespace_panel.py
@@ -28,10 +28,11 @@ from core.signal_processor import SignalProcessor
_VARS_POLL_MS = 400
_C_KIND = 0
-_C_NAME = 1
-_C_DIR = 2
-_C_VALUE = 3
-_C_SOURCE = 4
+_C_ID = 1
+_C_NAME = 2
+_C_DIR = 3
+_C_VALUE = 4
+_C_SOURCE = 5
def _fmt_value(v) -> str:
@@ -42,6 +43,16 @@ def _fmt_value(v) -> str:
return str(v)
+def _fmt_ref(dev_id: str, ch_id: str) -> str:
+ """Absolute reference for a source — device_id/channel_id for a Signal
+ (device_id is stable across machines even when the COM/tty port isn't,
+ see ProfileManager.apply()'s port re-detection), or the bare channel_id
+ for another Channel (derived channels have no device to qualify with)."""
+ if dev_id == "derived":
+ return ch_id
+ return f"{dev_id}/{ch_id}"
+
+
class NamespacePanel(QWidget):
def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
super().__init__()
@@ -73,13 +84,14 @@ class NamespacePanel(QWidget):
self._table = QTableWidget()
self._table.setObjectName("channelTable")
- self._table.setColumnCount(5)
+ self._table.setColumnCount(6)
self._table.setHorizontalHeaderLabels(
- ["Kind", "Name", "Dir", "Value", "Source"]
+ ["Kind", "ID", "Name", "Dir", "Value", "Source"]
)
hdr = self._table.horizontalHeader()
hdr.setSectionResizeMode(_C_SOURCE, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(_C_KIND, QHeaderView.ResizeMode.ResizeToContents)
+ hdr.setSectionResizeMode(_C_ID, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_NAME, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_DIR, QHeaderView.ResizeMode.ResizeToContents)
hdr.setSectionResizeMode(_C_VALUE, QHeaderView.ResizeMode.ResizeToContents)
@@ -122,23 +134,33 @@ class NamespacePanel(QWidget):
ch = dev.get_channel(ch_id) if dev else None
name = ch.name if ch else ch_id
is_wr = bool(ch and ch.writable)
- self._set_row(row, "Signal", name, "WRITE" if is_wr else "READ",
- None, f"{dev_id}/{ch_id}")
+ self._set_row(row, "Signal", ch_id, name, "WRITE" if is_wr else "READ",
+ None, _fmt_ref(dev_id, ch_id))
elif kind == "channel":
_, channel_id = key
dc = next((d for d in self.processor.get_derived()
if d.channel_id == channel_id), None)
name = dc.name if dc else channel_id
- src = dc.kind if dc else ""
- self._set_row(row, "Channel", name, "READ", None, src)
+ if dc and dc.sources:
+ # Absolute reference(s) this Channel is chained from — same
+ # format as another row's ID column, so a chain (Channel 1 ->
+ # Channel 2 -> Channel 3) can be traced by eye: Channel 3's
+ # Source literally matches Channel 2's ID.
+ src = ", ".join(_fmt_ref(d, c) for d, c in dc.sources)
+ else:
+ src = dc.kind if dc else ""
+ self._set_row(row, "Channel", channel_id, name, "READ", None, src)
else: # var
_, name = key
val = self.processor.get_var(name, None)
- self._set_row(row, "var", name, "R/W", val, "shared (vars)")
+ self._set_row(row, "var", name, name, "R/W", val, "shared (vars)")
- def _set_row(self, row: int, kind: str, name: str, direction: str,
- value, source: str):
+ def _set_row(self, row: int, kind: str, id_ref: str, name: str,
+ direction: str, value, source: str):
self._table.setItem(row, _C_KIND, QTableWidgetItem(kind))
+ id_item = QTableWidgetItem(id_ref)
+ id_item.setForeground(QColor("#94a3b8"))
+ self._table.setItem(row, _C_ID, id_item)
self._table.setItem(row, _C_NAME, QTableWidgetItem(name))
dir_item = QTableWidgetItem(direction)
dir_item.setForeground(_dir_color(direction))
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