diff --git a/bec_widgets/widgets/plots/heatmap/heatmap.py b/bec_widgets/widgets/plots/heatmap/heatmap.py index dc80269e..3266d9b7 100644 --- a/bec_widgets/widgets/plots/heatmap/heatmap.py +++ b/bec_widgets/widgets/plots/heatmap/heatmap.py @@ -1453,7 +1453,7 @@ class Heatmap(ImageBase): # Post Processing ################################################################################ - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def fft(self) -> bool: """ Whether FFT postprocessing is enabled. @@ -1470,7 +1470,7 @@ class Heatmap(ImageBase): """ self.main_image.fft = enable - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def log(self) -> bool: """ Whether logarithmic scaling is applied. @@ -1504,7 +1504,7 @@ class Heatmap(ImageBase): """ self.main_image.num_rotation_90 = value - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def transpose(self) -> bool: """ Whether the image is transposed. diff --git a/bec_widgets/widgets/plots/image/image.py b/bec_widgets/widgets/plots/image/image.py index a3c01be3..69968ac4 100644 --- a/bec_widgets/widgets/plots/image/image.py +++ b/bec_widgets/widgets/plots/image/image.py @@ -336,7 +336,7 @@ class Image(ImageBase): # Post Processing ################################################################################ - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def fft(self) -> bool: """ Whether FFT postprocessing is enabled. @@ -353,7 +353,7 @@ class Image(ImageBase): """ self.main_image.fft = enable - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def log(self) -> bool: """ Whether logarithmic scaling is applied. @@ -387,7 +387,7 @@ class Image(ImageBase): """ self.main_image.num_rotation_90 = value - @SafeProperty(bool) + @SafeProperty(bool, auto_emit=True) def transpose(self) -> bool: """ Whether the image is transposed. diff --git a/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py b/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py index 7d357944..6c8d63b6 100644 --- a/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py +++ b/bec_widgets/widgets/plots/image/toolbar_components/image_base_actions.py @@ -300,9 +300,14 @@ def image_processing(components: ToolbarComponents) -> ToolbarBundle: class ImageProcessingConnection(BundleConnection): """ Connection class for the image processing toolbar bundle. + + Provides bidirectional synchronization between toolbar actions and widget properties: + - Toolbar clicks → Update properties + - Property changes → Update toolbar (via property_changed signal) """ def __init__(self, components: ToolbarComponents, target_widget=None): + super().__init__(parent=components.toolbar) self.bundle_name = "image_processing" self.components = components self.target_widget = target_widget @@ -315,7 +320,6 @@ class ImageProcessingConnection(BundleConnection): raise AttributeError( "Target widget must implement 'fft', 'log', 'transpose', and 'num_rotation_90' attributes." ) - super().__init__() self.fft = components.get_action("image_processing_fft") self.log = components.get_action("image_processing_log") self.transpose = components.get_action("image_processing_transpose") @@ -324,6 +328,11 @@ class ImageProcessingConnection(BundleConnection): self.reset = components.get_action("image_processing_reset") self._connected = False + # Register property sync methods for bidirectional sync + self.register_checked_action_sync("fft", self.fft) + self.register_checked_action_sync("log", self.log) + self.register_checked_action_sync("transpose", self.transpose) + @SafeSlot() def toggle_fft(self): checked = self.fft.action.isChecked() @@ -367,8 +376,11 @@ class ImageProcessingConnection(BundleConnection): def connect(self): """ Connect the actions to the target widget's methods. + Enables bidirectional sync: toolbar ↔ properties. """ self._connected = True + + # Toolbar → Property connections self.fft.action.triggered.connect(self.toggle_fft) self.log.action.triggered.connect(self.toggle_log) self.transpose.action.triggered.connect(self.toggle_transpose) @@ -376,15 +388,25 @@ class ImageProcessingConnection(BundleConnection): self.left.action.triggered.connect(self.rotate_left) self.reset.action.triggered.connect(self.reset_settings) + # Property → Toolbar connections + self.connect_property_sync(self.target_widget) + def disconnect(self): """ Disconnect the actions from the target widget's methods. """ if not self._connected: return + + # Disconnect toolbar → property self.fft.action.triggered.disconnect(self.toggle_fft) self.log.action.triggered.disconnect(self.toggle_log) self.transpose.action.triggered.disconnect(self.toggle_transpose) self.right.action.triggered.disconnect(self.rotate_right) self.left.action.triggered.disconnect(self.rotate_left) self.reset.action.triggered.disconnect(self.reset_settings) + + # Disconnect property → toolbar + self.disconnect_property_sync(self.target_widget) + + self._connected = False diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index 908a16dd..88c23482 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -446,7 +446,7 @@ class PlotBase(BECWidget, QWidget): else: logger.warning(f"Property {key} not found.") - @SafeProperty(str, doc="The title of the axes.") + @SafeProperty(str, auto_emit=True, doc="The title of the axes.") def title(self) -> str: """ Set title of the plot. @@ -462,9 +462,8 @@ class PlotBase(BECWidget, QWidget): value(str): The title to set. """ self.plot_item.setTitle(value) - self.property_changed.emit("title", value) - @SafeProperty(str, doc="The text of the x label") + @SafeProperty(str, auto_emit=True, doc="The text of the x label") def x_label(self) -> str: """ The set label for the x-axis. @@ -481,7 +480,6 @@ class PlotBase(BECWidget, QWidget): """ self._user_x_label = value self._apply_x_label() - self.property_changed.emit("x_label", self._user_x_label) @property def x_label_suffix(self) -> str: @@ -535,7 +533,7 @@ class PlotBase(BECWidget, QWidget): if self.plot_item.getAxis("bottom").isVisible(): self.plot_item.setLabel("bottom", text=final_label) - @SafeProperty(str, doc="The text of the y label") + @SafeProperty(str, auto_emit=True, doc="The text of the y label") def y_label(self) -> str: """ The set label for the y-axis. @@ -551,7 +549,6 @@ class PlotBase(BECWidget, QWidget): """ self._user_y_label = value self._apply_y_label() - self.property_changed.emit("y_label", value) @property def y_label_suffix(self) -> str: @@ -772,7 +769,7 @@ class PlotBase(BECWidget, QWidget): """ self.y_limits = (self.y_lim[0], value) - @SafeProperty(bool, doc="Show grid on the x-axis.") + @SafeProperty(bool, auto_emit=True, doc="Show grid on the x-axis.") def x_grid(self) -> bool: """ Show grid on the x-axis. @@ -788,9 +785,8 @@ class PlotBase(BECWidget, QWidget): value(bool): The value to set. """ self.plot_item.showGrid(x=value) - self.property_changed.emit("x_grid", value) - @SafeProperty(bool, doc="Show grid on the y-axis.") + @SafeProperty(bool, auto_emit=True, doc="Show grid on the y-axis.") def y_grid(self) -> bool: """ Show grid on the y-axis. @@ -806,9 +802,8 @@ class PlotBase(BECWidget, QWidget): value(bool): The value to set. """ self.plot_item.showGrid(y=value) - self.property_changed.emit("y_grid", value) - @SafeProperty(bool, doc="Set X-axis to log scale if True, linear if False.") + @SafeProperty(bool, auto_emit=True, doc="Set X-axis to log scale if True, linear if False.") def x_log(self) -> bool: """ Set X-axis to log scale if True, linear if False. @@ -824,9 +819,8 @@ class PlotBase(BECWidget, QWidget): value(bool): The value to set. """ self.plot_item.setLogMode(x=value) - self.property_changed.emit("x_log", value) - @SafeProperty(bool, doc="Set Y-axis to log scale if True, linear if False.") + @SafeProperty(bool, auto_emit=True, doc="Set Y-axis to log scale if True, linear if False.") def y_log(self) -> bool: """ Set Y-axis to log scale if True, linear if False. @@ -842,9 +836,8 @@ class PlotBase(BECWidget, QWidget): value(bool): The value to set. """ self.plot_item.setLogMode(y=value) - self.property_changed.emit("y_log", value) - @SafeProperty(bool, doc="Show the outer axes of the plot widget.") + @SafeProperty(bool, auto_emit=True, doc="Show the outer axes of the plot widget.") def outer_axes(self) -> bool: """ Show the outer axes of the plot widget. @@ -863,9 +856,8 @@ class PlotBase(BECWidget, QWidget): self.plot_item.showAxis("right", value) self._outer_axes_visible = value - self.property_changed.emit("outer_axes", value) - @SafeProperty(bool, doc="Show inner axes of the plot widget.") + @SafeProperty(bool, auto_emit=True, doc="Show inner axes of the plot widget.") def inner_axes(self) -> bool: """ Show inner axes of the plot widget. @@ -886,7 +878,6 @@ class PlotBase(BECWidget, QWidget): self._inner_axes_visible = value self._apply_x_label() self._apply_y_label() - self.property_changed.emit("inner_axes", value) @SafeProperty(bool, doc="Invert X axis.") def invert_x(self) -> bool: @@ -1110,7 +1101,9 @@ class PlotBase(BECWidget, QWidget): self.unhook_crosshair() @SafeProperty( - int, doc="Minimum decimal places for crosshair when dynamic precision is enabled." + int, + auto_emit=True, + doc="Minimum decimal places for crosshair when dynamic precision is enabled.", ) def minimal_crosshair_precision(self) -> int: """ @@ -1130,7 +1123,6 @@ class PlotBase(BECWidget, QWidget): self._minimal_crosshair_precision = value_int if self.crosshair is not None: self.crosshair.min_precision = value_int - self.property_changed.emit("minimal_crosshair_precision", value_int) @SafeSlot() def reset(self) -> None: diff --git a/tests/unit_tests/test_heatmap_widget.py b/tests/unit_tests/test_heatmap_widget.py index 38a7b67a..fe8e0ee8 100644 --- a/tests/unit_tests/test_heatmap_widget.py +++ b/tests/unit_tests/test_heatmap_widget.py @@ -834,6 +834,24 @@ def test_device_properties_property_changed_signal(heatmap_widget): mock_handler.assert_any_call("x_device_name", "samx") +def test_auto_emit_syncs_heatmap_toolbar_actions(heatmap_widget): + from unittest.mock import Mock + + fft_action = heatmap_widget.toolbar.components.get_action("image_processing_fft").action + log_action = heatmap_widget.toolbar.components.get_action("image_processing_log").action + + mock_handler = Mock() + heatmap_widget.property_changed.connect(mock_handler) + + heatmap_widget.fft = True + heatmap_widget.log = True + + assert fft_action.isChecked() + assert log_action.isChecked() + mock_handler.assert_any_call("fft", True) + mock_handler.assert_any_call("log", True) + + def test_device_entry_validation_with_invalid_device(heatmap_widget): """Test that invalid device names are handled gracefully.""" # Try to set invalid device name diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 78e34705..48050251 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -249,6 +249,31 @@ def test_toolbar_actions_presence(qtbot, mocked_client): assert bec_image_view.toolbar.components.exists("image_dim_combo") +def test_auto_emit_syncs_image_toolbar_actions(qtbot, mocked_client): + from unittest.mock import Mock + + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + fft_action = bec_image_view.toolbar.components.get_action("image_processing_fft").action + log_action = bec_image_view.toolbar.components.get_action("image_processing_log").action + transpose_action = bec_image_view.toolbar.components.get_action( + "image_processing_transpose" + ).action + + mock_handler = Mock() + bec_image_view.property_changed.connect(mock_handler) + + bec_image_view.fft = True + bec_image_view.log = True + bec_image_view.transpose = True + + assert fft_action.isChecked() + assert log_action.isChecked() + assert transpose_action.isChecked() + mock_handler.assert_any_call("fft", True) + mock_handler.assert_any_call("log", True) + mock_handler.assert_any_call("transpose", True) + + def test_image_processing_fft_toggle(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) bec_image_view.fft = True