diff --git a/lib/matplotlib/meson.build b/lib/matplotlib/meson.build index c4746f332bcb..8010e82f48c8 100644 --- a/lib/matplotlib/meson.build +++ b/lib/matplotlib/meson.build @@ -161,6 +161,7 @@ subdir('_api') subdir('axes') subdir('backends') subdir('mpl-data') +subdir('mpl_gui') subdir('projections') subdir('sphinxext') subdir('style') diff --git a/lib/matplotlib/mpl_gui/__init__.py b/lib/matplotlib/mpl_gui/__init__.py new file mode 100644 index 000000000000..69217234d1ef --- /dev/null +++ b/lib/matplotlib/mpl_gui/__init__.py @@ -0,0 +1,352 @@ +""" +Prototype project for new Matplotlib GUI management. + +The pyplot module current serves two critical, but unrelated functions: + +1. provide a state-full implicit API that rhymes / was inspired by MATLAB +2. provide the management of interaction between Matplotlib and the GUI event + loop + +This project is prototype for separating the second function from the first. +This will enable users to both only use the explicit API (nee OO interface) and +to have smooth integration with the GUI event loop as with pyplot. + +""" +from collections import Counter +from itertools import count +import functools +import logging +import warnings + +from matplotlib.backend_bases import FigureCanvasBase as _FigureCanvasBase + +from ._figure import Figure # noqa: F401 + +from ._manage_interactive import ion, ioff, is_interactive # noqa: F401 +from ._manage_backend import select_gui_toolkit # noqa: F401 +from ._manage_backend import current_backend_module as _cbm +from ._promotion import promote_figure as promote_figure +from ._creation import figure, subplots, subplot_mosaic # noqa: F401 + +_log = logging.getLogger(__name__) + + +def show(figs, *, block=None, timeout=0): + """ + Show the figures and maybe block. + + Parameters + ---------- + figs : List[Figure] + The figures to show. If they do not currently have a GUI aware + canvas + manager attached they will be promoted. + + block : bool, optional + Whether to wait for all figures to be closed before returning. + + If `True` block and run the GUI main loop until all figure windows + are closed. + + If `False` ensure that all figure windows are displayed and return + immediately. In this case, you are responsible for ensuring + that the event loop is running to have responsive figures. + + Defaults to True in non-interactive mode and to False in interactive + mode (see `.is_interactive`). + + """ + # TODO handle single figure + + # call this to ensure a backend is indeed selected + backend = _cbm() + managers = [] + for fig in figs: + if fig.canvas.manager is not None: + managers.append(fig.canvas.manager) + else: + managers.append(promote_figure(fig)) + + if block is None: + block = not is_interactive() + + if block and len(managers): + if timeout == 0: + backend.show_managers(managers=managers, block=block) + elif len(managers): + manager, *_ = managers + manager.canvas.start_event_loop(timeout=timeout) + + +class FigureRegistry: + """ + A registry to wrap the creation of figures and track them. + + This instance will keep a hard reference to created Figures to ensure + that they do not get garbage collected. + + Parameters + ---------- + block : bool, optional + Whether to wait for all figures to be closed before returning from + show_all. + + If `True` block and run the GUI main loop until all figure windows + are closed. + + If `False` ensure that all figure windows are displayed and return + immediately. In this case, you are responsible for ensuring + that the event loop is running to have responsive figures. + + Defaults to True in non-interactive mode and to False in interactive + mode (see `.is_interactive`). + + timeout : float, optional + Default time to wait for all of the Figures to be closed if blocking. + + If 0 block forever. + + """ + + def __init__(self, *, block=None, timeout=0, prefix="Figure "): + # settings stashed to set defaults on show + self._timeout = timeout + self._block = block + # Settings / state to control the default figure label + self._count = count() + self._prefix = prefix + # the canonical location for storing the Figures this registry owns. + # any additional views must never include a figure not in the list but + # may omit figures + self.figures = [] + + def _register_fig(self, fig): + # if the user closes the figure by any other mechanism, drop our + # reference to it. This is important for getting a "pyplot" like user + # experience + fig.canvas.mpl_connect( + "close_event", + lambda e: self.figures.remove(fig) if fig in self.figures else None, + ) + # hold a hard reference to the figure. + self.figures.append(fig) + # Make sure we give the figure a quasi-unique label. We will never set + # the same label twice, but will not over-ride any user label (but + # empty string) on a Figure so if they provide duplicate labels, change + # the labels under us, or provide a label that will be shadowed in the + # future it will be what it is. + fignum = next(self._count) + if fig.get_label() == "": + fig.set_label(f"{self._prefix}{fignum:d}") + # TODO: is there a better way to track this than monkey patching? + fig._mpl_gui_fignum = fignum + return fig + + @property + def by_label(self): + """ + Return a dictionary of the current mapping labels -> figures. + + If there are duplicate labels, newer figures will take precedence. + """ + mapping = {fig.get_label(): fig for fig in self.figures} + if len(mapping) != len(self.figures): + counts = Counter(fig.get_label() for fig in self.figures) + multiples = {k: v for k, v in counts.items() if v > 1} + warnings.warn( + ( + f"There are repeated labels ({multiples!r}), but only the newest" + "figure with that label can be returned. " + ), + stacklevel=2, + ) + return mapping + + @property + def by_number(self): + """ + Return a dictionary of the current mapping number -> figures. + + """ + self._ensure_all_figures_promoted() + return {fig.canvas.manager.num: fig for fig in self.figures} + + @functools.wraps(figure) + def figure(self, *args, **kwargs): + fig = figure(*args, **kwargs) + return self._register_fig(fig) + + @functools.wraps(subplots) + def subplots(self, *args, **kwargs): + fig, axs = subplots(*args, **kwargs) + return self._register_fig(fig), axs + + @functools.wraps(subplot_mosaic) + def subplot_mosaic(self, *args, **kwargs): + fig, axd = subplot_mosaic(*args, **kwargs) + return self._register_fig(fig), axd + + def _ensure_all_figures_promoted(self): + for f in self.figures: + if f.canvas.manager is None: + promote_figure(f, num=f._mpl_gui_fignum) + + def show_all(self, *, block=None, timeout=None): + """ + Show all of the Figures that the FigureRegistry knows about. + + Parameters + ---------- + block : bool, optional + Whether to wait for all figures to be closed before returning from + show_all. + + If `True` block and run the GUI main loop until all figure windows + are closed. + + If `False` ensure that all figure windows are displayed and return + immediately. In this case, you are responsible for ensuring + that the event loop is running to have responsive figures. + + Defaults to the value set on the Registry at init + + timeout : float, optional + time to wait for all of the Figures to be closed if blocking. + + If 0 block forever. + + Defaults to the timeout set on the Registry at init + """ + if block is None: + block = self._block + + if timeout is None: + timeout = self._timeout + self._ensure_all_figures_promoted() + show(self.figures, block=self._block, timeout=self._timeout) + + # alias to easy pyplot compatibility + show = show_all + + def close_all(self): + """ + Close all Figures know to this Registry. + + This will do four things: + + 1. call the ``.destroy()`` method on the manager + 2. clears the Figure on the canvas instance + 3. replace the canvas on each Figure with a new `~matplotlib.backend_bases. + FigureCanvasBase` instance + 4. drops its hard reference to the Figure + + If the user still holds a reference to the Figure it can be revived by + passing it to `show`. + + """ + for fig in list(self.figures): + self.close(fig) + + def close(self, val): + """ + Close (meaning destroy the UI) and forget a managed Figure. + + This will do two things: + + - start the destruction process of an UI (the event loop may need to + run to complete this process and if the user is holding hard + references to any of the UI elements they may remain alive). + - Remove the `Figure` from this Registry. + + We will no longer have any hard references to the Figure, but if + the user does the `Figure` (and its components) will not be garbage + collected. Due to the circular references in Matplotlib these + objects may not be collected until the full cyclic garbage collection + runs. + + If the user still has a reference to the `Figure` they can re-show the + figure via `show`, but the `FigureRegistry` will not be aware of it. + + Parameters + ---------- + val : 'all' or int or str or Figure + + - The special case of 'all' closes all open Figures + - If any other string is passed, it is interpreted as a key in + `by_label` and that Figure is closed + - If an integer it is interpreted as a key in `by_number` and that + Figure is closed + - If it is a `Figure` instance, then that figure is closed + + """ + if val == "all": + return self.close_all() + # or do we want to close _all_ of the figures with a given label / number? + if isinstance(val, str): + fig = self.by_label[val] + elif isinstance(val, int): + fig = self.by_number[val] + else: + fig = val + if fig not in self.figures: + raise ValueError( + "Trying to close a figure not associated with this Registry." + ) + if fig.canvas.manager is not None: + fig.canvas.manager.destroy() + # disconnect figure from canvas + fig.canvas.figure = None + # disconnect canvas from figure + _FigureCanvasBase(figure=fig) + assert fig.canvas.manager is None + if fig in self.figures: + self.figures.remove(fig) + + +class FigureContext(FigureRegistry): + """ + Extends FigureRegistry to be used as a context manager. + + All figures known to the Registry will be shown on exiting the context. + + Parameters + ---------- + block : bool, optional + Whether to wait for all figures to be closed before returning from + show_all. + + If `True` block and run the GUI main loop until all figure windows + are closed. + + If `False` ensure that all figure windows are displayed and return + immediately. In this case, you are responsible for ensuring + that the event loop is running to have responsive figures. + + Defaults to True in non-interactive mode and to False in interactive + mode (see `.is_interactive`). + + timeout : float, optional + Default time to wait for all of the Figures to be closed if blocking. + + If 0 block forever. + + forgive_failure : bool, optional + If True, block to show the figure before letting the exception + propagate + + """ + + def __init__(self, *, forgive_failure=False, **kwargs): + super().__init__(**kwargs) + self._forgive_failure = forgive_failure + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + if exc_value is not None and not self._forgive_failure: + return + show(self.figures, block=self._block, timeout=self._timeout) + + +# from mpl_gui import * # is a language mis-feature +__all__ = [] diff --git a/lib/matplotlib/mpl_gui/_creation.py b/lib/matplotlib/mpl_gui/_creation.py new file mode 100644 index 000000000000..f884ad1e5fa2 --- /dev/null +++ b/lib/matplotlib/mpl_gui/_creation.py @@ -0,0 +1,319 @@ +"""Helpers to create new Figures.""" + +from matplotlib import is_interactive + +from ._figure import Figure +from ._promotion import promote_figure + + +def figure( + *, + label=None, # autoincrement if None, else integer from 1-N + figsize=None, # defaults to rc figure.figsize + dpi=None, # defaults to rc figure.dpi + facecolor=None, # defaults to rc figure.facecolor + edgecolor=None, # defaults to rc figure.edgecolor + frameon=True, + FigureClass=Figure, + clear=False, + auto_draw=True, + **kwargs, +): + """ + Create a new figure + + Parameters + ---------- + label : str, optional + Label for the figure. Will be used as the window title + + figsize : (float, float), default: :rc:`figure.figsize` + Width, height in inches. + + dpi : float, default: :rc:`figure.dpi` + The resolution of the figure in dots-per-inch. + + facecolor : color, default: :rc:`figure.facecolor` + The background color. + + edgecolor : color, default: :rc:`figure.edgecolor` + The border color. + + frameon : bool, default: True + If False, suppress drawing the figure frame. + + FigureClass : subclass of `~matplotlib.figure.Figure` + Optionally use a custom `~matplotlib.figure.Figure` instance. + + tight_layout : bool or dict, default: :rc:`figure.autolayout` + If ``False`` use *subplotpars*. If ``True`` adjust subplot parameters + using `~matplotlib.figure.Figure.tight_layout` with default padding. + When providing a dict containing the keys ``pad``, ``w_pad``, + ``h_pad``, and ``rect``, the default + `~matplotlib.figure.Figure.tight_layout` paddings will be overridden. + + **kwargs : optional + See `~.matplotlib.figure.Figure` for other possible arguments. + + Returns + ------- + `~matplotlib.figure.Figure` + The `~matplotlib.figure.Figure` instance returned will also be passed + to new_figure_manager in the backends, which allows to hook custom + `~matplotlib.figure.Figure` classes into the pyplot + interface. Additional kwargs will be passed to the + `~matplotlib.figure.Figure` init function. + + """ + + fig = FigureClass( + label=label, + figsize=figsize, + dpi=dpi, + facecolor=facecolor, + edgecolor=edgecolor, + frameon=frameon, + **kwargs, + ) + if is_interactive(): + promote_figure(fig, auto_draw=auto_draw) + return fig + + +def subplots( + nrows=1, + ncols=1, + *, + sharex=False, + sharey=False, + squeeze=True, + subplot_kw=None, + gridspec_kw=None, + **fig_kw, +): + """ + Create a figure and a set of subplots. + + This utility wrapper makes it convenient to create common layouts of + subplots, including the enclosing figure object, in a single call. + + Parameters + ---------- + nrows, ncols : int, default: 1 + Number of rows/columns of the subplot grid. + + sharex, sharey : bool or {'none', 'all', 'row', 'col'}, default: False + Controls sharing of properties among x (*sharex*) or y (*sharey*) + axes: + + - True or 'all': x- or y-axis will be shared among all subplots. + - False or 'none': each subplot x- or y-axis will be independent. + - 'row': each subplot row will share an x- or y-axis. + - 'col': each subplot column will share an x- or y-axis. + + When subplots have a shared x-axis along a column, only the x tick + labels of the bottom subplot are created. Similarly, when subplots + have a shared y-axis along a row, only the y tick labels of the first + column subplot are created. To later turn other subplots' ticklabels + on, use `~matplotlib.axes.Axes.tick_params`. + + When subplots have a shared axis that has units, calling + `~matplotlib.axis.Axis.set_units` will update each axis with the + new units. + + squeeze : bool, default: True + - If True, extra dimensions are squeezed out from the returned + array of `~matplotlib.axes.Axes`: + + - if only one subplot is constructed (nrows=ncols=1), the + resulting single Axes object is returned as a scalar. + - for Nx1 or 1xM subplots, the returned object is a 1D numpy + object array of Axes objects. + - for NxM, subplots with N>1 and M>1 are returned as a 2D array. + + - If False, no squeezing at all is done: the returned Axes object is + always a 2D array containing Axes instances, even if it ends up + being 1x1. + + subplot_kw : dict, optional + Dict with keywords passed to the + `~matplotlib.figure.Figure.add_subplot` call used to create each + subplot. + + gridspec_kw : dict, optional + Dict with keywords passed to the `~matplotlib.gridspec.GridSpec` + constructor used to create the grid the subplots are placed on. + + **fig_kw + All additional keyword arguments are passed to the + `.figure` call. + + Returns + ------- + fig : `~matplotlib.figure.Figure` + + ax : `~matplotlib.axes.Axes` or array of Axes + *ax* can be either a single `~matplotlib.axes.Axes` object or an + array of Axes objects if more than one subplot was created. The + dimensions of the resulting array can be controlled with the squeeze + keyword, see above. + + Typical idioms for handling the return value are:: + + # using the variable ax for single a Axes + fig, ax = plt.subplots() + + # using the variable axs for multiple Axes + fig, axs = plt.subplots(2, 2) + + # using tuple unpacking for multiple Axes + fig, (ax1, ax2) = plt.subplots(1, 2) + fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2) + + The names ``ax`` and pluralized ``axs`` are preferred over ``axes`` + because for the latter it's not clear if it refers to a single + `~matplotlib.axes.Axes` instance or a collection of these. + + See Also + -------- + + matplotlib.figure.Figure.subplots + matplotlib.figure.Figure.add_subplot + + Examples + -------- + :: + + # First create some toy data: + x = np.linspace(0, 2*np.pi, 400) + y = np.sin(x**2) + + # Create just a figure and only one subplot + fig, ax = plt.subplots() + ax.plot(x, y) + ax.set_title('Simple plot') + + # Create two subplots and unpack the output array immediately + f, (ax1, ax2) = plt.subplots(1, 2, sharey=True) + ax1.plot(x, y) + ax1.set_title('Sharing Y axis') + ax2.scatter(x, y) + + # Create four polar axes and access them through the returned array + fig, axs = plt.subplots(2, 2, subplot_kw=dict(projection="polar")) + axs[0, 0].plot(x, y) + axs[1, 1].scatter(x, y) + + # Share a X axis with each column of subplots + plt.subplots(2, 2, sharex='col') + + # Share a Y axis with each row of subplots + plt.subplots(2, 2, sharey='row') + + # Share both X and Y axes with all subplots + plt.subplots(2, 2, sharex='all', sharey='all') + + # Note that this is the same as + plt.subplots(2, 2, sharex=True, sharey=True) + + # Create figure number 10 with a single subplot + # and clears it if it already exists. + fig, ax = plt.subplots(num=10, clear=True) + + """ + fig = figure(**fig_kw) + axs = fig.subplots( + nrows=nrows, + ncols=ncols, + sharex=sharex, + sharey=sharey, + squeeze=squeeze, + subplot_kw=subplot_kw, + gridspec_kw=gridspec_kw, + ) + return fig, axs + + +def subplot_mosaic( + layout, *, subplot_kw=None, gridspec_kw=None, empty_sentinel=".", **fig_kw +): + """ + Build a layout of Axes based on ASCII art or nested lists. + + This is a helper function to build complex `~matplotlib.gridspec.GridSpec` + layouts visually. + + .. note :: + + This API is provisional and may be revised in the future based on + early user feedback. + + + Parameters + ---------- + layout : list of list of {hashable or nested} or str + + A visual layout of how you want your Axes to be arranged + labeled as strings. For example :: + + x = [['A panel', 'A panel', 'edge'], + ['C panel', '.', 'edge']] + + Produces 4 axes: + + - 'A panel' which is 1 row high and spans the first two columns + - 'edge' which is 2 rows high and is on the right edge + - 'C panel' which in 1 row and 1 column wide in the bottom left + - a blank space 1 row and 1 column wide in the bottom center + + Any of the entries in the layout can be a list of lists + of the same form to create nested layouts. + + If input is a str, then it must be of the form :: + + ''' + AAE + C.E + ''' + + where each character is a column and each line is a row. + This only allows only single character Axes labels and does + not allow nesting but is very terse. + + subplot_kw : dict, optional + Dictionary with keywords passed to the `~matplotlib.figure.Figure.add_subplot` + call used to create each subplot. + + gridspec_kw : dict, optional + Dictionary with keywords passed to the `~matplotlib.gridspec.GridSpec` + constructor used to create the grid the subplots are placed on. + + empty_sentinel : object, optional + Entry in the layout to mean "leave this space empty". Defaults + to ``'.'``. Note, if *layout* is a string, it is processed via + `inspect.cleandoc` to remove leading white space, which may + interfere with using white-space as the empty sentinel. + + **fig_kw + All additional keyword arguments are passed to the + `.figure` call. + + Returns + ------- + fig : `~matplotlib.figure.Figure` + The new figure + + dict[label, Axes] + A dictionary mapping the labels to the Axes objects. The order of + the axes is left-to-right and top-to-bottom of their position in the + total layout. + + """ + fig = figure(**fig_kw) + ax_dict = fig.subplot_mosaic( + layout, + subplot_kw=subplot_kw, + gridspec_kw=gridspec_kw, + empty_sentinel=empty_sentinel, + ) + return fig, ax_dict diff --git a/lib/matplotlib/mpl_gui/_figure.py b/lib/matplotlib/mpl_gui/_figure.py new file mode 100644 index 000000000000..0d949f77dc28 --- /dev/null +++ b/lib/matplotlib/mpl_gui/_figure.py @@ -0,0 +1,14 @@ +"""Locally patch Figure to accept a label kwarg.""" + + +from matplotlib.figure import Figure as _Figure + + +class Figure(_Figure): + """Thin sub-class of Figure to accept a label on init.""" + + def __init__(self, *args, label=None, **kwargs): + # docstring inherited + super().__init__(*args, **kwargs) + if label is not None: + self.set_label(label) diff --git a/lib/matplotlib/mpl_gui/_manage_backend.py b/lib/matplotlib/mpl_gui/_manage_backend.py new file mode 100644 index 000000000000..0b05f5adb0f2 --- /dev/null +++ b/lib/matplotlib/mpl_gui/_manage_backend.py @@ -0,0 +1,163 @@ +import sys +import logging +import types + +from matplotlib import cbook, rcsetup +from matplotlib import rcParams, rcParamsDefault +import matplotlib.backend_bases + +from matplotlib.backends import backend_registry + + +_backend_mod = None + +_log = logging.getLogger(__name__) + + +def current_backend_module(): + """ + Get the currently active backend module, selecting one if needed. + + Returns + ------- + matplotlib.backend_bases._Backend + """ + if _backend_mod is None: + select_gui_toolkit() + return _backend_mod + + +def select_gui_toolkit(newbackend=None): + """ + Select the GUI toolkit to use. + + The argument is case-insensitive. Switching between GUI toolkits is + possible only if no event loop for another interactive backend has started. + Switching to and from non-interactive backends is always possible. + + Parameters + ---------- + newbackend : Union[str, _Backend] + The name of the backend to use or a _Backend class to use. + + Returns + ------- + _Backend + The backend selected. + + """ + global _backend_mod + + # work-around the sentinel resolution in Matplotlib 😱 + if newbackend is None: + newbackend = dict.__getitem__(rcParams, "backend") + + if newbackend is rcsetup._auto_backend_sentinel: + current_framework = cbook._get_running_interactive_framework() + if (current_framework and + (backend := backend_registry.backend_for_gui_framework( + current_framework))): + candidates = [backend] + else: + candidates = [] + candidates += [ + "macosx", "qtagg", "gtk4agg", "gtk3agg", "tkagg", "wxagg"] + + # Don't try to fallback on the cairo-based backends as they each have + # an additional dependency (pycairo) over the agg-based backend, and + # are of worse quality. + for candidate in candidates: + try: + return select_gui_toolkit(candidate) + except ImportError: + continue + + else: + # Switching to Agg should always succeed; if it doesn't, let the + # exception propagate out. + return select_gui_toolkit("agg") + + if isinstance(newbackend, str): + # Backends are implemented as modules, but "inherit" default method + # implementations from backend_bases._Backend. This is achieved by + # creating a "class" that inherits from backend_bases._Backend and whose + # body is filled with the module's globals. + + backend_name = backend_registry.resolve_gui_or_backend(newbackend)[0] + mod = backend_registry.load_backend_module(newbackend) + if hasattr(mod, "Backend"): + orig_class = mod.Backend + + else: + class orig_class(matplotlib.backend_bases._Backend): + locals().update(vars(mod)) + + @classmethod + def mainloop(cls): + return mod.Show().mainloop() + + class BackendClass(orig_class): + @classmethod + def show_managers(cls, *, managers, block): + if not managers: + return + for manager in managers: + manager.show() # Emits a warning for non-interactive backend + manager.canvas.draw_idle() + if cls.mainloop is None: + return + if block: + try: + cls.FigureManager._active_managers = managers + cls.mainloop() + finally: + cls.FigureManager._active_managers = None + + if not hasattr(BackendClass.FigureManager, "_active_managers"): + BackendClass.FigureManager._active_managers = None + rc_params_string = newbackend + + else: + BackendClass = newbackend + mod_name = f"_backend_mod_{id(BackendClass)}" + rc_params_string = f"module://{mod_name}" + mod = types.ModuleType(mod_name) + mod.Backend = BackendClass + sys.modules[mod_name] = mod + + canvas_class = mod.FigureCanvas + required_framework = canvas_class.required_interactive_framework + if required_framework is not None: + current_framework = cbook._get_running_interactive_framework() + if ( + current_framework + and required_framework + and current_framework != required_framework + ): + raise ImportError( + "Cannot load backend {!r} which requires the {!r} interactive " + "framework, as {!r} is currently running".format( + newbackend, required_framework, current_framework + ) + ) + + _log.debug( + "Loaded backend %s version %s.", newbackend, BackendClass.backend_version + ) + + rcParams["backend"] = rcParamsDefault["backend"] = rc_params_string + + # is IPython imported? + mod_ipython = sys.modules.get("IPython") + if mod_ipython: + # if so are we in an IPython session + ip = mod_ipython.get_ipython() + if ip: + # macosx -> osx mapping for the osx backend in ipython + if required_framework == "macosx": + required_framework = "osx" + ip.enable_gui(required_framework) + + # remember to set the global variable + _backend_mod = BackendClass + return BackendClass diff --git a/lib/matplotlib/mpl_gui/_manage_interactive.py b/lib/matplotlib/mpl_gui/_manage_interactive.py new file mode 100644 index 000000000000..e66e6826c87b --- /dev/null +++ b/lib/matplotlib/mpl_gui/_manage_interactive.py @@ -0,0 +1,149 @@ +"""Module for managing if we are "interactive" or not.""" +from matplotlib import is_interactive as _is_interact, interactive as _interactive + + +def is_interactive(): + """ + Return whether plots are updated after every plotting command. + + The interactive mode is mainly useful if you build plots from the command + line and want to see the effect of each command while you are building the + figure. + + In interactive mode: + + - newly created figures will be shown immediately; + - figures will automatically redraw on change; + - `mpl_gui.show` will not block by default. + - `mpl_gui.FigureContext` will not block on ``__exit__`` by default. + + In non-interactive mode: + + - newly created figures and changes to figures will not be reflected until + explicitly asked to be; + - `mpl_gui.show` will block by default. + - `mpl_gui.FigureContext` will block on ``__exit__`` by default. + + See Also + -------- + ion : Enable interactive mode. + ioff : Disable interactive mode. + show : Show all figures (and maybe block). + """ + return _is_interact() + + +class _IoffContext: + """ + Context manager for `.ioff`. + + The state is changed in ``__init__()`` instead of ``__enter__()``. The + latter is a no-op. This allows using `.ioff` both as a function and + as a context. + """ + + def __init__(self): + self.wasinteractive = is_interactive() + _interactive(False) + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + if self.wasinteractive: + _interactive(True) + else: + _interactive(False) + + +class _IonContext: + """ + Context manager for `.ion`. + + The state is changed in ``__init__()`` instead of ``__enter__()``. The + latter is a no-op. This allows using `.ion` both as a function and + as a context. + """ + + def __init__(self): + self.wasinteractive = is_interactive() + _interactive(True) + + def __enter__(self): + pass + + def __exit__(self, exc_type, exc_value, traceback): + if not self.wasinteractive: + _interactive(False) + else: + _interactive(True) + + +def ioff(): + """ + Disable interactive mode. + + See `.is_interactive` for more details. + + See Also + -------- + ion : Enable interactive mode. + is_interactive : Whether interactive mode is enabled. + show : Show all figures (and maybe block). + + Notes + ----- + For a temporary change, this can be used as a context manager:: + + # if interactive mode is on + # then figures will be shown on creation + mg.ion() + # This figure will be shown immediately + fig = mg.figure() + + with mg.ioff(): + # interactive mode will be off + # figures will not automatically be shown + fig2 = mg.figure() + # ... + + To enable usage as a context manager, this function returns an + ``_IoffContext`` object. The return value is not intended to be stored + or accessed by the user. + """ + return _IoffContext() + + +def ion(): + """ + Enable interactive mode. + + See `.is_interactive` for more details. + + See Also + -------- + ioff : Disable interactive mode. + is_interactive : Whether interactive mode is enabled. + show : Show all figures (and maybe block). + + Notes + ----- + For a temporary change, this can be used as a context manager:: + + # if interactive mode is off + # then figures will not be shown on creation + mg.ioff() + # This figure will not be shown immediately + fig = mg.figure() + + with mg.ion(): + # interactive mode will be on + # figures will automatically be shown + fig2 = mg.figure() + # ... + + To enable usage as a context manager, this function returns an + ``_IonContext`` object. The return value is not intended to be stored + or accessed by the user. + """ + return _IonContext() diff --git a/lib/matplotlib/mpl_gui/_patched_backends/__init__.py b/lib/matplotlib/mpl_gui/_patched_backends/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lib/matplotlib/mpl_gui/_patched_backends/tkagg.py b/lib/matplotlib/mpl_gui/_patched_backends/tkagg.py new file mode 100644 index 000000000000..027ac9940d39 --- /dev/null +++ b/lib/matplotlib/mpl_gui/_patched_backends/tkagg.py @@ -0,0 +1,77 @@ +from contextlib import contextmanager + +import matplotlib as mpl +from matplotlib import _c_internal_utils +from matplotlib.backends.backend_tkagg import ( + _BackendTkAgg, + FigureManagerTk as _FigureManagerTk, +) + + +@contextmanager +def _restore_foreground_window_at_end(): + foreground = _c_internal_utils.Win32_GetForegroundWindow() + try: + yield + finally: + if mpl.rcParams["tk.window_focus"]: + _c_internal_utils.Win32_SetForegroundWindow(foreground) + + +class FigureManagerTk(_FigureManagerTk): + _active_managers = None + + def show(self): + with _restore_foreground_window_at_end(): + if not self._shown: + self.window.protocol("WM_DELETE_WINDOW", self.destroy) + self.window.deiconify() + self.canvas._tkcanvas.focus_set() + else: + self.canvas.draw_idle() + if mpl.rcParams["figure.raise_window"]: + self.canvas.manager.window.attributes("-topmost", 1) + self.canvas.manager.window.attributes("-topmost", 0) + self._shown = True + + def destroy(self, *args): + if self.canvas._idle_draw_id: + self.canvas._tkcanvas.after_cancel(self.canvas._idle_draw_id) + if self.canvas._event_loop_id: + self.canvas._tkcanvas.after_cancel(self.canvas._event_loop_id) + + # NOTE: events need to be flushed before issuing destroy (GH #9956), + # however, self.window.update() can break user code. This is the + # safest way to achieve a complete draining of the event queue, + # but it may require users to update() on their own to execute the + # completion in obscure corner cases. + def delayed_destroy(): + self.window.destroy() + + if self._owns_mainloop and not self._active_managers: + self.window.quit() + + # "after idle after 0" avoids Tcl error/race (GH #19940) + self.window.after_idle(self.window.after, 0, delayed_destroy) + + +@_BackendTkAgg.export +class _PatchedBackendTkAgg(_BackendTkAgg): + @classmethod + def mainloop(cls): + managers = cls.FigureManager._active_managers + if managers: + first_manager = managers[0] + manager_class = type(first_manager) + if manager_class._owns_mainloop: + return + manager_class._owns_mainloop = True + try: + first_manager.window.mainloop() + finally: + manager_class._owns_mainloop = False + + FigureManager = FigureManagerTk + + +Backend = _PatchedBackendTkAgg diff --git a/lib/matplotlib/mpl_gui/_promotion.py b/lib/matplotlib/mpl_gui/_promotion.py new file mode 100644 index 000000000000..a6261d8b3867 --- /dev/null +++ b/lib/matplotlib/mpl_gui/_promotion.py @@ -0,0 +1,102 @@ +"""State and logic to promote a Figure -> a GUI window.""" + +import threading +import itertools + +import matplotlib as mpl +from matplotlib import is_interactive +from matplotlib.cbook import _api +from matplotlib.backend_bases import FigureCanvasBase +from ._manage_backend import current_backend_module + + +_figure_count = itertools.count() + + +def _auto_draw_if_interactive(fig, val): + """ + An internal helper function for making sure that auto-redrawing + works as intended in the plain python repl. + + Parameters + ---------- + fig : Figure + A figure object which is assumed to be associated with a canvas + """ + if ( + val + and is_interactive() + and not fig.canvas.is_saving() + and not fig.canvas._is_idle_drawing + ): + # Some artists can mark themselves as stale in the middle of drawing + # (e.g. axes position & tick labels being computed at draw time), but + # this shouldn't trigger a redraw because the current redraw will + # already take them into account. + with fig.canvas._idle_draw_cntx(): + fig.canvas.draw_idle() + + +def promote_figure(fig, *, auto_draw=True, num=None): + """Create a new figure manager instance.""" + _backend_mod = current_backend_module() + + if ( + getattr(_backend_mod.FigureCanvas, "required_interactive_framework", None) + and threading.current_thread() is not threading.main_thread() + ): + _api.warn_external( + "Starting a Matplotlib GUI outside of the main thread will likely fail." + ) + + if fig.canvas.manager is not None: + if not isinstance(fig.canvas.manager, _backend_mod.FigureManager): + raise Exception("Figure already has a manager an it is the wrong type!") + else: + # TODO is this the right behavior? + return fig.canvas.manager + # TODO: do we want to make sure we poison / destroy / decouple the existing + # canavs? + next_num = next(_figure_count) + manager = _backend_mod.new_figure_manager_given_figure( + num if num is not None else next_num, fig + ) + if fig.get_label(): + manager.set_window_title(fig.get_label()) + + if auto_draw: + fig.stale_callback = _auto_draw_if_interactive + + if is_interactive(): + manager.show() + fig.canvas.draw_idle() + + # HACK: the callback in backend_bases uses GCF.destroy which misses these + # figures by design! + def _destroy(event): + + if event.key in mpl.rcParams["keymap.quit"]: + # grab the manager off the event + mgr = event.canvas.manager + if mgr is None: + raise RuntimeError("Should never be here, please report a bug") + fig = event.canvas.figure + # remove this callback. Callbacks lives on the Figure so survive + # the canvas being replaced. + old_cid = getattr(mgr, "_destroy_cid", None) + if old_cid is not None: + fig.canvas.mpl_disconnect(old_cid) + mgr._destroy_cid = None + # close the window + mgr.destroy() + # disconnect the manager from the canvas + fig.canvas.manager = None + # reset the dpi + fig.dpi = getattr(fig, "_original_dpi", fig.dpi) + # Go back to "base" canvas + # (this sets state on fig in the canvas init) + FigureCanvasBase(fig) + + manager._destroy_cid = fig.canvas.mpl_connect("key_press_event", _destroy) + + return manager diff --git a/lib/matplotlib/mpl_gui/meson.build b/lib/matplotlib/mpl_gui/meson.build new file mode 100644 index 000000000000..59e96bff9148 --- /dev/null +++ b/lib/matplotlib/mpl_gui/meson.build @@ -0,0 +1,15 @@ +python_sources = [ + '__init__.py', + '_creation.py', + '_figure.py', + '_manage_backend.py', + '_manage_interactive.py', + '_promotion.py', + 'registry.py' +] + +typing_sources = [ +] + +py3.install_sources(python_sources, typing_sources, + subdir: 'matplotlib/mpl_gui') diff --git a/lib/matplotlib/mpl_gui/registry.py b/lib/matplotlib/mpl_gui/registry.py new file mode 100644 index 000000000000..e8919b269f84 --- /dev/null +++ b/lib/matplotlib/mpl_gui/registry.py @@ -0,0 +1,39 @@ +"""Reproduces the module-level pyplot UX for Figure management.""" + +from . import FigureRegistry as _FigureRegistry +from ._manage_backend import select_gui_toolkit +from ._manage_interactive import ion, ioff, is_interactive + +_fr = _FigureRegistry() + +_fr_exports = [ + "figure", + "subplots", + "subplot_mosaic", + "by_label", + "show", + "show_all", + "close", + "close_all", +] + +for k in _fr_exports: + locals()[k] = getattr(_fr, k) + + +def get_figlabels(): + return list(_fr.by_label) + + +def get_fignums(): + return sorted(_fr.by_number) + + +# if one must. `from foo import *` is a language miss-feature, but provide +# sensible behavior anyway. +__all__ = _fr_exports + [ + "select_gui_toolkit", + "ion", + "ioff", + "is_interactive", +]