mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-04 16:02:51 +01:00
fix(dock_area): profile management with empty profile, applied across the whole repo
This commit is contained in:
@@ -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
|
||||
- "<name>": 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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
- "<name>": 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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -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
|
||||
#################################################################
|
||||
|
||||
Reference in New Issue
Block a user