Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions lib/matplotlib/backends/_backend_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
11 changes: 11 additions & 0 deletions lib/matplotlib/backends/backend_gtk3.py
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,17 @@ 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.popup_at_pointer(event.guiEvent)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly for gtk3:

...
menu.connect('selection-done', lambda m: m.destroy())
menu.popup_at_pointer(event.guiEvent)



@_BackendGTK.export
class _BackendGTK3(_BackendGTK):
Expand Down
28 changes: 28 additions & 0 deletions lib/matplotlib/backends/backend_gtk4.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My GTK is rusty, but I believe this should be marked for cleanup when closed:

...
popover.connect('closed', lambda p: p.unparent())
popover.popup()



@_BackendGTK.export
class _BackendGTK4(_BackendGTK):
Expand Down
8 changes: 8 additions & 0 deletions lib/matplotlib/backends/backend_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -627,6 +627,14 @@ 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)
menu.exec(event.guiEvent.globalPos())

def _widgetclosed(self):
CloseEvent("close_event", self.canvas)._process()
if self.window._destroying:
Expand Down
10 changes: 10 additions & 0 deletions lib/matplotlib/backends/backend_wx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
34 changes: 29 additions & 5 deletions lib/mpl_toolkits/mplot3d/axes3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import textwrap
import warnings

import functools
import numpy as np

import matplotlib as mpl
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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(

@scottshambaugh scottshambaugh Jan 16, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should check for the existence of the context_menu for the backend to avoid errors:

if hasattr(canvas.manager, 'context_menu'):
    canvas.manager.context_menu(event, labels=..., actions=...)

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
Expand Down Expand Up @@ -1546,11 +1567,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

Expand All @@ -1563,6 +1579,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
Expand Down
Loading