From 78bece9e2a6a4617c303e5a6e61e667a09b10bc4 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Mon, 4 Nov 2024 11:41:53 +0100 Subject: [PATCH] Check pressed mouse buttons in pan/zoom drag handlers. Sometimes, the mouse_release_event ending a pan/zoom can be lost, if it occurs while the canvas does not have focus (a typical case is when a context menu is implemented on top of the canvas, see example below); this can result in rather confusing behavior as the pan/zoom continues which no mouse button is pressed. To fix this, always check that the correct button is still pressed in the motion_notify_event handlers. To test, use e.g. ``` from matplotlib import pyplot as plt from matplotlib.backends.qt_compat import QtWidgets def on_button_press(event): if event.button != 3: # Right-click. return menu = QtWidgets.QMenu() menu.addAction("Some menu action", lambda: None) menu.exec(event.guiEvent.globalPosition().toPoint()) fig = plt.figure() fig.canvas.mpl_connect("button_press_event", on_button_press) fig.add_subplot() plt.show() ``` enter pan/zoom mode, right-click to open the context menu, exit the menu, and continue moving the mouse. --- lib/matplotlib/backend_bases.py | 32 +++++++++++++++---- lib/matplotlib/backend_bases.pyi | 2 +- lib/matplotlib/tests/test_backend_bases.py | 17 +++++++--- lib/mpl_toolkits/mplot3d/tests/test_axes3d.py | 6 ++-- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index d39fc0a1288b..9b9cfe3ccc76 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -3059,6 +3059,11 @@ def press_pan(self, event): def drag_pan(self, event): """Callback for dragging in pan/zoom mode.""" + if event.buttons != {self._pan_info.button}: + # Zoom ended while canvas not in focus (it did not receive a + # button_release_event); cancel it. + self.release_pan(None) # release_pan doesn't actually use event. + return for ax in self._pan_info.axes: # Using the recorded button at the press is safer than the current # button, as multiple buttons can get pressed during motion. @@ -3092,7 +3097,7 @@ def zoom(self, *args): for a in self.canvas.figure.get_axes(): a.set_navigate_mode(self.mode._navigate_mode) - _ZoomInfo = namedtuple("_ZoomInfo", "direction start_xy axes cid cbar") + _ZoomInfo = namedtuple("_ZoomInfo", "button start_xy axes cid cbar") def press_zoom(self, event): """Callback for mouse button press in zoom to rect mode.""" @@ -3117,11 +3122,17 @@ def press_zoom(self, event): cbar = None self._zoom_info = self._ZoomInfo( - direction="in" if event.button == 1 else "out", - start_xy=(event.x, event.y), axes=axes, cid=id_zoom, cbar=cbar) + button=event.button, start_xy=(event.x, event.y), axes=axes, + cid=id_zoom, cbar=cbar) def drag_zoom(self, event): """Callback for dragging in zoom mode.""" + if event.buttons != {self._zoom_info.button}: + # Zoom ended while canvas not in focus (it did not receive a + # button_release_event); cancel it. + self._cleanup_post_zoom() + return + start_xy = self._zoom_info.start_xy ax = self._zoom_info.axes[0] (x1, y1), (x2, y2) = np.clip( @@ -3150,6 +3161,7 @@ def release_zoom(self, event): self.remove_rubberband() start_x, start_y = self._zoom_info.start_xy + direction = "in" if self._zoom_info.button == 1 else "out" key = event.key # Force the key on colorbars to ignore the zoom-cancel on the # short-axis side @@ -3161,8 +3173,7 @@ def release_zoom(self, event): # "cancel" a zoom action by zooming by less than 5 pixels. if ((abs(event.x - start_x) < 5 and key != "y") or (abs(event.y - start_y) < 5 and key != "x")): - self.canvas.draw_idle() - self._zoom_info = None + self._cleanup_post_zoom() return for i, ax in enumerate(self._zoom_info.axes): @@ -3174,11 +3185,18 @@ def release_zoom(self, event): for prev in self._zoom_info.axes[:i]) ax._set_view_from_bbox( (start_x, start_y, event.x, event.y), - self._zoom_info.direction, key, twinx, twiny) + direction, key, twinx, twiny) + + self._cleanup_post_zoom() + self.push_current() + def _cleanup_post_zoom(self): + # We don't check the event button here, so that zooms can be cancelled + # by (pressing and) releasing another mouse button. + self.canvas.mpl_disconnect(self._zoom_info.cid) + self.remove_rubberband() self.canvas.draw_idle() self._zoom_info = None - self.push_current() def push_current(self): """Push the current view limits and position onto the stack.""" diff --git a/lib/matplotlib/backend_bases.pyi b/lib/matplotlib/backend_bases.pyi index 8089bb49e597..694bb2e13f32 100644 --- a/lib/matplotlib/backend_bases.pyi +++ b/lib/matplotlib/backend_bases.pyi @@ -429,7 +429,7 @@ class NavigationToolbar2: def zoom(self, *args) -> None: ... class _ZoomInfo(NamedTuple): - direction: Literal["in", "out"] + button: MouseButton start_xy: tuple[float, float] axes: list[Axes] cid: int diff --git a/lib/matplotlib/tests/test_backend_bases.py b/lib/matplotlib/tests/test_backend_bases.py index 3e1f524ed1c9..6635acf135ba 100644 --- a/lib/matplotlib/tests/test_backend_bases.py +++ b/lib/matplotlib/tests/test_backend_bases.py @@ -260,6 +260,8 @@ def test_interactive_colorbar(plot_func, orientation, tool, button, expected): # Set up the mouse movements start_event = MouseEvent( "button_press_event", fig.canvas, *s0, button) + drag_event = MouseEvent( + "motion_notify_event", fig.canvas, *s1, button, buttons={button}) stop_event = MouseEvent( "button_release_event", fig.canvas, *s1, button) @@ -267,12 +269,12 @@ def test_interactive_colorbar(plot_func, orientation, tool, button, expected): if tool == "zoom": tb.zoom() tb.press_zoom(start_event) - tb.drag_zoom(stop_event) + tb.drag_zoom(drag_event) tb.release_zoom(stop_event) else: tb.pan() tb.press_pan(start_event) - tb.drag_pan(stop_event) + tb.drag_pan(drag_event) tb.release_pan(stop_event) # Should be close, but won't be exact due to screen integer resolution @@ -395,6 +397,9 @@ def test_interactive_pan(key, mouseend, expectedxlim, expectedylim): start_event = MouseEvent( "button_press_event", fig.canvas, *sstart, button=MouseButton.LEFT, key=key) + drag_event = MouseEvent( + "motion_notify_event", fig.canvas, *send, button=MouseButton.LEFT, + buttons={MouseButton.LEFT}, key=key) stop_event = MouseEvent( "button_release_event", fig.canvas, *send, button=MouseButton.LEFT, key=key) @@ -402,7 +407,7 @@ def test_interactive_pan(key, mouseend, expectedxlim, expectedylim): tb = NavigationToolbar2(fig.canvas) tb.pan() tb.press_pan(start_event) - tb.drag_pan(stop_event) + tb.drag_pan(drag_event) tb.release_pan(stop_event) # Should be close, but won't be exact due to screen integer resolution assert tuple(ax.get_xlim()) == pytest.approx(expectedxlim, abs=0.02) @@ -510,6 +515,8 @@ def test_interactive_pan_zoom_events(tool, button, patch_vis, forward_nav, t_s): # Set up the mouse movements start_event = MouseEvent("button_press_event", fig.canvas, *s0, button) + drag_event = MouseEvent( + "motion_notify_event", fig.canvas, *s1, button, buttons={button}) stop_event = MouseEvent("button_release_event", fig.canvas, *s1, button) tb = NavigationToolbar2(fig.canvas) @@ -534,7 +541,7 @@ def test_interactive_pan_zoom_events(tool, button, patch_vis, forward_nav, t_s): tb.zoom() tb.press_zoom(start_event) - tb.drag_zoom(stop_event) + tb.drag_zoom(drag_event) tb.release_zoom(stop_event) assert ax_t.get_xlim() == pytest.approx(xlim_t, abs=0.15) @@ -570,7 +577,7 @@ def test_interactive_pan_zoom_events(tool, button, patch_vis, forward_nav, t_s): tb.pan() tb.press_pan(start_event) - tb.drag_pan(stop_event) + tb.drag_pan(drag_event) tb.release_pan(stop_event) assert ax_t.get_xlim() == pytest.approx(xlim_t, abs=0.15) diff --git a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py index ad952e4395af..f275556b6c71 100644 --- a/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py +++ b/lib/mpl_toolkits/mplot3d/tests/test_axes3d.py @@ -2130,6 +2130,8 @@ def test_toolbar_zoom_pan(tool, button, key, expected): # Set up the mouse movements start_event = MouseEvent( "button_press_event", fig.canvas, *s0, button, key=key) + drag_event = MouseEvent( + "motion_notify_event", fig.canvas, *s1, button, key=key, buttons={button}) stop_event = MouseEvent( "button_release_event", fig.canvas, *s1, button, key=key) @@ -2137,12 +2139,12 @@ def test_toolbar_zoom_pan(tool, button, key, expected): if tool == "zoom": tb.zoom() tb.press_zoom(start_event) - tb.drag_zoom(stop_event) + tb.drag_zoom(drag_event) tb.release_zoom(stop_event) else: tb.pan() tb.press_pan(start_event) - tb.drag_pan(stop_event) + tb.drag_pan(drag_event) tb.release_pan(stop_event) # Should be close, but won't be exact due to screen integer resolution