From 5b13e2c9d56bc10a05b89bbec009107f01e61da3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Sat, 28 Feb 2026 18:41:06 +0100 Subject: [PATCH] fix(dock_area): profile management with empty profile, applied across the whole repo --- bec_widgets/applications/bw_launch.py | 25 +-- bec_widgets/applications/launch_window.py | 38 ++-- bec_widgets/cli/client.py | 3 +- bec_widgets/cli/client_utils.py | 43 ++-- bec_widgets/utils/rpc_server.py | 9 +- .../containers/auto_update/auto_updates.py | 2 +- .../widgets/containers/dock_area/dock_area.py | 201 ++++++++++-------- .../toolbar_components/workspace_actions.py | 46 +++- tests/unit_tests/test_client_utils.py | 130 +++++++++++ tests/unit_tests/test_dock_area.py | 37 ++++ tests/unit_tests/test_launch_window.py | 25 ++- tests/unit_tests/test_main_widnow.py | 54 +++++ 12 files changed, 456 insertions(+), 157 deletions(-) diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index 8b891849..b4566f15 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -1,5 +1,7 @@ from __future__ import annotations +from typing import Literal + from bec_lib import bec_logger from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates @@ -9,37 +11,32 @@ logger = bec_logger.logger def dock_area( - object_name: str | None = None, profile: str | None = None, start_empty: bool = False + object_name: str | None = None, startup_profile: str | Literal["restore", "skip"] | None = None ) -> BECDockArea: """ Create an advanced dock area using Qt Advanced Docking System. Args: object_name(str): The name of the advanced dock area. - profile(str|None): Optional profile to load; if None the "general" profile is used. - start_empty(bool): If True, start with an empty dock area when loading specified profile. + startup_profile(str | Literal["restore", "skip"] | None): Startup mode for + the workspace: + - None: start empty + - "restore": restore last used profile + - "skip": do not initialize profile state + - "": load specific profile Returns: BECDockArea: The created advanced dock area. - Note: - The "general" profile is mandatory and will always exist. If manually deleted, - it will be automatically recreated. """ - # Default to "general" profile when called from CLI without specifying a profile - effective_profile = profile if profile is not None else "general" widget = BECDockArea( object_name=object_name, - restore_initial_profile=True, root_widget=True, profile_namespace="bec", - init_profile=effective_profile, - start_empty=start_empty, - ) - logger.info( - f"Created advanced dock area with profile: {effective_profile}, start_empty: {start_empty}" + startup_profile=startup_profile, ) + logger.info(f"Created advanced dock area with startup_profile: {startup_profile}") return widget diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 224c3d2d..4d1ef6b8 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: # pragma: no cover logger = bec_logger.logger MODULE_PATH = os.path.dirname(bec_widgets.__file__) +START_EMPTY_PROFILE_OPTION = "Start Empty (No Profile)" class LaunchTile(RoundedFrame): @@ -354,7 +355,7 @@ class LaunchWindow(BECMainWindow): def _refresh_dock_area_profiles(self, preserve_selection: bool = True) -> None: """ Refresh the dock-area profile selector, optionally preserving the selection. - Sets the combobox to the last used profile or "general" if no selection preserved. + Defaults to Start Empty when no valid selection can be preserved. Args: preserve_selection(bool): Whether to preserve the current selection or not. @@ -369,9 +370,10 @@ class LaunchWindow(BECMainWindow): ) profiles = list_profiles("bec") + selector_items = [START_EMPTY_PROFILE_OPTION, *profiles] selector.blockSignals(True) selector.clear() - for profile in profiles: + for profile in selector_items: selector.addItem(profile) if selected_text: @@ -380,21 +382,31 @@ class LaunchWindow(BECMainWindow): if idx >= 0: selector.setCurrentIndex(idx) else: - # Selection no longer exists, fall back to last profile or "general" + # Selection no longer exists, fall back to default startup selection. self._set_selector_to_default_profile(selector, profiles) else: - # No selection to preserve, use last profile or "general" + # No selection to preserve, use default startup selection. self._set_selector_to_default_profile(selector, profiles) selector.blockSignals(False) def _set_selector_to_default_profile(self, selector: QComboBox, profiles: list[str]) -> None: """ - Set the selector to the last used profile or "general" as fallback. + Set the selector default. + + Preference order: + 1) Start Empty option (if available) + 2) Last used profile + 3) First available profile Args: selector(QComboBox): The combobox to set. profiles(list[str]): List of available profiles. """ + start_empty_idx = selector.findText(START_EMPTY_PROFILE_OPTION, Qt.MatchFlag.MatchExactly) + if start_empty_idx >= 0: + selector.setCurrentIndex(start_empty_idx) + return + # Try to get last used profile last_profile = get_last_profile(namespace="bec") if last_profile and last_profile in profiles: @@ -403,13 +415,6 @@ class LaunchWindow(BECMainWindow): selector.setCurrentIndex(idx) return - # Fall back to "general" profile - if "general" in profiles: - idx = selector.findText("general", Qt.MatchFlag.MatchExactly) - if idx >= 0: - selector.setCurrentIndex(idx) - return - # If nothing else, select first item if selector.count() > 0: selector.setCurrentIndex(0) @@ -588,11 +593,14 @@ class LaunchWindow(BECMainWindow): """ tile = self.tiles.get("dock_area") if tile is None or tile.selector is None: - profile = None + startup_profile = None else: selection = tile.selector.currentText().strip() - profile = selection if selection else None - return self.launch("dock_area", profile=profile) + if selection == START_EMPTY_PROFILE_OPTION: + startup_profile = None + else: + startup_profile = selection if selection else None + return self.launch("dock_area", startup_profile=startup_profile) def _open_widget(self): """ diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index d5608f3d..1dc674ce 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -350,7 +350,7 @@ class BECDockArea(RPCBase): @rpc_timeout(None) @rpc_call - def load_profile(self, name: "str | None" = None, start_empty: "bool" = False): + def load_profile(self, name: "str | None" = None): """ Load a workspace profile. @@ -359,7 +359,6 @@ class BECDockArea(RPCBase): Args: name (str | None): The name of the profile to load. If None, prompts the user. - start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile. """ @rpc_call diff --git a/bec_widgets/cli/client_utils.py b/bec_widgets/cli/client_utils.py index 61a59f3c..c12a792b 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -322,8 +322,7 @@ class BECGuiClient(RPCBase): wait: bool = True, geometry: tuple[int, int, int, int] | None = None, launch_script: str = "dock_area", - profile: str | None = None, - start_empty: bool = False, + startup_profile: str | Literal["restore", "skip"] | None = None, **kwargs, ) -> client.AdvancedDockArea: """Create a new top-level dock area. @@ -333,24 +332,27 @@ class BECGuiClient(RPCBase): wait(bool, optional): Whether to wait for the server to start. Defaults to True. geometry(tuple[int, int, int, int] | None): The geometry of the dock area (pos_x, pos_y, w, h). launch_script(str): The launch script to use. Defaults to "dock_area". - profile(str | None): The profile name to load. If None, loads the "general" profile. - Use a profile name to load a specific saved profile. - start_empty(bool): If True, start with an empty dock area when loading specified profile. + startup_profile(str | Literal["restore", "skip"] | None): Startup mode for + the dock area: + - None: start in transient empty workspace + - "restore": restore last-used profile + - "skip": skip profile initialization + - "": load the named profile **kwargs: Additional keyword arguments passed to the dock area. Returns: client.AdvancedDockArea: The new dock area. - Note: - The "general" profile is mandatory and will always exist. If manually deleted, - it will be automatically recreated. - Examples: - >>> gui.new() # Start with the "general" profile - >>> gui.new(profile="my_profile") # Load specific profile, if profile does not exist, the new profile is created empty with specified name - >>> gui.new(start_empty=True) # Start with "general" profile but empty dock area - >>> gui.new(profile="my_profile", start_empty=True) # Start with "my_profile" profile but empty dock area + >>> gui.new() # Start with an empty unsaved workspace + >>> gui.new(startup_profile="restore") # Restore last profile + >>> gui.new(startup_profile="my_profile") # Load explicit profile """ + if "profile" in kwargs or "start_empty" in kwargs: + raise TypeError( + "gui.new() no longer accepts 'profile' or 'start_empty'. Use 'startup_profile' instead." + ) + if not self._check_if_server_is_alive(): self.start(wait=True) if wait: @@ -359,16 +361,14 @@ class BECGuiClient(RPCBase): name=name, geometry=geometry, launch_script=launch_script, - profile=profile, - start_empty=start_empty, + startup_profile=startup_profile, **kwargs, ) return self._new_impl( name=name, geometry=geometry, launch_script=launch_script, - profile=profile, - start_empty=start_empty, + startup_profile=startup_profile, **kwargs, ) @@ -378,8 +378,7 @@ class BECGuiClient(RPCBase): name: str | None, geometry: tuple[int, int, int, int] | None, launch_script: str, - profile: str | None, - start_empty: bool, + startup_profile: str | Literal["restore", "skip"] | None, **kwargs, ): if launch_script == "dock_area": @@ -388,8 +387,7 @@ class BECGuiClient(RPCBase): "system.launch_dock_area", name=name, geometry=geometry, - profile=profile, - start_empty=start_empty, + startup_profile=startup_profile, **kwargs, ) except ValueError as exc: @@ -406,8 +404,7 @@ class BECGuiClient(RPCBase): launch_script=launch_script, name=name, geometry=geometry, - profile=profile, - start_empty=start_empty, + startup_profile=startup_profile, **kwargs, ) # pylint: disable=protected-access diff --git a/bec_widgets/utils/rpc_server.py b/bec_widgets/utils/rpc_server.py index d95585ba..f318b8b7 100644 --- a/bec_widgets/utils/rpc_server.py +++ b/bec_widgets/utils/rpc_server.py @@ -4,7 +4,7 @@ import functools import traceback import types from contextlib import contextmanager -from typing import TYPE_CHECKING, Callable, TypeVar +from typing import TYPE_CHECKING, Callable, Literal, TypeVar from bec_lib.client import BECClient from bec_lib.endpoints import MessageEndpoints @@ -204,8 +204,7 @@ class RPCServer: def _launch_dock_area( name: str | None = None, geometry: tuple[int, int, int, int] | None = None, - profile: str | None = None, - start_empty: bool = False, + startup_profile: str | Literal["restore", "skip"] | None = None, ) -> QWidget | None: from bec_widgets.applications import bw_launch @@ -218,9 +217,7 @@ class RPCServer: else: name = WidgetContainerUtils.generate_unique_name("dock_area", existing_dock_areas) - result_widget = bw_launch.dock_area( - object_name=name, profile=profile, start_empty=start_empty - ) + result_widget = bw_launch.dock_area(object_name=name, startup_profile=startup_profile) result_widget.window().setWindowTitle(f"BEC - {name}") if isinstance(result_widget, BECMainWindow): diff --git a/bec_widgets/widgets/containers/auto_update/auto_updates.py b/bec_widgets/widgets/containers/auto_update/auto_updates.py index 856de98e..c259bda1 100644 --- a/bec_widgets/widgets/containers/auto_update/auto_updates.py +++ b/bec_widgets/widgets/containers/auto_update/auto_updates.py @@ -41,7 +41,7 @@ class AutoUpdates(BECMainWindow): parent=self, object_name="dock_area", enable_profile_management=False, - restore_initial_profile=False, + startup_profile="skip", ) self.setCentralWidget(self.dock_area) self._auto_update_selected_device: str | None = None diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index a3aafa5d..cd565b1a 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -87,6 +87,7 @@ logger = bec_logger.logger _PROFILE_NAMESPACE_UNSET = object() PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")} +StartupProfile = Literal["restore", "skip"] | str | None class BECDockArea(DockAreaWidget): @@ -124,9 +125,7 @@ class BECDockArea(DockAreaWidget): instance_id: str | None = None, auto_save_upon_exit: bool = True, enable_profile_management: bool = True, - restore_initial_profile: bool = True, - init_profile: str | None = None, - start_empty: bool = False, + startup_profile: StartupProfile = "restore", **kwargs, ): self._profile_namespace_hint = profile_namespace @@ -135,9 +134,7 @@ class BECDockArea(DockAreaWidget): self._instance_id = slugify.slugify(instance_id, separator="_") 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 - self._init_profile = init_profile - self._start_empty = start_empty + self._startup_profile = self._normalize_startup_profile(startup_profile) super().__init__( parent, default_add_direction=default_add_direction, @@ -162,10 +159,12 @@ class BECDockArea(DockAreaWidget): self._root_layout.insertWidget(0, self.toolbar) # Populate and hook the workspace combo - self._refresh_workspace_list() self._current_profile_name = None + self._empty_profile_active = False + self._empty_profile_consumed = False self._pending_autosave_skip: tuple[str, str] | None = None self._exit_snapshot_written = False + self._refresh_workspace_list() # State manager self.state_manager = WidgetStateManager( @@ -177,84 +176,85 @@ class BECDockArea(DockAreaWidget): # Initialize default editable state based on current lock self._set_editable(True) # default to editable; will sync toolbar toggle below - if self._ensure_initial_profile(): - self._refresh_workspace_list() - # Apply the requested mode after everything is set up self.mode = mode - if self._restore_initial_profile: - self._fetch_initial_profile() + self._fetch_initial_profile() - def _ensure_initial_profile(self) -> bool: + @staticmethod + def _normalize_startup_profile(startup_profile: StartupProfile) -> StartupProfile: """ - Ensure the "general" workspace profile always exists for the current namespace. - The "general" profile is mandatory and will be recreated if deleted. - If list_profile fails due to file permission or corrupted profiles, no action taken. - - Returns: - bool: True if a profile was created, False otherwise. + Normalize startup profile values. """ - namespace = self.profile_namespace - try: - existing_profiles = list_profiles(namespace) - except Exception as exc: # pragma: no cover - defensive guard - logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}") - return False + if startup_profile == "": + return None + return startup_profile - # Always ensure "general" profile exists - name = "general" - if name in existing_profiles: - return False - - logger.info( - f"Profile '{name}' not found in namespace '{namespace}'. Creating mandatory '{name}' workspace." - ) - - self._write_profile_settings(name, namespace, save_preview=False) - set_quick_select(name, True, namespace=namespace) - set_last_profile(name, namespace=namespace, instance=self._last_profile_instance_id()) - return True - - def _fetch_initial_profile(self): - # Restore last-used profile if available; otherwise fall back to combo selection + def _resolve_restore_startup_profile(self) -> str | None: + """ + Resolve the profile name when startup profile is set to "restore". + """ combo = self.toolbar.components.get_action("workspace_combo").widget namespace = self.profile_namespace - init_profile = None - # First priority: use init_profile if explicitly provided - if self._init_profile: - init_profile = self._init_profile - else: - # Try to restore from last used profile - instance_id = self._last_profile_instance_id() - if instance_id: - inst_profile = get_last_profile( - namespace=namespace, instance=instance_id, allow_namespace_fallback=False - ) - if inst_profile and self._profile_exists(inst_profile, namespace): - init_profile = inst_profile - if not init_profile: - 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: - # Fall back to "general" profile which is guaranteed to exist - if self._profile_exists("general", namespace): - init_profile = "general" - if init_profile: - self._load_initial_profile(init_profile) + instance_id = self._last_profile_instance_id() + if instance_id: + inst_profile = get_last_profile( + namespace=namespace, instance=instance_id, allow_namespace_fallback=False + ) + if inst_profile and self._profile_exists(inst_profile, namespace): + return inst_profile + + last = get_last_profile(namespace=namespace) + if last and self._profile_exists(last, namespace): + return last + + combo_text = combo.currentText().strip() + if combo_text and self._profile_exists(combo_text, namespace): + return combo_text + + return None + + def _fetch_initial_profile(self): + startup_profile = self._startup_profile + + if startup_profile == "skip": + logger.debug("Skipping startup profile initialization.") + return + + if startup_profile == "restore": + restored = self._resolve_restore_startup_profile() + if restored: + self._load_initial_profile(restored) + return + self._start_empty_workspace() + return + + if startup_profile is None: + self._start_empty_workspace() + return + + self._load_initial_profile(startup_profile) def _load_initial_profile(self, name: str) -> None: """Load the initial profile.""" - self.load_profile(name, start_empty=self._start_empty) + self.load_profile(name) combo = self.toolbar.components.get_action("workspace_combo").widget combo.blockSignals(True) - combo.setCurrentText(name) + if not self._empty_profile_active: + combo.setCurrentText(name) combo.blockSignals(False) + def _start_empty_workspace(self) -> None: + """ + Initialize the dock area in transient empty-profile mode. + """ + if ( + getattr(self, "_current_profile_name", None) is None + and not self._empty_profile_consumed + ): + self.delete_all() + self._enter_empty_profile_state() + def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None: prefs = getattr(dock, "_dock_preferences", {}) or {} if prefs.get("show_settings_action") is None: @@ -601,13 +601,6 @@ class BECDockArea(DockAreaWidget): """Namespace used to scope user/default profile files for this dock area.""" return self._resolve_profile_namespace() - def _active_profile_name_or_default(self) -> str: - name = getattr(self, "_current_profile_name", None) - if not name: - name = "general" - 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) @@ -675,12 +668,26 @@ class BECDockArea(DockAreaWidget): name: The profile name. namespace: The profile namespace. """ + self._empty_profile_active = False + self._empty_profile_consumed = True self._current_profile_name = name self.profile_changed.emit(name) 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) + def _enter_empty_profile_state(self) -> None: + """ + Switch to the transient empty workspace state. + + In this mode there is no active profile name, the toolbar shows an + explicit blank profile entry, and no autosave on shutdown is performed. + """ + self._empty_profile_active = True + self._current_profile_name = None + self._pending_autosave_skip = None + self._refresh_workspace_list() + @SafeSlot() def list_profiles(self) -> list[str]: """ @@ -814,10 +821,10 @@ class BECDockArea(DockAreaWidget): """ self.save_profile(name, show_dialog=True) + @SafeSlot() @SafeSlot(str) - @SafeSlot(str, bool) @rpc_timeout(None) - def load_profile(self, name: str | None = None, start_empty: bool = False): + def load_profile(self, name: str | None = None): """ Load a workspace profile. @@ -826,8 +833,10 @@ class BECDockArea(DockAreaWidget): Args: name (str | None): The name of the profile to load. If None, prompts the user. - start_empty (bool): If True, load a profile without any widgets. Danger of overwriting the dynamic state of that profile. """ + if name == "": + return + if not name: # Gui fallback if the name is not provided name, ok = QInputDialog.getText( self, "Load Workspace", "Enter the name of the workspace profile to load:" @@ -859,10 +868,6 @@ class BECDockArea(DockAreaWidget): # Clear existing docks and remove all widgets self.delete_all() - if start_empty: - self._finalize_profile_change(name, namespace) - return - # Rebuild widgets and restore states for item in read_manifest(settings): obj_name = item["object_name"] @@ -1008,25 +1013,36 @@ class BECDockArea(DockAreaWidget): """ combo = self.toolbar.components.get_action("workspace_combo").widget active_profile = getattr(self, "_current_profile_name", None) + empty_profile_active = bool(getattr(self, "_empty_profile_active", False)) namespace = self.profile_namespace if hasattr(combo, "set_quick_profile_provider"): combo.set_quick_profile_provider(lambda ns=namespace: list_quick_profiles(namespace=ns)) if hasattr(combo, "refresh_profiles"): - combo.refresh_profiles(active_profile) + if empty_profile_active: + combo.refresh_profiles(active_profile, show_empty_profile=True) + else: + combo.refresh_profiles(active_profile) else: # Fallback for regular QComboBox combo.blockSignals(True) combo.clear() quick_profiles = list_quick_profiles(namespace=namespace) - items = list(quick_profiles) + items = [""] if empty_profile_active else [] + items.extend(quick_profiles) if active_profile and active_profile not in items: items.insert(0, active_profile) combo.addItems(items) - if active_profile: + if empty_profile_active: + idx = combo.findText("") + if idx >= 0: + combo.setCurrentIndex(idx) + elif active_profile: idx = combo.findText(active_profile) if idx >= 0: combo.setCurrentIndex(idx) - if active_profile and active_profile not in quick_profiles: + if empty_profile_active: + combo.setToolTip("Unsaved empty workspace") + elif active_profile and active_profile not in quick_profiles: combo.setToolTip("Active profile is not in quick select") else: combo.setToolTip("") @@ -1131,7 +1147,16 @@ class BECDockArea(DockAreaWidget): logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)") return - name = self._active_profile_name_or_default() + if getattr(self, "_empty_profile_active", False): + logger.info("ADS prepare_for_shutdown: skipping autosave for unsaved empty workspace") + self._exit_snapshot_written = True + return + + name = getattr(self, "_current_profile_name", None) + if not name: + logger.info("ADS prepare_for_shutdown: skipping autosave (no active profile)") + self._exit_snapshot_written = True + return namespace = self.profile_namespace settings = open_user_settings(name, namespace=namespace) diff --git a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py index 3eea7237..5ab35058 100644 --- a/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/dock_area/toolbar_components/workspace_actions.py @@ -24,12 +24,15 @@ class ProfileComboBox(QComboBox): def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None: self._quick_provider = provider - def refresh_profiles(self, active_profile: str | None = None): + def refresh_profiles( + self, active_profile: str | None = None, show_empty_profile: bool = False + ) -> None: """ Refresh the profile list and ensure the active profile is visible. Args: active_profile(str | None): The currently active profile name. + show_empty_profile(bool): If True, show an explicit empty unsaved workspace entry. """ current_text = active_profile or self.currentText() @@ -39,9 +42,22 @@ class ProfileComboBox(QComboBox): quick_profiles = self._quick_provider() quick_set = set(quick_profiles) - items = list(quick_profiles) + items: list[str] = [] + if show_empty_profile: + items.append("") + if active_profile and active_profile not in quick_set: - items.insert(0, active_profile) + items.append(active_profile) + + for profile in quick_profiles: + if profile not in items: + items.append(profile) + + if active_profile and active_profile not in quick_set: + # keep active profile at the top when not in quick list + items.remove(active_profile) + insert_pos = 1 if show_empty_profile else 0 + items.insert(insert_pos, active_profile) for profile in items: self.addItem(profile) @@ -52,6 +68,15 @@ class ProfileComboBox(QComboBox): self.setItemData(idx, None, Qt.ItemDataRole.ToolTipRole) self.setItemData(idx, None, Qt.ItemDataRole.ForegroundRole) + if profile == "": + self.setItemData(idx, "Unsaved empty workspace", Qt.ItemDataRole.ToolTipRole) + if active_profile is None: + font = QFont(self.font()) + font.setItalic(True) + self.setItemData(idx, font, Qt.ItemDataRole.FontRole) + self.setCurrentIndex(idx) + continue + if active_profile and profile == active_profile: tooltip = "Active workspace profile" if profile not in quick_set: @@ -69,16 +94,23 @@ class ProfileComboBox(QComboBox): self.setItemData(idx, "Not in quick select", Qt.ItemDataRole.ToolTipRole) # Restore selection if possible - index = self.findText(current_text) - if index >= 0: - self.setCurrentIndex(index) + if show_empty_profile and active_profile is None: + empty_idx = self.findText("") + if empty_idx >= 0: + self.setCurrentIndex(empty_idx) + else: + index = self.findText(current_text) + if index >= 0: + self.setCurrentIndex(index) self.blockSignals(False) if active_profile and self.currentText() != active_profile: idx = self.findText(active_profile) if idx >= 0: self.setCurrentIndex(idx) - if active_profile and active_profile not in quick_set: + if show_empty_profile and self.currentText() == "": + self.setToolTip("Unsaved empty workspace") + elif active_profile and active_profile not in quick_set: self.setToolTip("Active profile is not in quick select") else: self.setToolTip("") diff --git a/tests/unit_tests/test_client_utils.py b/tests/unit_tests/test_client_utils.py index fbaa84b5..516bc34b 100644 --- a/tests/unit_tests/test_client_utils.py +++ b/tests/unit_tests/test_client_utils.py @@ -127,3 +127,133 @@ def test_client_utils_apply_theme_toggles_when_none(current_theme, expected_them mock.call("fetch_theme"), mock.call("change_theme", theme=expected_theme), ] + + +def test_client_utils_new_passes_startup_profile(): + gui = BECGuiClient() + launcher = mock.MagicMock() + + with mock.patch.object( + BECGuiClient, "launcher", new_callable=mock.PropertyMock + ) as launcher_prop: + launcher_prop.return_value = launcher + with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server): + with mock.patch.object(gui, "_check_if_server_is_alive", return_value=True): + gui.new(startup_profile="saved_profile") + + launcher._run_rpc.assert_called_once_with( + "system.launch_dock_area", name=None, geometry=None, startup_profile="saved_profile" + ) + + +def test_client_utils_new_defaults_to_empty_startup_profile(): + gui = BECGuiClient() + launcher = mock.MagicMock() + + with mock.patch.object( + BECGuiClient, "launcher", new_callable=mock.PropertyMock + ) as launcher_prop: + launcher_prop.return_value = launcher + with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server): + with mock.patch.object(gui, "_check_if_server_is_alive", return_value=True): + gui.new() + + launcher._run_rpc.assert_called_once_with( + "system.launch_dock_area", name=None, geometry=None, startup_profile=None + ) + + +def test_client_utils_new_rejects_legacy_profile_kwargs(): + gui = BECGuiClient() + with pytest.raises(TypeError, match="startup_profile"): + gui.new(profile="saved_profile") + + +def test_client_utils_new_falls_back_when_system_rpc_not_supported(): + gui = BECGuiClient() + launcher = mock.MagicMock() + launcher._run_rpc.side_effect = [ + ValueError("Unknown system RPC method: system.launch_dock_area"), + "fallback_widget", + ] + + with mock.patch.object( + BECGuiClient, "launcher", new_callable=mock.PropertyMock + ) as launcher_prop: + launcher_prop.return_value = launcher + with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server): + with mock.patch.object(gui, "_check_if_server_is_alive", return_value=True): + result = gui.new(startup_profile="restore") + + assert result == "fallback_widget" + assert launcher._run_rpc.call_args_list == [ + mock.call("system.launch_dock_area", name=None, geometry=None, startup_profile="restore"), + mock.call( + "launch", launch_script="dock_area", name=None, geometry=None, startup_profile="restore" + ), + ] + + +def test_client_utils_new_reraises_unexpected_system_rpc_error(): + gui = BECGuiClient() + launcher = mock.MagicMock() + launcher._run_rpc.side_effect = ValueError("Some other RPC error") + + with mock.patch.object( + BECGuiClient, "launcher", new_callable=mock.PropertyMock + ) as launcher_prop: + launcher_prop.return_value = launcher + with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server): + with mock.patch.object(gui, "_check_if_server_is_alive", return_value=True): + with pytest.raises(ValueError, match="Some other RPC error"): + gui.new(startup_profile="restore") + + +def test_client_utils_new_starts_server_when_not_alive(): + gui = BECGuiClient() + launcher = mock.MagicMock() + + with mock.patch.object( + BECGuiClient, "launcher", new_callable=mock.PropertyMock + ) as launcher_prop: + launcher_prop.return_value = launcher + with mock.patch("bec_widgets.cli.client_utils.wait_for_server", _no_wait_for_server): + with ( + mock.patch.object(gui, "_check_if_server_is_alive", return_value=False), + mock.patch.object(gui, "start") as mock_start, + ): + gui.new(wait=False, startup_profile=None) + + mock_start.assert_called_once_with(wait=True) + + +def test_client_utils_delete_uses_container_proxy(): + gui = BECGuiClient() + widget = mock.MagicMock() + widget._gui_id = "widget-id" + + with ( + mock.patch.object(BECGuiClient, "windows", new_callable=mock.PropertyMock) as windows_prop, + mock.patch.dict( + gui._server_registry, {"widget-id": {"container_proxy": "container-id"}}, clear=True + ), + ): + windows_prop.return_value = {"dock": widget} + gui.delete("dock") + + widget._run_rpc.assert_called_once_with("close", gui_id="container-id") + + +def test_client_utils_delete_falls_back_to_direct_close(): + gui = BECGuiClient() + widget = mock.MagicMock() + widget._gui_id = "widget-id" + + with ( + mock.patch.object(BECGuiClient, "windows", new_callable=mock.PropertyMock) as windows_prop, + mock.patch.dict(gui._server_registry, {"widget-id": {"container_proxy": None}}, clear=True), + ): + windows_prop.return_value = {"dock": widget} + gui.delete("dock") + + widget._run_rpc.assert_called_once_with("close") diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index 0222e14f..ef2c3792 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -1518,6 +1518,8 @@ class TestAdvancedDockAreaRestoreAndDialogs: profile_name = "refresh_profile" helper = profile_helper(advanced_dock_area) helper.open_user(profile_name).sync() + # Simulate a normal named-profile state (not transient empty startup mode). + advanced_dock_area._empty_profile_active = False advanced_dock_area._current_profile_name = profile_name combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget combo.refresh_profiles = MagicMock() @@ -1526,6 +1528,16 @@ class TestAdvancedDockAreaRestoreAndDialogs: combo.refresh_profiles.assert_called_once_with(profile_name) + def test_refresh_workspace_list_with_empty_workspace_state(self, advanced_dock_area): + combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget + combo.refresh_profiles = MagicMock() + advanced_dock_area._current_profile_name = None + advanced_dock_area._empty_profile_active = True + + advanced_dock_area._refresh_workspace_list() + + combo.refresh_profiles.assert_called_once_with(None, show_empty_profile=True) + def test_refresh_workspace_list_fallback(self, advanced_dock_area): class ComboStub: def __init__(self): @@ -1573,6 +1585,8 @@ class TestAdvancedDockAreaRestoreAndDialogs: with patch.object( advanced_dock_area.toolbar.components, "get_action", return_value=StubAction(combo_stub) ): + # Simulate a normal named-profile state (not transient empty startup mode). + advanced_dock_area._empty_profile_active = False advanced_dock_area._current_profile_name = active advanced_dock_area._refresh_workspace_list() @@ -1728,6 +1742,29 @@ class TestProfileManagement: class TestWorkspaceProfileOperations: """Test workspace profile save/load/delete operations.""" + def test_empty_startup_profile_creates_transient_unsaved_workspace(self, qtbot, mocked_client): + widget = BECDockArea(client=mocked_client, startup_profile=None) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + helper = profile_helper(widget) + + assert widget._empty_profile_active is True + assert widget._empty_profile_consumed is False + assert widget._current_profile_name is None + combo = widget.toolbar.components.get_action("workspace_combo").widget + assert combo.currentText() == "" + + with patch.object(widget, "_write_snapshot_to_settings") as mock_write: + widget.prepare_for_shutdown() + mock_write.assert_not_called() + + helper.open_user("real_profile").sync() + widget.load_profile("real_profile") + assert widget._empty_profile_active is False + assert widget._empty_profile_consumed is True + assert widget._current_profile_name == "real_profile" + assert combo.currentText() == "real_profile" + def test_save_profile_readonly_conflict( self, advanced_dock_area, temp_profile_dir, module_profile_factory ): diff --git a/tests/unit_tests/test_launch_window.py b/tests/unit_tests/test_launch_window.py index 7967df65..44df9022 100644 --- a/tests/unit_tests/test_launch_window.py +++ b/tests/unit_tests/test_launch_window.py @@ -7,7 +7,7 @@ import pytest from qtpy.QtGui import QFontMetrics import bec_widgets -from bec_widgets.applications.launch_window import LaunchWindow +from bec_widgets.applications.launch_window import START_EMPTY_PROFILE_OPTION, LaunchWindow from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow @@ -84,6 +84,29 @@ def test_launch_window_launch_plugin_auto_update(bec_launch_window): res.deleteLater() +def test_launch_window_dock_area_selector_has_start_empty_option(bec_launch_window): + selector = bec_launch_window.tiles["dock_area"].selector + assert selector is not None + assert selector.findText(START_EMPTY_PROFILE_OPTION) >= 0 + + +def test_launch_window_dock_area_selector_defaults_to_start_empty(bec_launch_window): + selector = bec_launch_window.tiles["dock_area"].selector + assert selector is not None + assert selector.currentText() == START_EMPTY_PROFILE_OPTION + + +def test_open_dock_area_with_start_empty_option_calls_launch(bec_launch_window): + selector = bec_launch_window.tiles["dock_area"].selector + assert selector is not None + selector.setCurrentText(START_EMPTY_PROFILE_OPTION) + + with mock.patch.object(bec_launch_window, "launch") as mock_launch: + bec_launch_window._open_dock_area() + + mock_launch.assert_called_once_with("dock_area", startup_profile=None) + + @pytest.mark.parametrize( "connection_names, hide", [ diff --git a/tests/unit_tests/test_main_widnow.py b/tests/unit_tests/test_main_widnow.py index ee0c1309..a23924f4 100644 --- a/tests/unit_tests/test_main_widnow.py +++ b/tests/unit_tests/test_main_widnow.py @@ -1,4 +1,6 @@ import webbrowser +from types import SimpleNamespace +from unittest.mock import MagicMock, patch import pytest from qtpy.QtCore import QEvent, QPoint, QPointF @@ -55,6 +57,58 @@ def test_status_bar_has_separator(bec_main_window): assert separators, "Expected at least one QFrame separator in the status bar." +def test_display_app_id_not_connected(bec_main_window): + with patch.object(bec_main_window.bec_dispatcher, "cli_server", None): + bec_main_window.display_app_id() + assert bec_main_window._app_id_label.text() == "Not connected" + + +def test_display_app_id_connected(bec_main_window): + with patch.object(bec_main_window.bec_dispatcher, "cli_server", MagicMock(gui_id="gui_123")): + bec_main_window.display_app_id() + assert bec_main_window._app_id_label.text() == "App ID: gui_123" + + +def test_event_consumes_status_tip(bec_main_window): + status_tip_event = QEvent(QEvent.Type.StatusTip) + assert bec_main_window.event(status_tip_event) is True + + +def test_get_launcher_from_qapp_returns_none_when_absent(bec_main_window): + with patch.object( + QApplication, "instance", return_value=SimpleNamespace(topLevelWidgets=lambda: []) + ): + assert bec_main_window._get_launcher_from_qapp() is None + + +def test_show_launcher_warns_when_cli_server_missing(bec_main_window): + with ( + patch.object(bec_main_window.bec_dispatcher, "cli_server", None), + patch.object(bec_main_window, "_get_launcher_from_qapp", return_value=None), + patch("bec_widgets.widgets.containers.main_window.main_window.logger.warning") as mock_warn, + ): + bec_main_window._show_launcher() + mock_warn.assert_called_once() + + +def test_show_launcher_creates_launcher_when_missing(bec_main_window): + launcher = MagicMock() + + with ( + patch.object(bec_main_window.bec_dispatcher, "cli_server", MagicMock(gui_id="server_id")), + patch.object(bec_main_window, "_get_launcher_from_qapp", return_value=None), + patch("bec_widgets.applications.launch_window.LaunchWindow", return_value=launcher) as cls, + ): + bec_main_window._show_launcher() + + cls.assert_called_once_with(gui_id="server_id:launcher") + launcher.setAttribute.assert_called_once() + launcher.show.assert_called_once() + launcher.activateWindow.assert_called_once() + launcher.raise_.assert_called_once() + assert bec_main_window._launcher_window is launcher + + ################################################################# # Tests for BECMainWindow Addons #################################################################