From f29c3e0a7f1163fd603a72ca46a852c2a4de3680 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Sun, 30 Aug 2020 22:25:06 +0200 Subject: [PATCH] Standardize creation of FigureManager from a given FigureCanvas class. The `new_manager` classmethod may appear to belong more naturally to the FigureManager class rather than the FigureCanvas class, but putting it on FigureCanvas has multiple advantages: - One may want to create managers at times where all one has is a FigureCanvas instance, which may not even have a corresponding manager (e.g. `subplot_tool`). - A given FigureManager class can be associated with many different FigureCanvas classes (indeed, FigureManagerQT can manage both FigureCanvasQTAgg and FigureCanvasQTCairo and also the mplcairo Qt canvas classes), whereas we don't have multiple FigureManager classes for a given FigureCanvas class. Still, put the *actual* logic in FigureManager.create_with_canvas and access it via some indirection, as suggested by timhoffm. --- lib/matplotlib/backend_bases.py | 41 +++++++++-- lib/matplotlib/backends/_backend_tk.py | 77 ++++++++++----------- lib/matplotlib/backends/backend_gtk3.py | 1 + lib/matplotlib/backends/backend_gtk4.py | 1 + lib/matplotlib/backends/backend_macosx.py | 3 +- lib/matplotlib/backends/backend_nbagg.py | 32 ++++----- lib/matplotlib/backends/backend_qt.py | 1 + lib/matplotlib/backends/backend_template.py | 18 ++--- lib/matplotlib/backends/backend_webagg.py | 8 +-- lib/matplotlib/backends/backend_wx.py | 39 ++++++----- 10 files changed, 128 insertions(+), 93 deletions(-) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 0e7ee91a0a8c..2a75de525592 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -1580,6 +1580,13 @@ class FigureCanvasBase: # interactive framework is required, or None otherwise. required_interactive_framework = None + # The manager class instantiated by new_manager. + # (This is defined as a classproperty because the manager class is + # currently defined *after* the canvas class, but one could also assign + # ``FigureCanvasBase.manager_class = FigureManagerBase`` + # after defining both classes.) + manager_class = _api.classproperty(lambda cls: FigureManagerBase) + events = [ 'resize_event', 'draw_event', @@ -1662,6 +1669,19 @@ def _fix_ipython_backend2gui(cls): if _is_non_interactive_terminal_ipython(ip): ip.enable_gui(backend2gui_rif) + @classmethod + def new_manager(cls, figure, num): + """ + Create a new figure manager for *figure*, using this canvas class. + + Notes + ----- + This method should not be reimplemented in subclasses. If + custom manager creation logic is needed, please reimplement + ``FigureManager.create_with_canvas``. + """ + return cls.manager_class.create_with_canvas(cls, figure, num) + @contextmanager def _idle_draw_cntx(self): self._is_idle_drawing = True @@ -2759,6 +2779,16 @@ def notify_axes_change(fig): if self.toolmanager is None and self.toolbar is not None: self.toolbar.update() + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + """ + Create a manager for a given *figure* using a specific *canvas_class*. + + Backends should override this method if they have specific needs for + setting up the canvas or the manager. + """ + return cls(canvas_class(figure), num) + def show(self): """ For GUI backends, show the figure window and redraw. @@ -3225,11 +3255,10 @@ def configure_subplots(self, *args): if hasattr(self, "subplot_tool"): self.subplot_tool.figure.canvas.manager.show() return - plt = _safe_pyplot_import() + # This import needs to happen here due to circular imports. + from matplotlib.figure import Figure with mpl.rc_context({"toolbar": "none"}): # No navbar for the toolfig. - # Use new_figure_manager() instead of figure() so that the figure - # doesn't get registered with pyplot. - manager = plt.new_figure_manager(-1, (6, 3)) + manager = type(self.canvas).new_manager(Figure(figsize=(6, 3)), -1) manager.set_window_title("Subplot configuration tool") tool_fig = manager.canvas.figure tool_fig.subplots_adjust(top=0.9) @@ -3457,9 +3486,7 @@ def new_figure_manager(cls, num, *args, **kwargs): @classmethod def new_figure_manager_given_figure(cls, num, figure): """Create a new figure manager instance for the given figure.""" - canvas = cls.FigureCanvas(figure) - manager = cls.FigureManager(canvas, num) - return manager + return cls.FigureCanvas.new_manager(figure, num) @classmethod def draw_if_interactive(cls): diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index c759fe4840d6..713081155613 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -162,6 +162,7 @@ def _on_timer(self): class FigureCanvasTk(FigureCanvasBase): required_interactive_framework = "tk" + manager_class = _api.classproperty(lambda cls: FigureManagerTk) def __init__(self, figure=None, master=None): super().__init__(figure) @@ -431,6 +432,43 @@ def __init__(self, canvas, num, window): self._shown = False + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + # docstring inherited + with _restore_foreground_window_at_end(): + if cbook._get_running_interactive_framework() is None: + cbook._setup_new_guiapp() + _c_internal_utils.Win32_SetProcessDpiAwareness_max() + window = tk.Tk(className="matplotlib") + window.withdraw() + + # Put a Matplotlib icon on the window rather than the default tk + # icon. See https://www.tcl.tk/man/tcl/TkCmd/wm.html#M50 + # + # `ImageTk` can be replaced with `tk` whenever the minimum + # supported Tk version is increased to 8.6, as Tk 8.6+ natively + # supports PNG images. + icon_fname = str(cbook._get_data_path( + 'images/matplotlib.png')) + icon_img = ImageTk.PhotoImage(file=icon_fname, master=window) + + icon_fname_large = str(cbook._get_data_path( + 'images/matplotlib_large.png')) + icon_img_large = ImageTk.PhotoImage( + file=icon_fname_large, master=window) + try: + window.iconphoto(False, icon_img_large, icon_img) + except Exception as exc: + # log the failure (due e.g. to Tk version), but carry on + _log.info('Could not load matplotlib icon: %s', exc) + + canvas = canvas_class(figure, master=window) + manager = cls(canvas, num, window) + if mpl.is_interactive(): + manager.show() + canvas.draw_idle() + return manager + def _update_window_dpi(self, *args): newdpi = self._window_dpi.get() self.window.call('tk', 'scaling', newdpi / 72) @@ -958,45 +996,6 @@ def trigger(self, *args): class _BackendTk(_Backend): FigureManager = FigureManagerTk - @classmethod - def new_figure_manager_given_figure(cls, num, figure): - """ - Create a new figure manager instance for the given figure. - """ - with _restore_foreground_window_at_end(): - if cbook._get_running_interactive_framework() is None: - cbook._setup_new_guiapp() - _c_internal_utils.Win32_SetProcessDpiAwareness_max() - window = tk.Tk(className="matplotlib") - window.withdraw() - - # Put a Matplotlib icon on the window rather than the default tk - # icon. See https://www.tcl.tk/man/tcl/TkCmd/wm.html#M50 - # - # `ImageTk` can be replaced with `tk` whenever the minimum - # supported Tk version is increased to 8.6, as Tk 8.6+ natively - # supports PNG images. - icon_fname = str(cbook._get_data_path( - 'images/matplotlib.png')) - icon_img = ImageTk.PhotoImage(file=icon_fname, master=window) - - icon_fname_large = str(cbook._get_data_path( - 'images/matplotlib_large.png')) - icon_img_large = ImageTk.PhotoImage( - file=icon_fname_large, master=window) - try: - window.iconphoto(False, icon_img_large, icon_img) - except Exception as exc: - # log the failure (due e.g. to Tk version), but carry on - _log.info('Could not load matplotlib icon: %s', exc) - - canvas = cls.FigureCanvas(figure, master=window) - manager = cls.FigureManager(canvas, num, window) - if mpl.is_interactive(): - manager.show() - canvas.draw_idle() - return manager - @staticmethod def mainloop(): managers = Gcf.get_all_fig_managers() diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 877fd800c821..ea32e9c3cba0 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -71,6 +71,7 @@ def _mpl_to_gtk_cursor(mpl_cursor): class FigureCanvasGTK3(Gtk.DrawingArea, FigureCanvasBase): required_interactive_framework = "gtk3" _timer_cls = TimerGTK3 + manager_class = _api.classproperty(lambda cls: FigureManagerGTK3) # Setting this as a static constant prevents # this resulting expression from leaking event_mask = (Gdk.EventMask.BUTTON_PRESS_MASK diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py index 63663834ec00..4cf6c0c8090e 100644 --- a/lib/matplotlib/backends/backend_gtk4.py +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -33,6 +33,7 @@ class FigureCanvasGTK4(Gtk.DrawingArea, FigureCanvasBase): required_interactive_framework = "gtk4" supports_blit = False _timer_cls = TimerGTK4 + manager_class = _api.classproperty(lambda cls: FigureManagerGTK4) _context_is_scaled = False def __init__(self, figure=None): diff --git a/lib/matplotlib/backends/backend_macosx.py b/lib/matplotlib/backends/backend_macosx.py index 4700e28e48cc..1995e5985008 100644 --- a/lib/matplotlib/backends/backend_macosx.py +++ b/lib/matplotlib/backends/backend_macosx.py @@ -1,5 +1,5 @@ import matplotlib as mpl -from matplotlib import cbook +from matplotlib import _api, cbook from matplotlib._pylab_helpers import Gcf from . import _macosx from .backend_agg import FigureCanvasAgg @@ -25,6 +25,7 @@ class FigureCanvasMac(_macosx.FigureCanvas, FigureCanvasAgg): required_interactive_framework = "macosx" _timer_cls = TimerMac + manager_class = _api.classproperty(lambda cls: FigureManagerMac) def __init__(self, figure): FigureCanvasBase.__init__(self, figure) diff --git a/lib/matplotlib/backends/backend_nbagg.py b/lib/matplotlib/backends/backend_nbagg.py index ec1430e60d1b..6c98f0fd9cf5 100644 --- a/lib/matplotlib/backends/backend_nbagg.py +++ b/lib/matplotlib/backends/backend_nbagg.py @@ -78,6 +78,21 @@ def __init__(self, canvas, num): self._shown = False super().__init__(canvas, num) + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + canvas = canvas_class(figure) + manager = cls(canvas, num) + if is_interactive(): + manager.show() + canvas.draw_idle() + + def destroy(event): + canvas.mpl_disconnect(cid) + Gcf.destroy(manager) + + cid = canvas.mpl_connect('close_event', destroy) + return manager + def display_js(self): # XXX How to do this just once? It has to deal with multiple # browser instances using the same kernel (require.js - but the @@ -143,7 +158,7 @@ def remove_comm(self, comm_id): class FigureCanvasNbAgg(FigureCanvasWebAggCore): - pass + manager_class = FigureManagerNbAgg class CommSocket: @@ -228,21 +243,6 @@ class _BackendNbAgg(_Backend): FigureCanvas = FigureCanvasNbAgg FigureManager = FigureManagerNbAgg - @staticmethod - def new_figure_manager_given_figure(num, figure): - canvas = FigureCanvasNbAgg(figure) - manager = FigureManagerNbAgg(canvas, num) - if is_interactive(): - manager.show() - figure.canvas.draw_idle() - - def destroy(event): - canvas.mpl_disconnect(cid) - Gcf.destroy(manager) - - cid = canvas.mpl_connect('close_event', destroy) - return manager - @staticmethod def show(block=None): ## TODO: something to do when keyword block==False ? diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 27945af8a342..2340cbf66c3d 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -232,6 +232,7 @@ def _timer_stop(self): class FigureCanvasQT(QtWidgets.QWidget, FigureCanvasBase): required_interactive_framework = "qt" _timer_cls = TimerQT + manager_class = _api.classproperty(lambda cls: FigureManagerQT) buttond = { getattr(_enum("QtCore.Qt.MouseButton"), k): v for k, v in [ diff --git a/lib/matplotlib/backends/backend_template.py b/lib/matplotlib/backends/backend_template.py index 9a25371465af..ebb9b413f5c0 100644 --- a/lib/matplotlib/backends/backend_template.py +++ b/lib/matplotlib/backends/backend_template.py @@ -174,6 +174,14 @@ def new_figure_manager_given_figure(num, figure): return manager +class FigureManagerTemplate(FigureManagerBase): + """ + Helper class for pyplot mode, wraps everything up into a neat bundle. + + For non-interactive backends, the base class is sufficient. + """ + + class FigureCanvasTemplate(FigureCanvasBase): """ The canvas the figure renders into. Calls the draw and print fig @@ -191,6 +199,8 @@ class methods button_press_event, button_release_event, A high-level Figure instance """ + manager_class = FigureManagerTemplate + def draw(self): """ Draw the figure using the renderer. @@ -227,14 +237,6 @@ def get_default_filetype(self): return 'foo' -class FigureManagerTemplate(FigureManagerBase): - """ - Helper class for pyplot mode, wraps everything up into a neat bundle. - - For non-interactive backends, the base class is sufficient. - """ - - ######################################################################## # # Now just provide the standard names that backend.__init__ is expecting diff --git a/lib/matplotlib/backends/backend_webagg.py b/lib/matplotlib/backends/backend_webagg.py index a78e86c25af8..9e1e4925496f 100644 --- a/lib/matplotlib/backends/backend_webagg.py +++ b/lib/matplotlib/backends/backend_webagg.py @@ -48,14 +48,14 @@ def run(self): webagg_server_thread = ServerThread() -class FigureCanvasWebAgg(core.FigureCanvasWebAggCore): - pass - - class FigureManagerWebAgg(core.FigureManagerWebAgg): _toolbar2_class = core.NavigationToolbar2WebAgg +class FigureCanvasWebAgg(core.FigureCanvasWebAggCore): + manager_class = FigureManagerWebAgg + + class WebAggApplication(tornado.web.Application): initialized = False started = False diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index 37177cf6f60e..3eaf59e3f502 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -63,6 +63,15 @@ def error_msg_wx(msg, parent=None): return None +# lru_cache holds a reference to the App and prevents it from being gc'ed. +@functools.lru_cache(1) +def _create_wxapp(): + wxapp = wx.App(False) + wxapp.SetExitOnFrameDelete(True) + cbook._setup_new_guiapp() + return wxapp + + class TimerWx(TimerBase): """Subclass of `.TimerBase` using wx.Timer events.""" @@ -418,6 +427,7 @@ class _FigureCanvasWxBase(FigureCanvasBase, wx.Panel): required_interactive_framework = "wx" _timer_cls = TimerWx + manager_class = _api.classproperty(lambda cls: FigureManagerWx) keyvald = { wx.WXK_CONTROL: 'control', @@ -970,6 +980,17 @@ def __init__(self, canvas, num, frame): self.frame = self.window = frame super().__init__(canvas, num) + @classmethod + def create_with_canvas(cls, canvas_class, figure, num): + # docstring inherited + wxapp = wx.GetApp() or _create_wxapp() + frame = FigureFrameWx(num, figure, canvas_class=canvas_class) + manager = figure.canvas.manager + if mpl.is_interactive(): + manager.frame.Show() + figure.canvas.draw_idle() + return manager + def show(self): # docstring inherited self.frame.Show() @@ -1344,24 +1365,6 @@ class _BackendWx(_Backend): FigureCanvas = FigureCanvasWx FigureManager = FigureManagerWx - @classmethod - def new_figure_manager_given_figure(cls, num, figure): - # Create a wx.App instance if it has not been created so far. - wxapp = wx.GetApp() - if wxapp is None: - wxapp = wx.App() - wxapp.SetExitOnFrameDelete(True) - cbook._setup_new_guiapp() - # Retain a reference to the app object so that it does not get - # garbage collected. - _BackendWx._theWxApp = wxapp - # Attaches figure.canvas, figure.canvas.manager. - frame = FigureFrameWx(num, figure, canvas_class=cls.FigureCanvas) - if mpl.is_interactive(): - frame.Show() - figure.canvas.draw_idle() - return figure.canvas.manager - @staticmethod def mainloop(): if not wx.App.IsMainLoopRunning():