diff options
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/namespace_panel.py | 48 | ||||
| -rw-r--r-- | ui/windows/channels_window.py | 77 | ||||
| -rw-r--r-- | ui/windows/config_window.py | 4 | ||||
| -rw-r--r-- | ui/windows/plot_window.py | 40 |
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 |
