diff options
Diffstat (limited to 'ui')
| -rw-r--r-- | ui/main_window.py | 36 | ||||
| -rw-r--r-- | ui/namespace_panel.py | 101 | ||||
| -rw-r--r-- | ui/windows/config_window.py | 104 |
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() |
