From fe0fbd08418fd38482fc326cb57c6cee76fdc6b8 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 5 Sep 2025 01:12:17 +0200 Subject: [PATCH 1/2] PoC: GUI-native crosshair cursor [skip ci] Addresses #30515 --- lib/matplotlib/backends/backend_qt.py | 5 ++++- lib/matplotlib/backends/backend_qtagg.py | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index d0aded5fff63..e7cf684dce44 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -239,6 +239,7 @@ def __init__(self, figure=None): self.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent) self.setMouseTracking(True) + self.mouse_xy = (0, 0) self.resize(*self.get_width_height()) palette = QtGui.QPalette(QtGui.QColor("white")) @@ -344,8 +345,10 @@ def mouseDoubleClickEvent(self, event): def mouseMoveEvent(self, event): if self.figure is None: return + self.mouse_xy = self.mouseEventCoords(event) + self.repaint() MouseEvent("motion_notify_event", self, - *self.mouseEventCoords(event), + *self.mouse_xy, buttons=self._mpl_buttons(event.buttons()), modifiers=self._mpl_modifiers(), guiEvent=event)._process() diff --git a/lib/matplotlib/backends/backend_qtagg.py b/lib/matplotlib/backends/backend_qtagg.py index 256e50a3d1c3..2b0db1dc092b 100644 --- a/lib/matplotlib/backends/backend_qtagg.py +++ b/lib/matplotlib/backends/backend_qtagg.py @@ -68,6 +68,19 @@ def paintEvent(self, event): ctypes.c_long.from_address(id(buf)).value = 1 self._draw_rect_callback(painter) + + figx = self.mouse_xy[0] / rect.width() + figy = 1 - self.mouse_xy[1] / rect.height() + x0 = rect.left() + x1 = rect.left() + rect.width() + y0 = rect.top() + y1 = rect.top() + rect.height() + x = rect.left() + int(figx * rect.width()) + y = rect.top() + int(figy * rect.height()) + painter.setPen(QtGui.QPen(QtCore.Qt.GlobalColor.red)) + painter.drawLine(x0, y, x1, y) + painter.drawLine(x, y0, x, y1) + finally: painter.end() From 804b503ec1a66d4a6fb5ed243114698cf9376029 Mon Sep 17 00:00:00 2001 From: Tim Hoffmann <2836374+timhoffm@users.noreply.github.com> Date: Fri, 5 Sep 2025 02:17:32 +0200 Subject: [PATCH 2/2] Limit crosshair to axes region --- lib/matplotlib/backends/backend_qt.py | 29 ++++++++++++++++++++---- lib/matplotlib/backends/backend_qtagg.py | 22 ++++++++---------- 2 files changed, 35 insertions(+), 16 deletions(-) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index e7cf684dce44..055a92376387 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -1,3 +1,4 @@ +from collections import namedtuple import functools import os import sys @@ -213,6 +214,9 @@ def _timer_stop(self): self._timer.stop() +Crosshair = namedtuple('Crosshair', 'x, y, x0, x1, y0, y1') + + class FigureCanvasQT(FigureCanvasBase, QtWidgets.QWidget): required_interactive_framework = "qt" _timer_cls = TimerQT @@ -231,6 +235,7 @@ class FigureCanvasQT(FigureCanvasBase, QtWidgets.QWidget): def __init__(self, figure=None): _create_qApp() super().__init__(figure=figure) + self._crosshair = None self._draw_pending = False self._is_drawing = False @@ -239,7 +244,6 @@ def __init__(self, figure=None): self.setAttribute(QtCore.Qt.WidgetAttribute.WA_OpaquePaintEvent) self.setMouseTracking(True) - self.mouse_xy = (0, 0) self.resize(*self.get_width_height()) palette = QtGui.QPalette(QtGui.QColor("white")) @@ -306,6 +310,21 @@ def mouseEventCoords(self, pos=None): y = self.figure.bbox.height / self.device_pixel_ratio - pos.y() return x * self.device_pixel_ratio, y * self.device_pixel_ratio + def _update_crosshair(self, x, y): + previous_crosshair = self._crosshair + ax = self.inaxes((x, y)) + if ax is None: + self._crosshair = None + else: + bbox = ax.get_position() # in figure coords + x0 = int(bbox.x0 * self.width()) + x1 = int(bbox.x1 * self.width()) + y0 = int((1 - bbox.y0) * self.height()) + y1 = int((1 - bbox.y1) * self.height()) + self._crosshair = Crosshair(x, y, x0, x1, y0, y1) + needs_repaint = previous_crosshair is not None or self._crosshair is not None + return needs_repaint + def enterEvent(self, event): # Force querying of the modifiers, as the cached modifier state can # have been invalidated while the window was out of focus. @@ -345,10 +364,12 @@ def mouseDoubleClickEvent(self, event): def mouseMoveEvent(self, event): if self.figure is None: return - self.mouse_xy = self.mouseEventCoords(event) - self.repaint() + mouse_xy = self.mouseEventCoords(event) + needs_repaint = self._update_crosshair(*mouse_xy) + if needs_repaint: + self.repaint() MouseEvent("motion_notify_event", self, - *self.mouse_xy, + *mouse_xy, buttons=self._mpl_buttons(event.buttons()), modifiers=self._mpl_modifiers(), guiEvent=event)._process() diff --git a/lib/matplotlib/backends/backend_qtagg.py b/lib/matplotlib/backends/backend_qtagg.py index 2b0db1dc092b..4b33e507e31d 100644 --- a/lib/matplotlib/backends/backend_qtagg.py +++ b/lib/matplotlib/backends/backend_qtagg.py @@ -69,18 +69,16 @@ def paintEvent(self, event): self._draw_rect_callback(painter) - figx = self.mouse_xy[0] / rect.width() - figy = 1 - self.mouse_xy[1] / rect.height() - x0 = rect.left() - x1 = rect.left() + rect.width() - y0 = rect.top() - y1 = rect.top() + rect.height() - x = rect.left() + int(figx * rect.width()) - y = rect.top() + int(figy * rect.height()) - painter.setPen(QtGui.QPen(QtCore.Qt.GlobalColor.red)) - painter.drawLine(x0, y, x1, y) - painter.drawLine(x, y0, x, y1) - + if self._crosshair is not None: + x = self._crosshair.x + y = rect.height() - int(self._crosshair.y) + x0 = rect.left() + self._crosshair.x0 + x1 = rect.left() + self._crosshair.x1 + y0 = rect.top() + self._crosshair.y0 + y1 = rect.top() + self._crosshair.y1 + painter.setPen(QtGui.QPen(QtCore.Qt.GlobalColor.red)) + painter.drawLine(x0, y, x1, y) + painter.drawLine(x, y0, x, y1) finally: painter.end()