diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 2052c9b9..9406560e 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -3480,6 +3480,34 @@ class MotorMap(RPCBase): Set Y-axis to log scale if True, linear if False. """ + @property + @rpc_call + def x_motor(self) -> "str": + """ + Name of the motor shown on the X axis. + """ + + @x_motor.setter + @rpc_call + def x_motor(self) -> "str": + """ + Name of the motor shown on the X axis. + """ + + @property + @rpc_call + def y_motor(self) -> "str": + """ + Name of the motor shown on the Y axis. + """ + + @y_motor.setter + @rpc_call + def y_motor(self) -> "str": + """ + Name of the motor shown on the Y axis. + """ + @property @rpc_call def legend_label_size(self) -> "int": @@ -3604,7 +3632,9 @@ class MotorMap(RPCBase): """ @rpc_call - def map(self, x_name: "str", y_name: "str", validate_bec: "bool" = True) -> "None": + def map( + self, x_name: "str", y_name: "str", validate_bec: "bool" = True, suppress_errors=False + ) -> "None": """ Set the x and y motor names. @@ -3612,6 +3642,7 @@ class MotorMap(RPCBase): x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. """ @rpc_call diff --git a/bec_widgets/widgets/plots/motor_map/motor_map.py b/bec_widgets/widgets/plots/motor_map/motor_map.py index e8947350..0df77920 100644 --- a/bec_widgets/widgets/plots/motor_map/motor_map.py +++ b/bec_widgets/widgets/plots/motor_map/motor_map.py @@ -17,7 +17,9 @@ from bec_widgets.utils.settings_dialog import SettingsDialog from bec_widgets.utils.toolbars.toolbar import MaterialIconAction from bec_widgets.widgets.plots.motor_map.settings.motor_map_settings import MotorMapSettings from bec_widgets.widgets.plots.motor_map.toolbar_components.motor_selection import ( - MotorSelectionAction, + MotorSelection, + MotorSelectionConnection, + motor_selection_bundle, ) from bec_widgets.widgets.plots.plot_base import PlotBase, UIMode @@ -126,6 +128,10 @@ class MotorMap(PlotBase): "x_log.setter", "y_log", "y_log.setter", + "x_motor", + "x_motor.setter", + "y_motor", + "y_motor.setter", "legend_label_size", "legend_label_size.setter", "attach", @@ -195,11 +201,10 @@ class MotorMap(PlotBase): """ Initialize the toolbar for the motor map widget. """ - motor_selection = MotorSelectionAction(parent=self) - self.toolbar.add_action("motor_selection", motor_selection) - - motor_selection.motor_x.currentTextChanged.connect(self.on_motor_selection_changed) - motor_selection.motor_y.currentTextChanged.connect(self.on_motor_selection_changed) + self.toolbar.add_bundle(motor_selection_bundle(self.toolbar.components)) + self.toolbar.connect_bundle( + "motor_selection", MotorSelectionConnection(self.toolbar.components, target_widget=self) + ) self.toolbar.components.get_action("reset_legend").action.setVisible(False) @@ -228,12 +233,19 @@ class MotorMap(PlotBase): if self.ui_mode == UIMode.POPUP: bundles.append("axis_popup") self.toolbar.show_bundles(bundles) + self._sync_motor_map_selection_toolbar() @SafeSlot() def on_motor_selection_changed(self, _): - action: MotorSelectionAction = self.toolbar.components.get_action("motor_selection") - motor_x = action.motor_x.currentText() - motor_y = action.motor_y.currentText() + action = self.toolbar.components.get_action("motor_selection") + motor_selection: MotorSelection = action.widget + motor_x = motor_selection.motor_x.currentText() + motor_y = motor_selection.motor_y.currentText() + + if motor_x and not self._validate_motor_name(motor_x): + return + if motor_y and not self._validate_motor_name(motor_y): + return if motor_x != "" and motor_y != "": if motor_x != self.config.x_motor.name or motor_y != self.config.y_motor.name: @@ -286,6 +298,36 @@ class MotorMap(PlotBase): # Widget Specific Properties ################################################################################ + @SafeProperty(str) + def x_motor(self) -> str: + """Name of the motor shown on the X axis.""" + return self.config.x_motor.name or "" + + @x_motor.setter + def x_motor(self, motor_name: str) -> None: + motor_name = motor_name or "" + if motor_name == (self.config.x_motor.name or ""): + return + if motor_name and self.y_motor: + self.map(motor_name, self.y_motor, suppress_errors=True) + return + self._set_motor_name(axis="x", motor_name=motor_name) + + @SafeProperty(str) + def y_motor(self) -> str: + """Name of the motor shown on the Y axis.""" + return self.config.y_motor.name or "" + + @y_motor.setter + def y_motor(self, motor_name: str) -> None: + motor_name = motor_name or "" + if motor_name == (self.config.y_motor.name or ""): + return + if motor_name and self.x_motor: + self.map(self.x_motor, motor_name, suppress_errors=True) + return + self._set_motor_name(axis="y", motor_name=motor_name) + # color_scatter for designer, color for CLI to not bother users with QColor @SafeProperty("QColor") def color_scatter(self) -> QtGui.QColor: @@ -427,11 +469,47 @@ class MotorMap(PlotBase): self.update_signal.emit() self.property_changed.emit("scatter_size", scatter_size) + def _validate_motor_name(self, motor_name: str) -> bool: + """ + Check motor validity against BEC without raising. + + Args: + motor_name(str): Name of the motor to validate. + + Returns: + bool: True if motor is valid, False otherwise. + """ + if not motor_name: + return False + try: + self.entry_validator.validate_signal(motor_name, None) + return True + except Exception: # noqa: BLE001 - validator can raise multiple error types + return False + + def _set_motor_name(self, axis: str, motor_name: str, *, sync_toolbar: bool = True) -> None: + """ + Update stored motor name for given axis and optionally refresh the toolbar selection. + """ + motor_name = motor_name or "" + motor_config = self.config.x_motor if axis == "x" else self.config.y_motor + + if motor_config.name == motor_name: + return + + motor_config.name = motor_name + self.property_changed.emit(f"{axis}_motor", motor_name) + + if sync_toolbar: + self._sync_motor_map_selection_toolbar() + ################################################################################ # High Level methods for API ################################################################################ @SafeSlot() - def map(self, x_name: str, y_name: str, validate_bec: bool = True) -> None: + def map( + self, x_name: str, y_name: str, validate_bec: bool = True, suppress_errors=False + ) -> None: """ Set the x and y motor names. @@ -439,15 +517,23 @@ class MotorMap(PlotBase): x_name(str): The name of the x motor. y_name(str): The name of the y motor. validate_bec(bool, optional): If True, validate the signal with BEC. Defaults to True. + suppress_errors(bool, optional): If True, suppress errors during validation. Defaults to False. Used for properties setting. If the validation fails, the changes are not applied. """ self.plot_item.clear() if validate_bec: - self.entry_validator.validate_signal(x_name, None) - self.entry_validator.validate_signal(y_name, None) + if suppress_errors: + try: + self.entry_validator.validate_signal(x_name, None) + self.entry_validator.validate_signal(y_name, None) + except Exception: + return + else: + self.entry_validator.validate_signal(x_name, None) + self.entry_validator.validate_signal(y_name, None) - self.config.x_motor.name = x_name - self.config.y_motor.name = y_name + self._set_motor_name(axis="x", motor_name=x_name, sync_toolbar=False) + self._set_motor_name(axis="y", motor_name=y_name, sync_toolbar=False) motor_x_limit = self._get_motor_limit(self.config.x_motor.name) motor_y_limit = self._get_motor_limit(self.config.y_motor.name) @@ -774,21 +860,24 @@ class MotorMap(PlotBase): """ Sync the motor map selection toolbar with the current motor map. """ - motor_selection = self.toolbar.components.get_action("motor_selection") + try: + motor_selection_action = self.toolbar.components.get_action("motor_selection") + except Exception: # noqa: BLE001 - toolbar might not be ready during early init + logger.warning(f"MotorMap ({self.object_name}) toolbar was not ready during init.") + return + if motor_selection_action is None: + return + motor_selection: MotorSelection = motor_selection_action.widget + target_x = self.config.x_motor.name or "" + target_y = self.config.y_motor.name or "" - motor_x = motor_selection.motor_x.currentText() - motor_y = motor_selection.motor_y.currentText() + if ( + motor_selection.motor_x.currentText() == target_x + and motor_selection.motor_y.currentText() == target_y + ): + return - if motor_x != self.config.x_motor.name: - motor_selection.motor_x.blockSignals(True) - motor_selection.motor_x.set_device(self.config.x_motor.name) - motor_selection.motor_x.check_validity(self.config.x_motor.name) - motor_selection.motor_x.blockSignals(False) - if motor_y != self.config.y_motor.name: - motor_selection.motor_y.blockSignals(True) - motor_selection.motor_y.set_device(self.config.y_motor.name) - motor_selection.motor_y.check_validity(self.config.y_motor.name) - motor_selection.motor_y.blockSignals(False) + motor_selection.set_motors(target_x, target_y) ################################################################################ # Export Methods diff --git a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py index a37c3f21..2307fa76 100644 --- a/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py +++ b/bec_widgets/widgets/plots/motor_map/toolbar_components/motor_selection.py @@ -1,43 +1,55 @@ -from qtpy.QtWidgets import QHBoxLayout, QToolBar, QWidget +from qtpy.QtWidgets import QHBoxLayout, QWidget -from bec_widgets.utils.toolbars.actions import NoCheckDelegate, ToolBarAction +from bec_widgets.utils.toolbars.actions import NoCheckDelegate, WidgetAction +from bec_widgets.utils.toolbars.bundles import ToolbarBundle, ToolbarComponents +from bec_widgets.utils.toolbars.connections import BundleConnection from bec_widgets.widgets.control.device_input.base_classes.device_input_base import BECDeviceFilter from bec_widgets.widgets.control.device_input.device_combobox.device_combobox import DeviceComboBox -class MotorSelectionAction(ToolBarAction): +class MotorSelection(QWidget): def __init__(self, parent=None): - super().__init__(icon_path=None, tooltip=None, checkable=False) - self.motor_x = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + super().__init__(parent=parent) + + self.motor_x = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_x.addItem("", None) self.motor_x.setCurrentText("") self.motor_x.setToolTip("Select Motor X") self.motor_x.setItemDelegate(NoCheckDelegate(self.motor_x)) - self.motor_y = DeviceComboBox(parent=parent, device_filter=[BECDeviceFilter.POSITIONER]) + self.motor_x.setEditable(True) + self.motor_y = DeviceComboBox(parent=self, device_filter=[BECDeviceFilter.POSITIONER]) self.motor_y.addItem("", None) self.motor_y.setCurrentText("") self.motor_y.setToolTip("Select Motor Y") self.motor_y.setItemDelegate(NoCheckDelegate(self.motor_y)) + self.motor_y.setEditable(True) - self.container = QWidget(parent) - layout = QHBoxLayout(self.container) + layout = QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) layout.addWidget(self.motor_x) layout.addWidget(self.motor_y) - self.container.setLayout(layout) - self.action = self.container - def add_to_toolbar(self, toolbar: QToolBar, target: QWidget): - """ - Adds the widget to the toolbar. - - Args: - toolbar (QToolBar): The toolbar to add the widget to. - target (QWidget): The target widget for the action. - """ - - toolbar.addWidget(self.container) + def set_motors(self, motor_x: str | None, motor_y: str | None) -> None: + """Set the displayed motors without emitting selection signals.""" + motor_x = motor_x or "" + motor_y = motor_y or "" + self.motor_x.blockSignals(True) + self.motor_y.blockSignals(True) + try: + if motor_x: + self.motor_x.set_device(motor_x) + self.motor_x.check_validity(motor_x) + else: + self.motor_x.setCurrentText("") + if motor_y: + self.motor_y.set_device(motor_y) + self.motor_y.check_validity(motor_y) + else: + self.motor_y.setCurrentText("") + finally: + self.motor_x.blockSignals(False) + self.motor_y.blockSignals(False) def cleanup(self): """ @@ -47,5 +59,57 @@ class MotorSelectionAction(ToolBarAction): self.motor_x.deleteLater() self.motor_y.close() self.motor_y.deleteLater() - self.container.close() - self.container.deleteLater() + + +def motor_selection_bundle(components: ToolbarComponents) -> ToolbarBundle: + """ + Creates a workspace toolbar bundle for MotorMap. + + Args: + components (ToolbarComponents): The components to be added to the bundle. + + Returns: + ToolbarBundle: The workspace toolbar bundle. + """ + + motor_selection_widget = MotorSelection(parent=components.toolbar) + components.add_safe( + "motor_selection", WidgetAction(widget=motor_selection_widget, adjust_size=False) + ) + + bundle = ToolbarBundle("motor_selection", components) + bundle.add_action("motor_selection") + return bundle + + +class MotorSelectionConnection(BundleConnection): + """ + Connection helper for the motor selection bundle. + """ + + def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) + self.bundle_name = "motor_selection" + self.components = components + self.target_widget = target_widget + self._connected = False + + def _widget(self) -> MotorSelection: + return self.components.get_action("motor_selection").widget + + def connect(self): + if self._connected: + return + widget = self._widget() + widget.motor_x.currentTextChanged.connect(self.target_widget.on_motor_selection_changed) + widget.motor_y.currentTextChanged.connect(self.target_widget.on_motor_selection_changed) + self._connected = True + + def disconnect(self): + if not self._connected: + return + widget = self._widget() + widget.motor_x.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed) + widget.motor_y.currentTextChanged.disconnect(self.target_widget.on_motor_selection_changed) + self._connected = False + widget.cleanup() diff --git a/tests/unit_tests/test_motor_map_next_gen.py b/tests/unit_tests/test_motor_map_next_gen.py index 277b3be1..4e296f63 100644 --- a/tests/unit_tests/test_motor_map_next_gen.py +++ b/tests/unit_tests/test_motor_map_next_gen.py @@ -1,5 +1,4 @@ -import numpy as np -import pyqtgraph as pg +from qtpy.QtTest import QSignalSpy from bec_widgets.widgets.plots.motor_map.motor_map import MotorMap from tests.unit_tests.client_mocks import mocked_client @@ -274,18 +273,74 @@ def test_motor_map_toolbar_selection(qtbot, mocked_client): # Verify toolbar bundle was created during initialization motor_selection = mm.toolbar.components.get_action("motor_selection") - motor_selection.motor_x.setCurrentText("samx") - motor_selection.motor_y.setCurrentText("samy") + motor_selection.widget.motor_x.setCurrentText("samx") + motor_selection.widget.motor_y.setCurrentText("samy") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samy" - motor_selection.motor_y.setCurrentText("samz") + motor_selection.widget.motor_y.setCurrentText("samz") assert mm.config.x_motor.name == "samx" assert mm.config.y_motor.name == "samz" +def test_motor_selection_set_motors_blocks_signals(qtbot, mocked_client): + """Ensure set_motors updates both comboboxes without emitting change signals.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + motor_selection = mm.toolbar.components.get_action("motor_selection").widget + + spy_x = QSignalSpy(motor_selection.motor_x.currentTextChanged) + spy_y = QSignalSpy(motor_selection.motor_y.currentTextChanged) + + motor_selection.set_motors("samx", "samy") + + assert motor_selection.motor_x.currentText() == "samx" + assert motor_selection.motor_y.currentText() == "samy" + assert spy_x.count() == 0 + assert spy_y.count() == 0 + + +def test_motor_properties_partial_then_complete_map(qtbot, mocked_client): + """Setting x then y via properties should map once both are valid.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + + spy = QSignalSpy(mm.property_changed) + mm.x_motor = "samx" + + assert mm.config.x_motor.name == "samx" + assert mm.config.y_motor.name is None + assert mm._trace is None # map not triggered yet + assert spy.at(0) == ["x_motor", "samx"] + + mm.y_motor = "samy" + + assert mm.config.x_motor.name == "samx" + assert mm.config.y_motor.name == "samy" + assert mm._trace is not None # map called once both valid + assert spy.at(1) == ["y_motor", "samy"] + assert len(mm._buffer["x"]) == 1 + assert len(mm._buffer["y"]) == 1 + + +def test_set_motor_name_emits_and_syncs_toolbar(qtbot, mocked_client): + """_set_motor_name should emit property changes and sync toolbar widgets.""" + mm = create_widget(qtbot, MotorMap, client=mocked_client) + motor_selection = mm.toolbar.components.get_action("motor_selection").widget + + spy = QSignalSpy(mm.property_changed) + mm._set_motor_name("x", "samx") + + assert mm.config.x_motor.name == "samx" + assert motor_selection.motor_x.currentText() == "samx" + assert spy.at(0) == ["x_motor", "samx"] + + # Calling with same name should be a no-op + initial_count = spy.count() + mm._set_motor_name("x", "samx") + assert spy.count() == initial_count + + def test_motor_map_settings_dialog(qtbot, mocked_client): """Test the settings dialog for the motor map.""" mm = create_widget(qtbot, MotorMap, client=mocked_client, popups=True)