mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-07-29 06:33:04 +02:00
feat(crosshair): keep the pinned marker when the crosshair is toggled off
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
##############################################
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user