1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-05 00:12:49 +01:00

feat(advanced_dock_area): created DockAreaWidget base class; profile management through namespaces; dock area variants

This commit is contained in:
2025-11-04 16:59:23 +01:00
committed by Klaus Wakonig
parent 28c0b3b18b
commit d259de227d
8 changed files with 2758 additions and 615 deletions

View File

@@ -1,11 +1,11 @@
from __future__ import annotations
import os
from typing import Literal, cast
from typing import Callable, Literal, Mapping, Sequence
import PySide6QtAds as QtAds
from bec_lib import bec_logger
from PySide6QtAds import CDockManager, CDockWidget
from PySide6QtAds import CDockWidget
from qtpy.QtCore import QTimer, Signal
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
@@ -17,13 +17,11 @@ from qtpy.QtWidgets import (
QVBoxLayout,
QWidget,
)
from shiboken6 import isValid
from bec_widgets import BECWidget, SafeProperty, SafeSlot
from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.property_editor import PropertyEditor
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
@@ -32,11 +30,15 @@ from bec_widgets.utils.toolbars.actions import (
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
from bec_widgets.utils.widget_state_manager import WidgetStateManager
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import DockAreaWidget
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
SETTINGS_KEYS,
default_profile_path,
default_profile_candidates,
delete_profile_files,
get_last_profile,
is_profile_read_only,
is_quick_select,
list_quick_profiles,
load_default_profile_screenshot,
load_user_profile_screenshot,
now_iso_utc,
@@ -46,9 +48,10 @@ from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
profile_origin_display,
read_manifest,
restore_user_from_default,
sanitize_namespace,
set_last_profile,
set_quick_select,
user_profile_path,
user_profile_candidates,
write_manifest,
)
from bec_widgets.widgets.containers.advanced_dock_area.settings.dialogs import (
@@ -80,30 +83,26 @@ from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import
logger = bec_logger.logger
_PROFILE_NAMESPACE_UNSET = object()
class DockSettingsDialog(QDialog):
def __init__(self, parent: QWidget, target: QWidget):
super().__init__(parent)
self.setWindowTitle("Dock Settings")
self.setModal(True)
layout = QVBoxLayout(self)
# Property editor
self.prop_editor = PropertyEditor(target, self, show_only_bec=True)
layout.addWidget(self.prop_editor)
PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_state")}
class AdvancedDockArea(BECWidget, QWidget):
class AdvancedDockArea(DockAreaWidget):
RPC = True
PLUGIN = False
USER_ACCESS = [
"new",
"dock_map",
"dock_list",
"widget_map",
"widget_list",
"lock_workspace",
"attach_all",
"delete_all",
"set_layout_ratios",
"describe_layout",
"print_layout_structure",
"mode",
"mode.setter",
]
@@ -115,38 +114,34 @@ class AdvancedDockArea(BECWidget, QWidget):
def __init__(
self,
parent=None,
mode: str = "developer",
mode: Literal["plot", "device", "utils", "user", "creator"] = "creator",
default_add_direction: Literal["left", "right", "top", "bottom"] = "right",
*args,
profile_namespace: str | None = None,
auto_profile_namespace: bool = True,
auto_save_upon_exit: bool = True,
enable_profile_management: bool = True,
restore_initial_profile: bool = True,
**kwargs,
):
super().__init__(parent=parent, *args, **kwargs)
# Title (as a top-level QWidget it can have a window title)
self.setWindowTitle("Advanced Dock Area")
# Top-level layout hosting a toolbar and the dock manager
self._root_layout = QVBoxLayout(self)
self._root_layout.setContentsMargins(0, 0, 0, 0)
self._root_layout.setSpacing(0)
# Init Dock Manager
self.dock_manager = CDockManager(self)
self.dock_manager.setStyleSheet("")
# Dock manager helper variables
self._locked = False # Lock state of the workspace
self._profile_namespace_hint = profile_namespace
self._profile_namespace_auto = auto_profile_namespace
self._profile_namespace_resolved: str | None | object = _PROFILE_NAMESPACE_UNSET
self._auto_save_upon_exit = auto_save_upon_exit
self._profile_management_enabled = enable_profile_management
self._restore_initial_profile = restore_initial_profile
super().__init__(
parent,
default_add_direction=default_add_direction,
title="Advanced Dock Area",
**kwargs,
)
# Initialize mode property first (before toolbar setup)
self._mode = "developer"
self._default_add_direction = (
default_add_direction
if default_add_direction in ("left", "right", "top", "bottom")
else "right"
)
self._mode = mode
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self.dark_mode_button.setVisible(enable_profile_management)
self._setup_toolbar()
self._hook_toolbar()
@@ -154,14 +149,14 @@ class AdvancedDockArea(BECWidget, QWidget):
self.save_dialog = None
self.manage_dialog = None
# Place toolbar and dock manager into layout
self._root_layout.addWidget(self.toolbar)
self._root_layout.addWidget(self.dock_manager, 1)
# Place toolbar above the dock manager provided by the base class
self._root_layout.insertWidget(0, self.toolbar)
# Populate and hook the workspace combo
self._refresh_workspace_list()
self._current_profile_name = None
self._pending_autosave_skip: tuple[str, str] | None = None
self._exit_snapshot_written = False
# State manager
self.state_manager = WidgetStateManager(self)
@@ -177,79 +172,96 @@ class AdvancedDockArea(BECWidget, QWidget):
# Apply the requested mode after everything is set up
self.mode = mode
QTimer.singleShot(
0, self._fetch_initial_profile
) # To allow full init before loading profile and prevent segfault on exit
if self._restore_initial_profile:
self._fetch_initial_profile()
def _fetch_initial_profile(self):
# Restore last-used profile if available; otherwise fall back to combo selection
combo = self.toolbar.components.get_action("workspace_combo").widget
last = get_last_profile()
if last and (
os.path.exists(user_profile_path(last)) or os.path.exists(default_profile_path(last))
):
init_profile = last
namespace = self.profile_namespace
last = get_last_profile(namespace)
if last:
user_exists = any(
os.path.exists(path) for path in user_profile_candidates(last, namespace)
)
default_exists = any(
os.path.exists(path) for path in default_profile_candidates(last, namespace)
)
init_profile = last if (user_exists or default_exists) else None
else:
init_profile = combo.currentText()
if not init_profile:
general_exists = any(
os.path.exists(path) for path in user_profile_candidates("general", namespace)
) or any(
os.path.exists(path) for path in default_profile_candidates("general", namespace)
)
if general_exists:
init_profile = "general"
if init_profile:
self.load_profile(init_profile)
combo.setCurrentText(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 _make_dock(
def _load_initial_profile(self, name: str) -> None:
"""Load the initial profile after construction when the event loop is running."""
self.load_profile(name)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.blockSignals(True)
combo.setCurrentText(name)
combo.blockSignals(False)
def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None:
prefs = getattr(dock, "_dock_preferences", {}) or {}
if prefs.get("show_settings_action") is None:
prefs = dict(prefs)
prefs["show_settings_action"] = True
dock._dock_preferences = prefs
super()._customize_dock(dock, widget)
@SafeSlot(popup_error=True)
def new(
self,
widget: QWidget,
widget: QWidget | str,
*,
closable: bool,
floatable: bool,
closable: bool = True,
floatable: bool = True,
movable: bool = True,
area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea,
start_floating: bool = False,
) -> CDockWidget:
dock = CDockWidget(widget.objectName())
dock.setWidget(widget)
dock.setFeature(CDockWidget.DockWidgetDeleteOnClose, True)
dock.setFeature(CDockWidget.CustomCloseHandling, True)
dock.setFeature(CDockWidget.DockWidgetClosable, closable)
dock.setFeature(CDockWidget.DockWidgetFloatable, floatable)
dock.setFeature(CDockWidget.DockWidgetMovable, movable)
where: Literal["left", "right", "top", "bottom"] | None = None,
on_close: Callable[[CDockWidget, QWidget], None] | None = None,
tab_with: CDockWidget | QWidget | str | None = None,
relative_to: CDockWidget | QWidget | str | None = None,
return_dock: bool = False,
show_title_bar: bool | None = None,
title_buttons: Mapping[str, bool] | Sequence[str] | str | None = None,
show_settings_action: bool | None = None,
promote_central: bool = False,
**widget_kwargs,
) -> QWidget | CDockWidget | BECWidget:
"""
Override the base helper so dock settings are available by default.
self._install_dock_settings_action(dock, widget)
def on_dock_close():
widget.close()
dock.closeDockWidget()
dock.deleteDockWidget()
def on_widget_destroyed():
if not isValid(dock):
return
dock.closeDockWidget()
dock.deleteDockWidget()
dock.closeRequested.connect(on_dock_close)
if hasattr(widget, "widget_removed"):
widget.widget_removed.connect(on_widget_destroyed)
dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget)
self.dock_manager.addDockWidget(area, dock)
if start_floating:
dock.setFloating()
return dock
def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None:
action = MaterialIconAction(
icon_name="settings", tooltip="Dock settings", filled=True, parent=self
).action
action.setToolTip("Dock settings")
action.setObjectName("dockSettingsAction")
action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget))
dock.setTitleBarActions([action])
dock.setting_action = action
def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None:
dlg = DockSettingsDialog(self, widget)
dlg.resize(600, 600)
dlg.exec()
The flag remains user-configurable (pass ``False`` to hide the action).
"""
if show_settings_action is None:
show_settings_action = True
return super().new(
widget,
closable=closable,
floatable=floatable,
movable=movable,
start_floating=start_floating,
where=where,
on_close=on_close,
tab_with=tab_with,
relative_to=relative_to,
return_dock=return_dock,
show_title_bar=show_title_bar,
title_buttons=title_buttons,
show_settings_action=show_settings_action,
promote_central=promote_central,
**widget_kwargs,
)
def _apply_dock_lock(self, locked: bool) -> None:
if locked:
@@ -257,28 +269,6 @@ class AdvancedDockArea(BECWidget, QWidget):
else:
self.dock_manager.lockDockWidgetFeaturesGlobally(QtAds.CDockWidget.NoDockWidgetFeatures)
def _delete_dock(self, dock: CDockWidget) -> None:
w = dock.widget()
if w and isValid(w):
w.close()
w.deleteLater()
if isValid(dock):
dock.closeDockWidget()
dock.deleteDockWidget()
def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea:
"""Return ADS DockWidgetArea from a human-friendly direction string.
If *where* is None, fall back to instance default.
"""
d = (where or getattr(self, "_default_add_direction", "right") or "right").lower()
mapping = {
"left": QtAds.DockWidgetArea.LeftDockWidgetArea,
"right": QtAds.DockWidgetArea.RightDockWidgetArea,
"top": QtAds.DockWidgetArea.TopDockWidgetArea,
"bottom": QtAds.DockWidgetArea.BottomDockWidgetArea,
}
return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea)
################################################################################
# Toolbar Setup
################################################################################
@@ -353,7 +343,12 @@ class AdvancedDockArea(BECWidget, QWidget):
self.toolbar.components.add_safe(
flat_action_id,
MaterialIconAction(
icon_name=icon_name, tooltip=tooltip, filled=True, parent=self
icon_name=icon_name,
tooltip=tooltip,
filled=True,
parent=self,
label_text=widget_type,
text_position="under",
),
)
bundle.add_action(flat_action_id)
@@ -372,7 +367,9 @@ class AdvancedDockArea(BECWidget, QWidget):
spacer_bundle.add_action("spacer")
self.toolbar.add_bundle(spacer_bundle)
self.toolbar.add_bundle(workspace_bundle(self.toolbar.components))
self.toolbar.add_bundle(
workspace_bundle(self.toolbar.components, enable_tools=self._profile_management_enabled)
)
self.toolbar.connect_bundle(
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
)
@@ -384,20 +381,22 @@ class AdvancedDockArea(BECWidget, QWidget):
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
),
)
self.toolbar.components.get_action("attach_all").action.setVisible(
self._profile_management_enabled
)
self.toolbar.components.add_safe(
"screenshot",
MaterialIconAction(icon_name="photo_camera", tooltip="Take Screenshot", parent=self),
)
self.toolbar.components.add_safe(
"dark_mode", WidgetAction(widget=self.dark_mode_button, adjust_size=False, parent=self)
self.toolbar.components.get_action("screenshot").action.setVisible(
self._profile_management_enabled
)
# Developer mode toggle (moved from menu into toolbar) #TODO temporary disable
# self.toolbar.components.add_safe(
# "developer_mode",
# MaterialIconAction(
# icon_name="code", tooltip="Developer Mode", checkable=True, parent=self
# ),
# )
dark_mode_action = WidgetAction(
widget=self.dark_mode_button, adjust_size=False, parent=self
)
dark_mode_action.widget.setVisible(self._profile_management_enabled)
self.toolbar.components.add_safe("dark_mode", dark_mode_action)
bda = ToolbarBundle("dock_actions", self.toolbar.components)
bda.add_action("attach_all")
bda.add_action("screenshot")
@@ -405,17 +404,7 @@ class AdvancedDockArea(BECWidget, QWidget):
# bda.add_action("developer_mode") #TODO temporary disable
self.toolbar.add_bundle(bda)
# Default bundle configuration (show menus by default)
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
self._apply_toolbar_layout()
# Store mappings on self for use in _hook_toolbar
self._ACTION_MAPPINGS = {
@@ -425,10 +414,11 @@ class AdvancedDockArea(BECWidget, QWidget):
}
def _hook_toolbar(self):
def _connect_menu(menu_key: str):
menu = self.toolbar.components.get_action(menu_key)
mapping = self._ACTION_MAPPINGS[menu_key]
# first two items not needed for this part
for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action
if widget_type == "LogPanel":
@@ -443,6 +433,7 @@ class AdvancedDockArea(BECWidget, QWidget):
widget=t,
closable=True,
startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}",
show_settings_action=True,
)
)
else:
@@ -452,197 +443,33 @@ class AdvancedDockArea(BECWidget, QWidget):
_connect_menu("menu_devices")
_connect_menu("menu_utils")
# Connect flat toolbar actions
def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]):
def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]):
for action_id, (_, _, widget_type) in mapping.items():
flat_action_id = f"flat_{action_id}"
flat_action = self.toolbar.components.get_action(flat_action_id).action
if widget_type == "LogPanel":
flat_action.setEnabled(False) # keep disabled per issue #644
else:
flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
flat_action.triggered.connect(lambda _, t=widget_type: self.new(t))
_connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"])
_connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_plots"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_devices"])
_connect_flat_actions(self._ACTION_MAPPINGS["menu_utils"])
self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all)
self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot)
# Developer mode toggle #TODO temporary disable
# self.toolbar.components.get_action("developer_mode").action.toggled.connect(
# self._on_developer_mode_toggled
# )
def _set_editable(self, editable: bool) -> None:
self.lock_workspace = not editable
self._editable = editable
attach_all_action = self.toolbar.components.get_action("attach_all").action
attach_all_action.setVisible(editable)
# Show full creation menus only when editable; otherwise keep minimal set
if editable:
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
else:
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
# Keep Developer mode UI in sync #TODO temporary disable
# self.toolbar.components.get_action("developer_mode").action.setChecked(editable)
if self._profile_management_enabled:
self.toolbar.components.get_action("attach_all").action.setVisible(editable)
def _on_developer_mode_toggled(self, checked: bool) -> None:
"""Handle developer mode checkbox toggle."""
self._set_editable(checked)
################################################################################
# Adding widgets
################################################################################
@SafeSlot(popup_error=True)
def new(
self,
widget: BECWidget | str,
closable: bool = True,
floatable: bool = True,
movable: bool = True,
start_floating: bool = False,
where: Literal["left", "right", "top", "bottom"] | None = None,
**kwargs,
) -> BECWidget:
"""
Create a new widget (or reuse an instance) and add it as a dock.
Args:
widget: Widget instance or a string widget type (factory-created).
closable: Whether the dock is closable.
floatable: Whether the dock is floatable.
movable: Whether the dock is movable.
start_floating: Start the dock in a floating state.
where: Preferred area to add the dock: "left" | "right" | "top" | "bottom".
If None, uses the instance default passed at construction time.
**kwargs: The keyword arguments for the widget.
Returns:
The widget instance.
"""
target_area = self._area_from_where(where)
# 1) Instantiate or look up the widget
if isinstance(widget, str):
widget = cast(
BECWidget, widget_handler.create_widget(widget_type=widget, parent=self, **kwargs)
)
widget.name_established.connect(
lambda: self._create_dock_with_name(
widget=widget,
closable=closable,
floatable=floatable,
movable=movable,
start_floating=start_floating,
area=target_area,
)
)
return widget
# If a widget instance is passed, dock it immediately
self._create_dock_with_name(
widget=widget,
closable=closable,
floatable=floatable,
movable=movable,
start_floating=start_floating,
area=target_area,
)
return widget
def _create_dock_with_name(
self,
widget: BECWidget,
closable: bool = True,
floatable: bool = False,
movable: bool = True,
start_floating: bool = False,
area: QtAds.DockWidgetArea | None = None,
):
target_area = area or self._area_from_where(None)
self._make_dock(
widget,
closable=closable,
floatable=floatable,
movable=movable,
area=target_area,
start_floating=start_floating,
)
self.dock_manager.setFocus()
################################################################################
# Dock Management
################################################################################
def dock_map(self) -> dict[str, CDockWidget]:
"""
Return the dock widgets map as dictionary with names as keys and dock widgets as values.
Returns:
dict: A dictionary mapping widget names to their corresponding dock widgets.
"""
return self.dock_manager.dockWidgetsMap()
def dock_list(self) -> list[CDockWidget]:
"""
Return the list of dock widgets.
Returns:
list: A list of all dock widgets in the dock area.
"""
return self.dock_manager.dockWidgets()
def widget_map(self) -> dict[str, QWidget]:
"""
Return a dictionary mapping widget names to their corresponding BECWidget instances.
Returns:
dict: A dictionary mapping widget names to BECWidget instances.
"""
return {dock.objectName(): dock.widget() for dock in self.dock_list()}
def widget_list(self) -> list[QWidget]:
"""
Return a list of all BECWidget instances in the dock area.
Returns:
list: A list of all BECWidget instances in the dock area.
"""
return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)]
@SafeSlot()
def attach_all(self):
"""
Return all floating docks to the dock area, preserving tab groups within each floating container.
"""
for container in self.dock_manager.floatingWidgets():
docks = container.dockWidgets()
if not docks:
continue
target = docks[0]
self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target)
for d in docks[1:]:
self.dock_manager.addDockWidgetTab(
QtAds.DockWidgetArea.RightDockWidgetArea, d, target
)
@SafeSlot()
def delete_all(self):
"""Delete all docks and widgets."""
for dock in list(self.dock_manager.dockWidgets()):
self._delete_dock(dock)
################################################################################
# Workspace Management
################################################################################
@@ -666,16 +493,49 @@ class AdvancedDockArea(BECWidget, QWidget):
"""
self._locked = value
self._apply_dock_lock(value)
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
if self._profile_management_enabled:
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
for dock in self.dock_list():
dock.setting_action.setVisible(not value)
def _resolve_profile_namespace(self) -> str | None:
if self._profile_namespace_resolved is not _PROFILE_NAMESPACE_UNSET:
return self._profile_namespace_resolved # type: ignore[return-value]
candidate = self._profile_namespace_hint
if self._profile_namespace_auto:
if not candidate:
obj_name = self.objectName()
candidate = obj_name if obj_name else None
if not candidate:
title = self.windowTitle()
candidate = title if title and title.strip() else None
if not candidate:
mode_name = getattr(self, "_mode", None) or "creator"
candidate = f"{mode_name}_workspace"
if not candidate:
candidate = self.__class__.__name__
resolved = sanitize_namespace(candidate) if candidate else None
if not resolved:
resolved = "general"
self._profile_namespace_resolved = resolved # type: ignore[assignment]
return resolved
@property
def profile_namespace(self) -> str | None:
"""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 _write_snapshot_to_settings(self, settings, save_preview: bool = True) -> None:
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
settings.setValue(SETTINGS_KEYS["state"], b"")
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
self.dock_manager.addPerspective(self.windowTitle())
self.dock_manager.savePerspectives(settings)
self.save_to_settings(settings, keys=PROFILE_STATE_KEYS)
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
if save_preview:
@@ -702,11 +562,13 @@ class AdvancedDockArea(BECWidget, QWidget):
name (str | None): The name of the profile to save. If None, prompts the user.
"""
namespace = self.profile_namespace
def _profile_exists(profile_name: str) -> bool:
return profile_origin(profile_name) != "unknown"
return profile_origin(profile_name, namespace=namespace) != "unknown"
initial_name = name or ""
quickselect_default = is_quick_select(name) if name else False
quickselect_default = is_quick_select(name, namespace=namespace) if name else False
current_profile = getattr(self, "_current_profile_name", "") or ""
dialog = SaveProfileDialog(
@@ -714,8 +576,8 @@ class AdvancedDockArea(BECWidget, QWidget):
current_name=initial_name,
current_profile_name=current_profile,
name_exists=_profile_exists,
profile_origin=profile_origin,
origin_label=profile_origin_display,
profile_origin=lambda n: profile_origin(n, namespace=namespace),
origin_label=lambda n: profile_origin_display(n, namespace=namespace),
quick_select_checked=quickselect_default,
)
if dialog.exec() != QDialog.Accepted:
@@ -723,7 +585,7 @@ class AdvancedDockArea(BECWidget, QWidget):
name = dialog.get_profile_name()
quickselect = dialog.is_quick_select()
origin_before_save = profile_origin(name)
origin_before_save = profile_origin(name, namespace=namespace)
overwrite_default = dialog.overwrite_existing and origin_before_save == "settings"
# Display saving placeholder
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
@@ -733,9 +595,11 @@ class AdvancedDockArea(BECWidget, QWidget):
workspace_combo.blockSignals(False)
# Create or update default copy controlled by overwrite flag
should_write_default = overwrite_default or not os.path.exists(default_profile_path(name))
should_write_default = overwrite_default or not any(
os.path.exists(path) for path in default_profile_candidates(name, namespace)
)
if should_write_default:
ds = open_default_settings(name)
ds = open_default_settings(name, namespace=namespace)
self._write_snapshot_to_settings(ds)
if not ds.value(SETTINGS_KEYS["created_at"], ""):
ds.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
@@ -744,7 +608,7 @@ class AdvancedDockArea(BECWidget, QWidget):
ds.setValue(SETTINGS_KEYS["is_quick_select"], False)
# Always (over)write the user copy
us = open_user_settings(name)
us = open_user_settings(name, namespace=namespace)
self._write_snapshot_to_settings(us)
if not us.value(SETTINGS_KEYS["created_at"], ""):
us.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
@@ -754,7 +618,7 @@ class AdvancedDockArea(BECWidget, QWidget):
# set quick select
if quickselect:
set_quick_select(name, quickselect)
set_quick_select(name, quickselect, namespace=namespace)
self._refresh_workspace_list()
if current_profile and current_profile != name and not dialog.overwrite_existing:
@@ -764,7 +628,7 @@ class AdvancedDockArea(BECWidget, QWidget):
workspace_combo.setCurrentText(name)
self._current_profile_name = name
self.profile_changed.emit(name)
set_last_profile(name)
set_last_profile(name, namespace=namespace)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles(active_profile=name)
@@ -782,21 +646,22 @@ class AdvancedDockArea(BECWidget, QWidget):
if not ok or not name:
return
namespace = self.profile_namespace
prev_name = getattr(self, "_current_profile_name", None)
skip_pair = getattr(self, "_pending_autosave_skip", None)
if prev_name and prev_name != name:
if skip_pair and skip_pair == (prev_name, name):
self._pending_autosave_skip = None
else:
us_prev = open_user_settings(prev_name)
self._write_snapshot_to_settings(us_prev, save_preview=False)
us_prev = open_user_settings(prev_name, namespace=namespace)
self._write_snapshot_to_settings(us_prev, save_preview=True)
# Choose source settings: user first, else default
if os.path.exists(user_profile_path(name)):
settings = open_user_settings(name)
elif os.path.exists(default_profile_path(name)):
settings = open_default_settings(name)
else:
settings = None
if any(os.path.exists(path) for path in user_profile_candidates(name, namespace)):
settings = open_user_settings(name, namespace=namespace)
elif any(os.path.exists(path) for path in default_profile_candidates(name, namespace)):
settings = open_default_settings(name, namespace=namespace)
if settings is None:
QMessageBox.warning(self, "Profile not found", f"Profile '{name}' not found.")
return
@@ -815,19 +680,13 @@ class AdvancedDockArea(BECWidget, QWidget):
area=QtAds.DockWidgetArea.RightDockWidgetArea,
)
geom = settings.value(SETTINGS_KEYS["geom"])
if geom:
self.restoreGeometry(geom)
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
if dock_state:
self.dock_manager.restoreState(dock_state)
self.dock_manager.loadPerspectives(settings)
self.load_from_settings(settings, keys=PROFILE_STATE_KEYS)
self.state_manager.load_state(settings=settings)
self._set_editable(self._editable)
self._current_profile_name = name
self.profile_changed.emit(name)
set_last_profile(name)
set_last_profile(name, namespace=namespace)
combo = self.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles(active_profile=name)
@@ -844,6 +703,7 @@ class AdvancedDockArea(BECWidget, QWidget):
target = name or getattr(self, "_current_profile_name", None)
if not target:
return
namespace = self.profile_namespace
current_pixmap = None
if self.isVisible():
@@ -851,13 +711,13 @@ class AdvancedDockArea(BECWidget, QWidget):
ba = bytes(self.screenshot_bytes())
current_pixmap.loadFromData(ba)
if current_pixmap is None or current_pixmap.isNull():
current_pixmap = load_user_profile_screenshot(target)
default_pixmap = load_default_profile_screenshot(target)
current_pixmap = load_user_profile_screenshot(target, namespace=namespace)
default_pixmap = load_default_profile_screenshot(target, namespace=namespace)
if not RestoreProfileDialog.confirm(self, current_pixmap, default_pixmap):
return
restore_user_from_default(target)
restore_user_from_default(target, namespace=namespace)
self.delete_all()
self.load_profile(target)
@@ -871,6 +731,13 @@ class AdvancedDockArea(BECWidget, QWidget):
if not name:
return
# Protect bundled/module/plugin profiles from deletion
if is_profile_read_only(name, namespace=self.profile_namespace):
QMessageBox.information(
self, "Delete Profile", f"Profile '{name}' is read-only and cannot be deleted."
)
return
# Confirm deletion for regular profiles
reply = QMessageBox.question(
self,
@@ -883,11 +750,8 @@ class AdvancedDockArea(BECWidget, QWidget):
if reply != QMessageBox.Yes:
return
file_path = user_profile_path(name)
try:
os.remove(file_path)
except FileNotFoundError:
return
namespace = self.profile_namespace
delete_profile_files(name, namespace=namespace)
self._refresh_workspace_list()
def _refresh_workspace_list(self):
@@ -896,17 +760,16 @@ class AdvancedDockArea(BECWidget, QWidget):
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
active_profile = getattr(self, "_current_profile_name", None)
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)
else:
# Fallback for regular QComboBox
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
list_quick_profiles,
)
combo.blockSignals(True)
combo.clear()
quick_profiles = list_quick_profiles()
quick_profiles = list_quick_profiles(namespace=namespace)
items = list(quick_profiles)
if active_profile and active_profile not in items:
items.insert(0, active_profile)
@@ -968,46 +831,70 @@ class AdvancedDockArea(BECWidget, QWidget):
@mode.setter
def mode(self, new_mode: str):
if new_mode not in ["plot", "device", "utils", "developer", "user"]:
allowed_modes = ["plot", "device", "utils", "user", "creator"]
if new_mode not in allowed_modes:
raise ValueError(f"Invalid mode: {new_mode}")
self._mode = new_mode
self.mode_changed.emit(new_mode)
self._apply_toolbar_layout()
# Update toolbar visibility based on mode
if new_mode == "user":
# User mode: show only essential tools
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
elif new_mode == "developer":
# Developer mode: show all tools (use menu bundles)
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
elif new_mode in ["plot", "device", "utils"]:
# Specific modes: show flat toolbar for that category
bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils"
self.toolbar.show_bundles([bundle_name])
# self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"])
def _apply_toolbar_layout(self) -> None:
mode_key = getattr(self, "_mode", "creator")
if mode_key == "user":
bundles = ["spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "creator":
bundles = [
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
elif mode_key == "plot":
bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "device":
bundles = ["flat_devices", "spacer_bundle", "workspace", "dock_actions"]
elif mode_key == "utils":
bundles = ["flat_utils", "spacer_bundle", "workspace", "dock_actions"]
else:
# Fallback to user mode
self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"])
bundles = ["spacer_bundle", "workspace", "dock_actions"]
if not self._profile_management_enabled:
flat_only = [b for b in bundles if b.startswith("flat_")]
if not flat_only:
flat_only = ["flat_plots", "flat_devices", "flat_utils"]
bundles = flat_only
self.toolbar.show_bundles(bundles)
def prepare_for_shutdown(self) -> None:
"""
Persist the current workspace snapshot while the UI is still fully visible.
Called by the main window before initiating widget teardown to avoid capturing
close-triggered visibility changes.
"""
if (
not self._auto_save_upon_exit
or getattr(self, "_exit_snapshot_written", False)
or getattr(self, "_destroyed", False)
):
logger.info("ADS prepare_for_shutdown: skipping (already handled or destroyed)")
return
name = self._active_profile_name_or_default()
namespace = self.profile_namespace
settings = open_user_settings(name, namespace=namespace)
self._write_snapshot_to_settings(settings)
set_last_profile(name, namespace=namespace)
self._exit_snapshot_written = True
def cleanup(self):
"""
Cleanup the dock area.
"""
# before cleanup save current profile (user copy)
name = getattr(self, "_current_profile_name", None)
if name:
us = open_user_settings(name)
self._write_snapshot_to_settings(us)
set_last_profile(name)
self.prepare_for_shutdown()
if self.manage_dialog is not None:
self.manage_dialog.reject()
self.manage_dialog = None
@@ -1025,7 +912,7 @@ if __name__ == "__main__": # pragma: no cover
apply_theme("dark")
dispatcher = BECDispatcher(gui_id="ads")
window = BECMainWindowNoRPC()
ads = AdvancedDockArea(mode="developer", root_widget=True)
ads = AdvancedDockArea(mode="creator", root_widget=True, enable_profile_management=True)
window.setCentralWidget(ads)
window.show()
window.resize(800, 600)

File diff suppressed because it is too large Load Diff

View File

@@ -10,11 +10,13 @@ Policy:
from __future__ import annotations
import os
import re
import shutil
from functools import lru_cache
from pathlib import Path
from typing import Literal
from bec_lib import bec_logger
from bec_lib.client import BECClient
from bec_lib.plugin_helper import plugin_package_name, plugin_repo_path
from pydantic import BaseModel, Field
@@ -22,18 +24,32 @@ from PySide6QtAds import CDockWidget
from qtpy.QtCore import QByteArray, QDateTime, QSettings, Qt
from qtpy.QtGui import QPixmap
logger = bec_logger.logger
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
ProfileOrigin = Literal["module", "plugin", "settings", "unknown"]
def module_profiles_dir() -> str:
"""Return the read-only module-bundled profiles directory (no writes here)."""
"""
Return the built-in AdvancedDockArea profiles directory bundled with the module.
Returns:
str: Absolute path of the read-only module profiles directory.
"""
return os.path.join(MODULE_PATH, "containers", "advanced_dock_area", "profiles")
@lru_cache(maxsize=1)
def _plugin_repo_root() -> Path | None:
"""
Resolve the plugin repository root path if running inside a plugin context.
Returns:
Path | None: Root path of the active plugin repository, or ``None`` when
no plugin context is detected.
"""
try:
return Path(plugin_repo_path())
except ValueError:
@@ -42,6 +58,13 @@ def _plugin_repo_root() -> Path | None:
@lru_cache(maxsize=1)
def _plugin_display_name() -> str | None:
"""
Determine a user-friendly plugin name for provenance labels.
Returns:
str | None: Human-readable name inferred from the plugin repo or package,
or ``None`` if it cannot be determined.
"""
repo_root = _plugin_repo_root()
if not repo_root:
return None
@@ -57,7 +80,13 @@ def _plugin_display_name() -> str | None:
@lru_cache(maxsize=1)
def plugin_profiles_dir() -> str | None:
"""Return the read-only plugin-bundled profiles directory if available."""
"""
Locate the read-only profiles directory shipped with a beamline plugin.
Returns:
str | None: Directory containing bundled plugin profiles, or ``None`` if
no plugin profiles are available.
"""
repo_root = _plugin_repo_root()
if not repo_root:
return None
@@ -66,8 +95,8 @@ def plugin_profiles_dir() -> str | None:
try:
package_root = repo_root.joinpath(*plugin_package_name().split("."))
candidates.append(package_root.joinpath("bec_widgets", "profiles"))
except ValueError:
pass
except ValueError as e:
logger.error(f"Could not determine plugin package name: {e}")
for candidate in candidates:
if candidate.is_dir():
@@ -76,7 +105,12 @@ def plugin_profiles_dir() -> str | None:
def _settings_profiles_root() -> str:
"""Return the writable profiles root provided by BEC client (or env fallback)."""
"""
Resolve the writable profiles root provided by the BEC client.
Returns:
str: Absolute path to the profiles root. The directory is created if missing.
"""
client = BECClient()
bec_widgets_settings = client._service_config.config.get("bec_widgets_settings")
bec_widgets_setting_path = (
@@ -88,63 +122,282 @@ def _settings_profiles_root() -> str:
return root
def default_profiles_dir() -> str:
path = os.path.join(_settings_profiles_root(), "default")
def sanitize_namespace(namespace: str | None) -> str | None:
"""
Clean user-provided namespace labels for filesystem compatibility.
Args:
namespace (str | None): Arbitrary namespace identifier supplied by the caller.
Returns:
str | None: Sanitized namespace containing only safe characters, or ``None``
when the input is empty.
"""
if not namespace:
return None
ns = namespace.strip()
if not ns:
return None
return re.sub(r"[^0-9A-Za-z._-]+", "_", ns)
def _profiles_dir(segment: str, namespace: str | None) -> str:
"""
Build (and ensure) the directory that holds profiles for a namespace segment.
Args:
segment (str): Either ``"user"`` or ``"default"``.
namespace (str | None): Optional namespace label to scope profiles.
Returns:
str: Absolute directory path for the requested segment/namespace pair.
"""
base = os.path.join(_settings_profiles_root(), segment)
ns = sanitize_namespace(namespace)
path = os.path.join(base, ns) if ns else base
os.makedirs(path, exist_ok=True)
return path
def user_profiles_dir() -> str:
path = os.path.join(_settings_profiles_root(), "user")
os.makedirs(path, exist_ok=True)
return path
def _user_path_candidates(name: str, namespace: str | None) -> list[str]:
"""
Generate candidate user-profile paths honoring namespace fallbacks.
Args:
name (str): Profile name without extension.
namespace (str | None): Optional namespace label.
Returns:
list[str]: Ordered list of candidate user profile paths (.ini files).
"""
ns = sanitize_namespace(namespace)
primary = os.path.join(_profiles_dir("user", ns), f"{name}.ini")
if not ns:
return [primary]
legacy = os.path.join(_profiles_dir("user", None), f"{name}.ini")
return [primary, legacy] if legacy != primary else [primary]
def default_profile_path(name: str) -> str:
return os.path.join(default_profiles_dir(), f"{name}.ini")
def _default_path_candidates(name: str, namespace: str | None) -> list[str]:
"""
Generate candidate default-profile paths honoring namespace fallbacks.
Args:
name (str): Profile name without extension.
namespace (str | None): Optional namespace label.
Returns:
list[str]: Ordered list of candidate default profile paths (.ini files).
"""
ns = sanitize_namespace(namespace)
primary = os.path.join(_profiles_dir("default", ns), f"{name}.ini")
if not ns:
return [primary]
legacy = os.path.join(_profiles_dir("default", None), f"{name}.ini")
return [primary, legacy] if legacy != primary else [primary]
def user_profile_path(name: str) -> str:
return os.path.join(user_profiles_dir(), f"{name}.ini")
def default_profiles_dir(namespace: str | None = None) -> str:
"""
Return the directory that stores default profiles for the namespace.
Args:
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
str: Absolute path to the default profile directory.
"""
return _profiles_dir("default", namespace)
def user_profiles_dir(namespace: str | None = None) -> str:
"""
Return the directory that stores user profiles for the namespace.
Args:
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
str: Absolute path to the user profile directory.
"""
return _profiles_dir("user", namespace)
def default_profile_path(name: str, namespace: str | None = None) -> str:
"""
Compute the canonical default profile path for a profile name.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
str: Absolute path to the default profile file (.ini).
"""
return _default_path_candidates(name, namespace)[0]
def user_profile_path(name: str, namespace: str | None = None) -> str:
"""
Compute the canonical user profile path for a profile name.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
str: Absolute path to the user profile file (.ini).
"""
return _user_path_candidates(name, namespace)[0]
def user_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
"""
List all user profile path candidates for a profile name.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
list[str]: De-duplicated list of candidate user profile paths.
"""
return list(dict.fromkeys(_user_path_candidates(name, namespace)))
def default_profile_candidates(name: str, namespace: str | None = None) -> list[str]:
"""
List all default profile path candidates for a profile name.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
list[str]: De-duplicated list of candidate default profile paths.
"""
return list(dict.fromkeys(_default_path_candidates(name, namespace)))
def _existing_user_settings(name: str, namespace: str | None = None) -> QSettings | None:
"""
Resolve the first existing user profile settings object.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
Returns:
QSettings | None: Config for the first existing user profile candidate, or ``None``
when no files are present.
"""
for path in user_profile_candidates(name, namespace):
if os.path.exists(path):
return QSettings(path, QSettings.IniFormat)
return None
def _existing_default_settings(name: str, namespace: str | None = None) -> QSettings | None:
"""
Resolve the first existing default profile settings object.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label to search. Defaults to ``None``.
Returns:
QSettings | None: Config for the first existing default profile candidate, or ``None``
when no files are present.
"""
for path in default_profile_candidates(name, namespace):
if os.path.exists(path):
return QSettings(path, QSettings.IniFormat)
return None
def module_profile_path(name: str) -> str:
"""
Build the absolute path to a bundled module profile.
Args:
name (str): Profile name without extension.
Returns:
str: Absolute path to the module's read-only profile file.
"""
return os.path.join(module_profiles_dir(), f"{name}.ini")
def plugin_profile_path(name: str) -> str | None:
"""
Build the absolute path to a bundled plugin profile if available.
Args:
name (str): Profile name without extension.
Returns:
str | None: Absolute plugin profile path, or ``None`` when plugins do not
provide profiles.
"""
directory = plugin_profiles_dir()
if not directory:
return None
return os.path.join(directory, f"{name}.ini")
def profile_origin(name: str) -> ProfileOrigin:
def profile_origin(name: str, namespace: str | None = None) -> ProfileOrigin:
"""
Determine where a profile originates from.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label to consider. Defaults to ``None``.
Returns:
ProfileOrigin: "module" for bundled BEC profiles, "plugin" for beamline plugin bundles,
"settings" for user-defined ones, and "unknown" if no backing files are found.
ProfileOrigin: ``"module"`` for bundled BEC profiles, ``"plugin"`` for beamline
plugin bundles, ``"settings"`` for writable copies, and ``"unknown"`` when
no backing files are found.
"""
if os.path.exists(module_profile_path(name)):
return "module"
plugin_path = plugin_profile_path(name)
if plugin_path and os.path.exists(plugin_path):
return "plugin"
if os.path.exists(user_profile_path(name)) or os.path.exists(default_profile_path(name)):
return "settings"
for path in user_profile_candidates(name, namespace) + default_profile_candidates(
name, namespace
):
if os.path.exists(path):
return "settings"
return "unknown"
def is_profile_read_only(name: str) -> bool:
"""Return True when the profile originates from bundled module or plugin directories."""
return profile_origin(name) in {"module", "plugin"}
def is_profile_read_only(name: str, namespace: str | None = None) -> bool:
"""
Check whether a profile is read-only because it originates from bundles.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label to consider. Defaults to ``None``.
Returns:
bool: ``True`` if the profile originates from module or plugin bundles.
"""
return profile_origin(name, namespace) in {"module", "plugin"}
def profile_origin_display(name: str) -> str | None:
"""Return a human-readable label for the profile's origin."""
origin = profile_origin(name)
def profile_origin_display(name: str, namespace: str | None = None) -> str | None:
"""
Build a user-facing label describing a profile's origin.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label to consider. Defaults to ``None``.
Returns:
str | None: Localized display label such as ``"BEC Widgets"`` or ``"User"``,
or ``None`` when origin cannot be determined.
"""
origin = profile_origin(name, namespace)
if origin == "module":
return "BEC Widgets"
if origin == "plugin":
@@ -154,26 +407,39 @@ def profile_origin_display(name: str) -> str | None:
return None
def delete_profile_files(name: str) -> bool:
def delete_profile_files(name: str, namespace: str | None = None) -> bool:
"""
Delete the profile files from the writable settings directories.
Removes both the user and default copies (if they exist) and clears the last profile
metadata when applicable. Returns True when at least one file was removed.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label scoped to the profile. Defaults
to ``None``.
Returns:
bool: ``True`` if at least one file was removed.
"""
if is_profile_read_only(name):
return False
read_only = is_profile_read_only(name, namespace)
removed = False
for path in {user_profile_path(name), default_profile_path(name)}:
# Always allow removing user copies; keep default copies for read-only origins.
for path in set(user_profile_candidates(name, namespace)):
try:
os.remove(path)
removed = True
except FileNotFoundError:
continue
if removed and get_last_profile() == name:
set_last_profile(None)
if not read_only:
for path in set(default_profile_candidates(name, namespace)):
try:
os.remove(path)
removed = True
except FileNotFoundError:
continue
if removed and get_last_profile(namespace) == name:
set_last_profile(None, namespace)
return removed
@@ -191,12 +457,32 @@ SETTINGS_KEYS = {
}
def list_profiles() -> list[str]:
# Collect profiles from writable settings (default + user)
defaults = {
os.path.splitext(f)[0] for f in os.listdir(default_profiles_dir()) if f.endswith(".ini")
}
users = {os.path.splitext(f)[0] for f in os.listdir(user_profiles_dir()) if f.endswith(".ini")}
def list_profiles(namespace: str | None = None) -> list[str]:
"""
Enumerate all known profile names, syncing bundled defaults when missing locally.
Args:
namespace (str | None, optional): Namespace label scoped to the profile set.
Defaults to ``None``.
Returns:
list[str]: Sorted unique profile names.
"""
ns = sanitize_namespace(namespace)
def _collect_from(directory: str) -> set[str]:
if not os.path.isdir(directory):
return set()
return {os.path.splitext(f)[0] for f in os.listdir(directory) if f.endswith(".ini")}
settings_dirs = {default_profiles_dir(namespace), user_profiles_dir(namespace)}
if ns:
settings_dirs.add(default_profiles_dir(None))
settings_dirs.add(user_profiles_dir(None))
settings_names: set[str] = set()
for directory in settings_dirs:
settings_names |= _collect_from(directory)
# Also consider read-only defaults from core module and beamline plugin repositories
read_only_sources: dict[str, tuple[str, str]] = {}
@@ -214,62 +500,127 @@ def list_profiles() -> list[str]:
read_only_sources.setdefault(name, (origin, os.path.join(directory, filename)))
for name, (_origin, src) in sorted(read_only_sources.items()):
# Ensure a copy in the settings default directory so existing code paths work unchanged
dst_default = default_profile_path(name)
# Ensure a copy in the namespace-specific settings default directory
dst_default = default_profile_path(name, namespace)
if not os.path.exists(dst_default):
os.makedirs(os.path.dirname(dst_default), exist_ok=True)
shutil.copyfile(src, dst_default)
# Ensure a user copy exists to allow edits in the writable settings area
dst_user = user_profile_path(name)
dst_user = user_profile_path(name, namespace)
if not os.path.exists(dst_user):
os.makedirs(os.path.dirname(dst_user), exist_ok=True)
shutil.copyfile(src, dst_user)
# Minimal metadata touch-up to align with existing expectations
s = open_user_settings(name)
if not s.value(SETTINGS_KEYS["created_at"], ""):
s = open_user_settings(name, namespace)
if s.value(SETTINGS_KEYS["created_at"], "") == "":
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
defaults |= set(read_only_sources.keys())
users |= set(read_only_sources.keys())
settings_names |= set(read_only_sources.keys())
# Return union of all discovered names
return sorted(defaults | users)
return sorted(settings_names)
def open_default_settings(name: str) -> QSettings:
return QSettings(default_profile_path(name), QSettings.IniFormat)
def open_default_settings(name: str, namespace: str | None = None) -> QSettings:
"""
Open (and create if necessary) the default profile settings file.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
QSettings: Settings instance targeting the default profile file.
"""
return QSettings(default_profile_path(name, namespace), QSettings.IniFormat)
def open_user_settings(name: str) -> QSettings:
return QSettings(user_profile_path(name), QSettings.IniFormat)
def open_user_settings(name: str, namespace: str | None = None) -> QSettings:
"""
Open (and create if necessary) the user profile settings file.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
QSettings: Settings instance targeting the user profile file.
"""
return QSettings(user_profile_path(name, namespace), QSettings.IniFormat)
def _app_settings() -> QSettings:
"""Return app-wide settings file for AdvancedDockArea metadata."""
"""
Access the application-wide metadata settings file for dock profiles.
Returns:
QSettings: Handle to the ``_meta.ini`` metadata store under the profiles root.
"""
return QSettings(os.path.join(_settings_profiles_root(), "_meta.ini"), QSettings.IniFormat)
def get_last_profile() -> str | None:
"""Return the last-used profile name if stored, else None."""
def _last_profile_key(namespace: str | None) -> str:
"""
Build the QSettings key used to store the last profile per namespace.
Args:
namespace (str | None): Namespace label.
Returns:
str: Scoped key string.
"""
ns = sanitize_namespace(namespace)
key = SETTINGS_KEYS["last_profile"]
return f"{key}/{ns}" if ns else key
def get_last_profile(namespace: str | None = None) -> str | None:
"""
Retrieve the last-used profile name persisted in app settings.
Args:
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
str | None: Profile name or ``None`` if none has been stored.
"""
s = _app_settings()
name = s.value(SETTINGS_KEYS["last_profile"], "", type=str)
name = s.value(_last_profile_key(namespace), "", type=str)
return name or None
def set_last_profile(name: str | None) -> None:
"""Persist the last-used profile name (or clear it if None)."""
def set_last_profile(name: str | None, namespace: str | None = None) -> None:
"""
Persist the last-used profile name (or clear the value when ``None``).
Args:
name (str | None): Profile name to store.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
"""
s = _app_settings()
if name:
s.setValue(SETTINGS_KEYS["last_profile"], name)
s.setValue(_last_profile_key(namespace), name)
else:
s.remove(SETTINGS_KEYS["last_profile"])
s.remove(_last_profile_key(namespace))
def now_iso_utc() -> str:
"""
Return the current UTC timestamp formatted in ISO 8601.
Returns:
str: UTC timestamp string (e.g., ``"2024-06-05T12:34:56Z"``).
"""
return QDateTime.currentDateTimeUtc().toString(Qt.ISODate)
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
"""
Write the manifest of dock widgets to settings.
Args:
settings(QSettings): Settings object to write to.
docks(list[CDockWidget]): List of dock widgets to serialize.
"""
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks))
for i, dock in enumerate(docks):
settings.setArrayIndex(i)
@@ -283,6 +634,15 @@ def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
def read_manifest(settings: QSettings) -> list[dict]:
"""
Read the manifest of dock widgets from settings.
Args:
settings(QSettings): Settings object to read from.
Returns:
list[dict]: List of dock widget metadata dictionaries.
"""
items: list[dict] = []
count = settings.beginReadArray(SETTINGS_KEYS["manifest"])
for i in range(count):
@@ -300,47 +660,88 @@ def read_manifest(settings: QSettings) -> list[dict]:
return items
def restore_user_from_default(name: str) -> None:
"""Overwrite the user profile with the default baseline (keep default intact)."""
src = default_profile_path(name)
dst = user_profile_path(name)
if not os.path.exists(src):
def restore_user_from_default(name: str, namespace: str | None = None) -> None:
"""
Copy the default profile to the user profile, preserving quick-select flag.
Args:
name(str): Profile name without extension.
namespace(str | None, optional): Namespace label. Defaults to ``None``.
"""
src = None
for candidate in default_profile_candidates(name, namespace):
if os.path.exists(candidate):
src = candidate
break
if not src:
return
preserve_quick_select = is_quick_select(name)
dst = user_profile_path(name, namespace)
preserve_quick_select = is_quick_select(name, namespace)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copyfile(src, dst)
s = open_user_settings(name)
s = open_user_settings(name, namespace)
if not s.value(SETTINGS_KEYS["created_at"], ""):
s.setValue(SETTINGS_KEYS["created_at"], now_iso_utc())
if preserve_quick_select:
s.setValue(SETTINGS_KEYS["is_quick_select"], True)
def is_quick_select(name: str) -> bool:
"""Return True if profile is marked to appear in quick-select combo."""
s = (
open_user_settings(name)
if os.path.exists(user_profile_path(name))
else (open_default_settings(name) if os.path.exists(default_profile_path(name)) else None)
)
def is_quick_select(name: str, namespace: str | None = None) -> bool:
"""
Return True if profile is marked to appear in quick-select combo.
Args:
name(str): Profile name without extension.
namespace(str | None, optional): Namespace label. Defaults to ``None``.
Returns:
bool: True if quick-select is enabled for the profile.
"""
s = _existing_user_settings(name, namespace)
if s is None:
s = _existing_default_settings(name, namespace)
if s is None:
return False
return s.value(SETTINGS_KEYS["is_quick_select"], False, type=bool)
def set_quick_select(name: str, enabled: bool) -> None:
"""Set/unset the quick-select flag on the USER copy (creates it if missing)."""
s = open_user_settings(name)
def set_quick_select(name: str, enabled: bool, namespace: str | None = None) -> None:
"""
Set or clear the quick-select flag for a profile.
Args:
name(str): Profile name without extension.
enabled(bool): True to enable quick-select, False to disable.
namespace(str | None, optional): Namespace label. Defaults to ``None``.
"""
s = open_user_settings(name, namespace)
s.setValue(SETTINGS_KEYS["is_quick_select"], bool(enabled))
def list_quick_profiles() -> list[str]:
"""List only profiles that have quick-select enabled (user wins over default)."""
names = list_profiles()
return [n for n in names if is_quick_select(n)]
def list_quick_profiles(namespace: str | None = None) -> list[str]:
"""
List only profiles that have quick-select enabled (user wins over default).
Args:
namespace(str | None, optional): Namespace label. Defaults to ``None``.
Returns:
list[str]: Sorted list of profile names with quick-select enabled.
"""
names = list_profiles(namespace)
return [n for n in names if is_quick_select(n, namespace)]
def _file_modified_iso(path: str) -> str:
"""
Get the file modification time as an ISO 8601 UTC string.
Args:
path(str): Path to the file.
Returns:
str: ISO 8601 UTC timestamp of last modification, or current time if unavailable.
"""
try:
mtime = os.path.getmtime(path)
return QDateTime.fromSecsSinceEpoch(int(mtime), Qt.UTC).toString(Qt.ISODate)
@@ -349,12 +750,30 @@ def _file_modified_iso(path: str) -> str:
def _manifest_count(settings: QSettings) -> int:
"""
Get the number of widgets recorded in the manifest.
Args:
settings(QSettings): Settings object to read from.
Returns:
int: Number of widgets in the manifest.
"""
n = settings.beginReadArray(SETTINGS_KEYS["manifest"])
settings.endArray()
return int(n or 0)
def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None:
"""
Load the screenshot pixmap stored in the given settings.
Args:
settings(QSettings): Settings object to read from.
Returns:
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
"""
data = settings.value(SETTINGS_KEYS["screenshot"], None)
if not data:
return None
@@ -379,6 +798,8 @@ def _load_screenshot_from_settings(settings: QSettings) -> QPixmap | None:
class ProfileInfo(BaseModel):
"""Pydantic model capturing profile metadata surfaced in the UI."""
name: str
author: str = "BEC Widgets"
notes: str = ""
@@ -393,21 +814,30 @@ class ProfileInfo(BaseModel):
is_read_only: bool = False
def get_profile_info(name: str) -> ProfileInfo:
def get_profile_info(name: str, namespace: str | None = None) -> ProfileInfo:
"""
Return merged metadata for a profile as a validated Pydantic model.
Prefers the USER copy; falls back to DEFAULT if the user copy is missing.
Assemble metadata and statistics for a profile.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
ProfileInfo: Structured profile metadata, preferring the user copy when present.
"""
u_path = user_profile_path(name)
d_path = default_profile_path(name)
origin = profile_origin(name)
prefer_user = os.path.exists(u_path)
user_paths = user_profile_candidates(name, namespace)
default_paths = default_profile_candidates(name, namespace)
u_path = next((p for p in user_paths if os.path.exists(p)), user_paths[0])
d_path = next((p for p in default_paths if os.path.exists(p)), default_paths[0])
origin = profile_origin(name, namespace)
read_only = origin in {"module", "plugin"}
s = (
open_user_settings(name)
if prefer_user
else (open_default_settings(name) if os.path.exists(d_path) else None)
)
prefer_user = os.path.exists(u_path)
if prefer_user:
s = QSettings(u_path, QSettings.IniFormat)
elif os.path.exists(d_path):
s = QSettings(d_path, QSettings.IniFormat)
else:
s = None
if s is None:
if origin == "module":
author = "BEC Widgets"
@@ -456,7 +886,7 @@ def get_profile_info(name: str) -> ProfileInfo:
notes=s.value("profile/notes", "", type=str) or "",
created=created,
modified=modified,
is_quick_select=is_quick_select(name),
is_quick_select=is_quick_select(name, namespace),
widget_count=count,
size_kb=size_kb,
user_path=u_path,
@@ -466,29 +896,54 @@ def get_profile_info(name: str) -> ProfileInfo:
)
def load_profile_screenshot(name: str) -> QPixmap | None:
"""Load the stored screenshot pixmap for a profile from settings (user preferred)."""
u_path = user_profile_path(name)
d_path = default_profile_path(name)
s = (
open_user_settings(name)
if os.path.exists(u_path)
else (open_default_settings(name) if os.path.exists(d_path) else None)
)
def load_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
"""
Load the stored screenshot pixmap for a profile from settings (user preferred).
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
"""
s = _existing_user_settings(name, namespace)
if s is None:
s = _existing_default_settings(name, namespace)
if s is None:
return None
return _load_screenshot_from_settings(s)
def load_user_profile_screenshot(name: str) -> QPixmap | None:
"""Load the screenshot from the user profile copy, if available."""
if not os.path.exists(user_profile_path(name)):
def load_default_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
"""
Load the screenshot from the default profile copy, if available.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
"""
s = _existing_default_settings(name, namespace)
if s is None:
return None
return _load_screenshot_from_settings(open_user_settings(name))
return _load_screenshot_from_settings(s)
def load_default_profile_screenshot(name: str) -> QPixmap | None:
"""Load the screenshot from the default profile copy, if available."""
if not os.path.exists(default_profile_path(name)):
def load_user_profile_screenshot(name: str, namespace: str | None = None) -> QPixmap | None:
"""
Load the screenshot from the user profile copy, if available.
Args:
name (str): Profile name without extension.
namespace (str | None, optional): Namespace label. Defaults to ``None``.
Returns:
QPixmap | None: Screenshot pixmap or ``None`` if unavailable.
"""
s = _existing_user_settings(name, namespace)
if s is None:
return None
return _load_screenshot_from_settings(open_default_settings(name))
return _load_screenshot_from_settings(s)

