3603 lines
129 KiB
Python
3603 lines
129 KiB
Python
"""
|
|
Abstract base classes define the primitives that renderers and
|
|
graphics contexts must implement to serve as a Matplotlib backend.
|
|
|
|
`RendererBase`
|
|
An abstract base class to handle drawing/rendering operations.
|
|
|
|
`FigureCanvasBase`
|
|
The abstraction layer that separates the `.Figure` from the backend
|
|
specific details like a user interface drawing area.
|
|
|
|
`GraphicsContextBase`
|
|
An abstract base class that provides color, line styles, etc.
|
|
|
|
`Event`
|
|
The base class for all of the Matplotlib event handling. Derived classes
|
|
such as `KeyEvent` and `MouseEvent` store the meta data like keys and
|
|
buttons pressed, x and y locations in pixel and `~.axes.Axes` coordinates.
|
|
|
|
`ShowBase`
|
|
The base class for the ``Show`` class of each interactive backend; the
|
|
'show' callable is then set to ``Show.__call__``.
|
|
|
|
`ToolContainerBase`
|
|
The base class for the Toolbar class of each interactive backend.
|
|
"""
|
|
|
|
from collections import namedtuple
|
|
from contextlib import ExitStack, contextmanager, nullcontext
|
|
from enum import Enum, IntEnum
|
|
import functools
|
|
import importlib
|
|
import inspect
|
|
import io
|
|
import itertools
|
|
import logging
|
|
import os
|
|
import pathlib
|
|
import signal
|
|
import socket
|
|
import sys
|
|
import time
|
|
import weakref
|
|
from weakref import WeakKeyDictionary
|
|
|
|
import numpy as np
|
|
|
|
import matplotlib as mpl
|
|
from matplotlib import (
|
|
_api, backend_tools as tools, cbook, colors, _docstring, text,
|
|
_tight_bbox, transforms, widgets, is_interactive, rcParams)
|
|
from matplotlib._pylab_helpers import Gcf
|
|
from matplotlib.backend_managers import ToolManager
|
|
from matplotlib.cbook import _setattr_cm
|
|
from matplotlib.layout_engine import ConstrainedLayoutEngine
|
|
from matplotlib.path import Path
|
|
from matplotlib.texmanager import TexManager
|
|
from matplotlib.transforms import Affine2D
|
|
from matplotlib._enums import JoinStyle, CapStyle
|
|
|
|
|
|
_log = logging.getLogger(__name__)
|
|
_default_filetypes = {
|
|
'eps': 'Encapsulated Postscript',
|
|
'jpg': 'Joint Photographic Experts Group',
|
|
'jpeg': 'Joint Photographic Experts Group',
|
|
'pdf': 'Portable Document Format',
|
|
'pgf': 'PGF code for LaTeX',
|
|
'png': 'Portable Network Graphics',
|
|
'ps': 'Postscript',
|
|
'raw': 'Raw RGBA bitmap',
|
|
'rgba': 'Raw RGBA bitmap',
|
|
'svg': 'Scalable Vector Graphics',
|
|
'svgz': 'Scalable Vector Graphics',
|
|
'tif': 'Tagged Image File Format',
|
|
'tiff': 'Tagged Image File Format',
|
|
'webp': 'WebP Image Format',
|
|
}
|
|
_default_backends = {
|
|
'eps': 'matplotlib.backends.backend_ps',
|
|
'jpg': 'matplotlib.backends.backend_agg',
|
|
'jpeg': 'matplotlib.backends.backend_agg',
|
|
'pdf': 'matplotlib.backends.backend_pdf',
|
|
'pgf': 'matplotlib.backends.backend_pgf',
|
|
'png': 'matplotlib.backends.backend_agg',
|
|
'ps': 'matplotlib.backends.backend_ps',
|
|
'raw': 'matplotlib.backends.backend_agg',
|
|
'rgba': 'matplotlib.backends.backend_agg',
|
|
'svg': 'matplotlib.backends.backend_svg',
|
|
'svgz': 'matplotlib.backends.backend_svg',
|
|
'tif': 'matplotlib.backends.backend_agg',
|
|
'tiff': 'matplotlib.backends.backend_agg',
|
|
'webp': 'matplotlib.backends.backend_agg',
|
|
}
|
|
|
|
|
|
def register_backend(format, backend, description=None):
|
|
"""
|
|
Register a backend for saving to a given file format.
|
|
|
|
Parameters
|
|
----------
|
|
format : str
|
|
File extension
|
|
backend : module string or canvas class
|
|
Backend for handling file output
|
|
description : str, default: ""
|
|
Description of the file type.
|
|
"""
|
|
if description is None:
|
|
description = ''
|
|
_default_backends[format] = backend
|
|
_default_filetypes[format] = description
|
|
|
|
|
|
def get_registered_canvas_class(format):
|
|
"""
|
|
Return the registered default canvas for given file format.
|
|
Handles deferred import of required backend.
|
|
"""
|
|
if format not in _default_backends:
|
|
return None
|
|
backend_class = _default_backends[format]
|
|
if isinstance(backend_class, str):
|
|
backend_class = importlib.import_module(backend_class).FigureCanvas
|
|
_default_backends[format] = backend_class
|
|
return backend_class
|
|
|
|
|
|
class RendererBase:
|
|
"""
|
|
An abstract base class to handle drawing/rendering operations.
|
|
|
|
The following methods must be implemented in the backend for full
|
|
functionality (though just implementing `draw_path` alone would give a
|
|
highly capable backend):
|
|
|
|
* `draw_path`
|
|
* `draw_image`
|
|
* `draw_gouraud_triangles`
|
|
|
|
The following methods *should* be implemented in the backend for
|
|
optimization reasons:
|
|
|
|
* `draw_text`
|
|
* `draw_markers`
|
|
* `draw_path_collection`
|
|
* `draw_quad_mesh`
|
|
"""
|
|
def __init__(self):
|
|
super().__init__()
|
|
self._texmanager = None
|
|
self._text2path = text.TextToPath()
|
|
self._raster_depth = 0
|
|
self._rasterizing = False
|
|
|
|
def open_group(self, s, gid=None):
|
|
"""
|
|
Open a grouping element with label *s* and *gid* (if set) as id.
|
|
|
|
Only used by the SVG renderer.
|
|
"""
|
|
|
|
def close_group(self, s):
|
|
"""
|
|
Close a grouping element with label *s*.
|
|
|
|
Only used by the SVG renderer.
|
|
"""
|
|
|
|
def draw_path(self, gc, path, transform, rgbFace=None):
|
|
"""Draw a `~.path.Path` instance using the given affine transform."""
|
|
raise NotImplementedError
|
|
|
|
def draw_markers(self, gc, marker_path, marker_trans, path,
|
|
trans, rgbFace=None):
|
|
"""
|
|
Draw a marker at each of *path*'s vertices (excluding control points).
|
|
|
|
The base (fallback) implementation makes multiple calls to `draw_path`.
|
|
Backends may want to override this method in order to draw the marker
|
|
only once and reuse it multiple times.
|
|
|
|
Parameters
|
|
----------
|
|
gc : `.GraphicsContextBase`
|
|
The graphics context.
|
|
marker_path : `~matplotlib.path.Path`
|
|
The path for the marker.
|
|
marker_trans : `~matplotlib.transforms.Transform`
|
|
An affine transform applied to the marker.
|
|
path : `~matplotlib.path.Path`
|
|
The locations to draw the markers.
|
|
trans : `~matplotlib.transforms.Transform`
|
|
An affine transform applied to the path.
|
|
rgbFace : :mpltype:`color`, optional
|
|
"""
|
|
for vertices, codes in path.iter_segments(trans, simplify=False):
|
|
if len(vertices):
|
|
x, y = vertices[-2:]
|
|
self.draw_path(gc, marker_path,
|
|
marker_trans +
|
|
transforms.Affine2D().translate(x, y),
|
|
rgbFace)
|
|
|
|
def draw_path_collection(self, gc, master_transform, paths, all_transforms,
|
|
offsets, offset_trans, facecolors, edgecolors,
|
|
linewidths, linestyles, antialiaseds, urls,
|
|
offset_position):
|
|
"""
|
|
Draw a collection of *paths*.
|
|
|
|
Each path is first transformed by the corresponding entry
|
|
in *all_transforms* (a list of (3, 3) matrices) and then by
|
|
*master_transform*. They are then translated by the corresponding
|
|
entry in *offsets*, which has been first transformed by *offset_trans*.
|
|
|
|
*facecolors*, *edgecolors*, *linewidths*, *linestyles*, and
|
|
*antialiased* are lists that set the corresponding properties.
|
|
|
|
*offset_position* is unused now, but the argument is kept for
|
|
backwards compatibility.
|
|
|
|
The base (fallback) implementation makes multiple calls to `draw_path`.
|
|
Backends may want to override this in order to render each set of
|
|
path data only once, and then reference that path multiple times with
|
|
the different offsets, colors, styles etc. The generator methods
|
|
`_iter_collection_raw_paths` and `_iter_collection` are provided to
|
|
help with (and standardize) the implementation across backends. It
|
|
is highly recommended to use those generators, so that changes to the
|
|
behavior of `draw_path_collection` can be made globally.
|
|
"""
|
|
path_ids = self._iter_collection_raw_paths(master_transform,
|
|
paths, all_transforms)
|
|
|
|
for xo, yo, path_id, gc0, rgbFace in self._iter_collection(
|
|
gc, list(path_ids), offsets, offset_trans,
|
|
facecolors, edgecolors, linewidths, linestyles,
|
|
antialiaseds, urls, offset_position):
|
|
path, transform = path_id
|
|
# Only apply another translation if we have an offset, else we
|
|
# reuse the initial transform.
|
|
if xo != 0 or yo != 0:
|
|
# The transformation can be used by multiple paths. Since
|
|
# translate is a inplace operation, we need to copy the
|
|
# transformation by .frozen() before applying the translation.
|
|
transform = transform.frozen()
|
|
transform.translate(xo, yo)
|
|
self.draw_path(gc0, path, transform, rgbFace)
|
|
|
|
def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight,
|
|
coordinates, offsets, offsetTrans, facecolors,
|
|
antialiased, edgecolors):
|
|
"""
|
|
Draw a quadmesh.
|
|
|
|
The base (fallback) implementation converts the quadmesh to paths and
|
|
then calls `draw_path_collection`.
|
|
"""
|
|
|
|
from matplotlib.collections import QuadMesh
|
|
paths = QuadMesh._convert_mesh_to_paths(coordinates)
|
|
|
|
if edgecolors is None:
|
|
edgecolors = facecolors
|
|
linewidths = np.array([gc.get_linewidth()], float)
|
|
|
|
return self.draw_path_collection(
|
|
gc, master_transform, paths, [], offsets, offsetTrans, facecolors,
|
|
edgecolors, linewidths, [], [antialiased], [None], 'screen')
|
|
|
|
def draw_gouraud_triangles(self, gc, triangles_array, colors_array,
|
|
transform):
|
|
"""
|
|
Draw a series of Gouraud triangles.
|
|
|
|
Parameters
|
|
----------
|
|
gc : `.GraphicsContextBase`
|
|
The graphics context.
|
|
triangles_array : (N, 3, 2) array-like
|
|
Array of *N* (x, y) points for the triangles.
|
|
colors_array : (N, 3, 4) array-like
|
|
Array of *N* RGBA colors for each point of the triangles.
|
|
transform : `~matplotlib.transforms.Transform`
|
|
An affine transform to apply to the points.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def _iter_collection_raw_paths(self, master_transform, paths,
|
|
all_transforms):
|
|
"""
|
|
Helper method (along with `_iter_collection`) to implement
|
|
`draw_path_collection` in a memory-efficient manner.
|
|
|
|
This method yields all of the base path/transform combinations, given a
|
|
master transform, a list of paths and list of transforms.
|
|
|
|
The arguments should be exactly what is passed in to
|
|
`draw_path_collection`.
|
|
|
|
The backend should take each yielded path and transform and create an
|
|
object that can be referenced (reused) later.
|
|
"""
|
|
Npaths = len(paths)
|
|
Ntransforms = len(all_transforms)
|
|
N = max(Npaths, Ntransforms)
|
|
|
|
if Npaths == 0:
|
|
return
|
|
|
|
transform = transforms.IdentityTransform()
|
|
for i in range(N):
|
|
path = paths[i % Npaths]
|
|
if Ntransforms:
|
|
transform = Affine2D(all_transforms[i % Ntransforms])
|
|
yield path, transform + master_transform
|
|
|
|
def _iter_collection_uses_per_path(self, paths, all_transforms,
|
|
offsets, facecolors, edgecolors):
|
|
"""
|
|
Compute how many times each raw path object returned by
|
|
`_iter_collection_raw_paths` would be used when calling
|
|
`_iter_collection`. This is intended for the backend to decide
|
|
on the tradeoff between using the paths in-line and storing
|
|
them once and reusing. Rounds up in case the number of uses
|
|
is not the same for every path.
|
|
"""
|
|
Npaths = len(paths)
|
|
if Npaths == 0 or len(facecolors) == len(edgecolors) == 0:
|
|
return 0
|
|
Npath_ids = max(Npaths, len(all_transforms))
|
|
N = max(Npath_ids, len(offsets))
|
|
return (N + Npath_ids - 1) // Npath_ids
|
|
|
|
def _iter_collection(self, gc, path_ids, offsets, offset_trans, facecolors,
|
|
edgecolors, linewidths, linestyles,
|
|
antialiaseds, urls, offset_position):
|
|
"""
|
|
Helper method (along with `_iter_collection_raw_paths`) to implement
|
|
`draw_path_collection` in a memory-efficient manner.
|
|
|
|
This method yields all of the path, offset and graphics context
|
|
combinations to draw the path collection. The caller should already
|
|
have looped over the results of `_iter_collection_raw_paths` to draw
|
|
this collection.
|
|
|
|
The arguments should be the same as that passed into
|
|
`draw_path_collection`, with the exception of *path_ids*, which is a
|
|
list of arbitrary objects that the backend will use to reference one of
|
|
the paths created in the `_iter_collection_raw_paths` stage.
|
|
|
|
Each yielded result is of the form::
|
|
|
|
xo, yo, path_id, gc, rgbFace
|
|
|
|
where *xo*, *yo* is an offset; *path_id* is one of the elements of
|
|
*path_ids*; *gc* is a graphics context and *rgbFace* is a color to
|
|
use for filling the path.
|
|
"""
|
|
Npaths = len(path_ids)
|
|
Noffsets = len(offsets)
|
|
N = max(Npaths, Noffsets)
|
|
Nfacecolors = len(facecolors)
|
|
Nedgecolors = len(edgecolors)
|
|
Nlinewidths = len(linewidths)
|
|
Nlinestyles = len(linestyles)
|
|
Nurls = len(urls)
|
|
|
|
if (Nfacecolors == 0 and Nedgecolors == 0) or Npaths == 0:
|
|
return
|
|
|
|
gc0 = self.new_gc()
|
|
gc0.copy_properties(gc)
|
|
|
|
def cycle_or_default(seq, default=None):
|
|
# Cycle over *seq* if it is not empty; else always yield *default*.
|
|
return (itertools.cycle(seq) if len(seq)
|
|
else itertools.repeat(default))
|
|
|
|
pathids = cycle_or_default(path_ids)
|
|
toffsets = cycle_or_default(offset_trans.transform(offsets), (0, 0))
|
|
fcs = cycle_or_default(facecolors)
|
|
ecs = cycle_or_default(edgecolors)
|
|
lws = cycle_or_default(linewidths)
|
|
lss = cycle_or_default(linestyles)
|
|
aas = cycle_or_default(antialiaseds)
|
|
urls = cycle_or_default(urls)
|
|
|
|
if Nedgecolors == 0:
|
|
gc0.set_linewidth(0.0)
|
|
|
|
for pathid, (xo, yo), fc, ec, lw, ls, aa, url in itertools.islice(
|
|
zip(pathids, toffsets, fcs, ecs, lws, lss, aas, urls), N):
|
|
if not (np.isfinite(xo) and np.isfinite(yo)):
|
|
continue
|
|
if Nedgecolors:
|
|
if Nlinewidths:
|
|
gc0.set_linewidth(lw)
|
|
if Nlinestyles:
|
|
gc0.set_dashes(*ls)
|
|
if len(ec) == 4 and ec[3] == 0.0:
|
|
gc0.set_linewidth(0)
|
|
else:
|
|
gc0.set_foreground(ec)
|
|
if fc is not None and len(fc) == 4 and fc[3] == 0:
|
|
fc = None
|
|
gc0.set_antialiased(aa)
|
|
if Nurls:
|
|
gc0.set_url(url)
|
|
yield xo, yo, pathid, gc0, fc
|
|
gc0.restore()
|
|
|
|
def get_image_magnification(self):
|
|
"""
|
|
Get the factor by which to magnify images passed to `draw_image`.
|
|
Allows a backend to have images at a different resolution to other
|
|
artists.
|
|
"""
|
|
return 1.0
|
|
|
|
def draw_image(self, gc, x, y, im, transform=None):
|
|
"""
|
|
Draw an RGBA image.
|
|
|
|
Parameters
|
|
----------
|
|
gc : `.GraphicsContextBase`
|
|
A graphics context with clipping information.
|
|
|
|
x : scalar
|
|
The distance in physical units (i.e., dots or pixels) from the left
|
|
hand side of the canvas.
|
|
|
|
y : scalar
|
|
The distance in physical units (i.e., dots or pixels) from the
|
|
bottom side of the canvas.
|
|
|
|
im : (N, M, 4) array of `numpy.uint8`
|
|
An array of RGBA pixels.
|
|
|
|
transform : `~matplotlib.transforms.Affine2DBase`
|
|
If and only if the concrete backend is written such that
|
|
`option_scale_image` returns ``True``, an affine transformation
|
|
(i.e., an `.Affine2DBase`) *may* be passed to `draw_image`. The
|
|
translation vector of the transformation is given in physical units
|
|
(i.e., dots or pixels). Note that the transformation does not
|
|
override *x* and *y*, and has to be applied *before* translating
|
|
the result by *x* and *y* (this can be accomplished by adding *x*
|
|
and *y* to the translation vector defined by *transform*).
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def option_image_nocomposite(self):
|
|
"""
|
|
Return whether image composition by Matplotlib should be skipped.
|
|
|
|
Raster backends should usually return False (letting the C-level
|
|
rasterizer take care of image composition); vector backends should
|
|
usually return ``not rcParams["image.composite_image"]``.
|
|
"""
|
|
return False
|
|
|
|
def option_scale_image(self):
|
|
"""
|
|
Return whether arbitrary affine transformations in `draw_image` are
|
|
supported (True for most vector backends).
|
|
"""
|
|
return False
|
|
|
|
def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None):
|
|
"""
|
|
Draw a TeX instance.
|
|
|
|
Parameters
|
|
----------
|
|
gc : `.GraphicsContextBase`
|
|
The graphics context.
|
|
x : float
|
|
The x location of the text in display coords.
|
|
y : float
|
|
The y location of the text baseline in display coords.
|
|
s : str
|
|
The TeX text string.
|
|
prop : `~matplotlib.font_manager.FontProperties`
|
|
The font properties.
|
|
angle : float
|
|
The rotation angle in degrees anti-clockwise.
|
|
mtext : `~matplotlib.text.Text`
|
|
The original text object to be rendered.
|
|
"""
|
|
self._draw_text_as_path(gc, x, y, s, prop, angle, ismath="TeX")
|
|
|
|
def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
|
|
"""
|
|
Draw a text instance.
|
|
|
|
Parameters
|
|
----------
|
|
gc : `.GraphicsContextBase`
|
|
The graphics context.
|
|
x : float
|
|
The x location of the text in display coords.
|
|
y : float
|
|
The y location of the text baseline in display coords.
|
|
s : str
|
|
The text string.
|
|
prop : `~matplotlib.font_manager.FontProperties`
|
|
The font properties.
|
|
angle : float
|
|
The rotation angle in degrees anti-clockwise.
|
|
ismath : bool or "TeX"
|
|
If True, use mathtext parser. If "TeX", use tex for rendering.
|
|
mtext : `~matplotlib.text.Text`
|
|
The original text object to be rendered.
|
|
|
|
Notes
|
|
-----
|
|
**Note for backend implementers:**
|
|
|
|
When you are trying to determine if you have gotten your bounding box
|
|
right (which is what enables the text layout/alignment to work
|
|
properly), it helps to change the line in text.py::
|
|
|
|
if 0: bbox_artist(self, renderer)
|
|
|
|
to if 1, and then the actual bounding box will be plotted along with
|
|
your text.
|
|
"""
|
|
|
|
self._draw_text_as_path(gc, x, y, s, prop, angle, ismath)
|
|
|
|
def _get_text_path_transform(self, x, y, s, prop, angle, ismath):
|
|
"""
|
|
Return the text path and transform.
|
|
|
|
Parameters
|
|
----------
|
|
x : float
|
|
The x location of the text in display coords.
|
|
y : float
|
|
The y location of the text baseline in display coords.
|
|
s : str
|
|
The text to be converted.
|
|
prop : `~matplotlib.font_manager.FontProperties`
|
|
The font property.
|
|
angle : float
|
|
Angle in degrees to render the text at.
|
|
ismath : bool or "TeX"
|
|
If True, use mathtext parser. If "TeX", use tex for rendering.
|
|
"""
|
|
|
|
text2path = self._text2path
|
|
fontsize = self.points_to_pixels(prop.get_size_in_points())
|
|
verts, codes = text2path.get_text_path(prop, s, ismath=ismath)
|
|
|
|
path = Path(verts, codes)
|
|
angle = np.deg2rad(angle)
|
|
if self.flipy():
|
|
width, height = self.get_canvas_width_height()
|
|
transform = (Affine2D()
|
|
.scale(fontsize / text2path.FONT_SCALE)
|
|
.rotate(angle)
|
|
.translate(x, height - y))
|
|
else:
|
|
transform = (Affine2D()
|
|
.scale(fontsize / text2path.FONT_SCALE)
|
|
.rotate(angle)
|
|
.translate(x, y))
|
|
|
|
return path, transform
|
|
|
|
def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath):
|
|
"""
|
|
Draw the text by converting them to paths using `.TextToPath`.
|
|
|
|
Parameters
|
|
----------
|
|
gc : `.GraphicsContextBase`
|
|
The graphics context.
|
|
x : float
|
|
The x location of the text in display coords.
|
|
y : float
|
|
The y location of the text baseline in display coords.
|
|
s : str
|
|
The text to be converted.
|
|
prop : `~matplotlib.font_manager.FontProperties`
|
|
The font property.
|
|
angle : float
|
|
Angle in degrees to render the text at.
|
|
ismath : bool or "TeX"
|
|
If True, use mathtext parser. If "TeX", use tex for rendering.
|
|
"""
|
|
path, transform = self._get_text_path_transform(
|
|
x, y, s, prop, angle, ismath)
|
|
color = gc.get_rgb()
|
|
gc.set_linewidth(0.0)
|
|
self.draw_path(gc, path, transform, rgbFace=color)
|
|
|
|
def get_text_width_height_descent(self, s, prop, ismath):
|
|
"""
|
|
Get the width, height, and descent (offset from the bottom to the baseline), in
|
|
display coords, of the string *s* with `.FontProperties` *prop*.
|
|
|
|
Whitespace at the start and the end of *s* is included in the reported width.
|
|
"""
|
|
fontsize = prop.get_size_in_points()
|
|
|
|
if ismath == 'TeX':
|
|
# todo: handle properties
|
|
return self.get_texmanager().get_text_width_height_descent(
|
|
s, fontsize, renderer=self)
|
|
|
|
dpi = self.points_to_pixels(72)
|
|
if ismath:
|
|
dims = self._text2path.mathtext_parser.parse(s, dpi, prop)
|
|
return dims[0:3] # return width, height, descent
|
|
|
|
flags = self._text2path._get_hinting_flag()
|
|
font = self._text2path._get_font(prop)
|
|
font.set_size(fontsize, dpi)
|
|
# the width and height of unrotated string
|
|
font.set_text(s, 0.0, flags=flags)
|
|
w, h = font.get_width_height()
|
|
d = font.get_descent()
|
|
w /= 64.0 # convert from subpixels
|
|
h /= 64.0
|
|
d /= 64.0
|
|
return w, h, d
|
|
|
|
def flipy(self):
|
|
"""
|
|
Return whether y values increase from top to bottom.
|
|
|
|
Note that this only affects drawing of texts.
|
|
"""
|
|
return True
|
|
|
|
def get_canvas_width_height(self):
|
|
"""Return the canvas width and height in display coords."""
|
|
return 1, 1
|
|
|
|
def get_texmanager(self):
|
|
"""Return the `.TexManager` instance."""
|
|
if self._texmanager is None:
|
|
self._texmanager = TexManager()
|
|
return self._texmanager
|
|
|
|
def new_gc(self):
|
|
"""Return an instance of a `.GraphicsContextBase`."""
|
|
return GraphicsContextBase()
|
|
|
|
def points_to_pixels(self, points):
|
|
"""
|
|
Convert points to display units.
|
|
|
|
You need to override this function (unless your backend
|
|
doesn't have a dpi, e.g., postscript or svg). Some imaging
|
|
systems assume some value for pixels per inch::
|
|
|
|
points to pixels = points * pixels_per_inch/72 * dpi/72
|
|
|
|
Parameters
|
|
----------
|
|
points : float or array-like
|
|
|
|
Returns
|
|
-------
|
|
Points converted to pixels
|
|
"""
|
|
return points
|
|
|
|
def start_rasterizing(self):
|
|
"""
|
|
Switch to the raster renderer.
|
|
|
|
Used by `.MixedModeRenderer`.
|
|
"""
|
|
|
|
def stop_rasterizing(self):
|
|
"""
|
|
Switch back to the vector renderer and draw the contents of the raster
|
|
renderer as an image on the vector renderer.
|
|
|
|
Used by `.MixedModeRenderer`.
|
|
"""
|
|
|
|
def start_filter(self):
|
|
"""
|
|
Switch to a temporary renderer for image filtering effects.
|
|
|
|
Currently only supported by the agg renderer.
|
|
"""
|
|
|
|
def stop_filter(self, filter_func):
|
|
"""
|
|
Switch back to the original renderer. The contents of the temporary
|
|
renderer is processed with the *filter_func* and is drawn on the
|
|
original renderer as an image.
|
|
|
|
Currently only supported by the agg renderer.
|
|
"""
|
|
|
|
def _draw_disabled(self):
|
|
"""
|
|
Context manager to temporary disable drawing.
|
|
|
|
This is used for getting the drawn size of Artists. This lets us
|
|
run the draw process to update any Python state but does not pay the
|
|
cost of the draw_XYZ calls on the canvas.
|
|
"""
|
|
no_ops = {
|
|
meth_name: lambda *args, **kwargs: None
|
|
for meth_name in dir(RendererBase)
|
|
if (meth_name.startswith("draw_")
|
|
or meth_name in ["open_group", "close_group"])
|
|
}
|
|
|
|
return _setattr_cm(self, **no_ops)
|
|
|
|
|
|
class GraphicsContextBase:
|
|
"""An abstract base class that provides color, line styles, etc."""
|
|
|
|
def __init__(self):
|
|
self._alpha = 1.0
|
|
self._forced_alpha = False # if True, _alpha overrides A from RGBA
|
|
self._antialiased = 1 # use 0, 1 not True, False for extension code
|
|
self._capstyle = CapStyle('butt')
|
|
self._cliprect = None
|
|
self._clippath = None
|
|
self._dashes = 0, None
|
|
self._joinstyle = JoinStyle('round')
|
|
self._linestyle = 'solid'
|
|
self._linewidth = 1
|
|
self._rgb = (0.0, 0.0, 0.0, 1.0)
|
|
self._hatch = None
|
|
self._hatch_color = colors.to_rgba(rcParams['hatch.color'])
|
|
self._hatch_linewidth = rcParams['hatch.linewidth']
|
|
self._url = None
|
|
self._gid = None
|
|
self._snap = None
|
|
self._sketch = None
|
|
|
|
def copy_properties(self, gc):
|
|
"""Copy properties from *gc* to self."""
|
|
self._alpha = gc._alpha
|
|
self._forced_alpha = gc._forced_alpha
|
|
self._antialiased = gc._antialiased
|
|
self._capstyle = gc._capstyle
|
|
self._cliprect = gc._cliprect
|
|
self._clippath = gc._clippath
|
|
self._dashes = gc._dashes
|
|
self._joinstyle = gc._joinstyle
|
|
self._linestyle = gc._linestyle
|
|
self._linewidth = gc._linewidth
|
|
self._rgb = gc._rgb
|
|
self._hatch = gc._hatch
|
|
self._hatch_color = gc._hatch_color
|
|
self._hatch_linewidth = gc._hatch_linewidth
|
|
self._url = gc._url
|
|
self._gid = gc._gid
|
|
self._snap = gc._snap
|
|
self._sketch = gc._sketch
|
|
|
|
def restore(self):
|
|
"""
|
|
Restore the graphics context from the stack - needed only
|
|
for backends that save graphics contexts on a stack.
|
|
"""
|
|
|
|
def get_alpha(self):
|
|
"""
|
|
Return the alpha value used for blending - not supported on all
|
|
backends.
|
|
"""
|
|
return self._alpha
|
|
|
|
def get_antialiased(self):
|
|
"""Return whether the object should try to do antialiased rendering."""
|
|
return self._antialiased
|
|
|
|
def get_capstyle(self):
|
|
"""Return the `.CapStyle`."""
|
|
return self._capstyle.name
|
|
|
|
def get_clip_rectangle(self):
|
|
"""
|
|
Return the clip rectangle as a `~matplotlib.transforms.Bbox` instance.
|
|
"""
|
|
return self._cliprect
|
|
|
|
def get_clip_path(self):
|
|
"""
|
|
Return the clip path in the form (path, transform), where path
|
|
is a `~.path.Path` instance, and transform is
|
|
an affine transform to apply to the path before clipping.
|
|
"""
|
|
if self._clippath is not None:
|
|
tpath, tr = self._clippath.get_transformed_path_and_affine()
|
|
if np.all(np.isfinite(tpath.vertices)):
|
|
return tpath, tr
|
|
else:
|
|
_log.warning("Ill-defined clip_path detected. Returning None.")
|
|
return None, None
|
|
return None, None
|
|
|
|
def get_dashes(self):
|
|
"""
|
|
Return the dash style as an (offset, dash-list) pair.
|
|
|
|
See `.set_dashes` for details.
|
|
|
|
Default value is (None, None).
|
|
"""
|
|
return self._dashes
|
|
|
|
def get_forced_alpha(self):
|
|
"""
|
|
Return whether the value given by get_alpha() should be used to
|
|
override any other alpha-channel values.
|
|
"""
|
|
return self._forced_alpha
|
|
|
|
def get_joinstyle(self):
|
|
"""Return the `.JoinStyle`."""
|
|
return self._joinstyle.name
|
|
|
|
def get_linewidth(self):
|
|
"""Return the line width in points."""
|
|
return self._linewidth
|
|
|
|
def get_rgb(self):
|
|
"""Return a tuple of three or four floats from 0-1."""
|
|
return self._rgb
|
|
|
|
def get_url(self):
|
|
"""Return a url if one is set, None otherwise."""
|
|
return self._url
|
|
|
|
def get_gid(self):
|
|
"""Return the object identifier if one is set, None otherwise."""
|
|
return self._gid
|
|
|
|
def get_snap(self):
|
|
"""
|
|
Return the snap setting, which can be:
|
|
|
|
* True: snap vertices to the nearest pixel center
|
|
* False: leave vertices as-is
|
|
* None: (auto) If the path contains only rectilinear line segments,
|
|
round to the nearest pixel center
|
|
"""
|
|
return self._snap
|
|
|
|
def set_alpha(self, alpha):
|
|
"""
|
|
Set the alpha value used for blending - not supported on all backends.
|
|
|
|
If ``alpha=None`` (the default), the alpha components of the
|
|
foreground and fill colors will be used to set their respective
|
|
transparencies (where applicable); otherwise, ``alpha`` will override
|
|
them.
|
|
"""
|
|
if alpha is not None:
|
|
self._alpha = alpha
|
|
self._forced_alpha = True
|
|
else:
|
|
self._alpha = 1.0
|
|
self._forced_alpha = False
|
|
self.set_foreground(self._rgb, isRGBA=True)
|
|
|
|
def set_antialiased(self, b):
|
|
"""Set whether object should be drawn with antialiased rendering."""
|
|
# Use ints to make life easier on extension code trying to read the gc.
|
|
self._antialiased = int(bool(b))
|
|
|
|
@_docstring.interpd
|
|
def set_capstyle(self, cs):
|
|
"""
|
|
Set how to draw endpoints of lines.
|
|
|
|
Parameters
|
|
----------
|
|
cs : `.CapStyle` or %(CapStyle)s
|
|
"""
|
|
self._capstyle = CapStyle(cs)
|
|
|
|
def set_clip_rectangle(self, rectangle):
|
|
"""Set the clip rectangle to a `.Bbox` or None."""
|
|
self._cliprect = rectangle
|
|
|
|
def set_clip_path(self, path):
|
|
"""Set the clip path to a `.TransformedPath` or None."""
|
|
_api.check_isinstance((transforms.TransformedPath, None), path=path)
|
|
self._clippath = path
|
|
|
|
def set_dashes(self, dash_offset, dash_list):
|
|
"""
|
|
Set the dash style for the gc.
|
|
|
|
Parameters
|
|
----------
|
|
dash_offset : float
|
|
Distance, in points, into the dash pattern at which to
|
|
start the pattern. It is usually set to 0.
|
|
dash_list : array-like or None
|
|
The on-off sequence as points. None specifies a solid line. All
|
|
values must otherwise be non-negative (:math:`\\ge 0`).
|
|
|
|
Notes
|
|
-----
|
|
See p. 666 of the PostScript
|
|
`Language Reference
|
|
<https://www.adobe.com/jp/print/postscript/pdfs/PLRM.pdf>`_
|
|
for more info.
|
|
"""
|
|
if dash_list is not None:
|
|
dl = np.asarray(dash_list)
|
|
if np.any(dl < 0.0):
|
|
raise ValueError(
|
|
"All values in the dash list must be non-negative")
|
|
if dl.size and not np.any(dl > 0.0):
|
|
raise ValueError(
|
|
'At least one value in the dash list must be positive')
|
|
self._dashes = dash_offset, dash_list
|
|
|
|
def set_foreground(self, fg, isRGBA=False):
|
|
"""
|
|
Set the foreground color.
|
|
|
|
Parameters
|
|
----------
|
|
fg : :mpltype:`color`
|
|
isRGBA : bool
|
|
If *fg* is known to be an ``(r, g, b, a)`` tuple, *isRGBA* can be
|
|
set to True to improve performance.
|
|
"""
|
|
if self._forced_alpha and isRGBA:
|
|
self._rgb = fg[:3] + (self._alpha,)
|
|
elif self._forced_alpha:
|
|
self._rgb = colors.to_rgba(fg, self._alpha)
|
|
elif isRGBA:
|
|
self._rgb = fg
|
|
else:
|
|
self._rgb = colors.to_rgba(fg)
|
|
|
|
@_docstring.interpd
|
|
def set_joinstyle(self, js):
|
|
"""
|
|
Set how to draw connections between line segments.
|
|
|
|
Parameters
|
|
----------
|
|
js : `.JoinStyle` or %(JoinStyle)s
|
|
"""
|
|
self._joinstyle = JoinStyle(js)
|
|
|
|
def set_linewidth(self, w):
|
|
"""Set the linewidth in points."""
|
|
self._linewidth = float(w)
|
|
|
|
def set_url(self, url):
|
|
"""Set the url for links in compatible backends."""
|
|
self._url = url
|
|
|
|
def set_gid(self, id):
|
|
"""Set the id."""
|
|
self._gid = id
|
|
|
|
def set_snap(self, snap):
|
|
"""
|
|
Set the snap setting which may be:
|
|
|
|
* True: snap vertices to the nearest pixel center
|
|
* False: leave vertices as-is
|
|
* None: (auto) If the path contains only rectilinear line segments,
|
|
round to the nearest pixel center
|
|
"""
|
|
self._snap = snap
|
|
|
|
def set_hatch(self, hatch):
|
|
"""Set the hatch style (for fills)."""
|
|
self._hatch = hatch
|
|
|
|
def get_hatch(self):
|
|
"""Get the current hatch style."""
|
|
return self._hatch
|
|
|
|
def get_hatch_path(self, density=6.0):
|
|
"""Return a `.Path` for the current hatch."""
|
|
hatch = self.get_hatch()
|
|
if hatch is None:
|
|
return None
|
|
return Path.hatch(hatch, density)
|
|
|
|
def get_hatch_color(self):
|
|
"""Get the hatch color."""
|
|
return self._hatch_color
|
|
|
|
def set_hatch_color(self, hatch_color):
|
|
"""Set the hatch color."""
|
|
self._hatch_color = hatch_color
|
|
|
|
def get_hatch_linewidth(self):
|
|
"""Get the hatch linewidth."""
|
|
return self._hatch_linewidth
|
|
|
|
def get_sketch_params(self):
|
|
"""
|
|
Return the sketch parameters for the artist.
|
|
|
|
Returns
|
|
-------
|
|
tuple or `None`
|
|
|
|
A 3-tuple with the following elements:
|
|
|
|
* ``scale``: The amplitude of the wiggle perpendicular to the
|
|
source line.
|
|
* ``length``: The length of the wiggle along the line.
|
|
* ``randomness``: The scale factor by which the length is
|
|
shrunken or expanded.
|
|
|
|
May return `None` if no sketch parameters were set.
|
|
"""
|
|
return self._sketch
|
|
|
|
def set_sketch_params(self, scale=None, length=None, randomness=None):
|
|
"""
|
|
Set the sketch parameters.
|
|
|
|
Parameters
|
|
----------
|
|
scale : float, optional
|
|
The amplitude of the wiggle perpendicular to the source line, in
|
|
pixels. If scale is `None`, or not provided, no sketch filter will
|
|
be provided.
|
|
length : float, default: 128
|
|
The length of the wiggle along the line, in pixels.
|
|
randomness : float, default: 16
|
|
The scale factor by which the length is shrunken or expanded.
|
|
"""
|
|
self._sketch = (
|
|
None if scale is None
|
|
else (scale, length or 128., randomness or 16.))
|
|
|
|
|
|
class TimerBase:
|
|
"""
|
|
A base class for providing timer events, useful for things animations.
|
|
Backends need to implement a few specific methods in order to use their
|
|
own timing mechanisms so that the timer events are integrated into their
|
|
event loops.
|
|
|
|
Subclasses must override the following methods:
|
|
|
|
- ``_timer_start``: Backend-specific code for starting the timer.
|
|
- ``_timer_stop``: Backend-specific code for stopping the timer.
|
|
|
|
Subclasses may additionally override the following methods:
|
|
|
|
- ``_timer_set_single_shot``: Code for setting the timer to single shot
|
|
operating mode, if supported by the timer object. If not, the `Timer`
|
|
class itself will store the flag and the ``_on_timer`` method should be
|
|
overridden to support such behavior.
|
|
|
|
- ``_timer_set_interval``: Code for setting the interval on the timer, if
|
|
there is a method for doing so on the timer object.
|
|
|
|
- ``_on_timer``: The internal function that any timer object should call,
|
|
which will handle the task of running all callbacks that have been set.
|
|
"""
|
|
|
|
def __init__(self, interval=None, callbacks=None):
|
|
"""
|
|
Parameters
|
|
----------
|
|
interval : int, default: 1000ms
|
|
The time between timer events in milliseconds. Will be stored as
|
|
``timer.interval``.
|
|
callbacks : list[tuple[callable, tuple, dict]]
|
|
List of (func, args, kwargs) tuples that will be called upon timer
|
|
events. This list is accessible as ``timer.callbacks`` and can be
|
|
manipulated directly, or the functions `~.TimerBase.add_callback`
|
|
and `~.TimerBase.remove_callback` can be used.
|
|
"""
|
|
self.callbacks = [] if callbacks is None else callbacks.copy()
|
|
# Set .interval and not ._interval to go through the property setter.
|
|
self.interval = 1000 if interval is None else interval
|
|
self.single_shot = False
|
|
|
|
def __del__(self):
|
|
"""Need to stop timer and possibly disconnect timer."""
|
|
self._timer_stop()
|
|
|
|
@_api.delete_parameter("3.9", "interval", alternative="timer.interval")
|
|
def start(self, interval=None):
|
|
"""
|
|
Start the timer object.
|
|
|
|
Parameters
|
|
----------
|
|
interval : int, optional
|
|
Timer interval in milliseconds; overrides a previously set interval
|
|
if provided.
|
|
"""
|
|
if interval is not None:
|
|
self.interval = interval
|
|
self._timer_start()
|
|
|
|
def stop(self):
|
|
"""Stop the timer."""
|
|
self._timer_stop()
|
|
|
|
def _timer_start(self):
|
|
pass
|
|
|
|
def _timer_stop(self):
|
|
pass
|
|
|
|
@property
|
|
def interval(self):
|
|
"""The time between timer events, in milliseconds."""
|
|
return self._interval
|
|
|
|
@interval.setter
|
|
def interval(self, interval):
|
|
# Force to int since none of the backends actually support fractional
|
|
# milliseconds, and some error or give warnings.
|
|
# Some backends also fail when interval == 0, so ensure >= 1 msec
|
|
interval = max(int(interval), 1)
|
|
self._interval = interval
|
|
self._timer_set_interval()
|
|
|
|
@property
|
|
def single_shot(self):
|
|
"""Whether this timer should stop after a single run."""
|
|
return self._single
|
|
|
|
@single_shot.setter
|
|
def single_shot(self, ss):
|
|
self._single = ss
|
|
self._timer_set_single_shot()
|
|
|
|
def add_callback(self, func, *args, **kwargs):
|
|
"""
|
|
Register *func* to be called by timer when the event fires. Any
|
|
additional arguments provided will be passed to *func*.
|
|
|
|
This function returns *func*, which makes it possible to use it as a
|
|
decorator.
|
|
"""
|
|
self.callbacks.append((func, args, kwargs))
|
|
return func
|
|
|
|
def remove_callback(self, func, *args, **kwargs):
|
|
"""
|
|
Remove *func* from list of callbacks.
|
|
|
|
*args* and *kwargs* are optional and used to distinguish between copies
|
|
of the same function registered to be called with different arguments.
|
|
This behavior is deprecated. In the future, ``*args, **kwargs`` won't
|
|
be considered anymore; to keep a specific callback removable by itself,
|
|
pass it to `add_callback` as a `functools.partial` object.
|
|
"""
|
|
if args or kwargs:
|
|
_api.warn_deprecated(
|
|
"3.1", message="In a future version, Timer.remove_callback "
|
|
"will not take *args, **kwargs anymore, but remove all "
|
|
"callbacks where the callable matches; to keep a specific "
|
|
"callback removable by itself, pass it to add_callback as a "
|
|
"functools.partial object.")
|
|
self.callbacks.remove((func, args, kwargs))
|
|
else:
|
|
funcs = [c[0] for c in self.callbacks]
|
|
if func in funcs:
|
|
self.callbacks.pop(funcs.index(func))
|
|
|
|
def _timer_set_interval(self):
|
|
"""Used to set interval on underlying timer object."""
|
|
|
|
def _timer_set_single_shot(self):
|
|
"""Used to set single shot on underlying timer object."""
|
|
|
|
def _on_timer(self):
|
|
"""
|
|
Runs all function that have been registered as callbacks. Functions
|
|
can return False (or 0) if they should not be called any more. If there
|
|
are no callbacks, the timer is automatically stopped.
|
|
"""
|
|
for func, args, kwargs in self.callbacks:
|
|
ret = func(*args, **kwargs)
|
|
# docstring above explains why we use `if ret == 0` here,
|
|
# instead of `if not ret`.
|
|
# This will also catch `ret == False` as `False == 0`
|
|
# but does not annoy the linters
|
|
# https://docs.python.org/3/library/stdtypes.html#boolean-values
|
|
if ret == 0:
|
|
self.callbacks.remove((func, args, kwargs))
|
|
|
|
if len(self.callbacks) == 0:
|
|
self.stop()
|
|
|
|
|
|
class Event:
|
|
"""
|
|
A Matplotlib event.
|
|
|
|
The following attributes are defined and shown with their default values.
|
|
Subclasses may define additional attributes.
|
|
|
|
Attributes
|
|
----------
|
|
name : str
|
|
The event name.
|
|
canvas : `FigureCanvasBase`
|
|
The backend-specific canvas instance generating the event.
|
|
guiEvent
|
|
The GUI event that triggered the Matplotlib event.
|
|
"""
|
|
|
|
def __init__(self, name, canvas, guiEvent=None):
|
|
self.name = name
|
|
self.canvas = canvas
|
|
self._guiEvent = guiEvent
|
|
self._guiEvent_deleted = False
|
|
|
|
def _process(self):
|
|
"""Process this event on ``self.canvas``, then unset ``guiEvent``."""
|
|
self.canvas.callbacks.process(self.name, self)
|
|
self._guiEvent_deleted = True
|
|
|
|
@property
|
|
def guiEvent(self):
|
|
# After deprecation elapses: remove _guiEvent_deleted; make guiEvent a plain
|
|
# attribute set to None by _process.
|
|
if self._guiEvent_deleted:
|
|
_api.warn_deprecated(
|
|
"3.8", message="Accessing guiEvent outside of the original GUI event "
|
|
"handler is unsafe and deprecated since %(since)s; in the future, the "
|
|
"attribute will be set to None after quitting the event handler. You "
|
|
"may separately record the value of the guiEvent attribute at your own "
|
|
"risk.")
|
|
return self._guiEvent
|
|
|
|
|
|
class DrawEvent(Event):
|
|
"""
|
|
An event triggered by a draw operation on the canvas.
|
|
|
|
In most backends, callbacks subscribed to this event will be fired after
|
|
the rendering is complete but before the screen is updated. Any extra
|
|
artists drawn to the canvas's renderer will be reflected without an
|
|
explicit call to ``blit``.
|
|
|
|
.. warning::
|
|
|
|
Calling ``canvas.draw`` and ``canvas.blit`` in these callbacks may
|
|
not be safe with all backends and may cause infinite recursion.
|
|
|
|
A DrawEvent has a number of special attributes in addition to those defined
|
|
by the parent `Event` class.
|
|
|
|
Attributes
|
|
----------
|
|
renderer : `RendererBase`
|
|
The renderer for the draw event.
|
|
"""
|
|
def __init__(self, name, canvas, renderer):
|
|
super().__init__(name, canvas)
|
|
self.renderer = renderer
|
|
|
|
|
|
class ResizeEvent(Event):
|
|
"""
|
|
An event triggered by a canvas resize.
|
|
|
|
A ResizeEvent has a number of special attributes in addition to those
|
|
defined by the parent `Event` class.
|
|
|
|
Attributes
|
|
----------
|
|
width : int
|
|
Width of the canvas in pixels.
|
|
height : int
|
|
Height of the canvas in pixels.
|
|
"""
|
|
|
|
def __init__(self, name, canvas):
|
|
super().__init__(name, canvas)
|
|
self.width, self.height = canvas.get_width_height()
|
|
|
|
|
|
class CloseEvent(Event):
|
|
"""An event triggered by a figure being closed."""
|
|
|
|
|
|
class LocationEvent(Event):
|
|
"""
|
|
An event that has a screen location.
|
|
|
|
A LocationEvent has a number of special attributes in addition to those
|
|
defined by the parent `Event` class.
|
|
|
|
Attributes
|
|
----------
|
|
x, y : int or None
|
|
Event location in pixels from bottom left of canvas.
|
|
inaxes : `~matplotlib.axes.Axes` or None
|
|
The `~.axes.Axes` instance over which the mouse is, if any.
|
|
xdata, ydata : float or None
|
|
Data coordinates of the mouse within *inaxes*, or *None* if the mouse
|
|
is not over an Axes.
|
|
modifiers : frozenset
|
|
The keyboard modifiers currently being pressed (except for KeyEvent).
|
|
"""
|
|
|
|
# Fully delete all occurrences of lastevent after deprecation elapses.
|
|
_lastevent = None
|
|
lastevent = _api.deprecated("3.8")(
|
|
_api.classproperty(lambda cls: cls._lastevent))
|
|
_last_axes_ref = None
|
|
|
|
def __init__(self, name, canvas, x, y, guiEvent=None, *, modifiers=None):
|
|
super().__init__(name, canvas, guiEvent=guiEvent)
|
|
# x position - pixels from left of canvas
|
|
self.x = int(x) if x is not None else x
|
|
# y position - pixels from right of canvas
|
|
self.y = int(y) if y is not None else y
|
|
self.inaxes = None # the Axes instance the mouse is over
|
|
self.xdata = None # x coord of mouse in data coords
|
|
self.ydata = None # y coord of mouse in data coords
|
|
self.modifiers = frozenset(modifiers if modifiers is not None else [])
|
|
|
|
if x is None or y is None:
|
|
# cannot check if event was in Axes if no (x, y) info
|
|
return
|
|
|
|
self._set_inaxes(self.canvas.inaxes((x, y))
|
|
if self.canvas.mouse_grabber is None else
|
|
self.canvas.mouse_grabber,
|
|
(x, y))
|
|
|
|
# Splitting _set_inaxes out is useful for the axes_leave_event handler: it
|
|
# needs to generate synthetic LocationEvents with manually-set inaxes. In
|
|
# that latter case, xy has already been cast to int so it can directly be
|
|
# read from self.x, self.y; in the normal case, however, it is more
|
|
# accurate to pass the untruncated float x, y values passed to the ctor.
|
|
|
|
def _set_inaxes(self, inaxes, xy=None):
|
|
self.inaxes = inaxes
|
|
if inaxes is not None:
|
|
try:
|
|
self.xdata, self.ydata = inaxes.transData.inverted().transform(
|
|
xy if xy is not None else (self.x, self.y))
|
|
except ValueError:
|
|
pass
|
|
|
|
|
|
class MouseButton(IntEnum):
|
|
LEFT = 1
|
|
MIDDLE = 2
|
|
RIGHT = 3
|
|
BACK = 8
|
|
FORWARD = 9
|
|
|
|
|
|
class MouseEvent(LocationEvent):
|
|
"""
|
|
A mouse event ('button_press_event', 'button_release_event', \
|
|
'scroll_event', 'motion_notify_event').
|
|
|
|
A MouseEvent has a number of special attributes in addition to those
|
|
defined by the parent `Event` and `LocationEvent` classes.
|
|
|
|
Attributes
|
|
----------
|
|
button : None or `MouseButton` or {'up', 'down'}
|
|
The button pressed. 'up' and 'down' are used for scroll events.
|
|
|
|
Note that LEFT and RIGHT actually refer to the "primary" and
|
|
"secondary" buttons, i.e. if the user inverts their left and right
|
|
buttons ("left-handed setting") then the LEFT button will be the one
|
|
physically on the right.
|
|
|
|
If this is unset, *name* is "scroll_event", and *step* is nonzero, then
|
|
this will be set to "up" or "down" depending on the sign of *step*.
|
|
|
|
key : None or str
|
|
The key pressed when the mouse event triggered, e.g. 'shift'.
|
|
See `KeyEvent`.
|
|
|
|
.. warning::
|
|
This key is currently obtained from the last 'key_press_event' or
|
|
'key_release_event' that occurred within the canvas. Thus, if the
|
|
last change of keyboard state occurred while the canvas did not have
|
|
focus, this attribute will be wrong. On the other hand, the
|
|
``modifiers`` attribute should always be correct, but it can only
|
|
report on modifier keys.
|
|
|
|
step : float
|
|
The number of scroll steps (positive for 'up', negative for 'down').
|
|
This applies only to 'scroll_event' and defaults to 0 otherwise.
|
|
|
|
dblclick : bool
|
|
Whether the event is a double-click. This applies only to
|
|
'button_press_event' and is False otherwise. In particular, it's
|
|
not used in 'button_release_event'.
|
|
|
|
Examples
|
|
--------
|
|
::
|
|
|
|
def on_press(event):
|
|
print('you pressed', event.button, event.xdata, event.ydata)
|
|
|
|
cid = fig.canvas.mpl_connect('button_press_event', on_press)
|
|
"""
|
|
|
|
def __init__(self, name, canvas, x, y, button=None, key=None,
|
|
step=0, dblclick=False, guiEvent=None, *, modifiers=None):
|
|
super().__init__(
|
|
name, canvas, x, y, guiEvent=guiEvent, modifiers=modifiers)
|
|
if button in MouseButton.__members__.values():
|
|
button = MouseButton(button)
|
|
if name == "scroll_event" and button is None:
|
|
if step > 0:
|
|
button = "up"
|
|
elif step < 0:
|
|
button = "down"
|
|
self.button = button
|
|
self.key = key
|
|
self.step = step
|
|
self.dblclick = dblclick
|
|
|
|
def __str__(self):
|
|
return (f"{self.name}: "
|
|
f"xy=({self.x}, {self.y}) xydata=({self.xdata}, {self.ydata}) "
|
|
f"button={self.button} dblclick={self.dblclick} "
|
|
f"inaxes={self.inaxes}")
|
|
|
|
|
|
class PickEvent(Event):
|
|
"""
|
|
A pick event.
|
|
|
|
This event is fired when the user picks a location on the canvas
|
|
sufficiently close to an artist that has been made pickable with
|
|
`.Artist.set_picker`.
|
|
|
|
A PickEvent has a number of special attributes in addition to those defined
|
|
by the parent `Event` class.
|
|
|
|
Attributes
|
|
----------
|
|
mouseevent : `MouseEvent`
|
|
The mouse event that generated the pick.
|
|
artist : `~matplotlib.artist.Artist`
|
|
The picked artist. Note that artists are not pickable by default
|
|
(see `.Artist.set_picker`).
|
|
other
|
|
Additional attributes may be present depending on the type of the
|
|
picked object; e.g., a `.Line2D` pick may define different extra
|
|
attributes than a `.PatchCollection` pick.
|
|
|
|
Examples
|
|
--------
|
|
Bind a function ``on_pick()`` to pick events, that prints the coordinates
|
|
of the picked data point::
|
|
|
|
ax.plot(np.rand(100), 'o', picker=5) # 5 points tolerance
|
|
|
|
def on_pick(event):
|
|
line = event.artist
|
|
xdata, ydata = line.get_data()
|
|
ind = event.ind
|
|
print(f'on pick line: {xdata[ind]:.3f}, {ydata[ind]:.3f}')
|
|
|
|
cid = fig.canvas.mpl_connect('pick_event', on_pick)
|
|
"""
|
|
|
|
def __init__(self, name, canvas, mouseevent, artist,
|
|
guiEvent=None, **kwargs):
|
|
if guiEvent is None:
|
|
guiEvent = mouseevent.guiEvent
|
|
super().__init__(name, canvas, guiEvent)
|
|
self.mouseevent = mouseevent
|
|
self.artist = artist
|
|
self.__dict__.update(kwargs)
|
|
|
|
|
|
class KeyEvent(LocationEvent):
|
|
"""
|
|
A key event (key press, key release).
|
|
|
|
A KeyEvent has a number of special attributes in addition to those defined
|
|
by the parent `Event` and `LocationEvent` classes.
|
|
|
|
Attributes
|
|
----------
|
|
key : None or str
|
|
The key(s) pressed. Could be *None*, a single case sensitive Unicode
|
|
character ("g", "G", "#", etc.), a special key ("control", "shift",
|
|
"f1", "up", etc.) or a combination of the above (e.g., "ctrl+alt+g",
|
|
"ctrl+alt+G").
|
|
|
|
Notes
|
|
-----
|
|
Modifier keys will be prefixed to the pressed key and will be in the order
|
|
"ctrl", "alt", "super". The exception to this rule is when the pressed key
|
|
is itself a modifier key, therefore "ctrl+alt" and "alt+control" can both
|
|
be valid key values.
|
|
|
|
Examples
|
|
--------
|
|
::
|
|
|
|
def on_key(event):
|
|
print('you pressed', event.key, event.xdata, event.ydata)
|
|
|
|
cid = fig.canvas.mpl_connect('key_press_event', on_key)
|
|
"""
|
|
|
|
def __init__(self, name, canvas, key, x=0, y=0, guiEvent=None):
|
|
super().__init__(name, canvas, x, y, guiEvent=guiEvent)
|
|
self.key = key
|
|
|
|
|
|
# Default callback for key events.
|
|
def _key_handler(event):
|
|
# Dead reckoning of key.
|
|
if event.name == "key_press_event":
|
|
event.canvas._key = event.key
|
|
elif event.name == "key_release_event":
|
|
event.canvas._key = None
|
|
|
|
|
|
# Default callback for mouse events.
|
|
def _mouse_handler(event):
|
|
# Dead-reckoning of button and key.
|
|
if event.name == "button_press_event":
|
|
event.canvas._button = event.button
|
|
elif event.name == "button_release_event":
|
|
event.canvas._button = None
|
|
elif event.name == "motion_notify_event" and event.button is None:
|
|
event.button = event.canvas._button
|
|
if event.key is None:
|
|
event.key = event.canvas._key
|
|
# Emit axes_enter/axes_leave.
|
|
if event.name == "motion_notify_event":
|
|
last_ref = LocationEvent._last_axes_ref
|
|
last_axes = last_ref() if last_ref else None
|
|
if last_axes != event.inaxes:
|
|
if last_axes is not None:
|
|
# Create a synthetic LocationEvent for the axes_leave_event.
|
|
# Its inaxes attribute needs to be manually set (because the
|
|
# cursor is actually *out* of that Axes at that point); this is
|
|
# done with the internal _set_inaxes method which ensures that
|
|
# the xdata and ydata attributes are also correct.
|
|
try:
|
|
leave_event = LocationEvent(
|
|
"axes_leave_event", last_axes.figure.canvas,
|
|
event.x, event.y, event.guiEvent,
|
|
modifiers=event.modifiers)
|
|
leave_event._set_inaxes(last_axes)
|
|
last_axes.figure.canvas.callbacks.process(
|
|
"axes_leave_event", leave_event)
|
|
except Exception:
|
|
pass # The last canvas may already have been torn down.
|
|
if event.inaxes is not None:
|
|
event.canvas.callbacks.process("axes_enter_event", event)
|
|
LocationEvent._last_axes_ref = (
|
|
weakref.ref(event.inaxes) if event.inaxes else None)
|
|
LocationEvent._lastevent = (
|
|
None if event.name == "figure_leave_event" else event)
|
|
|
|
|
|
def _get_renderer(figure, print_method=None):
|
|
"""
|
|
Get the renderer that would be used to save a `.Figure`.
|
|
|
|
If you need a renderer without any active draw methods use
|
|
renderer._draw_disabled to temporary patch them out at your call site.
|
|
"""
|
|
# This is implemented by triggering a draw, then immediately jumping out of
|
|
# Figure.draw() by raising an exception.
|
|
|
|
class Done(Exception):
|
|
pass
|
|
|
|
def _draw(renderer): raise Done(renderer)
|
|
|
|
with cbook._setattr_cm(figure, draw=_draw), ExitStack() as stack:
|
|
if print_method is None:
|
|
fmt = figure.canvas.get_default_filetype()
|
|
# Even for a canvas' default output type, a canvas switch may be
|
|
# needed, e.g. for FigureCanvasBase.
|
|
print_method = stack.enter_context(
|
|
figure.canvas._switch_canvas_and_return_print_method(fmt))
|
|
try:
|
|
print_method(io.BytesIO())
|
|
except Done as exc:
|
|
renderer, = exc.args
|
|
return renderer
|
|
else:
|
|
raise RuntimeError(f"{print_method} did not call Figure.draw, so "
|
|
f"no renderer is available")
|
|
|
|
|
|
def _no_output_draw(figure):
|
|
# _no_output_draw was promoted to the figure level, but
|
|
# keep this here in case someone was calling it...
|
|
figure.draw_without_rendering()
|
|
|
|
|
|
def _is_non_interactive_terminal_ipython(ip):
|
|
"""
|
|
Return whether we are in a terminal IPython, but non interactive.
|
|
|
|
When in _terminal_ IPython, ip.parent will have and `interact` attribute,
|
|
if this attribute is False we do not setup eventloop integration as the
|
|
user will _not_ interact with IPython. In all other case (ZMQKernel, or is
|
|
interactive), we do.
|
|
"""
|
|
return (hasattr(ip, 'parent')
|
|
and (ip.parent is not None)
|
|
and getattr(ip.parent, 'interact', None) is False)
|
|
|
|
|
|
@contextmanager
|
|
def _allow_interrupt(prepare_notifier, handle_sigint):
|
|
"""
|
|
A context manager that allows terminating a plot by sending a SIGINT. It
|
|
is necessary because the running backend prevents the Python interpreter
|
|
from running and processing signals (i.e., to raise a KeyboardInterrupt).
|
|
To solve this, one needs to somehow wake up the interpreter and make it
|
|
close the plot window. We do this by using the signal.set_wakeup_fd()
|
|
function which organizes a write of the signal number into a socketpair.
|
|
A backend-specific function, *prepare_notifier*, arranges to listen to
|
|
the pair's read socket while the event loop is running. (If it returns a
|
|
notifier object, that object is kept alive while the context manager runs.)
|
|
|
|
If SIGINT was indeed caught, after exiting the on_signal() function the
|
|
interpreter reacts to the signal according to the handler function which
|
|
had been set up by a signal.signal() call; here, we arrange to call the
|
|
backend-specific *handle_sigint* function. Finally, we call the old SIGINT
|
|
handler with the same arguments that were given to our custom handler.
|
|
|
|
We do this only if the old handler for SIGINT was not None, which means
|
|
that a non-python handler was installed, i.e. in Julia, and not SIG_IGN
|
|
which means we should ignore the interrupts.
|
|
|
|
Parameters
|
|
----------
|
|
prepare_notifier : Callable[[socket.socket], object]
|
|
handle_sigint : Callable[[], object]
|
|
"""
|
|
|
|
old_sigint_handler = signal.getsignal(signal.SIGINT)
|
|
if old_sigint_handler in (None, signal.SIG_IGN, signal.SIG_DFL):
|
|
yield
|
|
return
|
|
|
|
handler_args = None
|
|
wsock, rsock = socket.socketpair()
|
|
wsock.setblocking(False)
|
|
rsock.setblocking(False)
|
|
old_wakeup_fd = signal.set_wakeup_fd(wsock.fileno())
|
|
notifier = prepare_notifier(rsock)
|
|
|
|
def save_args_and_handle_sigint(*args):
|
|
nonlocal handler_args
|
|
handler_args = args
|
|
handle_sigint()
|
|
|
|
signal.signal(signal.SIGINT, save_args_and_handle_sigint)
|
|
try:
|
|
yield
|
|
finally:
|
|
wsock.close()
|
|
rsock.close()
|
|
signal.set_wakeup_fd(old_wakeup_fd)
|
|
signal.signal(signal.SIGINT, old_sigint_handler)
|
|
if handler_args is not None:
|
|
old_sigint_handler(*handler_args)
|
|
|
|
|
|
class FigureCanvasBase:
|
|
"""
|
|
The canvas the figure renders into.
|
|
|
|
Attributes
|
|
----------
|
|
figure : `~matplotlib.figure.Figure`
|
|
A high-level figure instance.
|
|
"""
|
|
|
|
# Set to one of {"qt", "gtk3", "gtk4", "wx", "tk", "macosx"} if an
|
|
# 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',
|
|
'key_press_event',
|
|
'key_release_event',
|
|
'button_press_event',
|
|
'button_release_event',
|
|
'scroll_event',
|
|
'motion_notify_event',
|
|
'pick_event',
|
|
'figure_enter_event',
|
|
'figure_leave_event',
|
|
'axes_enter_event',
|
|
'axes_leave_event',
|
|
'close_event'
|
|
]
|
|
|
|
fixed_dpi = None
|
|
|
|
filetypes = _default_filetypes
|
|
|
|
@_api.classproperty
|
|
def supports_blit(cls):
|
|
"""If this Canvas sub-class supports blitting."""
|
|
return (hasattr(cls, "copy_from_bbox")
|
|
and hasattr(cls, "restore_region"))
|
|
|
|
def __init__(self, figure=None):
|
|
from matplotlib.figure import Figure
|
|
self._fix_ipython_backend2gui()
|
|
self._is_idle_drawing = True
|
|
self._is_saving = False
|
|
if figure is None:
|
|
figure = Figure()
|
|
figure.set_canvas(self)
|
|
self.figure = figure
|
|
self.manager = None
|
|
self.widgetlock = widgets.LockDraw()
|
|
self._button = None # the button pressed
|
|
self._key = None # the key pressed
|
|
self.mouse_grabber = None # the Axes currently grabbing mouse
|
|
self.toolbar = None # NavigationToolbar2 will set me
|
|
self._is_idle_drawing = False
|
|
# We don't want to scale up the figure DPI more than once.
|
|
figure._original_dpi = figure.dpi
|
|
self._device_pixel_ratio = 1
|
|
super().__init__() # Typically the GUI widget init (if any).
|
|
|
|
callbacks = property(lambda self: self.figure._canvas_callbacks)
|
|
button_pick_id = property(lambda self: self.figure._button_pick_id)
|
|
scroll_pick_id = property(lambda self: self.figure._scroll_pick_id)
|
|
|
|
@classmethod
|
|
@functools.cache
|
|
def _fix_ipython_backend2gui(cls):
|
|
# Fix hard-coded module -> toolkit mapping in IPython (used for
|
|
# `ipython --auto`). This cannot be done at import time due to
|
|
# ordering issues, so we do it when creating a canvas, and should only
|
|
# be done once per class (hence the `cache`).
|
|
|
|
# This function will not be needed when Python 3.12, the latest version
|
|
# supported by IPython < 8.24, reaches end-of-life in late 2028.
|
|
# At that time this function can be made a no-op and deprecated.
|
|
mod_ipython = sys.modules.get("IPython")
|
|
if mod_ipython is None or mod_ipython.version_info[:2] >= (8, 24):
|
|
# Use of backend2gui is not needed for IPython >= 8.24 as the
|
|
# functionality has been moved to Matplotlib.
|
|
return
|
|
|
|
import IPython
|
|
ip = IPython.get_ipython()
|
|
if not ip:
|
|
return
|
|
from IPython.core import pylabtools as pt
|
|
if (not hasattr(pt, "backend2gui")
|
|
or not hasattr(ip, "enable_matplotlib")):
|
|
# In case we ever move the patch to IPython and remove these APIs,
|
|
# don't break on our side.
|
|
return
|
|
backend2gui_rif = {
|
|
"qt": "qt",
|
|
"gtk3": "gtk3",
|
|
"gtk4": "gtk4",
|
|
"wx": "wx",
|
|
"macosx": "osx",
|
|
}.get(cls.required_interactive_framework)
|
|
if backend2gui_rif:
|
|
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
|
|
try:
|
|
yield
|
|
finally:
|
|
self._is_idle_drawing = False
|
|
|
|
def is_saving(self):
|
|
"""
|
|
Return whether the renderer is in the process of saving
|
|
to a file, rather than rendering for an on-screen buffer.
|
|
"""
|
|
return self._is_saving
|
|
|
|
def blit(self, bbox=None):
|
|
"""Blit the canvas in bbox (default entire canvas)."""
|
|
|
|
def inaxes(self, xy):
|
|
"""
|
|
Return the topmost visible `~.axes.Axes` containing the point *xy*.
|
|
|
|
Parameters
|
|
----------
|
|
xy : (float, float)
|
|
(x, y) pixel positions from left/bottom of the canvas.
|
|
|
|
Returns
|
|
-------
|
|
`~matplotlib.axes.Axes` or None
|
|
The topmost visible Axes containing the point, or None if there
|
|
is no Axes at the point.
|
|
"""
|
|
axes_list = [a for a in self.figure.get_axes()
|
|
if a.patch.contains_point(xy) and a.get_visible()]
|
|
if axes_list:
|
|
axes = cbook._topmost_artist(axes_list)
|
|
else:
|
|
axes = None
|
|
|
|
return axes
|
|
|
|
def grab_mouse(self, ax):
|
|
"""
|
|
Set the child `~.axes.Axes` which is grabbing the mouse events.
|
|
|
|
Usually called by the widgets themselves. It is an error to call this
|
|
if the mouse is already grabbed by another Axes.
|
|
"""
|
|
if self.mouse_grabber not in (None, ax):
|
|
raise RuntimeError("Another Axes already grabs mouse input")
|
|
self.mouse_grabber = ax
|
|
|
|
def release_mouse(self, ax):
|
|
"""
|
|
Release the mouse grab held by the `~.axes.Axes` *ax*.
|
|
|
|
Usually called by the widgets. It is ok to call this even if *ax*
|
|
doesn't have the mouse grab currently.
|
|
"""
|
|
if self.mouse_grabber is ax:
|
|
self.mouse_grabber = None
|
|
|
|
def set_cursor(self, cursor):
|
|
"""
|
|
Set the current cursor.
|
|
|
|
This may have no effect if the backend does not display anything.
|
|
|
|
If required by the backend, this method should trigger an update in
|
|
the backend event loop after the cursor is set, as this method may be
|
|
called e.g. before a long-running task during which the GUI is not
|
|
updated.
|
|
|
|
Parameters
|
|
----------
|
|
cursor : `.Cursors`
|
|
The cursor to display over the canvas. Note: some backends may
|
|
change the cursor for the entire window.
|
|
"""
|
|
|
|
def draw(self, *args, **kwargs):
|
|
"""
|
|
Render the `.Figure`.
|
|
|
|
This method must walk the artist tree, even if no output is produced,
|
|
because it triggers deferred work that users may want to access
|
|
before saving output to disk. For example computing limits,
|
|
auto-limits, and tick values.
|
|
"""
|
|
|
|
def draw_idle(self, *args, **kwargs):
|
|
"""
|
|
Request a widget redraw once control returns to the GUI event loop.
|
|
|
|
Even if multiple calls to `draw_idle` occur before control returns
|
|
to the GUI event loop, the figure will only be rendered once.
|
|
|
|
Notes
|
|
-----
|
|
Backends may choose to override the method and implement their own
|
|
strategy to prevent multiple renderings.
|
|
|
|
"""
|
|
if not self._is_idle_drawing:
|
|
with self._idle_draw_cntx():
|
|
self.draw(*args, **kwargs)
|
|
|
|
@property
|
|
def device_pixel_ratio(self):
|
|
"""
|
|
The ratio of physical to logical pixels used for the canvas on screen.
|
|
|
|
By default, this is 1, meaning physical and logical pixels are the same
|
|
size. Subclasses that support High DPI screens may set this property to
|
|
indicate that said ratio is different. All Matplotlib interaction,
|
|
unless working directly with the canvas, remains in logical pixels.
|
|
|
|
"""
|
|
return self._device_pixel_ratio
|
|
|
|
def _set_device_pixel_ratio(self, ratio):
|
|
"""
|
|
Set the ratio of physical to logical pixels used for the canvas.
|
|
|
|
Subclasses that support High DPI screens can set this property to
|
|
indicate that said ratio is different. The canvas itself will be
|
|
created at the physical size, while the client side will use the
|
|
logical size. Thus the DPI of the Figure will change to be scaled by
|
|
this ratio. Implementations that support High DPI screens should use
|
|
physical pixels for events so that transforms back to Axes space are
|
|
correct.
|
|
|
|
By default, this is 1, meaning physical and logical pixels are the same
|
|
size.
|
|
|
|
Parameters
|
|
----------
|
|
ratio : float
|
|
The ratio of logical to physical pixels used for the canvas.
|
|
|
|
Returns
|
|
-------
|
|
bool
|
|
Whether the ratio has changed. Backends may interpret this as a
|
|
signal to resize the window, repaint the canvas, or change any
|
|
other relevant properties.
|
|
"""
|
|
if self._device_pixel_ratio == ratio:
|
|
return False
|
|
# In cases with mixed resolution displays, we need to be careful if the
|
|
# device pixel ratio changes - in this case we need to resize the
|
|
# canvas accordingly. Some backends provide events that indicate a
|
|
# change in DPI, but those that don't will update this before drawing.
|
|
dpi = ratio * self.figure._original_dpi
|
|
self.figure._set_dpi(dpi, forward=False)
|
|
self._device_pixel_ratio = ratio
|
|
return True
|
|
|
|
def get_width_height(self, *, physical=False):
|
|
"""
|
|
Return the figure width and height in integral points or pixels.
|
|
|
|
When the figure is used on High DPI screens (and the backend supports
|
|
it), the truncation to integers occurs after scaling by the device
|
|
pixel ratio.
|
|
|
|
Parameters
|
|
----------
|
|
physical : bool, default: False
|
|
Whether to return true physical pixels or logical pixels. Physical
|
|
pixels may be used by backends that support HiDPI, but still
|
|
configure the canvas using its actual size.
|
|
|
|
Returns
|
|
-------
|
|
width, height : int
|
|
The size of the figure, in points or pixels, depending on the
|
|
backend.
|
|
"""
|
|
return tuple(int(size / (1 if physical else self.device_pixel_ratio))
|
|
for size in self.figure.bbox.max)
|
|
|
|
@classmethod
|
|
def get_supported_filetypes(cls):
|
|
"""Return dict of savefig file formats supported by this backend."""
|
|
return cls.filetypes
|
|
|
|
@classmethod
|
|
def get_supported_filetypes_grouped(cls):
|
|
"""
|
|
Return a dict of savefig file formats supported by this backend,
|
|
where the keys are a file type name, such as 'Joint Photographic
|
|
Experts Group', and the values are a list of filename extensions used
|
|
for that filetype, such as ['jpg', 'jpeg'].
|
|
"""
|
|
groupings = {}
|
|
for ext, name in cls.filetypes.items():
|
|
groupings.setdefault(name, []).append(ext)
|
|
groupings[name].sort()
|
|
return groupings
|
|
|
|
@contextmanager
|
|
def _switch_canvas_and_return_print_method(self, fmt, backend=None):
|
|
"""
|
|
Context manager temporarily setting the canvas for saving the figure::
|
|
|
|
with canvas._switch_canvas_and_return_print_method(fmt, backend) \\
|
|
as print_method:
|
|
# ``print_method`` is a suitable ``print_{fmt}`` method, and
|
|
# the figure's canvas is temporarily switched to the method's
|
|
# canvas within the with... block. ``print_method`` is also
|
|
# wrapped to suppress extra kwargs passed by ``print_figure``.
|
|
|
|
Parameters
|
|
----------
|
|
fmt : str
|
|
If *backend* is None, then determine a suitable canvas class for
|
|
saving to format *fmt* -- either the current canvas class, if it
|
|
supports *fmt*, or whatever `get_registered_canvas_class` returns;
|
|
switch the figure canvas to that canvas class.
|
|
backend : str or None, default: None
|
|
If not None, switch the figure canvas to the ``FigureCanvas`` class
|
|
of the given backend.
|
|
"""
|
|
canvas = None
|
|
if backend is not None:
|
|
# Return a specific canvas class, if requested.
|
|
from .backends.registry import backend_registry
|
|
canvas_class = backend_registry.load_backend_module(backend).FigureCanvas
|
|
if not hasattr(canvas_class, f"print_{fmt}"):
|
|
raise ValueError(
|
|
f"The {backend!r} backend does not support {fmt} output")
|
|
canvas = canvas_class(self.figure)
|
|
elif hasattr(self, f"print_{fmt}"):
|
|
# Return the current canvas if it supports the requested format.
|
|
canvas = self
|
|
else:
|
|
# Return a default canvas for the requested format, if it exists.
|
|
canvas_class = get_registered_canvas_class(fmt)
|
|
if canvas_class is None:
|
|
raise ValueError(
|
|
"Format {!r} is not supported (supported formats: {})".format(
|
|
fmt, ", ".join(sorted(self.get_supported_filetypes()))))
|
|
canvas = canvas_class(self.figure)
|
|
canvas._is_saving = self._is_saving
|
|
meth = getattr(canvas, f"print_{fmt}")
|
|
mod = (meth.func.__module__
|
|
if hasattr(meth, "func") # partialmethod, e.g. backend_wx.
|
|
else meth.__module__)
|
|
if mod.startswith(("matplotlib.", "mpl_toolkits.")):
|
|
optional_kws = { # Passed by print_figure for other renderers.
|
|
"dpi", "facecolor", "edgecolor", "orientation",
|
|
"bbox_inches_restore"}
|
|
skip = optional_kws - {*inspect.signature(meth).parameters}
|
|
print_method = functools.wraps(meth)(lambda *args, **kwargs: meth(
|
|
*args, **{k: v for k, v in kwargs.items() if k not in skip}))
|
|
else: # Let third-parties do as they see fit.
|
|
print_method = meth
|
|
try:
|
|
yield print_method
|
|
finally:
|
|
self.figure.canvas = self
|
|
|
|
def print_figure(
|
|
self, filename, dpi=None, facecolor=None, edgecolor=None,
|
|
orientation='portrait', format=None, *,
|
|
bbox_inches=None, pad_inches=None, bbox_extra_artists=None,
|
|
backend=None, **kwargs):
|
|
"""
|
|
Render the figure to hardcopy. Set the figure patch face and edge
|
|
colors. This is useful because some of the GUIs have a gray figure
|
|
face color background and you'll probably want to override this on
|
|
hardcopy.
|
|
|
|
Parameters
|
|
----------
|
|
filename : str or path-like or file-like
|
|
The file where the figure is saved.
|
|
|
|
dpi : float, default: :rc:`savefig.dpi`
|
|
The dots per inch to save the figure in.
|
|
|
|
facecolor : :mpltype:`color` or 'auto', default: :rc:`savefig.facecolor`
|
|
The facecolor of the figure. If 'auto', use the current figure
|
|
facecolor.
|
|
|
|
edgecolor : :mpltype:`color` or 'auto', default: :rc:`savefig.edgecolor`
|
|
The edgecolor of the figure. If 'auto', use the current figure
|
|
edgecolor.
|
|
|
|
orientation : {'landscape', 'portrait'}, default: 'portrait'
|
|
Only currently applies to PostScript printing.
|
|
|
|
format : str, optional
|
|
Force a specific file format. If not given, the format is inferred
|
|
from the *filename* extension, and if that fails from
|
|
:rc:`savefig.format`.
|
|
|
|
bbox_inches : 'tight' or `.Bbox`, default: :rc:`savefig.bbox`
|
|
Bounding box in inches: only the given portion of the figure is
|
|
saved. If 'tight', try to figure out the tight bbox of the figure.
|
|
|
|
pad_inches : float or 'layout', default: :rc:`savefig.pad_inches`
|
|
Amount of padding in inches around the figure when bbox_inches is
|
|
'tight'. If 'layout' use the padding from the constrained or
|
|
compressed layout engine; ignored if one of those engines is not in
|
|
use.
|
|
|
|
bbox_extra_artists : list of `~matplotlib.artist.Artist`, optional
|
|
A list of extra artists that will be considered when the
|
|
tight bbox is calculated.
|
|
|
|
backend : str, optional
|
|
Use a non-default backend to render the file, e.g. to render a
|
|
png file with the "cairo" backend rather than the default "agg",
|
|
or a pdf file with the "pgf" backend rather than the default
|
|
"pdf". Note that the default backend is normally sufficient. See
|
|
:ref:`the-builtin-backends` for a list of valid backends for each
|
|
file format. Custom backends can be referenced as "module://...".
|
|
"""
|
|
if format is None:
|
|
# get format from filename, or from backend's default filetype
|
|
if isinstance(filename, os.PathLike):
|
|
filename = os.fspath(filename)
|
|
if isinstance(filename, str):
|
|
format = os.path.splitext(filename)[1][1:]
|
|
if format is None or format == '':
|
|
format = self.get_default_filetype()
|
|
if isinstance(filename, str):
|
|
filename = filename.rstrip('.') + '.' + format
|
|
format = format.lower()
|
|
|
|
if dpi is None:
|
|
dpi = rcParams['savefig.dpi']
|
|
if dpi == 'figure':
|
|
dpi = getattr(self.figure, '_original_dpi', self.figure.dpi)
|
|
|
|
if kwargs.get("papertype") == 'auto':
|
|
# When deprecation elapses, remove backend_ps._get_papertype & its callers.
|
|
_api.warn_deprecated(
|
|
"3.8", name="papertype='auto'", addendum="Pass an explicit paper type, "
|
|
"'figure', or omit the *papertype* argument entirely.")
|
|
|
|
# Remove the figure manager, if any, to avoid resizing the GUI widget.
|
|
with cbook._setattr_cm(self, manager=None), \
|
|
self._switch_canvas_and_return_print_method(format, backend) \
|
|
as print_method, \
|
|
cbook._setattr_cm(self.figure, dpi=dpi), \
|
|
cbook._setattr_cm(self.figure.canvas, _device_pixel_ratio=1), \
|
|
cbook._setattr_cm(self.figure.canvas, _is_saving=True), \
|
|
ExitStack() as stack:
|
|
|
|
for prop in ["facecolor", "edgecolor"]:
|
|
color = locals()[prop]
|
|
if color is None:
|
|
color = rcParams[f"savefig.{prop}"]
|
|
if not cbook._str_equal(color, "auto"):
|
|
stack.enter_context(self.figure._cm_set(**{prop: color}))
|
|
|
|
if bbox_inches is None:
|
|
bbox_inches = rcParams['savefig.bbox']
|
|
|
|
layout_engine = self.figure.get_layout_engine()
|
|
if layout_engine is not None or bbox_inches == "tight":
|
|
# we need to trigger a draw before printing to make sure
|
|
# CL works. "tight" also needs a draw to get the right
|
|
# locations:
|
|
renderer = _get_renderer(
|
|
self.figure,
|
|
functools.partial(
|
|
print_method, orientation=orientation)
|
|
)
|
|
# we do this instead of `self.figure.draw_without_rendering`
|
|
# so that we can inject the orientation
|
|
with getattr(renderer, "_draw_disabled", nullcontext)():
|
|
self.figure.draw(renderer)
|
|
if bbox_inches:
|
|
if bbox_inches == "tight":
|
|
bbox_inches = self.figure.get_tightbbox(
|
|
renderer, bbox_extra_artists=bbox_extra_artists)
|
|
if (isinstance(layout_engine, ConstrainedLayoutEngine) and
|
|
pad_inches == "layout"):
|
|
h_pad = layout_engine.get()["h_pad"]
|
|
w_pad = layout_engine.get()["w_pad"]
|
|
else:
|
|
if pad_inches in [None, "layout"]:
|
|
pad_inches = rcParams['savefig.pad_inches']
|
|
h_pad = w_pad = pad_inches
|
|
bbox_inches = bbox_inches.padded(w_pad, h_pad)
|
|
|
|
# call adjust_bbox to save only the given area
|
|
restore_bbox = _tight_bbox.adjust_bbox(
|
|
self.figure, bbox_inches, self.figure.canvas.fixed_dpi)
|
|
|
|
_bbox_inches_restore = (bbox_inches, restore_bbox)
|
|
else:
|
|
_bbox_inches_restore = None
|
|
|
|
# we have already done layout above, so turn it off:
|
|
stack.enter_context(self.figure._cm_set(layout_engine='none'))
|
|
try:
|
|
# _get_renderer may change the figure dpi (as vector formats
|
|
# force the figure dpi to 72), so we need to set it again here.
|
|
with cbook._setattr_cm(self.figure, dpi=dpi):
|
|
result = print_method(
|
|
filename,
|
|
facecolor=facecolor,
|
|
edgecolor=edgecolor,
|
|
orientation=orientation,
|
|
bbox_inches_restore=_bbox_inches_restore,
|
|
**kwargs)
|
|
finally:
|
|
if bbox_inches and restore_bbox:
|
|
restore_bbox()
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def get_default_filetype(cls):
|
|
"""
|
|
Return the default savefig file format as specified in
|
|
:rc:`savefig.format`.
|
|
|
|
The returned string does not include a period. This method is
|
|
overridden in backends that only support a single file type.
|
|
"""
|
|
return rcParams['savefig.format']
|
|
|
|
def get_default_filename(self):
|
|
"""
|
|
Return a string, which includes extension, suitable for use as
|
|
a default filename.
|
|
"""
|
|
basename = (self.manager.get_window_title() if self.manager is not None
|
|
else '')
|
|
basename = (basename or 'image').replace(' ', '_')
|
|
filetype = self.get_default_filetype()
|
|
filename = basename + '.' + filetype
|
|
return filename
|
|
|
|
@_api.deprecated("3.8")
|
|
def switch_backends(self, FigureCanvasClass):
|
|
"""
|
|
Instantiate an instance of FigureCanvasClass
|
|
|
|
This is used for backend switching, e.g., to instantiate a
|
|
FigureCanvasPS from a FigureCanvasGTK. Note, deep copying is
|
|
not done, so any changes to one of the instances (e.g., setting
|
|
figure size or line props), will be reflected in the other
|
|
"""
|
|
newCanvas = FigureCanvasClass(self.figure)
|
|
newCanvas._is_saving = self._is_saving
|
|
return newCanvas
|
|
|
|
def mpl_connect(self, s, func):
|
|
"""
|
|
Bind function *func* to event *s*.
|
|
|
|
Parameters
|
|
----------
|
|
s : str
|
|
One of the following events ids:
|
|
|
|
- 'button_press_event'
|
|
- 'button_release_event'
|
|
- 'draw_event'
|
|
- 'key_press_event'
|
|
- 'key_release_event'
|
|
- 'motion_notify_event'
|
|
- 'pick_event'
|
|
- 'resize_event'
|
|
- 'scroll_event'
|
|
- 'figure_enter_event',
|
|
- 'figure_leave_event',
|
|
- 'axes_enter_event',
|
|
- 'axes_leave_event'
|
|
- 'close_event'.
|
|
|
|
func : callable
|
|
The callback function to be executed, which must have the
|
|
signature::
|
|
|
|
def func(event: Event) -> Any
|
|
|
|
For the location events (button and key press/release), if the
|
|
mouse is over the Axes, the ``inaxes`` attribute of the event will
|
|
be set to the `~matplotlib.axes.Axes` the event occurs is over, and
|
|
additionally, the variables ``xdata`` and ``ydata`` attributes will
|
|
be set to the mouse location in data coordinates. See `.KeyEvent`
|
|
and `.MouseEvent` for more info.
|
|
|
|
.. note::
|
|
|
|
If func is a method, this only stores a weak reference to the
|
|
method. Thus, the figure does not influence the lifetime of
|
|
the associated object. Usually, you want to make sure that the
|
|
object is kept alive throughout the lifetime of the figure by
|
|
holding a reference to it.
|
|
|
|
Returns
|
|
-------
|
|
cid
|
|
A connection id that can be used with
|
|
`.FigureCanvasBase.mpl_disconnect`.
|
|
|
|
Examples
|
|
--------
|
|
::
|
|
|
|
def on_press(event):
|
|
print('you pressed', event.button, event.xdata, event.ydata)
|
|
|
|
cid = canvas.mpl_connect('button_press_event', on_press)
|
|
"""
|
|
|
|
return self.callbacks.connect(s, func)
|
|
|
|
def mpl_disconnect(self, cid):
|
|
"""
|
|
Disconnect the callback with id *cid*.
|
|
|
|
Examples
|
|
--------
|
|
::
|
|
|
|
cid = canvas.mpl_connect('button_press_event', on_press)
|
|
# ... later
|
|
canvas.mpl_disconnect(cid)
|
|
"""
|
|
self.callbacks.disconnect(cid)
|
|
|
|
# Internal subclasses can override _timer_cls instead of new_timer, though
|
|
# this is not a public API for third-party subclasses.
|
|
_timer_cls = TimerBase
|
|
|
|
def new_timer(self, interval=None, callbacks=None):
|
|
"""
|
|
Create a new backend-specific subclass of `.Timer`.
|
|
|
|
This is useful for getting periodic events through the backend's native
|
|
event loop. Implemented only for backends with GUIs.
|
|
|
|
Parameters
|
|
----------
|
|
interval : int
|
|
Timer interval in milliseconds.
|
|
|
|
callbacks : list[tuple[callable, tuple, dict]]
|
|
Sequence of (func, args, kwargs) where ``func(*args, **kwargs)``
|
|
will be executed by the timer every *interval*.
|
|
|
|
Callbacks which return ``False`` or ``0`` will be removed from the
|
|
timer.
|
|
|
|
Examples
|
|
--------
|
|
>>> timer = fig.canvas.new_timer(callbacks=[(f1, (1,), {'a': 3})])
|
|
"""
|
|
return self._timer_cls(interval=interval, callbacks=callbacks)
|
|
|
|
def flush_events(self):
|
|
"""
|
|
Flush the GUI events for the figure.
|
|
|
|
Interactive backends need to reimplement this method.
|
|
"""
|
|
|
|
def start_event_loop(self, timeout=0):
|
|
"""
|
|
Start a blocking event loop.
|
|
|
|
Such an event loop is used by interactive functions, such as
|
|
`~.Figure.ginput` and `~.Figure.waitforbuttonpress`, to wait for
|
|
events.
|
|
|
|
The event loop blocks until a callback function triggers
|
|
`stop_event_loop`, or *timeout* is reached.
|
|
|
|
If *timeout* is 0 or negative, never timeout.
|
|
|
|
Only interactive backends need to reimplement this method and it relies
|
|
on `flush_events` being properly implemented.
|
|
|
|
Interactive backends should implement this in a more native way.
|
|
"""
|
|
if timeout <= 0:
|
|
timeout = np.inf
|
|
timestep = 0.01
|
|
counter = 0
|
|
self._looping = True
|
|
while self._looping and counter * timestep < timeout:
|
|
self.flush_events()
|
|
time.sleep(timestep)
|
|
counter += 1
|
|
|
|
def stop_event_loop(self):
|
|
"""
|
|
Stop the current blocking event loop.
|
|
|
|
Interactive backends need to reimplement this to match
|
|
`start_event_loop`
|
|
"""
|
|
self._looping = False
|
|
|
|
|
|
def key_press_handler(event, canvas=None, toolbar=None):
|
|
"""
|
|
Implement the default Matplotlib key bindings for the canvas and toolbar
|
|
described at :ref:`key-event-handling`.
|
|
|
|
Parameters
|
|
----------
|
|
event : `KeyEvent`
|
|
A key press/release event.
|
|
canvas : `FigureCanvasBase`, default: ``event.canvas``
|
|
The backend-specific canvas instance. This parameter is kept for
|
|
back-compatibility, but, if set, should always be equal to
|
|
``event.canvas``.
|
|
toolbar : `NavigationToolbar2`, default: ``event.canvas.toolbar``
|
|
The navigation cursor toolbar. This parameter is kept for
|
|
back-compatibility, but, if set, should always be equal to
|
|
``event.canvas.toolbar``.
|
|
"""
|
|
if event.key is None:
|
|
return
|
|
if canvas is None:
|
|
canvas = event.canvas
|
|
if toolbar is None:
|
|
toolbar = canvas.toolbar
|
|
|
|
# toggle fullscreen mode (default key 'f', 'ctrl + f')
|
|
if event.key in rcParams['keymap.fullscreen']:
|
|
try:
|
|
canvas.manager.full_screen_toggle()
|
|
except AttributeError:
|
|
pass
|
|
|
|
# quit the figure (default key 'ctrl+w')
|
|
if event.key in rcParams['keymap.quit']:
|
|
Gcf.destroy_fig(canvas.figure)
|
|
if event.key in rcParams['keymap.quit_all']:
|
|
Gcf.destroy_all()
|
|
|
|
if toolbar is not None:
|
|
# home or reset mnemonic (default key 'h', 'home' and 'r')
|
|
if event.key in rcParams['keymap.home']:
|
|
toolbar.home()
|
|
# forward / backward keys to enable left handed quick navigation
|
|
# (default key for backward: 'left', 'backspace' and 'c')
|
|
elif event.key in rcParams['keymap.back']:
|
|
toolbar.back()
|
|
# (default key for forward: 'right' and 'v')
|
|
elif event.key in rcParams['keymap.forward']:
|
|
toolbar.forward()
|
|
# pan mnemonic (default key 'p')
|
|
elif event.key in rcParams['keymap.pan']:
|
|
toolbar.pan()
|
|
toolbar._update_cursor(event)
|
|
# zoom mnemonic (default key 'o')
|
|
elif event.key in rcParams['keymap.zoom']:
|
|
toolbar.zoom()
|
|
toolbar._update_cursor(event)
|
|
# saving current figure (default key 's')
|
|
elif event.key in rcParams['keymap.save']:
|
|
toolbar.save_figure()
|
|
|
|
if event.inaxes is None:
|
|
return
|
|
|
|
# these bindings require the mouse to be over an Axes to trigger
|
|
def _get_uniform_gridstate(ticks):
|
|
# Return True/False if all grid lines are on or off, None if they are
|
|
# not all in the same state.
|
|
return (True if all(tick.gridline.get_visible() for tick in ticks) else
|
|
False if not any(tick.gridline.get_visible() for tick in ticks) else
|
|
None)
|
|
|
|
ax = event.inaxes
|
|
# toggle major grids in current Axes (default key 'g')
|
|
# Both here and below (for 'G'), we do nothing if *any* grid (major or
|
|
# minor, x or y) is not in a uniform state, to avoid messing up user
|
|
# customization.
|
|
if (event.key in rcParams['keymap.grid']
|
|
# Exclude minor grids not in a uniform state.
|
|
and None not in [_get_uniform_gridstate(ax.xaxis.minorTicks),
|
|
_get_uniform_gridstate(ax.yaxis.minorTicks)]):
|
|
x_state = _get_uniform_gridstate(ax.xaxis.majorTicks)
|
|
y_state = _get_uniform_gridstate(ax.yaxis.majorTicks)
|
|
cycle = [(False, False), (True, False), (True, True), (False, True)]
|
|
try:
|
|
x_state, y_state = (
|
|
cycle[(cycle.index((x_state, y_state)) + 1) % len(cycle)])
|
|
except ValueError:
|
|
# Exclude major grids not in a uniform state.
|
|
pass
|
|
else:
|
|
# If turning major grids off, also turn minor grids off.
|
|
ax.grid(x_state, which="major" if x_state else "both", axis="x")
|
|
ax.grid(y_state, which="major" if y_state else "both", axis="y")
|
|
canvas.draw_idle()
|
|
# toggle major and minor grids in current Axes (default key 'G')
|
|
if (event.key in rcParams['keymap.grid_minor']
|
|
# Exclude major grids not in a uniform state.
|
|
and None not in [_get_uniform_gridstate(ax.xaxis.majorTicks),
|
|
_get_uniform_gridstate(ax.yaxis.majorTicks)]):
|
|
x_state = _get_uniform_gridstate(ax.xaxis.minorTicks)
|
|
y_state = _get_uniform_gridstate(ax.yaxis.minorTicks)
|
|
cycle = [(False, False), (True, False), (True, True), (False, True)]
|
|
try:
|
|
x_state, y_state = (
|
|
cycle[(cycle.index((x_state, y_state)) + 1) % len(cycle)])
|
|
except ValueError:
|
|
# Exclude minor grids not in a uniform state.
|
|
pass
|
|
else:
|
|
ax.grid(x_state, which="both", axis="x")
|
|
ax.grid(y_state, which="both", axis="y")
|
|
canvas.draw_idle()
|
|
# toggle scaling of y-axes between 'log and 'linear' (default key 'l')
|
|
elif event.key in rcParams['keymap.yscale']:
|
|
scale = ax.get_yscale()
|
|
if scale == 'log':
|
|
ax.set_yscale('linear')
|
|
ax.figure.canvas.draw_idle()
|
|
elif scale == 'linear':
|
|
try:
|
|
ax.set_yscale('log')
|
|
except ValueError as exc:
|
|
_log.warning(str(exc))
|
|
ax.set_yscale('linear')
|
|
ax.figure.canvas.draw_idle()
|
|
# toggle scaling of x-axes between 'log and 'linear' (default key 'k')
|
|
elif event.key in rcParams['keymap.xscale']:
|
|
scalex = ax.get_xscale()
|
|
if scalex == 'log':
|
|
ax.set_xscale('linear')
|
|
ax.figure.canvas.draw_idle()
|
|
elif scalex == 'linear':
|
|
try:
|
|
ax.set_xscale('log')
|
|
except ValueError as exc:
|
|
_log.warning(str(exc))
|
|
ax.set_xscale('linear')
|
|
ax.figure.canvas.draw_idle()
|
|
|
|
|
|
def button_press_handler(event, canvas=None, toolbar=None):
|
|
"""
|
|
The default Matplotlib button actions for extra mouse buttons.
|
|
|
|
Parameters are as for `key_press_handler`, except that *event* is a
|
|
`MouseEvent`.
|
|
"""
|
|
if canvas is None:
|
|
canvas = event.canvas
|
|
if toolbar is None:
|
|
toolbar = canvas.toolbar
|
|
if toolbar is not None:
|
|
button_name = str(MouseButton(event.button))
|
|
if button_name in rcParams['keymap.back']:
|
|
toolbar.back()
|
|
elif button_name in rcParams['keymap.forward']:
|
|
toolbar.forward()
|
|
|
|
|
|
class NonGuiException(Exception):
|
|
"""Raised when trying show a figure in a non-GUI backend."""
|
|
pass
|
|
|
|
|
|
class FigureManagerBase:
|
|
"""
|
|
A backend-independent abstraction of a figure container and controller.
|
|
|
|
The figure manager is used by pyplot to interact with the window in a
|
|
backend-independent way. It's an adapter for the real (GUI) framework that
|
|
represents the visual figure on screen.
|
|
|
|
The figure manager is connected to a specific canvas instance, which in turn
|
|
is connected to a specific figure instance. To access a figure manager for
|
|
a given figure in user code, you typically use ``fig.canvas.manager``.
|
|
|
|
GUI backends derive from this class to translate common operations such
|
|
as *show* or *resize* to the GUI-specific code. Non-GUI backends do not
|
|
support these operations and can just use the base class.
|
|
|
|
This following basic operations are accessible:
|
|
|
|
**Window operations**
|
|
|
|
- `~.FigureManagerBase.show`
|
|
- `~.FigureManagerBase.destroy`
|
|
- `~.FigureManagerBase.full_screen_toggle`
|
|
- `~.FigureManagerBase.resize`
|
|
- `~.FigureManagerBase.get_window_title`
|
|
- `~.FigureManagerBase.set_window_title`
|
|
|
|
**Key and mouse button press handling**
|
|
|
|
The figure manager sets up default key and mouse button press handling by
|
|
hooking up the `.key_press_handler` to the matplotlib event system. This
|
|
ensures the same shortcuts and mouse actions across backends.
|
|
|
|
**Other operations**
|
|
|
|
Subclasses will have additional attributes and functions to access
|
|
additional functionality. This is of course backend-specific. For example,
|
|
most GUI backends have ``window`` and ``toolbar`` attributes that give
|
|
access to the native GUI widgets of the respective framework.
|
|
|
|
Attributes
|
|
----------
|
|
canvas : `FigureCanvasBase`
|
|
The backend-specific canvas instance.
|
|
|
|
num : int or str
|
|
The figure number.
|
|
|
|
key_press_handler_id : int
|
|
The default key handler cid, when using the toolmanager.
|
|
To disable the default key press handling use::
|
|
|
|
figure.canvas.mpl_disconnect(
|
|
figure.canvas.manager.key_press_handler_id)
|
|
|
|
button_press_handler_id : int
|
|
The default mouse button handler cid, when using the toolmanager.
|
|
To disable the default button press handling use::
|
|
|
|
figure.canvas.mpl_disconnect(
|
|
figure.canvas.manager.button_press_handler_id)
|
|
"""
|
|
|
|
_toolbar2_class = None
|
|
_toolmanager_toolbar_class = None
|
|
|
|
def __init__(self, canvas, num):
|
|
self.canvas = canvas
|
|
canvas.manager = self # store a pointer to parent
|
|
self.num = num
|
|
self.set_window_title(f"Figure {num:d}")
|
|
|
|
self.key_press_handler_id = None
|
|
self.button_press_handler_id = None
|
|
if rcParams['toolbar'] != 'toolmanager':
|
|
self.key_press_handler_id = self.canvas.mpl_connect(
|
|
'key_press_event', key_press_handler)
|
|
self.button_press_handler_id = self.canvas.mpl_connect(
|
|
'button_press_event', button_press_handler)
|
|
|
|
self.toolmanager = (ToolManager(canvas.figure)
|
|
if mpl.rcParams['toolbar'] == 'toolmanager'
|
|
else None)
|
|
if (mpl.rcParams["toolbar"] == "toolbar2"
|
|
and self._toolbar2_class):
|
|
self.toolbar = self._toolbar2_class(self.canvas)
|
|
elif (mpl.rcParams["toolbar"] == "toolmanager"
|
|
and self._toolmanager_toolbar_class):
|
|
self.toolbar = self._toolmanager_toolbar_class(self.toolmanager)
|
|
else:
|
|
self.toolbar = None
|
|
|
|
if self.toolmanager:
|
|
tools.add_tools_to_manager(self.toolmanager)
|
|
if self.toolbar:
|
|
tools.add_tools_to_container(self.toolbar)
|
|
|
|
@self.canvas.figure.add_axobserver
|
|
def notify_axes_change(fig):
|
|
# Called whenever the current Axes is changed.
|
|
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)
|
|
|
|
@classmethod
|
|
def start_main_loop(cls):
|
|
"""
|
|
Start the main event loop.
|
|
|
|
This method is called by `.FigureManagerBase.pyplot_show`, which is the
|
|
implementation of `.pyplot.show`. To customize the behavior of
|
|
`.pyplot.show`, interactive backends should usually override
|
|
`~.FigureManagerBase.start_main_loop`; if more customized logic is
|
|
necessary, `~.FigureManagerBase.pyplot_show` can also be overridden.
|
|
"""
|
|
|
|
@classmethod
|
|
def pyplot_show(cls, *, block=None):
|
|
"""
|
|
Show all figures. This method is the implementation of `.pyplot.show`.
|
|
|
|
To customize the behavior of `.pyplot.show`, interactive backends
|
|
should usually override `~.FigureManagerBase.start_main_loop`; if more
|
|
customized logic is necessary, `~.FigureManagerBase.pyplot_show` can
|
|
also be overridden.
|
|
|
|
Parameters
|
|
----------
|
|
block : bool, optional
|
|
Whether to block by calling ``start_main_loop``. The default,
|
|
None, means to block if we are neither in IPython's ``%pylab`` mode
|
|
nor in ``interactive`` mode.
|
|
"""
|
|
managers = Gcf.get_all_fig_managers()
|
|
if not managers:
|
|
return
|
|
for manager in managers:
|
|
try:
|
|
manager.show() # Emits a warning for non-interactive backend.
|
|
except NonGuiException as exc:
|
|
_api.warn_external(str(exc))
|
|
if block is None:
|
|
# Hack: Are we in IPython's %pylab mode? In pylab mode, IPython
|
|
# (>= 0.10) tacks a _needmain attribute onto pyplot.show (always
|
|
# set to False).
|
|
pyplot_show = getattr(sys.modules.get("matplotlib.pyplot"), "show", None)
|
|
ipython_pylab = hasattr(pyplot_show, "_needmain")
|
|
block = not ipython_pylab and not is_interactive()
|
|
if block:
|
|
cls.start_main_loop()
|
|
|
|
def show(self):
|
|
"""
|
|
For GUI backends, show the figure window and redraw.
|
|
For non-GUI backends, raise an exception, unless running headless (i.e.
|
|
on Linux with an unset DISPLAY); this exception is converted to a
|
|
warning in `.Figure.show`.
|
|
"""
|
|
# This should be overridden in GUI backends.
|
|
if sys.platform == "linux" and not os.environ.get("DISPLAY"):
|
|
# We cannot check _get_running_interactive_framework() ==
|
|
# "headless" because that would also suppress the warning when
|
|
# $DISPLAY exists but is invalid, which is more likely an error and
|
|
# thus warrants a warning.
|
|
return
|
|
raise NonGuiException(
|
|
f"{type(self.canvas).__name__} is non-interactive, and thus cannot be "
|
|
f"shown")
|
|
|
|
def destroy(self):
|
|
pass
|
|
|
|
def full_screen_toggle(self):
|
|
pass
|
|
|
|
def resize(self, w, h):
|
|
"""For GUI backends, resize the window (in physical pixels)."""
|
|
|
|
def get_window_title(self):
|
|
"""
|
|
Return the title text of the window containing the figure, or None
|
|
if there is no window (e.g., a PS backend).
|
|
"""
|
|
return 'image'
|
|
|
|
def set_window_title(self, title):
|
|
"""
|
|
Set the title text of the window containing the figure.
|
|
|
|
This has no effect for non-GUI (e.g., PS) backends.
|
|
|
|
Examples
|
|
--------
|
|
>>> fig = plt.figure()
|
|
>>> fig.canvas.manager.set_window_title('My figure')
|
|
"""
|
|
|
|
|
|
cursors = tools.cursors
|
|
|
|
|
|
class _Mode(str, Enum):
|
|
NONE = ""
|
|
PAN = "pan/zoom"
|
|
ZOOM = "zoom rect"
|
|
|
|
def __str__(self):
|
|
return self.value
|
|
|
|
@property
|
|
def _navigate_mode(self):
|
|
return self.name if self is not _Mode.NONE else None
|
|
|
|
|
|
class NavigationToolbar2:
|
|
"""
|
|
Base class for the navigation cursor, version 2.
|
|
|
|
Backends must implement a canvas that handles connections for
|
|
'button_press_event' and 'button_release_event'. See
|
|
:meth:`FigureCanvasBase.mpl_connect` for more information.
|
|
|
|
They must also define
|
|
|
|
:meth:`save_figure`
|
|
Save the current figure.
|
|
|
|
:meth:`draw_rubberband` (optional)
|
|
Draw the zoom to rect "rubberband" rectangle.
|
|
|
|
:meth:`set_message` (optional)
|
|
Display message.
|
|
|
|
:meth:`set_history_buttons` (optional)
|
|
You can change the history back / forward buttons to indicate disabled / enabled
|
|
state.
|
|
|
|
and override ``__init__`` to set up the toolbar -- without forgetting to
|
|
call the base-class init. Typically, ``__init__`` needs to set up toolbar
|
|
buttons connected to the `home`, `back`, `forward`, `pan`, `zoom`, and
|
|
`save_figure` methods and using standard icons in the "images" subdirectory
|
|
of the data path.
|
|
|
|
That's it, we'll do the rest!
|
|
"""
|
|
|
|
# list of toolitems to add to the toolbar, format is:
|
|
# (
|
|
# text, # the text of the button (often not visible to users)
|
|
# tooltip_text, # the tooltip shown on hover (where possible)
|
|
# image_file, # name of the image for the button (without the extension)
|
|
# name_of_method, # name of the method in NavigationToolbar2 to call
|
|
# )
|
|
toolitems = (
|
|
('Home', 'Reset original view', 'home', 'home'),
|
|
('Back', 'Back to previous view', 'back', 'back'),
|
|
('Forward', 'Forward to next view', 'forward', 'forward'),
|
|
(None, None, None, None),
|
|
('Pan',
|
|
'Left button pans, Right button zooms\n'
|
|
'x/y fixes axis, CTRL fixes aspect',
|
|
'move', 'pan'),
|
|
('Zoom', 'Zoom to rectangle\nx/y fixes axis', 'zoom_to_rect', 'zoom'),
|
|
('Subplots', 'Configure subplots', 'subplots', 'configure_subplots'),
|
|
(None, None, None, None),
|
|
('Save', 'Save the figure', 'filesave', 'save_figure'),
|
|
)
|
|
|
|
def __init__(self, canvas):
|
|
self.canvas = canvas
|
|
canvas.toolbar = self
|
|
self._nav_stack = cbook._Stack()
|
|
# This cursor will be set after the initial draw.
|
|
self._last_cursor = tools.Cursors.POINTER
|
|
|
|
self._id_press = self.canvas.mpl_connect(
|
|
'button_press_event', self._zoom_pan_handler)
|
|
self._id_release = self.canvas.mpl_connect(
|
|
'button_release_event', self._zoom_pan_handler)
|
|
self._id_drag = self.canvas.mpl_connect(
|
|
'motion_notify_event', self.mouse_move)
|
|
self._pan_info = None
|
|
self._zoom_info = None
|
|
|
|
self.mode = _Mode.NONE # a mode string for the status bar
|
|
self.set_history_buttons()
|
|
|
|
def set_message(self, s):
|
|
"""Display a message on toolbar or in status bar."""
|
|
|
|
def draw_rubberband(self, event, x0, y0, x1, y1):
|
|
"""
|
|
Draw a rectangle rubberband to indicate zoom limits.
|
|
|
|
Note that it is not guaranteed that ``x0 <= x1`` and ``y0 <= y1``.
|
|
"""
|
|
|
|
def remove_rubberband(self):
|
|
"""Remove the rubberband."""
|
|
|
|
def home(self, *args):
|
|
"""
|
|
Restore the original view.
|
|
|
|
For convenience of being directly connected as a GUI callback, which
|
|
often get passed additional parameters, this method accepts arbitrary
|
|
parameters, but does not use them.
|
|
"""
|
|
self._nav_stack.home()
|
|
self.set_history_buttons()
|
|
self._update_view()
|
|
|
|
def back(self, *args):
|
|
"""
|
|
Move back up the view lim stack.
|
|
|
|
For convenience of being directly connected as a GUI callback, which
|
|
often get passed additional parameters, this method accepts arbitrary
|
|
parameters, but does not use them.
|
|
"""
|
|
self._nav_stack.back()
|
|
self.set_history_buttons()
|
|
self._update_view()
|
|
|
|
def forward(self, *args):
|
|
"""
|
|
Move forward in the view lim stack.
|
|
|
|
For convenience of being directly connected as a GUI callback, which
|
|
often get passed additional parameters, this method accepts arbitrary
|
|
parameters, but does not use them.
|
|
"""
|
|
self._nav_stack.forward()
|
|
self.set_history_buttons()
|
|
self._update_view()
|
|
|
|
def _update_cursor(self, event):
|
|
"""
|
|
Update the cursor after a mouse move event or a tool (de)activation.
|
|
"""
|
|
if self.mode and event.inaxes and event.inaxes.get_navigate():
|
|
if (self.mode == _Mode.ZOOM
|
|
and self._last_cursor != tools.Cursors.SELECT_REGION):
|
|
self.canvas.set_cursor(tools.Cursors.SELECT_REGION)
|
|
self._last_cursor = tools.Cursors.SELECT_REGION
|
|
elif (self.mode == _Mode.PAN
|
|
and self._last_cursor != tools.Cursors.MOVE):
|
|
self.canvas.set_cursor(tools.Cursors.MOVE)
|
|
self._last_cursor = tools.Cursors.MOVE
|
|
elif self._last_cursor != tools.Cursors.POINTER:
|
|
self.canvas.set_cursor(tools.Cursors.POINTER)
|
|
self._last_cursor = tools.Cursors.POINTER
|
|
|
|
@contextmanager
|
|
def _wait_cursor_for_draw_cm(self):
|
|
"""
|
|
Set the cursor to a wait cursor when drawing the canvas.
|
|
|
|
In order to avoid constantly changing the cursor when the canvas
|
|
changes frequently, do nothing if this context was triggered during the
|
|
last second. (Optimally we'd prefer only setting the wait cursor if
|
|
the *current* draw takes too long, but the current draw blocks the GUI
|
|
thread).
|
|
"""
|
|
self._draw_time, last_draw_time = (
|
|
time.time(), getattr(self, "_draw_time", -np.inf))
|
|
if self._draw_time - last_draw_time > 1:
|
|
try:
|
|
self.canvas.set_cursor(tools.Cursors.WAIT)
|
|
yield
|
|
finally:
|
|
self.canvas.set_cursor(self._last_cursor)
|
|
else:
|
|
yield
|
|
|
|
@staticmethod
|
|
def _mouse_event_to_message(event):
|
|
if event.inaxes and event.inaxes.get_navigate():
|
|
try:
|
|
s = event.inaxes.format_coord(event.xdata, event.ydata)
|
|
except (ValueError, OverflowError):
|
|
pass
|
|
else:
|
|
s = s.rstrip()
|
|
artists = [a for a in event.inaxes._mouseover_set
|
|
if a.contains(event)[0] and a.get_visible()]
|
|
if artists:
|
|
a = cbook._topmost_artist(artists)
|
|
if a is not event.inaxes.patch:
|
|
data = a.get_cursor_data(event)
|
|
if data is not None:
|
|
data_str = a.format_cursor_data(data).rstrip()
|
|
if data_str:
|
|
s = s + '\n' + data_str
|
|
return s
|
|
return ""
|
|
|
|
def mouse_move(self, event):
|
|
self._update_cursor(event)
|
|
self.set_message(self._mouse_event_to_message(event))
|
|
|
|
def _zoom_pan_handler(self, event):
|
|
if self.mode == _Mode.PAN:
|
|
if event.name == "button_press_event":
|
|
self.press_pan(event)
|
|
elif event.name == "button_release_event":
|
|
self.release_pan(event)
|
|
if self.mode == _Mode.ZOOM:
|
|
if event.name == "button_press_event":
|
|
self.press_zoom(event)
|
|
elif event.name == "button_release_event":
|
|
self.release_zoom(event)
|
|
|
|
def _start_event_axes_interaction(self, event, *, method):
|
|
|
|
def _ax_filter(ax):
|
|
return (ax.in_axes(event) and
|
|
ax.get_navigate() and
|
|
getattr(ax, f"can_{method}")()
|
|
)
|
|
|
|
def _capture_events(ax):
|
|
f = ax.get_forward_navigation_events()
|
|
if f == "auto": # (capture = patch visibility)
|
|
f = not ax.patch.get_visible()
|
|
return not f
|
|
|
|
# get all relevant axes for the event
|
|
axes = list(filter(_ax_filter, self.canvas.figure.get_axes()))
|
|
|
|
if len(axes) == 0:
|
|
return []
|
|
|
|
if self._nav_stack() is None:
|
|
self.push_current() # Set the home button to this view.
|
|
|
|
# group axes by zorder (reverse to trigger later axes first)
|
|
grps = dict()
|
|
for ax in reversed(axes):
|
|
grps.setdefault(ax.get_zorder(), []).append(ax)
|
|
|
|
axes_to_trigger = []
|
|
# go through zorders in reverse until we hit a capturing axes
|
|
for zorder in sorted(grps, reverse=True):
|
|
for ax in grps[zorder]:
|
|
axes_to_trigger.append(ax)
|
|
# NOTE: shared axes are automatically triggered, but twin-axes not!
|
|
axes_to_trigger.extend(ax._twinned_axes.get_siblings(ax))
|
|
|
|
if _capture_events(ax):
|
|
break # break if we hit a capturing axes
|
|
else:
|
|
# If the inner loop finished without an explicit break,
|
|
# (e.g. no capturing axes was found) continue the
|
|
# outer loop to the next zorder.
|
|
continue
|
|
|
|
# If the inner loop was terminated with an explicit break,
|
|
# terminate the outer loop as well.
|
|
break
|
|
|
|
# avoid duplicated triggers (but keep order of list)
|
|
axes_to_trigger = list(dict.fromkeys(axes_to_trigger))
|
|
|
|
return axes_to_trigger
|
|
|
|
def pan(self, *args):
|
|
"""
|
|
Toggle the pan/zoom tool.
|
|
|
|
Pan with left button, zoom with right.
|
|
"""
|
|
if not self.canvas.widgetlock.available(self):
|
|
self.set_message("pan unavailable")
|
|
return
|
|
if self.mode == _Mode.PAN:
|
|
self.mode = _Mode.NONE
|
|
self.canvas.widgetlock.release(self)
|
|
else:
|
|
self.mode = _Mode.PAN
|
|
self.canvas.widgetlock(self)
|
|
for a in self.canvas.figure.get_axes():
|
|
a.set_navigate_mode(self.mode._navigate_mode)
|
|
|
|
_PanInfo = namedtuple("_PanInfo", "button axes cid")
|
|
|
|
def press_pan(self, event):
|
|
"""Callback for mouse button press in pan/zoom mode."""
|
|
if (event.button not in [MouseButton.LEFT, MouseButton.RIGHT]
|
|
or event.x is None or event.y is None):
|
|
return
|
|
|
|
axes = self._start_event_axes_interaction(event, method="pan")
|
|
if not axes:
|
|
return
|
|
|
|
# call "ax.start_pan(..)" on all relevant axes of an event
|
|
for ax in axes:
|
|
ax.start_pan(event.x, event.y, event.button)
|
|
|
|
self.canvas.mpl_disconnect(self._id_drag)
|
|
id_drag = self.canvas.mpl_connect("motion_notify_event", self.drag_pan)
|
|
|
|
self._pan_info = self._PanInfo(
|
|
button=event.button, axes=axes, cid=id_drag)
|
|
|
|
def drag_pan(self, event):
|
|
"""Callback for dragging in pan/zoom mode."""
|
|
for ax in self._pan_info.axes:
|
|
# Using the recorded button at the press is safer than the current
|
|
# button, as multiple buttons can get pressed during motion.
|
|
ax.drag_pan(self._pan_info.button, event.key, event.x, event.y)
|
|
self.canvas.draw_idle()
|
|
|
|
def release_pan(self, event):
|
|
"""Callback for mouse button release in pan/zoom mode."""
|
|
if self._pan_info is None:
|
|
return
|
|
self.canvas.mpl_disconnect(self._pan_info.cid)
|
|
self._id_drag = self.canvas.mpl_connect(
|
|
'motion_notify_event', self.mouse_move)
|
|
for ax in self._pan_info.axes:
|
|
ax.end_pan()
|
|
self.canvas.draw_idle()
|
|
self._pan_info = None
|
|
self.push_current()
|
|
|
|
def zoom(self, *args):
|
|
if not self.canvas.widgetlock.available(self):
|
|
self.set_message("zoom unavailable")
|
|
return
|
|
"""Toggle zoom to rect mode."""
|
|
if self.mode == _Mode.ZOOM:
|
|
self.mode = _Mode.NONE
|
|
self.canvas.widgetlock.release(self)
|
|
else:
|
|
self.mode = _Mode.ZOOM
|
|
self.canvas.widgetlock(self)
|
|
for a in self.canvas.figure.get_axes():
|
|
a.set_navigate_mode(self.mode._navigate_mode)
|
|
|
|
_ZoomInfo = namedtuple("_ZoomInfo", "direction start_xy axes cid cbar")
|
|
|
|
def press_zoom(self, event):
|
|
"""Callback for mouse button press in zoom to rect mode."""
|
|
if (event.button not in [MouseButton.LEFT, MouseButton.RIGHT]
|
|
or event.x is None or event.y is None):
|
|
return
|
|
|
|
axes = self._start_event_axes_interaction(event, method="zoom")
|
|
if not axes:
|
|
return
|
|
|
|
id_zoom = self.canvas.mpl_connect(
|
|
"motion_notify_event", self.drag_zoom)
|
|
|
|
# A colorbar is one-dimensional, so we extend the zoom rectangle out
|
|
# to the edge of the Axes bbox in the other dimension. To do that we
|
|
# store the orientation of the colorbar for later.
|
|
parent_ax = axes[0]
|
|
if hasattr(parent_ax, "_colorbar"):
|
|
cbar = parent_ax._colorbar.orientation
|
|
else:
|
|
cbar = None
|
|
|
|
self._zoom_info = self._ZoomInfo(
|
|
direction="in" if event.button == 1 else "out",
|
|
start_xy=(event.x, event.y), axes=axes, cid=id_zoom, cbar=cbar)
|
|
|
|
def drag_zoom(self, event):
|
|
"""Callback for dragging in zoom mode."""
|
|
start_xy = self._zoom_info.start_xy
|
|
ax = self._zoom_info.axes[0]
|
|
(x1, y1), (x2, y2) = np.clip(
|
|
[start_xy, [event.x, event.y]], ax.bbox.min, ax.bbox.max)
|
|
key = event.key
|
|
# Force the key on colorbars to extend the short-axis bbox
|
|
if self._zoom_info.cbar == "horizontal":
|
|
key = "x"
|
|
elif self._zoom_info.cbar == "vertical":
|
|
key = "y"
|
|
if key == "x":
|
|
y1, y2 = ax.bbox.intervaly
|
|
elif key == "y":
|
|
x1, x2 = ax.bbox.intervalx
|
|
|
|
self.draw_rubberband(event, x1, y1, x2, y2)
|
|
|
|
def release_zoom(self, event):
|
|
"""Callback for mouse button release in zoom to rect mode."""
|
|
if self._zoom_info is None:
|
|
return
|
|
|
|
# We don't check the event button here, so that zooms can be cancelled
|
|
# by (pressing and) releasing another mouse button.
|
|
self.canvas.mpl_disconnect(self._zoom_info.cid)
|
|
self.remove_rubberband()
|
|
|
|
start_x, start_y = self._zoom_info.start_xy
|
|
key = event.key
|
|
# Force the key on colorbars to ignore the zoom-cancel on the
|
|
# short-axis side
|
|
if self._zoom_info.cbar == "horizontal":
|
|
key = "x"
|
|
elif self._zoom_info.cbar == "vertical":
|
|
key = "y"
|
|
# Ignore single clicks: 5 pixels is a threshold that allows the user to
|
|
# "cancel" a zoom action by zooming by less than 5 pixels.
|
|
if ((abs(event.x - start_x) < 5 and key != "y") or
|
|
(abs(event.y - start_y) < 5 and key != "x")):
|
|
self.canvas.draw_idle()
|
|
self._zoom_info = None
|
|
return
|
|
|
|
for i, ax in enumerate(self._zoom_info.axes):
|
|
# Detect whether this Axes is twinned with an earlier Axes in the
|
|
# list of zoomed Axes, to avoid double zooming.
|
|
twinx = any(ax.get_shared_x_axes().joined(ax, prev)
|
|
for prev in self._zoom_info.axes[:i])
|
|
twiny = any(ax.get_shared_y_axes().joined(ax, prev)
|
|
for prev in self._zoom_info.axes[:i])
|
|
ax._set_view_from_bbox(
|
|
(start_x, start_y, event.x, event.y),
|
|
self._zoom_info.direction, key, twinx, twiny)
|
|
|
|
self.canvas.draw_idle()
|
|
self._zoom_info = None
|
|
self.push_current()
|
|
|
|
def push_current(self):
|
|
"""Push the current view limits and position onto the stack."""
|
|
self._nav_stack.push(
|
|
WeakKeyDictionary(
|
|
{ax: (ax._get_view(),
|
|
# Store both the original and modified positions.
|
|
(ax.get_position(True).frozen(),
|
|
ax.get_position().frozen()))
|
|
for ax in self.canvas.figure.axes}))
|
|
self.set_history_buttons()
|
|
|
|
def _update_view(self):
|
|
"""
|
|
Update the viewlim and position from the view and position stack for
|
|
each Axes.
|
|
"""
|
|
nav_info = self._nav_stack()
|
|
if nav_info is None:
|
|
return
|
|
# Retrieve all items at once to avoid any risk of GC deleting an Axes
|
|
# while in the middle of the loop below.
|
|
items = list(nav_info.items())
|
|
for ax, (view, (pos_orig, pos_active)) in items:
|
|
ax._set_view(view)
|
|
# Restore both the original and modified positions
|
|
ax._set_position(pos_orig, 'original')
|
|
ax._set_position(pos_active, 'active')
|
|
self.canvas.draw_idle()
|
|
|
|
def configure_subplots(self, *args):
|
|
if hasattr(self, "subplot_tool"):
|
|
self.subplot_tool.figure.canvas.manager.show()
|
|
return
|
|
# 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.
|
|
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)
|
|
self.subplot_tool = widgets.SubplotTool(self.canvas.figure, tool_fig)
|
|
cid = self.canvas.mpl_connect(
|
|
"close_event", lambda e: manager.destroy())
|
|
|
|
def on_tool_fig_close(e):
|
|
self.canvas.mpl_disconnect(cid)
|
|
del self.subplot_tool
|
|
|
|
tool_fig.canvas.mpl_connect("close_event", on_tool_fig_close)
|
|
manager.show()
|
|
return self.subplot_tool
|
|
|
|
def save_figure(self, *args):
|
|
"""Save the current figure."""
|
|
raise NotImplementedError
|
|
|
|
def update(self):
|
|
"""Reset the Axes stack."""
|
|
self._nav_stack.clear()
|
|
self.set_history_buttons()
|
|
|
|
def set_history_buttons(self):
|
|
"""Enable or disable the back/forward button."""
|
|
|
|
|
|
class ToolContainerBase:
|
|
"""
|
|
Base class for all tool containers, e.g. toolbars.
|
|
|
|
Attributes
|
|
----------
|
|
toolmanager : `.ToolManager`
|
|
The tools with which this `ToolContainer` wants to communicate.
|
|
"""
|
|
|
|
_icon_extension = '.png'
|
|
"""
|
|
Toolcontainer button icon image format extension
|
|
|
|
**String**: Image extension
|
|
"""
|
|
|
|
def __init__(self, toolmanager):
|
|
self.toolmanager = toolmanager
|
|
toolmanager.toolmanager_connect(
|
|
'tool_message_event',
|
|
lambda event: self.set_message(event.message))
|
|
toolmanager.toolmanager_connect(
|
|
'tool_removed_event',
|
|
lambda event: self.remove_toolitem(event.tool.name))
|
|
|
|
def _tool_toggled_cbk(self, event):
|
|
"""
|
|
Capture the 'tool_trigger_[name]'
|
|
|
|
This only gets used for toggled tools.
|
|
"""
|
|
self.toggle_toolitem(event.tool.name, event.tool.toggled)
|
|
|
|
def add_tool(self, tool, group, position=-1):
|
|
"""
|
|
Add a tool to this container.
|
|
|
|
Parameters
|
|
----------
|
|
tool : tool_like
|
|
The tool to add, see `.ToolManager.get_tool`.
|
|
group : str
|
|
The name of the group to add this tool to.
|
|
position : int, default: -1
|
|
The position within the group to place this tool.
|
|
"""
|
|
tool = self.toolmanager.get_tool(tool)
|
|
image = self._get_image_filename(tool)
|
|
toggle = getattr(tool, 'toggled', None) is not None
|
|
self.add_toolitem(tool.name, group, position,
|
|
image, tool.description, toggle)
|
|
if toggle:
|
|
self.toolmanager.toolmanager_connect('tool_trigger_%s' % tool.name,
|
|
self._tool_toggled_cbk)
|
|
# If initially toggled
|
|
if tool.toggled:
|
|
self.toggle_toolitem(tool.name, True)
|
|
|
|
def _get_image_filename(self, tool):
|
|
"""Resolve a tool icon's filename."""
|
|
if not tool.image:
|
|
return None
|
|
if os.path.isabs(tool.image):
|
|
filename = tool.image
|
|
else:
|
|
if "image" in getattr(tool, "__dict__", {}):
|
|
raise ValueError("If 'tool.image' is an instance variable, "
|
|
"it must be an absolute path")
|
|
for cls in type(tool).__mro__:
|
|
if "image" in vars(cls):
|
|
try:
|
|
src = inspect.getfile(cls)
|
|
break
|
|
except (OSError, TypeError):
|
|
raise ValueError("Failed to locate source file "
|
|
"where 'tool.image' is defined") from None
|
|
else:
|
|
raise ValueError("Failed to find parent class defining 'tool.image'")
|
|
filename = str(pathlib.Path(src).parent / tool.image)
|
|
for filename in [filename, filename + self._icon_extension]:
|
|
if os.path.isfile(filename):
|
|
return os.path.abspath(filename)
|
|
for fname in [ # Fallback; once deprecation elapses.
|
|
tool.image,
|
|
tool.image + self._icon_extension,
|
|
cbook._get_data_path("images", tool.image),
|
|
cbook._get_data_path("images", tool.image + self._icon_extension),
|
|
]:
|
|
if os.path.isfile(fname):
|
|
_api.warn_deprecated(
|
|
"3.9", message=f"Loading icon {tool.image!r} from the current "
|
|
"directory or from Matplotlib's image directory. This behavior "
|
|
"is deprecated since %(since)s and will be removed %(removal)s; "
|
|
"Tool.image should be set to a path relative to the Tool's source "
|
|
"file, or to an absolute path.")
|
|
return os.path.abspath(fname)
|
|
|
|
def trigger_tool(self, name):
|
|
"""
|
|
Trigger the tool.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
Name (id) of the tool triggered from within the container.
|
|
"""
|
|
self.toolmanager.trigger_tool(name, sender=self)
|
|
|
|
def add_toolitem(self, name, group, position, image, description, toggle):
|
|
"""
|
|
A hook to add a toolitem to the container.
|
|
|
|
This hook must be implemented in each backend and contains the
|
|
backend-specific code to add an element to the toolbar.
|
|
|
|
.. warning::
|
|
This is part of the backend implementation and should
|
|
not be called by end-users. They should instead call
|
|
`.ToolContainerBase.add_tool`.
|
|
|
|
The callback associated with the button click event
|
|
must be *exactly* ``self.trigger_tool(name)``.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
Name of the tool to add, this gets used as the tool's ID and as the
|
|
default label of the buttons.
|
|
group : str
|
|
Name of the group that this tool belongs to.
|
|
position : int
|
|
Position of the tool within its group, if -1 it goes at the end.
|
|
image : str
|
|
Filename of the image for the button or `None`.
|
|
description : str
|
|
Description of the tool, used for the tooltips.
|
|
toggle : bool
|
|
* `True` : The button is a toggle (change the pressed/unpressed
|
|
state between consecutive clicks).
|
|
* `False` : The button is a normal button (returns to unpressed
|
|
state after release).
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def toggle_toolitem(self, name, toggled):
|
|
"""
|
|
A hook to toggle a toolitem without firing an event.
|
|
|
|
This hook must be implemented in each backend and contains the
|
|
backend-specific code to silently toggle a toolbar element.
|
|
|
|
.. warning::
|
|
This is part of the backend implementation and should
|
|
not be called by end-users. They should instead call
|
|
`.ToolManager.trigger_tool` or `.ToolContainerBase.trigger_tool`
|
|
(which are equivalent).
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
Id of the tool to toggle.
|
|
toggled : bool
|
|
Whether to set this tool as toggled or not.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def remove_toolitem(self, name):
|
|
"""
|
|
A hook to remove a toolitem from the container.
|
|
|
|
This hook must be implemented in each backend and contains the
|
|
backend-specific code to remove an element from the toolbar; it is
|
|
called when `.ToolManager` emits a `tool_removed_event`.
|
|
|
|
Because some tools are present only on the `.ToolManager` but not on
|
|
the `ToolContainer`, this method must be a no-op when called on a tool
|
|
absent from the container.
|
|
|
|
.. warning::
|
|
This is part of the backend implementation and should
|
|
not be called by end-users. They should instead call
|
|
`.ToolManager.remove_tool`.
|
|
|
|
Parameters
|
|
----------
|
|
name : str
|
|
Name of the tool to remove.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
def set_message(self, s):
|
|
"""
|
|
Display a message on the toolbar.
|
|
|
|
Parameters
|
|
----------
|
|
s : str
|
|
Message text.
|
|
"""
|
|
raise NotImplementedError
|
|
|
|
|
|
class _Backend:
|
|
# A backend can be defined by using the following pattern:
|
|
#
|
|
# @_Backend.export
|
|
# class FooBackend(_Backend):
|
|
# # override the attributes and methods documented below.
|
|
|
|
# `backend_version` may be overridden by the subclass.
|
|
backend_version = "unknown"
|
|
|
|
# The `FigureCanvas` class must be defined.
|
|
FigureCanvas = None
|
|
|
|
# For interactive backends, the `FigureManager` class must be overridden.
|
|
FigureManager = FigureManagerBase
|
|
|
|
# For interactive backends, `mainloop` should be a function taking no
|
|
# argument and starting the backend main loop. It should be left as None
|
|
# for non-interactive backends.
|
|
mainloop = None
|
|
|
|
# The following methods will be automatically defined and exported, but
|
|
# can be overridden.
|
|
|
|
@classmethod
|
|
def new_figure_manager(cls, num, *args, **kwargs):
|
|
"""Create a new figure manager instance."""
|
|
# This import needs to happen here due to circular imports.
|
|
from matplotlib.figure import Figure
|
|
fig_cls = kwargs.pop('FigureClass', Figure)
|
|
fig = fig_cls(*args, **kwargs)
|
|
return cls.new_figure_manager_given_figure(num, fig)
|
|
|
|
@classmethod
|
|
def new_figure_manager_given_figure(cls, num, figure):
|
|
"""Create a new figure manager instance for the given figure."""
|
|
return cls.FigureCanvas.new_manager(figure, num)
|
|
|
|
@classmethod
|
|
def draw_if_interactive(cls):
|
|
manager_class = cls.FigureCanvas.manager_class
|
|
# Interactive backends reimplement start_main_loop or pyplot_show.
|
|
backend_is_interactive = (
|
|
manager_class.start_main_loop != FigureManagerBase.start_main_loop
|
|
or manager_class.pyplot_show != FigureManagerBase.pyplot_show)
|
|
if backend_is_interactive and is_interactive():
|
|
manager = Gcf.get_active()
|
|
if manager:
|
|
manager.canvas.draw_idle()
|
|
|
|
@classmethod
|
|
def show(cls, *, block=None):
|
|
"""
|
|
Show all figures.
|
|
|
|
`show` blocks by calling `mainloop` if *block* is ``True``, or if it is
|
|
``None`` and we are not in `interactive` mode and if IPython's
|
|
``%matplotlib`` integration has not been activated.
|
|
"""
|
|
managers = Gcf.get_all_fig_managers()
|
|
if not managers:
|
|
return
|
|
for manager in managers:
|
|
try:
|
|
manager.show() # Emits a warning for non-interactive backend.
|
|
except NonGuiException as exc:
|
|
_api.warn_external(str(exc))
|
|
if cls.mainloop is None:
|
|
return
|
|
if block is None:
|
|
# Hack: Is IPython's %matplotlib integration activated? If so,
|
|
# IPython's activate_matplotlib (>= 0.10) tacks a _needmain
|
|
# attribute onto pyplot.show (always set to False).
|
|
pyplot_show = getattr(sys.modules.get("matplotlib.pyplot"), "show", None)
|
|
ipython_pylab = hasattr(pyplot_show, "_needmain")
|
|
block = not ipython_pylab and not is_interactive()
|
|
if block:
|
|
cls.mainloop()
|
|
|
|
# This method is the one actually exporting the required methods.
|
|
|
|
@staticmethod
|
|
def export(cls):
|
|
for name in [
|
|
"backend_version",
|
|
"FigureCanvas",
|
|
"FigureManager",
|
|
"new_figure_manager",
|
|
"new_figure_manager_given_figure",
|
|
"draw_if_interactive",
|
|
"show",
|
|
]:
|
|
setattr(sys.modules[cls.__module__], name, getattr(cls, name))
|
|
|
|
# For back-compatibility, generate a shim `Show` class.
|
|
|
|
class Show(ShowBase):
|
|
def mainloop(self):
|
|
return cls.mainloop()
|
|
|
|
setattr(sys.modules[cls.__module__], "Show", Show)
|
|
return cls
|
|
|
|
|
|
class ShowBase(_Backend):
|
|
"""
|
|
Simple base class to generate a ``show()`` function in backends.
|
|
|
|
Subclass must override ``mainloop()`` method.
|
|
"""
|
|
|
|
def __call__(self, block=None):
|
|
return self.show(block=block)
|