diff options
Diffstat (limited to 'ui/namespace_panel.py')
| -rw-r--r-- | ui/namespace_panel.py | 192 |
1 files changed, 192 insertions, 0 deletions
diff --git a/ui/namespace_panel.py b/ui/namespace_panel.py new file mode 100644 index 0000000..2fcc7a1 --- /dev/null +++ b/ui/namespace_panel.py @@ -0,0 +1,192 @@ +""" +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. + +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 PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QLabel, + QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView, + 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_NAME = 1 +_C_DIR = 2 +_C_VALUE = 3 +_C_SOURCE = 4 + + +def _fmt_value(v) -> str: + if v is None: + return "—" + if isinstance(v, float): + return f"{v:.4g}" + return str(v) + + +class NamespacePanel(QWidget): + def __init__(self, registry: DeviceRegistry, processor: SignalProcessor): + super().__init__() + self.registry = registry + self.processor = processor + # 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 a ready-to-paste reference " + "(vars.get(...) or channels.get(...)) to the clipboard." + ) + hint.setObjectName("traceSource") + hint.setWordWrap(True) + hint.setContentsMargins(8, 6, 8, 6) + lay.addWidget(hint) + + self._table = QTableWidget() + self._table.setObjectName("channelTable") + self._table.setColumnCount(5) + self._table.setHorizontalHeaderLabels( + ["Kind", "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_NAME, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(_C_DIR, QHeaderView.ResizeMode.ResizeToContents) + hdr.setSectionResizeMode(_C_VALUE, 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) + + # ── 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.channels: + if ch.enabled: + keys.append(("signal", dev.info.device_id, ch.channel_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) + + def _paint_row(self, row: int, key: Tuple): + kind = key[0] + if kind == "signal": + _, dev_id, ch_id = key + dev = self.registry.get_instance(dev_id) + 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}") + 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) + else: # var + _, name = key + val = self.processor.get_var(name, None) + self._set_row(row, "var", name, "R/W", val, "shared (vars)") + + def _set_row(self, row: int, kind: str, name: str, direction: str, + value, source: str): + self._table.setItem(row, _C_KIND, QTableWidgetItem(kind)) + 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") |
