Skip to content

Commit 11e357a

Browse files
committed
ENH: mpl_gui to main library
1 parent dc05767 commit 11e357a

File tree

11 files changed

+1242
-0
lines changed

11 files changed

+1242
-0
lines changed

lib/matplotlib/meson.build

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,7 @@ subdir('_api')
161161
subdir('axes')
162162
subdir('backends')
163163
subdir('mpl-data')
164+
subdir('mpl_gui')
164165
subdir('projections')
165166
subdir('sphinxext')
166167
subdir('style')

lib/matplotlib/mpl_gui/__init__.py

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
"""
2+
Prototype project for new Matplotlib GUI management.
3+
4+
The pyplot module current serves two critical, but unrelated functions:
5+
6+
1. provide a state-full implicit API that rhymes / was inspired by MATLAB
7+
2. provide the management of interaction between Matplotlib and the GUI event
8+
loop
9+
10+
This project is prototype for separating the second function from the first.
11+
This will enable users to both only use the explicit API (nee OO interface) and
12+
to have smooth integration with the GUI event loop as with pyplot.
13+
14+
"""
15+
from collections import Counter
16+
from itertools import count
17+
import functools
18+
import logging
19+
import warnings
20+
21+
from matplotlib.backend_bases import FigureCanvasBase as _FigureCanvasBase
22+
23+
from ._figure import Figure # noqa: F401
24+
25+
from ._manage_interactive import ion, ioff, is_interactive # noqa: F401
26+
from ._manage_backend import select_gui_toolkit # noqa: F401
27+
from ._manage_backend import current_backend_module as _cbm
28+
from ._promotion import promote_figure as promote_figure
29+
from ._creation import figure, subplots, subplot_mosaic # noqa: F401
30+
31+
_log = logging.getLogger(__name__)
32+
33+
34+
def show(figs, *, block=None, timeout=0):
35+
"""
36+
Show the figures and maybe block.
37+
38+
Parameters
39+
----------
40+
figs : List[Figure]
41+
The figures to show. If they do not currently have a GUI aware
42+
canvas + manager attached they will be promoted.
43+
44+
block : bool, optional
45+
Whether to wait for all figures to be closed before returning.
46+
47+
If `True` block and run the GUI main loop until all figure windows
48+
are closed.
49+
50+
If `False` ensure that all figure windows are displayed and return
51+
immediately. In this case, you are responsible for ensuring
52+
that the event loop is running to have responsive figures.
53+
54+
Defaults to True in non-interactive mode and to False in interactive
55+
mode (see `.is_interactive`).
56+
57+
"""
58+
# TODO handle single figure
59+
60+
# call this to ensure a backend is indeed selected
61+
backend = _cbm()
62+
managers = []
63+
for fig in figs:
64+
if fig.canvas.manager is not None:
65+
managers.append(fig.canvas.manager)
66+
else:
67+
managers.append(promote_figure(fig))
68+
69+
if block is None:
70+
block = not is_interactive()
71+
72+
if block and len(managers):
73+
if timeout == 0:
74+
backend.show_managers(managers=managers, block=block)
75+
elif len(managers):
76+
manager, *_ = managers
77+
manager.canvas.start_event_loop(timeout=timeout)
78+
79+
80+
class FigureRegistry:
81+
"""
82+
A registry to wrap the creation of figures and track them.
83+
84+
This instance will keep a hard reference to created Figures to ensure
85+
that they do not get garbage collected.
86+
87+
Parameters
88+
----------
89+
block : bool, optional
90+
Whether to wait for all figures to be closed before returning from
91+
show_all.
92+
93+
If `True` block and run the GUI main loop until all figure windows
94+
are closed.
95+
96+
If `False` ensure that all figure windows are displayed and return
97+
immediately. In this case, you are responsible for ensuring
98+
that the event loop is running to have responsive figures.
99+
100+
Defaults to True in non-interactive mode and to False in interactive
101+
mode (see `.is_interactive`).
102+
103+
timeout : float, optional
104+
Default time to wait for all of the Figures to be closed if blocking.
105+
106+
If 0 block forever.
107+
108+
"""
109+
110+
def __init__(self, *, block=None, timeout=0, prefix="Figure "):
111+
# settings stashed to set defaults on show
112+
self._timeout = timeout
113+
self._block = block
114+
# Settings / state to control the default figure label
115+
self._count = count()
116+
self._prefix = prefix
117+
# the canonical location for storing the Figures this registry owns.
118+
# any additional views must never include a figure not in the list but
119+
# may omit figures
120+
self.figures = []
121+
122+
def _register_fig(self, fig):
123+
# if the user closes the figure by any other mechanism, drop our
124+
# reference to it. This is important for getting a "pyplot" like user
125+
# experience
126+
fig.canvas.mpl_connect(
127+
"close_event",
128+
lambda e: self.figures.remove(fig) if fig in self.figures else None,
129+
)
130+
# hold a hard reference to the figure.
131+
self.figures.append(fig)
132+
# Make sure we give the figure a quasi-unique label. We will never set
133+
# the same label twice, but will not over-ride any user label (but
134+
# empty string) on a Figure so if they provide duplicate labels, change
135+
# the labels under us, or provide a label that will be shadowed in the
136+
# future it will be what it is.
137+
fignum = next(self._count)
138+
if fig.get_label() == "":
139+
fig.set_label(f"{self._prefix}{fignum:d}")
140+
# TODO: is there a better way to track this than monkey patching?
141+
fig._mpl_gui_fignum = fignum
142+
return fig
143+
144+
@property
145+
def by_label(self):
146+
"""
147+
Return a dictionary of the current mapping labels -> figures.
148+
149+
If there are duplicate labels, newer figures will take precedence.
150+
"""
151+
mapping = {fig.get_label(): fig for fig in self.figures}
152+
if len(mapping) != len(self.figures):
153+
counts = Counter(fig.get_label() for fig in self.figures)
154+
multiples = {k: v for k, v in counts.items() if v > 1}
155+
warnings.warn(
156+
(
157+
f"There are repeated labels ({multiples!r}), but only the newest"
158+
"figure with that label can be returned. "
159+
),
160+
stacklevel=2,
161+
)
162+
return mapping
163+
164+
@property
165+
def by_number(self):
166+
"""
167+
Return a dictionary of the current mapping number -> figures.
168+
169+
"""
170+
self._ensure_all_figures_promoted()
171+
return {fig.canvas.manager.num: fig for fig in self.figures}
172+
173+
@functools.wraps(figure)
174+
def figure(self, *args, **kwargs):
175+
fig = figure(*args, **kwargs)
176+
return self._register_fig(fig)
177+
178+
@functools.wraps(subplots)
179+
def subplots(self, *args, **kwargs):
180+
fig, axs = subplots(*args, **kwargs)
181+
return self._register_fig(fig), axs
182+
183+
@functools.wraps(subplot_mosaic)
184+
def subplot_mosaic(self, *args, **kwargs):
185+
fig, axd = subplot_mosaic(*args, **kwargs)
186+
return self._register_fig(fig), axd
187+
188+
def _ensure_all_figures_promoted(self):
189+
for f in self.figures:
190+
if f.canvas.manager is None:
191+
promote_figure(f, num=f._mpl_gui_fignum)
192+
193+
def show_all(self, *, block=None, timeout=None):
194+
"""
195+
Show all of the Figures that the FigureRegistry knows about.
196+
197+
Parameters
198+
----------
199+
block : bool, optional
200+
Whether to wait for all figures to be closed before returning from
201+
show_all.
202+
203+
If `True` block and run the GUI main loop until all figure windows
204+
are closed.
205+
206+
If `False` ensure that all figure windows are displayed and return
207+
immediately. In this case, you are responsible for ensuring
208+
that the event loop is running to have responsive figures.
209+
210+
Defaults to the value set on the Registry at init
211+
212+
timeout : float, optional
213+
time to wait for all of the Figures to be closed if blocking.
214+
215+
If 0 block forever.
216+
217+
Defaults to the timeout set on the Registry at init
218+
"""
219+
if block is None:
220+
block = self._block
221+
222+
if timeout is None:
223+
timeout = self._timeout
224+
self._ensure_all_figures_promoted()
225+
show(self.figures, block=self._block, timeout=self._timeout)
226+
227+
# alias to easy pyplot compatibility
228+
show = show_all
229+
230+
def close_all(self):
231+
"""
232+
Close all Figures know to this Registry.
233+
234+
This will do four things:
235+
236+
1. call the ``.destroy()`` method on the manager
237+
2. clears the Figure on the canvas instance
238+
3. replace the canvas on each Figure with a new `~matplotlib.backend_bases.
239+
FigureCanvasBase` instance
240+
4. drops its hard reference to the Figure
241+
242+
If the user still holds a reference to the Figure it can be revived by
243+
passing it to `show`.
244+
245+
"""
246+
for fig in list(self.figures):
247+
self.close(fig)
248+
249+
def close(self, val):
250+
"""
251+
Close (meaning destroy the UI) and forget a managed Figure.
252+
253+
This will do two things:
254+
255+
- start the destruction process of an UI (the event loop may need to
256+
run to complete this process and if the user is holding hard
257+
references to any of the UI elements they may remain alive).
258+
- Remove the `Figure` from this Registry.
259+
260+
We will no longer have any hard references to the Figure, but if
261+
the user does the `Figure` (and its components) will not be garbage
262+
collected. Due to the circular references in Matplotlib these
263+
objects may not be collected until the full cyclic garbage collection
264+
runs.
265+
266+
If the user still has a reference to the `Figure` they can re-show the
267+
figure via `show`, but the `FigureRegistry` will not be aware of it.
268+
269+
Parameters
270+
----------
271+
val : 'all' or int or str or Figure
272+
273+
- The special case of 'all' closes all open Figures
274+
- If any other string is passed, it is interpreted as a key in
275+
`by_label` and that Figure is closed
276+
- If an integer it is interpreted as a key in `by_number` and that
277+
Figure is closed
278+
- If it is a `Figure` instance, then that figure is closed
279+
280+
"""
281+
if val == "all":
282+
return self.close_all()
283+
# or do we want to close _all_ of the figures with a given label / number?
284+
if isinstance(val, str):
285+
fig = self.by_label[val]
286+
elif isinstance(val, int):
287+
fig = self.by_number[val]
288+
else:
289+
fig = val
290+
if fig not in self.figures:
291+
raise ValueError(
292+
"Trying to close a figure not associated with this Registry."
293+
)
294+
if fig.canvas.manager is not None:
295+
fig.canvas.manager.destroy()
296+
# disconnect figure from canvas
297+
fig.canvas.figure = None
298+
# disconnect canvas from figure
299+
_FigureCanvasBase(figure=fig)
300+
assert fig.canvas.manager is None
301+
if fig in self.figures:
302+
self.figures.remove(fig)
303+
304+
305+
class FigureContext(FigureRegistry):
306+
"""
307+
Extends FigureRegistry to be used as a context manager.
308+
309+
All figures known to the Registry will be shown on exiting the context.
310+
311+
Parameters
312+
----------
313+
block : bool, optional
314+
Whether to wait for all figures to be closed before returning from
315+
show_all.
316+
317+
If `True` block and run the GUI main loop until all figure windows
318+
are closed.
319+
320+
If `False` ensure that all figure windows are displayed and return
321+
immediately. In this case, you are responsible for ensuring
322+
that the event loop is running to have responsive figures.
323+
324+
Defaults to True in non-interactive mode and to False in interactive
325+
mode (see `.is_interactive`).
326+
327+
timeout : float, optional
328+
Default time to wait for all of the Figures to be closed if blocking.
329+
330+
If 0 block forever.
331+
332+
forgive_failure : bool, optional
333+
If True, block to show the figure before letting the exception
334+
propagate
335+
336+
"""
337+
338+
def __init__(self, *, forgive_failure=False, **kwargs):
339+
super().__init__(**kwargs)
340+
self._forgive_failure = forgive_failure
341+
342+
def __enter__(self):
343+
return self
344+
345+
def __exit__(self, exc_type, exc_value, traceback):
346+
if exc_value is not None and not self._forgive_failure:
347+
return
348+
show(self.figures, block=self._block, timeout=self._timeout)
349+
350+
351+
# from mpl_gui import * # is a language mis-feature
352+
__all__ = []

0 commit comments

Comments
 (0)