Skip to content

Context Menu for snapping view to primary axis planes in 3D plots#30976

Open
AdwaithBatchu wants to merge 3 commits intomatplotlib:mainfrom
AdwaithBatchu:context-menu-3d
Open

Context Menu for snapping view to primary axis planes in 3D plots#30976
AdwaithBatchu wants to merge 3 commits intomatplotlib:mainfrom
AdwaithBatchu:context-menu-3d

Conversation

@AdwaithBatchu
Copy link
Copy Markdown

@AdwaithBatchu AdwaithBatchu commented Jan 16, 2026

PR summary

closes #23544
This PR introduces a feature that adds context menu on 3d Axes triggered by right-click of the mouse.

  1. Added context_menu() in Figure Manager that takes arguments, a list of labels and a list of corresponding functions to execute upon selection.

  2. Modified _button_release() to call canvas.manager.context_menu() with functions for setting orthographic views when the right-click is released on the mouse without moving it significantly. Mouse movement is handled in _on_move() using a small threshold because previously I observed trackpad (on macOS) reported micro-movements during a static click, which falsely flagged the action as "drag" and blocked the menu in specific backends.

Backends

  • TkAgg: tk.Menu implementation.
  • QtAgg: QtWidgets.QMenu implementation.
  • WxAgg: Uses wx.Menu and PopupMenu.
  • Gtk3Agg: Uses Gtk.Menu.
  • Gtk4Agg: Uses Gtk.PopoverMenu and Gio.Menu.
  • MacOSX: Yet to be implemented
  • WebAgg: Yet to be implemented
  • NbAgg: Yet to be implemented
import matplotlib

# matplotlib.use("TkAgg") # Change to QtAgg, GTK4Agg, MacOSX, WxAgg, etc.
import matplotlib.pyplot as plt

fig = plt.figure()
ax = fig.add_subplot(projection="3d")
ax.plot([0, 1, 2], [0, 1, 0], [0, 1, 0])

plt.show()

PR checklist

@AdwaithBatchu AdwaithBatchu changed the title prototype for right click context menu Context Menu for snapping view to primary axis planes in 3D plots Jan 16, 2026
Copy link
Copy Markdown
Contributor

@scottshambaugh scottshambaugh left a comment

Choose a reason for hiding this comment

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

This is a lot more cleanly implemented than I thought it would require!

I can see context menus being useful in quite a few use cases, we should talk on the dev team about designing these with some thought to it. It may also be worth implementing a base class for this, but I think this is fine for now as a starting point.

@timhoffm in the original issue mentioned compatibility with our event architecture, which I don't know enough to comment on.

I played around with this locally on a qtagg backend, and things work largely as expected:

  • The menu works and each option correctly snaps to the expected view
  • Long presses on a touchscreen work the same as a mouse right-click to bring up the menu
  • With multiple subplots, the action is only applied to the subplot that was clicked on
  • Right clicking in a 2D subplot has no effect
  • Rotating, panning, and zooming still all work as expected with either the mouse or toolbar buttons

One minor issue that would be nice to fix but isn't blocking, is that if you bring up the menu and then drag the figure window to a new position, the popup does not follow the window.

Image

For documentation, this behavior should get a doc/release/next_whats_new entry, as well as explanation with a screenshot in https://matplotlib.org/stable/users/explain/figure/interactive.html

We should also add tests for this. Ideally for selecting each option, but at the least for opening the menu on each backend.

Comment thread lib/mpl_toolkits/mplot3d/axes3d.py Outdated
self.view_init(elev=elev, azim=azim)
canvas.draw_idle()

canvas.manager.context_menu(
Copy link
Copy Markdown
Contributor

@scottshambaugh scottshambaugh Jan 16, 2026

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=...)

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()

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)

@anntzer
Copy link
Copy Markdown
Contributor

anntzer commented Jan 16, 2026

If matplotlib itself starts to use context menus, then the design needs to be careful to allow developers that embed matplotlib widgets into their own GUIs to add their own context actions (something that I do fairly regularly, and which I would have to redesign if it started to conflict with builtin menus). There's likely many ways to design the API, but based on my own usage it could e.g. look like

canvas.add_context_menu_entry("group", "name", callback, condition=lambda e: True)

where "name" is what gets displayed in the menu, "group" allows grouping entries (with menu separators? or with submenus?), callback is the callback (taking the button_press_event as parameter) and condition is a function taking the button_press_event as argument and returning whether this entry should be displayed (e.g., for the use case here it would allow displaying the menu entries only when over a 3D axes).

