diff --git a/doc/users/next_whats_new/figure_hooks.rst b/doc/users/next_whats_new/figure_hooks.rst
new file mode 100644
index 000000000000..7132e4875f92
--- /dev/null
+++ b/doc/users/next_whats_new/figure_hooks.rst
@@ -0,0 +1,8 @@
+Figure hooks
+~~~~~~~~~~~~
+The new :rc:`figure.hooks` provides a mechanism to register
+arbitrary customizations on pyplot figures; it is a list of
+"dotted.module.name:dotted.callable.name" strings specifying functions
+that are called on each figure created by `.pyplot.figure`; these
+functions can e.g. attach callbacks or modify the toolbar. See
+:doc:`/gallery/user_interfaces/mplcvd` for an example of toolbar customization.
diff --git a/examples/user_interfaces/images/eye-symbolic.svg b/examples/user_interfaces/images/eye-symbolic.svg
new file mode 100644
index 000000000000..20d5db230405
--- /dev/null
+++ b/examples/user_interfaces/images/eye-symbolic.svg
@@ -0,0 +1,70 @@
+
+
+
diff --git a/examples/user_interfaces/images/eye.pdf b/examples/user_interfaces/images/eye.pdf
new file mode 100644
index 000000000000..52f18e8342f8
Binary files /dev/null and b/examples/user_interfaces/images/eye.pdf differ
diff --git a/examples/user_interfaces/images/eye.png b/examples/user_interfaces/images/eye.png
new file mode 100644
index 000000000000..365f6fbcde5d
Binary files /dev/null and b/examples/user_interfaces/images/eye.png differ
diff --git a/examples/user_interfaces/images/eye.svg b/examples/user_interfaces/images/eye.svg
new file mode 100644
index 000000000000..20d5db230405
--- /dev/null
+++ b/examples/user_interfaces/images/eye.svg
@@ -0,0 +1,70 @@
+
+
+
diff --git a/examples/user_interfaces/images/eye_large.png b/examples/user_interfaces/images/eye_large.png
new file mode 100644
index 000000000000..f8a2911032a4
Binary files /dev/null and b/examples/user_interfaces/images/eye_large.png differ
diff --git a/examples/user_interfaces/mplcvd.py b/examples/user_interfaces/mplcvd.py
new file mode 100644
index 000000000000..e65cb7d26a3b
--- /dev/null
+++ b/examples/user_interfaces/mplcvd.py
@@ -0,0 +1,299 @@
+"""
+mplcvd -- an example of figure hook
+===================================
+
+To use this hook, ensure that this module is in your ``PYTHONPATH``, and set
+``rcParams["figure.hooks"] = ["mplcvd:setup"]``. This hook depends on
+the ``colorspacious`` third-party module.
+"""
+
+import functools
+from pathlib import Path
+
+import colorspacious
+import numpy as np
+
+
+_BUTTON_NAME = "Filter"
+_BUTTON_HELP = "Simulate color vision deficiencies"
+_MENU_ENTRIES = {
+ "None": None,
+ "Greyscale": "greyscale",
+ "Deuteranopia": "deuteranomaly",
+ "Protanopia": "protanomaly",
+ "Tritanopia": "tritanomaly",
+}
+
+
+def _get_color_filter(name):
+ """
+ Given a color filter name, create a color filter function.
+
+ Parameters
+ ----------
+ name : str
+ The color filter name, one of the following:
+
+ - ``"none"``: ...
+ - ``"greyscale"``: Convert the input to luminosity.
+ - ``"deuteranopia"``: Simulate the most common form of red-green
+ colorblindness.
+ - ``"protanopia"``: Simulate a rarer form of red-green colorblindness.
+ - ``"tritanopia"``: Simulate the rare form of blue-yellow
+ colorblindness.
+
+ Color conversions use `colorspacious`_.
+
+ Returns
+ -------
+ callable
+ A color filter function that has the form:
+
+ def filter(input: np.ndarray[M, N, D])-> np.ndarray[M, N, D]
+
+ where (M, N) are the image dimensions, and D is the color depth (3 for
+ RGB, 4 for RGBA). Alpha is passed through unchanged and otherwise
+ ignored.
+ """
+ if name not in _MENU_ENTRIES:
+ raise ValueError(f"Unsupported filter name: {name!r}")
+ name = _MENU_ENTRIES[name]
+
+ if name is None:
+ return None
+
+ elif name == "greyscale":
+ rgb_to_jch = colorspacious.cspace_converter("sRGB1", "JCh")
+ jch_to_rgb = colorspacious.cspace_converter("JCh", "sRGB1")
+
+ def convert(im):
+ greyscale_JCh = rgb_to_jch(im)
+ greyscale_JCh[..., 1] = 0
+ im = jch_to_rgb(greyscale_JCh)
+ return im
+
+ else:
+ cvd_space = {"name": "sRGB1+CVD", "cvd_type": name, "severity": 100}
+ convert = colorspacious.cspace_converter(cvd_space, "sRGB1")
+
+ def filter_func(im, dpi):
+ alpha = None
+ if im.shape[-1] == 4:
+ im, alpha = im[..., :3], im[..., 3]
+ im = convert(im)
+ if alpha is not None:
+ im = np.dstack((im, alpha))
+ return np.clip(im, 0, 1), 0, 0
+
+ return filter_func
+
+
+def _set_menu_entry(tb, name):
+ tb.canvas.figure.set_agg_filter(_get_color_filter(name))
+ tb.canvas.draw_idle()
+
+
+def setup(figure):
+ tb = figure.canvas.toolbar
+ if tb is None:
+ return
+ for cls in type(tb).__mro__:
+ pkg = cls.__module__.split(".")[0]
+ if pkg != "matplotlib":
+ break
+ if pkg == "gi":
+ _setup_gtk(tb)
+ elif pkg in ("PyQt5", "PySide2", "PyQt6", "PySide6"):
+ _setup_qt(tb)
+ elif pkg == "tkinter":
+ _setup_tk(tb)
+ elif pkg == "wx":
+ _setup_wx(tb)
+ else:
+ raise NotImplementedError("The current backend is not supported")
+
+
+def _setup_gtk(tb):
+ from gi.repository import Gio, GLib, Gtk
+
+ for idx in range(tb.get_n_items()):
+ children = tb.get_nth_item(idx).get_children()
+ if children and isinstance(children[0], Gtk.Label):
+ break
+
+ toolitem = Gtk.SeparatorToolItem()
+ tb.insert(toolitem, idx)
+
+ image = Gtk.Image.new_from_gicon(
+ Gio.Icon.new_for_string(
+ str(Path(__file__).parent / "images/eye-symbolic.svg")),
+ Gtk.IconSize.LARGE_TOOLBAR)
+
+ # The type of menu is progressively downgraded depending on GTK version.
+ if Gtk.check_version(3, 6, 0) is None:
+
+ group = Gio.SimpleActionGroup.new()
+ action = Gio.SimpleAction.new_stateful("cvdsim",
+ GLib.VariantType("s"),
+ GLib.Variant("s", "none"))
+ group.add_action(action)
+
+ @functools.partial(action.connect, "activate")
+ def set_filter(action, parameter):
+ _set_menu_entry(tb, parameter.get_string())
+ action.set_state(parameter)
+
+ menu = Gio.Menu()
+ for name in _MENU_ENTRIES:
+ menu.append(name, f"local.cvdsim::{name}")
+
+ button = Gtk.MenuButton.new()
+ button.remove(button.get_children()[0])
+ button.add(image)
+ button.insert_action_group("local", group)
+ button.set_menu_model(menu)
+ button.get_style_context().add_class("flat")
+
+ item = Gtk.ToolItem()
+ item.add(button)
+ tb.insert(item, idx + 1)
+
+ else:
+
+ menu = Gtk.Menu()
+ group = []
+ for name in _MENU_ENTRIES:
+ item = Gtk.RadioMenuItem.new_with_label(group, name)
+ item.set_active(name == "None")
+ item.connect(
+ "activate", lambda item: _set_menu_entry(tb, item.get_label()))
+ group.append(item)
+ menu.append(item)
+ menu.show_all()
+
+ tbutton = Gtk.MenuToolButton.new(image, _BUTTON_NAME)
+ tbutton.set_menu(menu)
+ tb.insert(tbutton, idx + 1)
+
+ tb.show_all()
+
+
+def _setup_qt(tb):
+ from matplotlib.backends.qt_compat import QtGui, QtWidgets
+
+ menu = QtWidgets.QMenu()
+ try:
+ QActionGroup = QtGui.QActionGroup # Qt6
+ except AttributeError:
+ QActionGroup = QtWidgets.QActionGroup # Qt5
+ group = QActionGroup(menu)
+ group.triggered.connect(lambda action: _set_menu_entry(tb, action.text()))
+
+ for name in _MENU_ENTRIES:
+ action = menu.addAction(name)
+ action.setCheckable(True)
+ action.setActionGroup(group)
+ action.setChecked(name == "None")
+
+ actions = tb.actions()
+ before = next(
+ (action for action in actions
+ if isinstance(tb.widgetForAction(action), QtWidgets.QLabel)), None)
+
+ tb.insertSeparator(before)
+ button = QtWidgets.QToolButton()
+ # FIXME: _icon needs public API.
+ button.setIcon(tb._icon(str(Path(__file__).parent / "images/eye.png")))
+ button.setText(_BUTTON_NAME)
+ button.setToolTip(_BUTTON_HELP)
+ button.setPopupMode(QtWidgets.QToolButton.ToolButtonPopupMode.InstantPopup)
+ button.setMenu(menu)
+ tb.insertWidget(before, button)
+
+
+def _setup_tk(tb):
+ import tkinter as tk
+
+ tb._Spacer() # FIXME: _Spacer needs public API.
+
+ button = tk.Menubutton(master=tb, relief="raised")
+ button._image_file = str(Path(__file__).parent / "images/eye.png")
+ # FIXME: _set_image_for_button needs public API (perhaps like _icon).
+ tb._set_image_for_button(button)
+ button.pack(side=tk.LEFT)
+
+ menu = tk.Menu(master=button, tearoff=False)
+ for name in _MENU_ENTRIES:
+ menu.add("radiobutton", label=name,
+ command=lambda _name=name: _set_menu_entry(tb, _name))
+ menu.invoke(0)
+ button.config(menu=menu)
+
+
+def _setup_wx(tb):
+ import wx
+
+ idx = next(idx for idx in range(tb.ToolsCount)
+ if tb.GetToolByPos(idx).IsStretchableSpace())
+ tb.InsertSeparator(idx)
+ tool = tb.InsertTool(
+ idx + 1, -1, _BUTTON_NAME,
+ # FIXME: _icon needs public API.
+ tb._icon(str(Path(__file__).parent / "images/eye.png")),
+ # FIXME: ITEM_DROPDOWN is not supported on macOS.
+ kind=wx.ITEM_DROPDOWN, shortHelp=_BUTTON_HELP)
+
+ menu = wx.Menu()
+ for name in _MENU_ENTRIES:
+ item = menu.AppendRadioItem(-1, name)
+ menu.Bind(
+ wx.EVT_MENU,
+ lambda event, _name=name: _set_menu_entry(tb, _name),
+ id=item.Id,
+ )
+ tb.SetDropdownMenu(tool.Id, menu)
+
+
+if __name__ == '__main__':
+ import matplotlib.pyplot as plt
+ from matplotlib import cbook
+
+ plt.rcParams['figure.hooks'].append('mplcvd:setup')
+
+ fig, axd = plt.subplot_mosaic(
+ [
+ ['viridis', 'turbo'],
+ ['photo', 'lines']
+ ]
+ )
+
+ delta = 0.025
+ x = y = np.arange(-3.0, 3.0, delta)
+ X, Y = np.meshgrid(x, y)
+ Z1 = np.exp(-X**2 - Y**2)
+ Z2 = np.exp(-(X - 1)**2 - (Y - 1)**2)
+ Z = (Z1 - Z2) * 2
+
+ imv = axd['viridis'].imshow(
+ Z, interpolation='bilinear',
+ origin='lower', extent=[-3, 3, -3, 3],
+ vmax=abs(Z).max(), vmin=-abs(Z).max()
+ )
+ fig.colorbar(imv)
+ imt = axd['turbo'].imshow(
+ Z, interpolation='bilinear', cmap='turbo',
+ origin='lower', extent=[-3, 3, -3, 3],
+ vmax=abs(Z).max(), vmin=-abs(Z).max()
+ )
+ fig.colorbar(imt)
+
+ # A sample image
+ with cbook.get_sample_data('grace_hopper.jpg') as image_file:
+ photo = plt.imread(image_file)
+ axd['photo'].imshow(photo)
+
+ th = np.linspace(0, 2*np.pi, 1024)
+ for j in [1, 2, 4, 6]:
+ axd['lines'].plot(th, np.sin(th * j), label=f'$\\omega={j}$')
+ axd['lines'].legend(ncol=2, loc='upper right')
+ plt.show()
diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc
index b76b1191eecd..28dad0f392a5 100644
--- a/lib/matplotlib/mpl-data/matplotlibrc
+++ b/lib/matplotlib/mpl-data/matplotlibrc
@@ -103,6 +103,7 @@
#backend_fallback: True
#interactive: False
+#figure.hooks: # list of dotted.module.name:dotted.callable.name
#toolbar: toolbar2 # {None, toolbar2, toolmanager}
#timezone: UTC # a pytz timezone string, e.g., US/Central or Europe/Paris
diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py
index 25c1f43d5f33..3948fe8a2e36 100644
--- a/lib/matplotlib/pyplot.py
+++ b/lib/matplotlib/pyplot.py
@@ -756,10 +756,16 @@ def figure(num=None, # autoincrement if None, else integer from 1-N
Notes
-----
- Newly created figures are passed to the `~.FigureCanvasBase.new_manager`
+ A newly created figure is passed to the `~.FigureCanvasBase.new_manager`
method or the `new_figure_manager` function provided by the current
backend, which install a canvas and a manager on the figure.
+ Once this is done, :rc:`figure.hooks` are called, one at a time, on the
+ figure; these hooks allow arbitrary customization of the figure (e.g.,
+ attaching callbacks) or of associated elements (e.g., modifying the
+ toolbar). See :doc:`/gallery/user_interfaces/mplcvd` for an example of
+ toolbar customization.
+
If you are creating many figures, make sure you explicitly call
`.pyplot.close` on the figures you are not using, because this will
enable pyplot to properly clean up the memory.
@@ -812,6 +818,13 @@ def figure(num=None, # autoincrement if None, else integer from 1-N
if fig_label:
fig.set_label(fig_label)
+ for hookspecs in rcParams["figure.hooks"]:
+ module_name, dotted_name = hookspecs.split(":")
+ obj = importlib.import_module(module_name)
+ for part in dotted_name.split("."):
+ obj = getattr(obj, part)
+ obj(fig)
+
_pylab_helpers.Gcf._set_new_active_manager(manager)
# make sure backends (inline) that we don't ship that expect this
diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py
index 9061a1373703..7d0425196d78 100644
--- a/lib/matplotlib/rcsetup.py
+++ b/lib/matplotlib/rcsetup.py
@@ -816,6 +816,7 @@ def _convert_validator_spec(key, conv):
_validators = {
"backend": validate_backend,
"backend_fallback": validate_bool,
+ "figure.hooks": validate_stringlist,
"toolbar": _validate_toolbar,
"interactive": validate_bool,
"timezone": validate_string,
diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py
index 3b9632cf7795..95e3174d8ae8 100644
--- a/lib/matplotlib/tests/test_pyplot.py
+++ b/lib/matplotlib/tests/test_pyplot.py
@@ -440,3 +440,18 @@ def test_switch_backend_no_close():
assert len(plt.get_fignums()) == 2
plt.switch_backend('svg')
assert len(plt.get_fignums()) == 0
+
+
+def figure_hook_example(figure):
+ figure._test_was_here = True
+
+
+def test_figure_hook():
+
+ test_rc = {
+ 'figure.hooks': ['matplotlib.tests.test_pyplot:figure_hook_example']
+ }
+ with mpl.rc_context(test_rc):
+ fig = plt.figure()
+
+ assert fig._test_was_here