From fd2b8918f5483c8aa9052c1a8a17429d52b9540e Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Thu, 20 Nov 2025 11:32:27 +0100 Subject: [PATCH] feat(advanced_dock_area): instance lock for multiple ads in same session --- .../views/developer_view/developer_widget.py | 4 +- .../advanced_dock_area/advanced_dock_area.py | 76 +++++++++++++------ .../advanced_dock_area/profile_utils.py | 49 ++++++++++-- 3 files changed, 97 insertions(+), 32 deletions(-) diff --git a/bec_widgets/applications/views/developer_view/developer_widget.py b/bec_widgets/applications/views/developer_view/developer_widget.py index 38a6c856..39c8f49e 100644 --- a/bec_widgets/applications/views/developer_view/developer_widget.py +++ b/bec_widgets/applications/views/developer_view/developer_widget.py @@ -92,7 +92,7 @@ class DeveloperWidget(DockAreaWidget): self.terminal = WebConsole(self, startup_cmd="") self.terminal.setObjectName("Terminal") self.monaco = MonacoDock(self) - self.monaco.setObjectName("Monaco Editor") + self.monaco.setObjectName("MonacoEditor") self.monaco.save_enabled.connect(self._on_save_enabled_update) self.plotting_ads = AdvancedDockArea( self, @@ -103,7 +103,7 @@ class DeveloperWidget(DockAreaWidget): enable_profile_management=False, variant="compact", ) - self.plotting_ads.setObjectName("Plotting Area") + self.plotting_ads.setObjectName("PlottingArea") self.signature_help = QTextEdit(self) self.signature_help.setObjectName("Signature Help") self.signature_help.setAcceptRichText(True) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 3bdadcf7..4cac5392 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -118,6 +118,7 @@ class AdvancedDockArea(DockAreaWidget): default_add_direction: Literal["left", "right", "top", "bottom"] = "right", profile_namespace: str | None = None, auto_profile_namespace: bool = True, + instance_id: str | None = None, auto_save_upon_exit: bool = True, enable_profile_management: bool = True, restore_initial_profile: bool = True, @@ -126,6 +127,7 @@ class AdvancedDockArea(DockAreaWidget): self._profile_namespace_hint = profile_namespace self._profile_namespace_auto = auto_profile_namespace self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET + self._instance_id = sanitize_namespace(instance_id) if instance_id else None self._auto_save_upon_exit = auto_save_upon_exit self._profile_management_enabled = enable_profile_management self._restore_initial_profile = restore_initial_profile @@ -181,24 +183,23 @@ class AdvancedDockArea(DockAreaWidget): # Restore last-used profile if available; otherwise fall back to combo selection combo = self.toolbar.components.get_action("workspace_combo").widget namespace = self.profile_namespace - last = get_last_profile(namespace) - if last: - user_exists = any( - os.path.exists(path) for path in user_profile_candidates(last, namespace) + init_profile = None + instance_id = self._last_profile_instance_id() + if instance_id: + inst_profile = get_last_profile( + namespace=namespace, instance=instance_id, allow_namespace_fallback=False ) - default_exists = any( - os.path.exists(path) for path in default_profile_candidates(last, namespace) - ) - init_profile = last if (user_exists or default_exists) else None - else: - init_profile = combo.currentText() + if inst_profile and self._profile_exists(inst_profile, namespace): + init_profile = inst_profile if not init_profile: - general_exists = any( - os.path.exists(path) for path in user_profile_candidates("general", namespace) - ) or any( - os.path.exists(path) for path in default_profile_candidates("general", namespace) - ) - if general_exists: + last = get_last_profile(namespace=namespace) + if last and self._profile_exists(last, namespace): + init_profile = last + else: + text = combo.currentText() + init_profile = text if text else None + if not init_profile: + if self._profile_exists("general", namespace): init_profile = "general" if init_profile: # Defer initial load to the event loop so child widgets exist before state restore. @@ -500,6 +501,14 @@ class AdvancedDockArea(DockAreaWidget): for dock in self.dock_list(): dock.setting_action.setVisible(not value) + def _last_profile_instance_id(self) -> str | None: + """ + Identifier used to scope the last-profile entry for this dock area. + + When unset, profiles are scoped only by namespace. + """ + return self._instance_id + def _resolve_profile_namespace(self) -> str | None: if self._profile_namespace_resolved is not _PROFILE_NAMESPACE_UNSET: return self._profile_namespace_resolved # type: ignore[return-value] @@ -536,6 +545,11 @@ class AdvancedDockArea(DockAreaWidget): self._current_profile_name = name return name + def _profile_exists(self, name: str, namespace: str | None) -> bool: + return any( + os.path.exists(path) for path in user_profile_candidates(name, namespace) + ) or any(os.path.exists(path) for path in default_profile_candidates(name, namespace)) + def _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None: self.save_to_settings(settings, keys=PROFILE_STATE_KEYS) self.state_manager.save_state(settings=settings) @@ -630,7 +644,7 @@ class AdvancedDockArea(DockAreaWidget): workspace_combo.setCurrentText(name) self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -688,7 +702,7 @@ class AdvancedDockArea(DockAreaWidget): self._current_profile_name = name self.profile_changed.emit(name) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) combo = self.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles(active_profile=name) @@ -889,7 +903,7 @@ class AdvancedDockArea(DockAreaWidget): namespace = self.profile_namespace settings = open_user_settings(name, namespace=namespace) self._write_snapshot_to_settings(settings) - set_last_profile(name, namespace=namespace) + set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) self._exit_snapshot_written = True def cleanup(self): @@ -910,13 +924,31 @@ class AdvancedDockArea(DockAreaWidget): if __name__ == "__main__": # pragma: no cover import sys + from qtpy.QtWidgets import QTabWidget + app = QApplication(sys.argv) apply_theme("dark") dispatcher = BECDispatcher(gui_id="ads") window = BECMainWindowNoRPC() - ads = AdvancedDockArea(mode="creator", root_widget=True, enable_profile_management=True) - window.setCentralWidget(ads) + central = QWidget() + layout = QVBoxLayout(central) + window.setCentralWidget(central) + + # two dock areas stacked vertically no instance ids + ads = AdvancedDockArea(mode="creator", enable_profile_management=True) + ads2 = AdvancedDockArea(mode="creator", enable_profile_management=True) + layout.addWidget(ads, 1) + layout.addWidget(ads2, 1) + + # two dock areas inside a tab widget + tabs = QTabWidget(parent=central) + ads3 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab3") + ads4 = AdvancedDockArea(mode="creator", enable_profile_management=True, instance_id="AdsTab4") + tabs.addTab(ads3, "Workspace 3") + tabs.addTab(ads4, "Workspace 4") + layout.addWidget(tabs, 1) + window.show() - window.resize(800, 600) + window.resize(800, 1000) sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index 9e239b03..f09f5c54 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -559,9 +559,10 @@ def _app_settings() -> QSettings: return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat) -def _last_profile_key(namespace: str | None) -> str: +def _last_profile_key(namespace: str | None, instance: str | None = None) -> str: """ - Build the QSettings key used to store the last profile per namespace. + Build the QSettings key used to store the last profile per namespace and + optional instance id. Args: namespace (str | None): Namespace label. @@ -571,37 +572,69 @@ def _last_profile_key(namespace: str | None) -> str: """ ns = sanitize_namespace(namespace) key = SETTINGS_KEYS["last_profile"] - return f"{key}/{ns}" if ns else key + if ns: + key = f"{key}/{ns}" + inst = sanitize_namespace(instance) if instance else "" + if inst: + key = f"{key}@{inst}" + return key -def get_last_profile(namespace: str | None = None) -> str | None: +def get_last_profile( + namespace: str | None = None, + instance: str | None = None, + *, + allow_namespace_fallback: bool = True, +) -> str | None: """ Retrieve the last-used profile name persisted in app settings. + When *instance* is provided, the lookup is scoped to that particular dock + area instance. If the instance-specific entry is missing and + ``allow_namespace_fallback`` is True, the namespace-wide entry is + consulted next. + Args: namespace (str | None, optional): Namespace label. Defaults to ``None``. + instance (str | None, optional): Optional instance ID. Defaults to ``None``. + allow_namespace_fallback (bool): Whether to fall back to the namespace + entry when an instance-specific value is not found. Defaults to ``True``. Returns: str | None: Profile name or ``None`` if none has been stored. """ s = _app_settings() - name = s.value(_last_profile_key(namespace), "", type=str) + inst = instance or None + if inst: + name = s.value(_last_profile_key(namespace, inst), "", type=str) + if name: + return name + if not allow_namespace_fallback: + return None + name = s.value(_last_profile_key(namespace, None), "", type=str) return name or None -def set_last_profile(name: str | None, namespace: str | None = None) -> None: +def set_last_profile( + name: str | None, namespace: str | None = None, instance: str | None = None +) -> None: """ Persist the last-used profile name (or clear the value when ``None``). + When *instance* is provided, the value is stored under a key specific to + that dock area instance; otherwise it is stored under the namespace-wide key. + Args: name (str | None): Profile name to store. namespace (str | None, optional): Namespace label. Defaults to ``None``. + instance (str | None, optional): Optional instance ID. Defaults to ``None``. """ s = _app_settings() + key = _last_profile_key(namespace, instance) if name: - s.setValue(_last_profile_key(namespace), name) + s.setValue(key, name) else: - s.remove(_last_profile_key(namespace)) + s.remove(key) def now_iso_utc() -> str: