Skip to content

Commit 3c2c5c4

Browse files
committed
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.
1 parent 6a8211e commit 3c2c5c4

File tree

3 files changed

+38
-13
lines changed

3 files changed

+38
-13
lines changed

lib/matplotlib/backend_bases.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3059,6 +3059,11 @@ def press_pan(self, event):
30593059

30603060
def drag_pan(self, event):
30613061
"""Callback for dragging in pan/zoom mode."""
3062+
if event.buttons != {self._pan_info.button}:
3063+
# Zoom ended while canvas not in focus (it did not receive a
3064+
# button_release_event); cancel it.
3065+
self.release_pan(None) # release_pan doesn't actually use event.
3066+
return
30623067
for ax in self._pan_info.axes:
30633068
# Using the recorded button at the press is safer than the current
30643069
# button, as multiple buttons can get pressed during motion.
@@ -3092,7 +3097,7 @@ def zoom(self, *args):
30923097
for a in self.canvas.figure.get_axes():
30933098
a.set_navigate_mode(self.mode._navigate_mode)
30943099

3095-
_ZoomInfo = namedtuple("_ZoomInfo", "direction start_xy axes cid cbar")
3100+
_ZoomInfo = namedtuple("_ZoomInfo", "button start_xy axes cid cbar")
30963101

30973102
def press_zoom(self, event):
30983103
"""Callback for mouse button press in zoom to rect mode."""
@@ -3117,11 +3122,17 @@ def press_zoom(self, event):
31173122
cbar = None
31183123

31193124
self._zoom_info = self._ZoomInfo(
3120-
direction="in" if event.button == 1 else "out",
3121-
start_xy=(event.x, event.y), axes=axes, cid=id_zoom, cbar=cbar)
3125+
button=event.button, start_xy=(event.x, event.y), axes=axes,
3126+
cid=id_zoom, cbar=cbar)
31223127

31233128
def drag_zoom(self, event):
31243129
"""Callback for dragging in zoom mode."""
3130+
if event.buttons != {self._zoom_info.button}:
3131+
# Zoom ended while canvas not in focus (it did not receive a
3132+
# button_release_event); cancel it.
3133+
self._cleanup_post_zoom()
3134+
return
3135+
31253136
start_xy = self._zoom_info.start_xy
31263137
ax = self._zoom_info.axes[0]
31273138
(x1, y1), (x2, y2) = np.clip(
@@ -3150,6 +3161,7 @@ def release_zoom(self, event):
31503161
self.remove_rubberband()
31513162

31523163
start_x, start_y = self._zoom_info.start_xy
3164+
direction = "in" if self._zoom_info.button == 1 else "out"
31533165
key = event.key
31543166
# Force the key on colorbars to ignore the zoom-cancel on the
31553167
# short-axis side
@@ -3161,8 +3173,7 @@ def release_zoom(self, event):
31613173
# "cancel" a zoom action by zooming by less than 5 pixels.
31623174
if ((abs(event.x - start_x) < 5 and key != "y") or
31633175
(abs(event.y - start_y) < 5 and key != "x")):
3164-
self.canvas.draw_idle()
3165-
self._zoom_info = None
3176+
self._cleanup_post_zoom()
31663177
return
31673178

31683179
for i, ax in enumerate(self._zoom_info.axes):
@@ -3174,11 +3185,18 @@ def release_zoom(self, event):
31743185
for prev in self._zoom_info.axes[:i])
31753186
ax._set_view_from_bbox(
31763187
(start_x, start_y, event.x, event.y),
3177-
self._zoom_info.direction, key, twinx, twiny)
3188+
direction, key, twinx, twiny)
3189+
3190+
self._cleanup_post_zoom()
3191+
self.push_current()
31783192

