From a0473245f1f453bfc87ea1bc9bd479311e8a944b Mon Sep 17 00:00:00 2001 From: Christian Kolset Date: Sun, 20 Sep 2026 23:45:18 -0600 Subject: Persist dock layout to profiles; dedupe port names; Channels-tab Create button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix duplicated device names ("FT231X USB UART - FT231X USB UART (...)") — pyserial on Linux often reports the same udev model string twice for cheap FTDI/CH340-style adapters. Collapse it at the list_ports() source so every consumer (device auto-name, port picker) benefits. - Save Control Panel / Namespace dock area, floating geometry, visibility, size and order to .labui profiles via QMainWindow.saveState()/ restoreState(), round-tripped through the existing Profile.settings passthrough (self._settings) — no profile schema change needed. - Channels tab: replace the per-row Signal ⊕ button (and its pinned/ frozen-column overlay) with a single "Create Channel" button below the Namespace panel. Select one or more Signal/Channel rows (multi-select now supported) and click it — selected rows become the new Channel's sources, or an empty Channel is created if nothing's selected. Fixes a bug in the process: the multi/zero-source path defaulted to kind="expression" with no expression text, which fails to compile and silently dropped the channel; use kind="none" instead, which the user then edits in the Channels pane. NamespacePanel itself (ui/namespace_panel.py) goes back to a plain read-only reference widget — the create-channel button and its logic live entirely in ConfigWindow (config_window.py), so the shared dockable Namespace panel on the main window is unaffected. Co-Authored-By: Claude Sonnet 5 --- ui/main_window.py | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) (limited to 'ui/main_window.py') 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 -- cgit v1.2.3