diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index 42782b2f00e1..a9a2b3a5d7a0 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 labels is None or actions is None: + 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..156234f888c6 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -591,6 +591,17 @@ class FigureManagerGTK3(_FigureManagerGTK): _toolbar2_class = NavigationToolbar2GTK3 _toolmanager_toolbar_class = ToolbarGTK3 + def context_menu(self, event, labels=None, actions=None): + if labels is None or actions is None: + 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.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..13c2cfdfb71b 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -637,6 +637,32 @@ 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 i, (label, action) in enumerate(zip(labels, actions)): + action_name = f"{label}" + 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_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 6ea437a90ca1..5d1609aaf48a 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -8,6 +8,8 @@ from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, ResizeEvent, TimerBase, _allow_interrupt) +from Foundation import NSObject +import AppKit class TimerMac(_macosx.Timer, TimerBase): @@ -145,6 +147,12 @@ def save_figure(self, *args): return filename +class MenuCallback(NSObject): + def action_(self, sender): + if hasattr(self, 'callback'): + self.callback() + + class FigureManagerMac(_macosx.FigureManager, FigureManagerBase): _toolbar2_class = NavigationToolbar2Mac @@ -161,6 +169,23 @@ def __init__(self, canvas, num): self.show() self.canvas.draw_idle() + def context_menu(self, event, labels=None, actions=None): + if labels is None or actions is None: + return + menu = AppKit.NSMenu.alloc().init() + self._menu_callbacks = [] + for label, action in zip(labels, actions): + target = MenuCallback.alloc().init() + target.callback = action + self._menu_callbacks.append(target) + item = AppKit.NSMenuItem.alloc().initWithTitle_action_keyEquivalent_( + label, "action:", "" + ) + item.setTarget_(target) + menu.addItem_(item) + mouse_loc = AppKit.NSEvent.mouseLocation() + menu.popUpMenuPositioningItem_atLocation_inView_(None, mouse_loc, None) + def _close_button_pressed(self): Gcf.destroy(self) self.canvas.flush_events() diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 0b0240c90310..d8738fc5ad15 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -627,6 +627,14 @@ def full_screen_toggle(self): else: self.window.showFullScreen() + def context_menu(self, event, labels=None, actions=None): + if labels is None or actions is None: + return + menu = QtWidgets.QMenu(self.window) + for label, action in zip(labels, actions): + menu.addAction(label).triggered.connect(action) + menu.exec(event.guiEvent.globalPosition().toPoint()) + 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..631e82090539 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 labels is None or actions is None: + 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..fb9f0113ed08 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,23 @@ 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() + + canvas.manager.context_menu( + event, + labels=["XY", "YZ", "XZ"], + 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 @@ -1563,6 +1584,9 @@ def _on_move(self, event): w = self._pseudo_w h = self._pseudo_h + if (dx**2 + dy**2) > 1e-6: + self._mouse_moved = True + # Rotation if self.button_pressed in self._rotate_btn: # rotate viewing point