diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index 9390342a..3e6a948d 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -137,6 +137,9 @@ class Crosshair(QObject): self.pinned_v_line = None self.pinned_h_line = None self.pinned_pos = None + # Last emitted pin payload, kept so the pin can be re-announced to consumers + # after it is re-adopted: ("2d", payload) | ("1d", payload) | None. + self._pinned_emit = None self._pin_hit_radius_px = 12 # how close (scene px) a click counts as "on the pin" self.update_markers() self.check_log() @@ -648,8 +651,10 @@ class Crosshair(QObject): self._draw_pin(draw_pt[0], draw_pt[1]) self.pinned_pos = draw_pt if pinned_2d is not None: + self._pinned_emit = ("2d", pinned_2d) self.coordinatesPinned2D.emit(pinned_2d) - if pinned_1d is not None: + elif pinned_1d is not None: + self._pinned_emit = ("1d", pinned_1d) self.coordinatesPinned1D.emit(pinned_1d) def _draw_pin(self, x: float, y: float): @@ -715,6 +720,7 @@ class Crosshair(QObject): self.plot_item.removeItem(self.pinned_label) self.pinned_label = None self.pinned_pos = None + self._pinned_emit = None if had_pin: self.pinCleared.emit() @@ -733,6 +739,7 @@ class Crosshair(QObject): "h_line": self.pinned_h_line, "label": self.pinned_label, "pos": self.pinned_pos, + "emit": self._pinned_emit, } # Detach the removal callback: this crosshair is about to be deleted and a # detached pin has no owner to clear it through. @@ -744,6 +751,7 @@ class Crosshair(QObject): self.pinned_h_line = None self.pinned_label = None self.pinned_pos = None + self._pinned_emit = None return state def adopt_pin(self, state): @@ -759,10 +767,25 @@ class Crosshair(QObject): self.pinned_h_line = state.get("h_line") self.pinned_label = state.get("label") self.pinned_pos = state.get("pos") + self._pinned_emit = state.get("emit") # Rebind the removal callback to this crosshair (the previous owner is gone). if self.pinned_point is not None: self.pinned_point._on_remove = self.clear_pin + def reemit_pin(self): + """Re-announce the current pin's coordinates to consumers. + + Used after :meth:`adopt_pin` so dependent state (e.g. the image ROI panels' + frozen reference profiles) can be rebuilt for a pin that survived a toggle. + """ + if self._pinned_emit is None: + return + kind, payload = self._pinned_emit + if kind == "2d": + self.coordinatesPinned2D.emit(payload) + elif kind == "1d": + self.coordinatesPinned1D.emit(payload) + def _pin_hit(self, scene_pos) -> bool: """Return True if a click at ``scene_pos`` (scene pixels) lands on the pinned marker.""" if self.pinned_pos is None: diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index b6cf1c49..cc803bf1 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -658,6 +658,10 @@ class ImageBase(PlotBase): self.crosshair.coordinatesPinned2D.connect(self.pin_image_slices) self.crosshair.pinCleared.connect(self.clear_pinned_slices) self.image_updated.connect(self.update_image_slices) + # If a pin survived a previous toggle, rebuild its frozen profiles now that + # the consumer slots are connected again. + if self.crosshair.pinned_pos is not None: + self.crosshair.reemit_pin() else: self.unhook_crosshair() # Hide the ROI panels (the crosshair is destroyed, so its pin signals diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index ad12729e..5d6a9e19 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -1095,6 +1095,39 @@ def test_pin_survives_scan_reset(qtbot, mocked_client): assert bec_image_view.y_roi_pinned is not None +def test_pin_and_profiles_restored_after_roi_toggle(qtbot, mocked_client): + """Toggling crosshair-ROI off then on restores both the marker and its frozen profiles.""" + import numpy as np + + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + test_data = np.arange(25).reshape(5, 5) + bec_image_view.on_image_update_2d({"data": test_data}, {}) + switch = bec_image_view.toolbar.components.get_action("image_switch_crosshair") + switch.actions["crosshair_roi"].action.trigger() + qtbot.wait(50) + + bec_image_view.crosshair.set_pin(2.0, 3.0) + assert bec_image_view.x_roi_pinned is not None + pin_point = bec_image_view.crosshair.pinned_point + + # Toggle the ROI crosshair off: marker persists, frozen curves are cleared. + switch.actions["crosshair_roi"].action.trigger() + qtbot.wait(20) + assert bec_image_view.crosshair is None + assert bec_image_view._detached_pin is not None + assert bec_image_view.x_roi_pinned is None + + # Toggle back on: marker is re-adopted AND the frozen profiles are rebuilt. + switch.actions["crosshair_roi"].action.trigger() + qtbot.wait(20) + assert bec_image_view.crosshair is not None + assert bec_image_view.crosshair.pinned_point is pin_point + assert bec_image_view.x_roi_pinned is not None + assert bec_image_view.y_roi_pinned is not None + _, x_pinned = bec_image_view.x_roi_pinned.getData() + np.testing.assert_array_equal(x_pinned, test_data[:, 3]) + + def test_detached_pin_can_be_removed_with_right_click(qtbot, mocked_client, monkeypatch): """A pin left on the plot after disabling crosshair remains removable.""" from qtpy.QtWidgets import QMenu