From e7f9919620f7f28efba8a308512eaec8cc05959c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Tue, 5 Aug 2025 15:58:29 +0200 Subject: [PATCH] feat(advanced_dock_area): added ads based dock area with profiles --- bec_widgets/cli/client.py | 66 ++ .../jupyter_console/jupyter_console_window.py | 16 +- .../containers/advanced_dock_area/__init__.py | 0 .../advanced_dock_area/advanced_dock_area.py | 857 ++++++++++++++++++ .../toolbar_components/__init__.py | 0 .../toolbar_components/workspace_actions.py | 178 ++++ tests/unit_tests/test_advanced_dock_area.py | 806 ++++++++++++++++ 7 files changed, 1915 insertions(+), 8 deletions(-) create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/__init__.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py create mode 100644 bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py create mode 100644 tests/unit_tests/test_advanced_dock_area.py diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index bf60a328..4450afbd 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -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 diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 0e9037f6..26682abd 100644 --- a/bec_widgets/examples/jupyter_console/jupyter_console_window.py +++ b/bec_widgets/examples/jupyter_console/jupyter_console_window.py @@ -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, @@ -120,14 +122,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) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py new file mode 100644 index 00000000..cd25afd8 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -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()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py new file mode 100644 index 00000000..b994b3ca --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -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) diff --git a/tests/unit_tests/test_advanced_dock_area.py b/tests/unit_tests/test_advanced_dock_area.py new file mode 100644 index 00000000..48cc770f --- /dev/null +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -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