diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index 7061f1f00..0e2e95eec 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -429,10 +429,10 @@ def mouse_moved(self, event=None, manual_pos=None): if event is None: return # nothing to do scene_pos = event[0] # SignalProxy bundle - if not self.plot_item.vb.sceneBoundingRect().contains(scene_pos): - return view_pos = self.plot_item.vb.mapSceneToView(scene_pos) x, y = view_pos.x(), view_pos.y() + if not self._is_within_view_range(x, y): + return # Update cross‑hair visuals self.v_line.setPos(x) @@ -493,8 +493,12 @@ def mouse_clicked(self, event): if event.button() != Qt.MouseButton.LeftButton: return self.update_markers() - if self.plot_item.vb.sceneBoundingRect().contains(event._scenePos): - mouse_point = self.plot_item.vb.mapSceneToView(event._scenePos) + scene_pos_getter = getattr(event, "scenePos", None) + if not callable(scene_pos_getter): + return + scene_pos = scene_pos_getter() + mouse_point = self.plot_item.vb.mapSceneToView(scene_pos) + if self._is_within_view_range(mouse_point.x(), mouse_point.y()): x, y = mouse_point.x(), mouse_point.y() scaled_x, scaled_y = self.scale_emitted_coordinates(mouse_point.x(), mouse_point.y()) self.crosshairClicked.emit((scaled_x, scaled_y)) @@ -545,6 +549,10 @@ def mouse_clicked(self, event): else: continue + def _is_within_view_range(self, x: float, y: float) -> bool: + x_range, y_range = self.plot_item.vb.viewRange() + return min(x_range) <= x <= max(x_range) and min(y_range) <= y <= max(y_range) + def _get_transformed_position( self, x: float, y: float, transform: QTransform ) -> tuple[QPointF, QPointF]: diff --git a/pyproject.toml b/pyproject.toml index 499fa7765..b2f8b45d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ "ophyd_devices~=1.29, >=1.29.1", "pydantic~=2.0", "pylsp-bec~=1.2", - "pyqtgraph==0.13.7", + "pyqtgraph~=0.14.0", "qtconsole~=5.5, >=5.5.1", # needed for jupyter console "qtmonaco~=0.8, >=0.8.1", "qtpy~=2.4", diff --git a/tests/unit_tests/test_crosshair.py b/tests/unit_tests/test_crosshair.py index 0012c9d46..c0d90de69 100644 --- a/tests/unit_tests/test_crosshair.py +++ b/tests/unit_tests/test_crosshair.py @@ -14,6 +14,18 @@ # pylint: disable = redefined-outer-name +class _FakeMouseClickEvent: + def __init__(self, scene_pos: QPointF, button: Qt.MouseButton = Qt.MouseButton.LeftButton): + self._scene_pos = scene_pos + self._button = button + + def button(self): + return self._button + + def scenePos(self): + return self._scene_pos + + @pytest.fixture def plot_widget_with_crosshair(qtbot): widget = pg.PlotWidget() @@ -22,6 +34,7 @@ def plot_widget_with_crosshair(qtbot): widget.plot(x=[1, 2, 3], y=[4, 5, 6], name="Curve 1") plot_item = widget.getPlotItem() + plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0) crosshair = Crosshair(plot_item=plot_item, precision=3) yield crosshair, plot_item @@ -38,20 +51,17 @@ def image_widget_with_crosshair(qtbot): widget.addItem(image_item) plot_item = widget.getPlotItem() + plot_item.vb.setRange(xRange=(0, 100), yRange=(0, 100), padding=0) crosshair = Crosshair(plot_item=plot_item, precision=3) yield crosshair, plot_item def test_mouse_moved_lines(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair - - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] + crosshair, _ = plot_widget_with_crosshair # Simulate mouse movement - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(2, 5)) # Check that the vertical line is indeed at x=2 assert np.isclose(crosshair.v_line.pos().x(), 2) @@ -59,7 +69,7 @@ def test_mouse_moved_lines(plot_widget_with_crosshair): def test_mouse_moved_signals(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair + crosshair, _ = plot_widget_with_crosshair emitted_values_1D = [] @@ -68,43 +78,40 @@ def slot(coordinates): crosshair.coordinatesChanged1D.connect(slot) - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(2, 5)) # Assert the expected behavior assert emitted_values_1D == [("Curve 1", 2, 5)] def test_mouse_moved_signals_outside(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair + crosshair, _ = plot_widget_with_crosshair # Create a slot that will store the emitted values as tuples emitted_values_1D = [] + emitted_positions = [] def slot(coordinates): emitted_values_1D.append(coordinates) # Connect the signal to the custom slot crosshair.coordinatesChanged1D.connect(slot) + crosshair.crosshairChanged.connect(emitted_positions.append) - # Simulate a mouse moved event at a specific position - pos_in_view = QPointF(22, 55) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - # Call the mouse_moved method - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(2, 5)) + emitted_positions.clear() + emitted_values_1D.clear() + crosshair.mouse_moved(manual_pos=(22, 55)) # Assert the expected behavior assert emitted_values_1D == [] + assert emitted_positions == [] + assert np.isclose(crosshair.v_line.pos().x(), 2) + assert np.isclose(crosshair.h_line.pos().y(), 5) def test_mouse_moved_signals_2D(image_widget_with_crosshair): - crosshair, plot_item = image_widget_with_crosshair - image_item = plot_item.items[0] + crosshair, _ = image_widget_with_crosshair emitted_values_2D = [] @@ -113,17 +120,16 @@ def slot(coordinates): crosshair.coordinatesChanged2D.connect(slot) - pos_in_view = QPointF(21.0, 55.0) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(21.0, 55.0)) assert emitted_values_2D == [("ImageItem", 21, 55)] -def test_mouse_moved_signals_2D_outside(image_widget_with_crosshair): +def test_mouse_moved_signals_2D_outside_image_bounds_clamps_inside_view_range( + image_widget_with_crosshair, +): crosshair, plot_item = image_widget_with_crosshair + plot_item.vb.setRange(xRange=(0, 300), yRange=(0, 600), padding=0) emitted_values_2D = [] @@ -132,23 +138,34 @@ def slot(coordinates): crosshair.coordinatesChanged2D.connect(slot) - pos_in_view = QPointF(220.0, 555.0) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] + crosshair.mouse_moved(manual_pos=(220.0, 555.0)) - crosshair.mouse_moved(event_mock) + assert emitted_values_2D == [("ImageItem", 99, 99)] - assert emitted_values_2D == [] +def test_mouse_moved_signals_2D_outside_view_range_ignored(image_widget_with_crosshair): + crosshair, _ = image_widget_with_crosshair -def test_marker_positions_after_mouse_move(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair + emitted_values_2D = [] + emitted_positions = [] + + crosshair.coordinatesChanged2D.connect(emitted_values_2D.append) + crosshair.crosshairChanged.connect(emitted_positions.append) - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] + crosshair.mouse_moved(manual_pos=(21.0, 55.0)) + emitted_positions.clear() + emitted_values_2D.clear() + crosshair.mouse_moved(manual_pos=(220.0, 555.0)) - crosshair.mouse_moved(event_mock) + assert emitted_values_2D == [] + assert emitted_positions == [] + assert np.isclose(crosshair.v_line.pos().x(), 21.0) + assert np.isclose(crosshair.h_line.pos().y(), 55.0) + + +def test_marker_positions_after_mouse_move(plot_widget_with_crosshair): + crosshair, _ = plot_widget_with_crosshair + crosshair.mouse_moved(manual_pos=(2, 5)) marker = crosshair.marker_moved_1d["Curve 1"] marker_x, marker_y = marker.getData() @@ -172,7 +189,7 @@ def test_scale_emitted_coordinates(plot_widget_with_crosshair): def test_crosshair_changed_signal(plot_widget_with_crosshair): - crosshair, plot_item = plot_widget_with_crosshair + crosshair, _ = plot_widget_with_crosshair emitted_positions = [] @@ -181,11 +198,7 @@ def slot(position): crosshair.crosshairChanged.connect(slot) - pos_in_view = QPointF(2, 5) - pos_in_scene = plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - - crosshair.mouse_moved(event_mock) + crosshair.mouse_moved(manual_pos=(2, 5)) x, y = emitted_positions[0] @@ -193,33 +206,33 @@ def slot(position): assert np.isclose(y, 5) -def test_crosshair_clicked_signal(qtbot, plot_widget_with_crosshair): +def test_crosshair_clicked_signal(plot_widget_with_crosshair): crosshair, plot_item = plot_widget_with_crosshair emitted_positions = [] + emitted_view_positions = [] def slot(position): emitted_positions.append(position) crosshair.crosshairClicked.connect(slot) + crosshair.positionClicked.connect(emitted_view_positions.append) - x_data = 2 - y_data = 5 - - # Map data coordinates to scene coordinates - pos_in_scene = plot_item.vb.mapViewToScene(QPointF(x_data, y_data)) - # Map scene coordinates to widget coordinates - graphics_view = plot_item.vb.scene().views()[0] - qtbot.waitExposed(graphics_view) - pos_in_widget = graphics_view.mapFromScene(pos_in_scene) + crosshair.is_log_x = True + crosshair.is_log_y = True + plot_item.vb.setRange(xRange=(0, 1), yRange=(0, 1), padding=0) - # Simulate mouse click - qtbot.mouseClick(graphics_view.viewport(), Qt.LeftButton, pos=pos_in_widget) + known_view_point = QPointF(np.log10(2), np.log10(5)) + pos_in_scene = plot_item.vb.mapViewToScene(known_view_point) + crosshair.mouse_clicked(_FakeMouseClickEvent(pos_in_scene)) x, y = emitted_positions[0] + view_x, view_y = emitted_view_positions[0] - assert np.isclose(round(x, 1), 2) - assert np.isclose(round(y, 1), 5) + assert np.isclose(x, 2) + assert np.isclose(y, 5) + assert np.isclose(view_x, known_view_point.x()) + assert np.isclose(view_y, known_view_point.y()) def test_update_coord_label_1D(plot_widget_with_crosshair): @@ -359,20 +372,17 @@ def test_ignore_invisible_curves_on_move(qtbot, mocked_client): c0 = wf.plot(x=[1, 2, 3], y=[1, 4, 9], name="Curve_0") c1 = wf.plot(x=[1, 2, 3], y=[2, 5, 10], name="Curve_1") wf.hook_crosshair() + wf.crosshair.plot_item.vb.setRange(xRange=(0, 4), yRange=(0, 10), padding=0) # # Simulate a mouse move at (2,5) - pos_in_view = QPointF(2, 5) - pos_in_scene = wf.plot_item.vb.mapViewToScene(pos_in_view) - event_mock = [pos_in_scene] - # 1) Both curves visible: expect markers for both wf.crosshair.clear_markers() - wf.crosshair.mouse_moved(event_mock) + wf.crosshair.mouse_moved(manual_pos=(2, 5)) assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0", "Curve_1"} # 2) Hide Curve B and repeat: only Curve_0 should remain c1.setVisible(False) wf.crosshair.clear_markers() - wf.crosshair.mouse_moved(event_mock) + wf.crosshair.mouse_moved(manual_pos=(2, 5)) qtbot.wait(200) assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0"}