summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorChristian Kolset <ckolset@colostate.edu>2026-08-10 16:45:37 -0600
committerChristian Kolset <ckolset@colostate.edu>2026-08-10 16:45:37 -0600
commit8d546990c86df61174b2f3f2036771757aa69913 (patch)
treed306d5da66d0a77a060421f3e72b9005ebb9a400 /ui
parent5d5bcaed8639a162fda85c8cd0d340c296a054b8 (diff)
Add dockable Namespace panel — live Python-namespace browser
New right-docked panel (mirrors Control Panel's dock, opposite side) listing every Signal, Channel, and shared `vars` key currently in the profile, with live values and a double-click-to-copy reference snippet (vars.get(...) / channels.get(...)) for use in scripts. Closed by default — opt-in config tool, no collapse-strip nag like Control Panel gets since it's not needed mid-run. Also moves Configuration out of the View menu into its own toolbar button, and makes table row hover match the selection highlight color. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'ui')
-rw-r--r--ui/main_window.py93
-rw-r--r--ui/namespace_panel.py192
-rw-r--r--ui/style_dark.qss3
-rw-r--r--ui/style_light.qss3
4 files changed, 267 insertions, 24 deletions
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 */