From d6445ce462b38fef89920b91bde72a8d1ffc89b6 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Fri, 19 Jun 2026 10:24:36 +0200 Subject: [PATCH] feat(crosshair): add click-to-pin marker with frozen ROI profiles --- bec_widgets/utils/crosshair.py | 191 +++++++++++++++++- bec_widgets/widgets/plots/image/image_base.py | 98 ++++++++- .../widgets/plots/image/image_roi_plot.py | 3 + bec_widgets/widgets/plots/plot_base.py | 8 + tests/unit_tests/test_crosshair.py | 183 +++++++++++++++++ tests/unit_tests/test_image_view_next_gen.py | 60 ++++++ 6 files changed, 530 insertions(+), 13 deletions(-) diff --git a/bec_widgets/utils/crosshair.py b/bec_widgets/utils/crosshair.py index 7061f1f0..f6531c8e 100644 --- a/bec_widgets/utils/crosshair.py +++ b/bec_widgets/utils/crosshair.py @@ -7,7 +7,7 @@ import numpy as np import pyqtgraph as pg from qtpy.QtCore import QObject, QPointF, Qt, Signal from qtpy.QtGui import QCursor, QTransform -from qtpy.QtWidgets import QApplication +from qtpy.QtWidgets import QApplication, QMenu from bec_widgets.utils.error_popups import SafeSlot from bec_widgets.widgets.plots.image.image_item import ImageItem @@ -24,6 +24,31 @@ class CrosshairScatterItem(pg.ScatterPlotItem): pass +class PinScatterItem(CrosshairScatterItem): + """Scatter item for the pinned marker, owning its removal context menu. + + Right-clicks must be handled at the item level: item click events are + delivered before the ViewBox's, so accepting the event here is the only way + to keep pyqtgraph's plot context menu from opening on top of the pin menu. + """ + + def __init__(self, *args, on_remove=None, **kwargs): + super().__init__(*args, **kwargs) + self._on_remove = on_remove + + def mouseClickEvent(self, ev): + if ev.button() == Qt.MouseButton.RightButton and self._on_remove is not None: + ev.accept() + menu = QMenu() + remove_action = menu.addAction("Remove pinned marker") + chosen = menu.exec_(QCursor.pos()) + menu.deleteLater() + if chosen == remove_action: + self._on_remove() + return + super().mouseClickEvent(ev) + + class Crosshair(QObject): # QT Position of mouse cursor positionChanged = Signal(tuple) @@ -37,6 +62,10 @@ class Crosshair(QObject): # Signal for 2D plot coordinatesChanged2D = Signal(tuple) coordinatesClicked2D = Signal(tuple) + # Pinned marker signals (a persistent marker placed on click) + coordinatesPinned1D = Signal(tuple) + coordinatesPinned2D = Signal(tuple) + pinCleared = Signal() def __init__( self, @@ -101,6 +130,14 @@ class Crosshair(QObject): self.marker_clicked_1d = {} self.marker_2d_row = None self.marker_2d_col = None + # Pinned marker (a single persistent marker placed on click). The live + # crosshair keeps tracking the cursor; the pin stays until removed. + self.pinned_point = None + self.pinned_label = None + self.pinned_v_line = None + self.pinned_h_line = None + self.pinned_pos = None + self._pin_hit_radius_px = 12 # how close (scene px) a click counts as "on the pin" self.update_markers() self.check_log() self.check_derivatives() @@ -489,13 +526,16 @@ class Crosshair(QObject): event: The mouse clicked event """ - # we only accept left mouse clicks + # we only accept left mouse clicks; right-clicks on the pin are handled + # by the PinScatterItem itself (item events precede the ViewBox menu). 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) x, y = mouse_point.x(), mouse_point.y() + # Keep the raw click position; ``x``/``y`` are reused/snapped below. + pin_x, pin_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)) self.positionClicked.emit((x, y)) @@ -545,6 +585,147 @@ class Crosshair(QObject): else: continue + # Pinned marker: double-click clears it, clicking the existing pin + # removes it, otherwise (re)place a single pin at the clicked point. + if event.double(): + self.clear_pin() + elif self._pin_hit(event._scenePos): + self.clear_pin() + else: + self.set_pin(pin_x, pin_y, x_snap_values, y_snap_values) + + def set_pin(self, x: float, y: float, x_snap=None, y_snap=None): + """Place (or move) the single pinned marker at the data point nearest to (x, y). + + The pin is independent of the live crosshair: it stays in place until removed. + Emits :attr:`coordinatesPinned1D` / :attr:`coordinatesPinned2D` with the snapped + coordinates so consumers (e.g. the image ROI panels) can freeze a reference. + + Args: + x (float): The x-coordinate of the click, in plot coordinates. + y (float): The y-coordinate of the click, in plot coordinates. + x_snap, y_snap: Optionally pre-computed snap dictionaries (from + :meth:`snap_to_data`) to avoid recomputing them. + """ + # Make sure the tracked items are current (e.g. when called programmatically). + self.update_markers() + if x_snap is None or y_snap is None: + x_snap, y_snap = self.snap_to_data(x, y) + if x_snap is None or y_snap is None: + return + + precision = self._current_precision() + draw_pt = None + pinned_1d = None + pinned_2d = None + for item in self.items: + if isinstance(item, pg.PlotDataItem): + name = item.name() or str(id(item)) + sx, sy = x_snap.get(name), y_snap.get(name) + if sx is None or sy is None: + continue + draw_pt = (sx, sy) + sx_s, sy_s = self.scale_emitted_coordinates(sx, sy) + pinned_1d = (name, round(sx_s, precision), round(sy_s, precision)) + break + elif isinstance(item, pg.ImageItem): + name = item.objectName() or str(id(item)) + px, py = x_snap.get(name), y_snap.get(name) + if px is None or py is None: + continue + # Pin the marker at the pixel centre, mapped back into plot coordinates. + if isinstance(item, ImageItem) and item.image_transform is not None: + pt = item.image_transform.map(QPointF(px + 0.5, py + 0.5)) + draw_pt = (pt.x(), pt.y()) + else: + draw_pt = (px + 0.5, py + 0.5) + pinned_2d = (name, px, py) + break + + if draw_pt is None: + return + + self._draw_pin(draw_pt[0], draw_pt[1]) + self.pinned_pos = draw_pt + if pinned_2d is not None: + self.coordinatesPinned2D.emit(pinned_2d) + if pinned_1d is not None: + self.coordinatesPinned1D.emit(pinned_1d) + + def _draw_pin(self, x: float, y: float): + """Create/move the pinned marker at plot coordinates (x, y). + + The pin is drawn as a frozen crosshair: dashed infinite lines plus a small dot + and a coordinate label, so it is visually distinct from the live crosshair. + """ + pin_pen = pg.mkPen("#f2c037", width=1, style=Qt.PenStyle.DashLine) + if self.pinned_v_line is None: + self.pinned_v_line = pg.InfiniteLine(angle=90, movable=False, pen=pin_pen) + self.pinned_v_line.skip_auto_range = True + self.pinned_v_line.is_crosshair = True + self.pinned_v_line.setZValue(999) + self.plot_item.addItem(self.pinned_v_line, ignoreBounds=True) + if self.pinned_h_line is None: + self.pinned_h_line = pg.InfiniteLine(angle=0, movable=False, pen=pin_pen) + self.pinned_h_line.skip_auto_range = True + self.pinned_h_line.is_crosshair = True + self.pinned_h_line.setZValue(999) + self.plot_item.addItem(self.pinned_h_line, ignoreBounds=True) + self.pinned_v_line.setPos(x) + self.pinned_h_line.setPos(y) + + if self.pinned_point is None: + self.pinned_point = PinScatterItem( + size=9, + symbol="o", + pen=pg.mkPen("#3a2d00", width=1.5), + brush=pg.mkBrush("#f2c037"), + on_remove=self.clear_pin, + ) + self.pinned_point.skip_auto_range = True + self.pinned_point.is_crosshair = True + self.pinned_point.setZValue(1000) + self.plot_item.addItem(self.pinned_point) + self.pinned_point.setData([x], [y]) + + if self.pinned_label is None: + self.pinned_label = pg.TextItem("", anchor=(0, 1), color="#f2c037", fill=(0, 0, 0, 150)) + self.pinned_label.skip_auto_range = True + self.pinned_label.setZValue(1000) + self.plot_item.addItem(self.pinned_label) + x_scaled, y_scaled = self.scale_emitted_coordinates(x, y) + precision = self._current_precision() + self.pinned_label.setText(f"pin ({x_scaled:.{precision}f}, {y_scaled:.{precision}f})") + self.pinned_label.setPos(x, y) + self.pinned_label.setVisible(True) + + def clear_pin(self): + """Remove the pinned marker (if any) and notify consumers via :attr:`pinCleared`.""" + had_pin = self.pinned_pos is not None + if self.pinned_point is not None: + self.plot_item.removeItem(self.pinned_point) + self.pinned_point = None + if self.pinned_v_line is not None: + self.plot_item.removeItem(self.pinned_v_line) + self.pinned_v_line = None + if self.pinned_h_line is not None: + self.plot_item.removeItem(self.pinned_h_line) + self.pinned_h_line = None + if self.pinned_label is not None: + self.plot_item.removeItem(self.pinned_label) + self.pinned_label = None + self.pinned_pos = None + if had_pin: + self.pinCleared.emit() + + 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: + return False + pin_scene = self.plot_item.vb.mapViewToScene(QPointF(*self.pinned_pos)) + delta = pin_scene - scene_pos + return (delta.x() ** 2 + delta.y() ** 2) ** 0.5 <= self._pin_hit_radius_px + def _get_transformed_position( self, x: float, y: float, transform: QTransform ) -> tuple[QPointF, QPointF]: @@ -626,11 +807,16 @@ class Crosshair(QObject): self.is_log_x = self.plot_item.axes["bottom"]["item"].logMode self.is_log_y = self.plot_item.axes["left"]["item"].logMode self.clear_markers() + # The pin is stored in view coordinates, which change meaning when the + # axis scale changes; a stale pin would be drawn at the wrong data point. + self.clear_pin() def check_derivatives(self): """Checks if the derivatives are enabled and updates the internal state accordingly.""" self.is_derivative = self.plot_item.ctrl.derivativeCheck.isChecked() self.clear_markers() + # Same as check_log: the plotted data changes meaning, so drop the pin. + self.clear_pin() @SafeSlot() def reset(self): @@ -641,6 +827,7 @@ class Crosshair(QObject): if self.marker_2d_col is not None: self.plot_item.removeItem(self.marker_2d_col) self.marker_2d_col = None + self.clear_pin() self.clear_markers() def cleanup(self): diff --git a/bec_widgets/widgets/plots/image/image_base.py b/bec_widgets/widgets/plots/image/image_base.py index db8e783e..b6cf1c49 100644 --- a/bec_widgets/widgets/plots/image/image_base.py +++ b/bec_widgets/widgets/plots/image/image_base.py @@ -607,6 +607,13 @@ class ImageBase(PlotBase): self.x_roi.plot_item.setXLink(self.plot_item) self.y_roi = ImageROIPlot(parent=self) self.y_roi.plot_item.setYLink(self.plot_item) + + # Persistent live profile curves (updated in place so a pinned reference + # curve is not wiped on every crosshair move) and the frozen pinned curves. + self.x_roi_curve = None + self.y_roi_curve = None + self.x_roi_pinned = None + self.y_roi_pinned = None self.x_roi.apply_theme("dark") self.y_roi.apply_theme("dark") @@ -647,10 +654,15 @@ class ImageBase(PlotBase): self.side_panel_x.show_panel(self.x_panel_index) self.side_panel_y.show_panel(self.y_panel_index) self.crosshair.coordinatesChanged2D.connect(self.update_image_slices) + # Clicking pins a static copy of the current X/Y profiles for comparison. + self.crosshair.coordinatesPinned2D.connect(self.pin_image_slices) + self.crosshair.pinCleared.connect(self.clear_pinned_slices) self.image_updated.connect(self.update_image_slices) else: self.unhook_crosshair() - # Hide the ROI panels + # Hide the ROI panels (the crosshair is destroyed, so its pin signals + # disconnect automatically; just drop any frozen reference curves). + self.clear_pinned_slices() self.side_panel_x.hide_panel() self.side_panel_y.hide_panel() self.image_updated.disconnect(self.update_image_slices) @@ -678,14 +690,46 @@ class ImageBase(PlotBase): x = coordinates[1] y = coordinates[2] image_item = self.layer_manager["main"].image + slices = self._compute_image_slices(image_item, x, y) + if slices is None: + return + h_world_x, h_slice, v_world_y, v_slice = slices + + # Update the live curves in place. They are persistent (rather than + # clear()+plot() each time) so a pinned reference curve survives. + if self.x_roi_curve is None: + self.x_roi_curve = self.x_roi.plot_item.plot( + h_world_x, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3) + ) + else: + self.x_roi_curve.setData(h_world_x, h_slice) + + if self.y_roi_curve is None: + self.y_roi_curve = self.y_roi.plot_item.plot( + v_slice, v_world_y, pen=pg.mkPen(self.y_roi.curve_color, width=3) + ) + else: + self.y_roi_curve.setData(v_slice, v_world_y) + + def _compute_image_slices(self, image_item, row: int, col: int): + """Compute the horizontal/vertical profile slices through the image at (row, col). + + Args: + image_item: The image item to slice. + row (int): Pixel index along the image's first axis. + col (int): Pixel index along the image's second axis. + + Returns: + tuple | None: ``(h_world_x, h_slice, v_world_y, v_slice)`` in world coordinates, + or ``None`` if there is no image or the pixel is out of bounds. + """ image = image_item.image if image is None: - return + return None max_row, max_col = image.shape[0] - 1, image.shape[1] - 1 - row, col = x, y if not (0 <= row <= max_row and 0 <= col <= max_col): - return - # Horizontal slice + return None + # Horizontal slice (varies along the first axis at the fixed column) h_slice = image[:, col] x_pixel_indices = np.arange(h_slice.shape[0]) if image_item.image_transform is None: @@ -694,10 +738,7 @@ class ImageBase(PlotBase): h_world_x = [ image_item.image_transform.map(xi + 0.5, col + 0.5)[0] for xi in x_pixel_indices ] - self.x_roi.plot_item.clear() - self.x_roi.plot_item.plot(h_world_x, h_slice, pen=pg.mkPen(self.x_roi.curve_color, width=3)) - - # Vertical slice + # Vertical slice (varies along the second axis at the fixed row) v_slice = image[row, :] y_pixel_indices = np.arange(v_slice.shape[0]) if image_item.image_transform is None: @@ -706,8 +747,43 @@ class ImageBase(PlotBase): v_world_y = [ image_item.image_transform.map(row + 0.5, yi + 0.5)[1] for yi in y_pixel_indices ] - self.y_roi.plot_item.clear() - self.y_roi.plot_item.plot(v_slice, v_world_y, pen=pg.mkPen(self.y_roi.curve_color, width=3)) + return h_world_x, h_slice, v_world_y, v_slice + + @SafeSlot(tuple) + def pin_image_slices(self, coordinates: tuple): + """Freeze the X/Y profiles at the pinned pixel as static reference curves. + + Args: + coordinates (tuple): ``(name, row, col)`` pixel coordinates of the pin, as + emitted by the crosshair's ``coordinatesPinned2D`` signal. + """ + image_item = self.layer_manager["main"].image + slices = self._compute_image_slices(image_item, coordinates[1], coordinates[2]) + if slices is None: + return + h_world_x, h_slice, v_world_y, v_slice = slices + # Single pin: drop any previously frozen curves first. + self.clear_pinned_slices() + # Solid pen on purpose: Qt's dash stroker on a many-segment profile curve is + # 10-20x more expensive to paint than a solid stroke, which stalls the GUI + # thread on every image update while a pin is active. The straight pin + # InfiniteLines can stay dashed cheaply; these data curves cannot. + pen = pg.mkPen("#f2c037", width=2) + self.x_roi_pinned = self.x_roi.plot_item.plot(h_world_x, h_slice, pen=pen) + self.y_roi_pinned = self.y_roi.plot_item.plot(v_slice, v_world_y, pen=pen) + # Keep the frozen reference styling when the ROI plots re-pen on theme change. + self.x_roi_pinned.is_pinned_reference = True + self.y_roi_pinned.is_pinned_reference = True + + @SafeSlot() + def clear_pinned_slices(self): + """Remove the frozen pinned profile curves, if present.""" + if self.x_roi_pinned is not None: + self.x_roi.plot_item.removeItem(self.x_roi_pinned) + self.x_roi_pinned = None + if self.y_roi_pinned is not None: + self.y_roi.plot_item.removeItem(self.y_roi_pinned) + self.y_roi_pinned = None ################################################################################ # Widget Specific Properties diff --git a/bec_widgets/widgets/plots/image/image_roi_plot.py b/bec_widgets/widgets/plots/image/image_roi_plot.py index 7d32d6cf..629d5e20 100644 --- a/bec_widgets/widgets/plots/image/image_roi_plot.py +++ b/bec_widgets/widgets/plots/image/image_roi_plot.py @@ -26,6 +26,9 @@ class ImageROIPlot(RoundedFrame): else: self.curve_color = "k" for curve in self.plot_item.curves: + if getattr(curve, "is_pinned_reference", False): + # Frozen reference profiles keep their own (dashed) styling. + continue curve.setPen(pg.mkPen(self.curve_color, width=3)) super().apply_theme(theme) diff --git a/bec_widgets/widgets/plots/plot_base.py b/bec_widgets/widgets/plots/plot_base.py index c145b7b8..59f52d6a 100644 --- a/bec_widgets/widgets/plots/plot_base.py +++ b/bec_widgets/widgets/plots/plot_base.py @@ -116,6 +116,8 @@ class PlotBase(BECWidget, QWidget): crosshair_position_clicked = Signal(tuple) crosshair_coordinates_changed = Signal(tuple) crosshair_coordinates_clicked = Signal(tuple) + crosshair_coordinates_pinned = Signal(tuple) + crosshair_pin_cleared = Signal() def __init__( self, @@ -1081,6 +1083,9 @@ class PlotBase(BECWidget, QWidget): self.crosshair.coordinatesClicked1D.connect(self.crosshair_coordinates_clicked) self.crosshair.coordinatesChanged2D.connect(self.crosshair_coordinates_changed) self.crosshair.coordinatesClicked2D.connect(self.crosshair_coordinates_clicked) + self.crosshair.coordinatesPinned1D.connect(self.crosshair_coordinates_pinned) + self.crosshair.coordinatesPinned2D.connect(self.crosshair_coordinates_pinned) + self.crosshair.pinCleared.connect(self.crosshair_pin_cleared) def unhook_crosshair(self) -> None: """Unhook the crosshair from all plots.""" @@ -1091,6 +1096,9 @@ class PlotBase(BECWidget, QWidget): self.crosshair.coordinatesClicked1D.disconnect(self.crosshair_coordinates_clicked) self.crosshair.coordinatesChanged2D.disconnect(self.crosshair_coordinates_changed) self.crosshair.coordinatesClicked2D.disconnect(self.crosshair_coordinates_clicked) + self.crosshair.coordinatesPinned1D.disconnect(self.crosshair_coordinates_pinned) + self.crosshair.coordinatesPinned2D.disconnect(self.crosshair_coordinates_pinned) + self.crosshair.pinCleared.disconnect(self.crosshair_pin_cleared) self.crosshair.cleanup() self.crosshair.deleteLater() self.crosshair = None diff --git a/tests/unit_tests/test_crosshair.py b/tests/unit_tests/test_crosshair.py index 0012c9d4..5dbf8c7a 100644 --- a/tests/unit_tests/test_crosshair.py +++ b/tests/unit_tests/test_crosshair.py @@ -376,3 +376,186 @@ def test_ignore_invisible_curves_on_move(qtbot, mocked_client): wf.crosshair.mouse_moved(event_mock) qtbot.wait(200) assert set(wf.crosshair.marker_moved_1d.keys()) == {"Curve_0"} + + +############################################### +# Pinned marker (click to pin, remove gestures) +############################################### + + +class _FakeClickEvent: + """Minimal stand-in for a pyqtgraph MouseClickEvent for routing tests.""" + + def __init__(self, scene_pos, button=Qt.LeftButton, double=False): + self._scenePos = scene_pos + self._button = button + self._double = double + self.accepted = False + + def button(self): + return self._button + + def double(self): + return self._double + + def accept(self): + self.accepted = True + + +def test_set_pin_1d_creates_marker_and_emits(plot_widget_with_crosshair): + crosshair, _ = plot_widget_with_crosshair + pinned = [] + crosshair.coordinatesPinned1D.connect(pinned.append) + + assert crosshair.pinned_point is None + crosshair.set_pin(2, 5) + + assert crosshair.pinned_point is not None + assert crosshair.pinned_pos is not None + assert len(pinned) == 1 + _, px, py = pinned[0] + assert np.isclose(px, 2) and np.isclose(py, 5) + + +def test_pin_draws_dashed_crosshair_lines(plot_widget_with_crosshair): + crosshair, plot_item = plot_widget_with_crosshair + crosshair.set_pin(2, 5) + + # A frozen, dashed crosshair (two infinite lines) is drawn at the pin. + assert crosshair.pinned_v_line is not None + assert crosshair.pinned_h_line is not None + assert np.isclose(crosshair.pinned_v_line.value(), 2) + assert np.isclose(crosshair.pinned_h_line.value(), 5) + assert crosshair.pinned_v_line.pen.style() == Qt.PenStyle.DashLine + assert crosshair.pinned_v_line in plot_item.items + assert crosshair.pinned_h_line in plot_item.items + + crosshair.clear_pin() + assert crosshair.pinned_v_line is None + assert crosshair.pinned_h_line is None + + +def test_clear_pin_emits_only_when_pinned(plot_widget_with_crosshair): + crosshair, _ = plot_widget_with_crosshair + cleared = [] + crosshair.pinCleared.connect(lambda: cleared.append(True)) + + crosshair.set_pin(2, 5) + crosshair.clear_pin() + assert crosshair.pinned_point is None + assert crosshair.pinned_v_line is None + assert crosshair.pinned_h_line is None + assert crosshair.pinned_pos is None + assert cleared == [True] + + # Clearing again is a no-op and must not re-emit. + crosshair.clear_pin() + assert cleared == [True] + + +def test_single_click_pins(qtbot, plot_widget_with_crosshair): + crosshair, plot_item = plot_widget_with_crosshair + graphics_view = plot_item.vb.scene().views()[0] + qtbot.waitExposed(graphics_view) + pos = graphics_view.mapFromScene(plot_item.vb.mapViewToScene(QPointF(2, 5))) + + qtbot.mouseClick(graphics_view.viewport(), Qt.LeftButton, pos=pos) + assert crosshair.pinned_point is not None + assert crosshair.pinned_pos is not None + + +def test_reclick_on_pin_removes_it(plot_widget_with_crosshair): + crosshair, plot_item = plot_widget_with_crosshair + crosshair.set_pin(2, 5) + assert crosshair.pinned_point is not None + + # A left click that lands on the existing pin toggles it off. + scene_pos = plot_item.vb.mapViewToScene(QPointF(*crosshair.pinned_pos)) + crosshair.mouse_clicked(_FakeClickEvent(scene_pos, button=Qt.LeftButton)) + assert crosshair.pinned_point is None + + +def test_double_click_clears_pin(plot_widget_with_crosshair): + crosshair, plot_item = plot_widget_with_crosshair + crosshair.set_pin(2, 5) + assert crosshair.pinned_point is not None + + scene_pos = plot_item.vb.mapViewToScene(QPointF(2, 5)) + crosshair.mouse_clicked(_FakeClickEvent(scene_pos, double=True)) + assert crosshair.pinned_point is None + + +def test_right_click_on_pin_removes_via_menu(monkeypatch, plot_widget_with_crosshair): + """Right-clicks are handled by the pin item itself (accepting the event there + is what keeps pyqtgraph's plot context menu from opening on top).""" + from qtpy.QtWidgets import QMenu + + crosshair, _ = plot_widget_with_crosshair + crosshair.set_pin(2, 5) + assert crosshair.pinned_point is not None + + # Choose the (only) "Remove pinned marker" action without showing a real menu. + 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 + assert event.accepted is True + + +def test_right_click_on_pin_menu_cancelled_keeps_pin(monkeypatch, plot_widget_with_crosshair): + from qtpy.QtWidgets import QMenu + + crosshair, _ = plot_widget_with_crosshair + crosshair.set_pin(2, 5) + + monkeypatch.setattr(QMenu, "exec_", lambda self, *a, **k: None) # menu dismissed + event = _FakeClickEvent(None, button=Qt.RightButton) + crosshair.pinned_point.mouseClickEvent(event) + + assert crosshair.pinned_point is not None + assert event.accepted is True + + +def test_right_click_without_pin_is_ignored(plot_widget_with_crosshair): + crosshair, plot_item = plot_widget_with_crosshair + scene_pos = plot_item.vb.mapViewToScene(QPointF(2, 5)) + event = _FakeClickEvent(scene_pos, button=Qt.RightButton) + crosshair.mouse_clicked(event) + # No pin -> nothing handled at scene level, the event is left for + # pyqtgraph's own context menu. + assert event.accepted is False + + +def test_log_mode_change_clears_pin(plot_widget_with_crosshair): + """The pin lives in view coordinates; toggling log mode must drop it instead + of leaving it at a now-meaningless position.""" + crosshair, plot_item = plot_widget_with_crosshair + crosshair.set_pin(2, 5) + assert crosshair.pinned_point is not None + + plot_item.ctrl.logYCheck.setChecked(True) + + assert crosshair.pinned_point is None + assert crosshair.pinned_pos is None + + +def test_set_pin_2d_emits_pixel_coordinates(image_widget_with_crosshair): + crosshair, _ = image_widget_with_crosshair + pinned = [] + crosshair.coordinatesPinned2D.connect(pinned.append) + + crosshair.set_pin(40, 60) + assert crosshair.pinned_point is not None + assert len(pinned) == 1 + _, px, py = pinned[0] + assert (px, py) == (40, 60) + + +def test_reset_clears_pin(image_widget_with_crosshair): + crosshair, _ = image_widget_with_crosshair + crosshair.set_pin(40, 60) + assert crosshair.pinned_point is not None + crosshair.reset() + assert crosshair.pinned_point is None + assert crosshair.pinned_pos is None diff --git a/tests/unit_tests/test_image_view_next_gen.py b/tests/unit_tests/test_image_view_next_gen.py index 16ca82c2..5e137d1c 100644 --- a/tests/unit_tests/test_image_view_next_gen.py +++ b/tests/unit_tests/test_image_view_next_gen.py @@ -996,6 +996,66 @@ def test_roi_plot_data_from_image(qtbot, mocked_client): np.testing.assert_array_equal(h_slice, test_data[2]) +def test_pinned_roi_profiles_freeze_and_clear(qtbot, mocked_client): + """Clicking pins a frozen copy of the X/Y profiles that survives live updates.""" + 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) + + assert bec_image_view.x_roi_pinned is None + assert bec_image_view.y_roi_pinned is None + + # Pin at pixel (row=2, col=3) -> freezes the corresponding profiles. + bec_image_view.crosshair.set_pin(2.0, 3.0) + 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]) + y_pinned, _ = bec_image_view.y_roi_pinned.getData() + np.testing.assert_array_equal(y_pinned, test_data[2]) + + # A live update at another location must NOT wipe the frozen pinned curves. + pinned_item = bec_image_view.x_roi_pinned + bec_image_view.update_image_slices((0, 1, 1)) + assert bec_image_view.x_roi_pinned is pinned_item + assert pinned_item in bec_image_view.x_roi.plot_item.listDataItems() + + # Clearing the pin removes the frozen curves. + bec_image_view.crosshair.clear_pin() + assert bec_image_view.x_roi_pinned is None + assert bec_image_view.y_roi_pinned is None + assert pinned_item not in bec_image_view.x_roi.plot_item.listDataItems() + + +def test_pinned_roi_profiles_keep_style_on_theme_change(qtbot, mocked_client): + """Theme changes re-pen the live profile curves but must not restyle the + frozen (amber) pinned reference curves.""" + import numpy as np + + 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) + bec_image_view.update_image_slices((0, 2, 3)) # ensure the live curve exists + pinned = bec_image_view.x_roi_pinned + assert pinned.opts["pen"].color().name() == "#f2c037" + + bec_image_view.x_roi.apply_theme("light") + + # The frozen reference keeps its amber styling, the live curve is re-penned. + assert pinned.opts["pen"].color().name() == "#f2c037" + assert bec_image_view.x_roi_curve.opts["pen"].color().name() == "#000000" + + ############################################## # Device selection toolbar sync ##############################################