diff --git a/bec_widgets/applications/bw_launch.py b/bec_widgets/applications/bw_launch.py index b887ee1a..1a753edb 100644 --- a/bec_widgets/applications/bw_launch.py +++ b/bec_widgets/applications/bw_launch.py @@ -8,26 +8,38 @@ from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates logger = bec_logger.logger -def dock_area(object_name: str | None = None, profile: str | None = None) -> AdvancedDockArea: +def dock_area( + object_name: str | None = None, profile: str | None = None, start_empty: bool = False +) -> AdvancedDockArea: """ 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 last profile is restored. + 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. Returns: AdvancedDockArea: 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 = AdvancedDockArea( object_name=object_name, - restore_initial_profile=(profile is None), + 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}" ) - if profile: - widget.load_profile(profile) - logger.info(f"Created advanced dock area with profile: {profile}") return widget diff --git a/bec_widgets/applications/launch_window.py b/bec_widgets/applications/launch_window.py index 71c2c35e..8505b1b8 100644 --- a/bec_widgets/applications/launch_window.py +++ b/bec_widgets/applications/launch_window.py @@ -30,7 +30,10 @@ from bec_widgets.utils.round_frame import RoundedFrame from bec_widgets.utils.toolbars.toolbar import ModularToolBar from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea -from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import list_profiles +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + get_last_profile, + list_profiles, +) from bec_widgets.widgets.containers.auto_update.auto_updates import AutoUpdates from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow, BECMainWindowNoRPC from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton @@ -182,7 +185,6 @@ class LaunchTile(RoundedFrame): class LaunchWindow(BECMainWindow): RPC = True TILE_SIZE = (250, 300) - DEFAULT_WORKSPACE_OPTION = "Last used workspace" USER_ACCESS = ["show_launcher", "hide_launcher"] def __init__( @@ -345,6 +347,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. Args: preserve_selection(bool): Whether to preserve the current selection or not. @@ -361,19 +364,49 @@ class LaunchWindow(BECMainWindow): profiles = list_profiles("bec") selector.blockSignals(True) selector.clear() - selector.addItem(self.DEFAULT_WORKSPACE_OPTION) for profile in profiles: selector.addItem(profile) - if not selected_text or selected_text == self.DEFAULT_WORKSPACE_OPTION: - idx = 0 - else: + if selected_text: + # Try to preserve the current selection idx = selector.findText(selected_text, Qt.MatchFlag.MatchExactly) - if idx < 0: - idx = 0 - selector.setCurrentIndex(idx) + if idx >= 0: + selector.setCurrentIndex(idx) + else: + # Selection no longer exists, fall back to last profile or "general" + self._set_selector_to_default_profile(selector, profiles) + else: + # No selection to preserve, use last profile or "general" + 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. + + Args: + selector(QComboBox): The combobox to set. + profiles(list[str]): List of available profiles. + """ + # Try to get last used profile + last_profile = get_last_profile(namespace="bec") + if last_profile and last_profile in profiles: + idx = selector.findText(last_profile, Qt.MatchFlag.MatchExactly) + if idx >= 0: + 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) + def launch( self, launch_script: str, @@ -541,17 +574,14 @@ class LaunchWindow(BECMainWindow): def _open_dock_area(self): """ - Open Advanced Dock Area using the selected profile (if any). + Open Advanced Dock Area using the selected profile. """ tile = self.tiles.get("dock_area") if tile is None or tile.selector is None: profile = None else: selection = tile.selector.currentText().strip() - if not selection or selection == self.DEFAULT_WORKSPACE_OPTION: - profile = None - else: - profile = selection + profile = selection if selection else None return self.launch("dock_area", profile=profile) def _open_widget(self): diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index a224c2a0..6fd5dd0c 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -293,7 +293,7 @@ class AdvancedDockArea(RPCBase): @rpc_timeout(None) @rpc_call - def load_profile(self, name: "str | None" = None): + def load_profile(self, name: "str | None" = None, start_empty: "bool" = False): """ Load a workspace profile. @@ -302,6 +302,7 @@ class AdvancedDockArea(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 87303156..77a24e49 100644 --- a/bec_widgets/cli/client_utils.py +++ b/bec_widgets/cli/client_utils.py @@ -304,7 +304,7 @@ class BECGuiClient(RPCBase): geometry: tuple[int, int, int, int] | None = None, launch_script: str = "dock_area", profile: str | None = None, - empty: bool = False, + start_empty: bool = False, **kwargs, ) -> client.AdvancedDockArea: """Create a new top-level dock area. @@ -314,24 +314,24 @@ 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, restores the last used profile. + profile(str | None): The profile name to load. If None, loads the "general" profile. Use a profile name to load a specific saved profile. - empty(bool): If True, start with an empty dock area without loading any profile. - This takes precedence over the profile argument. Defaults to False. + start_empty(bool): If True, start with an empty dock area when loading specified 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() # Restore last used profile - >>> gui.new(profile="my_profile") # Load specific profile, if profile do not exist, the new profile is created empty with specified name - >>> gui.new(empty=True) # Start with empty dock area - >>> gui.new(name="custom_dock", empty=True) # Named empty dock area + >>> 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 """ - if empty: - # Use a unique non-existent profile name to ensure empty start - profile = "__empty__" if not self._check_if_server_is_alive(): self.start(wait=True) if wait: @@ -342,6 +342,7 @@ class BECGuiClient(RPCBase): name=name, geometry=geometry, profile=profile, + start_empty=start_empty, **kwargs, ) # pylint: disable=protected-access return widget @@ -351,6 +352,7 @@ class BECGuiClient(RPCBase): name=name, geometry=geometry, profile=profile, + start_empty=start_empty, **kwargs, ) # pylint: disable=protected-access return widget 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 d913df00..c9a4812e 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 @@ -126,6 +126,8 @@ class AdvancedDockArea(DockAreaWidget): 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, **kwargs, ): self._profile_namespace_hint = profile_namespace @@ -135,6 +137,8 @@ class AdvancedDockArea(DockAreaWidget): 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 super().__init__( parent, default_add_direction=default_add_direction, @@ -184,7 +188,8 @@ class AdvancedDockArea(DockAreaWidget): def _ensure_initial_profile(self) -> bool: """ - Ensure at least one workspace profile exists for the current namespace. + 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: @@ -197,12 +202,13 @@ class AdvancedDockArea(DockAreaWidget): logger.warning(f"Unable to enumerate profiles for namespace '{namespace}': {exc}") return False - if existing_profiles: + # Always ensure "general" profile exists + name = "general" + if name in existing_profiles: return False - name = "general" logger.info( - f"No profiles found for namespace '{namespace}'. Bootstrapping '{name}' workspace." + f"Profile '{name}' not found in namespace '{namespace}'. Creating mandatory '{name}' workspace." ) self._write_profile_settings(name, namespace, save_preview=False) @@ -215,30 +221,37 @@ class AdvancedDockArea(DockAreaWidget): combo = self.toolbar.components.get_action("workspace_combo").widget namespace = self.profile_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 - ) - 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: - if self._profile_exists("general", namespace): - init_profile = "general" + + # 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: # Defer initial load to the event loop so child widgets exist before state restore. QTimer.singleShot(0, lambda: self._load_initial_profile(init_profile)) def _load_initial_profile(self, name: str) -> None: """Load the initial profile after construction when the event loop is running.""" - self.load_profile(name) + self.load_profile(name, start_empty=self._start_empty) combo = self.toolbar.components.get_action("workspace_combo").widget combo.blockSignals(True) combo.setCurrentText(name) @@ -807,8 +820,9 @@ class AdvancedDockArea(DockAreaWidget): self.save_profile(name, show_dialog=True) @SafeSlot(str) + @SafeSlot(str, bool) @rpc_timeout(None) - def load_profile(self, name: str | None = None): + def load_profile(self, name: str | None = None, start_empty: bool = False): """ Load a workspace profile. @@ -817,6 +831,7 @@ class AdvancedDockArea(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 not name: # Gui fallback if the name is not provided name, ok = QInputDialog.getText( @@ -849,7 +864,7 @@ class AdvancedDockArea(DockAreaWidget): # Clear existing docks and remove all widgets self.delete_all() - if name == "__empty__": + if start_empty: self._finalize_profile_change(name, namespace) return