diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 42782b2f00e1..66a44217e1a6 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -646,6 +646,14 @@ def full_screen_toggle(self): is_fullscreen = bool(self.window.attributes('-fullscreen')) self.window.attributes('-fullscreen', not is_fullscreen) + def context_menu(self, event, labels=None, actions=None): + if not labels or not actions: + return + menu = tk.Menu(self.window, tearoff=0) + for label, action in zip(labels, actions): + menu.add_command(label=label, command=action) + menu.tk_popup(event.guiEvent.x_root, event.guiEvent.y_root) + class NavigationToolbar2Tk(NavigationToolbar2, tk.Frame): def __init__(self, canvas, window=None, *, pack_toolbar=True): diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 20a1a3c8f0a9..2ebc66287375 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -591,6 +591,18 @@ class FigureManagerGTK3(_FigureManagerGTK): _toolbar2_class = NavigationToolbar2GTK3 _toolmanager_toolbar_class = ToolbarGTK3 + def context_menu(self, event, labels=None, actions=None): + if not labels or not actions: + return + menu = Gtk.Menu() + for label, action in zip(labels, actions): + item = Gtk.MenuItem(label=label) + menu.append(item) + item.connect('activate', lambda _, a=action: a()) + item.show() + menu.connect('selection-done', lambda m: m.destroy()) + menu.popup_at_pointer(event.guiEvent) + @_BackendGTK.export class _BackendGTK3(_BackendGTK): diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 95b116e9a6ba..afd26365be92 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -637,6 +637,34 @@ class FigureManagerGTK4(_FigureManagerGTK): _toolbar2_class = NavigationToolbar2GTK4 _toolmanager_toolbar_class = ToolbarGTK4 + def context_menu(self, event, labels=None, actions=None): + if not labels or not actions: + return + menu = Gio.Menu() + action_group = Gio.SimpleActionGroup() + for label, action in zip(labels, actions): + action_name = label.replace(" ", "_") + g_action = Gio.SimpleAction.new(action_name, None) + g_action.connect("activate", lambda *_, a=action: a()) + action_group.add_action(g_action) + menu.append(label, f"win.{action_name}") + + self.canvas.insert_action_group("win", action_group) + popover = Gtk.PopoverMenu.new_from_model(menu) + popover.set_parent(self.canvas) + popover.set_has_arrow(False) + popover.set_halign(Gtk.Align.START) + + scale = self.canvas.get_scale_factor() + height = self.canvas.get_height() + rect = Gdk.Rectangle() + rect.x = int(event.x / scale) + rect.y = int(height - (event.y / scale)) + rect.width = 1 + rect.height = 1 + popover.set_pointing_to(rect) + popover.popup() + @_BackendGTK.export class _BackendGTK4(_BackendGTK): diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 0b0240c90310..b451df3c57a9 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -627,6 +627,17 @@ def full_screen_toggle(self): else: self.window.showFullScreen() + def context_menu(self, event, labels=None, actions=None): + if not labels or not actions: + return + menu = QtWidgets.QMenu(self.window) + for label, action in zip(labels, actions): + menu.addAction(label).triggered.connect(action) + if hasattr(event.guiEvent, 'globalPosition'): + menu.exec(event.guiEvent.globalPosition().toPoint()) + else: + menu.exec(event.guiEvent.globalPos()) + def _widgetclosed(self): CloseEvent("close_event", self.canvas)._process() if self.window._destroying: diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 0acb4499ed87..91e093259313 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -1018,6 +1018,16 @@ def full_screen_toggle(self): # docstring inherited self.frame.ShowFullScreen(not self.frame.IsFullScreen()) + def context_menu(self, event, labels=None, actions=None): + if not labels or not actions: + return + menu = wx.Menu() + for label, action in zip(labels, actions): + item = menu.Append(wx.NewIdRef(), label) + menu.Bind(wx.EVT_MENU, lambda _, a=action: a(), item) + self.canvas.PopupMenu(menu, event.guiEvent.GetPosition()) + menu.Destroy() + def get_window_title(self): # docstring inherited return self.window.GetTitle() diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index 32da8dfde7aa..8f43bce956f6 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -16,6 +16,7 @@ import textwrap import warnings +import functools import numpy as np import matplotlib as mpl @@ -187,6 +188,8 @@ def __init__( pseudo_bbox = self.transLimits.inverted().transform([(0, 0), (1, 1)]) self._pseudo_w, self._pseudo_h = pseudo_bbox[1] - pseudo_bbox[0] + self._mouse_moved = False + # mplot3d currently manages its own spines and needs these turned off # for bounding box calculations self.spines[:].set_visible(False) @@ -1357,6 +1360,7 @@ def clear(self): def _button_press(self, event): if event.inaxes == self: + self._mouse_moved = False self.button_pressed = event.button self._sx, self._sy = event.xdata, event.ydata toolbar = self.get_figure(root=True).canvas.toolbar @@ -1367,6 +1371,24 @@ def _button_press(self, event): def _button_release(self, event): self.button_pressed = None + + if event.button in self._zoom_btn and event.inaxes == self \ + and not self._mouse_moved: + canvas = self.get_figure(root=True).canvas + + def draw_lambda(elev, azim): + self.view_init(elev=elev, azim=azim) + canvas.draw_idle() + + if hasattr(canvas.manager, "context_menu"): + canvas.manager.context_menu( + event, + labels=["Go to X-Y view", "Go to Y-Z view", "Go to X-Z view"], + actions=[functools.partial(draw_lambda, elev=90, azim=-90), + functools.partial(draw_lambda, elev=0, azim=0), + functools.partial(draw_lambda, elev=0, azim=-90)], + ) + toolbar = self.get_figure(root=True).canvas.toolbar # backend_bases.release_zoom and backend_bases.release_pan call # push_current, so check the navigation mode so we don't call it twice @@ -1546,11 +1568,6 @@ def _on_move(self, event): if not self.button_pressed: return - if self.get_navigate_mode() is not None: - # we don't want to rotate if we are zooming/panning - # from the toolbar - return - if self.M is None: return @@ -1563,6 +1580,14 @@ def _on_move(self, event): w = self._pseudo_w h = self._pseudo_h + if (dx**2 + dy**2) > 1e-6: + self._mouse_moved = True + + if self.get_navigate_mode() is not None: + # we don't want to rotate if we are zooming/panning + # from the toolbar + return + # Rotation if self.button_pressed in self._rotate_btn: # rotate viewing point