diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py index 489d2451..a7a062ba 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -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) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py new file mode 100644 index 00000000..09898569 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py @@ -0,0 +1,1368 @@ +from __future__ import annotations + +import inspect +from dataclasses import dataclass +from typing import Any, Callable, Literal, Mapping, Sequence, cast + +from bec_qthemes import material_icon +from PySide6QtAds import ads +from qtpy.QtCore import QByteArray, QSettings, Qt, QTimer +from qtpy.QtGui import QIcon +from qtpy.QtWidgets import QDialog, QVBoxLayout, QWidget +from shiboken6 import isValid + +import bec_widgets.widgets.containers.ads as QtAds +from bec_widgets import BECWidget, SafeSlot +from bec_widgets.cli.rpc.rpc_widget_handler import widget_handler +from bec_widgets.utils.property_editor import PropertyEditor +from bec_widgets.utils.toolbars.actions import MaterialIconAction +from bec_widgets.widgets.containers.ads import ( + CDockAreaWidget, + CDockManager, + CDockSplitter, + CDockWidget, +) + + +class DockSettingsDialog(QDialog): + """Generic settings editor shown from dock title bar actions.""" + + def __init__(self, parent: QWidget, target: QWidget): + super().__init__(parent) + self.setWindowTitle("Dock Settings") + self.setModal(True) + layout = QVBoxLayout(self) + self.prop_editor = PropertyEditor(target, self, show_only_bec=True) + layout.addWidget(self.prop_editor) + + +class DockAreaWidget(BECWidget, QWidget): + """ + Lightweight dock area that exposes the core Qt ADS docking helpers without any + of the toolbar or workspace management features that the advanced variant offers. + """ + + RPC = True + PLUGIN = False + USER_ACCESS = [ + "new", + "dock_map", + "dock_list", + "widget_map", + "widget_list", + "attach_all", + "delete_all", + "set_layout_ratios", + "describe_layout", + "print_layout_structure", + "set_central_dock", + ] + + @dataclass + class DockCreationSpec: + widget: QWidget + closable: bool = True + floatable: bool = True + movable: bool = True + start_floating: bool = False + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea + on_close: Callable[[CDockWidget, QWidget], None] | None = None + tab_with: CDockWidget | None = None + relative_to: CDockWidget | None = None + title_visible: bool | None = None + title_buttons: Mapping[ads.TitleBarButton, bool] | None = None + show_settings_action: bool | None = False + dock_preferences: Mapping[str, Any] | None = None + promote_central: bool = False + dock_icon: QIcon | None = None + apply_widget_icon: bool = True + + def __init__( + self, + parent: QWidget | None = None, + default_add_direction: Literal["left", "right", "top", "bottom"] = "right", + title: str = "Dock Area", + variant: Literal["cards", "compact"] = "cards", + **kwargs, + ): + super().__init__(parent=parent, **kwargs) + + # Set variant property for styling + + if title: + self.setWindowTitle(title) + + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + self.dock_manager = CDockManager(self) + self.dock_manager.setStyleSheet("") + self.dock_manager.setProperty("variant", variant) + + self._locked = False + self._default_add_direction = ( + default_add_direction + if default_add_direction in ("left", "right", "top", "bottom") + else "right" + ) + + self._root_layout.addWidget(self.dock_manager, 1) + + ################################################################################ + # Dock Utility Helpers + ################################################################################ + + def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea: + """Translate a direction string into a Qt ADS dock widget area.""" + direction = (where or self._default_add_direction or "right").lower() + mapping = { + "left": QtAds.DockWidgetArea.LeftDockWidgetArea, + "right": QtAds.DockWidgetArea.RightDockWidgetArea, + "top": QtAds.DockWidgetArea.TopDockWidgetArea, + "bottom": QtAds.DockWidgetArea.BottomDockWidgetArea, + } + return mapping.get(direction, QtAds.DockWidgetArea.RightDockWidgetArea) + + def _customize_dock(self, dock: CDockWidget, widget: QWidget) -> None: + """Hook for subclasses to customise the dock before it is shown.""" + prefs: Mapping[str, Any] = getattr(dock, "_dock_preferences", {}) or {} + show_settings = prefs.get("show_settings_action") + if show_settings: + self._install_dock_settings_action(dock, widget) + + def _install_dock_settings_action(self, dock: CDockWidget, widget: QWidget) -> None: + """Attach a dock-level settings action if available.""" + if getattr(dock, "setting_action", None) is not None: + return + + action = MaterialIconAction( + icon_name="settings", tooltip="Dock settings", filled=True, parent=self + ).action + action.setObjectName("dockSettingsAction") + action.setToolTip("Dock settings") + action.triggered.connect(lambda: self._open_dock_settings_dialog(dock, widget)) + + existing = list(dock.titleBarActions()) + existing.append(action) + dock.setTitleBarActions(existing) + dock.setting_action = action + + def _open_dock_settings_dialog(self, dock: CDockWidget, widget: QWidget) -> None: + """Launch the property editor dialog for the dock's widget.""" + dlg = DockSettingsDialog(self, widget) + dlg.resize(600, 600) + dlg.exec() + + ################################################################################ + # Dock Lifecycle + ################################################################################ + + def _default_close_handler(self, dock: CDockWidget, widget: QWidget) -> None: + """Default dock close routine used when no custom handler is provided.""" + widget.close() + dock.closeDockWidget() + dock.deleteDockWidget() + + def close_dock(self, dock: CDockWidget, widget: QWidget | None = None) -> None: + """ + Helper for custom close handlers to invoke the default close behaviour. + + Args: + dock: Dock widget to close. + widget: Optional widget contained in the dock; resolved automatically when not given. + """ + target_widget = widget or dock.widget() + if target_widget is None: + return + self._default_close_handler(dock, target_widget) + + def _wrap_close_candidate( + self, candidate: Callable, widget: QWidget + ) -> Callable[[CDockWidget], None]: + """ + Wrap a user-provided close handler to adapt its signature. + + Args: + candidate(Callable): User-provided close handler. + widget(QWidget): Widget contained in the dock. + + Returns: + Callable[[CDockWidget], None]: Wrapped close handler. + """ + try: + sig = inspect.signature(candidate) + accepts_varargs = any( + p.kind == inspect.Parameter.VAR_POSITIONAL for p in sig.parameters.values() + ) + positional_params = [ + p + for p in sig.parameters.values() + if p.kind + in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD) + ] + except (ValueError, TypeError): + accepts_varargs = True + positional_params = [] + + positional_count = len(positional_params) + + def invoke(dock: CDockWidget) -> None: + try: + if accepts_varargs or positional_count >= 2: + candidate(dock, widget) + elif positional_count == 1: + candidate(dock) + else: + candidate() + except TypeError: + # Best effort fallback in case the signature inspection was misleading. + candidate(dock, widget) + + return invoke + + def _resolve_close_handler( + self, widget: QWidget, on_close: Callable[[CDockWidget, QWidget], None] | None = None + ) -> Callable[[CDockWidget], None]: + """ + Determine which close handler to use for a dock. + Priority: + 1. Explicit `on_close` callable passed to `new`. + 2. Widget attribute `handle_dock_close` or `on_dock_close` if callable. + 3. Default close handler. + + Args: + widget(QWidget): The widget contained in the dock. + on_close(Callable[[CDockWidget, QWidget], None] | None): Explicit close handler. + + Returns: + Callable[[CDockWidget], None]: Resolved close handler. + """ + + candidate = on_close + if candidate is None: + candidate = getattr(widget, "handle_dock_close", None) + if candidate is None: + candidate = getattr(widget, "on_dock_close", None) + + if callable(candidate): + return self._wrap_close_candidate(candidate, widget) + + return lambda dock: self._default_close_handler(dock, widget) + + def _make_dock( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool = True, + area: QtAds.DockWidgetArea = QtAds.DockWidgetArea.RightDockWidgetArea, + start_floating: bool = False, + on_close: Callable[[CDockWidget, QWidget], None] | None = None, + tab_with: CDockWidget | None = None, + relative_to: CDockWidget | None = None, + dock_preferences: Mapping[str, Any] | None = None, + promote_central: bool = False, + dock_icon: QIcon | None = None, + apply_widget_icon: bool = True, + ) -> CDockWidget: + """ + Create and add a new dock widget to the area. + + Args: + widget(QWidget): The widget to dock. + closable(bool): Whether the dock can be closed. + floatable(bool): Whether the dock can be floated. + movable(bool): Whether the dock can be moved. + area(QtAds.DockWidgetArea): Target dock area. + start_floating(bool): Whether the dock should start floating. + on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. + tab_with(CDockWidget | None): Optional dock to tab with. + relative_to(CDockWidget | None): Optional dock to position relative to. + dock_preferences(Mapping[str, Any] | None): Appearance preferences to apply. + promote_central(bool): Whether to promote the dock to central widget. + dock_icon(QIcon | None): Explicit icon to use for the dock. + apply_widget_icon(bool): Whether to apply the widget's ICON_NAME as dock icon. + + Returns: + CDockWidget: Created dock widget. + """ + if not widget.objectName(): + widget.setObjectName(widget.__class__.__name__) + + if tab_with is not None and relative_to is not None: + raise ValueError("Specify either 'tab_with' or 'relative_to', not both.") + + dock = CDockWidget(widget.objectName()) + dock.setWidget(widget) + dock._dock_preferences = dict(dock_preferences or {}) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetDeleteOnClose, True) + dock.setFeature(CDockWidget.DockWidgetFeature.CustomCloseHandling, True) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetClosable, closable) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetFloatable, floatable) + dock.setFeature(CDockWidget.DockWidgetFeature.DockWidgetMovable, movable) + + self._customize_dock(dock, widget) + resolved_icon = self._resolve_dock_icon(widget, dock_icon, apply_widget_icon) + + close_handler = self._resolve_close_handler(widget, on_close) + + def on_widget_destroyed(): + if not isValid(dock): + return + dock.closeDockWidget() + dock.deleteDockWidget() + + dock.closeRequested.connect(lambda: close_handler(dock)) + if hasattr(widget, "widget_removed"): + widget.widget_removed.connect(on_widget_destroyed) + + dock.setMinimumSizeHintMode(CDockWidget.eMinimumSizeHintMode.MinimumSizeHintFromDockWidget) + dock_area_widget = None + if tab_with is not None: + if not isValid(tab_with): + raise ValueError("Tab target dock widget is not valid anymore.") + dock_area_widget = tab_with.dockAreaWidget() + + if dock_area_widget is not None: + self.dock_manager.addDockWidgetTabToArea(dock, dock_area_widget) + else: + target_area_widget = None + if relative_to is not None: + if not isValid(relative_to): + raise ValueError("Relative target dock widget is not valid anymore.") + target_area_widget = relative_to.dockAreaWidget() + self.dock_manager.addDockWidget(area, dock, target_area_widget) + + if start_floating and tab_with is None and not promote_central: + dock.setFloating() + if resolved_icon is not None: + dock.setIcon(resolved_icon) + return dock + + def _delete_dock(self, dock: CDockWidget) -> None: + widget = dock.widget() + if widget and isValid(widget): + widget.close() + widget.deleteLater() + if isValid(dock): + dock.closeDockWidget() + dock.deleteDockWidget() + + def _resolve_dock_reference( + self, ref: CDockWidget | QWidget | str | None, *, allow_none: bool = True + ) -> CDockWidget | None: + """ + Resolve a dock reference from various input types. + + Args: + ref(CDockWidget | QWidget | str | None): Dock reference. + allow_none(bool): Whether to allow None as a valid return value. + + Returns: + CDockWidget | None: Resolved dock widget or None. + """ + if ref is None: + if allow_none: + return None + raise ValueError("Dock reference cannot be None.") + if isinstance(ref, CDockWidget): + if not isValid(ref): + raise ValueError("Dock widget reference is not valid anymore.") + return ref + if isinstance(ref, QWidget): + for dock in self.dock_list(): + if dock.widget() is ref: + return dock + raise ValueError("Widget reference is not associated with any dock in this area.") + if isinstance(ref, str): + dock_map = self.dock_map() + dock = dock_map.get(ref) + if dock is None: + raise ValueError(f"No dock found with objectName '{ref}'.") + return dock + raise TypeError( + "Dock reference must be a CDockWidget, QWidget, object name string, or None." + ) + + ################################################################################ + # Splitter Handling + ################################################################################ + + def _resolve_dock_icon( + self, widget: QWidget, dock_icon: QIcon | None, apply_widget_icon: bool + ) -> QIcon | None: + """ + Choose an icon for the dock: prefer an explicitly provided one, otherwise + fall back to the widget's `ICON_NAME` (material icons) when available. + + Args: + widget(QWidget): The widget to dock. + dock_icon(QIcon | None): Explicit icon to use for the dock. + + Returns: + QIcon | None: Resolved dock icon, or None if not available. + """ + + if dock_icon is not None: + return dock_icon + if not apply_widget_icon: + return None + icon_name = getattr(widget, "ICON_NAME", None) + if not icon_name: + return None + try: + return material_icon(icon_name, size=(24, 24), convert_to_pixmap=False) + except Exception: + return None + + def _build_creation_spec( + self, + widget: QWidget, + *, + closable: bool, + floatable: bool, + movable: bool, + start_floating: bool, + where: Literal["left", "right", "top", "bottom"] | None, + on_close: Callable[[CDockWidget, QWidget], None] | None, + tab_with: CDockWidget | QWidget | str | None, + relative_to: CDockWidget | QWidget | str | None, + show_title_bar: bool | None, + title_buttons: Mapping[str, bool] | Sequence[str] | str | None, + show_settings_action: bool | None, + promote_central: bool, + dock_icon: QIcon | None, + apply_widget_icon: bool, + ) -> DockCreationSpec: + """ + Normalize and validate dock creation parameters into a spec object. + + Args: + widget(QWidget): The widget to dock. + closable(bool): Whether the dock can be closed. + floatable(bool): Whether the dock can be floated. + movable(bool): Whether the dock can be moved. + start_floating(bool): Whether the dock should start floating. + where(Literal["left", "right", "top", "bottom"] | None): Target dock area. + on_close(Callable[[CDockWidget, QWidget], None] | None): Custom close handler. + tab_with(CDockWidget | QWidget | str | None): Optional dock to tab with. + relative_to(CDockWidget | QWidget | str | None): Optional dock to position relative to. + show_title_bar(bool | None): Whether to show the dock title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Title bar buttons to show/hide. + show_settings_action(bool | None): Whether to show the dock settings action. + promote_central(bool): Whether to promote the dock to central widget. + dock_icon(QIcon | None): Explicit icon to use for the dock. + apply_widget_icon(bool): Whether to apply the widget's ICON_NAME as dock icon. + + Returns: + DockCreationSpec: Normalized dock creation specification. + + """ + normalized_buttons = self._normalize_title_buttons(title_buttons) + resolved_tab = self._resolve_dock_reference(tab_with) + resolved_relative = self._resolve_dock_reference(relative_to) + + if resolved_tab is not None and resolved_relative is not None: + raise ValueError("Specify either 'tab_with' or 'relative_to', not both.") + + target_area = self._area_from_where(where) + if resolved_relative is not None and where is None: + inferred = self.dock_manager.dockWidgetArea(resolved_relative) + if inferred in ( + QtAds.DockWidgetArea.InvalidDockWidgetArea, + QtAds.DockWidgetArea.NoDockWidgetArea, + ): + inferred = self._area_from_where(None) + target_area = inferred + + dock_preferences = { + "show_title_bar": show_title_bar, + "title_buttons": normalized_buttons if normalized_buttons else None, + "show_settings_action": show_settings_action, + } + dock_preferences = {k: v for k, v in dock_preferences.items() if v is not None} + + return self.DockCreationSpec( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + area=target_area, + on_close=on_close, + tab_with=resolved_tab, + relative_to=resolved_relative, + title_visible=show_title_bar, + title_buttons=normalized_buttons if normalized_buttons else None, + show_settings_action=show_settings_action, + dock_preferences=dock_preferences or None, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + + def _create_dock_from_spec(self, spec: DockCreationSpec) -> CDockWidget: + """ + Create a dock from a normalized spec and apply preferences. + + Args: + spec(DockCreationSpec): Dock creation specification. + + Returns: + CDockWidget: Created dock widget. + """ + dock = self._make_dock( + spec.widget, + closable=spec.closable, + floatable=spec.floatable, + movable=spec.movable, + area=spec.area, + start_floating=spec.start_floating, + on_close=spec.on_close, + tab_with=spec.tab_with, + relative_to=spec.relative_to, + dock_preferences=spec.dock_preferences, + promote_central=spec.promote_central, + dock_icon=spec.dock_icon, + apply_widget_icon=spec.apply_widget_icon, + ) + self.dock_manager.setFocus() + self._apply_dock_preferences(dock) + if spec.promote_central: + self.set_central_dock(dock) + return dock + + def _coerce_weights( + self, + weights: Sequence[float] | Mapping[int | str, float] | None, + count: int, + orientation: Qt.Orientation, + ) -> list[float] | None: + """ + Normalize weight specs into a list matching splitter child count. + + Args: + weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. + count(int): Number of splitter children. + orientation(Qt.Orientation): Splitter orientation. + + Returns: + list[float] | None: Normalized weight list, or None if invalid. + """ + if weights is None or count <= 0: + return None + + result: list[float] + if isinstance(weights, (list, tuple)): + result = [float(v) for v in weights[:count]] + elif isinstance(weights, Mapping): + default = float(weights.get("default", 1.0)) + result = [default] * count + + alias: dict[str, int] = {} + if count >= 1: + alias["first"] = 0 + alias["start"] = 0 + if count >= 2: + alias["last"] = count - 1 + alias["end"] = count - 1 + if orientation == Qt.Orientation.Horizontal: + alias["left"] = 0 + alias["right"] = count - 1 + if count >= 3: + alias["center"] = count // 2 + alias["middle"] = count // 2 + else: + alias["top"] = 0 + alias["bottom"] = count - 1 + + for key, value in weights.items(): + if key == "default": + continue + idx: int | None = None + if isinstance(key, int): + idx = key + elif isinstance(key, str): + lowered = key.lower() + if lowered in alias: + idx = alias[lowered] + elif lowered.startswith("col"): + try: + idx = int(lowered[3:]) + except ValueError: + idx = None + elif lowered.startswith("row"): + try: + idx = int(lowered[3:]) + except ValueError: + idx = None + if idx is not None and 0 <= idx < count: + result[idx] = float(value) + else: + return None + + if len(result) < count: + result += [1.0] * (count - len(result)) + result = result[:count] + if all(v <= 0 for v in result): + result = [1.0] * count + return result + + def _schedule_splitter_weights( + self, + splitter: QtAds.CDockSplitter, + weights: Sequence[float] | Mapping[int | str, float] | None, + ) -> None: + """ + Apply weight ratios to a splitter once geometry is available. + + Args: + splitter(QtAds.CDockSplitter): Target splitter. + weights(Sequence[float] | Mapping[int | str, float] | None): Weight specification. + """ + if splitter is None or weights is None: + return + + ratios = self._coerce_weights(weights, splitter.count(), splitter.orientation()) + if not ratios: + return + + def apply(): + count = splitter.count() + if count != len(ratios): + return + + orientation = splitter.orientation() + total_px = ( + splitter.width() if orientation == Qt.Orientation.Horizontal else splitter.height() + ) + if total_px <= count: + QTimer.singleShot(0, apply) + return + + total = sum(ratios) + if total <= 0: + return + sizes = [max(1, int(round(total_px * (r / total)))) for r in ratios] + diff = total_px - sum(sizes) + if diff: + idx = max(range(count), key=lambda i: ratios[i]) + sizes[idx] = max(1, sizes[idx] + diff) + splitter.setSizes(sizes) + for i, weight in enumerate(ratios): + splitter.setStretchFactor(i, max(1, int(round(weight * 100)))) + + QTimer.singleShot(0, apply) + + def _normalize_override_keys( + self, + overrides: Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]], + ) -> dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]]: + """ + Normalize various key types into tuple paths. + + Args: + overrides(Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]]): + Original overrides mapping. + + Returns: + dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]]: + Normalized overrides mapping. + """ + normalized: dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]] = {} + for key, value in overrides.items(): + path: tuple[int, ...] | None = None + if isinstance(key, int): + path = (key,) + elif isinstance(key, (list, tuple)): + try: + path = tuple(int(k) for k in key) + except ValueError: + continue + elif isinstance(key, str): + cleaned = key.replace(" ", "").replace(".", "/") + if cleaned in ("", "/"): + path = () + else: + parts = [p for p in cleaned.split("/") if p] + try: + path = tuple(int(p) for p in parts) + except ValueError: + continue + if path is not None: + normalized[path] = value + return normalized + + def _apply_splitter_tree( + self, + splitter: QtAds.CDockSplitter, + path: tuple[int, ...], + horizontal: Sequence[float] | Mapping[int | str, float] | None, + vertical: Sequence[float] | Mapping[int | str, float] | None, + overrides: dict[tuple[int, ...], Sequence[float] | Mapping[int | str, float]], + ) -> None: + """Traverse splitter hierarchy and apply ratios.""" + orientation = splitter.orientation() + base_weights = horizontal if orientation == Qt.Orientation.Horizontal else vertical + + override = None + if overrides: + if path in overrides: + override = overrides[path] + elif len(path) >= 1: + key = (path[-1],) + if key in overrides: + override = overrides[key] + + self._schedule_splitter_weights(splitter, override or base_weights) + + for idx in range(splitter.count()): + child = splitter.widget(idx) + if isinstance(child, QtAds.CDockSplitter): + self._apply_splitter_tree(child, path + (idx,), horizontal, vertical, overrides) + + ################################################################################ + # Layout Inspection + ################################################################################ + + def _collect_splitter_info( + self, + splitter: CDockSplitter, + path: tuple[int, ...], + results: list[dict[str, Any]], + container_index: int, + ) -> None: + orientation = ( + "horizontal" if splitter.orientation() == Qt.Orientation.Horizontal else "vertical" + ) + entry: dict[str, Any] = { + "container": container_index, + "path": path, + "orientation": orientation, + "children": [], + } + results.append(entry) + + for idx in range(splitter.count()): + child = splitter.widget(idx) + if isinstance(child, CDockSplitter): + entry["children"].append({"index": idx, "type": "splitter"}) + self._collect_splitter_info(child, path + (idx,), results, container_index) + elif isinstance(child, CDockAreaWidget): + docks = [dock.objectName() for dock in child.dockWidgets()] + entry["children"].append({"index": idx, "type": "dock_area", "docks": docks}) + elif isinstance(child, CDockWidget): + entry["children"].append({"index": idx, "type": "dock", "name": child.objectName()}) + else: + entry["children"].append({"index": idx, "type": child.__class__.__name__}) + + def describe_layout(self) -> list[dict[str, Any]]: + """ + Return metadata describing splitter paths, orientations, and contained docks. + + Useful for determining the keys to use in `set_layout_ratios(splitter_overrides=...)`. + """ + info: list[dict[str, Any]] = [] + for container_index, container in enumerate(self.dock_manager.dockContainers()): + splitter = container.rootSplitter() + if splitter is None: + continue + self._collect_splitter_info(splitter, (), info, container_index) + return info + + def print_layout_structure(self) -> None: + """Pretty-print the current splitter paths to stdout.""" + for entry in self.describe_layout(): + children_desc = [] + for child in entry["children"]: + if child["type"] == "dock_area": + children_desc.append( + f"{child['index']}:dock_area[{', '.join(child['docks']) or '-'}]" + ) + elif child["type"] == "dock": + children_desc.append(f"{child['index']}:dock({child['name']})") + else: + children_desc.append(f"{child['index']}:{child['type']}") + summary = ", ".join(children_desc) + print( + f"container={entry['container']} path={entry['path']} " + f"orientation={entry['orientation']} -> [{summary}]" + ) + + ################################################################################ + # State Persistence + ################################################################################ + + @staticmethod + def _coerce_byte_array(value: Any) -> QByteArray | None: + """Best-effort conversion of arbitrary values into a QByteArray.""" + if isinstance(value, QByteArray): + return QByteArray(value) + if isinstance(value, (bytes, bytearray, memoryview)): + return QByteArray(bytes(value)) + return None + + @staticmethod + def _settings_keys(overrides: Mapping[str, str | None] | None = None) -> dict[str, str | None]: + """ + Merge caller overrides with sensible defaults. + + Only `geom`, `state`, and `ads_state` are recognised. Missing entries default to: + geom -> "dock_area/geometry" + state -> None (skip writing legacy main window state) + ads_state -> "dock_area/docking_state" + """ + defaults: dict[str, str | None] = { + "geom": "dock_area/geometry", + "state": None, + "ads_state": "dock_area/docking_state", + } + if overrides: + for key, value in overrides.items(): + if key in defaults: + defaults[key] = value + return defaults + + def save_to_settings( + self, + settings: QSettings, + *, + keys: Mapping[str, str | None] | None = None, + include_perspectives: bool = True, + perspective_name: str | None = None, + ) -> None: + """ + Persist the current dock layout into an existing `QSettings` instance. + + Args: + settings(QSettings): Target QSettings store (must outlive this call). + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + include_perspectives(bool): When True, save Qt ADS perspectives alongside the layout. + perspective_name(str | None): Optional explicit name for the saved perspective. + """ + resolved = self._settings_keys(keys) + + geom_key = resolved.get("geom") + if geom_key: + settings.setValue(geom_key, self.saveGeometry()) + + legacy_state_key = resolved.get("state") + if legacy_state_key: + settings.setValue(legacy_state_key, b"") + + ads_state_key = resolved.get("ads_state") + if ads_state_key: + settings.setValue(ads_state_key, self.dock_manager.saveState()) + + if include_perspectives: + name = perspective_name or self.windowTitle() + if name: + self.dock_manager.addPerspective(name) + self.dock_manager.savePerspectives(settings) + + def save_to_file( + self, + path: str, + *, + format: QSettings.Format = QSettings.IniFormat, + keys: Mapping[str, str | None] | None = None, + include_perspectives: bool = True, + perspective_name: str | None = None, + ) -> None: + """ + Convenience wrapper around `save_to_settings` that opens a temporary QSettings. + + Args: + path(str): File path to save the settings to. + format(QSettings.Format): File format to use. + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + include_perspectives(bool): When True, save Qt ADS perspectives alongside the layout. + perspective_name(str | None): Optional explicit name for the saved perspective. + """ + settings = QSettings(path, format) + self.save_to_settings( + settings, + keys=keys, + include_perspectives=include_perspectives, + perspective_name=perspective_name, + ) + settings.sync() + + def load_from_settings( + self, + settings: QSettings, + *, + keys: Mapping[str, str | None] | None = None, + restore_perspectives: bool = True, + ) -> None: + """ + Restore the dock layout from a `QSettings` instance previously populated by `save_to_settings`. + + Args: + settings(QSettings): Source QSettings store (must outlive this call). + keys(Mapping[str, str | None] | None): Optional mapping overriding the keys used for geometry/state entries. + restore_perspectives(bool): When True, restore Qt ADS perspectives alongside the layout. + """ + resolved = self._settings_keys(keys) + + geom_key = resolved.get("geom") + if geom_key: + geom_value = settings.value(geom_key) + geom_bytes = self._coerce_byte_array(geom_value) + if geom_bytes is not None: + self.restoreGeometry(geom_bytes) + + ads_state_key = resolved.get("ads_state") + if ads_state_key: + dock_state = settings.value(ads_state_key) + dock_bytes = self._coerce_byte_array(dock_state) + if dock_bytes is not None: + self.dock_manager.restoreState(dock_bytes) + + if restore_perspectives: + self.dock_manager.loadPerspectives(settings) + + def load_from_file( + self, + path: str, + *, + format: QSettings.Format = QSettings.IniFormat, + keys: Mapping[str, str | None] | None = None, + restore_perspectives: bool = True, + ) -> None: + """ + Convenience wrapper around `load_from_settings` that reads from a file path. + """ + settings = QSettings(path, format) + self.load_from_settings(settings, keys=keys, restore_perspectives=restore_perspectives) + + def set_layout_ratios( + self, + *, + horizontal: Sequence[float] | Mapping[int | str, float] | None = None, + vertical: Sequence[float] | Mapping[int | str, float] | None = None, + splitter_overrides: ( + Mapping[int | str | Sequence[int], Sequence[float] | Mapping[int | str, float]] | None + ) = None, + ) -> None: + """ + Adjust splitter ratios in the dock layout. + + Args: + horizontal: Weights applied to every horizontal splitter encountered. + vertical: Weights applied to every vertical splitter encountered. + splitter_overrides: Optional overrides targeting specific splitters identified + by their index path (e.g. ``{0: [1, 2], (1, 0): [3, 5]}``). Paths are zero-based + indices following the splitter hierarchy, starting from the root splitter. + + Example: + To build three columns with custom per-column ratios:: + + area.set_layout_ratios( + horizontal=[1, 2, 1], # column widths + splitter_overrides={ + 0: [1, 2], # column 0 (two rows) + 1: [3, 2, 1], # column 1 (three rows) + 2: [1], # column 2 (single row) + }, + ) + """ + + overrides = self._normalize_override_keys(splitter_overrides) if splitter_overrides else {} + + for container in self.dock_manager.dockContainers(): + splitter = container.rootSplitter() + if splitter is None: + continue + self._apply_splitter_tree(splitter, (), horizontal, vertical, overrides) + + @staticmethod + def _title_bar_button_enum(name: str) -> ads.TitleBarButton | None: + """Translate a user-friendly button name into an ADS TitleBarButton enum.""" + normalized = (name or "").lower().replace("-", "_").replace(" ", "_") + mapping: dict[str, ads.TitleBarButton] = { + "menu": ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs_menu": ads.TitleBarButton.TitleBarButtonTabsMenu, + "tabs": ads.TitleBarButton.TitleBarButtonTabsMenu, + "undock": ads.TitleBarButton.TitleBarButtonUndock, + "float": ads.TitleBarButton.TitleBarButtonUndock, + "detach": ads.TitleBarButton.TitleBarButtonUndock, + "close": ads.TitleBarButton.TitleBarButtonClose, + "auto_hide": ads.TitleBarButton.TitleBarButtonAutoHide, + "autohide": ads.TitleBarButton.TitleBarButtonAutoHide, + "minimize": ads.TitleBarButton.TitleBarButtonMinimize, + } + return mapping.get(normalized) + + def _normalize_title_buttons( + self, + spec: ( + Mapping[str | ads.TitleBarButton, bool] + | Sequence[str | ads.TitleBarButton] + | str + | ads.TitleBarButton + | None + ), + ) -> dict[ads.TitleBarButton, bool]: + """Normalize button visibility specifications into an enum mapping.""" + if spec is None: + return {} + + result: dict[ads.TitleBarButton, bool] = {} + if isinstance(spec, Mapping): + iterator = spec.items() + else: + if isinstance(spec, str): + spec = [spec] + iterator = ((name, False) for name in spec) + + for name, visible in iterator: + if isinstance(name, ads.TitleBarButton): + enum = name + else: + enum = self._title_bar_button_enum(str(name)) + if enum is None: + continue + result[enum] = bool(visible) + return result + + def _apply_dock_preferences(self, dock: CDockWidget) -> None: + """ + Apply deferred appearance preferences to a dock once it has been created. + + Args: + dock(CDockWidget): Target dock widget. + """ + prefs: Mapping[str, Any] = getattr(dock, "_dock_preferences", {}) + + def apply(): + title_bar = None + area_widget = dock.dockAreaWidget() + if area_widget is not None and hasattr(area_widget, "titleBar"): + title_bar = area_widget.titleBar() + + show_title_bar = prefs.get("show_title_bar") + if title_bar is not None and show_title_bar is not None: + title_bar.setVisible(bool(show_title_bar)) + + button_prefs = prefs.get("title_buttons") or {} + if title_bar is not None and button_prefs: + for enum, visible in button_prefs.items(): + try: + button = title_bar.button(enum) + except Exception: # pragma: no cover - defensive against ADS API changes + button = None + if button is not None: + button.setVisible(bool(visible)) + + # single shot to ensure dock is fully initialized, as widgets with their own dock manager can take a moment to initialize + QTimer.singleShot(0, apply) + + def set_central_dock(self, dock: CDockWidget | QWidget | str) -> None: + """ + Promote an existing dock to be the dock manager's central widget. + + Args: + dock(CDockWidget | QWidget | str): Dock reference to promote. + """ + resolved = self._resolve_dock_reference(dock, allow_none=False) + self.dock_manager.setCentralWidget(resolved) + self._apply_dock_preferences(resolved) + + ################################################################################ + # Public API + ################################################################################ + + @SafeSlot(popup_error=True) + def new( + self, + widget: QWidget | str, + *, + closable: bool = True, + floatable: bool = True, + movable: bool = True, + start_floating: bool = False, + 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 = False, + promote_central: bool = False, + dock_icon: QIcon | None = None, + apply_widget_icon: bool = True, + **widget_kwargs, + ) -> QWidget | CDockWidget | BECWidget: + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget(QWidget | str): Instance or registered widget type string. + closable(bool): Whether the dock is closable. + floatable(bool): Whether the dock is floatable. + movable(bool): Whether the dock is movable. + start_floating(bool): Whether to start the dock floating. + where(Literal["left", "right", "top", "bottom"] | None): Dock placement hint relative to the dock area (ignored when + ``relative_to`` is provided without an explicit value). + on_close(Callable[[CDockWidget, QWidget], None] | None): Optional custom close handler accepting (dock, widget). + tab_with(CDockWidget | QWidget | str | None): Existing dock (or widget/name) to tab the new dock alongside. + relative_to(CDockWidget | QWidget | str | None): Existing dock (or widget/name) used as the positional anchor. + When supplied and ``where`` is ``None``, the new dock inherits the + anchor's current dock area. + return_dock(bool): When True, return the created dock instead of the widget. + show_title_bar(bool | None): Explicitly show or hide the dock area's title bar. + title_buttons(Mapping[str, bool] | Sequence[str] | str | None): Mapping or iterable describing which title bar buttons should + remain visible. Provide a mapping of button names (``"float"``, + ``"close"``, ``"menu"``, ``"auto_hide"``, ``"minimize"``) to booleans, + or a sequence of button names to hide. + show_settings_action(bool | None): Control whether a dock settings/property action should + be installed. Defaults to ``False`` for the basic dock area; subclasses + such as `AdvancedDockArea` override the default to ``True``. + promote_central(bool): When True, promote the created dock to be the dock manager's + central widget (useful for editor stacks or other root content). + dock_icon(QIcon | None): Optional icon applied to the dock via ``CDockWidget.setIcon``. + Provide a `QIcon` (e.g. from ``material_icon``). When ``None`` (default), + the widget's ``ICON_NAME`` attribute is used when available. + apply_widget_icon(bool): When False, skip automatically resolving the icon from + the widget's ``ICON_NAME`` (useful for callers who want no icon and do not pass one explicitly). + + Returns: + The widget instance by default, or the created `CDockWidget` when `return_dock` is True. + """ + if isinstance(widget, str): + if return_dock: + raise ValueError( + "return_dock=True is not supported when creating widgets by type name." + ) + widget = cast( + BECWidget, + widget_handler.create_widget(widget_type=widget, parent=self, **widget_kwargs), + ) + + spec = self._build_creation_spec( + widget=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, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + + def _on_name_established(_name: str) -> None: + # Defer creation so BECConnector sibling name enforcement has completed. + QTimer.singleShot(0, lambda: self._create_dock_from_spec(spec)) + + widget.name_established.connect(_on_name_established) + return widget + + spec = self._build_creation_spec( + widget=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, + show_title_bar=show_title_bar, + title_buttons=title_buttons, + show_settings_action=show_settings_action, + promote_central=promote_central, + dock_icon=dock_icon, + apply_widget_icon=apply_widget_icon, + ) + dock = self._create_dock_from_spec(spec) + return dock if return_dock else widget + + def dock_map(self) -> dict[str, CDockWidget]: + """Return the dock widgets map as dictionary with names as keys.""" + return self.dock_manager.dockWidgetsMap() + + def dock_list(self) -> list[CDockWidget]: + """Return the list of dock widgets.""" + return self.dock_manager.dockWidgets() + + def widget_map(self) -> dict[str, QWidget]: + """Return a dictionary mapping widget names to their corresponding widgets.""" + return {dock.objectName(): dock.widget() for dock in self.dock_list()} + + def widget_list(self) -> list[QWidget]: + """Return a list of all widgets contained in the dock area.""" + return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)] + + @SafeSlot() + def attach_all(self): + """Re-attach floating docks back into the dock manager.""" + 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 dock in docks[1:]: + self.dock_manager.addDockWidgetTab( + QtAds.DockWidgetArea.RightDockWidgetArea, dock, target + ) + + @SafeSlot() + def delete_all(self): + """Delete all docks and their associated widgets.""" + for dock in list(self.dock_manager.dockWidgets()): + self._delete_dock(dock) + + +if __name__ == "__main__": # pragma: no cover + import sys + + from qtpy.QtWidgets import QApplication, QLabel, QMainWindow, QPushButton + + from bec_widgets.utils.colors import apply_theme + + class CustomCloseWidget(QWidget): + """Example widget showcasing custom close handling via handle_dock_close.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("CustomCloseWidget") + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Custom close handler – tabbed with Column 1 / Row 1.\n" + "Close this dock to see the stdout cleanup message.", + self, + ) + ) + btn = QPushButton("Click me before closing", self) + layout.addWidget(btn) + + def handle_dock_close(self, dock: CDockWidget, widget: QWidget) -> None: + print(f"[CustomCloseWidget] Closing {widget.objectName()}") + area = widget.parent() + while area is not None and not isinstance(area, DockAreaWidget): + area = area.parent() + if isinstance(area, DockAreaWidget): + area.close_dock(dock, widget) + + class LambdaCloseWidget(QWidget): + """Example widget that relies on an explicit lambda passed to BasicDockArea.new.""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName("LambdaCloseWidget") + layout = QVBoxLayout(self) + layout.addWidget( + QLabel( + "Custom lambda close handler – tabbed with Column 2 / Row 1.\n" + "Closing prints which dock triggered the callback.", + self, + ) + ) + + app = QApplication(sys.argv) + apply_theme("dark") + window = QMainWindow() + area = DockAreaWidget(root_widget=True, title="Basic Dock Area Demo") + window.setCentralWidget(area) + window.resize(1400, 800) + window.show() + + def make_panel(name: str, title: str, body: str = "") -> QWidget: + panel = QWidget() + panel.setObjectName(name) + layout = QVBoxLayout(panel) + layout.addWidget(QLabel(title, panel)) + if body: + layout.addWidget(QLabel(body, panel)) + layout.addStretch(1) + return panel + + # Column 1: plain 'where' usage + col1_top = area.new( + make_panel("C1R1", "Column 1 / Row 1", "Added with where='left'."), + closable=True, + where="left", + return_dock=True, + show_settings_action=True, + ) + area.new( + make_panel("C1R2", "Column 1 / Row 2", "Stacked via relative_to + where='bottom'."), + closable=True, + where="bottom", + relative_to=col1_top, + ) + + # Column 2: relative placement and tabbing + col2_top = area.new( + make_panel( + "C2R1", "Column 2 / Row 1", "Placed to the right of Column 1 using relative_to." + ), + closable=True, + where="right", + relative_to=col1_top, + return_dock=True, + ) + area.new( + make_panel("C2R2", "Column 2 / Row 2", "Added beneath Column 2 / Row 1 via relative_to."), + closable=True, + where="bottom", + relative_to=col2_top, + ) + area.new( + make_panel("C2Tabbed", "Column 2 / Tabbed", "Tabbed with Column 2 / Row 1 using tab_with."), + closable=True, + tab_with=col2_top, + ) + + # Column 3: mix of where, relative_to, and custom close handler + col3_top = area.new( + make_panel("C3R1", "Column 3 / Row 1", "Placed to the right of Column 2 via relative_to."), + closable=True, + where="right", + relative_to=col2_top, + return_dock=True, + ) + area.new( + make_panel( + "C3R2", "Column 3 / Row 2", "Plain where='bottom' relative to Column 3 / Row 1." + ), + closable=True, + where="bottom", + relative_to=col3_top, + ) + area.new( + make_panel( + "C3Lambda", + "Column 3 / Tabbed Lambda", + "Tabbed with Column 3 / Row 1. Custom close handler prints the dock name.", + ), + closable=True, + tab_with=col3_top, + on_close=lambda dock, widget: ( + print(f"[Lambda handler] Closing {widget.objectName()}"), + area.close_dock(dock, widget), + ), + show_settings_action=True, + ) + + area.set_layout_ratios( + horizontal=[1, 1.5, 1], splitter_overrides={0: [3, 2], 1: [4, 3], 2: [2, 1]} + ) + + print("\nSplitter structure (paths for splitter_overrides):") + area.print_layout_structure() + + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py index a49183ff..59c4847b 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -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) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py index e9d60f3f..19329c34 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/dialogs.py @@ -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("") diff --git a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py index 93622747..36357a17 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/settings/workspace_manager.py @@ -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( diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py index bdfb9a5a..cb7dabea 100644 --- a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -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() diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 27b43dfc..019874ea 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -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, diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py index 6dfe76e5..212494b2 100644 --- a/tests/unit_tests/test_advanced_dock_area.py +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -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: