From 11e357af31af63a965c064895d886c788f752378 Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Sun, 30 Mar 2025 15:42:33 -0700 Subject: [PATCH 1/2] ENH: mpl_gui to main library --- lib/matplotlib/meson.build | 1 + lib/matplotlib/mpl_gui/__init__.py | 352 ++++++++++++++++++ lib/matplotlib/mpl_gui/_creation.py | 319 ++++++++++++++++ lib/matplotlib/mpl_gui/_figure.py | 14 + lib/matplotlib/mpl_gui/_manage_backend.py | 174 +++++++++ lib/matplotlib/mpl_gui/_manage_interactive.py | 149 ++++++++ .../mpl_gui/_patched_backends/__init__.py | 0 .../mpl_gui/_patched_backends/tkagg.py | 77 ++++ lib/matplotlib/mpl_gui/_promotion.py | 102 +++++ lib/matplotlib/mpl_gui/meson.build | 15 + lib/matplotlib/mpl_gui/registry.py | 39 ++ 11 files changed, 1242 insertions(+) create mode 100644 lib/matplotlib/mpl_gui/__init__.py create mode 100644 lib/matplotlib/mpl_gui/_creation.py create mode 100644 lib/matplotlib/mpl_gui/_figure.py create mode 100644 lib/matplotlib/mpl_gui/_manage_backend.py create mode 100644 lib/matplotlib/mpl_gui/_manage_interactive.py create mode 100644 lib/matplotlib/mpl_gui/_patched_backends/__init__.py create mode 100644 lib/matplotlib/mpl_gui/_patched_backends/tkagg.py create mode 100644 lib/matplotlib/mpl_gui/_promotion.py create mode 100644 lib/matplotlib/mpl_gui/meson.build create mode 100644 lib/matplotlib/mpl_gui/registry.py 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..9eed7a0602b4 --- /dev/null +++ b/lib/matplotlib/mpl_gui/_manage_backend.py @@ -0,0 +1,174 @@ +import importlib +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() + mapping = { + "qt": "qtagg", + "gtk3": "gtk3agg", + "gtk4": "gtk4agg", + "wx": "wxagg", + "tk": "tkagg", + "macosx": "macosx", + "headless": "agg", + } + + best_guess = mapping.get(current_framework, None) + if best_guess is not None: + candidates = [best_guess] + else: + candidates = [] + candidates += ["macosx", "qt5agg", "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] + print(backend_name) + mod = importlib.import_module('matplotlib.backends.backend_' + backend_name) + 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 + + required_framework = getattr( + BackendClass.FigureCanvas, "required_interactive_framework", None + ) + 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", +] From 3a53d04f9451c529ad45e9211c1349fac3f4f84a Mon Sep 17 00:00:00 2001 From: Jody Klymak Date: Mon, 31 Mar 2025 07:34:54 -0700 Subject: [PATCH 2/2] ENH: mpl_gui to main library --- lib/matplotlib/mpl_gui/_manage_backend.py | 29 +++++++---------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/lib/matplotlib/mpl_gui/_manage_backend.py b/lib/matplotlib/mpl_gui/_manage_backend.py index 9eed7a0602b4..0b05f5adb0f2 100644 --- a/lib/matplotlib/mpl_gui/_manage_backend.py +++ b/lib/matplotlib/mpl_gui/_manage_backend.py @@ -1,4 +1,3 @@ -import importlib import sys import logging import types @@ -55,22 +54,14 @@ def select_gui_toolkit(newbackend=None): if newbackend is rcsetup._auto_backend_sentinel: current_framework = cbook._get_running_interactive_framework() - mapping = { - "qt": "qtagg", - "gtk3": "gtk3agg", - "gtk4": "gtk4agg", - "wx": "wxagg", - "tk": "tkagg", - "macosx": "macosx", - "headless": "agg", - } - - best_guess = mapping.get(current_framework, None) - if best_guess is not None: - candidates = [best_guess] + if (current_framework and + (backend := backend_registry.backend_for_gui_framework( + current_framework))): + candidates = [backend] else: candidates = [] - candidates += ["macosx", "qt5agg", "gtk3agg", "tkagg", "wxagg"] + 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 @@ -93,8 +84,7 @@ def select_gui_toolkit(newbackend=None): # body is filled with the module's globals. backend_name = backend_registry.resolve_gui_or_backend(newbackend)[0] - print(backend_name) - mod = importlib.import_module('matplotlib.backends.backend_' + backend_name) + mod = backend_registry.load_backend_module(newbackend) if hasattr(mod, "Backend"): orig_class = mod.Backend @@ -135,9 +125,8 @@ def show_managers(cls, *, managers, block): mod.Backend = BackendClass sys.modules[mod_name] = mod - required_framework = getattr( - BackendClass.FigureCanvas, "required_interactive_framework", None - ) + 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 ( pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy