mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-03-05 00:12:49 +01:00
feat(advanced_dock_area): created DockAreaWidget base class; profile management through namespaces; dock area variants
This commit is contained in:
@@ -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)
|
||||
|
||||
1368
bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py
Normal file
1368
bec_widgets/widgets/containers/advanced_dock_area/basic_dock_area.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user