diff --git a/bec_widgets/utils/bec_plugin_helper.py b/bec_widgets/utils/bec_plugin_helper.py index 50071bd5..8cca803b 100644 --- a/bec_widgets/utils/bec_plugin_helper.py +++ b/bec_widgets/utils/bec_plugin_helper.py @@ -143,6 +143,15 @@ def get_plugin_designer_registry() -> dict[str, tuple[str, str]]: return {} +@lru_cache +def get_plugin_widget_icons() -> dict[str, str]: + """If there is a plugin repository installed, return the designer widget icon registry.""" + designer_module = get_plugin_designer_module() + if designer_module and hasattr(designer_module, "widget_icons"): + return designer_module.widget_icons + return {} + + def get_all_plugin_widgets() -> BECClassContainer: """If there is a plugin repository installed, load all widgets from it.""" if plugin := user_widget_plugin(): diff --git a/bec_widgets/widgets/containers/dock_area/dock_area.py b/bec_widgets/widgets/containers/dock_area/dock_area.py index fc19e7fe..e350aff3 100644 --- a/bec_widgets/widgets/containers/dock_area/dock_area.py +++ b/bec_widgets/widgets/containers/dock_area/dock_area.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from functools import lru_cache from typing import Literal, Mapping, Sequence import slugify @@ -20,7 +21,12 @@ from qtpy.QtWidgets import ( import bec_widgets.widgets.containers.qt_ads as QtAds from bec_widgets import BECWidget, SafeProperty, SafeSlot from bec_widgets.cli.designer_plugins import widget_icons +from bec_widgets.utils.bec_plugin_helper import ( + get_plugin_rpc_widget_registry, + get_plugin_widget_icons, +) from bec_widgets.utils.colors import apply_theme +from bec_widgets.utils.plugin_utils import get_rpc_widget_registry from bec_widgets.utils.rpc_decorator import rpc_timeout from bec_widgets.utils.rpc_widget_handler import widget_handler from bec_widgets.utils.toolbars.actions import ( @@ -74,6 +80,19 @@ PROFILE_STATE_KEYS = {key: SETTINGS_KEYS[key] for key in ("geom", "state", "ads_ StartupProfile = Literal["restore", "skip"] | str | None +@lru_cache +def _plugin_toolbar_actions() -> dict[str, tuple[str, str, str]]: + plugin_registry = get_plugin_rpc_widget_registry() + internal_registry = get_rpc_widget_registry() + plugin_icons = get_plugin_widget_icons() + + return { + widget_name: (plugin_icons.get(widget_name, "widgets"), f"Add {widget_name}", widget_name) + for widget_name in sorted(plugin_registry) + if widget_name not in internal_registry + } + + class BECDockArea(DockAreaWidget): RPC = True PLUGIN = False @@ -390,6 +409,10 @@ class BECDockArea(DockAreaWidget): _build_menu("menu_devices", "Add Device Control ", device_actions) _build_menu("menu_utils", "Add Utils ", util_actions) + plugin_actions = _plugin_toolbar_actions() + 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) @@ -460,14 +483,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): @@ -476,7 +501,8 @@ class BECDockArea(DockAreaWidget): # first two items not needed for this part for key, (_, _, widget_type) in mapping.items(): - act = menu.actions[key].action + toolbar_action = menu.actions[key] + act = toolbar_action.action if key == "terminal": act.triggered.connect( lambda _, t=widget_type: self.new(widget=t, closable=True, startup_cmd=None) @@ -487,12 +513,19 @@ class BECDockArea(DockAreaWidget): widget=t, closable=True, show_settings_action=False ) ) + elif menu_key == "menu_plugins": + dock_icon = toolbar_action.get_icon() + act.triggered.connect( + lambda _, t=widget_type, i=dock_icon: self.new(widget=t, dock_icon=i) + ) else: act.triggered.connect(lambda _, t=widget_type: self.new(widget=t)) _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(): @@ -1108,14 +1141,10 @@ class BECDockArea(DockAreaWidget): if mode_key == "user": bundles = ["spacer_bundle", "workspace", "dock_actions"] elif mode_key == "creator": - bundles = [ - "menu_plots", - "menu_devices", - "menu_utils", - "spacer_bundle", - "workspace", - "dock_actions", - ] + bundles = ["menu_plots", "menu_devices", "menu_utils"] + if "menu_plugins" in getattr(self, "_ACTION_MAPPINGS", {}): + bundles.append("menu_plugins") + bundles += ["spacer_bundle", "workspace", "dock_actions"] elif mode_key == "plot": bundles = ["flat_plots", "spacer_bundle", "workspace", "dock_actions"] elif mode_key == "device": diff --git a/tests/unit_tests/test_bec_plugin_helper.py b/tests/unit_tests/test_bec_plugin_helper.py index 522814a8..1e8fb0c3 100644 --- a/tests/unit_tests/test_bec_plugin_helper.py +++ b/tests/unit_tests/test_bec_plugin_helper.py @@ -2,7 +2,10 @@ from importlib.machinery import FileFinder, SourceFileLoader from types import ModuleType from unittest import mock -from bec_widgets.utils.bec_plugin_helper import _all_widgets_from_all_submods +from bec_widgets.utils.bec_plugin_helper import ( + _all_widgets_from_all_submods, + get_plugin_widget_icons, +) from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.plugin_utils import BECClassContainer, BECClassInfo @@ -69,3 +72,29 @@ def test_all_widgets_from_module_no_widgets(): widgets = _all_widgets_from_all_submods(module).as_dict() assert widgets == {} + + +def test_get_plugin_widget_icons_from_designer_module(): + designer_module = mock.MagicMock(spec=ModuleType) + designer_module.widget_icons = {"PluginWidget": "star"} + + get_plugin_widget_icons.cache_clear() + try: + with mock.patch( + "bec_widgets.utils.bec_plugin_helper.get_plugin_designer_module", + return_value=designer_module, + ): + assert get_plugin_widget_icons() == {"PluginWidget": "star"} + finally: + get_plugin_widget_icons.cache_clear() + + +def test_get_plugin_widget_icons_without_designer_module(): + get_plugin_widget_icons.cache_clear() + try: + with mock.patch( + "bec_widgets.utils.bec_plugin_helper.get_plugin_designer_module", return_value=None + ): + assert get_plugin_widget_icons() == {} + finally: + get_plugin_widget_icons.cache_clear() diff --git a/tests/unit_tests/test_dock_area.py b/tests/unit_tests/test_dock_area.py index 0046ad20..d030a869 100644 --- a/tests/unit_tests/test_dock_area.py +++ b/tests/unit_tests/test_dock_area.py @@ -11,6 +11,7 @@ from qtpy.QtGui import QPixmap from qtpy.QtWidgets import QDialog, QMessageBox, QWidget import bec_widgets.widgets.containers.dock_area.basic_dock_area as basic_dock_module +import bec_widgets.widgets.containers.dock_area.dock_area as dock_area_module import bec_widgets.widgets.containers.dock_area.profile_utils as profile_utils from bec_widgets.widgets.containers.dock_area.basic_dock_area import ( DockAreaWidget, @@ -68,6 +69,13 @@ def temp_profile_dir(): return os.environ["BECWIDGETS_PROFILE_DIR"] +@pytest.fixture +def clear_plugin_toolbar_actions_cache(): + dock_area_module._plugin_toolbar_actions.cache_clear() + yield + dock_area_module._plugin_toolbar_actions.cache_clear() + + @pytest.fixture def module_profile_factory(monkeypatch, tmp_path): """Provide a helper to create synthetic module-level (read-only) profiles.""" @@ -985,6 +993,130 @@ 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, clear_plugin_toolbar_actions_cache + ): + """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_plugin_rpc_widget_registry", + return_value={}, + ): + widget = BECDockArea(client=mocked_client, startup_profile="skip") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + assert not widget.toolbar.components.exists("menu_plugins") + assert "menu_plugins" not in widget.toolbar.bundles + + def test_plugin_menu_includes_available_plugins( + self, qtbot, mocked_client, clear_plugin_toolbar_actions_cache + ): + """Test that the plugin menu is shown when plugin widgets are available.""" + plugin_registry = { + "FakePluginWidget": ("fake_plugin.widgets.fake_plugin_widget", "FakePluginWidget") + } + with ( + patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_rpc_widget_registry", + return_value=plugin_registry, + ), + patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_widget_icons", + return_value={"FakePluginWidget": "star"}, + ), + ): + widget = BECDockArea(client=mocked_client, startup_profile="skip") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + assert widget.toolbar.components.exists("menu_plugins") + assert "menu_plugins" in widget.toolbar.bundles + assert "menu_plugins" in widget.toolbar.shown_bundles + + menu_plugins = widget.toolbar.components.get_action("menu_plugins") + assert "FakePluginWidget" in menu_plugins.actions + action = menu_plugins.actions["FakePluginWidget"] + assert action.icon_name == "star" + assert action.tooltip == "Add FakePluginWidget" + + def test_plugin_menu_ignores_builtin_name_collisions( + self, qtbot, mocked_client, clear_plugin_toolbar_actions_cache + ): + """Test that plugin widgets shadowed by built-ins are not added to the plugin menu.""" + plugin_registry = {"Waveform": ("fake_plugin.widgets.waveform", "Waveform")} + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_rpc_widget_registry", + return_value=plugin_registry, + ): + widget = BECDockArea(client=mocked_client, startup_profile="skip") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + assert not widget.toolbar.components.exists("menu_plugins") + assert "menu_plugins" not in widget.toolbar.bundles + + def test_plugin_menu_action_triggers_new( + self, qtbot, mocked_client, clear_plugin_toolbar_actions_cache + ): + """Test that clicking a plugin menu action creates a new dock with the plugin widget.""" + plugin_registry = { + "FakePluginWidget": ("fake_plugin.widgets.fake_plugin_widget", "FakePluginWidget") + } + with patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_rpc_widget_registry", + return_value=plugin_registry, + ): + widget = BECDockArea(client=mocked_client, startup_profile="skip") + 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() + assert mock_new.call_args.kwargs["widget"] == "FakePluginWidget" + assert not mock_new.call_args.kwargs["dock_icon"].isNull() + + def test_plugin_menu_action_applies_dock_icon( + self, qtbot, mocked_client, clear_plugin_toolbar_actions_cache + ): + """Test that clicking a plugin menu action passes the menu icon to the created dock.""" + plugin_registry = { + "FakePluginWidget": ("fake_plugin.widgets.fake_plugin_widget", "FakePluginWidget") + } + with ( + patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_rpc_widget_registry", + return_value=plugin_registry, + ), + patch( + "bec_widgets.widgets.containers.dock_area.dock_area.get_plugin_widget_icons", + return_value={"FakePluginWidget": "star"}, + ), + ): + widget = BECDockArea(client=mocked_client, startup_profile="skip") + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + + def create_fake_widget(widget_type, parent=None, object_name=None, **_kwargs): + plugin_widget = QWidget(parent=parent) + plugin_widget.setObjectName(object_name or widget_type) + return plugin_widget + + with ( + patch.object( + basic_dock_module.widget_handler, "create_widget", side_effect=create_fake_widget + ), + patch.object(widget, "_create_dock_from_spec") as mock_create_dock, + ): + menu_plugins = widget.toolbar.components.get_action("menu_plugins") + menu_plugins.actions["FakePluginWidget"].action.trigger() + + mock_create_dock.assert_called_once() + spec = mock_create_dock.call_args.args[0] + assert not spec.dock_icon.isNull() + class TestDockSettingsDialog: """Test dock settings dialog functionality."""