summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
Diffstat (limited to 'ui')
-rw-r--r--ui/main_window.py36
-rw-r--r--ui/namespace_panel.py101
-rw-r--r--ui/windows/config_window.py104
3 files changed, 170 insertions, 71 deletions
diff --git a/ui/main_window.py b/ui/main_window.py
index ba93121..6dae1be 100644
--- a/ui/main_window.py
+++ b/ui/main_window.py
@@ -12,9 +12,10 @@ from PyQt6.QtWidgets import (
QToolBar, QSizePolicy, QApplication, QFrame,
QAbstractSpinBox, QComboBox, QMenu,
)
-from PyQt6.QtCore import Qt, QTimer, QObject, QEvent, QRect, pyqtSignal, pyqtSlot
+from PyQt6.QtCore import Qt, QTimer, QObject, QEvent, QRect, QByteArray, pyqtSignal, pyqtSlot
from PyQt6.QtGui import QPainter, QColor, QFont, QAction
import logging
+import base64
import os
@@ -116,6 +117,12 @@ _LIGHT_QSS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "style_lig
class MainWindow(QMainWindow):
+ # Bump when dock/toolbar structure changes in a way that would make an
+ # old saved dock_state blob meaningless (e.g. a dock renamed/removed) —
+ # restoreState() rejects a mismatched version and just no-ops instead
+ # of applying garbage.
+ _DOCK_STATE_VERSION = 1
+
def __init__(self):
super().__init__()
self.setWindowTitle("LabUI")
@@ -622,6 +629,7 @@ class MainWindow(QMainWindow):
def _profile_capture(self, name: str = "Profile") -> Profile:
"""Serialise current state into a Profile object."""
+ self._capture_dock_state()
return ProfileManager.capture(
registry=self.registry,
processor=self.processor,
@@ -702,6 +710,7 @@ class MainWindow(QMainWindow):
self._win_config.refresh_devices()
self._win_config.refresh_derived()
self._win_config.refresh_plot(self._chart._cfg)
+ QTimer.singleShot(0, self._apply_dock_state)
self._status.setText(f"Profile loaded: {profile.name}")
# ── Device / signal events ────────────────────────────────────────────
@@ -819,6 +828,31 @@ class MainWindow(QMainWindow):
self._apply_theme(self._settings.get("theme", "dark"))
self.engine._interval = self._settings.get("poll_ms", 100) / 1000.0
self._apply_developer_mode(self._settings.get("developer_mode", False))
+ QTimer.singleShot(0, self._apply_dock_state)
+
+ def _capture_dock_state(self):
+ """Snapshot Control Panel / Namespace dock area, floating geometry,
+ visibility, size and order via Qt's own QMainWindow.saveState() —
+ one call covers everything a QDockWidget can have moved, instead of
+ hand-tracking each attribute. Stored as base64 in self._settings,
+ which both app_settings.json (save_settings()) and .labui profiles
+ (Profile.settings — see ProfileManager.capture()) already round-trip
+ verbatim, so this needs no schema changes on either side, just
+ needs to run before whichever save happens."""
+ raw = bytes(self.saveState(self._DOCK_STATE_VERSION))
+ self._settings["dock_state"] = base64.b64encode(raw).decode("ascii")
+
+ def _apply_dock_state(self):
+ """Restore saved dock state — deferred one event-loop tick (via
+ QTimer.singleShot(0, ...) at call sites) since restoreState() before
+ the main window's initial layout has settled doesn't stick, the
+ same reason splitter sizing elsewhere in this codebase (e.g.
+ plot_window.py's _init_split) defers similarly."""
+ encoded = self._settings.get("dock_state")
+ if not encoded:
+ return
+ raw = QByteArray(base64.b64decode(encoded))
+ self.restoreState(raw, self._DOCK_STATE_VERSION)
def _apply_developer_mode(self, enabled: bool):
level = logging.DEBUG if enabled else logging.WARNING
diff --git a/ui/namespace_panel.py b/ui/namespace_panel.py
index e2b8809..f8804c0 100644
--- a/ui/namespace_panel.py
+++ b/ui/namespace_panel.py
@@ -5,24 +5,27 @@ 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.
+still happens in Configuration > Channels.
+
+Rows support multi-select (Ctrl/Shift-click). selected_references() exposes
+the currently-selected Signal/Channel rows as (dev_id, ch_id, name, unit,
+color) tuples — used by ConfigWindow's "Create Channel" button (Channels
+tab, config_window.py), which sits below this panel rather than inside it:
+this widget stays a plain reference browser with no create-channel
+knowledge of its own, reusable as-is for the read-only dockable instance
+on the main window (ui/main_window.py).
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 typing import Dict, List, Tuple
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QTableWidget, QTableWidgetItem, QHeaderView, QAbstractItemView,
- QToolButton, QApplication,
+ QApplication,
)
from PyQt6.QtCore import QTimer, pyqtSlot
from PyQt6.QtGui import QColor
@@ -38,7 +41,6 @@ _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:
@@ -60,14 +62,10 @@ def _fmt_ref(dev_id: str, ch_id: str) -> str:
class NamespacePanel(QWidget):
- def __init__(self, registry: DeviceRegistry, processor: SignalProcessor,
- on_create_channel: Optional[Callable[[str, str, str, str, str], None]] = None):
+ def __init__(self, registry: DeviceRegistry, processor: SignalProcessor):
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] = {}
@@ -85,7 +83,8 @@ class NamespacePanel(QWidget):
lay.setSpacing(0)
hint = QLabel(
- "Double-click a row to copy reference to clipboard"
+ "Double-click a row to copy reference to clipboard · "
+ "select rows to use as sources for Create Channel"
)
hint.setObjectName("traceSource")
hint.setWordWrap(True)
@@ -94,11 +93,8 @@ class NamespacePanel(QWidget):
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)
+ self._table.setColumnCount(6)
+ self._table.setHorizontalHeaderLabels(["Kind", "ID", "Name", "Dir", "Value", "Source"])
hdr = self._table.horizontalHeader()
hdr.setSectionResizeMode(_C_SOURCE, QHeaderView.ResizeMode.Stretch)
hdr.setSectionResizeMode(_C_KIND, QHeaderView.ResizeMode.ResizeToContents)
@@ -106,25 +102,57 @@ 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.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
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."""
+ def natural_width(self) -> int:
+ """Sum of current column widths (Source, being Stretch, counted at
+ a fixed placeholder rather than its live stretched width, which
+ would be circular) — how wide this panel would need to be to show
+ every column without a horizontal scrollbar. A query only, not an
+ enforced constraint: this panel never sets its own minimum width
+ (that fights QMainWindow's dock layout when this class is docked
+ stacked/tabbed with another dock, e.g. Control Panel, in
+ ui/main_window.py). A caller that wants "start small but readable"
+ sizing — see ConfigWindow's Channels tab — uses this once for
+ initial splitter sizing instead."""
total = 0
for i in range(self._table.columnCount()):
total += 80 if i == _C_SOURCE else self._table.columnWidth(i)
- self.setMinimumWidth(total + 24)
+ return total + 24
+
+ # ── Selection ────────────────────────────────────────────────────────────
+
+ def selected_references(self) -> List[Tuple[str, str, str, str, str]]:
+ """Currently-selected Signal/Channel rows as (dev_id, ch_id, name,
+ unit, color) — the shape ConfigWindow's "Create Channel" button
+ needs to build a new Channel's sources. Selected "var" rows and any
+ row whose underlying Signal/Channel has since vanished are skipped
+ silently (vars aren't valid Channel sources; a vanished row means
+ the selection is stale)."""
+ rows = {idx.row() for idx in self._table.selectionModel().selectedRows()}
+ out: List[Tuple[str, str, str, str, str]] = []
+ for key, row in self._row_index.items():
+ if row not in rows:
+ continue
+ if key[0] == "signal":
+ _, dev_id, ch_id = key
+ dev = self.registry.get_instance(dev_id)
+ ch = dev.get_signal(ch_id) if dev else None
+ if ch is not None:
+ out.append((dev_id, ch_id, ch.name, ch.unit, ch.color))
+ elif key[0] == "channel":
+ _, channel_id = key
+ dc = next((d for d in self.processor.get_derived()
+ if d.channel_id == channel_id), None)
+ if dc is not None:
+ out.append(("derived", channel_id, dc.name, dc.unit, dc.color))
+ return out
# ── Row-set (structural) rebuild — devices/channels/vars added or removed ──
@@ -149,13 +177,9 @@ 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)
@@ -164,17 +188,6 @@ 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/windows/config_window.py b/ui/windows/config_window.py
index 7ec0355..9b864bf 100644
--- a/ui/windows/config_window.py
+++ b/ui/windows/config_window.py
@@ -8,12 +8,13 @@ Devices tab layout (2 columns):
Right — signal table (Device / Signal ID / Dir / On / Label / Unit / Min / Max)
Channels tab layout (2 columns):
- 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.
+ Left — Namespace browser (Signals / Channels / vars, ui/namespace_panel.py
+ — plain read-only reference, reused as-is for the dockable
+ Namespace panel on the main window too) plus a "Create Channel"
+ button below it, local to this tab only: uses whichever rows are
+ selected (multi-select) as the new Channel's sources, or creates
+ an empty Channel if nothing's selected. See _on_create_channel_
+ clicked().
Right — derived / virtual channels — operations (filters/calibration/math/script)
are configured per Channel here, not on the Signals side.
"""
@@ -471,10 +472,20 @@ 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,
- on_create_channel=self._on_create_channel)
+ self._namespace_panel = NamespacePanel(self.registry, self.processor)
self.processor.processed_data.connect(self._namespace_panel.on_processed_data)
ns_l.addWidget(self._namespace_panel, 1)
+
+ ns_create_bar = QWidget(); ns_create_bar.setObjectName("cfgGlobalBar")
+ nc_lay = QHBoxLayout(ns_create_bar); nc_lay.setContentsMargins(10, 7, 10, 7); nc_lay.setSpacing(6)
+ nc_lay.addStretch()
+ create_btn = QPushButton("+ Create Channel"); create_btn.setObjectName("addTraceBtn")
+ create_btn.setToolTip("Selected rows become the new Channel's sources — "
+ "none selected creates an empty Channel")
+ create_btn.clicked.connect(self._on_create_channel_clicked)
+ nc_lay.addWidget(create_btn)
+ ns_l.addWidget(ns_create_bar)
+
splitter.addWidget(ns_w)
# Right: derived channels only
@@ -489,29 +500,70 @@ class ConfigWindow(QWidget):
inner_splitters[0].widget(0).setVisible(False)
splitter.addWidget(self._pipeline_tab)
- # Channels side larger by default
- splitter.setStretchFactor(0, 1)
- splitter.setStretchFactor(1, 3)
+ # Namespace starts at its content-driven natural width (see
+ # NamespacePanel.natural_width() — a query only, not an enforced
+ # minimum: a hard setMinimumWidth() here used to fight QMainWindow's
+ # dock layout when this same NamespacePanel class is also docked
+ # stacked/tabbed with the Control Panel dock, ui/main_window.py).
+ # Stretch factor 0 means Channels absorbs window-resize growth;
+ # dragging the divider narrower than natural_width() just lets
+ # Source etc. scroll — the user's call.
+ splitter.setStretchFactor(0, 0)
+ splitter.setStretchFactor(1, 1)
+
+ def _init_ns_split():
+ ns_w0 = self._namespace_panel.natural_width()
+ total = splitter.width()
+ splitter.setSizes([ns_w0, max(1, total - ns_w0)])
+
+ QTimer.singleShot(0, _init_ns_split)
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
+ def _on_create_channel_clicked(self):
+ """Create Channel button below the Channels tab's Namespace panel —
+ local to this tab (see module docstring). Uses whatever Signal/
+ Channel rows are currently selected (ui/namespace_panel.py::
+ NamespacePanel.selected_references(), multi-select) as the new
+ Channel's sources; none selected creates an empty Channel."""
+ refs = self._namespace_panel.selected_references()
+ existing = self.processor.get_derived()
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")
+
+ if len(refs) == 1:
+ # Exactly one Signal/Channel selected — the "1 -> 1 passthrough"
+ # quick path. No-ops if one already exists for this exact source.
+ dev_id, ch_id, ch_name, unit, color = refs[0]
+ for dc in existing:
+ if dc.kind == "none" and dc.sources == [(dev_id, ch_id)]:
+ return
+ 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")
+ else:
+ # Zero selected -> empty Channel, same naming convention
+ # PipelineTab._add_virtual() uses for one. Two+ selected -> one
+ # Channel sourced from all of them. kind="none" here (not
+ # "expression") because it's the only kind that compiles
+ # trivially with no expression/script text set yet — Channel.
+ # compile() does `compile(f"__result__ = {self.expression}")`
+ # for "expression", which is a SyntaxError on the default blank
+ # string, so processor.add_derived() below would silently fail
+ # and never add the channel. _add_virtual() gets away with
+ # defaulting to "expression" because it never calls
+ # add_derived() itself — it hands the Channel to the editor and
+ # waits for the user's own Apply. This path calls add_derived()
+ # immediately, so it needs a kind that survives that. The user
+ # picks the real kind/expression afterward in the editor.
+ name = _dedupe_name(f"Channel {len(existing) + 1}", existing_names)
+ channel_id = _slug_id(name, existing_ids)
+ dc = Channel(channel_id=channel_id, name=name, kind="none",
+ sources=[(d, c) for d, c, *_ in refs],
+ source_names=[n for _, _, n, _, _ in refs])
+
err = self.processor.add_derived(dc)
if err is None:
self._pipeline_tab.refresh_derived()