From 7bb33033a518450caeacc97af08d53cd0c4f67ca Mon Sep 17 00:00:00 2001 From: AdwaithBatchu Date: Thu, 18 Dec 2025 21:20:00 +0530 Subject: [PATCH 1/3] prototype for right click context menu --- lib/matplotlib/backends/_backend_tk.py | 8 ++++++ lib/matplotlib/backends/backend_gtk3.py | 11 ++++++++ lib/matplotlib/backends/backend_gtk4.py | 26 +++++++++++++++++ lib/matplotlib/backends/backend_macosx.py | 25 +++++++++++++++++ lib/matplotlib/backends/backend_qt.py | 11 ++++++++ lib/matplotlib/backends/backend_wx.py | 10 +++++++ lib/mpl_toolkits/mplot3d/axes3d.py | 34 +++++++++++++++++++---- 7 files changed, 120 insertions(+), 5 deletions(-) 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..f2b6dfb7fc69 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 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) + 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..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..a1bb3a775aa2 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=["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 +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 @@ -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 From 5417d958627eee8ad9c00c498e847819cba0f310 Mon Sep 17 00:00:00 2001 From: AdwaithBatchu Date: Fri, 16 Jan 2026 20:59:11 +0530 Subject: [PATCH 2/3] small fixes to gtk4 and some checks --- lib/matplotlib/backends/_backend_tk.py | 2 +- lib/matplotlib/backends/backend_gtk3.py | 2 +- lib/matplotlib/backends/backend_gtk4.py | 6 ++++-- lib/matplotlib/backends/backend_macosx.py | 25 ----------------------- lib/matplotlib/backends/backend_qt.py | 7 ++----- lib/matplotlib/backends/backend_wx.py | 2 +- 6 files changed, 9 insertions(+), 35 deletions(-) diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index a9a2b3a5d7a0..66a44217e1a6 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -647,7 +647,7 @@ def full_screen_toggle(self): self.window.attributes('-fullscreen', not is_fullscreen) def context_menu(self, event, labels=None, actions=None): - if labels is None or actions is None: + if not labels or not actions: return menu = tk.Menu(self.window, tearoff=0) for label, action in zip(labels, actions): diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 156234f888c6..8486a2f46ae3 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -592,7 +592,7 @@ class FigureManagerGTK3(_FigureManagerGTK): _toolmanager_toolbar_class = ToolbarGTK3 def context_menu(self, event, labels=None, actions=None): - if labels is None or actions is None: + if not labels or not actions: return menu = Gtk.Menu() for label, action in zip(labels, actions): diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 13c2cfdfb71b..afd26365be92 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -642,17 +642,19 @@ def context_menu(self, event, labels=None, actions=None): return menu = Gio.Menu() action_group = Gio.SimpleActionGroup() - for i, (label, action) in enumerate(zip(labels, actions)): - action_name = f"{label}" + 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() diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 5d1609aaf48a..6ea437a90ca1 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -8,8 +8,6 @@ from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, ResizeEvent, TimerBase, _allow_interrupt) -from Foundation import NSObject -import AppKit class TimerMac(_macosx.Timer, TimerBase): @@ -147,12 +145,6 @@ 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 @@ -169,23 +161,6 @@ 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 f2b6dfb7fc69..0f6ecdd8cace 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -628,15 +628,12 @@ def full_screen_toggle(self): self.window.showFullScreen() def context_menu(self, event, labels=None, actions=None): - if labels is None or actions is 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()) + menu.exec(event.guiEvent.globalPos()) def _widgetclosed(self): CloseEvent("close_event", self.canvas)._process() diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 631e82090539..91e093259313 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -1019,7 +1019,7 @@ def full_screen_toggle(self): self.frame.ShowFullScreen(not self.frame.IsFullScreen()) def context_menu(self, event, labels=None, actions=None): - if labels is None or actions is None: + if not labels or not actions: return menu = wx.Menu() for label, action in zip(labels, actions): From 69da89dc4ae9ae0f1d6539aadd22935ff5f38df6 Mon Sep 17 00:00:00 2001 From: AdwaithBatchu Date: Thu, 22 Jan 2026 21:02:17 +0530 Subject: [PATCH 3/3] made suggested changes --- lib/matplotlib/backends/backend_gtk3.py | 1 + lib/matplotlib/backends/backend_qt.py | 5 ++++- lib/mpl_toolkits/mplot3d/axes3d.py | 15 ++++++++------- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 8486a2f46ae3..2ebc66287375 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -600,6 +600,7 @@ def context_menu(self, event, labels=None, actions=None): 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) diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 0f6ecdd8cace..b451df3c57a9 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -633,7 +633,10 @@ def context_menu(self, event, labels=None, actions=None): menu = QtWidgets.QMenu(self.window) for label, action in zip(labels, actions): menu.addAction(label).triggered.connect(action) - menu.exec(event.guiEvent.globalPos()) + 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() diff --git a/lib/mpl_toolkits/mplot3d/axes3d.py b/lib/mpl_toolkits/mplot3d/axes3d.py index a1bb3a775aa2..8f43bce956f6 100644 --- a/lib/mpl_toolkits/mplot3d/axes3d.py +++ b/lib/mpl_toolkits/mplot3d/axes3d.py @@ -1380,13 +1380,14 @@ def draw_lambda(elev, azim): self.view_init(elev=elev, azim=azim) canvas.draw_idle() - 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)], - ) + 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