1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-12-30 10:41:18 +01:00

feat(advanced_dock_area): added ads based dock area with profiles

This commit is contained in:
2025-08-05 15:58:29 +02:00
parent 24f54fc252
commit cbcf23965b
7 changed files with 1915 additions and 8 deletions

View File

@@ -119,6 +119,72 @@ class AbortButton(RPCBase):
"""
class AdvancedDockArea(RPCBase):
@rpc_call
def new(
self,
widget: "BECWidget | str",
closable: "bool" = True,
floatable: "bool" = True,
movable: "bool" = True,
start_floating: "bool" = False,
) -> "BECWidget":
"""
Creates a new widget or reuses an existing one and schedules its dock creation.
Args:
widget (BECWidget | str): The widget instance or a string specifying the
type of widget to create.
closable (bool): Whether the dock should be closable. Defaults to True.
floatable (bool): Whether the dock should be floatable. Defaults to True.
movable (bool): Whether the dock should be movable. Defaults to True.
start_floating (bool): Whether to start the dock in a floating state. Defaults to False.
Returns:
widget: The widget instance.
"""
@rpc_call
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.
"""
@rpc_call
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.
"""
@property
@rpc_call
def lock_workspace(self) -> "bool":
"""
Get or set the lock state of the workspace.
Returns:
bool: True if the workspace is locked, False otherwise.
"""
@rpc_call
def attach_all(self):
"""
Return all floating docks to the dock area, preserving tab groups within each floating container.
"""
@rpc_call
def delete_all(self):
"""
Delete all docks and widgets.
"""
class AutoUpdates(RPCBase):
@property
@rpc_call

View File

@@ -16,6 +16,7 @@ from qtpy.QtWidgets import (
from bec_widgets.utils import BECDispatcher
from bec_widgets.utils.widget_io import WidgetHierarchy as wh
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import AdvancedDockArea
from bec_widgets.widgets.containers.dock import BECDockArea
from bec_widgets.widgets.containers.layout_manager.layout_manager import LayoutManagerWidget
from bec_widgets.widgets.editors.jupyter_console.jupyter_console import BECJupyterConsole
@@ -44,6 +45,7 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
"wh": wh,
"dock": self.dock,
"im": self.im,
"ads": self.ads,
# "mi": self.mi,
# "mm": self.mm,
# "lm": self.lm,
@@ -119,14 +121,12 @@ class JupyterConsoleWindow(QWidget): # pragma: no cover:
tab_widget.addTab(sixth_tab, "Image Next Gen")
tab_widget.setCurrentIndex(1)
#
# seventh_tab = QWidget()
# seventh_tab_layout = QVBoxLayout(seventh_tab)
# self.scatter = ScatterWaveform()
# self.scatter_mi = self.scatter.main_curve
# self.scatter.plot("samx", "samy", "bpm4i")
# seventh_tab_layout.addWidget(self.scatter)
# tab_widget.addTab(seventh_tab, "Scatter Waveform")
# tab_widget.setCurrentIndex(6)
seventh_tab = QWidget()
seventh_tab_layout = QVBoxLayout(seventh_tab)
self.ads = AdvancedDockArea(gui_id="ads")
seventh_tab_layout.addWidget(self.ads)
tab_widget.addTab(seventh_tab, "ADS")
tab_widget.setCurrentIndex(2)
#
# eighth_tab = QWidget()
# eighth_tab_layout = QVBoxLayout(eighth_tab)

View File

@@ -0,0 +1,857 @@
from __future__ import annotations
import os
from typing import cast
import PySide6QtAds as QtAds
from PySide6QtAds import CDockManager, CDockWidget
from qtpy.QtCore import QSettings, QSize, Qt
from qtpy.QtGui import QAction
from qtpy.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QHBoxLayout,
QInputDialog,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QSizePolicy,
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.property_editor import PropertyEditor
from bec_widgets.utils.toolbars.actions import (
ExpandableMenuAction,
MaterialIconAction,
WidgetAction,
)
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.toolbar_components.workspace_actions import (
WorkspaceConnection,
workspace_bundle,
)
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox
from bec_widgets.widgets.control.scan_control import ScanControl
from bec_widgets.widgets.editors.vscode.vscode import VSCodeEditor
from bec_widgets.widgets.plots.heatmap.heatmap import Heatmap
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap
from bec_widgets.widgets.plots.multi_waveform.multi_waveform import MultiWaveform
from bec_widgets.widgets.plots.scatter_waveform.scatter_waveform import ScatterWaveform
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from bec_widgets.widgets.progress.ring_progress_bar import RingProgressBar
from bec_widgets.widgets.services.bec_queue.bec_queue import BECQueue
from bec_widgets.widgets.services.bec_status_box.bec_status_box import BECStatusBox
from bec_widgets.widgets.utility.logpanel import LogPanel
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import DarkModeButton
MODULE_PATH = os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
_DEFAULT_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "default")
_USER_PROFILES_DIR = os.path.join(os.path.dirname(__file__), "states", "user")
def _profiles_dir() -> str:
path = os.environ.get("BECWIDGETS_PROFILE_DIR", _USER_PROFILES_DIR)
os.makedirs(path, exist_ok=True)
return path
def _profile_path(name: str) -> str:
return os.path.join(_profiles_dir(), f"{name}.ini")
SETTINGS_KEYS = {
"geom": "mainWindow/Geometry",
"state": "mainWindow/State",
"ads_state": "mainWindow/DockingState",
"manifest": "manifest/widgets",
"readonly": "profile/readonly",
}
def list_profiles() -> list[str]:
return sorted(os.path.splitext(f)[0] for f in os.listdir(_profiles_dir()) if f.endswith(".ini"))
def is_profile_readonly(name: str) -> bool:
"""Check if a profile is marked as read-only."""
settings = open_settings(name)
return settings.value(SETTINGS_KEYS["readonly"], False, type=bool)
def set_profile_readonly(name: str, readonly: bool) -> None:
"""Set the read-only status of a profile."""
settings = open_settings(name)
settings.setValue(SETTINGS_KEYS["readonly"], readonly)
settings.sync()
def open_settings(name: str) -> QSettings:
return QSettings(_profile_path(name), QSettings.IniFormat)
def write_manifest(settings: QSettings, docks: list[CDockWidget]) -> None:
settings.beginWriteArray(SETTINGS_KEYS["manifest"], len(docks))
for i, dock in enumerate(docks):
settings.setArrayIndex(i)
w = dock.widget()
settings.setValue("object_name", w.objectName())
settings.setValue("widget_class", w.__class__.__name__)
settings.setValue("closable", getattr(dock, "_default_closable", True))
settings.setValue("floatable", getattr(dock, "_default_floatable", True))
settings.setValue("movable", getattr(dock, "_default_movable", True))
settings.endArray()
def read_manifest(settings: QSettings) -> list[dict]:
items: list[dict] = []
count = settings.beginReadArray(SETTINGS_KEYS["manifest"])
for i in range(count):
settings.setArrayIndex(i)
items.append(
{
"object_name": settings.value("object_name"),
"widget_class": settings.value("widget_class"),
"closable": settings.value("closable", type=bool),
"floatable": settings.value("floatable", type=bool),
"movable": settings.value("movable", type=bool),
}
)
settings.endArray()
return items
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)
class SaveProfileDialog(QDialog):
"""Dialog for saving workspace profiles with read-only option."""
def __init__(self, parent: QWidget, current_name: str = ""):
super().__init__(parent)
self.setWindowTitle("Save Workspace Profile")
self.setModal(True)
self.resize(400, 150)
layout = QVBoxLayout(self)
# Name input
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Profile Name:"))
self.name_edit = QLineEdit(current_name)
self.name_edit.setPlaceholderText("Enter profile name...")
name_row.addWidget(self.name_edit)
layout.addLayout(name_row)
# Read-only checkbox
self.readonly_checkbox = QCheckBox("Mark as read-only (cannot be overwritten or deleted)")
layout.addWidget(self.readonly_checkbox)
# Info label
info_label = QLabel("Read-only profiles are protected from modification and deletion.")
info_label.setStyleSheet("color: gray; font-size: 10px;")
layout.addWidget(info_label)
# Buttons
btn_row = QHBoxLayout()
btn_row.addStretch(1)
self.save_btn = QPushButton("Save")
self.save_btn.setDefault(True)
cancel_btn = QPushButton("Cancel")
self.save_btn.clicked.connect(self.accept)
cancel_btn.clicked.connect(self.reject)
btn_row.addWidget(self.save_btn)
btn_row.addWidget(cancel_btn)
layout.addLayout(btn_row)
# Enable/disable save button based on name input
self.name_edit.textChanged.connect(self._update_save_button)
self._update_save_button()
def _update_save_button(self):
"""Enable save button only when name is not empty."""
self.save_btn.setEnabled(bool(self.name_edit.text().strip()))
def get_profile_name(self) -> str:
"""Get the entered profile name."""
return self.name_edit.text().strip()
def is_readonly(self) -> bool:
"""Check if the profile should be marked as read-only."""
return self.readonly_checkbox.isChecked()
class AdvancedDockArea(BECMainWindow):
RPC = True
PLUGIN = False
USER_ACCESS = ["new", "widget_map", "widget_list", "lock_workspace", "attach_all", "delete_all"]
def __init__(self, parent=None, *args, **kwargs):
super().__init__(parent=parent, *args, **kwargs)
# Setting the dock manager with flags
QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True
)
QtAds.CDockManager.setConfigFlag(
QtAds.CDockManager.eConfigFlag.HideSingleCentralWidgetTitleBar, True
)
self.dock_manager = CDockManager(self)
# Dock manager helper variables
self._locked = False # Lock state of the workspace
# Toolbar
self.dark_mode_button = DarkModeButton(parent=self, toolbar=True)
self._setup_toolbar()
self._hook_toolbar()
# Populate and hook the workspace combo
self._refresh_workspace_list()
# State manager
self.state_manager = WidgetStateManager(self)
# Insert Mode menu
self._editable = None
self._setup_developer_mode_menu()
# Notification center re-raise
self.notification_centre.raise_()
self.statusBar().raise_()
def minimumSizeHint(self):
return QSize(1200, 800)
def _make_dock(
self,
widget: QWidget,
*,
closable: bool,
floatable: bool,
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)
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()
def _apply_dock_lock(self, locked: bool) -> None:
if locked:
self.dock_manager.lockDockWidgetFeaturesGlobally()
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()
################################################################################
# Toolbar Setup
################################################################################
def _setup_toolbar(self):
self.toolbar = ModularToolBar(parent=self)
PLOT_ACTIONS = {
"waveform": (Waveform.ICON_NAME, "Add Waveform", "Waveform"),
"scatter_waveform": (
ScatterWaveform.ICON_NAME,
"Add Scatter Waveform",
"ScatterWaveform",
),
"multi_waveform": (MultiWaveform.ICON_NAME, "Add Multi Waveform", "MultiWaveform"),
"image": (Image.ICON_NAME, "Add Image", "Image"),
"motor_map": (MotorMap.ICON_NAME, "Add Motor Map", "MotorMap"),
"heatmap": (Heatmap.ICON_NAME, "Add Heatmap", "Heatmap"),
}
DEVICE_ACTIONS = {
"scan_control": (ScanControl.ICON_NAME, "Add Scan Control", "ScanControl"),
"positioner_box": (PositionerBox.ICON_NAME, "Add Device Box", "PositionerBox"),
}
UTIL_ACTIONS = {
"queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"),
"vs_code": (VSCodeEditor.ICON_NAME, "Add VS Code", "VSCodeEditor"),
"status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"),
"progress_bar": (
RingProgressBar.ICON_NAME,
"Add Circular ProgressBar",
"RingProgressBar",
),
"log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"),
"sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"),
}
def _build_menu(key: str, label: str, mapping: dict[str, tuple[str, str, str]]):
self.toolbar.components.add_safe(
key,
ExpandableMenuAction(
label=label,
actions={
k: MaterialIconAction(
icon_name=v[0], tooltip=v[1], filled=True, parent=self
)
for k, v in mapping.items()
},
),
)
b = ToolbarBundle(key, self.toolbar.components)
b.add_action(key)
self.toolbar.add_bundle(b)
_build_menu("menu_plots", "Add Plot ", PLOT_ACTIONS)
_build_menu("menu_devices", "Add Device Control ", DEVICE_ACTIONS)
_build_menu("menu_utils", "Add Utils ", UTIL_ACTIONS)
# Workspace
spacer_bundle = ToolbarBundle("spacer_bundle", self.toolbar.components)
spacer = QWidget(parent=self.toolbar.components.toolbar)
spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
self.toolbar.components.add_safe("spacer", WidgetAction(widget=spacer, adjust_size=False))
spacer_bundle.add_action("spacer")
self.toolbar.add_bundle(spacer_bundle)
self.toolbar.add_bundle(workspace_bundle(self.toolbar.components))
self.toolbar.connect_bundle(
"workspace", WorkspaceConnection(components=self.toolbar.components, target_widget=self)
)
# Dock actions
self.toolbar.components.add_safe(
"attach_all",
MaterialIconAction(
icon_name="zoom_in_map", tooltip="Attach all floating docks", parent=self
),
)
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)
)
bda = ToolbarBundle("dock_actions", self.toolbar.components)
bda.add_action("attach_all")
bda.add_action("screenshot")
bda.add_action("dark_mode")
self.toolbar.add_bundle(bda)
self.toolbar.show_bundles(
[
"menu_plots",
"menu_devices",
"menu_utils",
"spacer_bundle",
"workspace",
"dock_actions",
]
)
self.addToolBar(Qt.TopToolBarArea, self.toolbar)
# Store mappings on self for use in _hook_toolbar
self._ACTION_MAPPINGS = {
"menu_plots": PLOT_ACTIONS,
"menu_devices": DEVICE_ACTIONS,
"menu_utils": UTIL_ACTIONS,
}
def _hook_toolbar(self):
def _connect_menu(menu_key: str):
menu = self.toolbar.components.get_action(menu_key)
mapping = self._ACTION_MAPPINGS[menu_key]
for key, (_, _, widget_type) in mapping.items():
act = menu.actions[key].action
if widget_type == "LogPanel":
act.setEnabled(False) # keep disabled per issue #644
else:
act.triggered.connect(lambda _, t=widget_type: self.new(widget=t))
_connect_menu("menu_plots")
_connect_menu("menu_devices")
_connect_menu("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)
def _setup_developer_mode_menu(self):
"""Add a 'Developer' checkbox to the View menu after theme actions."""
mb = self.menuBar()
# Find the View menu (inherited from BECMainWindow)
view_menu = None
for action in mb.actions():
if action.menu() and action.menu().title() == "View":
view_menu = action.menu()
break
if view_menu is None:
# If View menu doesn't exist, create it
view_menu = mb.addMenu("View")
# Add separator after existing theme actions
view_menu.addSeparator()
# Add Developer mode checkbox
self._developer_mode_action = QAction("Developer", self, checkable=True)
# Default selection based on current lock state
self._editable = not self.lock_workspace
self._developer_mode_action.setChecked(self._editable)
# Wire up action
self._developer_mode_action.triggered.connect(self._on_developer_mode_toggled)
view_menu.addAction(self._developer_mode_action)
def _on_developer_mode_toggled(self, checked: bool) -> None:
"""Handle developer mode checkbox toggle."""
self._set_editable(checked)
def _set_editable(self, editable: bool) -> None:
self.lock_workspace = not editable
self._editable = editable
# Sync the toolbar lock toggle with current mode
lock_action = self.toolbar.components.get_action("lock").action
lock_action.setChecked(not editable)
lock_action.setVisible(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
if hasattr(self, "_developer_mode_action"):
self._developer_mode_action.setChecked(editable)
################################################################################
# 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,
) -> BECWidget:
"""
Creates a new widget or reuses an existing one and schedules its dock creation.
Args:
widget (BECWidget | str): The widget instance or a string specifying the
type of widget to create.
closable (bool): Whether the dock should be closable. Defaults to True.
floatable (bool): Whether the dock should be floatable. Defaults to True.
movable (bool): Whether the dock should be movable. Defaults to True.
start_floating (bool): Whether to start the dock in a floating state. Defaults to False.
Returns:
widget: The widget instance.
"""
# 1) Instantiate or look up the widget (this schedules the BECConnector naming logic)
if isinstance(widget, str):
widget = cast(BECWidget, widget_handler.create_widget(widget_type=widget, parent=self))
widget.name_established.connect(
lambda: self._create_dock_with_name(
widget=widget,
closable=closable,
floatable=floatable,
movable=movable,
start_floating=start_floating,
)
)
return widget
def _create_dock_with_name(
self,
widget: BECWidget,
closable: bool = True,
floatable: bool = False,
movable: bool = True,
start_floating: bool = False,
):
self._make_dock(
widget,
closable=closable,
floatable=floatable,
movable=movable,
area=QtAds.DockWidgetArea.RightDockWidgetArea,
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
################################################################################
@SafeProperty(bool)
def lock_workspace(self) -> bool:
"""
Get or set the lock state of the workspace.
Returns:
bool: True if the workspace is locked, False otherwise.
"""
return self._locked
@lock_workspace.setter
def lock_workspace(self, value: bool):
"""
Set the lock state of the workspace. Docks remain resizable, but are not movable or closable.
Args:
value (bool): True to lock the workspace, False to unlock it.
"""
self._locked = value
self._apply_dock_lock(value)
self.toolbar.components.get_action("save_workspace").action.setVisible(not value)
self.toolbar.components.get_action("delete_workspace").action.setVisible(not value)
for dock in self.dock_list():
dock.setting_action.setVisible(not value)
@SafeSlot(str)
def save_profile(self, name: str | None = None):
"""
Save the current workspace profile.
Args:
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
"""
if not name:
# Use the new SaveProfileDialog instead of QInputDialog
dialog = SaveProfileDialog(self)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
readonly = dialog.is_readonly()
# Check if profile already exists and is read-only
if os.path.exists(_profile_path(name)) and is_profile_readonly(name):
suggested_name = f"{name}_custom"
reply = QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be overwritten.\n\n"
f"Would you like to save it with a different name?\n"
f"Suggested name: '{suggested_name}'",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes,
)
if reply == QMessageBox.Yes:
# Show dialog again with suggested name pre-filled
dialog = SaveProfileDialog(self, suggested_name)
if dialog.exec() != QDialog.Accepted:
return
name = dialog.get_profile_name()
readonly = dialog.is_readonly()
# Check again if the new name is also read-only (recursive protection)
if os.path.exists(_profile_path(name)) and is_profile_readonly(name):
return self.save_profile()
else:
return
else:
# If name is provided directly, assume not read-only unless already exists
readonly = False
if os.path.exists(_profile_path(name)) and is_profile_readonly(name):
QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be overwritten.",
QMessageBox.Ok,
)
return
# Display saving placeholder
workspace_combo = self.toolbar.components.get_action("workspace_combo").widget
workspace_combo.blockSignals(True)
workspace_combo.insertItem(0, f"{name}-saving")
workspace_combo.setCurrentIndex(0)
workspace_combo.blockSignals(False)
# Save the profile
settings = open_settings(name)
settings.setValue(SETTINGS_KEYS["geom"], self.saveGeometry())
settings.setValue(SETTINGS_KEYS["state"], self.saveState())
settings.setValue(SETTINGS_KEYS["ads_state"], self.dock_manager.saveState())
self.dock_manager.addPerspective(name)
self.dock_manager.savePerspectives(settings)
self.state_manager.save_state(settings=settings)
write_manifest(settings, self.dock_list())
# Set read-only status if specified
if readonly:
set_profile_readonly(name, readonly)
settings.sync()
self._refresh_workspace_list()
workspace_combo.setCurrentText(name)
def load_profile(self, name: str | None = None):
"""
Load a workspace profile.
Args:
name (str | None): The name of the profile. If None, a dialog will prompt for a name.
"""
# FIXME this has to be tweaked
if not name:
name, ok = QInputDialog.getText(
self, "Load Workspace", "Enter the name of the workspace profile to load:"
)
if not ok or not name:
return
settings = open_settings(name)
for item in read_manifest(settings):
obj_name = item["object_name"]
widget_class = item["widget_class"]
if obj_name not in self.widget_map():
w = widget_handler.create_widget(widget_type=widget_class, parent=self)
w.setObjectName(obj_name)
self._make_dock(
w,
closable=item["closable"],
floatable=item["floatable"],
movable=item["movable"],
area=QtAds.DockWidgetArea.RightDockWidgetArea,
)
geom = settings.value(SETTINGS_KEYS["geom"])
if geom:
self.restoreGeometry(geom)
window_state = settings.value(SETTINGS_KEYS["state"])
if window_state:
self.restoreState(window_state)
dock_state = settings.value(SETTINGS_KEYS["ads_state"])
if dock_state:
self.dock_manager.restoreState(dock_state)
self.dock_manager.loadPerspectives(settings)
self.state_manager.load_state(settings=settings)
self._set_editable(self._editable)
@SafeSlot()
def delete_profile(self):
"""
Delete the currently selected workspace profile file and refresh the combo list.
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
name = combo.currentText()
if not name:
return
# Check if profile is read-only
if is_profile_readonly(name):
QMessageBox.warning(
self,
"Read-only Profile",
f"The profile '{name}' is marked as read-only and cannot be deleted.\n\n"
f"Read-only profiles are protected from modification and deletion.",
QMessageBox.Ok,
)
return
# Confirm deletion for regular profiles
reply = QMessageBox.question(
self,
"Delete Profile",
f"Are you sure you want to delete the profile '{name}'?\n\n"
f"This action cannot be undone.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
file_path = _profile_path(name)
try:
os.remove(file_path)
except FileNotFoundError:
return
self._refresh_workspace_list()
def _refresh_workspace_list(self):
"""
Populate the workspace combo box with all saved profile names (without .ini).
"""
combo = self.toolbar.components.get_action("workspace_combo").widget
if hasattr(combo, "refresh_profiles"):
combo.refresh_profiles()
else:
# Fallback for regular QComboBox
combo.blockSignals(True)
combo.clear()
combo.addItems(list_profiles())
combo.blockSignals(False)
################################################################################
# Styling
################################################################################
def cleanup(self):
"""
Cleanup the dock area.
"""
self.delete_all()
self.dark_mode_button.close()
self.dark_mode_button.deleteLater()
super().cleanup()
if __name__ == "__main__":
import sys
if sys.platform.startswith("linux"):
os.environ["QT_QPA_PLATFORM"] = "xcb"
app = QApplication(sys.argv)
dispatcher = BECDispatcher(gui_id="ads")
main_window = AdvancedDockArea()
main_window.show()
main_window.resize(800, 600)
sys.exit(app.exec())

View File

@@ -0,0 +1,178 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QComboBox, QSizePolicy, QWidget
from bec_widgets import SafeSlot
from bec_widgets.utils.toolbars.actions import MaterialIconAction, WidgetAction
from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents
class ProfileComboBox(QComboBox):
"""Custom combobox that displays icons for read-only profiles."""
def __init__(self, parent=None):
super().__init__(parent)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
def refresh_profiles(self):
"""Refresh the profile list with appropriate icons."""
from ..advanced_dock_area import is_profile_readonly, list_profiles
current_text = self.currentText()
self.blockSignals(True)
self.clear()
lock_icon = material_icon("edit_off", size=(16, 16), convert_to_pixmap=False)
for profile in list_profiles():
if is_profile_readonly(profile):
self.addItem(lock_icon, f"{profile}")
# Set tooltip for read-only profiles
self.setItemData(self.count() - 1, "Read-only profile", Qt.ToolTipRole)
else:
self.addItem(profile)
# Restore selection if possible
index = self.findText(current_text)
if index >= 0:
self.setCurrentIndex(index)
self.blockSignals(False)
def workspace_bundle(components: ToolbarComponents) -> ToolbarBundle:
"""
Creates a workspace toolbar bundle for AdvancedDockArea.
Args:
components (ToolbarComponents): The components to be added to the bundle.
Returns:
ToolbarBundle: The workspace toolbar bundle.
"""
# Lock icon action
components.add_safe(
"lock",
MaterialIconAction(
icon_name="lock_open_right",
tooltip="Lock Workspace",
checkable=True,
parent=components.toolbar,
),
)
# Workspace combo
combo = ProfileComboBox(parent=components.toolbar)
components.add_safe("workspace_combo", WidgetAction(widget=combo, adjust_size=False))
# Save the current workspace icon
components.add_safe(
"save_workspace",
MaterialIconAction(
icon_name="save",
tooltip="Save Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
# Delete workspace icon
components.add_safe(
"refresh_workspace",
MaterialIconAction(
icon_name="refresh",
tooltip="Refresh Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
# Delete workspace icon
components.add_safe(
"delete_workspace",
MaterialIconAction(
icon_name="delete",
tooltip="Delete Current Workspace",
checkable=False,
parent=components.toolbar,
),
)
bundle = ToolbarBundle("workspace", components)
bundle.add_action("lock")
bundle.add_action("workspace_combo")
bundle.add_action("save_workspace")
bundle.add_action("refresh_workspace")
bundle.add_action("delete_workspace")
return bundle
class WorkspaceConnection:
"""
Connection class for workspace actions in AdvancedDockArea.
"""
def __init__(self, components: ToolbarComponents, target_widget=None):
self.bundle_name = "workspace"
self.components = components
self.target_widget = target_widget
if not hasattr(self.target_widget, "lock_workspace"):
raise AttributeError("Target widget must implement 'lock_workspace'.")
super().__init__()
self._connected = False
def connect(self):
self._connected = True
# Connect the action to the target widget's method
self.components.get_action("lock").action.toggled.connect(self._lock_workspace)
self.components.get_action("save_workspace").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("refresh_workspace").action.triggered.connect(
self._refresh_workspace
)
self.components.get_action("delete_workspace").action.triggered.connect(
self.target_widget.delete_profile
)
def disconnect(self):
if not self._connected:
return
# Disconnect the action from the target widget's method
self.components.get_action("lock").action.toggled.disconnect(self._lock_workspace)
self.components.get_action("save_workspace").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("refresh_workspace").action.triggered.disconnect(
self._refresh_workspace
)
self.components.get_action("delete_workspace").action.triggered.disconnect(
self.target_widget.delete_profile
)
@SafeSlot(bool)
def _lock_workspace(self, value: bool):
"""
Switches the workspace lock state and change the icon accordingly.
"""
setattr(self.target_widget, "lock_workspace", value)
self.components.get_action("lock").action.setChecked(value)
icon = material_icon(
"lock" if value else "lock_open_right", size=(20, 20), convert_to_pixmap=False
)
self.components.get_action("lock").action.setIcon(icon)
@SafeSlot()
def _refresh_workspace(self):
"""
Refreshes the current workspace.
"""
combo = self.components.get_action("workspace_combo").widget
current_workspace = combo.currentText()
self.target_widget.load_profile(current_workspace)

View File

@@ -0,0 +1,806 @@
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
import os
import tempfile
from unittest import mock
from unittest.mock import MagicMock, patch
import pytest
from qtpy.QtCore import QSettings, Qt
from qtpy.QtGui import QAction
from qtpy.QtWidgets import QDialog, QMessageBox
from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import (
AdvancedDockArea,
DockSettingsDialog,
SaveProfileDialog,
_profile_path,
_profiles_dir,
is_profile_readonly,
list_profiles,
open_settings,
read_manifest,
set_profile_readonly,
write_manifest,
)
from .client_mocks import mocked_client
@pytest.fixture
def advanced_dock_area(qtbot, mocked_client):
"""Create an AdvancedDockArea instance for testing."""
widget = AdvancedDockArea(client=mocked_client)
qtbot.addWidget(widget)
qtbot.waitExposed(widget)
yield widget
@pytest.fixture
def temp_profile_dir():
"""Create a temporary directory for profile testing."""
with tempfile.TemporaryDirectory() as temp_dir:
with patch.dict(os.environ, {"BECWIDGETS_PROFILE_DIR": temp_dir}):
yield temp_dir
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 hasattr(advanced_dock_area, "dock_manager")
assert hasattr(advanced_dock_area, "toolbar")
assert hasattr(advanced_dock_area, "dark_mode_button")
assert hasattr(advanced_dock_area, "state_manager")
def test_minimum_size_hint(self, advanced_dock_area):
size_hint = advanced_dock_area.minimumSizeHint()
assert size_hint.width() == 1200
assert size_hint.height() == 800
def test_rpc_and_plugin_flags(self):
assert AdvancedDockArea.RPC is True
assert AdvancedDockArea.PLUGIN is False
def test_user_access_list(self):
expected_methods = [
"new",
"widget_map",
"widget_list",
"lock_workspace",
"attach_all",
"delete_all",
]
for method in expected_methods:
assert method in AdvancedDockArea.USER_ACCESS
class TestDockManagement:
"""Test dock creation, management, and manipulation."""
def test_new_widget_string(self, advanced_dock_area, qtbot):
"""Test creating a new widget from string."""
initial_count = len(advanced_dock_area.dock_list())
# Create a widget by string name
widget = advanced_dock_area.new("Waveform")
# Wait for the dock to be created (since it's async)
qtbot.wait(200)
# Check that dock was actually created
assert len(advanced_dock_area.dock_list()) == initial_count + 1
# Check widget was returned
assert widget is not None
assert hasattr(widget, "name_established")
def test_new_widget_instance(self, advanced_dock_area):
"""Test creating dock with existing widget instance."""
from bec_widgets.widgets.plots.waveform.waveform import Waveform
initial_count = len(advanced_dock_area.dock_list())
# Create widget instance
widget_instance = Waveform(parent=advanced_dock_area, client=advanced_dock_area.client)
widget_instance.setObjectName("test_widget")
# Add it to dock area
result = advanced_dock_area.new(widget_instance)
# Should return the same instance
assert result == widget_instance
# No new dock created since we passed an instance, not a string
assert len(advanced_dock_area.dock_list()) == initial_count
def test_dock_map(self, advanced_dock_area, qtbot):
"""Test dock_map returns correct mapping."""
# Initially empty
dock_map = advanced_dock_area.dock_map()
assert isinstance(dock_map, dict)
initial_count = len(dock_map)
# Create a widget
advanced_dock_area.new("Waveform")
qtbot.wait(200)
# Check dock map updated
new_dock_map = advanced_dock_area.dock_map()
assert len(new_dock_map) == initial_count + 1
def test_dock_list(self, advanced_dock_area, qtbot):
"""Test dock_list returns list of docks."""
dock_list = advanced_dock_area.dock_list()
assert isinstance(dock_list, list)
initial_count = len(dock_list)
# Create a widget
advanced_dock_area.new("Waveform")
qtbot.wait(200)
# Check dock list updated
new_dock_list = advanced_dock_area.dock_list()
assert len(new_dock_list) == initial_count + 1
def test_widget_map(self, advanced_dock_area, qtbot):
"""Test widget_map returns widget mapping."""
widget_map = advanced_dock_area.widget_map()
assert isinstance(widget_map, dict)
initial_count = len(widget_map)
# Create a widget
advanced_dock_area.new("DarkModeButton")
qtbot.wait(200)
# Check widget map updated
new_widget_map = advanced_dock_area.widget_map()
assert len(new_widget_map) == initial_count + 1
def test_widget_list(self, advanced_dock_area, qtbot):
"""Test widget_list returns list of widgets."""
widget_list = advanced_dock_area.widget_list()
assert isinstance(widget_list, list)
initial_count = len(widget_list)
# Create a widget
advanced_dock_area.new("DarkModeButton")
qtbot.wait(200)
# Check widget list updated
new_widget_list = advanced_dock_area.widget_list()
assert len(new_widget_list) == initial_count + 1
def test_attach_all(self, advanced_dock_area, qtbot):
"""Test attach_all functionality."""
# Create multiple widgets
advanced_dock_area.new("DarkModeButton", start_floating=True)
advanced_dock_area.new("DarkModeButton", start_floating=True)
# Wait for docks to be created
qtbot.wait(200)
# Should have floating widgets
initial_floating = len(advanced_dock_area.dock_manager.floatingWidgets())
# Attach all floating docks
advanced_dock_area.attach_all()
# Wait a bit for the operation to complete
qtbot.wait(200)
# Should have fewer floating widgets (or none if all were attached)
final_floating = len(advanced_dock_area.dock_manager.floatingWidgets())
assert final_floating <= initial_floating
def test_delete_all(self, advanced_dock_area, qtbot):
"""Test delete_all functionality."""
# Create multiple widgets
advanced_dock_area.new("DarkModeButton")
advanced_dock_area.new("DarkModeButton")
# Wait for docks to be created
qtbot.wait(200)
initial_count = len(advanced_dock_area.dock_list())
assert initial_count >= 2
# Delete all
advanced_dock_area.delete_all()
# Wait for deletion to complete
qtbot.wait(200)
# Should have no docks
assert len(advanced_dock_area.dock_list()) == 0
class TestWorkspaceLocking:
"""Test workspace locking functionality."""
def test_lock_workspace_property_getter(self, advanced_dock_area):
"""Test lock_workspace property getter."""
# Initially unlocked
assert advanced_dock_area.lock_workspace is False
# Set locked state directly
advanced_dock_area._locked = True
assert advanced_dock_area.lock_workspace is True
def test_lock_workspace_property_setter(self, advanced_dock_area, qtbot):
"""Test lock_workspace property setter."""
# Create a dock first
advanced_dock_area.new("DarkModeButton")
qtbot.wait(200)
# Initially unlocked
assert advanced_dock_area.lock_workspace is False
# Lock workspace
advanced_dock_area.lock_workspace = True
assert advanced_dock_area._locked is True
assert advanced_dock_area.lock_workspace is True
# Unlock workspace
advanced_dock_area.lock_workspace = False
assert advanced_dock_area._locked is False
assert advanced_dock_area.lock_workspace is False
class TestDeveloperMode:
"""Test developer mode functionality."""
def test_setup_developer_mode_menu(self, advanced_dock_area):
"""Test developer mode menu setup."""
# The menu should be set up during initialization
assert hasattr(advanced_dock_area, "_developer_mode_action")
assert isinstance(advanced_dock_area._developer_mode_action, QAction)
assert advanced_dock_area._developer_mode_action.isCheckable()
def test_developer_mode_toggle(self, advanced_dock_area):
"""Test developer mode toggle functionality."""
# Check initial state
initial_editable = advanced_dock_area._editable
# Toggle developer mode
advanced_dock_area._on_developer_mode_toggled(True)
assert advanced_dock_area._editable is True
assert advanced_dock_area.lock_workspace is False
advanced_dock_area._on_developer_mode_toggled(False)
assert advanced_dock_area._editable is False
assert advanced_dock_area.lock_workspace is True
def test_set_editable(self, advanced_dock_area):
"""Test _set_editable functionality."""
# Test setting editable to True
advanced_dock_area._set_editable(True)
assert advanced_dock_area.lock_workspace is False
assert advanced_dock_area._editable is True
# Test setting editable to False
advanced_dock_area._set_editable(False)
assert advanced_dock_area.lock_workspace is True
assert advanced_dock_area._editable is False
class TestToolbarFunctionality:
"""Test toolbar setup and functionality."""
def test_toolbar_setup(self, advanced_dock_area):
"""Test toolbar is properly set up."""
assert hasattr(advanced_dock_area, "toolbar")
assert hasattr(advanced_dock_area, "_ACTION_MAPPINGS")
# Check that action mappings are properly set
assert "menu_plots" in advanced_dock_area._ACTION_MAPPINGS
assert "menu_devices" in advanced_dock_area._ACTION_MAPPINGS
assert "menu_utils" in advanced_dock_area._ACTION_MAPPINGS
def test_toolbar_plot_actions(self, advanced_dock_area):
"""Test plot toolbar actions trigger widget creation."""
plot_actions = [
"waveform",
"scatter_waveform",
"multi_waveform",
"image",
"motor_map",
"heatmap",
]
for action_name in plot_actions:
with patch.object(advanced_dock_area, "new") as mock_new:
menu_plots = advanced_dock_area.toolbar.components.get_action("menu_plots")
action = menu_plots.actions[action_name].action
# Get the expected widget type from the action mappings
widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_plots"][action_name][2]
action.trigger()
mock_new.assert_called_once_with(widget=widget_type)
def test_toolbar_device_actions(self, advanced_dock_area):
"""Test device toolbar actions trigger widget creation."""
device_actions = ["scan_control", "positioner_box"]
for action_name in device_actions:
with patch.object(advanced_dock_area, "new") as mock_new:
menu_devices = advanced_dock_area.toolbar.components.get_action("menu_devices")
action = menu_devices.actions[action_name].action
# Get the expected widget type from the action mappings
widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_devices"][action_name][2]
action.trigger()
mock_new.assert_called_once_with(widget=widget_type)
def test_toolbar_utils_actions(self, advanced_dock_area):
"""Test utils toolbar actions trigger widget creation."""
utils_actions = ["queue", "vs_code", "status", "progress_bar", "sbb_monitor"]
for action_name in utils_actions:
with patch.object(advanced_dock_area, "new") as mock_new:
menu_utils = advanced_dock_area.toolbar.components.get_action("menu_utils")
action = menu_utils.actions[action_name].action
# Skip log_panel as it's disabled
if action_name == "log_panel":
assert not action.isEnabled()
continue
# Get the expected widget type from the action mappings
widget_type = advanced_dock_area._ACTION_MAPPINGS["menu_utils"][action_name][2]
action.trigger()
mock_new.assert_called_once_with(widget=widget_type)
def test_attach_all_action(self, advanced_dock_area, qtbot):
"""Test attach_all toolbar action."""
# Create floating docks
advanced_dock_area.new("DarkModeButton", start_floating=True)
advanced_dock_area.new("DarkModeButton", start_floating=True)
qtbot.wait(200)
initial_floating = len(advanced_dock_area.dock_manager.floatingWidgets())
# Trigger attach all action
action = advanced_dock_area.toolbar.components.get_action("attach_all").action
action.trigger()
# Wait a bit for the operation
qtbot.wait(200)
# Should have fewer or same floating widgets
final_floating = len(advanced_dock_area.dock_manager.floatingWidgets())
assert final_floating <= initial_floating
def test_screenshot_action(self, advanced_dock_area, tmpdir):
"""Test screenshot toolbar action."""
# Create a test screenshot file path in tmpdir
screenshot_path = tmpdir.join("test_screenshot.png")
# Mock the QFileDialog.getSaveFileName to return a test filename
with mock.patch("bec_widgets.utils.bec_widget.QFileDialog.getSaveFileName") as mock_dialog:
mock_dialog.return_value = (str(screenshot_path), "PNG Files (*.png)")
# Mock the screenshot.save method
with mock.patch.object(advanced_dock_area, "grab") as mock_grab:
mock_screenshot = mock.MagicMock()
mock_grab.return_value = mock_screenshot
# Trigger the screenshot action
action = advanced_dock_area.toolbar.components.get_action("screenshot").action
action.trigger()
# Verify the dialog was called
mock_dialog.assert_called_once()
# Verify grab was called
mock_grab.assert_called_once()
# Verify save was called with the filename
mock_screenshot.save.assert_called_once_with(str(screenshot_path))
class TestDockSettingsDialog:
"""Test dock settings dialog functionality."""
def test_dock_settings_dialog_init(self, advanced_dock_area):
"""Test DockSettingsDialog initialization."""
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
DarkModeButton,
)
# Create a real widget
mock_widget = DarkModeButton(parent=advanced_dock_area)
dialog = DockSettingsDialog(advanced_dock_area, mock_widget)
assert dialog.windowTitle() == "Dock Settings"
assert dialog.isModal()
assert hasattr(dialog, "prop_editor")
def test_open_dock_settings_dialog(self, advanced_dock_area, qtbot):
"""Test opening dock settings dialog."""
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
DarkModeButton,
)
# Create real widget and dock
widget = DarkModeButton(parent=advanced_dock_area)
widget.setObjectName("test_widget")
# Create a real dock
dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True)
# Mock dialog exec to avoid blocking
with patch.object(DockSettingsDialog, "exec") as mock_exec:
mock_exec.return_value = QDialog.Accepted
# Call the method
advanced_dock_area._open_dock_settings_dialog(dock, widget)
# Verify dialog was created and exec called
mock_exec.assert_called_once()
class TestSaveProfileDialog:
"""Test save profile dialog functionality."""
def test_save_profile_dialog_init(self, qtbot):
"""Test SaveProfileDialog initialization."""
dialog = SaveProfileDialog(None, "test_profile")
qtbot.addWidget(dialog)
assert dialog.windowTitle() == "Save Workspace Profile"
assert dialog.isModal()
assert dialog.name_edit.text() == "test_profile"
assert hasattr(dialog, "readonly_checkbox")
def test_save_profile_dialog_get_values(self, qtbot):
"""Test getting values from SaveProfileDialog."""
dialog = SaveProfileDialog(None)
qtbot.addWidget(dialog)
dialog.name_edit.setText("my_profile")
dialog.readonly_checkbox.setChecked(True)
assert dialog.get_profile_name() == "my_profile"
assert dialog.is_readonly() is True
def test_save_button_enabled_state(self, qtbot):
"""Test save button is enabled/disabled based on name input."""
dialog = SaveProfileDialog(None)
qtbot.addWidget(dialog)
# Initially should be disabled (empty name)
assert not dialog.save_btn.isEnabled()
# Should be enabled when name is entered
dialog.name_edit.setText("test")
assert dialog.save_btn.isEnabled()
# Should be disabled when name is cleared
dialog.name_edit.setText("")
assert not dialog.save_btn.isEnabled()
class TestProfileManagement:
"""Test profile management functionality."""
def test_profiles_dir_creation(self, temp_profile_dir):
"""Test that profiles directory is created."""
profiles_dir = _profiles_dir()
assert os.path.exists(profiles_dir)
assert profiles_dir == temp_profile_dir
def test_profile_path(self, temp_profile_dir):
"""Test profile path generation."""
path = _profile_path("test_profile")
expected = os.path.join(temp_profile_dir, "test_profile.ini")
assert path == expected
def test_open_settings(self, temp_profile_dir):
"""Test opening settings for a profile."""
settings = open_settings("test_profile")
assert isinstance(settings, QSettings)
def test_list_profiles_empty(self, temp_profile_dir):
"""Test listing profiles when directory is empty."""
profiles = list_profiles()
assert profiles == []
def test_list_profiles_with_files(self, temp_profile_dir):
"""Test listing profiles with existing files."""
# Create some test profile files
profile_names = ["profile1", "profile2", "profile3"]
for name in profile_names:
settings = open_settings(name)
settings.setValue("test", "value")
settings.sync()
profiles = list_profiles()
assert sorted(profiles) == sorted(profile_names)
def test_readonly_profile_operations(self, temp_profile_dir):
"""Test read-only profile functionality."""
profile_name = "readonly_profile"
# Initially should not be read-only
assert not is_profile_readonly(profile_name)
# Set as read-only
set_profile_readonly(profile_name, True)
assert is_profile_readonly(profile_name)
# Unset read-only
set_profile_readonly(profile_name, False)
assert not is_profile_readonly(profile_name)
def test_write_and_read_manifest(self, temp_profile_dir, advanced_dock_area, qtbot):
"""Test writing and reading dock manifest."""
settings = open_settings("test_manifest")
# Create real docks
advanced_dock_area.new("DarkModeButton")
advanced_dock_area.new("DarkModeButton")
advanced_dock_area.new("DarkModeButton")
# Wait for docks to be created
qtbot.wait(1000)
docks = advanced_dock_area.dock_list()
# Write manifest
write_manifest(settings, docks)
settings.sync()
# Read manifest
items = read_manifest(settings)
assert len(items) >= 3
for item in items:
assert "object_name" in item
assert "widget_class" in item
assert "closable" in item
assert "floatable" in item
assert "movable" in item
class TestWorkspaceProfileOperations:
"""Test workspace profile save/load/delete operations."""
def test_save_profile_with_name(self, advanced_dock_area, temp_profile_dir, qtbot):
"""Test saving profile with provided name."""
profile_name = "test_save_profile"
# Create some docks
advanced_dock_area.new("DarkModeButton")
qtbot.wait(200)
# Save profile
advanced_dock_area.save_profile(profile_name)
# Check that profile file was created
profile_path = _profile_path(profile_name)
assert os.path.exists(profile_path)
def test_save_profile_readonly_conflict(self, advanced_dock_area, temp_profile_dir):
"""Test saving profile when read-only profile exists."""
profile_name = "readonly_profile"
# Create a read-only profile
set_profile_readonly(profile_name, True)
settings = open_settings(profile_name)
settings.setValue("test", "value")
settings.sync()
with patch(
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.SaveProfileDialog"
) as mock_dialog_class:
mock_dialog = MagicMock()
mock_dialog.exec.return_value = QDialog.Accepted
mock_dialog.get_profile_name.return_value = profile_name
mock_dialog.is_readonly.return_value = False
mock_dialog_class.return_value = mock_dialog
with patch(
"bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area.QMessageBox.warning"
) as mock_warning:
mock_warning.return_value = QMessageBox.No
advanced_dock_area.save_profile()
mock_warning.assert_called_once()
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"
# Create a profile with manifest
settings = open_settings(profile_name)
settings.beginWriteArray("manifest/widgets", 1)
settings.setArrayIndex(0)
settings.setValue("object_name", "test_widget")
settings.setValue("widget_class", "DarkModeButton")
settings.setValue("closable", True)
settings.setValue("floatable", True)
settings.setValue("movable", True)
settings.endArray()
settings.sync()
initial_count = len(advanced_dock_area.widget_map())
# Load profile
advanced_dock_area.load_profile(profile_name)
# Wait for widget to be created
qtbot.wait(1000)
# Check widget was created
widget_map = advanced_dock_area.widget_map()
assert "test_widget" in widget_map
def test_delete_profile_readonly(self, advanced_dock_area, temp_profile_dir):
"""Test deleting read-only profile shows warning."""
profile_name = "readonly_profile"
# Create read-only profile
set_profile_readonly(profile_name, True)
settings = open_settings(profile_name)
settings.setValue("test", "value")
settings.sync()
with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action:
mock_combo = MagicMock()
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.warning"
) as mock_warning:
advanced_dock_area.delete_profile()
mock_warning.assert_called_once()
# Profile should still exist
assert os.path.exists(_profile_path(profile_name))
def test_delete_profile_success(self, advanced_dock_area, temp_profile_dir):
"""Test successful profile deletion."""
profile_name = "deletable_profile"
# Create regular profile
settings = open_settings(profile_name)
settings.setValue("test", "value")
settings.sync()
with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action:
mock_combo = MagicMock()
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.object(advanced_dock_area, "_refresh_workspace_list") as mock_refresh:
advanced_dock_area.delete_profile()
mock_question.assert_called_once()
mock_refresh.assert_called_once()
# Profile should be deleted
assert not os.path.exists(_profile_path(profile_name))
def test_refresh_workspace_list(self, advanced_dock_area, temp_profile_dir):
"""Test refreshing workspace list."""
# Create some profiles
for name in ["profile1", "profile2"]:
settings = open_settings(name)
settings.setValue("test", "value")
settings.sync()
with patch.object(advanced_dock_area.toolbar.components, "get_action") as mock_get_action:
mock_combo = MagicMock()
mock_combo.refresh_profiles = MagicMock()
mock_get_action.return_value.widget = mock_combo
advanced_dock_area._refresh_workspace_list()
mock_combo.refresh_profiles.assert_called_once()
class TestCleanupAndMisc:
"""Test cleanup and miscellaneous functionality."""
def test_cleanup(self, advanced_dock_area):
"""Test cleanup functionality."""
with patch.object(advanced_dock_area.dark_mode_button, "close") as mock_close:
with patch.object(advanced_dock_area.dark_mode_button, "deleteLater") as mock_delete:
with patch(
"bec_widgets.widgets.containers.main_window.main_window.BECMainWindow.cleanup"
) as mock_super_cleanup:
advanced_dock_area.cleanup()
mock_close.assert_called_once()
mock_delete.assert_called_once()
mock_super_cleanup.assert_called_once()
def test_delete_dock(self, advanced_dock_area, qtbot):
"""Test _delete_dock functionality."""
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
DarkModeButton,
)
# Create a real widget and dock
widget = DarkModeButton(parent=advanced_dock_area)
widget.setObjectName("test_widget")
dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True)
initial_count = len(advanced_dock_area.dock_list())
# Delete the dock
advanced_dock_area._delete_dock(dock)
# Wait for deletion to complete
qtbot.wait(200)
# Verify dock was removed
assert len(advanced_dock_area.dock_list()) == initial_count - 1
def test_apply_dock_lock(self, advanced_dock_area, qtbot):
"""Test _apply_dock_lock functionality."""
# Create a dock first
advanced_dock_area.new("DarkModeButton")
qtbot.wait(200)
# Test locking
advanced_dock_area._apply_dock_lock(True)
# No assertion needed - just verify it doesn't crash
# Test unlocking
advanced_dock_area._apply_dock_lock(False)
# No assertion needed - just verify it doesn't crash
def test_make_dock(self, advanced_dock_area):
"""Test _make_dock functionality."""
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
DarkModeButton,
)
# Create a real widget
widget = DarkModeButton(parent=advanced_dock_area)
widget.setObjectName("test_widget")
initial_count = len(advanced_dock_area.dock_list())
# Create dock
dock = advanced_dock_area._make_dock(widget, closable=True, floatable=True, movable=True)
# Verify dock was created
assert dock is not None
assert len(advanced_dock_area.dock_list()) == initial_count + 1
assert dock.widget() == widget
def test_install_dock_settings_action(self, advanced_dock_area):
"""Test _install_dock_settings_action functionality."""
from bec_widgets.widgets.utility.visual.dark_mode_button.dark_mode_button import (
DarkModeButton,
)
# Create real widget and dock
widget = DarkModeButton(parent=advanced_dock_area)
widget.setObjectName("test_widget")
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 title bar actions were set
title_bar_actions = dock.titleBarActions()
assert len(title_bar_actions) >= 1