diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index 23ae5b68..adcdeb83 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -21,6 +21,7 @@ import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets.applications.views.view import ViewTourSteps from bec_widgets.utils.bec_dispatcher import BECDispatcher +from bec_widgets.utils.bec_plugin_helper import get_all_plugin_widgets from bec_widgets.utils.colors import apply_theme from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_widget_handler import widget_handler @@ -398,6 +399,19 @@ class BECDockArea(DockAreaWidget): _build_menu("menu_devices", "Add Device Control ", device_actions) _build_menu("menu_utils", "Add Utils ", util_actions) + # Build plugin widget menu (only shown when plugin widgets are available) + plugin_widgets_dict = get_all_plugin_widgets().as_dict() + plugin_actions: dict[str, tuple[str, str, str]] = { + widget_name: ( + getattr(widget_cls, "ICON_NAME", "widgets"), + f"Add {widget_name}", + widget_name, + ) + for widget_name, widget_cls in plugin_widgets_dict.items() + } + if plugin_actions: + _build_menu("menu_plugins", "Add Plugins ", plugin_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) @@ -468,14 +482,16 @@ class BECDockArea(DockAreaWidget): bda.add_action("dark_mode") self.toolbar.add_bundle(bda) - self._apply_toolbar_layout() - - # Store mappings on self for use in _hook_toolbar + # Store mappings on self for use in _hook_toolbar and _apply_toolbar_layout self._ACTION_MAPPINGS = { "menu_plots": plot_actions, "menu_devices": device_actions, "menu_utils": util_actions, } + if plugin_actions: + self._ACTION_MAPPINGS["menu_plugins"] = plugin_actions + + self._apply_toolbar_layout() def _hook_toolbar(self): def _connect_menu(menu_key: str): @@ -501,6 +517,8 @@ class BECDockArea(DockAreaWidget): _connect_menu("menu_plots") _connect_menu("menu_devices") _connect_menu("menu_utils") + if "menu_plugins" in self._ACTION_MAPPINGS: + _connect_menu("menu_plugins") def _connect_flat_actions(mapping: dict[str, tuple[str, str, str]]): for action_id, (_, _, widget_type) in mapping.items(): @@ -1109,6 +1127,10 @@ class BECDockArea(DockAreaWidget): "menu_plots", "menu_devices", "menu_utils", + ] + if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}): + bundles.append("menu_plugins") + bundles += [ "spacer_bundle", "workspace", "dock_actions", diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index 92fd076c..dc94c7a7 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -1024,6 +1024,89 @@ class TestToolbarFunctionality: # Verify save was called with the filename mock_screenshot.save.assert_called_once_with(str(screenshot_path)) + def test_plugin_menu_not_shown_when_no_plugins(self, qtbot, mocked_client): + """Test that the plugin menu is not shown when there are no plugin widgets.""" + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_all_plugin_widgets" + ) as mock_plugins: + from bec_widgets.utils.plugin_utils import BECClassContainer + + mock_plugins.return_value = BECClassContainer() + widget = BECDockArea(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + assert "menu_plugins" not in widget._ACTION_MAPPINGS + # Verify no "menu_plugins" bundle exists in the toolbar + assert "menu_plugins" not in widget.toolbar.bundles + + def test_plugin_menu_shown_when_plugins_available(self, qtbot, mocked_client): + """Test that the plugin menu is shown when plugin widgets are available.""" + from bec_widgets.utils.bec_widget import BECWidget + from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo + from qtpy.QtWidgets import QWidget as _QWidget + + class FakePluginWidget(BECWidget, _QWidget): + ICON_NAME = "star" + PLUGIN = True + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + + container = BECClassContainer( + [BECClassInfo(name="FakePluginWidget", module="fake", file="fake.py", obj=FakePluginWidget)] + ) + + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_all_plugin_widgets" + ) as mock_plugins: + mock_plugins.return_value = container + # Make as_dict() return our fake widget + container_mock = MagicMock() + container_mock.as_dict.return_value = {"FakePluginWidget": FakePluginWidget} + mock_plugins.return_value = container_mock + + widget = BECDockArea(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + assert "menu_plugins" in widget._ACTION_MAPPINGS + assert "FakePluginWidget" in widget._ACTION_MAPPINGS["menu_plugins"] + plugin_entry = widget._ACTION_MAPPINGS["menu_plugins"]["FakePluginWidget"] + assert plugin_entry[0] == "star" # icon name + assert plugin_entry[1] == "Add FakePluginWidget" # tooltip + assert plugin_entry[2] == "FakePluginWidget" # widget type name + assert "menu_plugins" in widget.toolbar.bundles + + def test_plugin_menu_action_triggers_new(self, qtbot, mocked_client): + """Test that clicking a plugin menu action creates a new dock with the plugin widget.""" + from bec_widgets.utils.bec_widget import BECWidget + from qtpy.QtWidgets import QWidget as _QWidget + + class FakePluginWidget(BECWidget, _QWidget): + ICON_NAME = "star" + PLUGIN = True + + def __init__(self, parent=None, **kwargs): + super().__init__(parent=parent, **kwargs) + + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_all_plugin_widgets" + ) as mock_plugins: + container_mock = MagicMock() + container_mock.as_dict.return_value = {"FakePluginWidget": FakePluginWidget} + mock_plugins.return_value = container_mock + + widget = BECDockArea(client=mocked_client) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + with patch.object(widget, "new") as mock_new: + menu_plugins = widget.toolbar.components.get_action("menu_plugins") + action = menu_plugins.actions["FakePluginWidget"].action + action.trigger() + mock_new.assert_called_once_with(widget="FakePluginWidget") + class TestDockSettingsDialog: """Test dock settings dialog functionality."""