diff options
| -rw-r--r-- | core/signal_processor.py | 7 | ||||
| -rw-r--r-- | ui/main_window.py | 93 | ||||
| -rw-r--r-- | ui/namespace_panel.py | 192 | ||||
| -rw-r--r-- | ui/style_dark.qss | 3 | ||||
| -rw-r--r-- | ui/style_light.qss | 3 |
5 files changed, 274 insertions, 24 deletions
diff --git a/core/signal_processor.py b/core/signal_processor.py index c00df73..8abaff8 100644 --- a/core/signal_processor.py +++ b/core/signal_processor.py @@ -466,6 +466,13 @@ class SignalProcessor(QObject): with self._lock: return self._script_vars.get(name, default) + def list_vars(self) -> Dict[str, Any]: + """Snapshot of every currently-set script var. Scripts mutate + `vars` directly (vars['x'] = ...) rather than through set_var(), so + this is the only way to discover what's been assigned so far.""" + with self._lock: + return dict(self._script_vars) + # ── Main data path ──────────────────────────────────────────────────── def on_raw_batch(self, timestamp: float, readings: List[Tuple[str, str, float]]): diff --git a/ui/main_window.py b/ui/main_window.py index c258424..671cab1 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -29,18 +29,21 @@ class _WheelBlocker(QObject): return True return super().eventFilter(obj, event) -class _CtrlTabStrip(QWidget): - """Thin vertical strip shown on the left edge when the Control Panel dock is hidden. - Clicking it restores the dock. Safety feature — operator can never lose the panel.""" +class _DockTabStrip(QWidget): + """Thin strip shown on an edge when its dock is hidden. Clicking it + restores the dock. Safety feature — operator can never lose the panel.""" clicked = pyqtSignal() - def __init__(self, parent=None): + def __init__(self, label: str, color: str = "#3b82f6", arrow: str = "▶", parent=None): super().__init__(parent) + self._label = label + self._color = color + self._arrow = arrow self.setObjectName("ctrlTabStrip") self.setFixedWidth(22) self.setCursor(Qt.CursorShape.PointingHandCursor) - self.setToolTip("Show Control Panel") + self.setToolTip(f"Show {label.title()}") def mousePressEvent(self, event): if event.button() == Qt.MouseButton.LeftButton: @@ -50,7 +53,7 @@ class _CtrlTabStrip(QWidget): def paintEvent(self, _event): p = QPainter(self) p.setRenderHint(QPainter.RenderHint.Antialiasing) - p.fillRect(self.rect(), QColor("#3b82f6")) + p.fillRect(self.rect(), QColor(self._color)) p.setPen(QColor("#ffffff")) font = QFont() font.setPointSize(8) @@ -60,31 +63,32 @@ class _CtrlTabStrip(QWidget): p.rotate(-90.0) text_rect = QRect(-self.height() // 2, -self.width() // 2, self.height(), self.width()) - p.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, "CONTROL PANEL ▶") + p.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, f"{self._label} {self._arrow}") p.end() class _DockTitleBar(QWidget): - """Custom title bar for the Control Panel QDockWidget. - Replaces native title bar; provides Add and Close buttons.""" + """Custom title bar for a QDockWidget. Replaces the native title bar; + provides an optional Add button plus a Close button.""" - def __init__(self, dock: QDockWidget, add_callback, parent=None): + def __init__(self, dock: QDockWidget, title: str, add_callback=None, parent=None): super().__init__(parent) self.setObjectName("controlPanelHeader") self.setFixedHeight(30) lay = QHBoxLayout(self) lay.setContentsMargins(8, 0, 4, 0); lay.setSpacing(4) - lbl = QLabel("CONTROL PANEL"); lbl.setObjectName("panelHeader") + lbl = QLabel(title); lbl.setObjectName("panelHeader") lay.addWidget(lbl, 1) - add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn") - add_btn.setFixedSize(24, 22); add_btn.setToolTip("Add control widget") - add_btn.clicked.connect(add_callback) - lay.addWidget(add_btn) + if add_callback is not None: + add_btn = QPushButton("+"); add_btn.setObjectName("devicesSmallBtn") + add_btn.setFixedSize(24, 22); add_btn.setToolTip(f"Add to {title.title()}") + add_btn.clicked.connect(add_callback) + lay.addWidget(add_btn) close_btn = QPushButton("✕"); close_btn.setObjectName("devicesSmallBtn") - close_btn.setFixedSize(22, 22); close_btn.setToolTip("Hide Control Panel") + close_btn.setFixedSize(22, 22); close_btn.setToolTip(f"Hide {title.title()}") close_btn.clicked.connect(dock.hide) lay.addWidget(close_btn) @@ -98,6 +102,7 @@ from core.signal_processor import SignalProcessor from core.profile import Profile, ProfileManager from ui.control_panel import ControlPanel +from ui.namespace_panel import NamespacePanel from ui.strip_chart import StripChartWidget from ui.profile_manager_ui import ProfileButton from ui.windows.config_window import ConfigWindow @@ -189,11 +194,11 @@ class MainWindow(QMainWindow): self._act_ctrl_panel.setCheckable(True); self._act_ctrl_panel.setChecked(True) self._act_ctrl_panel.triggered.connect(self._on_view_ctrl_panel) self._view_menu.addAction(self._act_ctrl_panel) - self._view_menu.addSeparator() - act_config = QAction("Configuration", self) - act_config.triggered.connect(lambda: self._open_config(0)) - self._view_menu.addAction(act_config) + self._act_namespace = QAction("Namespace", self) + self._act_namespace.setCheckable(True); self._act_namespace.setChecked(False) + self._act_namespace.triggered.connect(self._on_view_namespace) + self._view_menu.addAction(self._act_namespace) self._view_plugin_sep = self._view_menu.addSeparator() self._view_plugin_sep.setVisible(False) @@ -202,6 +207,11 @@ class MainWindow(QMainWindow): lambda: self._view_menu.exec(view_btn.mapToGlobal(view_btn.rect().bottomLeft())) ) tb.addWidget(view_btn) + + config_btn = QPushButton("⚙ Configure"); config_btn.setObjectName("toolbarSectionBtn") + config_btn.clicked.connect(lambda: self._open_config(0)) + tb.addWidget(config_btn) + tb.addWidget(_sep()) self._run_btn = QPushButton("▶ RUN") @@ -246,7 +256,7 @@ class MainWindow(QMainWindow): root = QHBoxLayout(central); root.setContentsMargins(0,0,0,0); root.setSpacing(0) # Side tab strip — shown when Control Panel dock is hidden - self._ctrl_tab = _CtrlTabStrip() + self._ctrl_tab = _DockTabStrip("CONTROL PANEL") self._ctrl_tab.setVisible(False) self._ctrl_tab.clicked.connect(self._show_ctrl_panel) root.addWidget(self._ctrl_tab) @@ -254,6 +264,10 @@ class MainWindow(QMainWindow): self._chart = StripChartWidget(self.engine, self.registry, self.processor) root.addWidget(self._chart, 1) + # No collapse-strip for Namespace — it's a configuration/reference + # tool, not something the operator needs a can't-lose-it affordance + # for during a run (unlike Control Panel). View menu brings it back. + # ── Control Panel dock ──────────────────────────────────────────── self._ctrl = ControlPanel(self.registry, processor=self.processor, engine=self.engine) self._ctrl.setMinimumWidth(200); self._ctrl.setMaximumWidth(340) @@ -271,9 +285,29 @@ class MainWindow(QMainWindow): Qt.DockWidgetArea.RightDockWidgetArea ) self.addDockWidget(Qt.DockWidgetArea.LeftDockWidgetArea, self._ctrl_dock) - self._ctrl_dock.setTitleBarWidget(_DockTitleBar(self._ctrl_dock, self._ctrl._on_add)) + self._ctrl_dock.setTitleBarWidget(_DockTitleBar(self._ctrl_dock, "CONTROL PANEL", self._ctrl._on_add)) self._ctrl_dock.visibilityChanged.connect(self._on_ctrl_dock_visibility) + # ── Namespace dock ─────────────────────────────────────────────── + self._namespace = NamespacePanel(self.registry, self.processor) + self._namespace.setMinimumWidth(260) + + self._namespace_dock = QDockWidget("Namespace", self) + self._namespace_dock.setObjectName("namespaceDock") + self._namespace_dock.setWidget(self._namespace) + self._namespace_dock.setFeatures( + QDockWidget.DockWidgetFeature.DockWidgetMovable | + QDockWidget.DockWidgetFeature.DockWidgetClosable + ) + self._namespace_dock.setAllowedAreas( + Qt.DockWidgetArea.LeftDockWidgetArea | + Qt.DockWidgetArea.RightDockWidgetArea + ) + self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self._namespace_dock) + self._namespace_dock.setTitleBarWidget(_DockTitleBar(self._namespace_dock, "NAMESPACE")) + self._namespace_dock.visibilityChanged.connect(self._on_namespace_dock_visibility) + self._namespace_dock.hide() # closed by default — opt-in config tool, not shown on startup + sb = QStatusBar(); self.setStatusBar(sb) self._status = QLabel("Ready"); sb.addWidget(self._status) self._log_lbl = QLabel(""); sb.addPermanentWidget(self._log_lbl) @@ -303,9 +337,24 @@ class MainWindow(QMainWindow): self._ctrl_dock.show() self._ctrl_dock.raise_() + def _on_namespace_dock_visibility(self, visible: bool): + if hasattr(self, "_act_namespace"): + self._act_namespace.setChecked(visible) + + def _on_view_namespace(self, checked: bool): + if checked: + self._namespace_dock.show(); self._namespace_dock.raise_() + else: + self._namespace_dock.hide() + + def _show_namespace_panel(self): + self._namespace_dock.show() + self._namespace_dock.raise_() + def _connect_signals(self): self.engine.new_data.connect(self.processor.on_raw_batch) self.processor.processed_data.connect(self._chart.on_new_data) + self.processor.processed_data.connect(self._namespace.on_processed_data) self.engine.channel_unit_changed.connect(self._chart.refresh_pane_unit) self.engine.log_started.connect(lambda p: self._log_lbl.setText(f"● {p}")) self.engine.log_stopped.connect(lambda p: self._log_lbl.setText(f"✓ {p}")) 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") diff --git a/ui/style_dark.qss b/ui/style_dark.qss index 06c70d9..08b82e9 100644 --- a/ui/style_dark.qss +++ b/ui/style_dark.qss @@ -228,7 +228,8 @@ QTableWidget#channelTable { color: #e2e8f0; font-size: 12px; } -QTableWidget#channelTable::item:selected { +QTableWidget#channelTable::item:selected, +QTableWidget#channelTable::item:hover { background-color: #3b82f6; color: #0b0e13; } diff --git a/ui/style_light.qss b/ui/style_light.qss index f75e7cb..b9c8b62 100644 --- a/ui/style_light.qss +++ b/ui/style_light.qss @@ -63,7 +63,8 @@ QFrame#devWindowDivider { color:#e2e8f0; max-height:1px; } QWidget#cfgGlobalBar { background:#f8fafc; border-bottom:1px solid #e2e8f0; } QWidget#cfgBottomBar { background:#f8fafc; border-top:1px solid #e2e8f0; } QTableWidget#channelTable { background:#ffffff; gridline-color:#f1f5f9; border:1px solid #e2e8f0; alternate-background-color:#f8fafc; } -QTableWidget#channelTable::item:selected { background-color:#93c5fd; color:#0f172a; } +QTableWidget#channelTable::item:selected, +QTableWidget#channelTable::item:hover { background-color:#93c5fd; color:#0f172a; } QHeaderView::section { background:#f1f5f9; color:#475569; border:none; border-bottom:1px solid #e2e8f0; padding:4px 8px; font-weight:600; } /* Port scanner list — light theme */ |
