""" ui/namespace_panel.py 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 — 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 Callable, Dict, Optional, Tuple from PyQt6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, QToolButton, QApplication, ) from PyQt6.QtCore import QTimer, pyqtSlot from PyQt6.QtGui import QColor from devices.device_registry import DeviceRegistry from core.signal_processor import SignalProcessor _VARS_POLL_MS = 400 _C_KIND = 0 _C_ID = 1 _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: if v is None: return "—" if isinstance(v, float): return f"{v:.4g}" 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, 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] = {} self._build() self._rebuild_rows() self._poll = QTimer(self) self._poll.setInterval(_VARS_POLL_MS) self._poll.timeout.connect(self._on_poll) self._poll.start() def _build(self): lay = QVBoxLayout(self) lay.setContentsMargins(0, 0, 0, 0) lay.setSpacing(0) hint = QLabel( "Double-click a row to copy reference to clipboard" ) hint.setObjectName("traceSource") hint.setWordWrap(True) hint.setContentsMargins(8, 6, 8, 6) lay.addWidget(hint) self._table = QTableWidget() self._table.setObjectName("channelTable") 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) 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) 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) self._table.verticalHeader().setVisible(False) 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): keys = [] for dev in self.registry.all_instances(): for ch in dev.info.signals: if ch.enabled: keys.append(("signal", dev.info.device_id, ch.signal_id)) for dc in self.processor.get_derived(): keys.append(("channel", dc.channel_id)) for name in self.processor.list_vars(): keys.append(("var", name)) return keys def _rebuild_rows(self): keys = self._desired_keys() if list(self._row_index.keys()) == keys: return # no structural change — values refresh independently self._row_index.clear() self._table.setRowCount(len(keys)) 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) ch = dev.get_signal(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", 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() if d.channel_id == channel_id), None) name = dc.name if dc else channel_id 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, name, "R/W", val, "shared (vars)") 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)) self._table.setItem(row, _C_DIR, dir_item) self._table.setItem(row, _C_VALUE, QTableWidgetItem(_fmt_value(value))) self._table.setItem(row, _C_SOURCE, QTableWidgetItem(source)) # ── Live value updates ────────────────────────────────────────────────── @pyqtSlot(str, str, float, float) def on_processed_data(self, device_id: str, channel_id: str, ts: float, value: float): key = ("channel", channel_id) if device_id == "derived" else ("signal", device_id, channel_id) row = self._row_index.get(key) if row is None: return item = self._table.item(row, _C_VALUE) if item is not None: item.setText(_fmt_value(value)) def _on_poll(self): self._rebuild_rows() for name in self.processor.list_vars(): row = self._row_index.get(("var", name)) if row is None: continue item = self._table.item(row, _C_VALUE) if item is not None: item.setText(_fmt_value(self.processor.get_var(name, None))) # ── Copy-to-clipboard reference ───────────────────────────────────────── def _on_double_clicked(self, row: int, _col: int): key = next((k for k, r in self._row_index.items() if r == row), None) if key is None: return if key[0] == "var": snippet = f'vars.get("{key[1]}", 0)' elif key[0] == "signal": snippet = f'channels.get("{key[2]}", 0)' else: # channel snippet = f'channels.get("{key[1]}", 0)' QApplication.clipboard().setText(snippet) def _dir_color(direction: str) -> QColor: if direction == "WRITE": return QColor("#f59e0b") if direction == "R/W": return QColor("#818cf8") return QColor("#22c55e")