Compare commits

...

1 Commits

Author SHA1 Message Date
wyzula_j d19b9c97e4 feat: BL plugin menu in BECDockArea 2026-05-12 14:46:37 +02:00
4 changed files with 212 additions and 13 deletions
+9
View File
@@ -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():
@@ -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":
+30 -1
View File
@@ -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()
+132
View File
@@ -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."""