View File

@@ -5,7 +5,6 @@ from typing import Callable, Literal
from qtpy.QtCore import Qt
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QGroupBox,
@@ -183,10 +182,10 @@ class SaveProfileDialog(QDialog):
"Overwriting will update both the saved profile and its restore default.\n"
"Do you want to continue?"
),
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply != QMessageBox.Yes:
if reply != QMessageBox.StandardButton.Yes:
suggestion = self._generate_unique_name(name)
self._block_name_signals = True
self.name_edit.setText(suggestion)
@@ -206,16 +205,15 @@ class PreviewPanel(QGroupBox):
def __init__(self, title: str, pixmap: QPixmap | None, parent: QWidget | None = None):
super().__init__(title, parent)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
self._original: QPixmap | None = pixmap if (pixmap and not pixmap.isNull()) else None
layout = QVBoxLayout(self)
# layout.setContentsMargins(0,0,0,0) # leave room for group title and frame
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignCenter)
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.image_label.setMinimumSize(360, 240)
self.image_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.image_label.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
layout.addWidget(self.image_label, 1)
if self._original:
@@ -227,6 +225,13 @@ class PreviewPanel(QGroupBox):
)
def setPixmap(self, pixmap: QPixmap | None):
"""
Set the pixmap to display in the preview panel.
Args:
pixmap(QPixmap | None): The pixmap to display. If None or null, clears the preview.
"""
self._original = pixmap if (pixmap and not pixmap.isNull()) else None
if self._original:
self.image_label.setText("")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from functools import partial
from bec_lib import bec_logger
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtGui import QPixmap
@@ -11,8 +12,6 @@ from qtpy.QtWidgets import (
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QSizePolicy,
@@ -28,7 +27,7 @@ from qtpy.QtWidgets import (
)
from bec_widgets import BECWidget, SafeSlot
from bec_widgets.utils.colors import apply_theme, get_accent_colors
from bec_widgets.utils.colors import get_accent_colors
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
delete_profile_files,
get_profile_info,
@@ -38,6 +37,8 @@ from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
set_quick_select,
)
logger = bec_logger.logger
class WorkSpaceManager(BECWidget, QWidget):
RPC = False
@@ -52,6 +53,9 @@ class WorkSpaceManager(BECWidget, QWidget):
):
super().__init__(parent=parent, **kwargs)
self.target_widget = target_widget
self.profile_namespace = (
getattr(target_widget, "profile_namespace", None) if target_widget else None
)
self.accent_colors = get_accent_colors()
self._init_ui()
if self.target_widget is not None and hasattr(self.target_widget, "profile_changed"):
@@ -144,7 +148,7 @@ class WorkSpaceManager(BECWidget, QWidget):
def render_table(self):
self.profile_table.setRowCount(0)
for profile in list_profiles():
for profile in list_profiles(namespace=self.profile_namespace):
self._add_profile_row(profile)
def _add_profile_row(self, name: str):
@@ -156,7 +160,7 @@ class WorkSpaceManager(BECWidget, QWidget):
actions_items_layout = QHBoxLayout(actions_items)
actions_items_layout.setContentsMargins(0, 0, 0, 0)
info = get_profile_info(name)
info = get_profile_info(name, namespace=self.profile_namespace)
# Flags
is_active = (
@@ -237,7 +241,7 @@ class WorkSpaceManager(BECWidget, QWidget):
return item.text() if item else None
def _show_profile_details(self, name: str) -> None:
info = get_profile_info(name)
info = get_profile_info(name, namespace=self.profile_namespace)
self.profile_details_tree.clear()
entries = [
("Name", info.name),
@@ -255,7 +259,7 @@ class WorkSpaceManager(BECWidget, QWidget):
self.profile_details_tree.expandAll()
# Render screenshot preview from profile INI
pm = load_profile_screenshot(name)
pm = load_profile_screenshot(name, namespace=self.profile_namespace)
if pm is not None and not pm.isNull():
scaled = pm.scaled(
self.screenshot_label.width() or 800,
@@ -299,16 +303,16 @@ class WorkSpaceManager(BECWidget, QWidget):
"workspace_combo"
).widget.setCurrentText(profile_name)
except Exception as e:
print(f"Warning: Could not update workspace combo box. {e}")
pass
logger.warning(f"Warning: Could not update workspace combo box. {e}")
self.render_table()
self._select_by_name(profile_name)
self._show_profile_details(profile_name)
@SafeSlot(str)
def toggle_quick_select(self, profile_name: str):
enabled = is_quick_select(profile_name)
set_quick_select(profile_name, not enabled)
enabled = is_quick_select(profile_name, namespace=self.profile_namespace)
set_quick_select(profile_name, not enabled, namespace=self.profile_namespace)
self.render_table()
if self.target_widget is not None:
self.target_widget._refresh_workspace_list()
@@ -337,7 +341,7 @@ class WorkSpaceManager(BECWidget, QWidget):
@SafeSlot(str)
def delete_profile(self, profile_name: str):
info = get_profile_info(profile_name)
info = get_profile_info(profile_name, namespace=self.profile_namespace)
if info.is_read_only:
QMessageBox.information(
self, "Delete Profile", "This profile is read-only and cannot be deleted."
@@ -358,7 +362,7 @@ class WorkSpaceManager(BECWidget, QWidget):
return
try:
removed = delete_profile_files(profile_name)
removed = delete_profile_files(profile_name, namespace=self.profile_namespace)
except OSError as exc:
QMessageBox.warning(
self, "Delete Profile", f"Failed to delete profile '{profile_name}': {exc}"
@@ -378,7 +382,7 @@ class WorkSpaceManager(BECWidget, QWidget):
self.target_widget._refresh_workspace_list()
self.render_table()
remaining_profiles = list_profiles()
remaining_profiles = list_profiles(namespace=self.profile_namespace)
if remaining_profiles:
next_profile = remaining_profiles[0]
self._select_by_name(next_profile)
@@ -392,7 +396,7 @@ class WorkSpaceManager(BECWidget, QWidget):
name = self._current_selected_profile()
if not name:
return
pm = load_profile_screenshot(name)
pm = load_profile_screenshot(name, namespace=self.profile_namespace)
if pm is None or pm.isNull():
return
scaled = pm.scaled(

View File

@@ -1,5 +1,7 @@
from __future__ import annotations
from typing import Callable
from qtpy.QtCore import Qt
from qtpy.QtGui import QFont
from qtpy.QtWidgets import QComboBox, QSizePolicy
@@ -17,6 +19,10 @@ class ProfileComboBox(QComboBox):
def __init__(self, parent=None):
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._quick_provider: Callable[[], list[str]] = list_quick_profiles
def set_quick_profile_provider(self, provider: Callable[[], list[str]]) -> None:
self._quick_provider = provider
def refresh_profiles(self, active_profile: str | None = None):
"""
@@ -30,7 +36,7 @@ class ProfileComboBox(QComboBox):
self.blockSignals(True)
self.clear()
quick_profiles = list_quick_profiles()
quick_profiles = self._quick_provider()
quick_set = set(quick_profiles)
items = list(quick_profiles)
@@ -76,7 +82,7 @@ class ProfileComboBox(QComboBox):
self.setToolTip("")
def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
def workspace_bundle(components: ToolbarComponents, enable_tools: bool = True) -> ToolbarBundle:
"""
Creates a workspace toolbar bundle for AdvancedDockArea.
@@ -88,9 +94,9 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
# Workspace combo
combo = ProfileComboBox(parent=components.toolbar)
combo.setVisible(enable_tools)
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
# Save the current workspace icon
components.add_safe(
"save_workspace",
MaterialIconAction(
@@ -100,7 +106,8 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
parent=components.toolbar,
),
)
# Delete workspace icon
components.get_action("save_workspace").action.setVisible(enable_tools)
components.add_safe(
"reset_default_workspace",
MaterialIconAction(
@@ -110,17 +117,15 @@ def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
parent=components.toolbar,
),
)
# Workspace Manager icon
components.get_action("reset_default_workspace").action.setVisible(enable_tools)
components.add_safe(
"manage_workspaces",
MaterialIconAction(
icon_name="manage_accounts",
tooltip="Manage",
checkable=True,
parent=components.toolbar,
label_text="Manage",
icon_name="manage_accounts", tooltip="Manage", checkable=True, parent=components.toolbar
),
)
components.get_action("manage_workspaces").action.setVisible(enable_tools)
bundle = ToolbarBundle("workspace", components)
bundle.add_action("workspace_combo")
@@ -147,35 +152,40 @@ class WorkspaceConnection(BundleConnection):
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action("save_workspace").action.triggered.connect(
self.target_widget.save_profile
)
save_action = self.components.get_action("save_workspace").action
if save_action.isVisible():
save_action.triggered.connect(self.target_widget.save_profile)
self.components.get_action("workspace_combo").widget.currentTextChanged.connect(
self.target_widget.load_profile
)
self.components.get_action("reset_default_workspace").action.triggered.connect(
self._reset_workspace_to_default
)
self.components.get_action("manage_workspaces").action.triggered.connect(
self.target_widget.show_workspace_manager
)
reset_action = self.components.get_action("reset_default_workspace").action
if reset_action.isVisible():
reset_action.triggered.connect(self._reset_workspace_to_default)
manage_action = self.components.get_action("manage_workspaces").action
if manage_action.isVisible():
manage_action.triggered.connect(self.target_widget.show_workspace_manager)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action("save_workspace").action.triggered.disconnect(
self.target_widget.save_profile
)
save_action = self.components.get_action("save_workspace").action
if save_action.isVisible():
save_action.triggered.disconnect(self.target_widget.save_profile)
self.components.get_action("workspace_combo").widget.currentTextChanged.disconnect(
self.target_widget.load_profile
)
self.components.get_action("reset_default_workspace").action.triggered.disconnect(
self._reset_workspace_to_default
)
self.components.get_action("manage_workspaces").action.triggered.disconnect(
self.target_widget.show_workspace_manager
)
reset_action = self.components.get_action("reset_default_workspace").action
if reset_action.isVisible():
reset_action.triggered.disconnect(self._reset_workspace_to_default)
manage_action = self.components.get_action("manage_workspaces").action
if manage_action.isVisible():
manage_action.triggered.disconnect(self.target_widget.show_workspace_manager)
self._connected = False
@SafeSlot()

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
import os
import shiboken6
from bec_lib.endpoints import MessageEndpoints
from qtpy.QtCore import QEasingCurve, QEvent, QPropertyAnimation, QSize, Qt, QTimer
from qtpy.QtGui import QAction, QActionGroup, QIcon
@@ -22,7 +21,6 @@ from bec_widgets.utils import UILoader
from bec_widgets.utils.bec_widget import BECWidget
from bec_widgets.utils.colors import apply_theme
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.widget_io import WidgetHierarchy
from bec_widgets.widgets.containers.main_window.addons.hover_widget import HoverWidget
from bec_widgets.widgets.containers.main_window.addons.notification_center.notification_banner import (
BECNotificationBroker,

View File

@@ -6,16 +6,20 @@ from unittest import mock
from unittest.mock import MagicMock, patch
import pytest
from qtpy.QtCore import QSettings, Qt
from qtpy.QtCore import QSettings, Qt, QTimer
from qtpy.QtGui import QPixmap
from qtpy.QtWidgets import QDialog, QMessageBox
from qtpy.QtWidgets import QDialog, QMessageBox, QWidget
import bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area as basic_dock_module
import bec_widgets.widgets.containers.advanced_dock_area.profile_utils as profile_utils
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import (
AdvancedDockArea,
DockSettingsDialog,
SaveProfileDialog,
)
from bec_widgets.widgets.containers.advanced_dock_area.basic_dock_area import (
DockAreaWidget,
DockSettingsDialog,
)
from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import (
default_profile_path,
get_profile_info,
@@ -145,13 +149,372 @@ def workspace_manager_target():
return _factory
@pytest.fixture
def basic_dock_area(qtbot, mocked_client):
"""Create a namesake DockAreaWidget without the advanced toolbar."""
widget = DockAreaWidget(client=mocked_client, title="Test Dock Area")
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
class _NamespaceProfiles:
"""Helper that routes profile file helpers through a namespace."""
def __init__(self, widget: AdvancedDockArea):
self.namespace = widget.profile_namespace
def open_user(self, name: str):
return open_user_settings(name, namespace=self.namespace)
def open_default(self, name: str):
return open_default_settings(name, namespace=self.namespace)
def user_path(self, name: str) -> str:
return user_profile_path(name, namespace=self.namespace)
def default_path(self, name: str) -> str:
return default_profile_path(name, namespace=self.namespace)
def list_profiles(self) -> list[str]:
return list_profiles(namespace=self.namespace)
def set_quick_select(self, name: str, enabled: bool):
set_quick_select(name, enabled, namespace=self.namespace)
def is_quick_select(self, name: str) -> bool:
return is_quick_select(name, namespace=self.namespace)
def profile_helper(widget: AdvancedDockArea) -> _NamespaceProfiles:
"""Return a helper wired to the widget's profile namespace."""
return _NamespaceProfiles(widget)
class TestBasicDockArea:
"""Focused coverage for the lightweight DockAreaWidget base."""
def test_new_widget_instance_registers_in_maps(self, basic_dock_area):
panel = QWidget(parent=basic_dock_area)
panel.setObjectName("basic_panel")
dock = basic_dock_area.new(panel, return_dock=True)
assert dock.objectName() == "basic_panel"
assert basic_dock_area.dock_map()["basic_panel"] is dock
assert basic_dock_area.widget_map()["basic_panel"] is panel
def test_new_widget_string_creates_widget(self, basic_dock_area, qtbot):
basic_dock_area.new("DarkModeButton")
qtbot.waitUntil(lambda: len(basic_dock_area.dock_list()) > 0, timeout=1000)
assert basic_dock_area.widget_list()
def test_custom_close_handler_invoked(self, basic_dock_area, qtbot):
class CloseAwareWidget(QWidget):
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("closable")
self.closed = False
def handle_dock_close(self, dock, widget): # pragma: no cover - exercised via signal
self.closed = True
dock.closeDockWidget()
dock.deleteDockWidget()
widget = CloseAwareWidget(parent=basic_dock_area)
dock = basic_dock_area.new(widget, return_dock=True)
dock.closeRequested.emit()
qtbot.waitUntil(lambda: widget.closed, timeout=1000)
assert widget.closed is True
assert "closable" not in basic_dock_area.dock_map()
def test_attach_all_and_delete_all(self, basic_dock_area):
first = QWidget(parent=basic_dock_area)
first.setObjectName("floating_one")
second = QWidget(parent=basic_dock_area)
second.setObjectName("floating_two")
dock_one = basic_dock_area.new(first, return_dock=True, start_floating=True)
dock_two = basic_dock_area.new(second, return_dock=True, start_floating=True)
assert dock_one.isFloating() and dock_two.isFloating()
basic_dock_area.attach_all()
assert not dock_one.isFloating()
assert not dock_two.isFloating()
basic_dock_area.delete_all()
assert basic_dock_area.dock_list() == []
def test_splitter_weight_coercion_supports_aliases(self, basic_dock_area):
weights = {"default": 0.5, "left": 2, "center": 3, "right": 4}
result = basic_dock_area._coerce_weights(weights, 3, Qt.Orientation.Horizontal)
assert result == [2.0, 3.0, 4.0]
assert basic_dock_area._coerce_weights([0.0], 3, Qt.Orientation.Vertical) == [0.0, 1.0, 1.0]
assert basic_dock_area._coerce_weights([0.0, 0.0], 2, Qt.Orientation.Vertical) == [1.0, 1.0]
def test_splitter_override_keys_are_normalized(self, basic_dock_area):
overrides = {0: [1, 2], (1, 0): [3, 4], "2.1": [5], " / ": [6]}
normalized = basic_dock_area._normalize_override_keys(overrides)
assert normalized == {(0,): [1, 2], (1, 0): [3, 4], (2, 1): [5], (): [6]}
def test_schedule_splitter_weights_sets_sizes(self, basic_dock_area, monkeypatch):
monkeypatch.setattr(QTimer, "singleShot", lambda *_args: _args[-1]())
class DummySplitter:
def __init__(self):
self._children = [object(), object(), object()]
self.sizes = None
self.stretch = []
def count(self):
return len(self._children)
def orientation(self):
return Qt.Orientation.Horizontal
def width(self):
return 300
def height(self):
return 120
def setSizes(self, sizes):
self.sizes = sizes
def setStretchFactor(self, idx, value):
self.stretch.append((idx, value))
splitter = DummySplitter()
basic_dock_area._schedule_splitter_weights(splitter, [1, 2, 1])
assert splitter.sizes == [75, 150, 75]
assert splitter.stretch == [(0, 100), (1, 200), (2, 100)]
def test_apply_splitter_tree_honors_overrides(self, basic_dock_area, monkeypatch):
class DummySplitter:
def __init__(self, orientation, children=None, label="splitter"):
self._orientation = orientation
self._children = list(children or [])
self.label = label
def count(self):
return len(self._children)
def orientation(self):
return self._orientation
def widget(self, idx):
return self._children[idx]
monkeypatch.setattr(basic_dock_module.QtAds, "CDockSplitter", DummySplitter)
leaf = DummySplitter(Qt.Orientation.Horizontal, [], label="leaf")
column_one = DummySplitter(Qt.Orientation.Vertical, [leaf], label="column_one")
column_zero = DummySplitter(Qt.Orientation.Vertical, [], label="column_zero")
root = DummySplitter(Qt.Orientation.Horizontal, [column_zero, column_one], label="root")
calls = []
def fake_schedule(self, splitter, weights):
calls.append((splitter.label, weights))
monkeypatch.setattr(DockAreaWidget, "_schedule_splitter_weights", fake_schedule)
overrides = {(): ["root_override"], (0,): ["column_override"]}
basic_dock_area._apply_splitter_tree(
root, (), horizontal=[1, 2], vertical=[3, 4], overrides=overrides
)
assert calls[0] == ("root", ["root_override"])
assert calls[1] == ("column_zero", ["column_override"])
assert calls[2] == ("column_one", [3, 4])
assert calls[3] == ("leaf", ["column_override"])
def test_set_layout_ratios_normalizes_and_applies(self, basic_dock_area, monkeypatch):
class DummyContainer:
def __init__(self, splitter):
self._splitter = splitter
def rootSplitter(self):
return self._splitter
root_one = object()
root_two = object()
containers = [DummyContainer(root_one), DummyContainer(None), DummyContainer(root_two)]
monkeypatch.setattr(basic_dock_area.dock_manager, "dockContainers", lambda: containers)
calls = []
def fake_apply(self, splitter, path, horizontal, vertical, overrides):
calls.append((splitter, path, horizontal, vertical, overrides))
monkeypatch.setattr(DockAreaWidget, "_apply_splitter_tree", fake_apply)
basic_dock_area.set_layout_ratios(
horizontal=[1, 1, 1], vertical=[2, 3], splitter_overrides={"1/0": [5, 5], "": [9]}
)
assert len(calls) == 2
for splitter, path, horizontal, vertical, overrides in calls:
assert splitter in {root_one, root_two}
assert path == ()
assert horizontal == [1, 1, 1]
assert vertical == [2, 3]
assert overrides == {(): [9], (1, 0): [5, 5]}
def test_show_settings_action_defaults_disabled(self, basic_dock_area):
widget = QWidget(parent=basic_dock_area)
widget.setObjectName("settings_default")
dock = basic_dock_area.new(widget, return_dock=True)
assert dock._dock_preferences.get("show_settings_action") is False
assert not hasattr(dock, "setting_action")
def test_show_settings_action_can_be_enabled(self, basic_dock_area):
widget = QWidget(parent=basic_dock_area)
widget.setObjectName("settings_enabled")
dock = basic_dock_area.new(widget, return_dock=True, show_settings_action=True)
assert dock._dock_preferences.get("show_settings_action") is True
assert hasattr(dock, "setting_action")
assert dock.setting_action.toolTip() == "Dock settings"
def test_collect_splitter_info_describes_children(self, basic_dock_area, monkeypatch):
class DummyDockWidget:
def __init__(self, name):
self._name = name
def objectName(self):
return self._name
class DummyDockArea:
def __init__(self, dock_names):
self._docks = [DummyDockWidget(name) for name in dock_names]
def dockWidgets(self):
return self._docks
class DummySplitter:
def __init__(self, orientation, children=None):
self._orientation = orientation
self._children = list(children or [])
def orientation(self):
return self._orientation
def count(self):
return len(self._children)
def widget(self, idx):
return self._children[idx]
class Spacer:
pass
monkeypatch.setattr(basic_dock_module, "CDockSplitter", DummySplitter)
monkeypatch.setattr(basic_dock_module, "CDockAreaWidget", DummyDockArea)
monkeypatch.setattr(basic_dock_module, "CDockWidget", DummyDockWidget)
nested_splitter = DummySplitter(Qt.Orientation.Horizontal)
dock_area_child = DummyDockArea(["left", "right"])
dock_child = DummyDockWidget("solo")
spacer = Spacer()
root_splitter = DummySplitter(
Qt.Orientation.Vertical, [nested_splitter, dock_area_child, dock_child, spacer]
)
results = []
basic_dock_area._collect_splitter_info(root_splitter, (2,), results, container_index=5)
assert len(results) == 2
root_entry = results[0]
assert root_entry["container"] == 5
assert root_entry["path"] == (2,)
assert root_entry["orientation"] == "vertical"
assert root_entry["children"] == [
{"index": 0, "type": "splitter"},
{"index": 1, "type": "dock_area", "docks": ["left", "right"]},
{"index": 2, "type": "dock", "name": "solo"},
{"index": 3, "type": "Spacer"},
]
nested_entry = results[1]
assert nested_entry["path"] == (2, 0)
assert nested_entry["orientation"] == "horizontal"
def test_describe_layout_aggregates_containers(self, basic_dock_area, monkeypatch):
class DummyContainer:
def __init__(self, splitter):
self._splitter = splitter
def rootSplitter(self):
return self._splitter
containers = [DummyContainer("root0"), DummyContainer(None), DummyContainer("root2")]
monkeypatch.setattr(basic_dock_area.dock_manager, "dockContainers", lambda: containers)
calls = []
def recorder(self, splitter, path, results, container_index):
entry = {"container": container_index, "splitter": splitter, "path": path}
results.append(entry)
calls.append(entry)
monkeypatch.setattr(DockAreaWidget, "_collect_splitter_info", recorder)
info = basic_dock_area.describe_layout()
assert info == calls
assert [entry["splitter"] for entry in info] == ["root0", "root2"]
assert [entry["container"] for entry in info] == [0, 2]
assert all(entry["path"] == () for entry in info)
def test_print_layout_structure_formats_output(self, basic_dock_area, monkeypatch, capsys):
entries = [
{
"container": 1,
"path": (0,),
"orientation": "horizontal",
"children": [
{"index": 0, "type": "dock_area", "docks": ["alpha", "beta"]},
{"index": 1, "type": "dock", "name": "solo"},
{"index": 2, "type": "splitter"},
{"index": 3, "type": "Placeholder"},
],
}
]
monkeypatch.setattr(DockAreaWidget, "describe_layout", lambda self: entries)
basic_dock_area.print_layout_structure()
captured = capsys.readouterr().out.strip().splitlines()
assert captured == [
"container=1 path=(0,) orientation=horizontal -> "
"[0:dock_area[alpha, beta], 1:dock(solo), 2:splitter, 3:Placeholder]"
]
class TestAdvancedDockAreaInit:
"""Test initialization and basic properties."""
def test_init(self, advanced_dock_area):
assert advanced_dock_area is not None
assert isinstance(advanced_dock_area, AdvancedDockArea)
assert advanced_dock_area.mode == "developer"
assert advanced_dock_area.mode == "creator"
assert hasattr(advanced_dock_area, "dock_manager")
assert hasattr(advanced_dock_area, "toolbar")
assert hasattr(advanced_dock_area, "dark_mode_button")
@@ -293,6 +656,29 @@ class TestDockManagement:
assert len(advanced_dock_area.dock_list()) == 0
class TestAdvancedDockSettingsAction:
"""Ensure AdvancedDockArea exposes dock settings actions by default."""
def test_settings_action_installed_by_default(self, advanced_dock_area):
widget = QWidget(parent=advanced_dock_area)
widget.setObjectName("advanced_default_settings")
dock = advanced_dock_area.new(widget, return_dock=True)
assert hasattr(dock, "setting_action")
assert dock.setting_action.toolTip() == "Dock settings"
assert dock._dock_preferences.get("show_settings_action") is True
def test_settings_action_can_be_disabled(self, advanced_dock_area):
widget = QWidget(parent=advanced_dock_area)
widget.setObjectName("advanced_settings_off")
dock = advanced_dock_area.new(widget, return_dock=True, show_settings_action=False)
assert not hasattr(dock, "setting_action")
assert dock._dock_preferences.get("show_settings_action") is False
class TestWorkspaceLocking:
"""Test workspace locking functionality."""
@@ -873,6 +1259,11 @@ class TestWorkSpaceManager:
):
readonly = module_profile_factory("readonly_delete")
list_profiles()
monkeypatch.setattr(
profile_utils,
"get_profile_info",
lambda *a, **k: profile_utils.ProfileInfo(name=readonly, is_read_only=True),
)
info_calls = []
monkeypatch.setattr(
QMessageBox,
@@ -892,19 +1283,20 @@ class TestAdvancedDockAreaRestoreAndDialogs:
def test_restore_user_profile_from_default_confirm_true(self, advanced_dock_area, monkeypatch):
profile_name = "profile_restore_true"
open_default_settings(profile_name).sync()
open_user_settings(profile_name).sync()
helper = profile_helper(advanced_dock_area)
helper.open_default(profile_name).sync()
helper.open_user(profile_name).sync()
advanced_dock_area._current_profile_name = profile_name
advanced_dock_area.isVisible = lambda: False
pix = QPixmap(8, 8)
pix.fill(Qt.red)
monkeypatch.setattr(
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_user_profile_screenshot",
lambda name: pix,
lambda name, namespace=None: pix,
)
monkeypatch.setattr(
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.load_default_profile_screenshot",
lambda name: pix,
lambda name, namespace=None: pix,
)
monkeypatch.setattr(
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.RestoreProfileDialog.confirm",
@@ -920,14 +1312,18 @@ class TestAdvancedDockAreaRestoreAndDialogs:
):
advanced_dock_area.restore_user_profile_from_default()
mock_restore.assert_called_once_with(profile_name)
assert mock_restore.call_count == 1
args, kwargs = mock_restore.call_args
assert args == (profile_name,)
assert kwargs.get("namespace") == advanced_dock_area.profile_namespace
mock_delete_all.assert_called_once()
mock_load_profile.assert_called_once_with(profile_name)
def test_restore_user_profile_from_default_confirm_false(self, advanced_dock_area, monkeypatch):
profile_name = "profile_restore_false"
open_default_settings(profile_name).sync()
open_user_settings(profile_name).sync()
helper = profile_helper(advanced_dock_area)
helper.open_default(profile_name).sync()
helper.open_user(profile_name).sync()
advanced_dock_area._current_profile_name = profile_name
advanced_dock_area.isVisible = lambda: False
monkeypatch.setattr(
@@ -960,7 +1356,8 @@ class TestAdvancedDockAreaRestoreAndDialogs:
def test_refresh_workspace_list_with_refresh_profiles(self, advanced_dock_area):
profile_name = "refresh_profile"
open_user_settings(profile_name).sync()
helper = profile_helper(advanced_dock_area)
helper.open_user(profile_name).sync()
advanced_dock_area._current_profile_name = profile_name
combo = advanced_dock_area.toolbar.components.get_action("workspace_combo").widget
combo.refresh_profiles = MagicMock()
@@ -1002,9 +1399,10 @@ class TestAdvancedDockAreaRestoreAndDialogs:
active = "active_profile"
quick = "quick_profile"
open_user_settings(active).sync()
open_user_settings(quick).sync()
set_quick_select(quick, True)
helper = profile_helper(advanced_dock_area)
helper.open_user(active).sync()
helper.open_user(quick).sync()
helper.set_quick_select(quick, True)
combo_stub = ComboStub()
@@ -1027,7 +1425,8 @@ class TestAdvancedDockAreaRestoreAndDialogs:
assert not action.isChecked()
advanced_dock_area._current_profile_name = "manager_profile"
open_user_settings("manager_profile").sync()
helper = profile_helper(advanced_dock_area)
helper.open_user("manager_profile").sync()
advanced_dock_area.show_workspace_manager()
@@ -1175,7 +1574,8 @@ class TestWorkspaceProfileOperations:
"""Test saving profile when read-only profile exists."""
profile_name = module_profile_factory("readonly_profile")
new_profile = f"{profile_name}_custom"
target_path = user_profile_path(new_profile)
helper = profile_helper(advanced_dock_area)
target_path = helper.user_path(new_profile)
if os.path.exists(target_path):
os.remove(target_path)
@@ -1203,9 +1603,10 @@ class TestWorkspaceProfileOperations:
def test_load_profile_with_manifest(self, advanced_dock_area, temp_profile_dir, qtbot):
"""Test loading profile with widget manifest."""
profile_name = "test_load_profile"
helper = profile_helper(advanced_dock_area)
# Create a profile with manifest
settings = open_user_settings(profile_name)
settings = helper.open_user(profile_name)
settings.beginWriteArray("manifest/widgets", 1)
settings.setArrayIndex(0)
settings.setValue("object_name", "test_widget")
@@ -1232,8 +1633,9 @@ class TestWorkspaceProfileOperations:
"""Saving a new profile avoids overwriting the source profile during the switch."""
source_profile = "autosave_source"
new_profile = "autosave_new"
helper = profile_helper(advanced_dock_area)
settings = open_user_settings(source_profile)
settings = helper.open_user(source_profile)
settings.beginWriteArray("manifest/widgets", 1)
settings.setArrayIndex(0)
settings.setValue("object_name", "source_widget")
@@ -1269,8 +1671,8 @@ class TestWorkspaceProfileOperations:
advanced_dock_area.save_profile()
qtbot.wait(500)
source_manifest = read_manifest(open_user_settings(source_profile))
new_manifest = read_manifest(open_user_settings(new_profile))
source_manifest = read_manifest(helper.open_user(source_profile))
new_manifest = read_manifest(helper.open_user(new_profile))
assert len(source_manifest) == 1
assert len(new_manifest) == 2
@@ -1279,9 +1681,10 @@ class TestWorkspaceProfileOperations:
"""Regular profile switches should persist the outgoing layout."""
profile_a = "autosave_keep"
profile_b = "autosave_target"
helper = profile_helper(advanced_dock_area)
for profile in (profile_a, profile_b):
settings = open_user_settings(profile)
settings = helper.open_user(profile)
settings.beginWriteArray("manifest/widgets", 1)
settings.setArrayIndex(0)
settings.setValue("object_name", f"{profile}_widget")
@@ -1300,7 +1703,7 @@ class TestWorkspaceProfileOperations:
advanced_dock_area.load_profile(profile_b)
qtbot.wait(500)
manifest_a = read_manifest(open_user_settings(profile_a))
manifest_a = read_manifest(helper.open_user(profile_a))
assert len(manifest_a) == 2
def test_delete_profile_readonly(
@@ -1308,12 +1711,14 @@ class TestWorkspaceProfileOperations:
):
"""Test deleting bundled profile removes only the writable copy."""
profile_name = module_profile_factory("readonly_profile")
list_profiles() # ensure default and user copies are materialized
settings = open_user_settings(profile_name)
helper = profile_helper(advanced_dock_area)
helper.list_profiles() # ensure default and user copies are materialized
helper.open_default(profile_name).sync()
settings = helper.open_user(profile_name)
settings.setValue("test", "value")
settings.sync()
user_path = user_profile_path(profile_name)
default_path = default_profile_path(profile_name)
user_path = helper.user_path(profile_name)
default_path = helper.default_path(profile_name)
assert os.path.exists(user_path)
assert os.path.exists(default_path)
@@ -1322,27 +1727,34 @@ class TestWorkspaceProfileOperations:
mock_combo.currentText.return_value = profile_name
mock_get_action.return_value.widget = mock_combo
with patch(
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question"
) as mock_question:
mock_question.return_value = QMessageBox.Yes
with (
patch(
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.question",
return_value=QMessageBox.Yes,
) as mock_question,
patch(
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.information",
return_value=None,
) as mock_info,
):
advanced_dock_area.delete_profile()
mock_question.assert_called_once()
# User copy should be removed, default remains
assert not os.path.exists(user_path)
mock_question.assert_not_called()
mock_info.assert_called_once()
# Read-only profile should remain intact (user + default copies)
assert os.path.exists(user_path)
assert os.path.exists(default_path)
def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir):
"""Test successful profile deletion."""
profile_name = "deletable_profile"
helper = profile_helper(advanced_dock_area)
# Create regular profile
settings = open_user_settings(profile_name)
settings = helper.open_user(profile_name)
settings.setValue("test", "value")
settings.sync()
user_path = user_profile_path(profile_name)
user_path = helper.user_path(profile_name)
assert os.path.exists(user_path)
with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action:
@@ -1366,8 +1778,9 @@ class TestWorkspaceProfileOperations:
def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir):
"""Test refreshing workspace list."""
# Create some profiles
helper = profile_helper(advanced_dock_area)
for name in ["profile1", "profile2"]:
settings = open_user_settings(name)
settings = helper.open_user(name)
settings.setValue("test", "value")
settings.sync()
@@ -1451,15 +1864,18 @@ class TestCleanupAndMisc:
widget = DarkModeButton(parent=advanced_dock_area)
widget.setObjectName("test_widget")
dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True)
with patch.object(advanced_dock_area, "_open_dock_settings_dialog") as mock_open_dialog:
dock = advanced_dock_area._make_dock(
widget, closable=True, floatable=True, movable=True
)
# Verify dock has settings action
assert hasattr(dock, "setting_action")
assert dock.setting_action is not None
# Verify dock has settings action
assert hasattr(dock, "setting_action")
assert dock.setting_action is not None
assert dock.setting_action.toolTip() == "Dock settings"
# Verify title bar actions were set
title_bar_actions = dock.titleBarActions()
assert len(title_bar_actions) >= 1
dock.setting_action.trigger()
mock_open_dialog.assert_called_once_with(dock, widget)
class TestModeSwitching:
@@ -1467,7 +1883,7 @@ class TestModeSwitching:
def test_mode_property_setter_valid_modes(self, advanced_dock_area):
"""Test setting valid modes."""
valid_modes = ["plot", "device", "utils", "developer", "user"]
valid_modes = ["plot", "device", "utils", "creator", "user"]
for mode in valid_modes:
advanced_dock_area.mode = mode
@@ -1534,7 +1950,7 @@ class TestToolbarModeBundles:
def test_developer_mode_toolbar_visibility(self, advanced_dock_area):
"""Test toolbar bundle visibility in developer mode."""
advanced_dock_area.mode = "developer"
advanced_dock_area.mode = "creator"
shown_bundles = advanced_dock_area.toolbar.shown_bundles
@@ -1622,7 +2038,7 @@ class TestFlatToolbarActions:
with patch.object(advanced_dock_area, "new") as mock_new:
action = advanced_dock_area.toolbar.components.get_action(action_name).action
action.trigger()
mock_new.assert_called_once_with(widget=widget_type)
mock_new.assert_called_once_with(widget_type)
def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area):
"""Test flat device actions trigger widget creation."""
@@ -1635,7 +2051,7 @@ class TestFlatToolbarActions:
with patch.object(advanced_dock_area, "new") as mock_new:
action = advanced_dock_area.toolbar.components.get_action(action_name).action
action.trigger()
mock_new.assert_called_once_with(widget=widget_type)
mock_new.assert_called_once_with(widget_type)
def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area):
"""Test flat utils actions trigger widget creation."""
@@ -1658,7 +2074,7 @@ class TestFlatToolbarActions:
continue
action.trigger()
mock_new.assert_called_once_with(widget=widget_type)
mock_new.assert_called_once_with(widget_type)
def test_flat_log_panel_action_disabled(self, advanced_dock_area):
"""Test that flat log panel action is disabled."""
@@ -1671,7 +2087,7 @@ class TestModeTransitions:
def test_mode_transition_sequence(self, advanced_dock_area, qtbot):
"""Test sequence of mode transitions."""
modes = ["plot", "device", "utils", "developer", "user"]
modes = ["plot", "device", "utils", "creator", "user"]
for mode in modes:
with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker:
@@ -1686,7 +2102,7 @@ class TestModeTransitions:
advanced_dock_area.mode = "plot"
advanced_dock_area.mode = "device"
advanced_dock_area.mode = "utils"
advanced_dock_area.mode = "developer"
advanced_dock_area.mode = "creator"
advanced_dock_area.mode = "user"
# Final state should be consistent
@@ -1758,7 +2174,7 @@ class TestModeProperty:
def test_multiple_mode_changes(self, advanced_dock_area, qtbot):
"""Test multiple rapid mode changes."""
modes = ["plot", "device", "utils", "developer", "user"]
modes = ["plot", "device", "utils", "creator", "user"]
for i, mode in enumerate(modes):
with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: