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