From 7dcaf8fe4c15a0eeaf9cbf5675b52a7254cb0b35 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/__init__.py | 16 + .../jupyter_console/jupyter_console_window.py | 16 +- .../containers/advanced_dock_area/__init__.py | 0 .../advanced_dock_area/advanced_dock_area.py | 931 +++++++++++++++ .../advanced_dock_area/profile_utils.py | 79 ++ .../toolbar_components/__init__.py | 0 .../toolbar_components/workspace_actions.py | 183 +++ tests/unit_tests/test_advanced_dock_area.py | 1051 +++++++++++++++++ 8 files changed, 2268 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/profile_utils.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/__init__.py b/bec_widgets/__init__.py index 2621e27e..3d7d19fb 100644 --- a/bec_widgets/__init__.py +++ b/bec_widgets/__init__.py @@ -1,4 +1,20 @@ +import os +import sys + +import PySide6QtAds as QtAds + from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +if sys.platform.startswith("linux"): + qt_platform = os.environ.get("QT_QPA_PLATFORM", "") + if qt_platform != "offscreen": + os.environ["QT_QPA_PLATFORM"] = "xcb" + +# Default QtAds configuration +QtAds.CDockManager.setConfigFlag(QtAds.CDockManager.eConfigFlag.FocusHighlighting, True) +QtAds.CDockManager.setConfigFlag( + QtAds.CDockManager.eConfigFlag.RetainTabSizeWhenCloseButtonHidden, True +) + __all__ = ["BECWidget", "SafeSlot", "SafeProperty"] diff --git a/bec_widgets/examples/jupyter_console/jupyter_console_window.py b/bec_widgets/examples/jupyter_console/jupyter_console_window.py index 6c80dd13..88a7dc44 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, @@ -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) 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..fda1613f --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/advanced_dock_area.py @@ -0,0 +1,931 @@ +from __future__ import annotations + +import os +from typing import Literal, cast + +import PySide6QtAds as QtAds +from PySide6QtAds import CDockManager, CDockWidget +from qtpy.QtCore import Signal +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.profile_utils import ( + SETTINGS_KEYS, + is_profile_readonly, + list_profiles, + open_settings, + profile_path, + read_manifest, + set_profile_readonly, + write_manifest, +) +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 BECMainWindowNoRPC +from bec_widgets.widgets.control.device_control.positioner_box import PositionerBox, PositionerBox2D +from bec_widgets.widgets.control.scan_control import ScanControl +from bec_widgets.widgets.editors.web_console.web_console import WebConsole +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 + + +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(BECWidget, QWidget): + RPC = True + PLUGIN = False + USER_ACCESS = [ + "new", + "widget_map", + "widget_list", + "lock_workspace", + "attach_all", + "delete_all", + "mode", + "mode.setter", + ] + + # Define a signal for mode changes + mode_changed = Signal(str) + + def __init__( + self, + parent=None, + mode: str = "developer", + default_add_direction: Literal["left", "right", "top", "bottom"] = "right", + *args, + **kwargs, + ): + super().__init__(parent=parent, *args, **kwargs) + + # Title (as a top-level QWidget it can have a window title) + self.setWindowTitle("Advanced Dock Area") + + # Top-level layout hosting a toolbar and the dock manager + self._root_layout = QVBoxLayout(self) + self._root_layout.setContentsMargins(0, 0, 0, 0) + self._root_layout.setSpacing(0) + + # Init Dock Manager + self.dock_manager = CDockManager(self) + + # Dock manager helper variables + self._locked = False # Lock state of the workspace + + # Initialize mode property first (before toolbar setup) + self._mode = "developer" + self._default_add_direction = ( + default_add_direction + if default_add_direction in ("left", "right", "top", "bottom") + else "right" + ) + + # Toolbar + self.dark_mode_button = DarkModeButton(parent=self, toolbar=True) + self._setup_toolbar() + self._hook_toolbar() + + # Place toolbar and dock manager into layout + self._root_layout.addWidget(self.toolbar) + self._root_layout.addWidget(self.dock_manager, 1) + + # Populate and hook the workspace combo + self._refresh_workspace_list() + + # State manager + self.state_manager = WidgetStateManager(self) + + # Developer mode state + self._editable = None + # Initialize default editable state based on current lock + self._set_editable(True) # default to editable; will sync toolbar toggle below + + # Sync Developer toggle icon state after initial setup + dev_action = self.toolbar.components.get_action("developer_mode").action + dev_action.setChecked(self._editable) + + # Apply the requested mode after everything is set up + self.mode = mode + + 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() + + def _area_from_where(self, where: str | None) -> QtAds.DockWidgetArea: + """Return ADS DockWidgetArea from a human-friendly direction string. + If *where* is None, fall back to instance default. + """ + d = (where or getattr(self, "_default_add_direction", "right") or "right").lower() + mapping = { + "left": QtAds.DockWidgetArea.LeftDockWidgetArea, + "right": QtAds.DockWidgetArea.RightDockWidgetArea, + "top": QtAds.DockWidgetArea.TopDockWidgetArea, + "bottom": QtAds.DockWidgetArea.BottomDockWidgetArea, + } + return mapping.get(d, QtAds.DockWidgetArea.RightDockWidgetArea) + + ################################################################################ + # Toolbar Setup + ################################################################################ + + 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"), + "positioner_box_2D": ( + PositionerBox2D.ICON_NAME, + "Add Device 2D Box", + "PositionerBox2D", + ), + } + UTIL_ACTIONS = { + "queue": (BECQueue.ICON_NAME, "Add Scan Queue", "BECQueue"), + "status": (BECStatusBox.ICON_NAME, "Add BEC Status Box", "BECStatusBox"), + "progress_bar": ( + RingProgressBar.ICON_NAME, + "Add Circular ProgressBar", + "RingProgressBar", + ), + "terminal": (WebConsole.ICON_NAME, "Add Terminal", "WebConsole"), + "bec_shell": (WebConsole.ICON_NAME, "Add BEC Shell", "WebConsole"), + "log_panel": (LogPanel.ICON_NAME, "Add LogPanel - Disabled", "LogPanel"), + "sbb_monitor": ("train", "Add SBB Monitor", "SBBMonitor"), + } + + # Create expandable menu actions (original behavior) + 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) + + # Create flat toolbar bundles for each widget type + def _build_flat_bundles(category: str, mapping: dict[str, tuple[str, str, str]]): + bundle = ToolbarBundle(f"flat_{category}", self.toolbar.components) + + for action_id, (icon_name, tooltip, widget_type) in mapping.items(): + # Create individual action for each widget type + flat_action_id = f"flat_{action_id}" + self.toolbar.components.add_safe( + flat_action_id, + MaterialIconAction( + icon_name=icon_name, tooltip=tooltip, filled=True, parent=self + ), + ) + bundle.add_action(flat_action_id) + + self.toolbar.add_bundle(bundle) + + _build_flat_bundles("plots", PLOT_ACTIONS) + _build_flat_bundles("devices", DEVICE_ACTIONS) + _build_flat_bundles("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) + ) + # Developer mode toggle (moved from menu into toolbar) + self.toolbar.components.add_safe( + "developer_mode", + MaterialIconAction( + icon_name="code", tooltip="Developer Mode", checkable=True, parent=self + ), + ) + bda = ToolbarBundle("dock_actions", self.toolbar.components) + bda.add_action("attach_all") + bda.add_action("screenshot") + bda.add_action("dark_mode") + bda.add_action("developer_mode") + self.toolbar.add_bundle(bda) + + # Default bundle configuration (show menus by default) + self.toolbar.show_bundles( + [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + ) + + # 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 + elif key == "terminal": + act.triggered.connect( + lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None) + ) + elif key == "bec_shell": + act.triggered.connect( + lambda _, t=widget_type: self.new( + widget=t, + closable=True, + startup_cmd=f"bec --gui-id {self.bec_dispatcher.cli_server.gui_id}", + ) + ) + else: + act.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) + + _connect_menu("menu_plots") + _connect_menu("menu_devices") + _connect_menu("menu_utils") + + # Connect flat toolbar actions + def _connect_flat_actions(category: str, mapping: dict[str, tuple[str, str, str]]): + for action_id, (_, _, widget_type) in mapping.items(): + flat_action_id = f"flat_{action_id}" + flat_action = self.toolbar.components.get_action(flat_action_id).action + if widget_type == "LogPanel": + flat_action.setEnabled(False) # keep disabled per issue #644 + else: + flat_action.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) + + _connect_flat_actions("plots", self._ACTION_MAPPINGS["menu_plots"]) + _connect_flat_actions("devices", self._ACTION_MAPPINGS["menu_devices"]) + _connect_flat_actions("utils", self._ACTION_MAPPINGS["menu_utils"]) + + self.toolbar.components.get_action("attach_all").action.triggered.connect(self.attach_all) + self.toolbar.components.get_action("screenshot").action.triggered.connect(self.screenshot) + # Developer mode toggle + self.toolbar.components.get_action("developer_mode").action.toggled.connect( + self._on_developer_mode_toggled + ) + + def _set_editable(self, editable: bool) -> None: + self.lock_workspace = not editable + self._editable = editable + + # 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 + self.toolbar.components.get_action("developer_mode").action.setChecked(editable) + + def _on_developer_mode_toggled(self, checked: bool) -> None: + """Handle developer mode checkbox toggle.""" + self._set_editable(checked) + + ################################################################################ + # Adding widgets + ################################################################################ + @SafeSlot(popup_error=True) + def new( + self, + widget: BECWidget | str, + closable: bool = True, + floatable: bool = True, + movable: bool = True, + start_floating: bool = False, + where: Literal["left", "right", "top", "bottom"] | None = None, + **kwargs, + ) -> BECWidget: + """ + Create a new widget (or reuse an instance) and add it as a dock. + + Args: + widget: Widget instance or a string widget type (factory-created). + closable: Whether the dock is closable. + floatable: Whether the dock is floatable. + movable: Whether the dock is movable. + start_floating: Start the dock in a floating state. + where: Preferred area to add the dock: "left" | "right" | "top" | "bottom". + If None, uses the instance default passed at construction time. + **kwargs: The keyword arguments for the widget. + Returns: + The widget instance. + """ + target_area = self._area_from_where(where) + + # 1) Instantiate or look up the widget + if isinstance(widget, str): + widget = cast( + BECWidget, widget_handler.create_widget(widget_type=widget, parent=self, **kwargs) + ) + widget.name_established.connect( + lambda: self._create_dock_with_name( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + area=target_area, + ) + ) + return widget + + # If a widget instance is passed, dock it immediately + self._create_dock_with_name( + widget=widget, + closable=closable, + floatable=floatable, + movable=movable, + start_floating=start_floating, + area=target_area, + ) + return widget + + def _create_dock_with_name( + self, + widget: BECWidget, + closable: bool = True, + floatable: bool = False, + movable: bool = True, + start_floating: bool = False, + area: QtAds.DockWidgetArea | None = None, + ): + target_area = area or self._area_from_where(None) + self._make_dock( + widget, + closable=closable, + floatable=floatable, + movable=movable, + area=target_area, + start_floating=start_floating, + ) + self.dock_manager.setFocus() + + ################################################################################ + # Dock Management + ################################################################################ + + def dock_map(self) -> dict[str, CDockWidget]: + """ + Return the dock widgets map as dictionary with names as keys and dock widgets as values. + + Returns: + dict: A dictionary mapping widget names to their corresponding dock widgets. + """ + return self.dock_manager.dockWidgetsMap() + + def dock_list(self) -> list[CDockWidget]: + """ + Return the list of dock widgets. + + Returns: + list: A list of all dock widgets in the dock area. + """ + return self.dock_manager.dockWidgets() + + def widget_map(self) -> dict[str, QWidget]: + """ + Return a dictionary mapping widget names to their corresponding BECWidget instances. + + Returns: + dict: A dictionary mapping widget names to BECWidget instances. + """ + return {dock.objectName(): dock.widget() for dock in self.dock_list()} + + def widget_list(self) -> list[QWidget]: + """ + Return a list of all BECWidget instances in the dock area. + + Returns: + list: A list of all BECWidget instances in the dock area. + """ + return [dock.widget() for dock in self.dock_list() if isinstance(dock.widget(), QWidget)] + + @SafeSlot() + def attach_all(self): + """ + Return all floating docks to the dock area, preserving tab groups within each floating container. + """ + for container in self.dock_manager.floatingWidgets(): + docks = container.dockWidgets() + if not docks: + continue + target = docks[0] + self.dock_manager.addDockWidget(QtAds.DockWidgetArea.RightDockWidgetArea, target) + for d in docks[1:]: + self.dock_manager.addDockWidgetTab( + QtAds.DockWidgetArea.RightDockWidgetArea, d, target + ) + + @SafeSlot() + def delete_all(self): + """Delete all docks and widgets.""" + for dock in list(self.dock_manager.dockWidgets()): + self._delete_dock(dock) + + ################################################################################ + # Workspace Management + ################################################################################ + @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"], b"" + ) # No QMainWindow state; placeholder for backward compat + 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) + # No window state for QWidget-based host; keep for backwards compat read + # window_state = settings.value(SETTINGS_KEYS["state"]) # ignored + 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) + + ################################################################################ + # Mode Switching + ################################################################################ + + @SafeProperty(str) + def mode(self) -> str: + return self._mode + + @mode.setter + def mode(self, new_mode: str): + if new_mode not in ["plot", "device", "utils", "developer", "user"]: + raise ValueError(f"Invalid mode: {new_mode}") + self._mode = new_mode + self.mode_changed.emit(new_mode) + + # Update toolbar visibility based on mode + if new_mode == "user": + # User mode: show only essential tools + self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) + elif new_mode == "developer": + # Developer mode: show all tools (use menu bundles) + self.toolbar.show_bundles( + [ + "menu_plots", + "menu_devices", + "menu_utils", + "spacer_bundle", + "workspace", + "dock_actions", + ] + ) + elif new_mode in ["plot", "device", "utils"]: + # Specific modes: show flat toolbar for that category + bundle_name = f"flat_{new_mode}s" if new_mode != "utils" else "flat_utils" + self.toolbar.show_bundles([bundle_name]) + # self.toolbar.show_bundles([bundle_name, "spacer_bundle", "workspace", "dock_actions"]) + else: + # Fallback to user mode + self.toolbar.show_bundles(["spacer_bundle", "workspace", "dock_actions"]) + + def cleanup(self): + """ + Cleanup the dock area. + """ + self.delete_all() + self.dark_mode_button.close() + self.dark_mode_button.deleteLater() + self.toolbar.cleanup() + super().cleanup() + + +if __name__ == "__main__": + import sys + + app = QApplication(sys.argv) + dispatcher = BECDispatcher(gui_id="ads") + window = BECMainWindowNoRPC() + ads = AdvancedDockArea(mode="developer", root_widget=True) + window.setCentralWidget(ads) + window.show() + window.resize(800, 600) + sys.exit(app.exec()) diff --git a/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py new file mode 100644 index 00000000..47fe1ddd --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/profile_utils.py @@ -0,0 +1,79 @@ +import os + +from PySide6QtAds import CDockWidget +from qtpy.QtCore import QSettings + +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 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..616dcc08 --- /dev/null +++ b/bec_widgets/widgets/containers/advanced_dock_area/toolbar_components/workspace_actions.py @@ -0,0 +1,183 @@ +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 +from bec_widgets.utils.toolbars.connections import BundleConnection +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + is_profile_readonly, + list_profiles, +) + + +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.""" + + 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(BundleConnection): + """ + Connection class for workspace actions in AdvancedDockArea. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + 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'.") + 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 + ) + self._connected = False + + @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..d6563a33 --- /dev/null +++ b/tests/unit_tests/test_advanced_dock_area.py @@ -0,0 +1,1051 @@ +# 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 +from qtpy.QtWidgets import QDialog, QMessageBox + +from bec_widgets.widgets.containers.advanced_dock_area.advanced_dock_area import ( + AdvancedDockArea, + DockSettingsDialog, + SaveProfileDialog, +) +from bec_widgets.widgets.containers.advanced_dock_area.profile_utils import ( + is_profile_readonly, + list_profiles, + open_settings, + profile_path, + 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 advanced_dock_area.mode == "developer" + 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_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, qtbot): + """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 + + qtbot.wait(200) + + assert len(advanced_dock_area.dock_list()) == initial_count + 1 + + 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_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_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", "terminal", "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() + if action_name == "terminal": + mock_new.assert_called_once_with( + widget="WebConsole", closable=True, startup_cmd=None + ) + else: + 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_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_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_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 + + +class TestModeSwitching: + """Test mode switching functionality.""" + + def test_mode_property_setter_valid_modes(self, advanced_dock_area): + """Test setting valid modes.""" + valid_modes = ["plot", "device", "utils", "developer", "user"] + + for mode in valid_modes: + advanced_dock_area.mode = mode + assert advanced_dock_area.mode == mode + + def test_mode_changed_signal_emission(self, advanced_dock_area, qtbot): + """Test that mode_changed signal is emitted when mode changes.""" + # Set up signal spy + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = "plot" + + # Check signal was emitted with correct argument + assert blocker.args == ["plot"] + + +class TestToolbarModeBundles: + """Test toolbar bundle creation and visibility for different modes.""" + + def test_flat_bundles_created(self, advanced_dock_area): + """Test that flat bundles are created during toolbar setup.""" + # Check that flat bundles exist + assert "flat_plots" in advanced_dock_area.toolbar.bundles + assert "flat_devices" in advanced_dock_area.toolbar.bundles + assert "flat_utils" in advanced_dock_area.toolbar.bundles + + def test_plot_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in plot mode.""" + advanced_dock_area.mode = "plot" + + # Should show only flat_plots bundle (and essential bundles in real implementation) + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_plots" in shown_bundles + + # Should not show other flat bundles + assert "flat_devices" not in shown_bundles + assert "flat_utils" not in shown_bundles + + # Should not show menu bundles + assert "menu_plots" not in shown_bundles + assert "menu_devices" not in shown_bundles + assert "menu_utils" not in shown_bundles + + def test_device_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in device mode.""" + advanced_dock_area.mode = "device" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_devices" in shown_bundles + + # Should not show other flat bundles + assert "flat_plots" not in shown_bundles + assert "flat_utils" not in shown_bundles + + def test_utils_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in utils mode.""" + advanced_dock_area.mode = "utils" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "flat_utils" in shown_bundles + + # Should not show other flat bundles + assert "flat_plots" not in shown_bundles + assert "flat_devices" not in shown_bundles + + def test_developer_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in developer mode.""" + advanced_dock_area.mode = "developer" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + + # Should show menu bundles + assert "menu_plots" in shown_bundles + assert "menu_devices" in shown_bundles + assert "menu_utils" in shown_bundles + + # Should show essential bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + def test_user_mode_toolbar_visibility(self, advanced_dock_area): + """Test toolbar bundle visibility in user mode.""" + advanced_dock_area.mode = "user" + + shown_bundles = advanced_dock_area.toolbar.shown_bundles + + # Should show only essential bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + # Should not show any widget creation bundles + assert "menu_plots" not in shown_bundles + assert "menu_devices" not in shown_bundles + assert "menu_utils" not in shown_bundles + assert "flat_plots" not in shown_bundles + assert "flat_devices" not in shown_bundles + assert "flat_utils" not in shown_bundles + + +class TestFlatToolbarActions: + """Test flat toolbar actions functionality.""" + + def test_flat_plot_actions_created(self, advanced_dock_area): + """Test that flat plot actions are created.""" + plot_actions = [ + "flat_waveform", + "flat_scatter_waveform", + "flat_multi_waveform", + "flat_image", + "flat_motor_map", + "flat_heatmap", + ] + + for action_name in plot_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_device_actions_created(self, advanced_dock_area): + """Test that flat device actions are created.""" + device_actions = ["flat_scan_control", "flat_positioner_box"] + + for action_name in device_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_utils_actions_created(self, advanced_dock_area): + """Test that flat utils actions are created.""" + utils_actions = [ + "flat_queue", + "flat_status", + "flat_progress_bar", + "flat_terminal", + "flat_bec_shell", + "flat_log_panel", + "flat_sbb_monitor", + ] + + for action_name in utils_actions: + assert advanced_dock_area.toolbar.components.exists(action_name) + + def test_flat_plot_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat plot actions trigger widget creation.""" + plot_action_mapping = { + "flat_waveform": "Waveform", + "flat_scatter_waveform": "ScatterWaveform", + "flat_multi_waveform": "MultiWaveform", + "flat_image": "Image", + "flat_motor_map": "MotorMap", + "flat_heatmap": "Heatmap", + } + + for action_name, widget_type in plot_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_device_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat device actions trigger widget creation.""" + device_action_mapping = { + "flat_scan_control": "ScanControl", + "flat_positioner_box": "PositionerBox", + } + + for action_name, widget_type in device_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_utils_actions_trigger_widget_creation(self, advanced_dock_area): + """Test flat utils actions trigger widget creation.""" + utils_action_mapping = { + "flat_queue": "BECQueue", + "flat_status": "BECStatusBox", + "flat_progress_bar": "RingProgressBar", + "flat_terminal": "WebConsole", + "flat_bec_shell": "WebConsole", + "flat_sbb_monitor": "SBBMonitor", + } + + for action_name, widget_type in utils_action_mapping.items(): + with patch.object(advanced_dock_area, "new") as mock_new: + action = advanced_dock_area.toolbar.components.get_action(action_name).action + + # Skip log_panel as it's disabled + if action_name == "flat_log_panel": + assert not action.isEnabled() + continue + + action.trigger() + mock_new.assert_called_once_with(widget=widget_type) + + def test_flat_log_panel_action_disabled(self, advanced_dock_area): + """Test that flat log panel action is disabled.""" + action = advanced_dock_area.toolbar.components.get_action("flat_log_panel").action + assert not action.isEnabled() + + +class TestModeTransitions: + """Test mode transitions and state consistency.""" + + def test_mode_transition_sequence(self, advanced_dock_area, qtbot): + """Test sequence of mode transitions.""" + modes = ["plot", "device", "utils", "developer", "user"] + + for mode in modes: + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = mode + + assert advanced_dock_area.mode == mode + assert blocker.args == [mode] + + def test_mode_consistency_after_multiple_changes(self, advanced_dock_area): + """Test mode consistency after multiple rapid changes.""" + # Rapidly change modes + advanced_dock_area.mode = "plot" + advanced_dock_area.mode = "device" + advanced_dock_area.mode = "utils" + advanced_dock_area.mode = "developer" + advanced_dock_area.mode = "user" + + # Final state should be consistent + assert advanced_dock_area.mode == "user" + + # Toolbar should show correct bundles for user mode + shown_bundles = advanced_dock_area.toolbar.shown_bundles + assert "spacer_bundle" in shown_bundles + assert "workspace" in shown_bundles + assert "dock_actions" in shown_bundles + + def test_toolbar_refresh_on_mode_change(self, advanced_dock_area): + """Test that toolbar is properly refreshed when mode changes.""" + initial_bundles = set(advanced_dock_area.toolbar.shown_bundles) + + # Change to a different mode + advanced_dock_area.mode = "plot" + plot_bundles = set(advanced_dock_area.toolbar.shown_bundles) + + # Bundles should be different + assert initial_bundles != plot_bundles + assert "flat_plots" in plot_bundles + + def test_mode_switching_preserves_existing_docks(self, advanced_dock_area, qtbot): + """Test that mode switching doesn't affect existing docked widgets.""" + # Create some widgets + advanced_dock_area.new("DarkModeButton") + advanced_dock_area.new("DarkModeButton") + qtbot.wait(200) + + initial_dock_count = len(advanced_dock_area.dock_list()) + initial_widget_count = len(advanced_dock_area.widget_list()) + + # Switch modes + advanced_dock_area.mode = "plot" + advanced_dock_area.mode = "device" + advanced_dock_area.mode = "user" + + # Dock and widget counts should remain the same + assert len(advanced_dock_area.dock_list()) == initial_dock_count + assert len(advanced_dock_area.widget_list()) == initial_widget_count + + +class TestModeProperty: + """Test mode property getter and setter behavior.""" + + def test_mode_property_getter(self, advanced_dock_area): + """Test mode property getter returns correct value.""" + # Set internal mode directly and test getter + advanced_dock_area._mode = "plot" + assert advanced_dock_area.mode == "plot" + + advanced_dock_area._mode = "device" + assert advanced_dock_area.mode == "device" + + def test_mode_property_setter_updates_internal_state(self, advanced_dock_area): + """Test mode property setter updates internal state.""" + advanced_dock_area.mode = "plot" + assert advanced_dock_area._mode == "plot" + + advanced_dock_area.mode = "utils" + assert advanced_dock_area._mode == "utils" + + def test_mode_property_setter_triggers_toolbar_update(self, advanced_dock_area): + """Test mode property setter triggers toolbar update.""" + with patch.object(advanced_dock_area.toolbar, "show_bundles") as mock_show_bundles: + advanced_dock_area.mode = "plot" + mock_show_bundles.assert_called_once() + + def test_multiple_mode_changes(self, advanced_dock_area, qtbot): + """Test multiple rapid mode changes.""" + modes = ["plot", "device", "utils", "developer", "user"] + + for i, mode in enumerate(modes): + with qtbot.waitSignal(advanced_dock_area.mode_changed, timeout=1000) as blocker: + advanced_dock_area.mode = mode + + assert advanced_dock_area.mode == mode + assert blocker.args == [mode]