diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 39450ee32065..a5ccf46ed818 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -331,17 +331,21 @@ def leaveEvent(self, event): def mousePressEvent(self, event): button = self.buttond.get(event.button()) if button is not None and self.figure is not None: + mods = self._mpl_modifiers() MouseEvent("button_press_event", self, - *self.mouseEventCoords(event), button, - modifiers=self._mpl_modifiers(), + *self.mouseEventCoords(event), + self._remap_darwin_button(button, mods), + modifiers=mods, guiEvent=event)._process() def mouseDoubleClickEvent(self, event): button = self.buttond.get(event.button()) if button is not None and self.figure is not None: + mods = self._mpl_modifiers() MouseEvent("button_press_event", self, - *self.mouseEventCoords(event), button, dblclick=True, - modifiers=self._mpl_modifiers(), + *self.mouseEventCoords(event), + self._remap_darwin_button(button, mods), dblclick=True, + modifiers=mods, guiEvent=event)._process() def mouseMoveEvent(self, event): @@ -356,9 +360,11 @@ def mouseMoveEvent(self, event): def mouseReleaseEvent(self, event): button = self.buttond.get(event.button()) if button is not None and self.figure is not None: + mods = self._mpl_modifiers() MouseEvent("button_release_event", self, - *self.mouseEventCoords(event), button, - modifiers=self._mpl_modifiers(), + *self.mouseEventCoords(event), + self._remap_darwin_button(button, mods), + modifiers=mods, guiEvent=event)._process() def wheelEvent(self, event): @@ -424,6 +430,13 @@ def _mpl_buttons(buttons): return {button for mask, button in FigureCanvasQT.buttond.items() if _to_int(mask) & buttons} + @staticmethod + def _remap_darwin_button(button, mods): + # On macOS, Option+left-click emulates middle-click (mirrors _macosx.m). + if sys.platform == "darwin" and button == MouseButton.LEFT and 'alt' in mods: + return MouseButton.MIDDLE + return button + @staticmethod def _mpl_modifiers(modifiers=None, *, exclude=None): if modifiers is None: diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index ae24effe505f..fd2c29d10944 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -131,6 +131,43 @@ def on_key_press(event): assert result == answer +@pytest.mark.parametrize('backend', [ + # Note: the value is irrelevant; the important part is the marker. + pytest.param( + 'Qt5Agg', + marks=pytest.mark.backend('Qt5Agg', skip_on_importerror=True)), + pytest.param( + 'QtAgg', + marks=pytest.mark.backend('QtAgg', skip_on_importerror=True)), +]) +def test_macos_option_click_emulates_middle_button(backend, monkeypatch): + """ + Make a figure. + Send a mouse press and release event with the Alt/Option modifier held. + Catch the events. + Assert the button is remapped to middle on macOS, unchanged elsewhere. + """ + from matplotlib.backend_bases import MouseButton + from matplotlib.backends.qt_compat import QtCore, QtWidgets + + monkeypatch.setattr(QtWidgets.QApplication, "keyboardModifiers", + lambda self: QtCore.Qt.KeyboardModifier.AltModifier) + + class _MouseEvent: + def button(self): return QtCore.Qt.MouseButton.LeftButton + def position(self): return QtCore.QPointF(0, 0) # Qt6 + def pos(self): return QtCore.QPoint(0, 0) # Qt5 + + expected = MouseButton.MIDDLE if sys.platform == "darwin" else MouseButton.LEFT + results = [] + fig = plt.figure() + fig.canvas.mpl_connect('button_press_event', lambda e: results.append(e.button)) + fig.canvas.mpl_connect('button_release_event', lambda e: results.append(e.button)) + fig.canvas.mousePressEvent(_MouseEvent()) + fig.canvas.mouseReleaseEvent(_MouseEvent()) + assert results == [expected, expected] + + @pytest.mark.backend('QtAgg', skip_on_importerror=True) def test_device_pixel_ratio_change(): """ diff --git a/src/_macosx.m b/src/_macosx.m index 0de0540018a7..27783429c685 100755 --- a/src/_macosx.m +++ b/src/_macosx.m @@ -71,6 +71,8 @@ static bool keyChangeCapsLock = false; /* Keep track of the current mouse up/down state for open/closed cursor hand */ static bool leftMouseGrabbing = false; +/* Mouse button number set on press, Option/Ctrl remap effective left to 2/3 */ +static int effectiveLeftButton = 1; // Global variable to store the original SIGINT handler static PyOS_sighandler_t originalSigintAction = NULL; @@ -1597,6 +1599,7 @@ - (void)mouseDown:(NSEvent *)event [[NSCursor closedHandCursor] set]; } } + effectiveLeftButton = button; break; } case NSEventTypeOtherMouseDown: button = 2; break; @@ -1622,8 +1625,8 @@ - (void)mouseUp:(NSEvent *)event y = location.y * device_scale; switch ([event type]) { case NSEventTypeLeftMouseUp: + button = effectiveLeftButton; leftMouseGrabbing = false; - button = 1; if ([NSCursor currentCursor]==[NSCursor closedHandCursor]) [[NSCursor openHandCursor] set]; break;