From 006366ef8099c8313ae596bf6267e9f98dfbeb5e Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Sun, 20 Sep 2026 22:30:13 -0600 Subject: Make Channel the single source of truth for a reading's name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Device tab stays hardware-only (pins, wiring, may vary per workstation); the Channels window owns the profile-facing identity. Concretely: - Fix a real regression: SerialDevice generic-format Apply & Reconnect nulled out custom channel_names/units every time (no fallback, unlike every other optional field in reconfigure()), silently reverting to sig0/sig1/... ids. - Plot legend now live-tracks the current Signal/Channel name instead of freezing tr.label at add-time, so a rename shows up without re-adding the trace. - Add an inline quick-reconnect (combo + button, pre-selects the actual current source) directly on each Channel block in the Channels window, for the common single-source case — rewiring hardware no longer means touching every Plot/pipeline that referenced it. - Collapse the Channel ID field behind an "ID ▸" advanced toggle; channel_id is already auto-derived from the name (_slug_id) and rarely needs to be seen. - Devices tab's Signal "Name" column relabeled "Label" with a tooltip — de-emphasized now that Channel name is the real identity. - Move "create a Channel from this Signal" from a column on the Devices tab's signal table to a ⊕ button on each Signal row in the Channels tab's Namespace panel — the action now lives where the identity is created, not on the hardware tab. Only that instance opts in (on_create_channel); the read-only Namespace dock on the main window is unaffected. - Floor the Namespace panel's width at its columns' natural size so dragging the Channels-tab splitter narrower can't push the ⊕ column behind the horizontal scrollbar. - Breadcrumb log when a profile-saved Signal override can't be applied because the signal_id no longer exists. Co-Authored-By: Claude Sonnet 5 --- ui/namespace_panel.py | 55 +++++++++++++++++--- ui/strip_chart.py | 15 +++++- ui/windows/channels_window.py | 114 +++++++++++++++++++++++++++++++++++++----- ui/windows/config_window.py | 93 +++++++++++++++------------------- ui/windows/devices_window.py | 8 ++- 5 files changed, 210 insertions(+), 75 deletions(-) (limited to 'ui') diff --git a/ui/namespace_panel.py b/ui/namespace_panel.py index 9616db7..e2b8809 100644 --- a/ui/namespace_panel.py +++ b/ui/namespace_panel.py @@ -5,19 +5,24 @@ NAMESPACE panel — dockable, live browser of everything referenceable in a script: Signals (raw device endpoints), Channels (processed/derived values), and vars (the shared dict bridging Control Panel On Action Scripts and Channel scripts). Read-only reference, not an editor — editing -still happens in Configuration > Channels. +still happens in Configuration > Channels — with one narrow exception: +when constructed with `on_create_channel`, each Signal row gets a ⊕ +button to create a passthrough Channel from it directly. Only +ConfigWindow's Channels-tab instance opts into this (config_window.py); +the dockable instance on the main window (ui/main_window.py) does not +pass a callback, so it stays exactly the plain read-only reference. Docked opposite the Control Panel (ui/main_window.py), built the same way: a QDockWidget you can drag, float, redock, or close. """ from __future__ import annotations -from typing import Dict, Tuple +from typing import Callable, Dict, Optional, Tuple from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, - QApplication, + QToolButton, QApplication, ) from PyQt6.QtCore import QTimer, pyqtSlot from PyQt6.QtGui import QColor @@ -33,6 +38,7 @@ _C_NAME = 2 _C_DIR = 3 _C_VALUE = 4 _C_SOURCE = 5 +_C_CREATE = 6 # only present when on_create_channel is set def _fmt_value(v) -> str: @@ -54,10 +60,14 @@ def _fmt_ref(dev_id: str, ch_id: str) -> str: class NamespacePanel(QWidget): - def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor, + on_create_channel: Optional[Callable[[str, str, str, str, str], None]] = None): super().__init__() self.registry = registry self.processor = processor + # (dev_id, ch_id, name, unit, color) -> None, called when a Signal + # row's ⊕ button is clicked. None hides the column entirely. + self.on_create_channel = on_create_channel # row key -> row index. Keys: # ("signal", dev_id, ch_id) | ("channel", channel_id) | ("var", name) self._row_index: Dict[Tuple, int] = {} @@ -84,10 +94,11 @@ class NamespacePanel(QWidget): self._table = QTableWidget() self._table.setObjectName("channelTable") - self._table.setColumnCount(6) - self._table.setHorizontalHeaderLabels( - ["Kind", "ID", "Name", "Dir", "Value", "Source"] - ) + labels = ["Kind", "ID", "Name", "Dir", "Value", "Source"] + if self.on_create_channel: + labels.append("") + self._table.setColumnCount(len(labels)) + self._table.setHorizontalHeaderLabels(labels) hdr = self._table.horizontalHeader() hdr.setSectionResizeMode(_C_SOURCE, QHeaderView.ResizeMode.Stretch) hdr.setSectionResizeMode(_C_KIND, QHeaderView.ResizeMode.ResizeToContents) @@ -95,6 +106,8 @@ class NamespacePanel(QWidget): hdr.setSectionResizeMode(_C_NAME, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(_C_DIR, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(_C_VALUE, QHeaderView.ResizeMode.ResizeToContents) + if self.on_create_channel: + hdr.setSectionResizeMode(_C_CREATE, QHeaderView.ResizeMode.ResizeToContents) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers) self._table.setAlternatingRowColors(True) @@ -102,6 +115,17 @@ class NamespacePanel(QWidget): self._table.cellDoubleClicked.connect(self._on_double_clicked) lay.addWidget(self._table) + def _apply_min_width(self): + """Floor the panel's width at the sum of its columns' natural sizes + (Source gets a fixed placeholder rather than its live stretched + width, which would be circular) — so dragging the Channels-tab + splitter divider narrower can never push the ⊕ create-channel + column (or any other column) behind the horizontal scrollbar.""" + total = 0 + for i in range(self._table.columnCount()): + total += 80 if i == _C_SOURCE else self._table.columnWidth(i) + self.setMinimumWidth(total + 24) + # ── Row-set (structural) rebuild — devices/channels/vars added or removed ── def _desired_keys(self): @@ -125,9 +149,13 @@ class NamespacePanel(QWidget): for row, key in enumerate(keys): self._row_index[key] = row self._paint_row(row, key) + self._apply_min_width() def _paint_row(self, row: int, key: Tuple): kind = key[0] + if self.on_create_channel: + self._table.removeCellWidget(row, _C_CREATE) # row's kind may have + # changed since last paint if kind == "signal": _, dev_id, ch_id = key dev = self.registry.get_instance(dev_id) @@ -136,6 +164,17 @@ class NamespacePanel(QWidget): is_wr = bool(ch and ch.writable) self._set_row(row, "Signal", ch_id, name, "WRITE" if is_wr else "READ", None, _fmt_ref(dev_id, ch_id)) + if self.on_create_channel and ch is not None: + btn = QToolButton() + btn.setText("⊕") + btn.setObjectName("addTraceBtn") + btn.setFixedSize(24, 22) + btn.setToolTip("Create a Channel from this Signal") + btn.clicked.connect( + lambda _=False, d=dev_id, c=ch_id, n=ch.name, u=ch.unit, col=ch.color: + self.on_create_channel(d, c, n, u, col) + ) + self._table.setCellWidget(row, _C_CREATE, btn) elif kind == "channel": _, channel_id = key dc = next((d for d in self.processor.get_derived() diff --git a/ui/strip_chart.py b/ui/strip_chart.py index b295a8c..e77c092 100644 --- a/ui/strip_chart.py +++ b/ui/strip_chart.py @@ -180,7 +180,7 @@ class StripChartWidget(QWidget): pen = pg.mkPen(color="#6b7280" if missing else tr.color, width=tr.width, style=Qt.PenStyle.DashLine if missing else _STYLES.get(tr.style, Qt.PenStyle.SolidLine)) - label = tr.label or tr.channel_id + label = tr.label or self._lookup_channel_name(tr.device_id, tr.channel_id) curve = plot.plot([], [], pen=pen, name=f"{label} (missing)" if missing else label) key = (tr.device_id, tr.channel_id) @@ -216,6 +216,19 @@ class StripChartWidget(QWidget): dev = self.registry.get_instance(device_id) return dev is None or dev.get_signal(channel_id) is None + def _lookup_channel_name(self, device_id: str, channel_id: str) -> str: + """Live display name for a trace with no user-typed label, so a + Signal/Channel rename shows up on the chart without re-adding the + trace — mirrors _lookup_channel_unit().""" + if device_id == "derived": + for dc in self.processor.get_derived(): + if dc.channel_id == channel_id: + return dc.name + return channel_id + dev = self.registry.get_instance(device_id) + ch = dev.get_signal(channel_id) if dev else None + return (ch.name if ch and ch.name else channel_id) + def _lookup_channel_unit(self, device_id: str, channel_id: str) -> str: if device_id == "derived": for dc in self.processor.get_derived(): diff --git a/ui/windows/channels_window.py b/ui/windows/channels_window.py index 31d1458..cc83b3b 100644 --- a/ui/windows/channels_window.py +++ b/ui/windows/channels_window.py @@ -431,8 +431,18 @@ class PipelineTab(QWidget): phys_bar = QWidget(); phys_bar.setObjectName("cfgGlobalBar") pb_lay = QHBoxLayout(phys_bar); pb_lay.setContentsMargins(10, 7, 10, 7); pb_lay.setSpacing(6) pb_lbl = QLabel("Signals"); pb_lbl.setObjectName("devWindowTitle") - pb_lay.addWidget(pb_lbl, 1) + pb_lay.addWidget(pb_lbl) + self._src_cb = _channel_combo(self.registry, self.processor, include_derived=True) + self._src_cb.setObjectName("channelPickerCb") + self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None) + self._src_cb.setCurrentIndex(0) + pb_lay.addWidget(self._src_cb, 1) + add_virt = QPushButton("+ New Channel"); add_virt.setObjectName("addTraceBtn") + add_virt.setToolTip("Create a Channel from the selected Signal") + add_virt.clicked.connect(self._add_virtual) + pb_lay.addWidget(add_virt) add_btn = QPushButton("+ Add"); add_btn.setObjectName("addTraceBtn") + add_btn.setToolTip("Add an existing Signal to this list") add_btn.clicked.connect(self._on_add_channel) pb_lay.addWidget(add_btn) phys_l.addWidget(phys_bar) @@ -458,15 +468,7 @@ class PipelineTab(QWidget): virt_bar = QWidget(); virt_bar.setObjectName("cfgGlobalBar") vb_lay = QHBoxLayout(virt_bar); vb_lay.setContentsMargins(10, 7, 10, 7); vb_lay.setSpacing(6) vb_lbl = QLabel("Channels"); vb_lbl.setObjectName("devWindowTitle") - vb_lay.addWidget(vb_lbl) - self._src_cb = _channel_combo(self.registry, self.processor, include_derived=True) - self._src_cb.setObjectName("channelPickerCb") - self._src_cb.insertItem(0, "Signal: none (empty channel)", userData=None) - self._src_cb.setCurrentIndex(0) - vb_lay.addWidget(self._src_cb, 1) - add_virt = QPushButton("+ Add Channel"); add_virt.setObjectName("addTraceBtn") - add_virt.clicked.connect(self._add_virtual) - vb_lay.addWidget(add_virt) + vb_lay.addWidget(vb_lbl, 1) virt_l.addWidget(virt_bar) div2 = QFrame(); div2.setFrameShape(QFrame.Shape.HLine) @@ -861,15 +863,45 @@ class ChannelBlock(QFrame): outer.addWidget(hdr) hdr.mousePressEvent = lambda e: self._toggle() + # Subtitle — always visible (collapsed or expanded): what this + # Channel is currently wired to, plus a one-click rebind for the + # common single-source case. Lets a rewire be fixed (or a channel + # proactively repointed at a different Signal) without expanding + # the block or hunting for the per-source combo in the editor body. + sub = QWidget(); sub.setObjectName("plotBlockSubtitle") + sub_lay = QHBoxLayout(sub) + sub_lay.setContentsMargins(30, 0, 6, 4); sub_lay.setSpacing(6) + self._src_lbl = QLabel(""); self._src_lbl.setObjectName("traceSource") + sub_lay.addWidget(self._src_lbl, 1) + self._reconnect_cb = _channel_combo(self.registry, include_derived=False) + self._reconnect_cb.setFixedWidth(220) + sub_lay.addWidget(self._reconnect_cb) + self._reconnect_btn = QPushButton("↺ Reconnect") + self._reconnect_btn.setObjectName("addTraceBtn") + self._reconnect_btn.setToolTip("Point this channel at a different physical Signal") + self._reconnect_btn.clicked.connect(self._do_reconnect) + sub_lay.addWidget(self._reconnect_btn) + outer.addWidget(sub) + # Body body = QWidget(); body.setObjectName("plotBlockSettings") bl = QVBoxLayout(body); bl.setContentsMargins(10, 8, 10, 10); bl.setSpacing(6) - # ID / unit row + # ID / unit row — the id is auto-derived from the name at creation + # (_slug_id()) and normally never needs to be seen, let alone typed; + # tucked behind a toggle so Name is the only identity field most + # users ever interact with. meta = QHBoxLayout() - meta.addWidget(QLabel("ID:")) + self._id_toggle = QToolButton(); self._id_toggle.setText("ID ▸") + self._id_toggle.setObjectName("traceRemoveBtn"); self._id_toggle.setCheckable(True) + self._id_toggle.setToolTip(f"Technical id: {self.dc.channel_id} — only needed to " + f"reference this channel from an expression/script by id, " + f"or to rename it. Click to edit.") + self._id_toggle.toggled.connect(self._toggle_id_box) + meta.addWidget(self._id_toggle) self._id = QLineEdit(self.dc.channel_id) self._id.setObjectName("traceLabel"); self._id.setFixedWidth(100) + self._id.setVisible(False) self._id.textChanged.connect(lambda t: setattr(self.dc, "channel_id", t)) meta.addWidget(self._id) meta.addWidget(QLabel("Unit:")) @@ -1023,6 +1055,58 @@ class ChannelBlock(QFrame): self._update_vars_hint() self._update_visibility() self.refresh_missing_sources() + self._refresh_source_subtitle() + + def _refresh_source_subtitle(self): + """Update the always-visible '← wired-to' line and show/hide the + inline quick-reconnect control — only offered for the unambiguous + single-source case; multi-source channels use the per-source combos + in the expanded editor instead.""" + names = [f"{_channel_display_name(self.registry, self.processor, d, c)} ({d}/{c})" + for d, c in self.dc.sources] + self._src_lbl.setText("← " + ", ".join(names) if names else "no source") + single = len(self.dc.sources) == 1 + self._reconnect_cb.setVisible(single) + self._reconnect_btn.setVisible(single) + # Pre-select the actual current source — otherwise the combo always + # shows whatever landed first in registry iteration order, which + # looks like that's what's connected even when it isn't. + if single: + cur = self.dc.sources[0] + self._reconnect_cb.blockSignals(True) + for i in range(self._reconnect_cb.count()): + if self._reconnect_cb.itemData(i) == cur: + self._reconnect_cb.setCurrentIndex(i) + break + else: + self._reconnect_cb.setCurrentIndex(-1) + self._reconnect_cb.blockSignals(False) + + def _do_reconnect(self): + """Inline one-click rebind: point this channel's sole source at + whatever Signal is currently selected in the quick-reconnect combo — + no need to expand the block or use the per-source editor combos.""" + new_src = self._reconnect_cb.currentData() + if not new_src or len(self.dc.sources) != 1: + return + self.dc.sources = [new_src] + self.dc.source_names = [_channel_display_name(self.registry, self.processor, *new_src)] + if self._src_combos: + cb = self._src_combos[0] + for i in range(cb.count()): + if cb.itemData(i) == new_src: + cb.setCurrentIndex(i) + break + err = self.processor.add_derived(self.dc) + self._refresh_source_subtitle() + self.refresh_missing_sources() + if err: + self._st.setText(f"⚠ {err[:100]}") + self._st.setStyleSheet("color:#ef4444;") + else: + self._st.setText("✓ Reconnected") + self._st.setStyleSheet("color:#22c55e;") + self.applied.emit(self.dc) def refresh_missing_sources(self): """Update the header warning icon for a source signal that no longer @@ -1046,6 +1130,10 @@ class ChannelBlock(QFrame): self._body.setVisible(self._expanded) self._toggle_btn.setText("▼" if self._expanded else "▶") + def _toggle_id_box(self, checked: bool): + self._id.setVisible(checked) + self._id_toggle.setText("ID ▾" if checked else "ID ▸") + def _add_src(self, src=None): row = QHBoxLayout() cb = _channel_combo(self.registry, self.processor, @@ -1247,6 +1335,8 @@ class ChannelBlock(QFrame): elif self.dc.kind in ("function", "custom_script"): self.dc.script = self._code.toPlainText() err = self.processor.add_derived(self.dc) + self._refresh_source_subtitle() + self.refresh_missing_sources() # Emitted regardless of whether this channel's own expression/script # compiles — propagate_rename() (via id_renamed) still needs to run # so other channels' sources / plot traces / controls don't dangle. diff --git a/ui/windows/config_window.py b/ui/windows/config_window.py index 62789f1..7ec0355 100644 --- a/ui/windows/config_window.py +++ b/ui/windows/config_window.py @@ -5,10 +5,15 @@ Unified Configuration window — Devices | Channels | Plot in one tabbed window. Devices tab layout (2 columns): Left — physical devices (add/configure/remove) - Right — signal table (Device / Signal ID / Dir / On / Name / Unit / Min / Max) + Right — signal table (Device / Signal ID / Dir / On / Label / Unit / Min / Max) Channels tab layout (2 columns): - Left — Namespace browser (read-only reference: Signals / Channels / vars) + Left — Namespace browser (Signals / Channels / vars). Mostly a read-only + reference, with one exception: each Signal row has a ⊕ button to + create a passthrough Channel from it directly — the quick "make + this Signal a Channel" path. See ui/namespace_panel.py — only + this instance opts into that button (on_create_channel), not the + read-only dockable Namespace panel on the main window. Right — derived / virtual channels — operations (filters/calibration/math/script) are configured per Channel here, not on the Signals side. """ @@ -18,7 +23,7 @@ from copy import deepcopy from typing import List, Optional, Set, Tuple from PyQt6.QtWidgets import ( - QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QToolButton, + QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QScrollArea, QFrame, QTabWidget, QTableWidget, QTableWidgetItem, QHeaderView, QCheckBox, QMessageBox, QAbstractItemView, QSplitter, QDoubleSpinBox, @@ -55,32 +60,31 @@ class _SignalsTab(SignalsTab): _C_UNIT = 5 _C_MIN = 6 _C_MAX = 7 - _C_MKCH = 8 # "+ Channel" button - channel_created = pyqtSignal() channel_renamed = pyqtSignal(str, str, str, str) # old_dev, old_id, new_dev, new_id - def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): - self._processor = processor - super().__init__(registry) - def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) self._table = QTableWidget() self._table.setObjectName("channelTable") - self._table.setColumnCount(9) + self._table.setColumnCount(8) self._table.setHorizontalHeaderLabels( - ["Device", "Signal ID", "Dir", "On", "Name", "Unit", "Min", "Max", ""] + ["Device", "Signal ID", "Dir", "On", "Label", "Unit", "Min", "Max"] ) + name_hdr_item = self._table.horizontalHeaderItem(self._C_NAME) + if name_hdr_item is not None: + name_hdr_item.setToolTip( + "Technical label only — for the profile-facing name, create a Channel " + "in the Channels window." + ) hdr = self._table.horizontalHeader() hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch) hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_CH_ID, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_DIR, QHeaderView.ResizeMode.ResizeToContents) hdr.setSectionResizeMode(self._C_ENABLED, QHeaderView.ResizeMode.ResizeToContents) - hdr.setSectionResizeMode(self._C_MKCH, QHeaderView.ResizeMode.ResizeToContents) self._table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows) self._table.setAlternatingRowColors(True) self._table.verticalHeader().setVisible(False) @@ -139,25 +143,6 @@ class _SignalsTab(SignalsTab): self._table.setItem(row, self._C_MIN, QTableWidgetItem(str(ch.min_value))) self._table.setItem(row, self._C_MAX, QTableWidgetItem(str(ch.max_value))) - mkch_w = QWidget() - mkch_l = QHBoxLayout(mkch_w) - mkch_l.setContentsMargins(2, 0, 2, 0) - mkch_l.setAlignment(Qt.AlignmentFlag.AlignCenter) - mkch_btn = QToolButton() - mkch_btn.setText("⊕") - mkch_btn.setObjectName("addTraceBtn") - mkch_btn.setFixedSize(24, 22) - mkch_btn.setToolTip("Create a Channel from this Signal") - d_cap, c_cap, n_cap, u_cap, col_cap = ( - dev.info.device_id, ch.signal_id, ch.name, ch.unit, ch.color - ) - mkch_btn.clicked.connect( - lambda _=False, d=d_cap, c=c_cap, n=n_cap, u=u_cap, col=col_cap: - self._on_create_channel(d, c, n, u, col) - ) - mkch_l.addWidget(mkch_btn) - self._table.setCellWidget(row, self._C_MKCH, mkch_w) - row += 1 self._table.blockSignals(False) @@ -205,25 +190,6 @@ class _SignalsTab(SignalsTab): name_item.setData(Qt.ItemDataRole.UserRole, (dev_id, new_id)) self.channel_renamed.emit(dev_id, old_id, dev_id, new_id) - def _on_create_channel(self, dev_id: str, ch_id: str, ch_name: str, unit: str, color: str): - """Create a passthrough Channel (kind="none") from this Signal, - named after it — the "1 Signal -> 1 Channel" quick path. No-ops if - a passthrough Channel for this exact Signal already exists.""" - existing = self._processor.get_derived() - for dc in existing: - if dc.kind == "none" and dc.sources == [(dev_id, ch_id)]: - return - existing_names = {d.name for d in existing} - existing_ids = {d.channel_id for d in existing} - name = _dedupe_name(ch_name, existing_names) - channel_id = _slug_id(name, existing_ids) - dc = Channel(channel_id=channel_id, name=name, kind="none", - sources=[(dev_id, ch_id)], source_names=[ch_name], - unit=unit, color=color or "#00d4ff") - err = self._processor.add_derived(dc) - if err is None: - self.channel_created.emit() - # ══════════════════════════════════════════════════════════════════════════════ # Plot tab — PlotWindow embedded without floating chrome @@ -401,7 +367,7 @@ class ConfigWindow(QWidget): sig_div = QFrame(); sig_div.setFrameShape(QFrame.Shape.HLine) sig_div.setObjectName("devWindowDivider"); sig_l.addWidget(sig_div) - self._signals_tab = _SignalsTab(self.registry, self.processor) + self._signals_tab = _SignalsTab(self.registry) self._signals_tab.channel_visibility_changed.connect(self._on_sig_visibility) self._signals_tab.channel_name_changed.connect(self._on_sig_name) self._signals_tab.channel_unit_changed.connect(self._on_sig_unit) @@ -505,7 +471,8 @@ class ConfigWindow(QWidget): ns_div = QFrame(); ns_div.setFrameShape(QFrame.Shape.HLine) ns_div.setObjectName("devWindowDivider"); ns_l.addWidget(ns_div) - self._namespace_panel = NamespacePanel(self.registry, self.processor) + self._namespace_panel = NamespacePanel(self.registry, self.processor, + on_create_channel=self._on_create_channel) self.processor.processed_data.connect(self._namespace_panel.on_processed_data) ns_l.addWidget(self._namespace_panel, 1) splitter.addWidget(ns_w) @@ -521,7 +488,6 @@ class ConfigWindow(QWidget): if inner_splitters: inner_splitters[0].widget(0).setVisible(False) splitter.addWidget(self._pipeline_tab) - self._signals_tab.channel_created.connect(self._pipeline_tab.refresh_derived) # Channels side larger by default splitter.setStretchFactor(0, 1) @@ -529,6 +495,27 @@ class ConfigWindow(QWidget): self.tabs.addTab(w, " Channels ") + def _on_create_channel(self, dev_id: str, ch_id: str, ch_name: str, unit: str, color: str): + """Create a passthrough Channel (kind="none") from this Signal, + named after it — the "1 Signal -> 1 Channel" quick path, triggered + from the ⊕ button on a Signal row in the Channels tab's Namespace + panel (ui/namespace_panel.py). No-ops if a passthrough Channel for + this exact Signal already exists.""" + existing = self.processor.get_derived() + for dc in existing: + if dc.kind == "none" and dc.sources == [(dev_id, ch_id)]: + return + existing_names = {d.name for d in existing} + existing_ids = {d.channel_id for d in existing} + name = _dedupe_name(ch_name, existing_names) + channel_id = _slug_id(name, existing_ids) + dc = Channel(channel_id=channel_id, name=name, kind="none", + sources=[(dev_id, ch_id)], source_names=[ch_name], + unit=unit, color=color or "#00d4ff") + err = self.processor.add_derived(dc) + if err is None: + self._pipeline_tab.refresh_derived() + def _on_sig_visibility(self, dev_id: str, ch_id: str, enabled: bool): self._pipeline_tab.on_channel_enabled_changed(dev_id, ch_id, enabled) self.channel_visibility_changed.emit(dev_id, ch_id, enabled) diff --git a/ui/windows/devices_window.py b/ui/windows/devices_window.py index 5d47681..15549f1 100644 --- a/ui/windows/devices_window.py +++ b/ui/windows/devices_window.py @@ -157,8 +157,14 @@ class SignalsTab(QWidget): self._table.setObjectName("channelTable") self._table.setColumnCount(6) self._table.setHorizontalHeaderLabels( - ["Device", "On", "Name", "Unit", "Min", "Max"] + ["Device", "On", "Label", "Unit", "Min", "Max"] ) + name_hdr_item = self._table.horizontalHeaderItem(self._C_NAME) + if name_hdr_item is not None: + name_hdr_item.setToolTip( + "Technical label only — for the profile-facing name, create a Channel " + "in the Channels window." + ) hdr = self._table.horizontalHeader() hdr.setSectionResizeMode(self._C_NAME, QHeaderView.ResizeMode.Stretch) hdr.setSectionResizeMode(self._C_DEVICE, QHeaderView.ResizeMode.ResizeToContents) -- cgit v1.2.3