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

@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
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.

self.view_init(elev=elev, azim=azim)
canvas.draw_idle()

canvas.manager.context_menu(
Copy link
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
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
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
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
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
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
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
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
Contributor

anntzer commented Jan 18, 2026

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

@scottshambaugh
Copy link
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
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
Contributor

anntzer commented Feb 6, 2026

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

@anntzer
Copy link
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.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

4 participants