@scottshambaugh
Copy link
Copy Markdown
Contributor

@anntzer how do you feel about getting that API fleshed out in this PR versus marking everything here as a private interface that we can revisit?

@anntzer
Copy link
Copy Markdown
Contributor

anntzer commented Jan 17, 2026

Even if the implementation here is all hidden behind private APIs, behaviorally this will still break third-parties that rely on their own context menus. If anything I think I would prefer (wearing my hat of such a third-party) that Matplotlib provides a documented API for this purpose and explicitly marks it as provisional, so that I can at least provide keep adding my own context menu entries by version-gating on Matplotlib's version.

@scottshambaugh
Copy link
Copy Markdown
Contributor

That sounds reasonable to me.

@AdwaithBatchu you have a start on the backends - is an API something that you'd be interested in sketching out? I think it's a bit of a different task that will require some thought and care.

@timhoffm
Copy link
Copy Markdown
Member

timhoffm commented Jan 17, 2026

Aren't we going one step to far here with adding a full framework for defining context menus? That sounds very much like a second version of toolmanager, and we haven't even managed to migrate to that. Therefore I'm very hesitant on us providing a way for third parties "adding my own context menu entries".

I believe a much smaller step is sufficient: Third parties may very well provide their own context menus, but we don't mix between them and us.

  • We have our own private non-configurable context menu.
  • There is an option to activate / deactivate it.
  • Our context menu is deactivated by default on bare FigureCanvases. They are the ones that get integrated into third party applications. The application should have control over the context menu, and may provide their own, or alternatively opt in to use our context menu. This ensures backward compatiblity with existing applications.
  • We activate the context menu by default on windows that we create, e.g. via plt.show(). These are "mini"-GUIs in our responsibility and we can therefore afford to activate the context menu by default.

@anntzer
Copy link
Copy Markdown
Contributor

anntzer commented Jan 18, 2026

Sure, providing a way to disable the builtin menu seems reasonable too.

@scottshambaugh
Copy link
Copy Markdown
Contributor

scottshambaugh commented Jan 22, 2026

Hi @AdwaithBatchu, we talked about this in the weekly dev call today and decided that a context menu is not the right way to solve the original problem (rationale here: #23544 (comment)). Thank you for the work on this and apologies for the change in direction after you had already spent time on it!

I'm going to close this PR as not-planned, but please feel free to give the toolbar menu approach a shot if you'd like.

@scottshambaugh
Copy link
Copy Markdown
Contributor

Ok I mentioned this over in the issue but to reiterate here, we agree with your point about choosing which subplot to interact with, and that the context menu is the way to go.

The only update is that we should not overwrite any existing menus that a user has already defined. @anntzer do you have an example of what that looks like in one of your implementations that we can test against?

@anntzer
Copy link
Copy Markdown
Contributor

anntzer commented Feb 6, 2026

Sure, I'll pull out a minimal example from my programs.

@anntzer
Copy link
Copy Markdown
Contributor

anntzer commented Feb 7, 2026

This is a minimal example extracted from my own packages, which allows triggering the qt figureoptions menu for the single axes under the mouse, rather than having to go through the axes selection dropdown menu, which can be unwieldy when there are dozens of subpanels on a figure:

import functools
from matplotlib import pyplot as plt


def register_context_action(fig, gen_callbacks=None):
    """
    Register actions for a right-click menu on a Figure.

    On a right-click, *gen_callbacks* is called with the mouse event as single argument;
    it should return a mapping of menu entry names to menu action callables.  The menu
    action callables also take the mouse event as single argument.

    Parameters
    ----------
    fig : Figure instance
    gen_callbacks : Callable[[MouseEvent], dict[str, Callable[[MouseEvent], ...]]]
    """

    if gen_callbacks is None:  # Allow use as decorator.
        return functools.partial(register_context_action, fig)

    def on_button_press(event):
        tb = fig.canvas.toolbar
        if tb and str(tb.mode) or event.button.name != "RIGHT":
            return
        callbacks = gen_callbacks(event)
        if not callbacks:
            return

        gui_event = event.guiEvent
        pkg = type(gui_event).__module__.split(".")[0]

        if pkg.startswith(("PyQt", "PySide")):
            from matplotlib.backends.qt_compat import QtWidgets
            menu = QtWidgets.QMenu()
            for name, func in callbacks.items():
                menu.addAction(name, functools.partial(func, event))
            menu.exec(
                event.guiEvent.globalPosition().toPoint()  # Qt6.
                if hasattr(event.guiEvent, "globalPosition")
                else event.guiEvent.globalPos())  # Qt5.
        elif ...:  # Other backends (elided here, as the example below is qt only).
            pass

    fig.canvas.mpl_connect("button_press_event", on_button_press)


def context_axes_edit(fig):
    # Allow triggering the Qt menu option for a single axes.
    from matplotlib.backends.backend_qt import figureoptions
    register_context_action(fig, lambda e: (
        {"Edit Axes": lambda e: figureoptions.figure_edit(e.inaxes, e.canvas.toolbar)}
        if e.inaxes else {}))


if __name__ == "__main__":
    fig, axs = plt.subplots(2, 2)
    axs[0, 0].plot([0, 1])
    context_axes_edit(fig)
    plt.show()

In fact, I guess it would be nice to have something like that in matplotlib itself, if we decide to start providing builtin context menus...

Another fairly general action I have is one that allows exporting the data artists of an axes to a npz file (essentially something that iterates over the axes children and switches over the child type, exporting line.get_data() for Line2D, image.get_array() for AxesImage, etc.) I also have a few actions that are much more domain-specific, which I essentially put in the context menu because I already have some machinery available to do so, rather than e.g. making a real menu bar.

@AdwaithBatchu
Copy link
Copy Markdown
Author

I have put some thought into what could be done to disable/enable the context menu, Here are my thoughts considering what @timhoffm suggested,

How to activate/deactivate

We write the context_menu() function for every first party backend but it is called by canvas.manager only when it is activated, we track whether its activated or not using a bool variable in FigureManagerBase class (default False)

When (for example) axes3d tries to call context_menu, we will check this variable (say,canvas.is_activated) to know whether it is activated / deactivated.

When to activate/deactivate

By default context menu would be deactivated. We activate in either of these two cases:

  1. User opts-in (for example, by setting the bool variable as True)

  2. When plt.show() is called

    This is based on the reasoning provided by timhoffm in this comment

    We activate the context menu by default on windows that we create, e.g. via plt.show(). These are "mini"-GUIs in our responsibility and we can therefore afford to activate the context menu by default.

    • Enable context menu in backends that support it (tk, gtk..), here by enabling we mean that if the backend used by the user has context_menu() code written by us (typical first party backends provided by matplotlib) and the plot is shown on a window in our responsibility, then we enable the context_menu by setting the bool variable to true.

Drawbacks

In the example provided by anntzer, the script uses fig.canvas.mpl_connect to bind mouse press with a user defined function to interact with the backend and open context menu. The plot is shown using plt.show(). The above design wouldn’t work for this.

Possible Solution: Detect if mpl_connect has been called by the user to connect to the mouse press event, in which case, context menu is disabled.

There might be other methods through which users can add context menu (or any action bound to right click) even on the windows created through plt.show(). So is there any list that contains possible ways the user can do this and also ways to detect each of them on the user system?

@anntzer
Copy link
Copy Markdown
Contributor

anntzer commented Apr 4, 2026

A few tentative ideas:

  • Just let end-users call a method or set an rcParam to enable/disable specific builtin event handlers. A drawback may be that this can get unwieldy if builtin event handlers start to increase in number...
  • Add a flag on the event object that allows a handler to notify that "I already took care of this event, other handlers should not handle it" (i.e. some kind of lock system). However this would likely also require some priority system to specify the order in which event handlers get to see the events.

@timhoffm
Copy link
Copy Markdown
Member

timhoffm commented Apr 4, 2026

  • Add a flag on the event object that allows a handler to notify that "I already took care of this event, other handlers should not handle it"

IMHO, and inspired by Qt, this primarily makes sense if there is a natural unambiguous order to potential handlers. E.g. events propagate up the Qt widget hierarchy. When there are multiple conceptually equal handlers (Qt slots) such a stop possibility is problematic as you can’t know who you might be depriving of the event. Insertion order may be a too weak criterion.

Overall, stopping event propagation is a very powerful mechanism, but I think it’s more than we need and too much effort to pull off for the case at hand.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Waiting for author

Development

Successfully merging this pull request may close these issues.

[ENH]: Add ability to snap view to primary axis planes in 3D plots

5 participants