From 9e113c9fb3cdb4f3340e92556dbe1a87645b3ff7 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sat, 22 Jan 2022 15:15:13 +0100 Subject: [PATCH 1/3] Arbitrary toolbar customization hooks. MEP22 was intended to provide a way to customize user interactions, in particular with the toolbar via the implementation to "tools". In practice, it remains currently difficult to add general customizations; for example, the color-vision deficiency simulator recently proposed does not fit in the MEP22 framework. Moreover, there is no way to register MEP22 tools to be added to all figures. This patch proposes an alternative approach for toolbar customization: it adds a rcParam (`figure.hooks`) which is a list of callables (actually, of "modulename:functioname" strings, so that they can be specified in the matplotlibrc file) that get called whenever plt.figure() creates a figure is instantiated; each of the callable gets the figure as parameter and can modify it as it sees fit (note that it is equivalent to pass the figure or the toolbar as parameter, as one can reach one from the other; passing the figure was deemed nicer). This makes it easy to distribute such customizations as plain python modules than can be installed from PyPI. Also note that figure hooks are intentionally *not* applied when figures are created without going through `plt.figure` -- when embedding, one can easily explicitly call the hooks directly on the figure (this is one reason why passing the figure is nicer than passing the toolbar). As an example, the color-vision deficiency simulator is modified to use this hook (see docstring of the `user_interfaces/mplcvd.py` example). The advantage of this approach is that arbitrary modifications to the toolbar become possible (here, adding a menu-in-a-toolbar), but this naturally means that each GUI toolkit needs its own code. Likely we will need to provide "template" implementations that can be copied by third-parties. (There is also some functionality currently only available as private API, as mentioned in comments; one such API is `_icon`, which provides theme-dependent icon recolorization. These APIs should be made public in some form, but that should be doable.) (One should check to what extent this approach is generalizable to the macos and notebook backends -- customizations to the former could possibly be implemented via PyObjC(?), and the latter via js injection?) --- doc/users/next_whats_new/figure_hooks.rst | 8 + .../user_interfaces/images/eye-symbolic.svg | 70 +++++ examples/user_interfaces/images/eye.pdf | Bin 0 -> 3526 bytes examples/user_interfaces/images/eye.png | Bin 0 -> 635 bytes examples/user_interfaces/images/eye.svg | 70 +++++ examples/user_interfaces/images/eye_large.png | Bin 0 -> 1149 bytes examples/user_interfaces/mplcvd.py | 254 ++++++++++++++++++ lib/matplotlib/mpl-data/matplotlibrc | 1 + lib/matplotlib/pyplot.py | 15 +- lib/matplotlib/rcsetup.py | 1 + 10 files changed, 418 insertions(+), 1 deletion(-) create mode 100644 doc/users/next_whats_new/figure_hooks.rst create mode 100644 examples/user_interfaces/images/eye-symbolic.svg create mode 100644 examples/user_interfaces/images/eye.pdf create mode 100644 examples/user_interfaces/images/eye.png create mode 100644 examples/user_interfaces/images/eye.svg create mode 100644 examples/user_interfaces/images/eye_large.png create mode 100644 examples/user_interfaces/mplcvd.py 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 @@ + + + + + + + + 2021-07-14T19:48:07.525592 + image/svg+xml + + + Matplotlib v3.4.2.post1357+gf1afce77c6.d20210714, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/user_interfaces/images/eye.pdf b/examples/user_interfaces/images/eye.pdf new file mode 100644 index 0000000000000000000000000000000000000000..52f18e8342f88713254957adedbec8b76267fb3d GIT binary patch literal 3526 zcmeHKdu&r>6jx#tE@6TiBva%=GRMRAKHA>4Fc-RZ#VK=RFPH=8UHi4Y(B4~a?^p*u zCq`!wWK$j?8e!4!N>BqRNP^;PAjo*QD4PoWF-VvYjL0jf=evE~trdZ2;vaU8w&$LE z9=~(W?>jfA)a5p?Ml+SOu^VmJPO*qVa@}kyFAveBf*(f^2a+;GS2Tt(qMd>&1Z6+U z&!@2DC5krP4TVv4O1Ubi7@1=?xn)U3rr76FK~=FLAq)9R(WQ#)DaR_R0Y5G`*vC;7 z(M2KfSrC7B#@~z3EJT+F1g|Lh5g)sKMoHal>`{?bGoA{E)hFPPBw39hmWf5+glMn? z0(A=P(Uq~I7OG?h+MCD011+65(xs14hCW}fty&SK8M<+Vk?%d0)JzIOQWzwEI zR_DJqlRmbjAUY=7n|tx}Obi#-oH{DLJ|%nUGRBd;X`l4QJMS%iW%zpTyWoK*PIep| zIC9QhVcxXP7pp2R9$UFTukov4J1_Q*%6t3Qz}nH5Zra?-FCul0hZ?WAmIBF@<(Whm zXb=-w4Vsmdk=i6MDTv{0V3C0}n*j!!71=B{0{7M6;&>ZHm)F;+TKBnSC6wsIL<8uN zd}x|uapCBJT??qiXh746#@${%^2g7cFZ^CRB71P?z}(JZHRGRc+v_@nPb{rywpYL9 z7(TgyAJV?ce^NQyyt4H;s@k@2_=+vdqs7(i7lx0Iu-vCzL!B>e-!kZwX=`QMQ!X^Qj5PctKY{0|Lfi{yu^{>}fkdxMMb|Tgw@ispV>wS3y=nk*j1iNLJ4gTt zh#V0QApz2(z9cGA3-MWnvzY669^9YRP;C`e-IPIiiM~a z9dH%{zRFb`sv=Hf>`TJe0Y=YAMx{%FWEGo43z+x^{-_(Z!CBJ$->|I=G5l}q`s(|y z;HC9xhT>dP9psHnVGUFiwYh zRKY$9Cdy917{*_Sw^&RTgr*#yW7NeLNewE-yoWELJ!?k}@L@AQ-LcIdZ$GC(k0 z22u3WV;DYNX5!LfVJx;Z9}LH2(BsqeShEc_`M&Y7oINc!U=P?z`s#5OGi)h+Wem@x z`QU73T`a01h(WAq4_rl(PexkD!DB|5ER#mn9y!Xz`4|dCiWgA@Mb%1YVt6Z+lT%pY Grv3!`lDCHd literal 0 HcmV?d00001 diff --git a/examples/user_interfaces/images/eye.png b/examples/user_interfaces/images/eye.png new file mode 100644 index 0000000000000000000000000000000000000000..365f6fbcde5d7cf2da6401167bb664cfe2a09171 GIT binary patch literal 635 zcmV->0)+jEP)V>bE;KGOE^u#ibTKnEH!EjmF=1w7Wj8ltHZEi` zFfuVPH!(CUAZT=SaC15@FKuCTaBOdMY-wUHZ*pfZFd7L}00009a7bBm000BA000BA z0ri9JNdN!=$w@>(RCt{2*3XNLQ5XmC&wJA_nMj$GUyEr5OYDdRG9_iL`~jnsjg5_k z>=jFii9$BW$^sjNq-2<4{9LFzX2TuHVjPS2x#M)_z4zM8LYzAFp6@y5`+d&ytG7rg zna@$1SN8t`|AxSVZU>5@7{p!-#q7sByv9phODQ$l5~P&oOcdCG8~BOTVSd3$3{-Me zkoVzI1<_e-#+EihKQMx#tpL_zEG|srV|*Wv{WUmRLH<1Z9!2jQ0j$Pk9C{MgPU8a> z;s|EiSbsg@RFCyd)Mf>O;9+cDMh~{e`XW|!AWx_VPd{D+b}Sb-72qQ*3T!yAD>&RG z(BGQA#{fP@a_nphOyUz(W-eRNg*2b{TVlCA@H5RqyNg~diM5ncU76>9GVZjjhiCCn zT!y!p!1BzJZMxUN8!XQ5><|1Liu_p{@g2{wF2fH7UPV5EO<8e{DC^;=NY&Ol*o7%H zaKP`tZNPbDhJ3|6oM>8(rMM8H59G0|sN}VH9EZo^1$}K=%8)Vx8*%tS%1W zRa~4=Hn`iks!Zpp%vHCryNavng33`8MGbqfFRUF@7SnrWHyy`lN@=_;$6x + + + + + + + 2021-07-14T19:48:07.525592 + image/svg+xml + + + Matplotlib v3.4.2.post1357+gf1afce77c6.d20210714, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/examples/user_interfaces/images/eye_large.png b/examples/user_interfaces/images/eye_large.png new file mode 100644 index 0000000000000000000000000000000000000000..f8a2911032a4b41a4d71b5763c40c7ec9a03ec13 GIT binary patch literal 1149 zcmV-@1cLjCP)V>bE;KGOE^u#ibTKnEH!EjmF=1w7Wj8ltHZEi` zFfuVPH!(CUAZT=SaC15@FKuCTaBOdMY-wUHZ*pfZFd7L}00009a7bBm000MK000MK z0XTY@+W-Iq%Sl8*RCt{2*hy$zRTu~G-%XQhoN8@aB9)>G5i8Yz&|na&C{9$fu(}fk z5pk{yt0*EW(uE){oEEKU#Ug^#O%+r`MYMxOi{h%S+G@lhNo{P4sJSlAedNBId+&RB zH{}s{AAFm0zTy8r!*_-#j$?CKy4nq(s&ojnTRH^VEgb^wmJWe-ONT(ar9+_IGUo|& zR}BzFQG|1`GSLgL2&Z8oj$jmH*o*Dhfn9MNAFY~S9LFsw&coCA9&ro$7hcEJ=x!ND z3&2<5;}$^|=>gn{GUV1DA)iy=IKubKe76W?Xp#xdM70bGb*GC|+OQf$t&Z>UW> zR_FLN(h2n85gbjmPvZU=6A&uH;Z&f%ack1opIJ@^aAMMTV++frruQZ>286wk?t4zb zzQ_YVB-HMj^UoMe^{>eUe=uq9!ygq0Y)R$l$F@xWlMM+7wS6q*^b^j?*sRM0UY@ky zQ~`X9-I+YSrkCrx3JKt3>`58_igR*i=;ch%+VXj(B7upVT&JinB9pkJh``2_;SmfJ z?2qR&iEqc*!d|FCV7wsT66{Oa{fGs11aN&O*li`%>CuWni}c%)oP+8M$7@*v@1;85 zP;wIDx?@CObAvPKp_J{vwOOcLrw2{Rc$|0?$cMrbn{9cgLI2yr9)NkmIh8vDF|KMb z8XL^+=>LY3O7eB%3pq1TGZ@YU96^6cz%$gJm{oOJdCcT{Dr3JXuL`}AG5WH>sN6G4 z;6Fi6NuHZC_WN;a(K)a$W4yH}I3m1M5&T{(E&+d~`sy=@OLNbG#9@VS3rr_@S8`b8 zB~G=Pc8;)9i8_FV1oN9DNN&YIl>s58`1ZuP@wz|1y5-b8QO(T&w;< z7YmoV%S)2Z6Ar{z6Whs@?;(7EyRoPw#sX~0A^oCeZ=GWr)%&-4)}FN;7bW4fzQO(IYCE-*H&*Ma7}<85-L$P$04j~=-)(w`NBrcdlMfN zD$yXiW&pY$YZX5?-^a3MV%4634JC@A0pVq|s=(w=;k4W??153CqMaaoaxE5qHZBrw zuX!?p$FVVP7FY{YewpxPQlWj2;#~2UbgdGg{H6mr#*J zQPhRyrv66H7v9GUP5q6I3Cn4Z@GrUpJL5R6e5 np.ndarray[M, N, D] + + where (M, N) are the image dimentions, 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) 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, From e6eecba865851530b6284945f4b877d5fc19aba3 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 16 Dec 2022 17:15:19 -0500 Subject: [PATCH 2/3] DOC: add demo code to example --- examples/user_interfaces/mplcvd.py | 47 +++++++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/examples/user_interfaces/mplcvd.py b/examples/user_interfaces/mplcvd.py index 6347108cb73d..e65cb7d26a3b 100644 --- a/examples/user_interfaces/mplcvd.py +++ b/examples/user_interfaces/mplcvd.py @@ -51,7 +51,7 @@ def _get_color_filter(name): def filter(input: np.ndarray[M, N, D])-> np.ndarray[M, N, D] - where (M, N) are the image dimentions, and D is the color depth (3 for + 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. """ @@ -252,3 +252,48 @@ def _setup_wx(tb): 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() From ac5f694672695a4b656cd920ce66948a9dda7f61 Mon Sep 17 00:00:00 2001 From: Thomas A Caswell Date: Fri, 16 Dec 2022 17:22:15 -0500 Subject: [PATCH 3/3] TST: add a test of figure.hooks --- lib/matplotlib/tests/test_pyplot.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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