3193+
def _cleanup_post_zoom(self):
3194+
# We don't check the event button here, so that zooms can be cancelled
3195+
# by (pressing and) releasing another mouse button.
3196+
self.canvas.mpl_disconnect(self._zoom_info.cid)
3197+
self.remove_rubberband()
31793198
self.canvas.draw_idle()
31803199
self._zoom_info = None
3181-
self.push_current()
31823200

31833201
def push_current(self):
31843202
"""Push the current view limits and position onto the stack."""

lib/matplotlib/backend_bases.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,7 @@ class NavigationToolbar2:
429429
def zoom(self, *args) -> None: ...
430430

431431
class _ZoomInfo(NamedTuple):
432-
direction: Literal["in", "out"]
432+
button: MouseButton
433433
start_xy: tuple[float, float]
434434
axes: list[Axes]
435435
cid: int

lib/matplotlib/tests/test_backend_bases.py

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -260,19 +260,21 @@ def test_interactive_colorbar(plot_func, orientation, tool, button, expected):
260260
# Set up the mouse movements
261261
start_event = MouseEvent(
262262
"button_press_event", fig.canvas, *s0, button)
263+
drag_event = MouseEvent(
264+
"motion_notify_event", fig.canvas, *s1, button, buttons={button})
263265
stop_event = MouseEvent(
264266
"button_release_event", fig.canvas, *s1, button)
265267

266268
tb = NavigationToolbar2(fig.canvas)
267269
if tool == "zoom":
268270
tb.zoom()
269271
tb.press_zoom(start_event)
270-
tb.drag_zoom(stop_event)
272+
tb.drag_zoom(drag_event)
271273
tb.release_zoom(stop_event)
272274
else:
273275
tb.pan()
274276
tb.press_pan(start_event)
275-
tb.drag_pan(stop_event)
277+
tb.drag_pan(drag_event)
276278
tb.release_pan(stop_event)
277279

278280
# Should be close, but won't be exact due to screen integer resolution
@@ -395,14 +397,17 @@ def test_interactive_pan(key, mouseend, expectedxlim, expectedylim):
395397
start_event = MouseEvent(
396398
"button_press_event", fig.canvas, *sstart, button=MouseButton.LEFT,
397399
key=key)
400+
drag_event = MouseEvent(
401+
"motion_notify_event", fig.canvas, *send, button=MouseButton.LEFT,
402+
buttons={MouseButton.LEFT}, key=key)
398403
stop_event = MouseEvent(
399404
"button_release_event", fig.canvas, *send, button=MouseButton.LEFT,
400405
key=key)
401406

402407
tb = NavigationToolbar2(fig.canvas)
403408
tb.pan()
404409
tb.press_pan(start_event)
405-
tb.drag_pan(stop_event)
410+
tb.drag_pan(drag_event)
406411
tb.release_pan(stop_event)
407412
# Should be close, but won't be exact due to screen integer resolution
408413
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):
510515

511516
# Set up the mouse movements
512517
start_event = MouseEvent("button_press_event", fig.canvas, *s0, button)
518+
drag_event = MouseEvent(
519+
"motion_notify_event", fig.canvas, *s1, button, buttons={button})
513520
stop_event = MouseEvent("button_release_event", fig.canvas, *s1, button)
514521

515522
tb = NavigationToolbar2(fig.canvas)
@@ -534,7 +541,7 @@ def test_interactive_pan_zoom_events(tool, button, patch_vis, forward_nav, t_s):
534541

535542
tb.zoom()
536543
tb.press_zoom(start_event)
537-
tb.drag_zoom(stop_event)
544+
tb.drag_zoom(drag_event)
538545
tb.release_zoom(stop_event)
539546

540547
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):
570577

571578
tb.pan()
572579
tb.press_pan(start_event)
573-
tb.drag_pan(stop_event)
580+
tb.drag_pan(drag_event)
574581
tb.release_pan(stop_event)
575582

576583
assert ax_t.get_xlim() == pytest.approx(xlim_t, abs=0.15)

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy