From 34e432e0edf8b6e2c1429bfae4298c681a4557a6 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 19 Jun 2026 10:41:01 +0200 Subject: [PATCH] feat(crosshair): keep the pinned marker when the crosshair is toggled off --- bec_widgets/utils/crosshair.py | 45 +++++++++++++++ bec_widgets/widgets/plots/plot_base.py | 39 ++++++++++++- tests/unit_tests/test_crosshair.py | 21 +++++++ tests/unit_tests/test_image_view_next_gen.py | 46 +++++++++++++++- tests/unit_tests/test_plot_base_next_gen.py | 58 ++++++++++++++++++++ 5 files changed, 205 insertions(+), 4 deletions(-) diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index f6531c8e..a704418c 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -718,6 +718,51 @@ class Crosshair(QObject): if had_pin: self.pinCleared.emit() + def release_pin(self): + """Detach the pin from this crosshair *without* removing it from the plot. + + Used when the crosshair is unhooked (e.g. toggled off) but the pin should + stay on the plot. Returns a state dict that :meth:`adopt_pin` can use to + re-attach the same items to a future crosshair, or ``None`` if there is no pin. + """ + if self.pinned_pos is None: + return None + state = { + "point": self.pinned_point, + "v_line": self.pinned_v_line, + "h_line": self.pinned_h_line, + "label": self.pinned_label, + "pos": self.pinned_pos, + } + # Detach the removal callback: this crosshair is about to be deleted and a + # detached pin has no owner to clear it through. + if self.pinned_point is not None: + self.pinned_point._on_remove = None + # Drop our references without removing the graphics items from the plot. + self.pinned_point = None + self.pinned_v_line = None + self.pinned_h_line = None + self.pinned_label = None + self.pinned_pos = None + return state + + def adopt_pin(self, state): + """Re-attach a pin previously detached with :meth:`release_pin`. + + The graphics items are already on the plot item; this only restores the + references so the crosshair can manage (move / clear) the pin again. + """ + if not state: + return + self.pinned_point = state.get("point") + self.pinned_v_line = state.get("v_line") + self.pinned_h_line = state.get("h_line") + self.pinned_label = state.get("label") + self.pinned_pos = state.get("pos") + # 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 _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/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index 59f52d6a..89e93790 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -163,6 +163,9 @@ class PlotBase(BECWidget, QWidget): # PlotItem Addons self.plot_item.addLegend() self.crosshair = None + # Holds a pin that outlived its crosshair (e.g. crosshair toggled off) so it + # can be re-adopted when the crosshair is hooked again. + self._detached_pin = None self.fps_monitor = None self.fps_label = QLabel(alignment=Qt.AlignmentFlag.AlignRight) self._user_x_label = "" @@ -1086,10 +1089,25 @@ class PlotBase(BECWidget, QWidget): self.crosshair.coordinatesPinned1D.connect(self.crosshair_coordinates_pinned) self.crosshair.coordinatesPinned2D.connect(self.crosshair_coordinates_pinned) self.crosshair.pinCleared.connect(self.crosshair_pin_cleared) + # Re-adopt a pin that survived a previous toggle-off. + if self._detached_pin is not None: + self.crosshair.adopt_pin(self._detached_pin) + self._detached_pin = None - def unhook_crosshair(self) -> None: - """Unhook the crosshair from all plots.""" + def unhook_crosshair(self, preserve_pin: bool = True) -> None: + """Unhook the crosshair from all plots. + + Args: + preserve_pin (bool): When True (the default, e.g. the crosshair is toggled + off from the toolbar), keep any active pin on the plot so it survives + the toggle and is re-adopted on the next hook. When False (e.g. on + widget teardown), the pin is discarded as well. + """ if self.crosshair is not None: + if preserve_pin: + self._detached_pin = self.crosshair.release_pin() + if self._detached_pin is not None and self._detached_pin.get("point") is not None: + self._detached_pin["point"]._on_remove = self._discard_detached_pin self.crosshair.crosshairChanged.disconnect(self.crosshair_position_changed) self.crosshair.crosshairClicked.disconnect(self.crosshair_position_clicked) self.crosshair.coordinatesChanged1D.disconnect(self.crosshair_coordinates_changed) @@ -1102,6 +1120,21 @@ class PlotBase(BECWidget, QWidget): self.crosshair.cleanup() self.crosshair.deleteLater() self.crosshair = None + if not preserve_pin: + self._discard_detached_pin() + + def _discard_detached_pin(self) -> None: + """Remove a detached (crosshair-less) pin's items from the plot, if any.""" + if self._detached_pin is None: + return + had_pin = self._detached_pin.get("pos") is not None + for key in ("point", "v_line", "h_line", "label"): + item = self._detached_pin.get(key) + if item is not None: + self.plot_item.removeItem(item) + self._detached_pin = None + if had_pin: + self.crosshair_pin_cleared.emit() def toggle_crosshair(self, enabled: bool | None = None) -> None: """Toggle the crosshair on all plots. @@ -1152,7 +1185,7 @@ class PlotBase(BECWidget, QWidget): def cleanup(self): self.toolbar.cleanup() - self.unhook_crosshair() + self.unhook_crosshair(preserve_pin=False) self.unhook_fps_monitor(delete_label=True) self.tick_item.cleanup() self.arrow_item.cleanup() diff --git a/tests/unit_tests/test_crosshair.py b/tests/unit_tests/test_crosshair.py index 5dbf8c7a..1dfa75af 100644 --- a/tests/unit_tests/test_crosshair.py +++ b/tests/unit_tests/test_crosshair.py @@ -540,6 +540,27 @@ def test_log_mode_change_clears_pin(plot_widget_with_crosshair): assert crosshair.pinned_pos is None +def test_adopted_pin_rebinds_removal_callback(monkeypatch, plot_widget_with_crosshair): + """After release/adopt (crosshair toggled off/on) the pin's context-menu + removal must act on the adopting crosshair, not the deleted one.""" + from qtpy.QtWidgets import QMenu + + crosshair, _ = plot_widget_with_crosshair + crosshair.set_pin(2, 5) + + state = crosshair.release_pin() + # While detached there is no owner: the menu is disabled. + assert state["point"]._on_remove is None + + crosshair.adopt_pin(state) + assert crosshair.pinned_point is not None + + monkeypatch.setattr(QMenu, "exec_", lambda self, *a, **k: self.actions()[0]) + event = _FakeClickEvent(None, button=Qt.RightButton) + crosshair.pinned_point.mouseClickEvent(event) + assert crosshair.pinned_point is None + + def test_set_pin_2d_emits_pixel_coordinates(image_widget_with_crosshair): crosshair, _ = image_widget_with_crosshair pinned = [] diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 5e137d1c..fba0ba65 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -2,7 +2,7 @@ import numpy as np import pyqtgraph as pg import pytest from bec_lib.endpoints import MessageEndpoints -from qtpy.QtCore import QPointF +from qtpy.QtCore import QPointF, Qt from bec_widgets.widgets.plots.image.image import Image from tests.unit_tests.client_mocks import mocked_client @@ -25,6 +25,20 @@ def _set_signal_config( } +class _FakeClickEvent: + """Minimal stand-in for a pyqtgraph MouseClickEvent.""" + + def __init__(self, button=Qt.LeftButton): + self._button = button + self.accepted = False + + def button(self): + return self._button + + def accept(self): + self.accepted = True + + def test_initialization_defaults(qtbot, mocked_client): bec_image_view = create_widget(qtbot, Image, client=mocked_client) assert bec_image_view.color_map == "plasma" @@ -1056,6 +1070,36 @@ def test_pinned_roi_profiles_keep_style_on_theme_change(qtbot, mocked_client): assert bec_image_view.x_roi_curve.opts["pen"].color().name() == "#000000" +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 + + bec_image_view = create_widget(qtbot, Image, client=mocked_client) + bec_image_view.on_image_update_2d({"data": np.arange(25).reshape(5, 5)}, {}) + 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) + 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 + + pin_point = bec_image_view._detached_pin["point"] + cleared = [] + bec_image_view.crosshair_pin_cleared.connect(lambda: cleared.append(True)) + monkeypatch.setattr(QMenu, "exec_", lambda self, *a, **k: self.actions()[0]) + + event = _FakeClickEvent(button=Qt.RightButton) + pin_point.mouseClickEvent(event) + + assert bec_image_view._detached_pin is None + assert pin_point not in bec_image_view.plot_item.items + assert event.accepted is True + assert cleared == [True] + + ############################################## # Device selection toolbar sync ############################################## diff --git a/tests/unit_tests/test_plot_base_next_gen.py b/tests/unit_tests/test_plot_base_next_gen.py index ad67ba68..fa68222d 100644 --- a/tests/unit_tests/test_plot_base_next_gen.py +++ b/tests/unit_tests/test_plot_base_next_gen.py @@ -260,6 +260,64 @@ def test_crosshair_hook_unhook(qtbot, mocked_client): assert pb.crosshair is None +def test_pin_survives_crosshair_toggle_off(qtbot, mocked_client): + """Toggling the crosshair off keeps the pinned marker; toggling on re-adopts it.""" + pb = create_widget(qtbot, PlotBase, client=mocked_client) + pb.plot_item.plot(x=[1, 2, 3], y=[4, 5, 6], name="c") + + pb.hook_crosshair() + pb.crosshair.set_pin(2, 5) + pin_items = [ + pb.crosshair.pinned_point, + pb.crosshair.pinned_v_line, + pb.crosshair.pinned_h_line, + pb.crosshair.pinned_label, + ] + assert all(item is not None for item in pin_items) + + # Toggle the crosshair off: the live crosshair is gone but the pin remains. + pb.toggle_crosshair() + assert pb.crosshair is None + assert pb._detached_pin is not None + for item in pin_items: + assert item in pb.plot_item.items + + # Toggle back on: the new crosshair re-adopts the very same pin items. + pb.toggle_crosshair() + assert pb.crosshair is not None + assert pb._detached_pin is None + assert pb.crosshair.pinned_point is pin_items[0] + assert pb.crosshair.pinned_pos is not None + + # The re-adopted pin is fully managed again and can be cleared. + pb.crosshair.clear_pin() + assert pb.crosshair.pinned_point is None + for item in pin_items: + assert item not in pb.plot_item.items + + +def test_teardown_discards_detached_pin(qtbot, mocked_client): + """A pin left detached (crosshair off) is removed from the plot on teardown.""" + pb = create_widget(qtbot, PlotBase, client=mocked_client) + pb.plot_item.plot(x=[1, 2, 3], y=[4, 5, 6], name="c") + pb.hook_crosshair() + pb.crosshair.set_pin(2, 5) + pin_items = [ + pb.crosshair.pinned_point, + pb.crosshair.pinned_v_line, + pb.crosshair.pinned_h_line, + pb.crosshair.pinned_label, + ] + pb.toggle_crosshair() # detach the pin + assert pb._detached_pin is not None + + # Teardown (preserve_pin=False) must remove the detached pin's items. + pb.unhook_crosshair(preserve_pin=False) + assert pb._detached_pin is None + for item in pin_items: + assert item not in pb.plot_item.items + + def test_mouse_mode_switch_is_exclusive(qtbot, mocked_client): """The pan/rectangle mouse modes must be mutually exclusive and always one active.""" import pyqtgraph as pg