up follow livre

This commit is contained in:
Tykayn 2025-08-30 18:14:14 +02:00 committed by tykayn
parent 70a5c3465c
commit cffb31c1ef
12198 changed files with 2562132 additions and 35 deletions

View file

@ -0,0 +1,5 @@
from .registry import BackendFilter, backend_registry # noqa: F401
# NOTE: plt.switch_backend() (called at import time) will add a "backend"
# attribute here for backcompat.
_QT_FORCE_QT5_BINDING = False

View file

@ -0,0 +1,331 @@
"""
Common code for GTK3 and GTK4 backends.
"""
import logging
import sys
import matplotlib as mpl
from matplotlib import _api, backend_tools, cbook
from matplotlib._pylab_helpers import Gcf
from matplotlib.backend_bases import (
_Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
TimerBase)
from matplotlib.backend_tools import Cursors
import gi
# The GTK3/GTK4 backends will have already called `gi.require_version` to set
# the desired GTK.
from gi.repository import Gdk, Gio, GLib, Gtk
try:
gi.require_foreign("cairo")
except ImportError as e:
raise ImportError("Gtk-based backends require cairo") from e
_log = logging.getLogger(__name__)
_application = None # Placeholder
def _shutdown_application(app):
# The application might prematurely shut down if Ctrl-C'd out of IPython,
# so close all windows.
for win in app.get_windows():
win.close()
# The PyGObject wrapper incorrectly thinks that None is not allowed, or we
# would call this:
# Gio.Application.set_default(None)
# Instead, we set this property and ignore default applications with it:
app._created_by_matplotlib = True
global _application
_application = None
def _create_application():
global _application
if _application is None:
app = Gio.Application.get_default()
if app is None or getattr(app, '_created_by_matplotlib', False):
# display_is_valid returns False only if on Linux and neither X11
# nor Wayland display can be opened.
if not mpl._c_internal_utils.display_is_valid():
raise RuntimeError('Invalid DISPLAY variable')
_application = Gtk.Application.new('org.matplotlib.Matplotlib3',
Gio.ApplicationFlags.NON_UNIQUE)
# The activate signal must be connected, but we don't care for
# handling it, since we don't do any remote processing.
_application.connect('activate', lambda *args, **kwargs: None)
_application.connect('shutdown', _shutdown_application)
_application.register()
cbook._setup_new_guiapp()
else:
_application = app
return _application
def mpl_to_gtk_cursor_name(mpl_cursor):
return _api.check_getitem({
Cursors.MOVE: "move",
Cursors.HAND: "pointer",
Cursors.POINTER: "default",
Cursors.SELECT_REGION: "crosshair",
Cursors.WAIT: "wait",
Cursors.RESIZE_HORIZONTAL: "ew-resize",
Cursors.RESIZE_VERTICAL: "ns-resize",
}, cursor=mpl_cursor)
class TimerGTK(TimerBase):
"""Subclass of `.TimerBase` using GTK timer events."""
def __init__(self, *args, **kwargs):
self._timer = None
super().__init__(*args, **kwargs)
def _timer_start(self):
# Need to stop it, otherwise we potentially leak a timer id that will
# never be stopped.
self._timer_stop()
self._timer = GLib.timeout_add(self._interval, self._on_timer)
def _timer_stop(self):
if self._timer is not None:
GLib.source_remove(self._timer)
self._timer = None
def _timer_set_interval(self):
# Only stop and restart it if the timer has already been started.
if self._timer is not None:
self._timer_stop()
self._timer_start()
def _on_timer(self):
super()._on_timer()
# Gtk timeout_add() requires that the callback returns True if it
# is to be called again.
if self.callbacks and not self._single:
return True
else:
self._timer = None
return False
class _FigureCanvasGTK(FigureCanvasBase):
_timer_cls = TimerGTK
class _FigureManagerGTK(FigureManagerBase):
"""
Attributes
----------
canvas : `FigureCanvas`
The FigureCanvas instance
num : int or str
The Figure number
toolbar : Gtk.Toolbar or Gtk.Box
The toolbar
vbox : Gtk.VBox
The Gtk.VBox containing the canvas and toolbar
window : Gtk.Window
The Gtk.Window
"""
def __init__(self, canvas, num):
self._gtk_ver = gtk_ver = Gtk.get_major_version()
app = _create_application()
self.window = Gtk.Window()
app.add_window(self.window)
super().__init__(canvas, num)
if gtk_ver == 3:
icon_ext = "png" if sys.platform == "win32" else "svg"
self.window.set_icon_from_file(
str(cbook._get_data_path(f"images/matplotlib.{icon_ext}")))
self.vbox = Gtk.Box()
self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL)
if gtk_ver == 3:
self.window.add(self.vbox)
self.vbox.show()
self.canvas.show()
self.vbox.pack_start(self.canvas, True, True, 0)
elif gtk_ver == 4:
self.window.set_child(self.vbox)
self.vbox.prepend(self.canvas)
# calculate size for window
w, h = self.canvas.get_width_height()
if self.toolbar is not None:
if gtk_ver == 3:
self.toolbar.show()
self.vbox.pack_end(self.toolbar, False, False, 0)
elif gtk_ver == 4:
sw = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER)
sw.set_child(self.toolbar)
self.vbox.append(sw)
min_size, nat_size = self.toolbar.get_preferred_size()
h += nat_size.height
self.window.set_default_size(w, h)
self._destroying = False
self.window.connect("destroy", lambda *args: Gcf.destroy(self))
self.window.connect({3: "delete_event", 4: "close-request"}[gtk_ver],
lambda *args: Gcf.destroy(self))
if mpl.is_interactive():
self.window.show()
self.canvas.draw_idle()
self.canvas.grab_focus()
def destroy(self, *args):
if self._destroying:
# Otherwise, this can be called twice when the user presses 'q',
# which calls Gcf.destroy(self), then this destroy(), then triggers
# Gcf.destroy(self) once again via
# `connect("destroy", lambda *args: Gcf.destroy(self))`.
return
self._destroying = True
self.window.destroy()
self.canvas.destroy()
@classmethod
def start_main_loop(cls):
global _application
if _application is None:
return
try:
_application.run() # Quits when all added windows close.
except KeyboardInterrupt:
# Ensure all windows can process their close event from
# _shutdown_application.
context = GLib.MainContext.default()
while context.pending():
context.iteration(True)
raise
finally:
# Running after quit is undefined, so create a new one next time.
_application = None
def show(self):
# show the figure window
self.window.show()
self.canvas.draw()
if mpl.rcParams["figure.raise_window"]:
meth_name = {3: "get_window", 4: "get_surface"}[self._gtk_ver]
if getattr(self.window, meth_name)():
self.window.present()
else:
# If this is called by a callback early during init,
# self.window (a GtkWindow) may not have an associated
# low-level GdkWindow (on GTK3) or GdkSurface (on GTK4) yet,
# and present() would crash.
_api.warn_external("Cannot raise window yet to be setup")
def full_screen_toggle(self):
is_fullscreen = {
3: lambda w: (w.get_window().get_state()
& Gdk.WindowState.FULLSCREEN),
4: lambda w: w.is_fullscreen(),
}[self._gtk_ver]
if is_fullscreen(self.window):
self.window.unfullscreen()
else:
self.window.fullscreen()
def get_window_title(self):
return self.window.get_title()
def set_window_title(self, title):
self.window.set_title(title)
def resize(self, width, height):
width = int(width / self.canvas.device_pixel_ratio)
height = int(height / self.canvas.device_pixel_ratio)
if self.toolbar:
min_size, nat_size = self.toolbar.get_preferred_size()
height += nat_size.height
canvas_size = self.canvas.get_allocation()
if self._gtk_ver >= 4 or canvas_size.width == canvas_size.height == 1:
# A canvas size of (1, 1) cannot exist in most cases, because
# window decorations would prevent such a small window. This call
# must be before the window has been mapped and widgets have been
# sized, so just change the window's starting size.
self.window.set_default_size(width, height)
else:
self.window.resize(width, height)
class _NavigationToolbar2GTK(NavigationToolbar2):
# Must be implemented in GTK3/GTK4 backends:
# * __init__
# * save_figure
def set_message(self, s):
escaped = GLib.markup_escape_text(s)
self.message.set_markup(f'<small>{escaped}</small>')
def draw_rubberband(self, event, x0, y0, x1, y1):
height = self.canvas.figure.bbox.height
y1 = height - y1
y0 = height - y0
rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)]
self.canvas._draw_rubberband(rect)
def remove_rubberband(self):
self.canvas._draw_rubberband(None)
def _update_buttons_checked(self):
for name, active in [("Pan", "PAN"), ("Zoom", "ZOOM")]:
button = self._gtk_ids.get(name)
if button:
with button.handler_block(button._signal_handler):
button.set_active(self.mode.name == active)
def pan(self, *args):
super().pan(*args)
self._update_buttons_checked()
def zoom(self, *args):
super().zoom(*args)
self._update_buttons_checked()
def set_history_buttons(self):
can_backward = self._nav_stack._pos > 0
can_forward = self._nav_stack._pos < len(self._nav_stack) - 1
if 'Back' in self._gtk_ids:
self._gtk_ids['Back'].set_sensitive(can_backward)
if 'Forward' in self._gtk_ids:
self._gtk_ids['Forward'].set_sensitive(can_forward)
class RubberbandGTK(backend_tools.RubberbandBase):
def draw_rubberband(self, x0, y0, x1, y1):
_NavigationToolbar2GTK.draw_rubberband(
self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
def remove_rubberband(self):
_NavigationToolbar2GTK.remove_rubberband(
self._make_classic_style_pseudo_toolbar())
class ConfigureSubplotsGTK(backend_tools.ConfigureSubplotsBase):
def trigger(self, *args):
_NavigationToolbar2GTK.configure_subplots(self, None)
class _BackendGTK(_Backend):
backend_version = "{}.{}.{}".format(
Gtk.get_major_version(),
Gtk.get_minor_version(),
Gtk.get_micro_version(),
)
mainloop = _FigureManagerGTK.start_main_loop

View file

@ -0,0 +1,189 @@
"""
Common functionality between the PDF and PS backends.
"""
from io import BytesIO
import functools
import logging
from fontTools import subset
import matplotlib as mpl
from .. import font_manager, ft2font
from .._afm import AFM
from ..backend_bases import RendererBase
@functools.lru_cache(50)
def _cached_get_afm_from_fname(fname):
with open(fname, "rb") as fh:
return AFM(fh)
def get_glyphs_subset(fontfile, characters):
"""
Subset a TTF font
Reads the named fontfile and restricts the font to the characters.
Parameters
----------
fontfile : str
Path to the font file
characters : str
Continuous set of characters to include in subset
Returns
-------
fontTools.ttLib.ttFont.TTFont
An open font object representing the subset, which needs to
be closed by the caller.
"""
options = subset.Options(glyph_names=True, recommended_glyphs=True)
# Prevent subsetting extra tables.
options.drop_tables += [
'FFTM', # FontForge Timestamp.
'PfEd', # FontForge personal table.
'BDF', # X11 BDF header.
'meta', # Metadata stores design/supported languages (meaningless for subsets).
'MERG', # Merge Table.
'TSIV', # Microsoft Visual TrueType extension.
'Zapf', # Information about the individual glyphs in the font.
'bdat', # The bitmap data table.
'bloc', # The bitmap location table.
'cidg', # CID to Glyph ID table (Apple Advanced Typography).
'fdsc', # The font descriptors table.
'feat', # Feature name table (Apple Advanced Typography).
'fmtx', # The Font Metrics Table.
'fond', # Data-fork font information (Apple Advanced Typography).
'just', # The justification table (Apple Advanced Typography).
'kerx', # An extended kerning table (Apple Advanced Typography).
'ltag', # Language Tag.
'morx', # Extended Glyph Metamorphosis Table.
'trak', # Tracking table.
'xref', # The cross-reference table (some Apple font tooling information).
]
# if fontfile is a ttc, specify font number
if fontfile.endswith(".ttc"):
options.font_number = 0
font = subset.load_font(fontfile, options)
subsetter = subset.Subsetter(options=options)
subsetter.populate(text=characters)
subsetter.subset(font)
return font
def font_as_file(font):
"""
Convert a TTFont object into a file-like object.
Parameters
----------
font : fontTools.ttLib.ttFont.TTFont
A font object
Returns
-------
BytesIO
A file object with the font saved into it
"""
fh = BytesIO()
font.save(fh, reorderTables=False)
return fh
class CharacterTracker:
"""
Helper for font subsetting by the pdf and ps backends.
Maintains a mapping of font paths to the set of character codepoints that
are being used from that font.
"""
def __init__(self):
self.used = {}
def track(self, font, s):
"""Record that string *s* is being typeset using font *font*."""
char_to_font = font._get_fontmap(s)
for _c, _f in char_to_font.items():
self.used.setdefault(_f.fname, set()).add(ord(_c))
def track_glyph(self, font, glyph):
"""Record that codepoint *glyph* is being typeset using font *font*."""
self.used.setdefault(font.fname, set()).add(glyph)
class RendererPDFPSBase(RendererBase):
# The following attributes must be defined by the subclasses:
# - _afm_font_dir
# - _use_afm_rc_name
def __init__(self, width, height):
super().__init__()
self.width = width
self.height = height
def flipy(self):
# docstring inherited
return False # y increases from bottom to top.
def option_scale_image(self):
# docstring inherited
return True # PDF and PS support arbitrary image scaling.
def option_image_nocomposite(self):
# docstring inherited
# Decide whether to composite image based on rcParam value.
return not mpl.rcParams["image.composite_image"]
def get_canvas_width_height(self):
# docstring inherited
return self.width * 72.0, self.height * 72.0
def get_text_width_height_descent(self, s, prop, ismath):
# docstring inherited
if ismath == "TeX":
return super().get_text_width_height_descent(s, prop, ismath)
elif ismath:
parse = self._text2path.mathtext_parser.parse(s, 72, prop)
return parse.width, parse.height, parse.depth
elif mpl.rcParams[self._use_afm_rc_name]:
font = self._get_font_afm(prop)
l, b, w, h, d = font.get_str_bbox_and_descent(s)
scale = prop.get_size_in_points() / 1000
w *= scale
h *= scale
d *= scale
return w, h, d
else:
font = self._get_font_ttf(prop)
font.set_text(s, 0.0, flags=ft2font.LoadFlags.NO_HINTING)
w, h = font.get_width_height()
d = font.get_descent()
scale = 1 / 64
w *= scale
h *= scale
d *= scale
return w, h, d
def _get_font_afm(self, prop):
fname = font_manager.findfont(
prop, fontext="afm", directory=self._afm_font_dir)
return _cached_get_afm_from_fname(fname)
def _get_font_ttf(self, prop):
fnames = font_manager.fontManager._find_fonts_by_props(prop)
try:
font = font_manager.get_font(fnames)
font.clear()
font.set_size(prop.get_size_in_points(), 72)
return font
except RuntimeError:
logging.getLogger(__name__).warning(
"The PostScript/PDF backend does not currently "
"support the selected font (%s).", fnames)
raise

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
import numpy as np
from numpy.typing import NDArray
TK_PHOTO_COMPOSITE_OVERLAY: int
TK_PHOTO_COMPOSITE_SET: int
def blit(
interp: int,
photo_name: str,
data: NDArray[np.uint8],
comp_rule: int,
offset: tuple[int, int, int, int],
bbox: tuple[int, int, int, int],
) -> None: ...
def enable_dpi_awareness(frame_handle: int, interp: int) -> bool | None: ...

View file

@ -0,0 +1,528 @@
"""
An `Anti-Grain Geometry`_ (AGG) backend.
Features that are implemented:
* capstyles and join styles
* dashes
* linewidth
* lines, rectangles, ellipses
* clipping to a rectangle
* output to RGBA and Pillow-supported image formats
* alpha blending
* DPI scaling properly - everything scales properly (dashes, linewidths, etc)
* draw polygon
* freetype2 w/ ft2font
Still TODO:
* integrate screen dpi w/ ppi and text
.. _Anti-Grain Geometry: http://agg.sourceforge.net/antigrain.com
"""
from contextlib import nullcontext
from math import radians, cos, sin
import numpy as np
import matplotlib as mpl
from matplotlib import _api, cbook
from matplotlib.backend_bases import (
_Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
from matplotlib.font_manager import fontManager as _fontManager, get_font
from matplotlib.ft2font import LoadFlags
from matplotlib.mathtext import MathTextParser
from matplotlib.path import Path
from matplotlib.transforms import Bbox, BboxBase
from matplotlib.backends._backend_agg import RendererAgg as _RendererAgg
def get_hinting_flag():
mapping = {
'default': LoadFlags.DEFAULT,
'no_autohint': LoadFlags.NO_AUTOHINT,
'force_autohint': LoadFlags.FORCE_AUTOHINT,
'no_hinting': LoadFlags.NO_HINTING,
True: LoadFlags.FORCE_AUTOHINT,
False: LoadFlags.NO_HINTING,
'either': LoadFlags.DEFAULT,
'native': LoadFlags.NO_AUTOHINT,
'auto': LoadFlags.FORCE_AUTOHINT,
'none': LoadFlags.NO_HINTING,
}
return mapping[mpl.rcParams['text.hinting']]
class RendererAgg(RendererBase):
"""
The renderer handles all the drawing primitives using a graphics
context instance that controls the colors/styles
"""
def __init__(self, width, height, dpi):
super().__init__()
self.dpi = dpi
self.width = width
self.height = height
self._renderer = _RendererAgg(int(width), int(height), dpi)
self._filter_renderers = []
self._update_methods()
self.mathtext_parser = MathTextParser('agg')
self.bbox = Bbox.from_bounds(0, 0, self.width, self.height)
def __getstate__(self):
# We only want to preserve the init keywords of the Renderer.
# Anything else can be re-created.
return {'width': self.width, 'height': self.height, 'dpi': self.dpi}
def __setstate__(self, state):
self.__init__(state['width'], state['height'], state['dpi'])
def _update_methods(self):
self.draw_gouraud_triangles = self._renderer.draw_gouraud_triangles
self.draw_image = self._renderer.draw_image
self.draw_markers = self._renderer.draw_markers
self.draw_path_collection = self._renderer.draw_path_collection
self.draw_quad_mesh = self._renderer.draw_quad_mesh
self.copy_from_bbox = self._renderer.copy_from_bbox
def draw_path(self, gc, path, transform, rgbFace=None):
# docstring inherited
nmax = mpl.rcParams['agg.path.chunksize'] # here at least for testing
npts = path.vertices.shape[0]
if (npts > nmax > 100 and path.should_simplify and
rgbFace is None and gc.get_hatch() is None):
nch = np.ceil(npts / nmax)
chsize = int(np.ceil(npts / nch))
i0 = np.arange(0, npts, chsize)
i1 = np.zeros_like(i0)
i1[:-1] = i0[1:] - 1
i1[-1] = npts
for ii0, ii1 in zip(i0, i1):
v = path.vertices[ii0:ii1, :]
c = path.codes
if c is not None:
c = c[ii0:ii1]
c[0] = Path.MOVETO # move to end of last chunk
p = Path(v, c)
p.simplify_threshold = path.simplify_threshold
try:
self._renderer.draw_path(gc, p, transform, rgbFace)
except OverflowError:
msg = (
"Exceeded cell block limit in Agg.\n\n"
"Please reduce the value of "
f"rcParams['agg.path.chunksize'] (currently {nmax}) "
"or increase the path simplification threshold"
"(rcParams['path.simplify_threshold'] = "
f"{mpl.rcParams['path.simplify_threshold']:.2f} by "
"default and path.simplify_threshold = "
f"{path.simplify_threshold:.2f} on the input)."
)
raise OverflowError(msg) from None
else:
try:
self._renderer.draw_path(gc, path, transform, rgbFace)
except OverflowError:
cant_chunk = ''
if rgbFace is not None:
cant_chunk += "- cannot split filled path\n"
if gc.get_hatch() is not None:
cant_chunk += "- cannot split hatched path\n"
if not path.should_simplify:
cant_chunk += "- path.should_simplify is False\n"
if len(cant_chunk):
msg = (
"Exceeded cell block limit in Agg, however for the "
"following reasons:\n\n"
f"{cant_chunk}\n"
"we cannot automatically split up this path to draw."
"\n\nPlease manually simplify your path."
)
else:
inc_threshold = (
"or increase the path simplification threshold"
"(rcParams['path.simplify_threshold'] = "
f"{mpl.rcParams['path.simplify_threshold']} "
"by default and path.simplify_threshold "
f"= {path.simplify_threshold} "
"on the input)."
)
if nmax > 100:
msg = (
"Exceeded cell block limit in Agg. Please reduce "
"the value of rcParams['agg.path.chunksize'] "
f"(currently {nmax}) {inc_threshold}"
)
else:
msg = (
"Exceeded cell block limit in Agg. Please set "
"the value of rcParams['agg.path.chunksize'], "
f"(currently {nmax}) to be greater than 100 "
+ inc_threshold
)
raise OverflowError(msg) from None
def draw_mathtext(self, gc, x, y, s, prop, angle):
"""Draw mathtext using :mod:`matplotlib.mathtext`."""
ox, oy, width, height, descent, font_image = \
self.mathtext_parser.parse(s, self.dpi, prop,
antialiased=gc.get_antialiased())
xd = descent * sin(radians(angle))
yd = descent * cos(radians(angle))
x = round(x + ox + xd)
y = round(y - oy + yd)
self._renderer.draw_text_image(font_image, x, y + 1, angle, gc)
def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
# docstring inherited
if ismath:
return self.draw_mathtext(gc, x, y, s, prop, angle)
font = self._prepare_font(prop)
# We pass '0' for angle here, since it will be rotated (in raster
# space) in the following call to draw_text_image).
font.set_text(s, 0, flags=get_hinting_flag())
font.draw_glyphs_to_bitmap(
antialiased=gc.get_antialiased())
d = font.get_descent() / 64.0
# The descent needs to be adjusted for the angle.
xo, yo = font.get_bitmap_offset()
xo /= 64.0
yo /= 64.0
xd = d * sin(radians(angle))
yd = d * cos(radians(angle))
x = round(x + xo + xd)
y = round(y + yo + yd)
self._renderer.draw_text_image(font, x, y + 1, angle, gc)
def get_text_width_height_descent(self, s, prop, ismath):
# docstring inherited
_api.check_in_list(["TeX", True, False], ismath=ismath)
if ismath == "TeX":
return super().get_text_width_height_descent(s, prop, ismath)
if ismath:
ox, oy, width, height, descent, font_image = \
self.mathtext_parser.parse(s, self.dpi, prop)
return width, height, descent
font = self._prepare_font(prop)
font.set_text(s, 0.0, flags=get_hinting_flag())
w, h = font.get_width_height() # width and height of unrotated string
d = font.get_descent()
w /= 64.0 # convert from subpixels
h /= 64.0
d /= 64.0
return w, h, d
def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None):
# docstring inherited
# todo, handle props, angle, origins
size = prop.get_size_in_points()
texmanager = self.get_texmanager()
Z = texmanager.get_grey(s, size, self.dpi)
Z = np.array(Z * 255.0, np.uint8)
w, h, d = self.get_text_width_height_descent(s, prop, ismath="TeX")
xd = d * sin(radians(angle))
yd = d * cos(radians(angle))
x = round(x + xd)
y = round(y + yd)
self._renderer.draw_text_image(Z, x, y, angle, gc)
def get_canvas_width_height(self):
# docstring inherited
return self.width, self.height
def _prepare_font(self, font_prop):
"""
Get the `.FT2Font` for *font_prop*, clear its buffer, and set its size.
"""
font = get_font(_fontManager._find_fonts_by_props(font_prop))
font.clear()
size = font_prop.get_size_in_points()
font.set_size(size, self.dpi)
return font
def points_to_pixels(self, points):
# docstring inherited
return points * self.dpi / 72
def buffer_rgba(self):
return memoryview(self._renderer)
def tostring_argb(self):
return np.asarray(self._renderer).take([3, 0, 1, 2], axis=2).tobytes()
def clear(self):
self._renderer.clear()
def option_image_nocomposite(self):
# docstring inherited
# It is generally faster to composite each image directly to
# the Figure, and there's no file size benefit to compositing
# with the Agg backend
return True
def option_scale_image(self):
# docstring inherited
return False
def restore_region(self, region, bbox=None, xy=None):
"""
Restore the saved region. If bbox (instance of BboxBase, or
its extents) is given, only the region specified by the bbox
will be restored. *xy* (a pair of floats) optionally
specifies the new position (the LLC of the original region,
not the LLC of the bbox) where the region will be restored.
>>> region = renderer.copy_from_bbox()
>>> x1, y1, x2, y2 = region.get_extents()
>>> renderer.restore_region(region, bbox=(x1+dx, y1, x2, y2),
... xy=(x1-dx, y1))
"""
if bbox is not None or xy is not None:
if bbox is None:
x1, y1, x2, y2 = region.get_extents()
elif isinstance(bbox, BboxBase):
x1, y1, x2, y2 = bbox.extents
else:
x1, y1, x2, y2 = bbox
if xy is None:
ox, oy = x1, y1
else:
ox, oy = xy
# The incoming data is float, but the _renderer type-checking wants
# to see integers.
self._renderer.restore_region(region, int(x1), int(y1),
int(x2), int(y2), int(ox), int(oy))
else:
self._renderer.restore_region(region)
def start_filter(self):
"""
Start filtering. It simply creates a new canvas (the old one is saved).
"""
self._filter_renderers.append(self._renderer)
self._renderer = _RendererAgg(int(self.width), int(self.height),
self.dpi)
self._update_methods()
def stop_filter(self, post_processing):
"""
Save the current canvas as an image and apply post processing.
The *post_processing* function::
def post_processing(image, dpi):
# ny, nx, depth = image.shape
# image (numpy array) has RGBA channels and has a depth of 4.
...
# create a new_image (numpy array of 4 channels, size can be
# different). The resulting image may have offsets from
# lower-left corner of the original image
return new_image, offset_x, offset_y
The saved renderer is restored and the returned image from
post_processing is plotted (using draw_image) on it.
"""
orig_img = np.asarray(self.buffer_rgba())
slice_y, slice_x = cbook._get_nonzero_slices(orig_img[..., 3])
cropped_img = orig_img[slice_y, slice_x]
self._renderer = self._filter_renderers.pop()
self._update_methods()
if cropped_img.size:
img, ox, oy = post_processing(cropped_img / 255, self.dpi)
gc = self.new_gc()
if img.dtype.kind == 'f':
img = np.asarray(img * 255., np.uint8)
self._renderer.draw_image(
gc, slice_x.start + ox, int(self.height) - slice_y.stop + oy,
img[::-1])
class FigureCanvasAgg(FigureCanvasBase):
# docstring inherited
_lastKey = None # Overwritten per-instance on the first draw.
def copy_from_bbox(self, bbox):
renderer = self.get_renderer()
return renderer.copy_from_bbox(bbox)
def restore_region(self, region, bbox=None, xy=None):
renderer = self.get_renderer()
return renderer.restore_region(region, bbox, xy)
def draw(self):
# docstring inherited
self.renderer = self.get_renderer()
self.renderer.clear()
# Acquire a lock on the shared font cache.
with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar
else nullcontext()):
self.figure.draw(self.renderer)
# A GUI class may be need to update a window using this draw, so
# don't forget to call the superclass.
super().draw()
def get_renderer(self):
w, h = self.figure.bbox.size
key = w, h, self.figure.dpi
reuse_renderer = (self._lastKey == key)
if not reuse_renderer:
self.renderer = RendererAgg(w, h, self.figure.dpi)
self._lastKey = key
return self.renderer
def tostring_argb(self):
"""
Get the image as ARGB `bytes`.
`draw` must be called at least once before this function will work and
to update the renderer for any subsequent changes to the Figure.
"""
return self.renderer.tostring_argb()
def buffer_rgba(self):
"""
Get the image as a `memoryview` to the renderer's buffer.
`draw` must be called at least once before this function will work and
to update the renderer for any subsequent changes to the Figure.
"""
return self.renderer.buffer_rgba()
def print_raw(self, filename_or_obj, *, metadata=None):
if metadata is not None:
raise ValueError("metadata not supported for raw/rgba")
FigureCanvasAgg.draw(self)
renderer = self.get_renderer()
with cbook.open_file_cm(filename_or_obj, "wb") as fh:
fh.write(renderer.buffer_rgba())
print_rgba = print_raw
def _print_pil(self, filename_or_obj, fmt, pil_kwargs, metadata=None):
"""
Draw the canvas, then save it using `.image.imsave` (to which
*pil_kwargs* and *metadata* are forwarded).
"""
FigureCanvasAgg.draw(self)
mpl.image.imsave(
filename_or_obj, self.buffer_rgba(), format=fmt, origin="upper",
dpi=self.figure.dpi, metadata=metadata, pil_kwargs=pil_kwargs)
def print_png(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
"""
Write the figure to a PNG file.
Parameters
----------
filename_or_obj : str or path-like or file-like
The file to write to.
metadata : dict, optional
Metadata in the PNG file as key-value pairs of bytes or latin-1
encodable strings.
According to the PNG specification, keys must be shorter than 79
chars.
The `PNG specification`_ defines some common keywords that may be
used as appropriate:
- Title: Short (one line) title or caption for image.
- Author: Name of image's creator.
- Description: Description of image (possibly long).
- Copyright: Copyright notice.
- Creation Time: Time of original image creation
(usually RFC 1123 format).
- Software: Software used to create the image.
- Disclaimer: Legal disclaimer.
- Warning: Warning of nature of content.
- Source: Device used to create the image.
- Comment: Miscellaneous comment;
conversion from other image format.
Other keywords may be invented for other purposes.
If 'Software' is not given, an autogenerated value for Matplotlib
will be used. This can be removed by setting it to *None*.
For more details see the `PNG specification`_.
.. _PNG specification: \
https://www.w3.org/TR/2003/REC-PNG-20031110/#11keywords
pil_kwargs : dict, optional
Keyword arguments passed to `PIL.Image.Image.save`.
If the 'pnginfo' key is present, it completely overrides
*metadata*, including the default 'Software' key.
"""
self._print_pil(filename_or_obj, "png", pil_kwargs, metadata)
def print_to_buffer(self):
FigureCanvasAgg.draw(self)
renderer = self.get_renderer()
return (bytes(renderer.buffer_rgba()),
(int(renderer.width), int(renderer.height)))
# Note that these methods should typically be called via savefig() and
# print_figure(), and the latter ensures that `self.figure.dpi` already
# matches the dpi kwarg (if any).
def print_jpg(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
# savefig() has already applied savefig.facecolor; we now set it to
# white to make imsave() blend semi-transparent figures against an
# assumed white background.
with mpl.rc_context({"savefig.facecolor": "white"}):
self._print_pil(filename_or_obj, "jpeg", pil_kwargs, metadata)
print_jpeg = print_jpg
def print_tif(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
self._print_pil(filename_or_obj, "tiff", pil_kwargs, metadata)
print_tiff = print_tif
def print_webp(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
self._print_pil(filename_or_obj, "webp", pil_kwargs, metadata)
print_jpg.__doc__, print_tif.__doc__, print_webp.__doc__ = map(
"""
Write the figure to a {} file.
Parameters
----------
filename_or_obj : str or path-like or file-like
The file to write to.
pil_kwargs : dict, optional
Additional keyword arguments that are passed to
`PIL.Image.Image.save` when saving the figure.
""".format, ["JPEG", "TIFF", "WebP"])
@_Backend.export
class _BackendAgg(_Backend):
backend_version = 'v2.2'
FigureCanvas = FigureCanvasAgg
FigureManager = FigureManagerBase

View file

@ -0,0 +1,529 @@
"""
A Cairo backend for Matplotlib
==============================
:Author: Steve Chaplin and others
This backend depends on cairocffi or pycairo.
"""
import functools
import gzip
import math
import numpy as np
try:
import cairo
if cairo.version_info < (1, 14, 0): # Introduced set_device_scale.
raise ImportError(f"Cairo backend requires cairo>=1.14.0, "
f"but only {cairo.version_info} is available")
except ImportError:
try:
import cairocffi as cairo
except ImportError as err:
raise ImportError(
"cairo backend requires that pycairo>=1.14.0 or cairocffi "
"is installed") from err
from .. import _api, cbook, font_manager
from matplotlib.backend_bases import (
_Backend, FigureCanvasBase, FigureManagerBase, GraphicsContextBase,
RendererBase)
from matplotlib.font_manager import ttfFontProperty
from matplotlib.path import Path
from matplotlib.transforms import Affine2D
def _set_rgba(ctx, color, alpha, forced_alpha):
if len(color) == 3 or forced_alpha:
ctx.set_source_rgba(*color[:3], alpha)
else:
ctx.set_source_rgba(*color)
def _append_path(ctx, path, transform, clip=None):
for points, code in path.iter_segments(
transform, remove_nans=True, clip=clip):
if code == Path.MOVETO:
ctx.move_to(*points)
elif code == Path.CLOSEPOLY:
ctx.close_path()
elif code == Path.LINETO:
ctx.line_to(*points)
elif code == Path.CURVE3:
cur = np.asarray(ctx.get_current_point())
a = points[:2]
b = points[-2:]
ctx.curve_to(*(cur / 3 + a * 2 / 3), *(a * 2 / 3 + b / 3), *b)
elif code == Path.CURVE4:
ctx.curve_to(*points)
def _cairo_font_args_from_font_prop(prop):
"""
Convert a `.FontProperties` or a `.FontEntry` to arguments that can be
passed to `.Context.select_font_face`.
"""
def attr(field):
try:
return getattr(prop, f"get_{field}")()
except AttributeError:
return getattr(prop, field)
name = attr("name")
slant = getattr(cairo, f"FONT_SLANT_{attr('style').upper()}")
weight = attr("weight")
weight = (cairo.FONT_WEIGHT_NORMAL
if font_manager.weight_dict.get(weight, weight) < 550
else cairo.FONT_WEIGHT_BOLD)
return name, slant, weight
class RendererCairo(RendererBase):
def __init__(self, dpi):
self.dpi = dpi
self.gc = GraphicsContextCairo(renderer=self)
self.width = None
self.height = None
self.text_ctx = cairo.Context(
cairo.ImageSurface(cairo.FORMAT_ARGB32, 1, 1))
super().__init__()
def set_context(self, ctx):
surface = ctx.get_target()
if hasattr(surface, "get_width") and hasattr(surface, "get_height"):
size = surface.get_width(), surface.get_height()
elif hasattr(surface, "get_extents"): # GTK4 RecordingSurface.
ext = surface.get_extents()
size = ext.width, ext.height
else: # vector surfaces.
ctx.save()
ctx.reset_clip()
rect, *rest = ctx.copy_clip_rectangle_list()
if rest:
raise TypeError("Cannot infer surface size")
_, _, *size = rect
ctx.restore()
self.gc.ctx = ctx
self.width, self.height = size
@staticmethod
def _fill_and_stroke(ctx, fill_c, alpha, alpha_overrides):
if fill_c is not None:
ctx.save()
_set_rgba(ctx, fill_c, alpha, alpha_overrides)
ctx.fill_preserve()
ctx.restore()
ctx.stroke()
def draw_path(self, gc, path, transform, rgbFace=None):
# docstring inherited
ctx = gc.ctx
# Clip the path to the actual rendering extents if it isn't filled.
clip = (ctx.clip_extents()
if rgbFace is None and gc.get_hatch() is None
else None)
transform = (transform
+ Affine2D().scale(1, -1).translate(0, self.height))
ctx.new_path()
_append_path(ctx, path, transform, clip)
if rgbFace is not None:
ctx.save()
_set_rgba(ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
ctx.fill_preserve()
ctx.restore()
hatch_path = gc.get_hatch_path()
if hatch_path:
dpi = int(self.dpi)
hatch_surface = ctx.get_target().create_similar(
cairo.Content.COLOR_ALPHA, dpi, dpi)
hatch_ctx = cairo.Context(hatch_surface)
_append_path(hatch_ctx, hatch_path,
Affine2D().scale(dpi, -dpi).translate(0, dpi),
None)
hatch_ctx.set_line_width(self.points_to_pixels(gc.get_hatch_linewidth()))
hatch_ctx.set_source_rgba(*gc.get_hatch_color())
hatch_ctx.fill_preserve()
hatch_ctx.stroke()
hatch_pattern = cairo.SurfacePattern(hatch_surface)
hatch_pattern.set_extend(cairo.Extend.REPEAT)
ctx.save()
ctx.set_source(hatch_pattern)
ctx.fill_preserve()
ctx.restore()
ctx.stroke()
def draw_markers(self, gc, marker_path, marker_trans, path, transform,
rgbFace=None):
# docstring inherited
ctx = gc.ctx
ctx.new_path()
# Create the path for the marker; it needs to be flipped here already!
_append_path(ctx, marker_path, marker_trans + Affine2D().scale(1, -1))
marker_path = ctx.copy_path_flat()
# Figure out whether the path has a fill
x1, y1, x2, y2 = ctx.fill_extents()
if x1 == 0 and y1 == 0 and x2 == 0 and y2 == 0:
filled = False
# No fill, just unset this (so we don't try to fill it later on)
rgbFace = None
else:
filled = True
transform = (transform
+ Affine2D().scale(1, -1).translate(0, self.height))
ctx.new_path()
for i, (vertices, codes) in enumerate(
path.iter_segments(transform, simplify=False)):
if len(vertices):
x, y = vertices[-2:]
ctx.save()
# Translate and apply path
ctx.translate(x, y)
ctx.append_path(marker_path)
ctx.restore()
# Slower code path if there is a fill; we need to draw
# the fill and stroke for each marker at the same time.
# Also flush out the drawing every once in a while to
# prevent the paths from getting way too long.
if filled or i % 1000 == 0:
self._fill_and_stroke(
ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
# Fast path, if there is no fill, draw everything in one step
if not filled:
self._fill_and_stroke(
ctx, rgbFace, gc.get_alpha(), gc.get_forced_alpha())
def draw_image(self, gc, x, y, im):
im = cbook._unmultiplied_rgba8888_to_premultiplied_argb32(im[::-1])
surface = cairo.ImageSurface.create_for_data(
im.ravel().data, cairo.FORMAT_ARGB32,
im.shape[1], im.shape[0], im.shape[1] * 4)
ctx = gc.ctx
y = self.height - y - im.shape[0]
ctx.save()
ctx.set_source_surface(surface, float(x), float(y))
ctx.paint()
ctx.restore()
def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
# docstring inherited
# Note: (x, y) are device/display coords, not user-coords, unlike other
# draw_* methods
if ismath:
self._draw_mathtext(gc, x, y, s, prop, angle)
else:
ctx = gc.ctx
ctx.new_path()
ctx.move_to(x, y)
ctx.save()
ctx.select_font_face(*_cairo_font_args_from_font_prop(prop))
ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points()))
opts = cairo.FontOptions()
opts.set_antialias(gc.get_antialiased())
ctx.set_font_options(opts)
if angle:
ctx.rotate(np.deg2rad(-angle))
ctx.show_text(s)
ctx.restore()
def _draw_mathtext(self, gc, x, y, s, prop, angle):
ctx = gc.ctx
width, height, descent, glyphs, rects = \
self._text2path.mathtext_parser.parse(s, self.dpi, prop)
ctx.save()
ctx.translate(x, y)
if angle:
ctx.rotate(np.deg2rad(-angle))
for font, fontsize, idx, ox, oy in glyphs:
ctx.new_path()
ctx.move_to(ox, -oy)
ctx.select_font_face(
*_cairo_font_args_from_font_prop(ttfFontProperty(font)))
ctx.set_font_size(self.points_to_pixels(fontsize))
ctx.show_text(chr(idx))
for ox, oy, w, h in rects:
ctx.new_path()
ctx.rectangle(ox, -oy, w, -h)
ctx.set_source_rgb(0, 0, 0)
ctx.fill_preserve()
ctx.restore()
def get_canvas_width_height(self):
# docstring inherited
return self.width, self.height
def get_text_width_height_descent(self, s, prop, ismath):
# docstring inherited
if ismath == 'TeX':
return super().get_text_width_height_descent(s, prop, ismath)
if ismath:
width, height, descent, *_ = \
self._text2path.mathtext_parser.parse(s, self.dpi, prop)
return width, height, descent
ctx = self.text_ctx
# problem - scale remembers last setting and font can become
# enormous causing program to crash
# save/restore prevents the problem
ctx.save()
ctx.select_font_face(*_cairo_font_args_from_font_prop(prop))
ctx.set_font_size(self.points_to_pixels(prop.get_size_in_points()))
y_bearing, w, h = ctx.text_extents(s)[1:4]
ctx.restore()
return w, h, h + y_bearing
def new_gc(self):
# docstring inherited
self.gc.ctx.save()
# FIXME: The following doesn't properly implement a stack-like behavior
# and relies instead on the (non-guaranteed) fact that artists never
# rely on nesting gc states, so directly resetting the attributes (IOW
# a single-level stack) is enough.
self.gc._alpha = 1
self.gc._forced_alpha = False # if True, _alpha overrides A from RGBA
self.gc._hatch = None
return self.gc
def points_to_pixels(self, points):
# docstring inherited
return points / 72 * self.dpi
class GraphicsContextCairo(GraphicsContextBase):
_joind = {
'bevel': cairo.LINE_JOIN_BEVEL,
'miter': cairo.LINE_JOIN_MITER,
'round': cairo.LINE_JOIN_ROUND,
}
_capd = {
'butt': cairo.LINE_CAP_BUTT,
'projecting': cairo.LINE_CAP_SQUARE,
'round': cairo.LINE_CAP_ROUND,
}
def __init__(self, renderer):
super().__init__()
self.renderer = renderer
def restore(self):
self.ctx.restore()
def set_alpha(self, alpha):
super().set_alpha(alpha)
_set_rgba(
self.ctx, self._rgb, self.get_alpha(), self.get_forced_alpha())
def set_antialiased(self, b):
self.ctx.set_antialias(
cairo.ANTIALIAS_DEFAULT if b else cairo.ANTIALIAS_NONE)
def get_antialiased(self):
return self.ctx.get_antialias()
def set_capstyle(self, cs):
self.ctx.set_line_cap(_api.check_getitem(self._capd, capstyle=cs))
self._capstyle = cs
def set_clip_rectangle(self, rectangle):
if not rectangle:
return
x, y, w, h = np.round(rectangle.bounds)
ctx = self.ctx
ctx.new_path()
ctx.rectangle(x, self.renderer.height - h - y, w, h)
ctx.clip()
def set_clip_path(self, path):
if not path:
return
tpath, affine = path.get_transformed_path_and_affine()
ctx = self.ctx
ctx.new_path()
affine = (affine
+ Affine2D().scale(1, -1).translate(0, self.renderer.height))
_append_path(ctx, tpath, affine)
ctx.clip()
def set_dashes(self, offset, dashes):
self._dashes = offset, dashes
if dashes is None:
self.ctx.set_dash([], 0) # switch dashes off
else:
self.ctx.set_dash(
list(self.renderer.points_to_pixels(np.asarray(dashes))),
offset)
def set_foreground(self, fg, isRGBA=None):
super().set_foreground(fg, isRGBA)
if len(self._rgb) == 3:
self.ctx.set_source_rgb(*self._rgb)
else:
self.ctx.set_source_rgba(*self._rgb)
def get_rgb(self):
return self.ctx.get_source().get_rgba()[:3]
def set_joinstyle(self, js):
self.ctx.set_line_join(_api.check_getitem(self._joind, joinstyle=js))
self._joinstyle = js
def set_linewidth(self, w):
self._linewidth = float(w)
self.ctx.set_line_width(self.renderer.points_to_pixels(w))
class _CairoRegion:
def __init__(self, slices, data):
self._slices = slices
self._data = data
class FigureCanvasCairo(FigureCanvasBase):
@property
def _renderer(self):
# In theory, _renderer should be set in __init__, but GUI canvas
# subclasses (FigureCanvasFooCairo) don't always interact well with
# multiple inheritance (FigureCanvasFoo inits but doesn't super-init
# FigureCanvasCairo), so initialize it in the getter instead.
if not hasattr(self, "_cached_renderer"):
self._cached_renderer = RendererCairo(self.figure.dpi)
return self._cached_renderer
def get_renderer(self):
return self._renderer
def copy_from_bbox(self, bbox):
surface = self._renderer.gc.ctx.get_target()
if not isinstance(surface, cairo.ImageSurface):
raise RuntimeError(
"copy_from_bbox only works when rendering to an ImageSurface")
sw = surface.get_width()
sh = surface.get_height()
x0 = math.ceil(bbox.x0)
x1 = math.floor(bbox.x1)
y0 = math.ceil(sh - bbox.y1)
y1 = math.floor(sh - bbox.y0)
if not (0 <= x0 and x1 <= sw and bbox.x0 <= bbox.x1
and 0 <= y0 and y1 <= sh and bbox.y0 <= bbox.y1):
raise ValueError("Invalid bbox")
sls = slice(y0, y0 + max(y1 - y0, 0)), slice(x0, x0 + max(x1 - x0, 0))
data = (np.frombuffer(surface.get_data(), np.uint32)
.reshape((sh, sw))[sls].copy())
return _CairoRegion(sls, data)
def restore_region(self, region):
surface = self._renderer.gc.ctx.get_target()
if not isinstance(surface, cairo.ImageSurface):
raise RuntimeError(
"restore_region only works when rendering to an ImageSurface")
surface.flush()
sw = surface.get_width()
sh = surface.get_height()
sly, slx = region._slices
(np.frombuffer(surface.get_data(), np.uint32)
.reshape((sh, sw))[sly, slx]) = region._data
surface.mark_dirty_rectangle(
slx.start, sly.start, slx.stop - slx.start, sly.stop - sly.start)
def print_png(self, fobj):
self._get_printed_image_surface().write_to_png(fobj)
def print_rgba(self, fobj):
width, height = self.get_width_height()
buf = self._get_printed_image_surface().get_data()
fobj.write(cbook._premultiplied_argb32_to_unmultiplied_rgba8888(
np.asarray(buf).reshape((width, height, 4))))
print_raw = print_rgba
def _get_printed_image_surface(self):
self._renderer.dpi = self.figure.dpi
width, height = self.get_width_height()
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
self._renderer.set_context(cairo.Context(surface))
self.figure.draw(self._renderer)
return surface
def _save(self, fmt, fobj, *, orientation='portrait'):
# save PDF/PS/SVG
dpi = 72
self.figure.dpi = dpi
w_in, h_in = self.figure.get_size_inches()
width_in_points, height_in_points = w_in * dpi, h_in * dpi
if orientation == 'landscape':
width_in_points, height_in_points = (
height_in_points, width_in_points)
if fmt == 'ps':
if not hasattr(cairo, 'PSSurface'):
raise RuntimeError('cairo has not been compiled with PS '
'support enabled')
surface = cairo.PSSurface(fobj, width_in_points, height_in_points)
elif fmt == 'pdf':
if not hasattr(cairo, 'PDFSurface'):
raise RuntimeError('cairo has not been compiled with PDF '
'support enabled')
surface = cairo.PDFSurface(fobj, width_in_points, height_in_points)
elif fmt in ('svg', 'svgz'):
if not hasattr(cairo, 'SVGSurface'):
raise RuntimeError('cairo has not been compiled with SVG '
'support enabled')
if fmt == 'svgz':
if isinstance(fobj, str):
fobj = gzip.GzipFile(fobj, 'wb')
else:
fobj = gzip.GzipFile(None, 'wb', fileobj=fobj)
surface = cairo.SVGSurface(fobj, width_in_points, height_in_points)
else:
raise ValueError(f"Unknown format: {fmt!r}")
self._renderer.dpi = self.figure.dpi
self._renderer.set_context(cairo.Context(surface))
ctx = self._renderer.gc.ctx
if orientation == 'landscape':
ctx.rotate(np.pi / 2)
ctx.translate(0, -height_in_points)
# Perhaps add an '%%Orientation: Landscape' comment?
self.figure.draw(self._renderer)
ctx.show_page()
surface.finish()
if fmt == 'svgz':
fobj.close()
print_pdf = functools.partialmethod(_save, "pdf")
print_ps = functools.partialmethod(_save, "ps")
print_svg = functools.partialmethod(_save, "svg")
print_svgz = functools.partialmethod(_save, "svgz")
@_Backend.export
class _BackendCairo(_Backend):
backend_version = cairo.version
FigureCanvas = FigureCanvasCairo
FigureManager = FigureManagerBase

View file

@ -0,0 +1,596 @@
import functools
import logging
import os
from pathlib import Path
import matplotlib as mpl
from matplotlib import _api, backend_tools, cbook
from matplotlib.backend_bases import (
ToolContainerBase, MouseButton,
CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent)
try:
import gi
except ImportError as err:
raise ImportError("The GTK3 backends require PyGObject") from err
try:
# :raises ValueError: If module/version is already loaded, already
# required, or unavailable.
gi.require_version("Gtk", "3.0")
except ValueError as e:
# in this case we want to re-raise as ImportError so the
# auto-backend selection logic correctly skips.
raise ImportError(e) from e
from gi.repository import Gio, GLib, GObject, Gtk, Gdk
from . import _backend_gtk
from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611
_BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK,
TimerGTK as TimerGTK3,
)
_log = logging.getLogger(__name__)
@functools.cache
def _mpl_to_gtk_cursor(mpl_cursor):
return Gdk.Cursor.new_from_name(
Gdk.Display.get_default(),
_backend_gtk.mpl_to_gtk_cursor_name(mpl_cursor))
class FigureCanvasGTK3(_FigureCanvasGTK, Gtk.DrawingArea):
required_interactive_framework = "gtk3"
manager_class = _api.classproperty(lambda cls: FigureManagerGTK3)
# Setting this as a static constant prevents
# this resulting expression from leaking
event_mask = (Gdk.EventMask.BUTTON_PRESS_MASK
| Gdk.EventMask.BUTTON_RELEASE_MASK
| Gdk.EventMask.EXPOSURE_MASK
| Gdk.EventMask.KEY_PRESS_MASK
| Gdk.EventMask.KEY_RELEASE_MASK
| Gdk.EventMask.ENTER_NOTIFY_MASK
| Gdk.EventMask.LEAVE_NOTIFY_MASK
| Gdk.EventMask.POINTER_MOTION_MASK
| Gdk.EventMask.SCROLL_MASK)
def __init__(self, figure=None):
super().__init__(figure=figure)
self._idle_draw_id = 0
self._rubberband_rect = None
self.connect('scroll_event', self.scroll_event)
self.connect('button_press_event', self.button_press_event)
self.connect('button_release_event', self.button_release_event)
self.connect('configure_event', self.configure_event)
self.connect('screen-changed', self._update_device_pixel_ratio)
self.connect('notify::scale-factor', self._update_device_pixel_ratio)
self.connect('draw', self.on_draw_event)
self.connect('draw', self._post_draw)
self.connect('key_press_event', self.key_press_event)
self.connect('key_release_event', self.key_release_event)
self.connect('motion_notify_event', self.motion_notify_event)
self.connect('enter_notify_event', self.enter_notify_event)
self.connect('leave_notify_event', self.leave_notify_event)
self.connect('size_allocate', self.size_allocate)
self.set_events(self.__class__.event_mask)
self.set_can_focus(True)
css = Gtk.CssProvider()
css.load_from_data(b".matplotlib-canvas { background-color: white; }")
style_ctx = self.get_style_context()
style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
style_ctx.add_class("matplotlib-canvas")
def destroy(self):
CloseEvent("close_event", self)._process()
def set_cursor(self, cursor):
# docstring inherited
window = self.get_property("window")
if window is not None:
window.set_cursor(_mpl_to_gtk_cursor(cursor))
context = GLib.MainContext.default()
context.iteration(True)
def _mpl_coords(self, event=None):
"""
Convert the position of a GTK event, or of the current cursor position
if *event* is None, to Matplotlib coordinates.
GTK use logical pixels, but the figure is scaled to physical pixels for
rendering. Transform to physical pixels so that all of the down-stream
transforms work as expected.
Also, the origin is different and needs to be corrected.
"""
if event is None:
window = self.get_window()
t, x, y, state = window.get_device_position(
window.get_display().get_device_manager().get_client_pointer())
else:
x, y = event.x, event.y
x = x * self.device_pixel_ratio
# flip y so y=0 is bottom of canvas
y = self.figure.bbox.height - y * self.device_pixel_ratio
return x, y
def scroll_event(self, widget, event):
step = 1 if event.direction == Gdk.ScrollDirection.UP else -1
MouseEvent("scroll_event", self,
*self._mpl_coords(event), step=step,
modifiers=self._mpl_modifiers(event.state),
guiEvent=event)._process()
return False # finish event propagation?
def button_press_event(self, widget, event):
MouseEvent("button_press_event", self,
*self._mpl_coords(event), event.button,
modifiers=self._mpl_modifiers(event.state),
guiEvent=event)._process()
return False # finish event propagation?
def button_release_event(self, widget, event):
MouseEvent("button_release_event", self,
*self._mpl_coords(event), event.button,
modifiers=self._mpl_modifiers(event.state),
guiEvent=event)._process()
return False # finish event propagation?
def key_press_event(self, widget, event):
KeyEvent("key_press_event", self,
self._get_key(event), *self._mpl_coords(),
guiEvent=event)._process()
return True # stop event propagation
def key_release_event(self, widget, event):
KeyEvent("key_release_event", self,
self._get_key(event), *self._mpl_coords(),
guiEvent=event)._process()
return True # stop event propagation
def motion_notify_event(self, widget, event):
MouseEvent("motion_notify_event", self, *self._mpl_coords(event),
buttons=self._mpl_buttons(event.state),
modifiers=self._mpl_modifiers(event.state),
guiEvent=event)._process()
return False # finish event propagation?
def enter_notify_event(self, widget, event):
gtk_mods = Gdk.Keymap.get_for_display(
self.get_display()).get_modifier_state()
LocationEvent("figure_enter_event", self, *self._mpl_coords(event),
modifiers=self._mpl_modifiers(gtk_mods),
guiEvent=event)._process()
def leave_notify_event(self, widget, event):
gtk_mods = Gdk.Keymap.get_for_display(
self.get_display()).get_modifier_state()
LocationEvent("figure_leave_event", self, *self._mpl_coords(event),
modifiers=self._mpl_modifiers(gtk_mods),
guiEvent=event)._process()
def size_allocate(self, widget, allocation):
dpival = self.figure.dpi
winch = allocation.width * self.device_pixel_ratio / dpival
hinch = allocation.height * self.device_pixel_ratio / dpival
self.figure.set_size_inches(winch, hinch, forward=False)
ResizeEvent("resize_event", self)._process()
self.draw_idle()
@staticmethod
def _mpl_buttons(event_state):
modifiers = [
(MouseButton.LEFT, Gdk.ModifierType.BUTTON1_MASK),
(MouseButton.MIDDLE, Gdk.ModifierType.BUTTON2_MASK),
(MouseButton.RIGHT, Gdk.ModifierType.BUTTON3_MASK),
(MouseButton.BACK, Gdk.ModifierType.BUTTON4_MASK),
(MouseButton.FORWARD, Gdk.ModifierType.BUTTON5_MASK),
]
# State *before* press/release.
return [name for name, mask in modifiers if event_state & mask]
@staticmethod
def _mpl_modifiers(event_state, *, exclude=None):
modifiers = [
("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"),
("alt", Gdk.ModifierType.MOD1_MASK, "alt"),
("shift", Gdk.ModifierType.SHIFT_MASK, "shift"),
("super", Gdk.ModifierType.MOD4_MASK, "super"),
]
return [name for name, mask, key in modifiers
if exclude != key and event_state & mask]
def _get_key(self, event):
unikey = chr(Gdk.keyval_to_unicode(event.keyval))
key = cbook._unikey_or_keysym_to_mplkey(
unikey, Gdk.keyval_name(event.keyval))
mods = self._mpl_modifiers(event.state, exclude=key)
if "shift" in mods and unikey.isprintable():
mods.remove("shift")
return "+".join([*mods, key])
def _update_device_pixel_ratio(self, *args, **kwargs):
# We need to be careful in cases with mixed resolution displays if
# device_pixel_ratio changes.
if self._set_device_pixel_ratio(self.get_scale_factor()):
# The easiest way to resize the canvas is to emit a resize event
# since we implement all the logic for resizing the canvas for that
# event.
self.queue_resize()
self.queue_draw()
def configure_event(self, widget, event):
if widget.get_property("window") is None:
return
w = event.width * self.device_pixel_ratio
h = event.height * self.device_pixel_ratio
if w < 3 or h < 3:
return # empty fig
# resize the figure (in inches)
dpi = self.figure.dpi
self.figure.set_size_inches(w / dpi, h / dpi, forward=False)
return False # finish event propagation?
def _draw_rubberband(self, rect):
self._rubberband_rect = rect
# TODO: Only update the rubberband area.
self.queue_draw()
def _post_draw(self, widget, ctx):
if self._rubberband_rect is None:
return
x0, y0, w, h = (dim / self.device_pixel_ratio
for dim in self._rubberband_rect)
x1 = x0 + w
y1 = y0 + h
# Draw the lines from x0, y0 towards x1, y1 so that the
# dashes don't "jump" when moving the zoom box.
ctx.move_to(x0, y0)
ctx.line_to(x0, y1)
ctx.move_to(x0, y0)
ctx.line_to(x1, y0)
ctx.move_to(x0, y1)
ctx.line_to(x1, y1)
ctx.move_to(x1, y0)
ctx.line_to(x1, y1)
ctx.set_antialias(1)
ctx.set_line_width(1)
ctx.set_dash((3, 3), 0)
ctx.set_source_rgb(0, 0, 0)
ctx.stroke_preserve()
ctx.set_dash((3, 3), 3)
ctx.set_source_rgb(1, 1, 1)
ctx.stroke()
def on_draw_event(self, widget, ctx):
# to be overwritten by GTK3Agg or GTK3Cairo
pass
def draw(self):
# docstring inherited
if self.is_drawable():
self.queue_draw()
def draw_idle(self):
# docstring inherited
if self._idle_draw_id != 0:
return
def idle_draw(*args):
try:
self.draw()
finally:
self._idle_draw_id = 0
return False
self._idle_draw_id = GLib.idle_add(idle_draw)
def flush_events(self):
# docstring inherited
context = GLib.MainContext.default()
while context.pending():
context.iteration(True)
class NavigationToolbar2GTK3(_NavigationToolbar2GTK, Gtk.Toolbar):
def __init__(self, canvas):
GObject.GObject.__init__(self)
self.set_style(Gtk.ToolbarStyle.ICONS)
self._gtk_ids = {}
for text, tooltip_text, image_file, callback in self.toolitems:
if text is None:
self.insert(Gtk.SeparatorToolItem(), -1)
continue
image = Gtk.Image.new_from_gicon(
Gio.Icon.new_for_string(
str(cbook._get_data_path('images',
f'{image_file}-symbolic.svg'))),
Gtk.IconSize.LARGE_TOOLBAR)
self._gtk_ids[text] = button = (
Gtk.ToggleToolButton() if callback in ['zoom', 'pan'] else
Gtk.ToolButton())
button.set_label(text)
button.set_icon_widget(image)
# Save the handler id, so that we can block it as needed.
button._signal_handler = button.connect(
'clicked', getattr(self, callback))
button.set_tooltip_text(tooltip_text)
self.insert(button, -1)
# This filler item ensures the toolbar is always at least two text
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
# over images because those use two-line messages which resize the
# toolbar.
toolitem = Gtk.ToolItem()
self.insert(toolitem, -1)
label = Gtk.Label()
label.set_markup(
'<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
toolitem.set_expand(True) # Push real message to the right.
toolitem.add(label)
toolitem = Gtk.ToolItem()
self.insert(toolitem, -1)
self.message = Gtk.Label()
self.message.set_justify(Gtk.Justification.RIGHT)
toolitem.add(self.message)
self.show_all()
_NavigationToolbar2GTK.__init__(self, canvas)
def save_figure(self, *args):
dialog = Gtk.FileChooserDialog(
title="Save the figure",
transient_for=self.canvas.get_toplevel(),
action=Gtk.FileChooserAction.SAVE,
buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
Gtk.STOCK_SAVE, Gtk.ResponseType.OK),
)
for name, fmts \
in self.canvas.get_supported_filetypes_grouped().items():
ff = Gtk.FileFilter()
ff.set_name(name)
for fmt in fmts:
ff.add_pattern(f'*.{fmt}')
dialog.add_filter(ff)
if self.canvas.get_default_filetype() in fmts:
dialog.set_filter(ff)
@functools.partial(dialog.connect, "notify::filter")
def on_notify_filter(*args):
name = dialog.get_filter().get_name()
fmt = self.canvas.get_supported_filetypes_grouped()[name][0]
dialog.set_current_name(
str(Path(dialog.get_current_name()).with_suffix(f'.{fmt}')))
dialog.set_current_folder(mpl.rcParams["savefig.directory"])
dialog.set_current_name(self.canvas.get_default_filename())
dialog.set_do_overwrite_confirmation(True)
response = dialog.run()
fname = dialog.get_filename()
ff = dialog.get_filter() # Doesn't autoadjust to filename :/
fmt = self.canvas.get_supported_filetypes_grouped()[ff.get_name()][0]
dialog.destroy()
if response != Gtk.ResponseType.OK:
return None
# Save dir for next time, unless empty str (which means use cwd).
if mpl.rcParams['savefig.directory']:
mpl.rcParams['savefig.directory'] = os.path.dirname(fname)
try:
self.canvas.figure.savefig(fname, format=fmt)
return fname
except Exception as e:
dialog = Gtk.MessageDialog(
transient_for=self.canvas.get_toplevel(), text=str(e),
message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK)
dialog.run()
dialog.destroy()
class ToolbarGTK3(ToolContainerBase, Gtk.Box):
_icon_extension = '-symbolic.svg'
def __init__(self, toolmanager):
ToolContainerBase.__init__(self, toolmanager)
Gtk.Box.__init__(self)
self.set_property('orientation', Gtk.Orientation.HORIZONTAL)
self._message = Gtk.Label()
self._message.set_justify(Gtk.Justification.RIGHT)
self.pack_end(self._message, False, False, 0)
self.show_all()
self._groups = {}
self._toolitems = {}
def add_toolitem(self, name, group, position, image_file, description,
toggle):
if toggle:
button = Gtk.ToggleToolButton()
else:
button = Gtk.ToolButton()
button.set_label(name)
if image_file is not None:
image = Gtk.Image.new_from_gicon(
Gio.Icon.new_for_string(image_file),
Gtk.IconSize.LARGE_TOOLBAR)
button.set_icon_widget(image)
if position is None:
position = -1
self._add_button(button, group, position)
signal = button.connect('clicked', self._call_tool, name)
button.set_tooltip_text(description)
button.show_all()
self._toolitems.setdefault(name, [])
self._toolitems[name].append((button, signal))
def _add_button(self, button, group, position):
if group not in self._groups:
if self._groups:
self._add_separator()
toolbar = Gtk.Toolbar()
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
self.pack_start(toolbar, False, False, 0)
toolbar.show_all()
self._groups[group] = toolbar
self._groups[group].insert(button, position)
def _call_tool(self, btn, name):
self.trigger_tool(name)
def toggle_toolitem(self, name, toggled):
if name not in self._toolitems:
return
for toolitem, signal in self._toolitems[name]:
toolitem.handler_block(signal)
toolitem.set_active(toggled)
toolitem.handler_unblock(signal)
def remove_toolitem(self, name):
for toolitem, _signal in self._toolitems.pop(name, []):
for group in self._groups:
if toolitem in self._groups[group]:
self._groups[group].remove(toolitem)
def _add_separator(self):
sep = Gtk.Separator()
sep.set_property("orientation", Gtk.Orientation.VERTICAL)
self.pack_start(sep, False, True, 0)
sep.show_all()
def set_message(self, s):
self._message.set_label(s)
@backend_tools._register_tool_class(FigureCanvasGTK3)
class SaveFigureGTK3(backend_tools.SaveFigureBase):
def trigger(self, *args, **kwargs):
NavigationToolbar2GTK3.save_figure(
self._make_classic_style_pseudo_toolbar())
@backend_tools._register_tool_class(FigureCanvasGTK3)
class HelpGTK3(backend_tools.ToolHelpBase):
def _normalize_shortcut(self, key):
"""
Convert Matplotlib key presses to GTK+ accelerator identifiers.
Related to `FigureCanvasGTK3._get_key`.
"""
special = {
'backspace': 'BackSpace',
'pagedown': 'Page_Down',
'pageup': 'Page_Up',
'scroll_lock': 'Scroll_Lock',
}
parts = key.split('+')
mods = ['<' + mod + '>' for mod in parts[:-1]]
key = parts[-1]
if key in special:
key = special[key]
elif len(key) > 1:
key = key.capitalize()
elif key.isupper():
mods += ['<shift>']
return ''.join(mods) + key
def _is_valid_shortcut(self, key):
"""
Check for a valid shortcut to be displayed.
- GTK will never send 'cmd+' (see `FigureCanvasGTK3._get_key`).
- The shortcut window only shows keyboard shortcuts, not mouse buttons.
"""
return 'cmd+' not in key and not key.startswith('MouseButton.')
def _show_shortcuts_window(self):
section = Gtk.ShortcutsSection()
for name, tool in sorted(self.toolmanager.tools.items()):
if not tool.description:
continue
# Putting everything in a separate group allows GTK to
# automatically split them into separate columns/pages, which is
# useful because we have lots of shortcuts, some with many keys
# that are very wide.
group = Gtk.ShortcutsGroup()
section.add(group)
# A hack to remove the title since we have no group naming.
group.forall(lambda widget, data: widget.set_visible(False), None)
shortcut = Gtk.ShortcutsShortcut(
accelerator=' '.join(
self._normalize_shortcut(key)
for key in self.toolmanager.get_tool_keymap(name)
if self._is_valid_shortcut(key)),
title=tool.name,
subtitle=tool.description)
group.add(shortcut)
window = Gtk.ShortcutsWindow(
title='Help',
modal=True,
transient_for=self._figure.canvas.get_toplevel())
section.show() # Must be done explicitly before add!
window.add(section)
window.show_all()
def _show_shortcuts_dialog(self):
dialog = Gtk.MessageDialog(
self._figure.canvas.get_toplevel(),
0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, self._get_help_text(),
title="Help")
dialog.run()
dialog.destroy()
def trigger(self, *args):
if Gtk.check_version(3, 20, 0) is None:
self._show_shortcuts_window()
else:
self._show_shortcuts_dialog()
@backend_tools._register_tool_class(FigureCanvasGTK3)
class ToolCopyToClipboardGTK3(backend_tools.ToolCopyToClipboardBase):
def trigger(self, *args, **kwargs):
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
window = self.canvas.get_window()
x, y, width, height = window.get_geometry()
pb = Gdk.pixbuf_get_from_window(window, x, y, width, height)
clipboard.set_image(pb)
Toolbar = ToolbarGTK3
backend_tools._register_tool_class(
FigureCanvasGTK3, _backend_gtk.ConfigureSubplotsGTK)
backend_tools._register_tool_class(
FigureCanvasGTK3, _backend_gtk.RubberbandGTK)
class FigureManagerGTK3(_FigureManagerGTK):
_toolbar2_class = NavigationToolbar2GTK3
_toolmanager_toolbar_class = ToolbarGTK3
@_BackendGTK.export
class _BackendGTK3(_BackendGTK):
FigureCanvas = FigureCanvasGTK3
FigureManager = FigureManagerGTK3

View file

@ -0,0 +1,74 @@
import numpy as np
from .. import cbook, transforms
from . import backend_agg, backend_gtk3
from .backend_gtk3 import GLib, Gtk, _BackendGTK3
import cairo # Presence of cairo is already checked by _backend_gtk.
class FigureCanvasGTK3Agg(backend_agg.FigureCanvasAgg,
backend_gtk3.FigureCanvasGTK3):
def __init__(self, figure):
super().__init__(figure=figure)
self._bbox_queue = []
def on_draw_event(self, widget, ctx):
if self._idle_draw_id:
GLib.source_remove(self._idle_draw_id)
self._idle_draw_id = 0
self.draw()
scale = self.device_pixel_ratio
allocation = self.get_allocation()
w = allocation.width * scale
h = allocation.height * scale
if not len(self._bbox_queue):
Gtk.render_background(
self.get_style_context(), ctx,
allocation.x, allocation.y,
allocation.width, allocation.height)
bbox_queue = [transforms.Bbox([[0, 0], [w, h]])]
else:
bbox_queue = self._bbox_queue
for bbox in bbox_queue:
x = int(bbox.x0)
y = h - int(bbox.y1)
width = int(bbox.x1) - int(bbox.x0)
height = int(bbox.y1) - int(bbox.y0)
buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32(
np.asarray(self.copy_from_bbox(bbox)))
image = cairo.ImageSurface.create_for_data(
buf.ravel().data, cairo.FORMAT_ARGB32, width, height)
image.set_device_scale(scale, scale)
ctx.set_source_surface(image, x / scale, y / scale)
ctx.paint()
if len(self._bbox_queue):
self._bbox_queue = []
return False
def blit(self, bbox=None):
# If bbox is None, blit the entire canvas to gtk. Otherwise
# blit only the area defined by the bbox.
if bbox is None:
bbox = self.figure.bbox
scale = self.device_pixel_ratio
allocation = self.get_allocation()
x = int(bbox.x0 / scale)
y = allocation.height - int(bbox.y1 / scale)
width = (int(bbox.x1) - int(bbox.x0)) // scale
height = (int(bbox.y1) - int(bbox.y0)) // scale
self._bbox_queue.append(bbox)
self.queue_draw_area(x, y, width, height)
@_BackendGTK3.export
class _BackendGTK3Agg(_BackendGTK3):
FigureCanvas = FigureCanvasGTK3Agg

View file

@ -0,0 +1,35 @@
from contextlib import nullcontext
from .backend_cairo import FigureCanvasCairo
from .backend_gtk3 import GLib, Gtk, FigureCanvasGTK3, _BackendGTK3
class FigureCanvasGTK3Cairo(FigureCanvasCairo, FigureCanvasGTK3):
def on_draw_event(self, widget, ctx):
if self._idle_draw_id:
GLib.source_remove(self._idle_draw_id)
self._idle_draw_id = 0
self.draw()
with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar
else nullcontext()):
allocation = self.get_allocation()
# Render the background before scaling, as the allocated size here is in
# logical pixels.
Gtk.render_background(
self.get_style_context(), ctx,
0, 0, allocation.width, allocation.height)
scale = self.device_pixel_ratio
# Scale physical drawing to logical size.
ctx.scale(1 / scale, 1 / scale)
self._renderer.set_context(ctx)
# Set renderer to physical size so it renders in full resolution.
self._renderer.width = allocation.width * scale
self._renderer.height = allocation.height * scale
self._renderer.dpi = self.figure.dpi
self.figure.draw(self._renderer)
@_BackendGTK3.export
class _BackendGTK3Cairo(_BackendGTK3):
FigureCanvas = FigureCanvasGTK3Cairo

View file

@ -0,0 +1,641 @@
import functools
import io
import os
import matplotlib as mpl
from matplotlib import _api, backend_tools, cbook
from matplotlib.backend_bases import (
ToolContainerBase, MouseButton,
KeyEvent, LocationEvent, MouseEvent, ResizeEvent, CloseEvent)
try:
import gi
except ImportError as err:
raise ImportError("The GTK4 backends require PyGObject") from err
try:
# :raises ValueError: If module/version is already loaded, already
# required, or unavailable.
gi.require_version("Gtk", "4.0")
except ValueError as e:
# in this case we want to re-raise as ImportError so the
# auto-backend selection logic correctly skips.
raise ImportError(e) from e
from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf
from . import _backend_gtk
from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611
_BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK,
TimerGTK as TimerGTK4,
)
_GOBJECT_GE_3_47 = gi.version_info >= (3, 47, 0)
_GTK_GE_4_12 = Gtk.check_version(4, 12, 0) is None
class FigureCanvasGTK4(_FigureCanvasGTK, Gtk.DrawingArea):
required_interactive_framework = "gtk4"
supports_blit = False
manager_class = _api.classproperty(lambda cls: FigureManagerGTK4)
def __init__(self, figure=None):
super().__init__(figure=figure)
self.set_hexpand(True)
self.set_vexpand(True)
self._idle_draw_id = 0
self._rubberband_rect = None
self.set_draw_func(self._draw_func)
self.connect('resize', self.resize_event)
if _GTK_GE_4_12:
self.connect('realize', self._realize_event)
else:
self.connect('notify::scale-factor', self._update_device_pixel_ratio)
click = Gtk.GestureClick()
click.set_button(0) # All buttons.
click.connect('pressed', self.button_press_event)
click.connect('released', self.button_release_event)
self.add_controller(click)
key = Gtk.EventControllerKey()
key.connect('key-pressed', self.key_press_event)
key.connect('key-released', self.key_release_event)
self.add_controller(key)
motion = Gtk.EventControllerMotion()
motion.connect('motion', self.motion_notify_event)
motion.connect('enter', self.enter_notify_event)
motion.connect('leave', self.leave_notify_event)
self.add_controller(motion)
scroll = Gtk.EventControllerScroll.new(
Gtk.EventControllerScrollFlags.VERTICAL)
scroll.connect('scroll', self.scroll_event)
self.add_controller(scroll)
self.set_focusable(True)
css = Gtk.CssProvider()
style = '.matplotlib-canvas { background-color: white; }'
if Gtk.check_version(4, 9, 3) is None:
css.load_from_data(style, -1)
else:
css.load_from_data(style.encode('utf-8'))
style_ctx = self.get_style_context()
style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
style_ctx.add_class("matplotlib-canvas")
def destroy(self):
CloseEvent("close_event", self)._process()
def set_cursor(self, cursor):
# docstring inherited
self.set_cursor_from_name(_backend_gtk.mpl_to_gtk_cursor_name(cursor))
def _mpl_coords(self, xy=None):
"""
Convert the *xy* position of a GTK event, or of the current cursor
position if *xy* is None, to Matplotlib coordinates.
GTK use logical pixels, but the figure is scaled to physical pixels for
rendering. Transform to physical pixels so that all of the down-stream
transforms work as expected.
Also, the origin is different and needs to be corrected.
"""
if xy is None:
surface = self.get_native().get_surface()
is_over, x, y, mask = surface.get_device_position(
self.get_display().get_default_seat().get_pointer())
else:
x, y = xy
x = x * self.device_pixel_ratio
# flip y so y=0 is bottom of canvas
y = self.figure.bbox.height - y * self.device_pixel_ratio
return x, y
def scroll_event(self, controller, dx, dy):
MouseEvent(
"scroll_event", self, *self._mpl_coords(), step=dy,
modifiers=self._mpl_modifiers(controller),
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
)._process()
return True
def button_press_event(self, controller, n_press, x, y):
MouseEvent(
"button_press_event", self, *self._mpl_coords((x, y)),
controller.get_current_button(),
modifiers=self._mpl_modifiers(controller),
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
)._process()
self.grab_focus()
def button_release_event(self, controller, n_press, x, y):
MouseEvent(
"button_release_event", self, *self._mpl_coords((x, y)),
controller.get_current_button(),
modifiers=self._mpl_modifiers(controller),
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
)._process()
def key_press_event(self, controller, keyval, keycode, state):
KeyEvent(
"key_press_event", self, self._get_key(keyval, keycode, state),
*self._mpl_coords(),
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
)._process()
return True
def key_release_event(self, controller, keyval, keycode, state):
KeyEvent(
"key_release_event", self, self._get_key(keyval, keycode, state),
*self._mpl_coords(),
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
)._process()
return True
def motion_notify_event(self, controller, x, y):
MouseEvent(
"motion_notify_event", self, *self._mpl_coords((x, y)),
buttons=self._mpl_buttons(controller),
modifiers=self._mpl_modifiers(controller),
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
)._process()
def enter_notify_event(self, controller, x, y):
LocationEvent(
"figure_enter_event", self, *self._mpl_coords((x, y)),
modifiers=self._mpl_modifiers(),
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
)._process()
def leave_notify_event(self, controller):
LocationEvent(
"figure_leave_event", self, *self._mpl_coords(),
modifiers=self._mpl_modifiers(),
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
)._process()
def resize_event(self, area, width, height):
self._update_device_pixel_ratio()
dpi = self.figure.dpi
winch = width * self.device_pixel_ratio / dpi
hinch = height * self.device_pixel_ratio / dpi
self.figure.set_size_inches(winch, hinch, forward=False)
ResizeEvent("resize_event", self)._process()
self.draw_idle()
def _mpl_buttons(self, controller):
# NOTE: This spews "Broken accounting of active state" warnings on
# right click on macOS.
surface = self.get_native().get_surface()
is_over, x, y, event_state = surface.get_device_position(
self.get_display().get_default_seat().get_pointer())
# NOTE: alternatively we could use
# event_state = controller.get_current_event_state()
# but for button_press/button_release this would report the state
# *prior* to the event rather than after it; the above reports the
# state *after* it.
mod_table = [
(MouseButton.LEFT, Gdk.ModifierType.BUTTON1_MASK),
(MouseButton.MIDDLE, Gdk.ModifierType.BUTTON2_MASK),
(MouseButton.RIGHT, Gdk.ModifierType.BUTTON3_MASK),
(MouseButton.BACK, Gdk.ModifierType.BUTTON4_MASK),
(MouseButton.FORWARD, Gdk.ModifierType.BUTTON5_MASK),
]
return {name for name, mask in mod_table if event_state & mask}
def _mpl_modifiers(self, controller=None):
if controller is None:
surface = self.get_native().get_surface()
is_over, x, y, event_state = surface.get_device_position(
self.get_display().get_default_seat().get_pointer())
else:
event_state = controller.get_current_event_state()
mod_table = [
("ctrl", Gdk.ModifierType.CONTROL_MASK),
("alt", Gdk.ModifierType.ALT_MASK),
("shift", Gdk.ModifierType.SHIFT_MASK),
("super", Gdk.ModifierType.SUPER_MASK),
]
return [name for name, mask in mod_table if event_state & mask]
def _get_key(self, keyval, keycode, state):
unikey = chr(Gdk.keyval_to_unicode(keyval))
key = cbook._unikey_or_keysym_to_mplkey(
unikey,
Gdk.keyval_name(keyval))
modifiers = [
("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"),
("alt", Gdk.ModifierType.ALT_MASK, "alt"),
("shift", Gdk.ModifierType.SHIFT_MASK, "shift"),
("super", Gdk.ModifierType.SUPER_MASK, "super"),
]
mods = [
mod for mod, mask, mod_key in modifiers
if (mod_key != key and state & mask
and not (mod == "shift" and unikey.isprintable()))]
return "+".join([*mods, key])
def _realize_event(self, obj):
surface = self.get_native().get_surface()
surface.connect('notify::scale', self._update_device_pixel_ratio)
self._update_device_pixel_ratio()
def _update_device_pixel_ratio(self, *args, **kwargs):
# We need to be careful in cases with mixed resolution displays if
# device_pixel_ratio changes.
if _GTK_GE_4_12:
scale = self.get_native().get_surface().get_scale()
else:
scale = self.get_scale_factor()
assert scale is not None
if self._set_device_pixel_ratio(scale):
self.draw()
def _draw_rubberband(self, rect):
self._rubberband_rect = rect
# TODO: Only update the rubberband area.
self.queue_draw()
def _draw_func(self, drawing_area, ctx, width, height):
self.on_draw_event(self, ctx)
self._post_draw(self, ctx)
def _post_draw(self, widget, ctx):
if self._rubberband_rect is None:
return
lw = 1
dash = 3
x0, y0, w, h = (dim / self.device_pixel_ratio
for dim in self._rubberband_rect)
x1 = x0 + w
y1 = y0 + h
# Draw the lines from x0, y0 towards x1, y1 so that the
# dashes don't "jump" when moving the zoom box.
ctx.move_to(x0, y0)
ctx.line_to(x0, y1)
ctx.move_to(x0, y0)
ctx.line_to(x1, y0)
ctx.move_to(x0, y1)
ctx.line_to(x1, y1)
ctx.move_to(x1, y0)
ctx.line_to(x1, y1)
ctx.set_antialias(1)
ctx.set_line_width(lw)
ctx.set_dash((dash, dash), 0)
ctx.set_source_rgb(0, 0, 0)
ctx.stroke_preserve()
ctx.set_dash((dash, dash), dash)
ctx.set_source_rgb(1, 1, 1)
ctx.stroke()
def on_draw_event(self, widget, ctx):
# to be overwritten by GTK4Agg or GTK4Cairo
pass
def draw(self):
# docstring inherited
if self.is_drawable():
self.queue_draw()
def draw_idle(self):
# docstring inherited
if self._idle_draw_id != 0:
return
def idle_draw(*args):
try:
self.draw()
finally:
self._idle_draw_id = 0
return False
self._idle_draw_id = GLib.idle_add(idle_draw)
def flush_events(self):
# docstring inherited
context = GLib.MainContext.default()
while context.pending():
context.iteration(True)
class NavigationToolbar2GTK4(_NavigationToolbar2GTK, Gtk.Box):
def __init__(self, canvas):
Gtk.Box.__init__(self)
self.add_css_class('toolbar')
self._gtk_ids = {}
for text, tooltip_text, image_file, callback in self.toolitems:
if text is None:
self.append(Gtk.Separator())
continue
image = Gtk.Image.new_from_gicon(
Gio.Icon.new_for_string(
str(cbook._get_data_path('images',
f'{image_file}-symbolic.svg'))))
self._gtk_ids[text] = button = (
Gtk.ToggleButton() if callback in ['zoom', 'pan'] else
Gtk.Button())
button.set_child(image)
button.add_css_class('flat')
button.add_css_class('image-button')
# Save the handler id, so that we can block it as needed.
button._signal_handler = button.connect(
'clicked', getattr(self, callback))
button.set_tooltip_text(tooltip_text)
self.append(button)
# This filler item ensures the toolbar is always at least two text
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
# over images because those use two-line messages which resize the
# toolbar.
label = Gtk.Label()
label.set_markup(
'<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
label.set_hexpand(True) # Push real message to the right.
self.append(label)
self.message = Gtk.Label()
self.message.set_justify(Gtk.Justification.RIGHT)
self.append(self.message)
_NavigationToolbar2GTK.__init__(self, canvas)
def save_figure(self, *args):
dialog = Gtk.FileChooserNative(
title='Save the figure',
transient_for=self.canvas.get_root(),
action=Gtk.FileChooserAction.SAVE,
modal=True)
self._save_dialog = dialog # Must keep a reference.
ff = Gtk.FileFilter()
ff.set_name('All files')
ff.add_pattern('*')
dialog.add_filter(ff)
dialog.set_filter(ff)
formats = []
default_format = None
for i, (name, fmts) in enumerate(
self.canvas.get_supported_filetypes_grouped().items()):
ff = Gtk.FileFilter()
ff.set_name(name)
for fmt in fmts:
ff.add_pattern(f'*.{fmt}')
dialog.add_filter(ff)
formats.append(name)
if self.canvas.get_default_filetype() in fmts:
default_format = i
# Setting the choice doesn't always work, so make sure the default
# format is first.
formats = [formats[default_format], *formats[:default_format],
*formats[default_format+1:]]
dialog.add_choice('format', 'File format', formats, formats)
dialog.set_choice('format', formats[0])
dialog.set_current_folder(Gio.File.new_for_path(
os.path.expanduser(mpl.rcParams['savefig.directory'])))
dialog.set_current_name(self.canvas.get_default_filename())
@functools.partial(dialog.connect, 'response')
def on_response(dialog, response):
file = dialog.get_file()
fmt = dialog.get_choice('format')
fmt = self.canvas.get_supported_filetypes_grouped()[fmt][0]
dialog.destroy()
self._save_dialog = None
if response != Gtk.ResponseType.ACCEPT:
return
# Save dir for next time, unless empty str (which means use cwd).
if mpl.rcParams['savefig.directory']:
parent = file.get_parent()
mpl.rcParams['savefig.directory'] = parent.get_path()
try:
self.canvas.figure.savefig(file.get_path(), format=fmt)
except Exception as e:
msg = Gtk.MessageDialog(
transient_for=self.canvas.get_root(),
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK, modal=True,
text=str(e))
msg.show()
dialog.show()
return self.UNKNOWN_SAVED_STATUS
class ToolbarGTK4(ToolContainerBase, Gtk.Box):
_icon_extension = '-symbolic.svg'
def __init__(self, toolmanager):
ToolContainerBase.__init__(self, toolmanager)
Gtk.Box.__init__(self)
self.set_property('orientation', Gtk.Orientation.HORIZONTAL)
# Tool items are created later, but must appear before the message.
self._tool_box = Gtk.Box()
self.append(self._tool_box)
self._groups = {}
self._toolitems = {}
# This filler item ensures the toolbar is always at least two text
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
# over images because those use two-line messages which resize the
# toolbar.
label = Gtk.Label()
label.set_markup(
'<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
label.set_hexpand(True) # Push real message to the right.
self.append(label)
self._message = Gtk.Label()
self._message.set_justify(Gtk.Justification.RIGHT)
self.append(self._message)
def add_toolitem(self, name, group, position, image_file, description,
toggle):
if toggle:
button = Gtk.ToggleButton()
else:
button = Gtk.Button()
button.set_label(name)
button.add_css_class('flat')
if image_file is not None:
image = Gtk.Image.new_from_gicon(
Gio.Icon.new_for_string(image_file))
button.set_child(image)
button.add_css_class('image-button')
if position is None:
position = -1
self._add_button(button, group, position)
signal = button.connect('clicked', self._call_tool, name)
button.set_tooltip_text(description)
self._toolitems.setdefault(name, [])
self._toolitems[name].append((button, signal))
def _find_child_at_position(self, group, position):
children = [None]
child = self._groups[group].get_first_child()
while child is not None:
children.append(child)
child = child.get_next_sibling()
return children[position]
def _add_button(self, button, group, position):
if group not in self._groups:
if self._groups:
self._add_separator()
group_box = Gtk.Box()
self._tool_box.append(group_box)
self._groups[group] = group_box
self._groups[group].insert_child_after(
button, self._find_child_at_position(group, position))
def _call_tool(self, btn, name):
self.trigger_tool(name)
def toggle_toolitem(self, name, toggled):
if name not in self._toolitems:
return
for toolitem, signal in self._toolitems[name]:
toolitem.handler_block(signal)
toolitem.set_active(toggled)
toolitem.handler_unblock(signal)
def remove_toolitem(self, name):
for toolitem, _signal in self._toolitems.pop(name, []):
for group in self._groups:
if toolitem in self._groups[group]:
self._groups[group].remove(toolitem)
def _add_separator(self):
sep = Gtk.Separator()
sep.set_property("orientation", Gtk.Orientation.VERTICAL)
self._tool_box.append(sep)
def set_message(self, s):
self._message.set_label(s)
@backend_tools._register_tool_class(FigureCanvasGTK4)
class SaveFigureGTK4(backend_tools.SaveFigureBase):
def trigger(self, *args, **kwargs):
NavigationToolbar2GTK4.save_figure(
self._make_classic_style_pseudo_toolbar())
@backend_tools._register_tool_class(FigureCanvasGTK4)
class HelpGTK4(backend_tools.ToolHelpBase):
def _normalize_shortcut(self, key):
"""
Convert Matplotlib key presses to GTK+ accelerator identifiers.
Related to `FigureCanvasGTK4._get_key`.
"""
special = {
'backspace': 'BackSpace',
'pagedown': 'Page_Down',
'pageup': 'Page_Up',
'scroll_lock': 'Scroll_Lock',
}
parts = key.split('+')
mods = ['<' + mod + '>' for mod in parts[:-1]]
key = parts[-1]
if key in special:
key = special[key]
elif len(key) > 1:
key = key.capitalize()
elif key.isupper():
mods += ['<shift>']
return ''.join(mods) + key
def _is_valid_shortcut(self, key):
"""
Check for a valid shortcut to be displayed.
- GTK will never send 'cmd+' (see `FigureCanvasGTK4._get_key`).
- The shortcut window only shows keyboard shortcuts, not mouse buttons.
"""
return 'cmd+' not in key and not key.startswith('MouseButton.')
def trigger(self, *args):
section = Gtk.ShortcutsSection()
for name, tool in sorted(self.toolmanager.tools.items()):
if not tool.description:
continue
# Putting everything in a separate group allows GTK to
# automatically split them into separate columns/pages, which is
# useful because we have lots of shortcuts, some with many keys
# that are very wide.
group = Gtk.ShortcutsGroup()
section.append(group)
# A hack to remove the title since we have no group naming.
child = group.get_first_child()
while child is not None:
child.set_visible(False)
child = child.get_next_sibling()
shortcut = Gtk.ShortcutsShortcut(
accelerator=' '.join(
self._normalize_shortcut(key)
for key in self.toolmanager.get_tool_keymap(name)
if self._is_valid_shortcut(key)),
title=tool.name,
subtitle=tool.description)
group.append(shortcut)
window = Gtk.ShortcutsWindow(
title='Help',
modal=True,
transient_for=self._figure.canvas.get_root())
window.set_child(section)
window.show()
@backend_tools._register_tool_class(FigureCanvasGTK4)
class ToolCopyToClipboardGTK4(backend_tools.ToolCopyToClipboardBase):
def trigger(self, *args, **kwargs):
with io.BytesIO() as f:
self.canvas.print_rgba(f)
w, h = self.canvas.get_width_height()
pb = GdkPixbuf.Pixbuf.new_from_data(f.getbuffer(),
GdkPixbuf.Colorspace.RGB, True,
8, w, h, w*4)
clipboard = self.canvas.get_clipboard()
clipboard.set(pb)
backend_tools._register_tool_class(
FigureCanvasGTK4, _backend_gtk.ConfigureSubplotsGTK)
backend_tools._register_tool_class(
FigureCanvasGTK4, _backend_gtk.RubberbandGTK)
Toolbar = ToolbarGTK4
class FigureManagerGTK4(_FigureManagerGTK):
_toolbar2_class = NavigationToolbar2GTK4
_toolmanager_toolbar_class = ToolbarGTK4
@_BackendGTK.export
class _BackendGTK4(_BackendGTK):
FigureCanvas = FigureCanvasGTK4
FigureManager = FigureManagerGTK4

View file

@ -0,0 +1,41 @@
import numpy as np
from .. import cbook
from . import backend_agg, backend_gtk4
from .backend_gtk4 import GLib, Gtk, _BackendGTK4
import cairo # Presence of cairo is already checked by _backend_gtk.
class FigureCanvasGTK4Agg(backend_agg.FigureCanvasAgg,
backend_gtk4.FigureCanvasGTK4):
def on_draw_event(self, widget, ctx):
if self._idle_draw_id:
GLib.source_remove(self._idle_draw_id)
self._idle_draw_id = 0
self.draw()
scale = self.device_pixel_ratio
allocation = self.get_allocation()
Gtk.render_background(
self.get_style_context(), ctx,
allocation.x, allocation.y,
allocation.width, allocation.height)
buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32(
np.asarray(self.get_renderer().buffer_rgba()))
height, width, _ = buf.shape
image = cairo.ImageSurface.create_for_data(
buf.ravel().data, cairo.FORMAT_ARGB32, width, height)
image.set_device_scale(scale, scale)
ctx.set_source_surface(image, 0, 0)
ctx.paint()
return False
@_BackendGTK4.export
class _BackendGTK4Agg(_BackendGTK4):
FigureCanvas = FigureCanvasGTK4Agg

View file

@ -0,0 +1,32 @@
from contextlib import nullcontext
from .backend_cairo import FigureCanvasCairo
from .backend_gtk4 import GLib, Gtk, FigureCanvasGTK4, _BackendGTK4
class FigureCanvasGTK4Cairo(FigureCanvasCairo, FigureCanvasGTK4):
def _set_device_pixel_ratio(self, ratio):
# Cairo in GTK4 always uses logical pixels, so we don't need to do anything for
# changes to the device pixel ratio.
return False
def on_draw_event(self, widget, ctx):
if self._idle_draw_id:
GLib.source_remove(self._idle_draw_id)
self._idle_draw_id = 0
self.draw()
with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar
else nullcontext()):
self._renderer.set_context(ctx)
allocation = self.get_allocation()
Gtk.render_background(
self.get_style_context(), ctx,
allocation.x, allocation.y,
allocation.width, allocation.height)
self.figure.draw(self._renderer)
@_BackendGTK4.export
class _BackendGTK4Cairo(_BackendGTK4):
FigureCanvas = FigureCanvasGTK4Cairo

View file

@ -0,0 +1,196 @@
import os
import matplotlib as mpl
from matplotlib import _api, cbook
from matplotlib._pylab_helpers import Gcf
from . import _macosx
from .backend_agg import FigureCanvasAgg
from matplotlib.backend_bases import (
_Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
ResizeEvent, TimerBase, _allow_interrupt)
class TimerMac(_macosx.Timer, TimerBase):
"""Subclass of `.TimerBase` using CFRunLoop timer events."""
# completely implemented at the C-level (in _macosx.Timer)
def _allow_interrupt_macos():
"""A context manager that allows terminating a plot by sending a SIGINT."""
return _allow_interrupt(
lambda rsock: _macosx.wake_on_fd_write(rsock.fileno()), _macosx.stop)
class FigureCanvasMac(FigureCanvasAgg, _macosx.FigureCanvas, FigureCanvasBase):
# docstring inherited
# Ideally this class would be `class FCMacAgg(FCAgg, FCMac)`
# (FC=FigureCanvas) where FCMac would be an ObjC-implemented mac-specific
# class also inheriting from FCBase (this is the approach with other GUI
# toolkits). However, writing an extension type inheriting from a Python
# base class is slightly tricky (the extension type must be a heap type),
# and we can just as well lift the FCBase base up one level, keeping it *at
# the end* to have the right method resolution order.
# Events such as button presses, mouse movements, and key presses are
# handled in C and events (MouseEvent, etc.) are triggered from there.
required_interactive_framework = "macosx"
_timer_cls = TimerMac
manager_class = _api.classproperty(lambda cls: FigureManagerMac)
def __init__(self, figure):
super().__init__(figure=figure)
self._draw_pending = False
self._is_drawing = False
# Keep track of the timers that are alive
self._timers = set()
def draw(self):
"""Render the figure and update the macosx canvas."""
# The renderer draw is done here; delaying causes problems with code
# that uses the result of the draw() to update plot elements.
if self._is_drawing:
return
with cbook._setattr_cm(self, _is_drawing=True):
super().draw()
self.update()
def draw_idle(self):
# docstring inherited
if not (getattr(self, '_draw_pending', False) or
getattr(self, '_is_drawing', False)):
self._draw_pending = True
# Add a singleshot timer to the eventloop that will call back
# into the Python method _draw_idle to take care of the draw
self._single_shot_timer(self._draw_idle)
def _single_shot_timer(self, callback):
"""Add a single shot timer with the given callback"""
def callback_func(callback, timer):
callback()
self._timers.remove(timer)
timer = self.new_timer(interval=0)
timer.single_shot = True
timer.add_callback(callback_func, callback, timer)
self._timers.add(timer)
timer.start()
def _draw_idle(self):
"""
Draw method for singleshot timer
This draw method can be added to a singleshot timer, which can
accumulate draws while the eventloop is spinning. This method will
then only draw the first time and short-circuit the others.
"""
with self._idle_draw_cntx():
if not self._draw_pending:
# Short-circuit because our draw request has already been
# taken care of
return
self._draw_pending = False
self.draw()
def blit(self, bbox=None):
# docstring inherited
super().blit(bbox)
self.update()
def resize(self, width, height):
# Size from macOS is logical pixels, dpi is physical.
scale = self.figure.dpi / self.device_pixel_ratio
width /= scale
height /= scale
self.figure.set_size_inches(width, height, forward=False)
ResizeEvent("resize_event", self)._process()
self.draw_idle()
def start_event_loop(self, timeout=0):
# docstring inherited
# Set up a SIGINT handler to allow terminating a plot via CTRL-C.
with _allow_interrupt_macos():
self._start_event_loop(timeout=timeout) # Forward to ObjC implementation.
class NavigationToolbar2Mac(_macosx.NavigationToolbar2, NavigationToolbar2):
def __init__(self, canvas):
data_path = cbook._get_data_path('images')
_, tooltips, image_names, _ = zip(*NavigationToolbar2.toolitems)
_macosx.NavigationToolbar2.__init__(
self, canvas,
tuple(str(data_path / image_name) + ".pdf"
for image_name in image_names if image_name is not None),
tuple(tooltip for tooltip in tooltips if tooltip is not None))
NavigationToolbar2.__init__(self, canvas)
def draw_rubberband(self, event, x0, y0, x1, y1):
self.canvas.set_rubberband(int(x0), int(y0), int(x1), int(y1))
def remove_rubberband(self):
self.canvas.remove_rubberband()
def save_figure(self, *args):
directory = os.path.expanduser(mpl.rcParams['savefig.directory'])
filename = _macosx.choose_save_file('Save the figure',
directory,
self.canvas.get_default_filename())
if filename is None: # Cancel
return
# Save dir for next time, unless empty str (which means use cwd).
if mpl.rcParams['savefig.directory']:
mpl.rcParams['savefig.directory'] = os.path.dirname(filename)
self.canvas.figure.savefig(filename)
return filename
class FigureManagerMac(_macosx.FigureManager, FigureManagerBase):
_toolbar2_class = NavigationToolbar2Mac
def __init__(self, canvas, num):
self._shown = False
_macosx.FigureManager.__init__(self, canvas)
icon_path = str(cbook._get_data_path('images/matplotlib.pdf'))
_macosx.FigureManager.set_icon(icon_path)
FigureManagerBase.__init__(self, canvas, num)
self._set_window_mode(mpl.rcParams["macosx.window_mode"])
if self.toolbar is not None:
self.toolbar.update()
if mpl.is_interactive():
self.show()
self.canvas.draw_idle()
def _close_button_pressed(self):
Gcf.destroy(self)
self.canvas.flush_events()
def destroy(self):
# We need to clear any pending timers that never fired, otherwise
# we get a memory leak from the timer callbacks holding a reference
while self.canvas._timers:
timer = self.canvas._timers.pop()
timer.stop()
super().destroy()
@classmethod
def start_main_loop(cls):
# Set up a SIGINT handler to allow terminating a plot via CTRL-C.
with _allow_interrupt_macos():
_macosx.show()
def show(self):
if self.canvas.figure.stale:
self.canvas.draw_idle()
if not self._shown:
self._show()
self._shown = True
if mpl.rcParams["figure.raise_window"]:
self._raise()
@_Backend.export
class _BackendMac(_Backend):
FigureCanvas = FigureCanvasMac
FigureManager = FigureManagerMac
mainloop = FigureManagerMac.start_main_loop

View file

@ -0,0 +1,119 @@
import numpy as np
from matplotlib import cbook
from .backend_agg import RendererAgg
from matplotlib._tight_bbox import process_figure_for_rasterizing
class MixedModeRenderer:
"""
A helper class to implement a renderer that switches between
vector and raster drawing. An example may be a PDF writer, where
most things are drawn with PDF vector commands, but some very
complex objects, such as quad meshes, are rasterised and then
output as images.
"""
def __init__(self, figure, width, height, dpi, vector_renderer,
raster_renderer_class=None,
bbox_inches_restore=None):
"""
Parameters
----------
figure : `~matplotlib.figure.Figure`
The figure instance.
width : float
The width of the canvas in logical units
height : float
The height of the canvas in logical units
dpi : float
The dpi of the canvas
vector_renderer : `~matplotlib.backend_bases.RendererBase`
An instance of a subclass of
`~matplotlib.backend_bases.RendererBase` that will be used for the
vector drawing.
raster_renderer_class : `~matplotlib.backend_bases.RendererBase`
The renderer class to use for the raster drawing. If not provided,
this will use the Agg backend (which is currently the only viable
option anyway.)
"""
if raster_renderer_class is None:
raster_renderer_class = RendererAgg
self._raster_renderer_class = raster_renderer_class
self._width = width
self._height = height
self.dpi = dpi
self._vector_renderer = vector_renderer
self._raster_renderer = None
# A reference to the figure is needed as we need to change
# the figure dpi before and after the rasterization. Although
# this looks ugly, I couldn't find a better solution. -JJL
self.figure = figure
self._figdpi = figure.dpi
self._bbox_inches_restore = bbox_inches_restore
self._renderer = vector_renderer
def __getattr__(self, attr):
# Proxy everything that hasn't been overridden to the base
# renderer. Things that *are* overridden can call methods
# on self._renderer directly, but must not cache/store
# methods (because things like RendererAgg change their
# methods on the fly in order to optimise proxying down
# to the underlying C implementation).
return getattr(self._renderer, attr)
def start_rasterizing(self):
"""
Enter "raster" mode. All subsequent drawing commands (until
`stop_rasterizing` is called) will be drawn with the raster backend.
"""
# change the dpi of the figure temporarily.
self.figure.dpi = self.dpi
if self._bbox_inches_restore: # when tight bbox is used
r = process_figure_for_rasterizing(self.figure,
self._bbox_inches_restore)
self._bbox_inches_restore = r
self._raster_renderer = self._raster_renderer_class(
self._width*self.dpi, self._height*self.dpi, self.dpi)
self._renderer = self._raster_renderer
def stop_rasterizing(self):
"""
Exit "raster" mode. All of the drawing that was done since
the last `start_rasterizing` call will be copied to the
vector backend by calling draw_image.
"""
self._renderer = self._vector_renderer
height = self._height * self.dpi
img = np.asarray(self._raster_renderer.buffer_rgba())
slice_y, slice_x = cbook._get_nonzero_slices(img[..., 3])
cropped_img = img[slice_y, slice_x]
if cropped_img.size:
gc = self._renderer.new_gc()
# TODO: If the mixedmode resolution differs from the figure's
# dpi, the image must be scaled (dpi->_figdpi). Not all
# backends support this.
self._renderer.draw_image(
gc,
slice_x.start * self._figdpi / self.dpi,
(height - slice_y.stop) * self._figdpi / self.dpi,
cropped_img[::-1])
self._raster_renderer = None
# restore the figure dpi.
self.figure.dpi = self._figdpi
if self._bbox_inches_restore: # when tight bbox is used
r = process_figure_for_rasterizing(self.figure,
self._bbox_inches_restore,
self._figdpi)
self._bbox_inches_restore = r

View file

@ -0,0 +1,243 @@
"""Interactive figures in the IPython notebook."""
# Note: There is a notebook in
# lib/matplotlib/backends/web_backend/nbagg_uat.ipynb to help verify
# that changes made maintain expected behaviour.
from base64 import b64encode
import io
import json
import pathlib
import uuid
from ipykernel.comm import Comm
from IPython.display import display, Javascript, HTML
from matplotlib import is_interactive
from matplotlib._pylab_helpers import Gcf
from matplotlib.backend_bases import _Backend, CloseEvent, NavigationToolbar2
from .backend_webagg_core import (
FigureCanvasWebAggCore, FigureManagerWebAgg, NavigationToolbar2WebAgg)
from .backend_webagg_core import ( # noqa: F401 # pylint: disable=W0611
TimerTornado, TimerAsyncio)
def connection_info():
"""
Return a string showing the figure and connection status for the backend.
This is intended as a diagnostic tool, and not for general use.
"""
result = [
'{fig} - {socket}'.format(
fig=(manager.canvas.figure.get_label()
or f"Figure {manager.num}"),
socket=manager.web_sockets)
for manager in Gcf.get_all_fig_managers()
]
if not is_interactive():
result.append(f'Figures pending show: {len(Gcf.figs)}')
return '\n'.join(result)
_FONT_AWESOME_CLASSES = { # font-awesome 4 names
'home': 'fa fa-home',
'back': 'fa fa-arrow-left',
'forward': 'fa fa-arrow-right',
'zoom_to_rect': 'fa fa-square-o',
'move': 'fa fa-arrows',
'download': 'fa fa-floppy-o',
None: None
}
class NavigationIPy(NavigationToolbar2WebAgg):
# Use the standard toolbar items + download button
toolitems = [(text, tooltip_text,
_FONT_AWESOME_CLASSES[image_file], name_of_method)
for text, tooltip_text, image_file, name_of_method
in (NavigationToolbar2.toolitems +
(('Download', 'Download plot', 'download', 'download'),))
if image_file in _FONT_AWESOME_CLASSES]
class FigureManagerNbAgg(FigureManagerWebAgg):
_toolbar2_class = ToolbarCls = NavigationIPy
def __init__(self, canvas, num):
self._shown = False
super().__init__(canvas, num)
@classmethod
def create_with_canvas(cls, canvas_class, figure, num):
canvas = canvas_class(figure)
manager = cls(canvas, num)
if is_interactive():
manager.show()
canvas.draw_idle()
def destroy(event):
canvas.mpl_disconnect(cid)
Gcf.destroy(manager)
cid = canvas.mpl_connect('close_event', destroy)
return manager
def display_js(self):
# XXX How to do this just once? It has to deal with multiple
# browser instances using the same kernel (require.js - but the
# file isn't static?).
display(Javascript(FigureManagerNbAgg.get_javascript()))
def show(self):
if not self._shown:
self.display_js()
self._create_comm()
else:
self.canvas.draw_idle()
self._shown = True
# plt.figure adds an event which makes the figure in focus the active
# one. Disable this behaviour, as it results in figures being put as
# the active figure after they have been shown, even in non-interactive
# mode.
if hasattr(self, '_cidgcf'):
self.canvas.mpl_disconnect(self._cidgcf)
if not is_interactive():
from matplotlib._pylab_helpers import Gcf
Gcf.figs.pop(self.num, None)
def reshow(self):
"""
A special method to re-show the figure in the notebook.
"""
self._shown = False
self.show()
@property
def connected(self):
return bool(self.web_sockets)
@classmethod
def get_javascript(cls, stream=None):
if stream is None:
output = io.StringIO()
else:
output = stream
super().get_javascript(stream=output)
output.write((pathlib.Path(__file__).parent
/ "web_backend/js/nbagg_mpl.js")
.read_text(encoding="utf-8"))
if stream is None:
return output.getvalue()
def _create_comm(self):
comm = CommSocket(self)
self.add_web_socket(comm)
return comm
def destroy(self):
self._send_event('close')
# need to copy comms as callbacks will modify this list
for comm in list(self.web_sockets):
comm.on_close()
self.clearup_closed()
def clearup_closed(self):
"""Clear up any closed Comms."""
self.web_sockets = {socket for socket in self.web_sockets
if socket.is_open()}
if len(self.web_sockets) == 0:
CloseEvent("close_event", self.canvas)._process()
def remove_comm(self, comm_id):
self.web_sockets = {socket for socket in self.web_sockets
if socket.comm.comm_id != comm_id}
class FigureCanvasNbAgg(FigureCanvasWebAggCore):
manager_class = FigureManagerNbAgg
class CommSocket:
"""
Manages the Comm connection between IPython and the browser (client).
Comms are 2 way, with the CommSocket being able to publish a message
via the send_json method, and handle a message with on_message. On the
JS side figure.send_message and figure.ws.onmessage do the sending and
receiving respectively.
"""
def __init__(self, manager):
self.supports_binary = None
self.manager = manager
self.uuid = str(uuid.uuid4())
# Publish an output area with a unique ID. The javascript can then
# hook into this area.
display(HTML("<div id=%r></div>" % self.uuid))
try:
self.comm = Comm('matplotlib', data={'id': self.uuid})
except AttributeError as err:
raise RuntimeError('Unable to create an IPython notebook Comm '
'instance. Are you in the IPython '
'notebook?') from err
self.comm.on_msg(self.on_message)
manager = self.manager
self._ext_close = False
def _on_close(close_message):
self._ext_close = True
manager.remove_comm(close_message['content']['comm_id'])
manager.clearup_closed()
self.comm.on_close(_on_close)
def is_open(self):
return not (self._ext_close or self.comm._closed)
def on_close(self):
# When the socket is closed, deregister the websocket with
# the FigureManager.
if self.is_open():
try:
self.comm.close()
except KeyError:
# apparently already cleaned it up?
pass
def send_json(self, content):
self.comm.send({'data': json.dumps(content)})
def send_binary(self, blob):
if self.supports_binary:
self.comm.send({'blob': 'image/png'}, buffers=[blob])
else:
# The comm is ASCII, so we send the image in base64 encoded data
# URL form.
data = b64encode(blob).decode('ascii')
data_uri = f"data:image/png;base64,{data}"
self.comm.send({'data': data_uri})
def on_message(self, message):
# The 'supports_binary' message is relevant to the
# websocket itself. The other messages get passed along
# to matplotlib as-is.
# Every message has a "type" and a "figure_id".
message = json.loads(message['content']['data'])
if message['type'] == 'closing':
self.on_close()
self.manager.clearup_closed()
elif message['type'] == 'supports_binary':
self.supports_binary = message['value']
else:
self.manager.handle_json(message)
@_Backend.export
class _BackendNbAgg(_Backend):
FigureCanvas = FigureCanvasNbAgg
FigureManager = FigureManagerNbAgg

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,28 @@
from .. import backends
backends._QT_FORCE_QT5_BINDING = True
from .backend_qt import ( # noqa
SPECIAL_KEYS,
# Public API
cursord, _create_qApp, _BackendQT, TimerQT, MainWindow, FigureCanvasQT,
FigureManagerQT, ToolbarQt, NavigationToolbar2QT, SubplotToolQt,
SaveFigureQt, ConfigureSubplotsQt, RubberbandQt,
HelpQt, ToolCopyToClipboardQT,
# internal re-exports
FigureCanvasBase, FigureManagerBase, MouseButton, NavigationToolbar2,
TimerBase, ToolContainerBase, figureoptions, Gcf
)
from . import backend_qt as _backend_qt # noqa
@_BackendQT.export
class _BackendQT5(_BackendQT):
pass
def __getattr__(name):
if name == 'qApp':
return _backend_qt.qApp
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -0,0 +1,14 @@
"""
Render to qt from agg
"""
from .. import backends
backends._QT_FORCE_QT5_BINDING = True
from .backend_qtagg import ( # noqa: F401, E402 # pylint: disable=W0611
_BackendQTAgg, FigureCanvasQTAgg, FigureManagerQT, NavigationToolbar2QT,
FigureCanvasAgg, FigureCanvasQT)
@_BackendQTAgg.export
class _BackendQT5Agg(_BackendQTAgg):
pass

View file

@ -0,0 +1,11 @@
from .. import backends
backends._QT_FORCE_QT5_BINDING = True
from .backend_qtcairo import ( # noqa: F401, E402 # pylint: disable=W0611
_BackendQTCairo, FigureCanvasQTCairo, FigureCanvasCairo, FigureCanvasQT
)
@_BackendQTCairo.export
class _BackendQT5Cairo(_BackendQTCairo):
pass

View file

@ -0,0 +1,86 @@
"""
Render to qt from agg.
"""
import ctypes
from matplotlib.transforms import Bbox
from .qt_compat import QT_API, QtCore, QtGui
from .backend_agg import FigureCanvasAgg
from .backend_qt import _BackendQT, FigureCanvasQT
from .backend_qt import ( # noqa: F401 # pylint: disable=W0611
FigureManagerQT, NavigationToolbar2QT)
class FigureCanvasQTAgg(FigureCanvasAgg, FigureCanvasQT):
def paintEvent(self, event):
"""
Copy the image from the Agg canvas to the qt.drawable.
In Qt, all drawing should be done inside of here when a widget is
shown onscreen.
"""
self._draw_idle() # Only does something if a draw is pending.
# If the canvas does not have a renderer, then give up and wait for
# FigureCanvasAgg.draw(self) to be called.
if not hasattr(self, 'renderer'):
return
painter = QtGui.QPainter(self)
try:
# See documentation of QRect: bottom() and right() are off
# by 1, so use left() + width() and top() + height().
rect = event.rect()
# scale rect dimensions using the screen dpi ratio to get
# correct values for the Figure coordinates (rather than
# QT5's coords)
width = rect.width() * self.device_pixel_ratio
height = rect.height() * self.device_pixel_ratio
left, top = self.mouseEventCoords(rect.topLeft())
# shift the "top" by the height of the image to get the
# correct corner for our coordinate system
bottom = top - height
# same with the right side of the image
right = left + width
# create a buffer using the image bounding box
bbox = Bbox([[left, bottom], [right, top]])
buf = memoryview(self.copy_from_bbox(bbox))
if QT_API == "PyQt6":
from PyQt6 import sip
ptr = int(sip.voidptr(buf))
else:
ptr = buf
painter.eraseRect(rect) # clear the widget canvas
qimage = QtGui.QImage(ptr, buf.shape[1], buf.shape[0],
QtGui.QImage.Format.Format_RGBA8888)
qimage.setDevicePixelRatio(self.device_pixel_ratio)
# set origin using original QT coordinates
origin = QtCore.QPoint(rect.left(), rect.top())
painter.drawImage(origin, qimage)
# Adjust the buf reference count to work around a memory
# leak bug in QImage under PySide.
if QT_API == "PySide2" and QtCore.__version_info__ < (5, 12):
ctypes.c_long.from_address(id(buf)).value = 1
self._draw_rect_callback(painter)
finally:
painter.end()
def print_figure(self, *args, **kwargs):
super().print_figure(*args, **kwargs)
# In some cases, Qt will itself trigger a paint event after closing the file
# save dialog. When that happens, we need to be sure that the internal canvas is
# re-drawn. However, if the user is using an automatically-chosen Qt backend but
# saving with a different backend (such as pgf), we do not want to trigger a
# full draw in Qt, so just set the flag for next time.
self._draw_pending = True
@_BackendQT.export
class _BackendQTAgg(_BackendQT):
FigureCanvas = FigureCanvasQTAgg

View file

@ -0,0 +1,46 @@
import ctypes
from .backend_cairo import cairo, FigureCanvasCairo
from .backend_qt import _BackendQT, FigureCanvasQT
from .qt_compat import QT_API, QtCore, QtGui
class FigureCanvasQTCairo(FigureCanvasCairo, FigureCanvasQT):
def draw(self):
if hasattr(self._renderer.gc, "ctx"):
self._renderer.dpi = self.figure.dpi
self.figure.draw(self._renderer)
super().draw()
def paintEvent(self, event):
width = int(self.device_pixel_ratio * self.width())
height = int(self.device_pixel_ratio * self.height())
if (width, height) != self._renderer.get_canvas_width_height():
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
self._renderer.set_context(cairo.Context(surface))
self._renderer.dpi = self.figure.dpi
self.figure.draw(self._renderer)
buf = self._renderer.gc.ctx.get_target().get_data()
if QT_API == "PyQt6":
from PyQt6 import sip
ptr = int(sip.voidptr(buf))
else:
ptr = buf
qimage = QtGui.QImage(
ptr, width, height,
QtGui.QImage.Format.Format_ARGB32_Premultiplied)
# Adjust the buf reference count to work around a memory leak bug in
# QImage under PySide.
if QT_API == "PySide2" and QtCore.__version_info__ < (5, 12):
ctypes.c_long.from_address(id(buf)).value = 1
qimage.setDevicePixelRatio(self.device_pixel_ratio)
painter = QtGui.QPainter(self)
painter.eraseRect(event.rect())
painter.drawImage(0, 0, qimage)
self._draw_rect_callback(painter)
painter.end()
@_BackendQT.export
class _BackendQTCairo(_BackendQT):
FigureCanvas = FigureCanvasQTCairo

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,213 @@
"""
A fully functional, do-nothing backend intended as a template for backend
writers. It is fully functional in that you can select it as a backend e.g.
with ::
import matplotlib
matplotlib.use("template")
and your program will (should!) run without error, though no output is
produced. This provides a starting point for backend writers; you can
selectively implement drawing methods (`~.RendererTemplate.draw_path`,
`~.RendererTemplate.draw_image`, etc.) and slowly see your figure come to life
instead having to have a full-blown implementation before getting any results.
Copy this file to a directory outside the Matplotlib source tree, somewhere
where Python can import it (by adding the directory to your ``sys.path`` or by
packaging it as a normal Python package); if the backend is importable as
``import my.backend`` you can then select it using ::
import matplotlib
matplotlib.use("module://my.backend")
If your backend implements support for saving figures (i.e. has a ``print_xyz`` method),
you can register it as the default handler for a given file type::
from matplotlib.backend_bases import register_backend
register_backend('xyz', 'my_backend', 'XYZ File Format')
...
plt.savefig("figure.xyz")
"""
from matplotlib import _api
from matplotlib._pylab_helpers import Gcf
from matplotlib.backend_bases import (
FigureCanvasBase, FigureManagerBase, GraphicsContextBase, RendererBase)
from matplotlib.figure import Figure
class RendererTemplate(RendererBase):
"""
The renderer handles drawing/rendering operations.
This is a minimal do-nothing class that can be used to get started when
writing a new backend. Refer to `.backend_bases.RendererBase` for
documentation of the methods.
"""
def __init__(self, dpi):
super().__init__()
self.dpi = dpi
def draw_path(self, gc, path, transform, rgbFace=None):
pass
# draw_markers is optional, and we get more correct relative
# timings by leaving it out. backend implementers concerned with
# performance will probably want to implement it
# def draw_markers(self, gc, marker_path, marker_trans, path, trans,
# rgbFace=None):
# pass
# draw_path_collection is optional, and we get more correct
# relative timings by leaving it out. backend implementers concerned with
# performance will probably want to implement it
# def draw_path_collection(self, gc, master_transform, paths,
# all_transforms, offsets, offset_trans,
# facecolors, edgecolors, linewidths, linestyles,
# antialiaseds):
# pass
# draw_quad_mesh is optional, and we get more correct
# relative timings by leaving it out. backend implementers concerned with
# performance will probably want to implement it
# def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight,
# coordinates, offsets, offsetTrans, facecolors,
# antialiased, edgecolors):
# pass
def draw_image(self, gc, x, y, im):
pass
def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
pass
def flipy(self):
# docstring inherited
return True
def get_canvas_width_height(self):
# docstring inherited
return 100, 100
def get_text_width_height_descent(self, s, prop, ismath):
return 1, 1, 1
def new_gc(self):
# docstring inherited
return GraphicsContextTemplate()
def points_to_pixels(self, points):
# if backend doesn't have dpi, e.g., postscript or svg
return points
# elif backend assumes a value for pixels_per_inch
# return points/72.0 * self.dpi.get() * pixels_per_inch/72.0
# else
# return points/72.0 * self.dpi.get()
class GraphicsContextTemplate(GraphicsContextBase):
"""
The graphics context provides the color, line styles, etc. See the cairo
and postscript backends for examples of mapping the graphics context
attributes (cap styles, join styles, line widths, colors) to a particular
backend. In cairo this is done by wrapping a cairo.Context object and
forwarding the appropriate calls to it using a dictionary mapping styles
to gdk constants. In Postscript, all the work is done by the renderer,
mapping line styles to postscript calls.
If it's more appropriate to do the mapping at the renderer level (as in
the postscript backend), you don't need to override any of the GC methods.
If it's more appropriate to wrap an instance (as in the cairo backend) and
do the mapping here, you'll need to override several of the setter
methods.
The base GraphicsContext stores colors as an RGB tuple on the unit
interval, e.g., (0.5, 0.0, 1.0). You may need to map this to colors
appropriate for your backend.
"""
########################################################################
#
# The following functions and classes are for pyplot and implement
# window/figure managers, etc.
#
########################################################################
class FigureManagerTemplate(FigureManagerBase):
"""
Helper class for pyplot mode, wraps everything up into a neat bundle.
For non-interactive backends, the base class is sufficient. For
interactive backends, see the documentation of the `.FigureManagerBase`
class for the list of methods that can/should be overridden.
"""
class FigureCanvasTemplate(FigureCanvasBase):
"""
The canvas the figure renders into. Calls the draw and print fig
methods, creates the renderers, etc.
Note: GUI templates will want to connect events for button presses,
mouse movements and key presses to functions that call the base
class methods button_press_event, button_release_event,
motion_notify_event, key_press_event, and key_release_event. See the
implementations of the interactive backends for examples.
Attributes
----------
figure : `~matplotlib.figure.Figure`
A high-level Figure instance
"""
# The instantiated manager class. For further customization,
# ``FigureManager.create_with_canvas`` can also be overridden; see the
# wx-based backends for an example.
manager_class = FigureManagerTemplate
def draw(self):
"""
Draw the figure using the renderer.
It is important that this method actually walk the artist tree
even if not output is produced because this will trigger
deferred work (like computing limits auto-limits and tick
values) that users may want access to before saving to disk.
"""
renderer = RendererTemplate(self.figure.dpi)
self.figure.draw(renderer)
# You should provide a print_xxx function for every file format
# you can write.
# If the file type is not in the base set of filetypes,
# you should add it to the class-scope filetypes dictionary as follows:
filetypes = {**FigureCanvasBase.filetypes, 'foo': 'My magic Foo format'}
def print_foo(self, filename, **kwargs):
"""
Write out format foo.
This method is normally called via `.Figure.savefig` and
`.FigureCanvasBase.print_figure`, which take care of setting the figure
facecolor, edgecolor, and dpi to the desired output values, and will
restore them to the original values. Therefore, `print_foo` does not
need to handle these settings.
"""
self.draw()
def get_default_filetype(self):
return 'foo'
########################################################################
#
# Now just provide the standard names that backend.__init__ is expecting
#
########################################################################
FigureCanvas = FigureCanvasTemplate
FigureManager = FigureManagerTemplate

View file

@ -0,0 +1,20 @@
from . import _backend_tk
from .backend_agg import FigureCanvasAgg
from ._backend_tk import _BackendTk, FigureCanvasTk
from ._backend_tk import ( # noqa: F401 # pylint: disable=W0611
FigureManagerTk, NavigationToolbar2Tk)
class FigureCanvasTkAgg(FigureCanvasAgg, FigureCanvasTk):
def draw(self):
super().draw()
self.blit()
def blit(self, bbox=None):
_backend_tk.blit(self._tkphoto, self.renderer.buffer_rgba(),
(0, 1, 2, 3), bbox=bbox)
@_BackendTk.export
class _BackendTkAgg(_BackendTk):
FigureCanvas = FigureCanvasTkAgg

View file

@ -0,0 +1,26 @@
import sys
import numpy as np
from . import _backend_tk
from .backend_cairo import cairo, FigureCanvasCairo
from ._backend_tk import _BackendTk, FigureCanvasTk
class FigureCanvasTkCairo(FigureCanvasCairo, FigureCanvasTk):
def draw(self):
width = int(self.figure.bbox.width)
height = int(self.figure.bbox.height)
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
self._renderer.set_context(cairo.Context(surface))
self._renderer.dpi = self.figure.dpi
self.figure.draw(self._renderer)
buf = np.reshape(surface.get_data(), (height, width, 4))
_backend_tk.blit(
self._tkphoto, buf,
(2, 1, 0, 3) if sys.byteorder == "little" else (1, 2, 3, 0))
@_BackendTk.export
class _BackendTkCairo(_BackendTk):
FigureCanvas = FigureCanvasTkCairo

View file

@ -0,0 +1,328 @@
"""Displays Agg images in the browser, with interactivity."""
# The WebAgg backend is divided into two modules:
#
# - `backend_webagg_core.py` contains code necessary to embed a WebAgg
# plot inside of a web application, and communicate in an abstract
# way over a web socket.
#
# - `backend_webagg.py` contains a concrete implementation of a basic
# application, implemented with tornado.
from contextlib import contextmanager
import errno
from io import BytesIO
import json
import mimetypes
from pathlib import Path
import random
import sys
import signal
import threading
try:
import tornado
except ImportError as err:
raise RuntimeError("The WebAgg backend requires Tornado.") from err
import tornado.web
import tornado.ioloop
import tornado.websocket
import matplotlib as mpl
from matplotlib.backend_bases import _Backend
from matplotlib._pylab_helpers import Gcf
from . import backend_webagg_core as core
from .backend_webagg_core import ( # noqa: F401 # pylint: disable=W0611
TimerAsyncio, TimerTornado)
webagg_server_thread = threading.Thread(
target=lambda: tornado.ioloop.IOLoop.instance().start())
class FigureManagerWebAgg(core.FigureManagerWebAgg):
_toolbar2_class = core.NavigationToolbar2WebAgg
@classmethod
def pyplot_show(cls, *, block=None):
WebAggApplication.initialize()
url = "http://{address}:{port}{prefix}".format(
address=WebAggApplication.address,
port=WebAggApplication.port,
prefix=WebAggApplication.url_prefix)
if mpl.rcParams['webagg.open_in_browser']:
import webbrowser
if not webbrowser.open(url):
print(f"To view figure, visit {url}")
else:
print(f"To view figure, visit {url}")
WebAggApplication.start()
class FigureCanvasWebAgg(core.FigureCanvasWebAggCore):
manager_class = FigureManagerWebAgg
class WebAggApplication(tornado.web.Application):
initialized = False
started = False
class FavIcon(tornado.web.RequestHandler):
def get(self):
self.set_header('Content-Type', 'image/png')
self.write(Path(mpl.get_data_path(),
'images/matplotlib.png').read_bytes())
class SingleFigurePage(tornado.web.RequestHandler):
def __init__(self, application, request, *, url_prefix='', **kwargs):
self.url_prefix = url_prefix
super().__init__(application, request, **kwargs)
def get(self, fignum):
fignum = int(fignum)
manager = Gcf.get_fig_manager(fignum)
ws_uri = f'ws://{self.request.host}{self.url_prefix}/'
self.render(
"single_figure.html",
prefix=self.url_prefix,
ws_uri=ws_uri,
fig_id=fignum,
toolitems=core.NavigationToolbar2WebAgg.toolitems,
canvas=manager.canvas)
class AllFiguresPage(tornado.web.RequestHandler):
def __init__(self, application, request, *, url_prefix='', **kwargs):
self.url_prefix = url_prefix
super().__init__(application, request, **kwargs)
def get(self):
ws_uri = f'ws://{self.request.host}{self.url_prefix}/'
self.render(
"all_figures.html",
prefix=self.url_prefix,
ws_uri=ws_uri,
figures=sorted(Gcf.figs.items()),
toolitems=core.NavigationToolbar2WebAgg.toolitems)
class MplJs(tornado.web.RequestHandler):
def get(self):
self.set_header('Content-Type', 'application/javascript')
js_content = core.FigureManagerWebAgg.get_javascript()
self.write(js_content)
class Download(tornado.web.RequestHandler):
def get(self, fignum, fmt):
fignum = int(fignum)
manager = Gcf.get_fig_manager(fignum)
self.set_header(
'Content-Type', mimetypes.types_map.get(fmt, 'binary'))
buff = BytesIO()
manager.canvas.figure.savefig(buff, format=fmt)
self.write(buff.getvalue())
class WebSocket(tornado.websocket.WebSocketHandler):
supports_binary = True
def open(self, fignum):
self.fignum = int(fignum)
self.manager = Gcf.get_fig_manager(self.fignum)
self.manager.add_web_socket(self)
if hasattr(self, 'set_nodelay'):
self.set_nodelay(True)
def on_close(self):
self.manager.remove_web_socket(self)
def on_message(self, message):
message = json.loads(message)
# The 'supports_binary' message is on a client-by-client
# basis. The others affect the (shared) canvas as a
# whole.
if message['type'] == 'supports_binary':
self.supports_binary = message['value']
else:
manager = Gcf.get_fig_manager(self.fignum)
# It is possible for a figure to be closed,
# but a stale figure UI is still sending messages
# from the browser.
if manager is not None:
manager.handle_json(message)
def send_json(self, content):
self.write_message(json.dumps(content))
def send_binary(self, blob):
if self.supports_binary:
self.write_message(blob, binary=True)
else:
data_uri = "data:image/png;base64,{}".format(
blob.encode('base64').replace('\n', ''))
self.write_message(data_uri)
def __init__(self, url_prefix=''):
if url_prefix:
assert url_prefix[0] == '/' and url_prefix[-1] != '/', \
'url_prefix must start with a "/" and not end with one.'
super().__init__(
[
# Static files for the CSS and JS
(url_prefix + r'/_static/(.*)',
tornado.web.StaticFileHandler,
{'path': core.FigureManagerWebAgg.get_static_file_path()}),
# Static images for the toolbar
(url_prefix + r'/_images/(.*)',
tornado.web.StaticFileHandler,
{'path': Path(mpl.get_data_path(), 'images')}),
# A Matplotlib favicon
(url_prefix + r'/favicon.ico', self.FavIcon),
# The page that contains all of the pieces
(url_prefix + r'/([0-9]+)', self.SingleFigurePage,
{'url_prefix': url_prefix}),
# The page that contains all of the figures
(url_prefix + r'/?', self.AllFiguresPage,
{'url_prefix': url_prefix}),
(url_prefix + r'/js/mpl.js', self.MplJs),
# Sends images and events to the browser, and receives
# events from the browser
(url_prefix + r'/([0-9]+)/ws', self.WebSocket),
# Handles the downloading (i.e., saving) of static images
(url_prefix + r'/([0-9]+)/download.([a-z0-9.]+)',
self.Download),
],
template_path=core.FigureManagerWebAgg.get_static_file_path())
@classmethod
def initialize(cls, url_prefix='', port=None, address=None):
if cls.initialized:
return
# Create the class instance
app = cls(url_prefix=url_prefix)
cls.url_prefix = url_prefix
# This port selection algorithm is borrowed, more or less
# verbatim, from IPython.
def random_ports(port, n):
"""
Generate a list of n random ports near the given port.
The first 5 ports will be sequential, and the remaining n-5 will be
randomly selected in the range [port-2*n, port+2*n].
"""
for i in range(min(5, n)):
yield port + i
for i in range(n - 5):
yield port + random.randint(-2 * n, 2 * n)
if address is None:
cls.address = mpl.rcParams['webagg.address']
else:
cls.address = address
cls.port = mpl.rcParams['webagg.port']
for port in random_ports(cls.port,
mpl.rcParams['webagg.port_retries']):
try:
app.listen(port, cls.address)
except OSError as e:
if e.errno != errno.EADDRINUSE:
raise
else:
cls.port = port
break
else:
raise SystemExit(
"The webagg server could not be started because an available "
"port could not be found")
cls.initialized = True
@classmethod
def start(cls):
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
pass
else:
cls.started = True
if cls.started:
return
"""
IOLoop.running() was removed as of Tornado 2.4; see for example
https://groups.google.com/forum/#!topic/python-tornado/QLMzkpQBGOY
Thus there is no correct way to check if the loop has already been
launched. We may end up with two concurrently running loops in that
unlucky case with all the expected consequences.
"""
ioloop = tornado.ioloop.IOLoop.instance()
def shutdown():
ioloop.stop()
print("Server is stopped")
sys.stdout.flush()
cls.started = False
@contextmanager
def catch_sigint():
old_handler = signal.signal(
signal.SIGINT,
lambda sig, frame: ioloop.add_callback_from_signal(shutdown))
try:
yield
finally:
signal.signal(signal.SIGINT, old_handler)
# Set the flag to True *before* blocking on ioloop.start()
cls.started = True
print("Press Ctrl+C to stop WebAgg server")
sys.stdout.flush()
with catch_sigint():
ioloop.start()
def ipython_inline_display(figure):
import tornado.template
WebAggApplication.initialize()
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
if not webagg_server_thread.is_alive():
webagg_server_thread.start()
fignum = figure.number
tpl = Path(core.FigureManagerWebAgg.get_static_file_path(),
"ipython_inline_figure.html").read_text()
t = tornado.template.Template(tpl)
return t.generate(
prefix=WebAggApplication.url_prefix,
fig_id=fignum,
toolitems=core.NavigationToolbar2WebAgg.toolitems,
canvas=figure.canvas,
port=WebAggApplication.port).decode('utf-8')
@_Backend.export
class _BackendWebAgg(_Backend):
FigureCanvas = FigureCanvasWebAgg
FigureManager = FigureManagerWebAgg

View file

@ -0,0 +1,527 @@
"""Displays Agg images in the browser, with interactivity."""
# The WebAgg backend is divided into two modules:
#
# - `backend_webagg_core.py` contains code necessary to embed a WebAgg
# plot inside of a web application, and communicate in an abstract
# way over a web socket.
#
# - `backend_webagg.py` contains a concrete implementation of a basic
# application, implemented with asyncio.
import asyncio
import datetime
from io import BytesIO, StringIO
import json
import logging
import os
from pathlib import Path
import numpy as np
from PIL import Image
from matplotlib import _api, backend_bases, backend_tools
from matplotlib.backends import backend_agg
from matplotlib.backend_bases import (
_Backend, MouseButton, KeyEvent, LocationEvent, MouseEvent, ResizeEvent)
_log = logging.getLogger(__name__)
_SPECIAL_KEYS_LUT = {'Alt': 'alt',
'AltGraph': 'alt',
'CapsLock': 'caps_lock',
'Control': 'control',
'Meta': 'meta',
'NumLock': 'num_lock',
'ScrollLock': 'scroll_lock',
'Shift': 'shift',
'Super': 'super',
'Enter': 'enter',
'Tab': 'tab',
'ArrowDown': 'down',
'ArrowLeft': 'left',
'ArrowRight': 'right',
'ArrowUp': 'up',
'End': 'end',
'Home': 'home',
'PageDown': 'pagedown',
'PageUp': 'pageup',
'Backspace': 'backspace',
'Delete': 'delete',
'Insert': 'insert',
'Escape': 'escape',
'Pause': 'pause',
'Select': 'select',
'Dead': 'dead',
'F1': 'f1',
'F2': 'f2',
'F3': 'f3',
'F4': 'f4',
'F5': 'f5',
'F6': 'f6',
'F7': 'f7',
'F8': 'f8',
'F9': 'f9',
'F10': 'f10',
'F11': 'f11',
'F12': 'f12'}
def _handle_key(key):
"""Handle key values"""
value = key[key.index('k') + 1:]
if 'shift+' in key:
if len(value) == 1:
key = key.replace('shift+', '')
if value in _SPECIAL_KEYS_LUT:
value = _SPECIAL_KEYS_LUT[value]
key = key[:key.index('k')] + value
return key
class TimerTornado(backend_bases.TimerBase):
def __init__(self, *args, **kwargs):
self._timer = None
super().__init__(*args, **kwargs)
def _timer_start(self):
import tornado
self._timer_stop()
if self._single:
ioloop = tornado.ioloop.IOLoop.instance()
self._timer = ioloop.add_timeout(
datetime.timedelta(milliseconds=self.interval),
self._on_timer)
else:
self._timer = tornado.ioloop.PeriodicCallback(
self._on_timer,
max(self.interval, 1e-6))
self._timer.start()
def _timer_stop(self):
import tornado
if self._timer is None:
return
elif self._single:
ioloop = tornado.ioloop.IOLoop.instance()
ioloop.remove_timeout(self._timer)
else:
self._timer.stop()
self._timer = None
def _timer_set_interval(self):
# Only stop and restart it if the timer has already been started
if self._timer is not None:
self._timer_stop()
self._timer_start()
class TimerAsyncio(backend_bases.TimerBase):
def __init__(self, *args, **kwargs):
self._task = None
super().__init__(*args, **kwargs)
async def _timer_task(self, interval):
while True:
try:
await asyncio.sleep(interval)
self._on_timer()
if self._single:
break
except asyncio.CancelledError:
break
def _timer_start(self):
self._timer_stop()
self._task = asyncio.ensure_future(
self._timer_task(max(self.interval / 1_000., 1e-6))
)
def _timer_stop(self):
if self._task is not None:
self._task.cancel()
self._task = None
def _timer_set_interval(self):
# Only stop and restart it if the timer has already been started
if self._task is not None:
self._timer_stop()
self._timer_start()
class FigureCanvasWebAggCore(backend_agg.FigureCanvasAgg):
manager_class = _api.classproperty(lambda cls: FigureManagerWebAgg)
_timer_cls = TimerAsyncio
# Webagg and friends having the right methods, but still
# having bugs in practice. Do not advertise that it works until
# we can debug this.
supports_blit = False
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Set to True when the renderer contains data that is newer
# than the PNG buffer.
self._png_is_old = True
# Set to True by the `refresh` message so that the next frame
# sent to the clients will be a full frame.
self._force_full = True
# The last buffer, for diff mode.
self._last_buff = np.empty((0, 0))
# Store the current image mode so that at any point, clients can
# request the information. This should be changed by calling
# self.set_image_mode(mode) so that the notification can be given
# to the connected clients.
self._current_image_mode = 'full'
# Track mouse events to fill in the x, y position of key events.
self._last_mouse_xy = (None, None)
def show(self):
# show the figure window
from matplotlib.pyplot import show
show()
def draw(self):
self._png_is_old = True
try:
super().draw()
finally:
self.manager.refresh_all() # Swap the frames.
def blit(self, bbox=None):
self._png_is_old = True
self.manager.refresh_all()
def draw_idle(self):
self.send_event("draw")
def set_cursor(self, cursor):
# docstring inherited
cursor = _api.check_getitem({
backend_tools.Cursors.HAND: 'pointer',
backend_tools.Cursors.POINTER: 'default',
backend_tools.Cursors.SELECT_REGION: 'crosshair',
backend_tools.Cursors.MOVE: 'move',
backend_tools.Cursors.WAIT: 'wait',
backend_tools.Cursors.RESIZE_HORIZONTAL: 'ew-resize',
backend_tools.Cursors.RESIZE_VERTICAL: 'ns-resize',
}, cursor=cursor)
self.send_event('cursor', cursor=cursor)
def set_image_mode(self, mode):
"""
Set the image mode for any subsequent images which will be sent
to the clients. The modes may currently be either 'full' or 'diff'.
Note: diff images may not contain transparency, therefore upon
draw this mode may be changed if the resulting image has any
transparent component.
"""
_api.check_in_list(['full', 'diff'], mode=mode)
if self._current_image_mode != mode:
self._current_image_mode = mode
self.handle_send_image_mode(None)
def get_diff_image(self):
if self._png_is_old:
renderer = self.get_renderer()
pixels = np.asarray(renderer.buffer_rgba())
# The buffer is created as type uint32 so that entire
# pixels can be compared in one numpy call, rather than
# needing to compare each plane separately.
buff = pixels.view(np.uint32).squeeze(2)
if (self._force_full
# If the buffer has changed size we need to do a full draw.
or buff.shape != self._last_buff.shape
# If any pixels have transparency, we need to force a full
# draw as we cannot overlay new on top of old.
or (pixels[:, :, 3] != 255).any()):
self.set_image_mode('full')
output = buff
else:
self.set_image_mode('diff')
diff = buff != self._last_buff
output = np.where(diff, buff, 0)
# Store the current buffer so we can compute the next diff.
self._last_buff = buff.copy()
self._force_full = False
self._png_is_old = False
data = output.view(dtype=np.uint8).reshape((*output.shape, 4))
with BytesIO() as png:
Image.fromarray(data).save(png, format="png")
return png.getvalue()
def handle_event(self, event):
e_type = event['type']
handler = getattr(self, f'handle_{e_type}',
self.handle_unknown_event)
return handler(event)
def handle_unknown_event(self, event):
_log.warning('Unhandled message type %s. %s', event["type"], event)
def handle_ack(self, event):
# Network latency tends to decrease if traffic is flowing
# in both directions. Therefore, the browser sends back
# an "ack" message after each image frame is received.
# This could also be used as a simple sanity check in the
# future, but for now the performance increase is enough
# to justify it, even if the server does nothing with it.
pass
def handle_draw(self, event):
self.draw()
def _handle_mouse(self, event):
x = event['x']
y = event['y']
y = self.get_renderer().height - y
self._last_mouse_xy = x, y
e_type = event['type']
button = event['button'] + 1 # JS numbers off by 1 compared to mpl.
buttons = { # JS ordering different compared to mpl.
button for button, mask in [
(MouseButton.LEFT, 1),
(MouseButton.RIGHT, 2),
(MouseButton.MIDDLE, 4),
(MouseButton.BACK, 8),
(MouseButton.FORWARD, 16),
] if event['buttons'] & mask # State *after* press/release.
}
modifiers = event['modifiers']
guiEvent = event.get('guiEvent')
if e_type in ['button_press', 'button_release']:
MouseEvent(e_type + '_event', self, x, y, button,
modifiers=modifiers, guiEvent=guiEvent)._process()
elif e_type == 'dblclick':
MouseEvent('button_press_event', self, x, y, button, dblclick=True,
modifiers=modifiers, guiEvent=guiEvent)._process()
elif e_type == 'scroll':
MouseEvent('scroll_event', self, x, y, step=event['step'],
modifiers=modifiers, guiEvent=guiEvent)._process()
elif e_type == 'motion_notify':
MouseEvent(e_type + '_event', self, x, y,
buttons=buttons, modifiers=modifiers, guiEvent=guiEvent,
)._process()
elif e_type in ['figure_enter', 'figure_leave']:
LocationEvent(e_type + '_event', self, x, y,
modifiers=modifiers, guiEvent=guiEvent)._process()
handle_button_press = handle_button_release = handle_dblclick = \
handle_figure_enter = handle_figure_leave = handle_motion_notify = \
handle_scroll = _handle_mouse
def _handle_key(self, event):
KeyEvent(event['type'] + '_event', self,
_handle_key(event['key']), *self._last_mouse_xy,
guiEvent=event.get('guiEvent'))._process()
handle_key_press = handle_key_release = _handle_key
def handle_toolbar_button(self, event):
# TODO: Be more suspicious of the input
getattr(self.toolbar, event['name'])()
def handle_refresh(self, event):
figure_label = self.figure.get_label()
if not figure_label:
figure_label = f"Figure {self.manager.num}"
self.send_event('figure_label', label=figure_label)
self._force_full = True
if self.toolbar:
# Normal toolbar init would refresh this, but it happens before the
# browser canvas is set up.
self.toolbar.set_history_buttons()
self.draw_idle()
def handle_resize(self, event):
x = int(event.get('width', 800)) * self.device_pixel_ratio
y = int(event.get('height', 800)) * self.device_pixel_ratio
fig = self.figure
# An attempt at approximating the figure size in pixels.
fig.set_size_inches(x / fig.dpi, y / fig.dpi, forward=False)
# Acknowledge the resize, and force the viewer to update the
# canvas size to the figure's new size (which is hopefully
# identical or within a pixel or so).
self._png_is_old = True
self.manager.resize(*fig.bbox.size, forward=False)
ResizeEvent('resize_event', self)._process()
self.draw_idle()
def handle_send_image_mode(self, event):
# The client requests notification of what the current image mode is.
self.send_event('image_mode', mode=self._current_image_mode)
def handle_set_device_pixel_ratio(self, event):
self._handle_set_device_pixel_ratio(event.get('device_pixel_ratio', 1))
def handle_set_dpi_ratio(self, event):
# This handler is for backwards-compatibility with older ipympl.
self._handle_set_device_pixel_ratio(event.get('dpi_ratio', 1))
def _handle_set_device_pixel_ratio(self, device_pixel_ratio):
if self._set_device_pixel_ratio(device_pixel_ratio):
self._force_full = True
self.draw_idle()
def send_event(self, event_type, **kwargs):
if self.manager:
self.manager._send_event(event_type, **kwargs)
_ALLOWED_TOOL_ITEMS = {
'home',
'back',
'forward',
'pan',
'zoom',
'download',
None,
}
class NavigationToolbar2WebAgg(backend_bases.NavigationToolbar2):
# Use the standard toolbar items + download button
toolitems = [
(text, tooltip_text, image_file, name_of_method)
for text, tooltip_text, image_file, name_of_method
in (*backend_bases.NavigationToolbar2.toolitems,
('Download', 'Download plot', 'filesave', 'download'))
if name_of_method in _ALLOWED_TOOL_ITEMS
]
def __init__(self, canvas):
self.message = ''
super().__init__(canvas)
def set_message(self, message):
if message != self.message:
self.canvas.send_event("message", message=message)
self.message = message
def draw_rubberband(self, event, x0, y0, x1, y1):
self.canvas.send_event("rubberband", x0=x0, y0=y0, x1=x1, y1=y1)
def remove_rubberband(self):
self.canvas.send_event("rubberband", x0=-1, y0=-1, x1=-1, y1=-1)
def save_figure(self, *args):
"""Save the current figure."""
self.canvas.send_event('save')
return self.UNKNOWN_SAVED_STATUS
def pan(self):
super().pan()
self.canvas.send_event('navigate_mode', mode=self.mode.name)
def zoom(self):
super().zoom()
self.canvas.send_event('navigate_mode', mode=self.mode.name)
def set_history_buttons(self):
can_backward = self._nav_stack._pos > 0
can_forward = self._nav_stack._pos < len(self._nav_stack) - 1
self.canvas.send_event('history_buttons',
Back=can_backward, Forward=can_forward)
class FigureManagerWebAgg(backend_bases.FigureManagerBase):
# This must be None to not break ipympl
_toolbar2_class = None
ToolbarCls = NavigationToolbar2WebAgg
_window_title = "Matplotlib"
def __init__(self, canvas, num):
self.web_sockets = set()
super().__init__(canvas, num)
def show(self):
pass
def resize(self, w, h, forward=True):
self._send_event(
'resize',
size=(w / self.canvas.device_pixel_ratio,
h / self.canvas.device_pixel_ratio),
forward=forward)
def set_window_title(self, title):
self._send_event('figure_label', label=title)
self._window_title = title
def get_window_title(self):
return self._window_title
# The following methods are specific to FigureManagerWebAgg
def add_web_socket(self, web_socket):
assert hasattr(web_socket, 'send_binary')
assert hasattr(web_socket, 'send_json')
self.web_sockets.add(web_socket)
self.resize(*self.canvas.figure.bbox.size)
self._send_event('refresh')
def remove_web_socket(self, web_socket):
self.web_sockets.remove(web_socket)
def handle_json(self, content):
self.canvas.handle_event(content)
def refresh_all(self):
if self.web_sockets:
diff = self.canvas.get_diff_image()
if diff is not None:
for s in self.web_sockets:
s.send_binary(diff)
@classmethod
def get_javascript(cls, stream=None):
if stream is None:
output = StringIO()
else:
output = stream
output.write((Path(__file__).parent / "web_backend/js/mpl.js")
.read_text(encoding="utf-8"))
toolitems = []
for name, tooltip, image, method in cls.ToolbarCls.toolitems:
if name is None:
toolitems.append(['', '', '', ''])
else:
toolitems.append([name, tooltip, image, method])
output.write(f"mpl.toolbar_items = {json.dumps(toolitems)};\n\n")
extensions = []
for filetype, ext in sorted(FigureCanvasWebAggCore.
get_supported_filetypes_grouped().
items()):
extensions.append(ext[0])
output.write(f"mpl.extensions = {json.dumps(extensions)};\n\n")
output.write("mpl.default_extension = {};".format(
json.dumps(FigureCanvasWebAggCore.get_default_filetype())))
if stream is None:
return output.getvalue()
@classmethod
def get_static_file_path(cls):
return os.path.join(os.path.dirname(__file__), 'web_backend')
def _send_event(self, event_type, **kwargs):
payload = {'type': event_type, **kwargs}
for s in self.web_sockets:
s.send_json(payload)
@_Backend.export
class _BackendWebAggCoreAgg(_Backend):
FigureCanvas = FigureCanvasWebAggCore
FigureManager = FigureManagerWebAgg

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
import wx
from .backend_agg import FigureCanvasAgg
from .backend_wx import _BackendWx, _FigureCanvasWxBase
from .backend_wx import ( # noqa: F401 # pylint: disable=W0611
NavigationToolbar2Wx as NavigationToolbar2WxAgg)
class FigureCanvasWxAgg(FigureCanvasAgg, _FigureCanvasWxBase):
def draw(self, drawDC=None):
"""
Render the figure using agg.
"""
FigureCanvasAgg.draw(self)
self.bitmap = self._create_bitmap()
self._isDrawn = True
self.gui_repaint(drawDC=drawDC)
def blit(self, bbox=None):
# docstring inherited
bitmap = self._create_bitmap()
if bbox is None:
self.bitmap = bitmap
else:
srcDC = wx.MemoryDC(bitmap)
destDC = wx.MemoryDC(self.bitmap)
x = int(bbox.x0)
y = int(self.bitmap.GetHeight() - bbox.y1)
destDC.Blit(x, y, int(bbox.width), int(bbox.height), srcDC, x, y)
destDC.SelectObject(wx.NullBitmap)
srcDC.SelectObject(wx.NullBitmap)
self.gui_repaint()
def _create_bitmap(self):
"""Create a wx.Bitmap from the renderer RGBA buffer"""
rgba = self.get_renderer().buffer_rgba()
h, w, _ = rgba.shape
bitmap = wx.Bitmap.FromBufferRGBA(w, h, rgba)
bitmap.SetScaleFactor(self.GetDPIScaleFactor())
return bitmap
@_BackendWx.export
class _BackendWxAgg(_BackendWx):
FigureCanvas = FigureCanvasWxAgg

View file

@ -0,0 +1,23 @@
import wx.lib.wxcairo as wxcairo
from .backend_cairo import cairo, FigureCanvasCairo
from .backend_wx import _BackendWx, _FigureCanvasWxBase
from .backend_wx import ( # noqa: F401 # pylint: disable=W0611
NavigationToolbar2Wx as NavigationToolbar2WxCairo)
class FigureCanvasWxCairo(FigureCanvasCairo, _FigureCanvasWxBase):
def draw(self, drawDC=None):
size = self.figure.bbox.size.astype(int)
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, *size)
self._renderer.set_context(cairo.Context(surface))
self._renderer.dpi = self.figure.dpi
self.figure.draw(self._renderer)
self.bitmap = wxcairo.BitmapFromImageSurface(surface)
self._isDrawn = True
self.gui_repaint(drawDC=drawDC)
@_BackendWx.export
class _BackendWxCairo(_BackendWx):
FigureCanvas = FigureCanvasWxCairo

View file

@ -0,0 +1,159 @@
"""
Qt binding and backend selector.
The selection logic is as follows:
- if any of PyQt6, PySide6, PyQt5, or PySide2 have already been
imported (checked in that order), use it;
- otherwise, if the QT_API environment variable (used by Enthought) is set, use
it to determine which binding to use;
- otherwise, use whatever the rcParams indicate.
"""
import operator
import os
import platform
import sys
from packaging.version import parse as parse_version
import matplotlib as mpl
from . import _QT_FORCE_QT5_BINDING
QT_API_PYQT6 = "PyQt6"
QT_API_PYSIDE6 = "PySide6"
QT_API_PYQT5 = "PyQt5"
QT_API_PYSIDE2 = "PySide2"
QT_API_ENV = os.environ.get("QT_API")
if QT_API_ENV is not None:
QT_API_ENV = QT_API_ENV.lower()
_ETS = { # Mapping of QT_API_ENV to requested binding.
"pyqt6": QT_API_PYQT6, "pyside6": QT_API_PYSIDE6,
"pyqt5": QT_API_PYQT5, "pyside2": QT_API_PYSIDE2,
}
# First, check if anything is already imported.
if sys.modules.get("PyQt6.QtCore"):
QT_API = QT_API_PYQT6
elif sys.modules.get("PySide6.QtCore"):
QT_API = QT_API_PYSIDE6
elif sys.modules.get("PyQt5.QtCore"):
QT_API = QT_API_PYQT5
elif sys.modules.get("PySide2.QtCore"):
QT_API = QT_API_PYSIDE2
# Otherwise, check the QT_API environment variable (from Enthought). This can
# only override the binding, not the backend (in other words, we check that the
# requested backend actually matches). Use _get_backend_or_none to avoid
# triggering backend resolution (which can result in a partially but
# incompletely imported backend_qt5).
elif (mpl.rcParams._get_backend_or_none() or "").lower().startswith("qt5"):
if QT_API_ENV in ["pyqt5", "pyside2"]:
QT_API = _ETS[QT_API_ENV]
else:
_QT_FORCE_QT5_BINDING = True # noqa: F811
QT_API = None
# A non-Qt backend was selected but we still got there (possible, e.g., when
# fully manually embedding Matplotlib in a Qt app without using pyplot).
elif QT_API_ENV is None:
QT_API = None
elif QT_API_ENV in _ETS:
QT_API = _ETS[QT_API_ENV]
else:
raise RuntimeError(
"The environment variable QT_API has the unrecognized value {!r}; "
"valid values are {}".format(QT_API_ENV, ", ".join(_ETS)))
def _setup_pyqt5plus():
global QtCore, QtGui, QtWidgets, __version__
global _isdeleted, _to_int
if QT_API == QT_API_PYQT6:
from PyQt6 import QtCore, QtGui, QtWidgets, sip
__version__ = QtCore.PYQT_VERSION_STR
QtCore.Signal = QtCore.pyqtSignal
QtCore.Slot = QtCore.pyqtSlot
QtCore.Property = QtCore.pyqtProperty
_isdeleted = sip.isdeleted
_to_int = operator.attrgetter('value')
elif QT_API == QT_API_PYSIDE6:
from PySide6 import QtCore, QtGui, QtWidgets, __version__
import shiboken6
def _isdeleted(obj): return not shiboken6.isValid(obj)
if parse_version(__version__) >= parse_version('6.4'):
_to_int = operator.attrgetter('value')
else:
_to_int = int
elif QT_API == QT_API_PYQT5:
from PyQt5 import QtCore, QtGui, QtWidgets
import sip
__version__ = QtCore.PYQT_VERSION_STR
QtCore.Signal = QtCore.pyqtSignal
QtCore.Slot = QtCore.pyqtSlot
QtCore.Property = QtCore.pyqtProperty
_isdeleted = sip.isdeleted
_to_int = int
elif QT_API == QT_API_PYSIDE2:
from PySide2 import QtCore, QtGui, QtWidgets, __version__
try:
from PySide2 import shiboken2
except ImportError:
import shiboken2
def _isdeleted(obj):
return not shiboken2.isValid(obj)
_to_int = int
else:
raise AssertionError(f"Unexpected QT_API: {QT_API}")
if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]:
_setup_pyqt5plus()
elif QT_API is None: # See above re: dict.__getitem__.
if _QT_FORCE_QT5_BINDING:
_candidates = [
(_setup_pyqt5plus, QT_API_PYQT5),
(_setup_pyqt5plus, QT_API_PYSIDE2),
]
else:
_candidates = [
(_setup_pyqt5plus, QT_API_PYQT6),
(_setup_pyqt5plus, QT_API_PYSIDE6),
(_setup_pyqt5plus, QT_API_PYQT5),
(_setup_pyqt5plus, QT_API_PYSIDE2),
]
for _setup, QT_API in _candidates:
try:
_setup()
except ImportError:
continue
break
else:
raise ImportError(
"Failed to import any of the following Qt binding modules: {}"
.format(", ".join([QT_API for _, QT_API in _candidates]))
)
else: # We should not get there.
raise AssertionError(f"Unexpected QT_API: {QT_API}")
_version_info = tuple(QtCore.QLibraryInfo.version().segments())
if _version_info < (5, 12):
raise ImportError(
f"The Qt version imported is "
f"{QtCore.QLibraryInfo.version().toString()} but Matplotlib requires "
f"Qt>=5.12")
# Fixes issues with Big Sur
# https://bugreports.qt.io/browse/QTBUG-87014, fixed in qt 5.15.2
if (sys.platform == 'darwin' and
parse_version(platform.mac_ver()[0]) >= parse_version("10.16") and
_version_info < (5, 15, 2)):
os.environ.setdefault("QT_MAC_WANTS_LAYER", "1")
# Backports.
def _exec(obj):
# exec on PyQt6, exec_ elsewhere.
obj.exec() if hasattr(obj, "exec") else obj.exec_()

View file

@ -0,0 +1,592 @@
"""
formlayout
==========
Module creating Qt form dialogs/layouts to edit various type of parameters
formlayout License Agreement (MIT License)
------------------------------------------
Copyright (c) 2009 Pierre Raybaut
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
"""
# History:
# 1.0.10: added float validator
# (disable "Ok" and "Apply" button when not valid)
# 1.0.7: added support for "Apply" button
# 1.0.6: code cleaning
__version__ = '1.0.10'
__license__ = __doc__
from ast import literal_eval
import copy
import datetime
import logging
from numbers import Integral, Real
from matplotlib import _api, colors as mcolors
from matplotlib.backends.qt_compat import _to_int, QtGui, QtWidgets, QtCore
_log = logging.getLogger(__name__)
BLACKLIST = {"title", "label"}
class ColorButton(QtWidgets.QPushButton):
"""
Color choosing push button
"""
colorChanged = QtCore.Signal(QtGui.QColor)
def __init__(self, parent=None):
super().__init__(parent)
self.setFixedSize(20, 20)
self.setIconSize(QtCore.QSize(12, 12))
self.clicked.connect(self.choose_color)
self._color = QtGui.QColor()
def choose_color(self):
color = QtWidgets.QColorDialog.getColor(
self._color, self.parentWidget(), "",
QtWidgets.QColorDialog.ColorDialogOption.ShowAlphaChannel)
if color.isValid():
self.set_color(color)
def get_color(self):
return self._color
@QtCore.Slot(QtGui.QColor)
def set_color(self, color):
if color != self._color:
self._color = color
self.colorChanged.emit(self._color)
pixmap = QtGui.QPixmap(self.iconSize())
pixmap.fill(color)
self.setIcon(QtGui.QIcon(pixmap))
color = QtCore.Property(QtGui.QColor, get_color, set_color)
def to_qcolor(color):
"""Create a QColor from a matplotlib color"""
qcolor = QtGui.QColor()
try:
rgba = mcolors.to_rgba(color)
except ValueError:
_api.warn_external(f'Ignoring invalid color {color!r}')
return qcolor # return invalid QColor
qcolor.setRgbF(*rgba)
return qcolor
class ColorLayout(QtWidgets.QHBoxLayout):
"""Color-specialized QLineEdit layout"""
def __init__(self, color, parent=None):
super().__init__()
assert isinstance(color, QtGui.QColor)
self.lineedit = QtWidgets.QLineEdit(
mcolors.to_hex(color.getRgbF(), keep_alpha=True), parent)
self.lineedit.editingFinished.connect(self.update_color)
self.addWidget(self.lineedit)
self.colorbtn = ColorButton(parent)
self.colorbtn.color = color
self.colorbtn.colorChanged.connect(self.update_text)
self.addWidget(self.colorbtn)
def update_color(self):
color = self.text()
qcolor = to_qcolor(color) # defaults to black if not qcolor.isValid()
self.colorbtn.color = qcolor
def update_text(self, color):
self.lineedit.setText(mcolors.to_hex(color.getRgbF(), keep_alpha=True))
def text(self):
return self.lineedit.text()
def font_is_installed(font):
"""Check if font is installed"""
return [fam for fam in QtGui.QFontDatabase().families()
if str(fam) == font]
def tuple_to_qfont(tup):
"""
Create a QFont from tuple:
(family [string], size [int], italic [bool], bold [bool])
"""
if not (isinstance(tup, tuple) and len(tup) == 4
and font_is_installed(tup[0])
and isinstance(tup[1], Integral)
and isinstance(tup[2], bool)
and isinstance(tup[3], bool)):
return None
font = QtGui.QFont()
family, size, italic, bold = tup
font.setFamily(family)
font.setPointSize(size)
font.setItalic(italic)
font.setBold(bold)
return font
def qfont_to_tuple(font):
return (str(font.family()), int(font.pointSize()),
font.italic(), font.bold())
class FontLayout(QtWidgets.QGridLayout):
"""Font selection"""
def __init__(self, value, parent=None):
super().__init__()
font = tuple_to_qfont(value)
assert font is not None
# Font family
self.family = QtWidgets.QFontComboBox(parent)
self.family.setCurrentFont(font)
self.addWidget(self.family, 0, 0, 1, -1)
# Font size
self.size = QtWidgets.QComboBox(parent)
self.size.setEditable(True)
sizelist = [*range(6, 12), *range(12, 30, 2), 36, 48, 72]
size = font.pointSize()
if size not in sizelist:
sizelist.append(size)
sizelist.sort()
self.size.addItems([str(s) for s in sizelist])
self.size.setCurrentIndex(sizelist.index(size))
self.addWidget(self.size, 1, 0)
# Italic or not
self.italic = QtWidgets.QCheckBox(self.tr("Italic"), parent)
self.italic.setChecked(font.italic())
self.addWidget(self.italic, 1, 1)
# Bold or not
self.bold = QtWidgets.QCheckBox(self.tr("Bold"), parent)
self.bold.setChecked(font.bold())
self.addWidget(self.bold, 1, 2)
def get_font(self):
font = self.family.currentFont()
font.setItalic(self.italic.isChecked())
font.setBold(self.bold.isChecked())
font.setPointSize(int(self.size.currentText()))
return qfont_to_tuple(font)
def is_edit_valid(edit):
text = edit.text()
state = edit.validator().validate(text, 0)[0]
return state == QtGui.QDoubleValidator.State.Acceptable
class FormWidget(QtWidgets.QWidget):
update_buttons = QtCore.Signal()
def __init__(self, data, comment="", with_margin=False, parent=None):
"""
Parameters
----------
data : list of (label, value) pairs
The data to be edited in the form.
comment : str, optional
with_margin : bool, default: False
If False, the form elements reach to the border of the widget.
This is the desired behavior if the FormWidget is used as a widget
alongside with other widgets such as a QComboBox, which also do
not have a margin around them.
However, a margin can be desired if the FormWidget is the only
widget within a container, e.g. a tab in a QTabWidget.
parent : QWidget or None
The parent widget.
"""
super().__init__(parent)
self.data = copy.deepcopy(data)
self.widgets = []
self.formlayout = QtWidgets.QFormLayout(self)
if not with_margin:
self.formlayout.setContentsMargins(0, 0, 0, 0)
if comment:
self.formlayout.addRow(QtWidgets.QLabel(comment))
self.formlayout.addRow(QtWidgets.QLabel(" "))
def get_dialog(self):
"""Return FormDialog instance"""
dialog = self.parent()
while not isinstance(dialog, QtWidgets.QDialog):
dialog = dialog.parent()
return dialog
def setup(self):
for label, value in self.data:
if label is None and value is None:
# Separator: (None, None)
self.formlayout.addRow(QtWidgets.QLabel(" "),
QtWidgets.QLabel(" "))
self.widgets.append(None)
continue
elif label is None:
# Comment
self.formlayout.addRow(QtWidgets.QLabel(value))
self.widgets.append(None)
continue
elif tuple_to_qfont(value) is not None:
field = FontLayout(value, self)
elif (label.lower() not in BLACKLIST
and mcolors.is_color_like(value)):
field = ColorLayout(to_qcolor(value), self)
elif isinstance(value, str):
field = QtWidgets.QLineEdit(value, self)
elif isinstance(value, (list, tuple)):
if isinstance(value, tuple):
value = list(value)
# Note: get() below checks the type of value[0] in self.data so
# it is essential that value gets modified in-place.
# This means that the code is actually broken in the case where
# value is a tuple, but fortunately we always pass a list...
selindex = value.pop(0)
field = QtWidgets.QComboBox(self)
if isinstance(value[0], (list, tuple)):
keys = [key for key, _val in value]
value = [val for _key, val in value]
else:
keys = value
field.addItems(value)
if selindex in value:
selindex = value.index(selindex)
elif selindex in keys:
selindex = keys.index(selindex)
elif not isinstance(selindex, Integral):
_log.warning(
"index '%s' is invalid (label: %s, value: %s)",
selindex, label, value)
selindex = 0
field.setCurrentIndex(selindex)
elif isinstance(value, bool):
field = QtWidgets.QCheckBox(self)
field.setChecked(value)
elif isinstance(value, Integral):
field = QtWidgets.QSpinBox(self)
field.setRange(-10**9, 10**9)
field.setValue(value)
elif isinstance(value, Real):
field = QtWidgets.QLineEdit(repr(value), self)
field.setCursorPosition(0)
field.setValidator(QtGui.QDoubleValidator(field))
field.validator().setLocale(QtCore.QLocale("C"))
dialog = self.get_dialog()
dialog.register_float_field(field)
field.textChanged.connect(lambda text: dialog.update_buttons())
elif isinstance(value, datetime.datetime):
field = QtWidgets.QDateTimeEdit(self)
field.setDateTime(value)
elif isinstance(value, datetime.date):
field = QtWidgets.QDateEdit(self)
field.setDate(value)
else:
field = QtWidgets.QLineEdit(repr(value), self)
self.formlayout.addRow(label, field)
self.widgets.append(field)
def get(self):
valuelist = []
for index, (label, value) in enumerate(self.data):
field = self.widgets[index]
if label is None:
# Separator / Comment
continue
elif tuple_to_qfont(value) is not None:
value = field.get_font()
elif isinstance(value, str) or mcolors.is_color_like(value):
value = str(field.text())
elif isinstance(value, (list, tuple)):
index = int(field.currentIndex())
if isinstance(value[0], (list, tuple)):
value = value[index][0]
else:
value = value[index]
elif isinstance(value, bool):
value = field.isChecked()
elif isinstance(value, Integral):
value = int(field.value())
elif isinstance(value, Real):
value = float(str(field.text()))
elif isinstance(value, datetime.datetime):
datetime_ = field.dateTime()
if hasattr(datetime_, "toPyDateTime"):
value = datetime_.toPyDateTime()
else:
value = datetime_.toPython()
elif isinstance(value, datetime.date):
date_ = field.date()
if hasattr(date_, "toPyDate"):
value = date_.toPyDate()
else:
value = date_.toPython()
else:
value = literal_eval(str(field.text()))
valuelist.append(value)
return valuelist
class FormComboWidget(QtWidgets.QWidget):
update_buttons = QtCore.Signal()
def __init__(self, datalist, comment="", parent=None):
super().__init__(parent)
layout = QtWidgets.QVBoxLayout()
self.setLayout(layout)
self.combobox = QtWidgets.QComboBox()
layout.addWidget(self.combobox)
self.stackwidget = QtWidgets.QStackedWidget(self)
layout.addWidget(self.stackwidget)
self.combobox.currentIndexChanged.connect(
self.stackwidget.setCurrentIndex)
self.widgetlist = []
for data, title, comment in datalist:
self.combobox.addItem(title)
widget = FormWidget(data, comment=comment, parent=self)
self.stackwidget.addWidget(widget)
self.widgetlist.append(widget)
def setup(self):
for widget in self.widgetlist:
widget.setup()
def get(self):
return [widget.get() for widget in self.widgetlist]
class FormTabWidget(QtWidgets.QWidget):
update_buttons = QtCore.Signal()
def __init__(self, datalist, comment="", parent=None):
super().__init__(parent)
layout = QtWidgets.QVBoxLayout()
self.tabwidget = QtWidgets.QTabWidget()
layout.addWidget(self.tabwidget)
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
self.widgetlist = []
for data, title, comment in datalist:
if len(data[0]) == 3:
widget = FormComboWidget(data, comment=comment, parent=self)
else:
widget = FormWidget(data, with_margin=True, comment=comment,
parent=self)
index = self.tabwidget.addTab(widget, title)
self.tabwidget.setTabToolTip(index, comment)
self.widgetlist.append(widget)
def setup(self):
for widget in self.widgetlist:
widget.setup()
def get(self):
return [widget.get() for widget in self.widgetlist]
class FormDialog(QtWidgets.QDialog):
"""Form Dialog"""
def __init__(self, data, title="", comment="",
icon=None, parent=None, apply=None):
super().__init__(parent)
self.apply_callback = apply
# Form
if isinstance(data[0][0], (list, tuple)):
self.formwidget = FormTabWidget(data, comment=comment,
parent=self)
elif len(data[0]) == 3:
self.formwidget = FormComboWidget(data, comment=comment,
parent=self)
else:
self.formwidget = FormWidget(data, comment=comment,
parent=self)
layout = QtWidgets.QVBoxLayout()
layout.addWidget(self.formwidget)
self.float_fields = []
self.formwidget.setup()
# Button box
self.bbox = bbox = QtWidgets.QDialogButtonBox(
QtWidgets.QDialogButtonBox.StandardButton(
_to_int(QtWidgets.QDialogButtonBox.StandardButton.Ok) |
_to_int(QtWidgets.QDialogButtonBox.StandardButton.Cancel)
))
self.formwidget.update_buttons.connect(self.update_buttons)
if self.apply_callback is not None:
apply_btn = bbox.addButton(
QtWidgets.QDialogButtonBox.StandardButton.Apply)
apply_btn.clicked.connect(self.apply)
bbox.accepted.connect(self.accept)
bbox.rejected.connect(self.reject)
layout.addWidget(bbox)
self.setLayout(layout)
self.setWindowTitle(title)
if not isinstance(icon, QtGui.QIcon):
icon = QtWidgets.QWidget().style().standardIcon(
QtWidgets.QStyle.SP_MessageBoxQuestion)
self.setWindowIcon(icon)
def register_float_field(self, field):
self.float_fields.append(field)
def update_buttons(self):
valid = True
for field in self.float_fields:
if not is_edit_valid(field):
valid = False
for btn_type in ["Ok", "Apply"]:
btn = self.bbox.button(
getattr(QtWidgets.QDialogButtonBox.StandardButton,
btn_type))
if btn is not None:
btn.setEnabled(valid)
def accept(self):
self.data = self.formwidget.get()
self.apply_callback(self.data)
super().accept()
def reject(self):
self.data = None
super().reject()
def apply(self):
self.apply_callback(self.formwidget.get())
def get(self):
"""Return form result"""
return self.data
def fedit(data, title="", comment="", icon=None, parent=None, apply=None):
"""
Create form dialog
data: datalist, datagroup
title: str
comment: str
icon: QIcon instance
parent: parent QWidget
apply: apply callback (function)
datalist: list/tuple of (field_name, field_value)
datagroup: list/tuple of (datalist *or* datagroup, title, comment)
-> one field for each member of a datalist
-> one tab for each member of a top-level datagroup
-> one page (of a multipage widget, each page can be selected with a combo
box) for each member of a datagroup inside a datagroup
Supported types for field_value:
- int, float, str, bool
- colors: in Qt-compatible text form, i.e. in hex format or name
(red, ...) (automatically detected from a string)
- list/tuple:
* the first element will be the selected index (or value)
* the other elements can be couples (key, value) or only values
"""
# Create a QApplication instance if no instance currently exists
# (e.g., if the module is used directly from the interpreter)
if QtWidgets.QApplication.startingUp():
_app = QtWidgets.QApplication([])
dialog = FormDialog(data, title, comment, icon, parent, apply)
if parent is not None:
if hasattr(parent, "_fedit_dialog"):
parent._fedit_dialog.close()
parent._fedit_dialog = dialog
dialog.show()
if __name__ == "__main__":
_app = QtWidgets.QApplication([])
def create_datalist_example():
return [('str', 'this is a string'),
('list', [0, '1', '3', '4']),
('list2', ['--', ('none', 'None'), ('--', 'Dashed'),
('-.', 'DashDot'), ('-', 'Solid'),
('steps', 'Steps'), (':', 'Dotted')]),
('float', 1.2),
(None, 'Other:'),
('int', 12),
('font', ('Arial', 10, False, True)),
('color', '#123409'),
('bool', True),
('date', datetime.date(2010, 10, 10)),
('datetime', datetime.datetime(2010, 10, 10)),
]
def create_datagroup_example():
datalist = create_datalist_example()
return ((datalist, "Category 1", "Category 1 comment"),
(datalist, "Category 2", "Category 2 comment"),
(datalist, "Category 3", "Category 3 comment"))
# --------- datalist example
datalist = create_datalist_example()
def apply_test(data):
print("data:", data)
fedit(datalist, title="Example",
comment="This is just an <b>example</b>.",
apply=apply_test)
_app.exec()
# --------- datagroup example
datagroup = create_datagroup_example()
fedit(datagroup, "Global title",
apply=apply_test)
_app.exec()
# --------- datagroup inside a datagroup example
datalist = create_datalist_example()
datagroup = create_datagroup_example()
fedit(((datagroup, "Title 1", "Tab 1 comment"),
(datalist, "Title 2", "Tab 2 comment"),
(datalist, "Title 3", "Tab 3 comment")),
"Global title",
apply=apply_test)
_app.exec()

View file

@ -0,0 +1,271 @@
# Copyright © 2009 Pierre Raybaut
# Licensed under the terms of the MIT License
# see the Matplotlib licenses directory for a copy of the license
"""Module that provides a GUI-based editor for Matplotlib's figure options."""
from itertools import chain
from matplotlib import cbook, cm, colors as mcolors, markers, image as mimage
from matplotlib.backends.qt_compat import QtGui
from matplotlib.backends.qt_editor import _formlayout
from matplotlib.dates import DateConverter, num2date
LINESTYLES = {'-': 'Solid',
'--': 'Dashed',
'-.': 'DashDot',
':': 'Dotted',
'None': 'None',
}
DRAWSTYLES = {
'default': 'Default',
'steps-pre': 'Steps (Pre)', 'steps': 'Steps (Pre)',
'steps-mid': 'Steps (Mid)',
'steps-post': 'Steps (Post)'}
MARKERS = markers.MarkerStyle.markers
def figure_edit(axes, parent=None):
"""Edit matplotlib figure options"""
sep = (None, None) # separator
# Get / General
def convert_limits(lim, converter):
"""Convert axis limits for correct input editors."""
if isinstance(converter, DateConverter):
return map(num2date, lim)
# Cast to builtin floats as they have nicer reprs.
return map(float, lim)
axis_map = axes._axis_map
axis_limits = {
name: tuple(convert_limits(
getattr(axes, f'get_{name}lim')(), axis.get_converter()
))
for name, axis in axis_map.items()
}
general = [
('Title', axes.get_title()),
sep,
*chain.from_iterable([
(
(None, f"<b>{name.title()}-Axis</b>"),
('Min', axis_limits[name][0]),
('Max', axis_limits[name][1]),
('Label', axis.label.get_text()),
('Scale', [axis.get_scale(),
'linear', 'log', 'symlog', 'logit']),
sep,
)
for name, axis in axis_map.items()
]),
('(Re-)Generate automatic legend', False),
]
# Save the converter and unit data
axis_converter = {
name: axis.get_converter()
for name, axis in axis_map.items()
}
axis_units = {
name: axis.get_units()
for name, axis in axis_map.items()
}
# Get / Curves
labeled_lines = []
for line in axes.get_lines():
label = line.get_label()
if label == '_nolegend_':
continue
labeled_lines.append((label, line))
curves = []
def prepare_data(d, init):
"""
Prepare entry for FormLayout.
*d* is a mapping of shorthands to style names (a single style may
have multiple shorthands, in particular the shorthands `None`,
`"None"`, `"none"` and `""` are synonyms); *init* is one shorthand
of the initial style.
This function returns an list suitable for initializing a
FormLayout combobox, namely `[initial_name, (shorthand,
style_name), (shorthand, style_name), ...]`.
"""
if init not in d:
d = {**d, init: str(init)}
# Drop duplicate shorthands from dict (by overwriting them during
# the dict comprehension).
name2short = {name: short for short, name in d.items()}
# Convert back to {shorthand: name}.
short2name = {short: name for name, short in name2short.items()}
# Find the kept shorthand for the style specified by init.
canonical_init = name2short[d[init]]
# Sort by representation and prepend the initial value.
return ([canonical_init] +
sorted(short2name.items(),
key=lambda short_and_name: short_and_name[1]))
for label, line in labeled_lines:
color = mcolors.to_hex(
mcolors.to_rgba(line.get_color(), line.get_alpha()),
keep_alpha=True)
ec = mcolors.to_hex(
mcolors.to_rgba(line.get_markeredgecolor(), line.get_alpha()),
keep_alpha=True)
fc = mcolors.to_hex(
mcolors.to_rgba(line.get_markerfacecolor(), line.get_alpha()),
keep_alpha=True)
curvedata = [
('Label', label),
sep,
(None, '<b>Line</b>'),
('Line style', prepare_data(LINESTYLES, line.get_linestyle())),
('Draw style', prepare_data(DRAWSTYLES, line.get_drawstyle())),
('Width', line.get_linewidth()),
('Color (RGBA)', color),
sep,
(None, '<b>Marker</b>'),
('Style', prepare_data(MARKERS, line.get_marker())),
('Size', line.get_markersize()),
('Face color (RGBA)', fc),
('Edge color (RGBA)', ec)]
curves.append([curvedata, label, ""])
# Is there a curve displayed?
has_curve = bool(curves)
# Get ScalarMappables.
labeled_mappables = []
for mappable in [*axes.images, *axes.collections]:
label = mappable.get_label()
if label == '_nolegend_' or mappable.get_array() is None:
continue
labeled_mappables.append((label, mappable))
mappables = []
cmaps = [(cmap, name) for name, cmap in sorted(cm._colormaps.items())]
for label, mappable in labeled_mappables:
cmap = mappable.get_cmap()
if cmap.name not in cm._colormaps:
cmaps = [(cmap, cmap.name), *cmaps]
low, high = mappable.get_clim()
mappabledata = [
('Label', label),
('Colormap', [cmap.name] + cmaps),
('Min. value', low),
('Max. value', high),
]
if hasattr(mappable, "get_interpolation"): # Images.
interpolations = [
(name, name) for name in sorted(mimage.interpolations_names)]
mappabledata.append((
'Interpolation',
[mappable.get_interpolation(), *interpolations]))
interpolation_stages = ['data', 'rgba', 'auto']
mappabledata.append((
'Interpolation stage',
[mappable.get_interpolation_stage(), *interpolation_stages]))
mappables.append([mappabledata, label, ""])
# Is there a scalarmappable displayed?
has_sm = bool(mappables)
datalist = [(general, "Axes", "")]
if curves:
datalist.append((curves, "Curves", ""))
if mappables:
datalist.append((mappables, "Images, etc.", ""))
def apply_callback(data):
"""A callback to apply changes."""
orig_limits = {
name: getattr(axes, f"get_{name}lim")()
for name in axis_map
}
general = data.pop(0)
curves = data.pop(0) if has_curve else []
mappables = data.pop(0) if has_sm else []
if data:
raise ValueError("Unexpected field")
title = general.pop(0)
axes.set_title(title)
generate_legend = general.pop()
for i, (name, axis) in enumerate(axis_map.items()):
axis_min = general[4*i]
axis_max = general[4*i + 1]
axis_label = general[4*i + 2]
axis_scale = general[4*i + 3]
if axis.get_scale() != axis_scale:
getattr(axes, f"set_{name}scale")(axis_scale)
axis._set_lim(axis_min, axis_max, auto=False)
axis.set_label_text(axis_label)
# Restore the unit data
axis._set_converter(axis_converter[name])
axis.set_units(axis_units[name])
# Set / Curves
for index, curve in enumerate(curves):
line = labeled_lines[index][1]
(label, linestyle, drawstyle, linewidth, color, marker, markersize,
markerfacecolor, markeredgecolor) = curve
line.set_label(label)
line.set_linestyle(linestyle)
line.set_drawstyle(drawstyle)
line.set_linewidth(linewidth)
rgba = mcolors.to_rgba(color)
line.set_alpha(None)
line.set_color(rgba)
if marker != 'none':
line.set_marker(marker)
line.set_markersize(markersize)
line.set_markerfacecolor(markerfacecolor)
line.set_markeredgecolor(markeredgecolor)
# Set ScalarMappables.
for index, mappable_settings in enumerate(mappables):
mappable = labeled_mappables[index][1]
if len(mappable_settings) == 6:
label, cmap, low, high, interpolation, interpolation_stage = \
mappable_settings
mappable.set_interpolation(interpolation)
mappable.set_interpolation_stage(interpolation_stage)
elif len(mappable_settings) == 4:
label, cmap, low, high = mappable_settings
mappable.set_label(label)
mappable.set_cmap(cmap)
mappable.set_clim(*sorted([low, high]))
# re-generate legend, if checkbox is checked
if generate_legend:
draggable = None
ncols = 1
if axes.legend_ is not None:
old_legend = axes.get_legend()
draggable = old_legend._draggable is not None
ncols = old_legend._ncols
new_legend = axes.legend(ncols=ncols)
if new_legend:
new_legend.set_draggable(draggable)
# Redraw
figure = axes.get_figure()
figure.canvas.draw()
for name in axis_map:
if getattr(axes, f"get_{name}lim")() != orig_limits[name]:
figure.canvas.toolbar.push_current()
break
_formlayout.fedit(
datalist, title="Figure options", parent=parent,
icon=QtGui.QIcon(
str(cbook._get_data_path('images', 'qt4_editor_options.svg'))),
apply=apply_callback)

View file

@ -0,0 +1,414 @@
from enum import Enum
import importlib
class BackendFilter(Enum):
"""
Filter used with :meth:`~matplotlib.backends.registry.BackendRegistry.list_builtin`
.. versionadded:: 3.9
"""
INTERACTIVE = 0
NON_INTERACTIVE = 1
class BackendRegistry:
"""
Registry of backends available within Matplotlib.
This is the single source of truth for available backends.
All use of ``BackendRegistry`` should be via the singleton instance
``backend_registry`` which can be imported from ``matplotlib.backends``.
Each backend has a name, a module name containing the backend code, and an
optional GUI framework that must be running if the backend is interactive.
There are three sources of backends: built-in (source code is within the
Matplotlib repository), explicit ``module://some.backend`` syntax (backend is
obtained by loading the module), or via an entry point (self-registering
backend in an external package).
.. versionadded:: 3.9
"""
# Mapping of built-in backend name to GUI framework, or "headless" for no
# GUI framework. Built-in backends are those which are included in the
# Matplotlib repo. A backend with name 'name' is located in the module
# f"matplotlib.backends.backend_{name.lower()}"
_BUILTIN_BACKEND_TO_GUI_FRAMEWORK = {
"gtk3agg": "gtk3",
"gtk3cairo": "gtk3",
"gtk4agg": "gtk4",
"gtk4cairo": "gtk4",
"macosx": "macosx",
"nbagg": "nbagg",
"notebook": "nbagg",
"qtagg": "qt",
"qtcairo": "qt",
"qt5agg": "qt5",
"qt5cairo": "qt5",
"tkagg": "tk",
"tkcairo": "tk",
"webagg": "webagg",
"wx": "wx",
"wxagg": "wx",
"wxcairo": "wx",
"agg": "headless",
"cairo": "headless",
"pdf": "headless",
"pgf": "headless",
"ps": "headless",
"svg": "headless",
"template": "headless",
}
# Reverse mapping of gui framework to preferred built-in backend.
_GUI_FRAMEWORK_TO_BACKEND = {
"gtk3": "gtk3agg",
"gtk4": "gtk4agg",
"headless": "agg",
"macosx": "macosx",
"qt": "qtagg",
"qt5": "qt5agg",
"qt6": "qtagg",
"tk": "tkagg",
"wx": "wxagg",
}
def __init__(self):
# Only load entry points when first needed.
self._loaded_entry_points = False
# Mapping of non-built-in backend to GUI framework, added dynamically from
# entry points and from matplotlib.use("module://some.backend") format.
# New entries have an "unknown" GUI framework that is determined when first
# needed by calling _get_gui_framework_by_loading.
self._backend_to_gui_framework = {}
# Mapping of backend name to module name, where different from
# f"matplotlib.backends.backend_{backend_name.lower()}". These are either
# hardcoded for backward compatibility, or loaded from entry points or
# "module://some.backend" syntax.
self._name_to_module = {
"notebook": "nbagg",
}
def _backend_module_name(self, backend):
if backend.startswith("module://"):
return backend[9:]
# Return name of module containing the specified backend.
# Does not check if the backend is valid, use is_valid_backend for that.
backend = backend.lower()
# Check if have specific name to module mapping.
backend = self._name_to_module.get(backend, backend)
return (backend[9:] if backend.startswith("module://")
else f"matplotlib.backends.backend_{backend}")
def _clear(self):
# Clear all dynamically-added data, used for testing only.
self.__init__()
def _ensure_entry_points_loaded(self):
# Load entry points, if they have not already been loaded.
if not self._loaded_entry_points:
entries = self._read_entry_points()
self._validate_and_store_entry_points(entries)
self._loaded_entry_points = True
def _get_gui_framework_by_loading(self, backend):
# Determine GUI framework for a backend by loading its module and reading the
# FigureCanvas.required_interactive_framework attribute.
# Returns "headless" if there is no GUI framework.
module = self.load_backend_module(backend)
canvas_class = module.FigureCanvas
return canvas_class.required_interactive_framework or "headless"
def _read_entry_points(self):
# Read entry points of modules that self-advertise as Matplotlib backends.
# Expects entry points like this one from matplotlib-inline (in pyproject.toml
# format):
# [project.entry-points."matplotlib.backend"]
# inline = "matplotlib_inline.backend_inline"
import importlib.metadata as im
entry_points = im.entry_points(group="matplotlib.backend")
entries = [(entry.name, entry.value) for entry in entry_points]
# For backward compatibility, if matplotlib-inline and/or ipympl are installed
# but too old to include entry points, create them. Do not import ipympl
# directly as this calls matplotlib.use() whilst in this function.
def backward_compatible_entry_points(
entries, module_name, threshold_version, names, target):
from matplotlib import _parse_to_version_info
try:
module_version = im.version(module_name)
if _parse_to_version_info(module_version) < threshold_version:
for name in names:
entries.append((name, target))
except im.PackageNotFoundError:
pass
names = [entry[0] for entry in entries]
if "inline" not in names:
backward_compatible_entry_points(
entries, "matplotlib_inline", (0, 1, 7), ["inline"],
"matplotlib_inline.backend_inline")
if "ipympl" not in names:
backward_compatible_entry_points(
entries, "ipympl", (0, 9, 4), ["ipympl", "widget"],
"ipympl.backend_nbagg")
return entries
def _validate_and_store_entry_points(self, entries):
# Validate and store entry points so that they can be used via matplotlib.use()
# in the normal manner. Entry point names cannot be of module:// format, cannot
# shadow a built-in backend name, and there cannot be multiple entry points
# with the same name but different modules. Multiple entry points with the same
# name and value are permitted (it can sometimes happen outside of our control,
# see https://github.com/matplotlib/matplotlib/issues/28367).
for name, module in set(entries):
name = name.lower()
if name.startswith("module://"):
raise RuntimeError(
f"Entry point name '{name}' cannot start with 'module://'")
if name in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK:
raise RuntimeError(f"Entry point name '{name}' is a built-in backend")
if name in self._backend_to_gui_framework:
raise RuntimeError(f"Entry point name '{name}' duplicated")
self._name_to_module[name] = "module://" + module
# Do not yet know backend GUI framework, determine it only when necessary.
self._backend_to_gui_framework[name] = "unknown"
def backend_for_gui_framework(self, framework):
"""
Return the name of the backend corresponding to the specified GUI framework.
Parameters
----------
framework : str
GUI framework such as "qt".
Returns
-------
str or None
Backend name or None if GUI framework not recognised.
"""
return self._GUI_FRAMEWORK_TO_BACKEND.get(framework.lower())
def is_valid_backend(self, backend):
"""
Return True if the backend name is valid, False otherwise.
A backend name is valid if it is one of the built-in backends or has been
dynamically added via an entry point. Those beginning with ``module://`` are
always considered valid and are added to the current list of all backends
within this function.
Even if a name is valid, it may not be importable or usable. This can only be
determined by loading and using the backend module.
Parameters
----------
backend : str
Name of backend.
Returns
-------
bool
True if backend is valid, False otherwise.
"""
if not backend.startswith("module://"):
backend = backend.lower()
# For backward compatibility, convert ipympl and matplotlib-inline long
# module:// names to their shortened forms.
backwards_compat = {
"module://ipympl.backend_nbagg": "widget",
"module://matplotlib_inline.backend_inline": "inline",
}
backend = backwards_compat.get(backend, backend)
if (backend in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK or
backend in self._backend_to_gui_framework):
return True
if backend.startswith("module://"):
self._backend_to_gui_framework[backend] = "unknown"
return True
# Only load entry points if really need to and not already done so.
self._ensure_entry_points_loaded()
if backend in self._backend_to_gui_framework:
return True
return False
def list_all(self):
"""
Return list of all known backends.
These include built-in backends and those obtained at runtime either from entry
points or explicit ``module://some.backend`` syntax.
Entry points will be loaded if they haven't been already.
Returns
-------
list of str
Backend names.
"""
self._ensure_entry_points_loaded()
return [*self.list_builtin(), *self._backend_to_gui_framework]
def list_builtin(self, filter_=None):
"""
Return list of backends that are built into Matplotlib.
Parameters
----------
filter_ : `~.BackendFilter`, optional
Filter to apply to returned backends. For example, to return only
non-interactive backends use `.BackendFilter.NON_INTERACTIVE`.
Returns
-------
list of str
Backend names.
"""
if filter_ == BackendFilter.INTERACTIVE:
return [k for k, v in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.items()
if v != "headless"]
elif filter_ == BackendFilter.NON_INTERACTIVE:
return [k for k, v in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.items()
if v == "headless"]
return [*self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK]
def list_gui_frameworks(self):
"""
Return list of GUI frameworks used by Matplotlib backends.
Returns
-------
list of str
GUI framework names.
"""
return [k for k in self._GUI_FRAMEWORK_TO_BACKEND if k != "headless"]
def load_backend_module(self, backend):
"""
Load and return the module containing the specified backend.
Parameters
----------
backend : str
Name of backend to load.
Returns
-------
Module
Module containing backend.
"""
module_name = self._backend_module_name(backend)
return importlib.import_module(module_name)
def resolve_backend(self, backend):
"""
Return the backend and GUI framework for the specified backend name.
If the GUI framework is not yet known then it will be determined by loading the
backend module and checking the ``FigureCanvas.required_interactive_framework``
attribute.
This function only loads entry points if they have not already been loaded and
the backend is not built-in and not of ``module://some.backend`` format.
Parameters
----------
backend : str or None
Name of backend, or None to use the default backend.
Returns
-------
backend : str
The backend name.
framework : str or None
The GUI framework, which will be None for a backend that is non-interactive.
"""
if isinstance(backend, str):
if not backend.startswith("module://"):
backend = backend.lower()
else: # Might be _auto_backend_sentinel or None
# Use whatever is already running...
from matplotlib import get_backend
backend = get_backend()
# Is backend already known (built-in or dynamically loaded)?
gui = (self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.get(backend) or
self._backend_to_gui_framework.get(backend))
# Is backend "module://something"?
if gui is None and isinstance(backend, str) and backend.startswith("module://"):
gui = "unknown"
# Is backend a possible entry point?
if gui is None and not self._loaded_entry_points:
self._ensure_entry_points_loaded()
gui = self._backend_to_gui_framework.get(backend)
# Backend known but not its gui framework.
if gui == "unknown":
gui = self._get_gui_framework_by_loading(backend)
self._backend_to_gui_framework[backend] = gui
if gui is None:
raise RuntimeError(f"'{backend}' is not a recognised backend name")
return backend, gui if gui != "headless" else None
def resolve_gui_or_backend(self, gui_or_backend):
"""
Return the backend and GUI framework for the specified string that may be
either a GUI framework or a backend name, tested in that order.
This is for use with the IPython %matplotlib magic command which may be a GUI
framework such as ``%matplotlib qt`` or a backend name such as
``%matplotlib qtagg``.
This function only loads entry points if they have not already been loaded and
the backend is not built-in and not of ``module://some.backend`` format.
Parameters
----------
gui_or_backend : str or None
Name of GUI framework or backend, or None to use the default backend.
Returns
-------
backend : str
The backend name.
framework : str or None
The GUI framework, which will be None for a backend that is non-interactive.
"""
if not gui_or_backend.startswith("module://"):
gui_or_backend = gui_or_backend.lower()
# First check if it is a gui loop name.
backend = self.backend_for_gui_framework(gui_or_backend)
if backend is not None:
return backend, gui_or_backend if gui_or_backend != "headless" else None
# Then check if it is a backend name.
try:
return self.resolve_backend(gui_or_backend)
except Exception: # KeyError ?
raise RuntimeError(
f"'{gui_or_backend}' is not a recognised GUI loop or backend name")
# Singleton
backend_registry = BackendRegistry()

View file

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="{{ prefix }}/_static/css/page.css" type="text/css">
<link rel="stylesheet" href="{{ prefix }}/_static/css/boilerplate.css" type="text/css">
<link rel="stylesheet" href="{{ prefix }}/_static/css/fbm.css" type="text/css">
<link rel="stylesheet" href="{{ prefix }}/_static/css/mpl.css" type="text/css">
<script src="{{ prefix }}/_static/js/mpl_tornado.js"></script>
<script src="{{ prefix }}/js/mpl.js"></script>
<script>
function ready(fn) {
if (document.readyState != "loading") {
fn();
} else {
document.addEventListener("DOMContentLoaded", fn);
}
}
function figure_ready(fig_id) {
return function () {
var main_div = document.querySelector("div#figures");
var figure_div = document.createElement("div");
figure_div.id = "figure-div";
main_div.appendChild(figure_div);
var websocket_type = mpl.get_websocket_type();
var uri = "{{ ws_uri }}" + fig_id + "/ws";
if (window.location.protocol === "https:") uri = uri.replace('ws:', 'wss:')
var websocket = new websocket_type(uri);
var fig = new mpl.figure(fig_id, websocket, mpl_ondownload, figure_div);
fig.focus_on_mouseover = true;
fig.canvas.setAttribute("tabindex", fig_id);
}
};
{% for (fig_id, fig_manager) in figures %}
ready(figure_ready({{ str(fig_id) }}));
{% end %}
</script>
<title>MPL | WebAgg current figures</title>
</head>
<body>
<div id="mpl-warnings" class="mpl-warnings"></div>
<div id="figures" style="margin: 10px 10px;"></div>
</body>
</html>

View file

@ -0,0 +1,77 @@
/**
* HTML5 Boilerplate
*
* style.css contains a reset, font normalization and some base styles.
*
* Credit is left where credit is due.
* Much inspiration was taken from these projects:
* - yui.yahooapis.com/2.8.1/build/base/base.css
* - camendesign.com/design/
* - praegnanz.de/weblog/htmlcssjs-kickstart
*/
/**
* html5doctor.com Reset Stylesheet (Eric Meyer's Reset Reloaded + HTML5 baseline)
* v1.6.1 2010-09-17 | Authors: Eric Meyer & Richard Clark
* html5doctor.com/html-5-reset-stylesheet/
*/
html, body, div, span, object, iframe,
h1, h2, h3, h4, h5, h6, p, blockquote, pre,
abbr, address, cite, code, del, dfn, em, img, ins, kbd, q, samp,
small, strong, sub, sup, var, b, i, dl, dt, dd, ol, ul, li,
fieldset, form, label, legend,
table, caption, tbody, tfoot, thead, tr, th, td,
article, aside, canvas, details, figcaption, figure,
footer, header, hgroup, menu, nav, section, summary,
time, mark, audio, video {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font: inherit;
vertical-align: baseline;
}
sup { vertical-align: super; }
sub { vertical-align: sub; }
article, aside, details, figcaption, figure,
footer, header, hgroup, menu, nav, section {
display: block;
}
blockquote, q { quotes: none; }
blockquote:before, blockquote:after,
q:before, q:after { content: ""; content: none; }
ins { background-color: #ff9; color: #000; text-decoration: none; }
mark { background-color: #ff9; color: #000; font-style: italic; font-weight: bold; }
del { text-decoration: line-through; }
abbr[title], dfn[title] { border-bottom: 1px dotted; cursor: help; }
table { border-collapse: collapse; border-spacing: 0; }
hr { display: block; height: 1px; border: 0; border-top: 1px solid #ccc; margin: 1em 0; padding: 0; }
input, select { vertical-align: middle; }
/**
* Font normalization inspired by YUI Library's fonts.css: developer.yahoo.com/yui/
*/
body { font:13px/1.231 sans-serif; *font-size:small; } /* Hack retained to preserve specificity */
select, input, textarea, button { font:99% sans-serif; }
/* Normalize monospace sizing:
en.wikipedia.org/wiki/MediaWiki_talk:Common.css/Archive_11#Teletype_style_fix_for_Chrome */
pre, code, kbd, samp { font-family: monospace, sans-serif; }
em,i { font-style: italic; }
b,strong { font-weight: bold; }

View file

@ -0,0 +1,97 @@
/* Flexible box model classes */
/* Taken from Alex Russell https://infrequently.org/2009/08/css-3-progress/ */
.hbox {
display: -webkit-box;
-webkit-box-orient: horizontal;
-webkit-box-align: stretch;
display: -moz-box;
-moz-box-orient: horizontal;
-moz-box-align: stretch;
display: box;
box-orient: horizontal;
box-align: stretch;
}
.hbox > * {
-webkit-box-flex: 0;
-moz-box-flex: 0;
box-flex: 0;
}
.vbox {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-box-align: stretch;
display: -moz-box;
-moz-box-orient: vertical;
-moz-box-align: stretch;
display: box;
box-orient: vertical;
box-align: stretch;
}
.vbox > * {
-webkit-box-flex: 0;
-moz-box-flex: 0;
box-flex: 0;
}
.reverse {
-webkit-box-direction: reverse;
-moz-box-direction: reverse;
box-direction: reverse;
}
.box-flex0 {
-webkit-box-flex: 0;
-moz-box-flex: 0;
box-flex: 0;
}
.box-flex1, .box-flex {
-webkit-box-flex: 1;
-moz-box-flex: 1;
box-flex: 1;
}
.box-flex2 {
-webkit-box-flex: 2;
-moz-box-flex: 2;
box-flex: 2;
}
.box-group1 {
-webkit-box-flex-group: 1;
-moz-box-flex-group: 1;
box-flex-group: 1;
}
.box-group2 {
-webkit-box-flex-group: 2;
-moz-box-flex-group: 2;
box-flex-group: 2;
}
.start {
-webkit-box-pack: start;
-moz-box-pack: start;
box-pack: start;
}
.end {
-webkit-box-pack: end;
-moz-box-pack: end;
box-pack: end;
}
.center {
-webkit-box-pack: center;
-moz-box-pack: center;
box-pack: center;
}

View file

@ -0,0 +1,84 @@
/* General styling */
.ui-helper-clearfix:before,
.ui-helper-clearfix:after {
content: "";
display: table;
border-collapse: collapse;
}
.ui-helper-clearfix:after {
clear: both;
}
/* Header */
.ui-widget-header {
border: 1px solid #dddddd;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
background: #e9e9e9;
color: #333333;
font-weight: bold;
}
/* Toolbar and items */
.mpl-toolbar {
width: 100%;
}
.mpl-toolbar div.mpl-button-group {
display: inline-block;
}
.mpl-button-group + .mpl-button-group {
margin-left: 0.5em;
}
.mpl-widget {
background-color: #fff;
border: 1px solid #ccc;
display: inline-block;
cursor: pointer;
color: #333;
padding: 6px;
vertical-align: middle;
}
.mpl-widget:disabled,
.mpl-widget[disabled] {
background-color: #ddd;
border-color: #ddd !important;
cursor: not-allowed;
}
.mpl-widget:disabled img,
.mpl-widget[disabled] img {
/* Convert black to grey */
filter: contrast(0%);
}
.mpl-widget.active img {
/* Convert black to tab:blue, approximately */
filter: invert(34%) sepia(97%) saturate(468%) hue-rotate(162deg) brightness(96%) contrast(91%);
}
button.mpl-widget:focus,
button.mpl-widget:hover {
background-color: #ddd;
border-color: #aaa;
}
.mpl-button-group button.mpl-widget {
margin-left: -1px;
}
.mpl-button-group button.mpl-widget:first-child {
border-top-left-radius: 6px;
border-bottom-left-radius: 6px;
margin-left: 0px;
}
.mpl-button-group button.mpl-widget:last-child {
border-top-right-radius: 6px;
border-bottom-right-radius: 6px;
}
select.mpl-widget {
cursor: default;
}

View file

@ -0,0 +1,82 @@
/**
* Primary styles
*
* Author: IPython Development Team
*/
body {
background-color: white;
/* This makes sure that the body covers the entire window and needs to
be in a different element than the display: box in wrapper below */
position: absolute;
left: 0px;
right: 0px;
top: 0px;
bottom: 0px;
overflow: visible;
}
div#header {
/* Initially hidden to prevent FLOUC */
display: none;
position: relative;
height: 40px;
padding: 5px;
margin: 0px;
width: 100%;
}
span#ipython_notebook {
position: absolute;
padding: 2px 2px 2px 5px;
}
span#ipython_notebook img {
font-family: Verdana, "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif;
height: 24px;
text-decoration:none;
display: inline;
color: black;
}
#site {
width: 100%;
display: none;
}
/* We set the fonts by hand here to override the values in the theme */
.ui-widget {
font-family: "Lucinda Grande", "Lucinda Sans Unicode", Helvetica, Arial, Verdana, sans-serif;
}
.ui-widget input, .ui-widget select, .ui-widget textarea, .ui-widget button {
font-family: "Lucinda Grande", "Lucinda Sans Unicode", Helvetica, Arial, Verdana, sans-serif;
}
/* Smaller buttons */
.ui-button .ui-button-text {
padding: 0.2em 0.8em;
font-size: 77%;
}
input.ui-button {
padding: 0.3em 0.9em;
}
span#login_widget {
float: right;
}
.border-box-sizing {
box-sizing: border-box;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
}
#figure-div {
display: inline-block;
margin: 10px;
vertical-align: top;
}

View file

@ -0,0 +1,34 @@
<!-- Within the kernel, we don't know the address of the matplotlib
websocket server, so we have to get in client-side and fetch our
resources that way. -->
<script>
// We can't proceed until these JavaScript files are fetched, so
// we fetch them synchronously
$.ajaxSetup({async: false});
$.getScript("http://" + window.location.hostname + ":{{ port }}{{prefix}}/_static/js/mpl_tornado.js");
$.getScript("http://" + window.location.hostname + ":{{ port }}{{prefix}}/js/mpl.js");
$.ajaxSetup({async: true});
function init_figure{{ fig_id }}(e) {
$('div.output').off('resize');
var output_div = e.target.querySelector('div.output_subarea');
var websocket_type = mpl.get_websocket_type();
var websocket = new websocket_type(
"ws://" + window.location.hostname + ":{{ port }}{{ prefix}}/" +
{{ repr(str(fig_id)) }} + "/ws");
var fig = new mpl.figure(
{{repr(str(fig_id))}}, websocket, mpl_ondownload, output_div);
// Fetch the first image
fig.context.drawImage(fig.imageObj, 0, 0);
fig.focus_on_mouseover = true;
}
// We can't initialize the figure contents until our content
// has been added to the DOM. This is a bit of hack to get an
// event for that.
$('div.output').resize(init_figure{{ fig_id }});
</script>

View file

@ -0,0 +1,707 @@
/* Put everything inside the global mpl namespace */
/* global mpl */
window.mpl = {};
mpl.get_websocket_type = function () {
if (typeof WebSocket !== 'undefined') {
return WebSocket;
} else if (typeof MozWebSocket !== 'undefined') {
return MozWebSocket;
} else {
alert(
'Your browser does not have WebSocket support. ' +
'Please try Chrome, Safari or Firefox ≥ 6. ' +
'Firefox 4 and 5 are also supported but you ' +
'have to enable WebSockets in about:config.'
);
}
};
mpl.figure = function (figure_id, websocket, ondownload, parent_element) {
this.id = figure_id;
this.ws = websocket;
this.supports_binary = this.ws.binaryType !== undefined;
if (!this.supports_binary) {
var warnings = document.getElementById('mpl-warnings');
if (warnings) {
warnings.style.display = 'block';
warnings.textContent =
'This browser does not support binary websocket messages. ' +
'Performance may be slow.';
}
}
this.imageObj = new Image();
this.context = undefined;
this.message = undefined;
this.canvas = undefined;
this.rubberband_canvas = undefined;
this.rubberband_context = undefined;
this.format_dropdown = undefined;
this.image_mode = 'full';
this.root = document.createElement('div');
this.root.setAttribute('style', 'display: inline-block');
this._root_extra_style(this.root);
parent_element.appendChild(this.root);
this._init_header(this);
this._init_canvas(this);
this._init_toolbar(this);
var fig = this;
this.waiting = false;
this.ws.onopen = function () {
fig.send_message('supports_binary', { value: fig.supports_binary });
fig.send_message('send_image_mode', {});
if (fig.ratio !== 1) {
fig.send_message('set_device_pixel_ratio', {
device_pixel_ratio: fig.ratio,
});
}
fig.send_message('refresh', {});
};
this.imageObj.onload = function () {
if (fig.image_mode === 'full') {
// Full images could contain transparency (where diff images
// almost always do), so we need to clear the canvas so that
// there is no ghosting.
fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);
}
fig.context.drawImage(fig.imageObj, 0, 0);
};
this.imageObj.onunload = function () {
fig.ws.close();
};
this.ws.onmessage = this._make_on_message_function(this);
this.ondownload = ondownload;
};
mpl.figure.prototype._init_header = function () {
var titlebar = document.createElement('div');
titlebar.classList =
'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix';
var titletext = document.createElement('div');
titletext.classList = 'ui-dialog-title';
titletext.setAttribute(
'style',
'width: 100%; text-align: center; padding: 3px;'
);
titlebar.appendChild(titletext);
this.root.appendChild(titlebar);
this.header = titletext;
};
mpl.figure.prototype._canvas_extra_style = function (_canvas_div) {};
mpl.figure.prototype._root_extra_style = function (_canvas_div) {};
mpl.figure.prototype._init_canvas = function () {
var fig = this;
var canvas_div = (this.canvas_div = document.createElement('div'));
canvas_div.setAttribute('tabindex', '0');
canvas_div.setAttribute(
'style',
'border: 1px solid #ddd;' +
'box-sizing: content-box;' +
'clear: both;' +
'min-height: 1px;' +
'min-width: 1px;' +
'outline: 0;' +
'overflow: hidden;' +
'position: relative;' +
'resize: both;' +
'z-index: 2;'
);
function on_keyboard_event_closure(name) {
return function (event) {
return fig.key_event(event, name);
};
}
canvas_div.addEventListener(
'keydown',
on_keyboard_event_closure('key_press')
);
canvas_div.addEventListener(
'keyup',
on_keyboard_event_closure('key_release')
);
this._canvas_extra_style(canvas_div);
this.root.appendChild(canvas_div);
var canvas = (this.canvas = document.createElement('canvas'));
canvas.classList.add('mpl-canvas');
canvas.setAttribute(
'style',
'box-sizing: content-box;' +
'pointer-events: none;' +
'position: relative;' +
'z-index: 0;'
);
this.context = canvas.getContext('2d');
var backingStore =
this.context.backingStorePixelRatio ||
this.context.webkitBackingStorePixelRatio ||
this.context.mozBackingStorePixelRatio ||
this.context.msBackingStorePixelRatio ||
this.context.oBackingStorePixelRatio ||
this.context.backingStorePixelRatio ||
1;
this.ratio = (window.devicePixelRatio || 1) / backingStore;
var rubberband_canvas = (this.rubberband_canvas = document.createElement(
'canvas'
));
rubberband_canvas.setAttribute(
'style',
'box-sizing: content-box;' +
'left: 0;' +
'pointer-events: none;' +
'position: absolute;' +
'top: 0;' +
'z-index: 1;'
);
// Apply a ponyfill if ResizeObserver is not implemented by browser.
if (this.ResizeObserver === undefined) {
if (window.ResizeObserver !== undefined) {
this.ResizeObserver = window.ResizeObserver;
} else {
var obs = _JSXTOOLS_RESIZE_OBSERVER({});
this.ResizeObserver = obs.ResizeObserver;
}
}
this.resizeObserverInstance = new this.ResizeObserver(function (entries) {
// There's no need to resize if the WebSocket is not connected:
// - If it is still connecting, then we will get an initial resize from
// Python once it connects.
// - If it has disconnected, then resizing will clear the canvas and
// never get anything back to refill it, so better to not resize and
// keep something visible.
if (fig.ws.readyState != 1) {
return;
}
var nentries = entries.length;
for (var i = 0; i < nentries; i++) {
var entry = entries[i];
var width, height;
if (entry.contentBoxSize) {
if (entry.contentBoxSize instanceof Array) {
// Chrome 84 implements new version of spec.
width = entry.contentBoxSize[0].inlineSize;
height = entry.contentBoxSize[0].blockSize;
} else {
// Firefox implements old version of spec.
width = entry.contentBoxSize.inlineSize;
height = entry.contentBoxSize.blockSize;
}
} else {
// Chrome <84 implements even older version of spec.
width = entry.contentRect.width;
height = entry.contentRect.height;
}
// Keep the size of the canvas and rubber band canvas in sync with
// the canvas container.
if (entry.devicePixelContentBoxSize) {
// Chrome 84 implements new version of spec.
canvas.setAttribute(
'width',
entry.devicePixelContentBoxSize[0].inlineSize
);
canvas.setAttribute(
'height',
entry.devicePixelContentBoxSize[0].blockSize
);
} else {
canvas.setAttribute('width', width * fig.ratio);
canvas.setAttribute('height', height * fig.ratio);
}
/* This rescales the canvas back to display pixels, so that it
* appears correct on HiDPI screens. */
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
rubberband_canvas.setAttribute('width', width);
rubberband_canvas.setAttribute('height', height);
// And update the size in Python. We ignore the initial 0/0 size
// that occurs as the element is placed into the DOM, which should
// otherwise not happen due to the minimum size styling.
if (width != 0 && height != 0) {
fig.request_resize(width, height);
}
}
});
this.resizeObserverInstance.observe(canvas_div);
function on_mouse_event_closure(name) {
/* User Agent sniffing is bad, but WebKit is busted:
* https://bugs.webkit.org/show_bug.cgi?id=144526
* https://bugs.webkit.org/show_bug.cgi?id=181818
* The worst that happens here is that they get an extra browser
* selection when dragging, if this check fails to catch them.
*/
var UA = navigator.userAgent;
var isWebKit = /AppleWebKit/.test(UA) && !/Chrome/.test(UA);
if(isWebKit) {
return function (event) {
/* This prevents the web browser from automatically changing to
* the text insertion cursor when the button is pressed. We
* want to control all of the cursor setting manually through
* the 'cursor' event from matplotlib */
event.preventDefault()
return fig.mouse_event(event, name);
};
} else {
return function (event) {
return fig.mouse_event(event, name);
};
}
}
canvas_div.addEventListener(
'mousedown',
on_mouse_event_closure('button_press')
);
canvas_div.addEventListener(
'mouseup',
on_mouse_event_closure('button_release')
);
canvas_div.addEventListener(
'dblclick',
on_mouse_event_closure('dblclick')
);
// Throttle sequential mouse events to 1 every 20ms.
canvas_div.addEventListener(
'mousemove',
on_mouse_event_closure('motion_notify')
);
canvas_div.addEventListener(
'mouseenter',
on_mouse_event_closure('figure_enter')
);
canvas_div.addEventListener(
'mouseleave',
on_mouse_event_closure('figure_leave')
);
canvas_div.addEventListener('wheel', function (event) {
if (event.deltaY < 0) {
event.step = 1;
} else {
event.step = -1;
}
on_mouse_event_closure('scroll')(event);
});
canvas_div.appendChild(canvas);
canvas_div.appendChild(rubberband_canvas);
this.rubberband_context = rubberband_canvas.getContext('2d');
this.rubberband_context.strokeStyle = '#000000';
this._resize_canvas = function (width, height, forward) {
if (forward) {
canvas_div.style.width = width + 'px';
canvas_div.style.height = height + 'px';
}
};
// Disable right mouse context menu.
canvas_div.addEventListener('contextmenu', function (_e) {
event.preventDefault();
return false;
});
function set_focus() {
canvas.focus();
canvas_div.focus();
}
window.setTimeout(set_focus, 100);
};
mpl.figure.prototype._init_toolbar = function () {
var fig = this;
var toolbar = document.createElement('div');
toolbar.classList = 'mpl-toolbar';
this.root.appendChild(toolbar);
function on_click_closure(name) {
return function (_event) {
return fig.toolbar_button_onclick(name);
};
}
function on_mouseover_closure(tooltip) {
return function (event) {
if (!event.currentTarget.disabled) {
return fig.toolbar_button_onmouseover(tooltip);
}
};
}
fig.buttons = {};
var buttonGroup = document.createElement('div');
buttonGroup.classList = 'mpl-button-group';
for (var toolbar_ind in mpl.toolbar_items) {
var name = mpl.toolbar_items[toolbar_ind][0];
var tooltip = mpl.toolbar_items[toolbar_ind][1];
var image = mpl.toolbar_items[toolbar_ind][2];
var method_name = mpl.toolbar_items[toolbar_ind][3];
if (!name) {
/* Instead of a spacer, we start a new button group. */
if (buttonGroup.hasChildNodes()) {
toolbar.appendChild(buttonGroup);
}
buttonGroup = document.createElement('div');
buttonGroup.classList = 'mpl-button-group';
continue;
}
var button = (fig.buttons[name] = document.createElement('button'));
button.classList = 'mpl-widget';
button.setAttribute('role', 'button');
button.setAttribute('aria-disabled', 'false');
button.addEventListener('click', on_click_closure(method_name));
button.addEventListener('mouseover', on_mouseover_closure(tooltip));
var icon_img = document.createElement('img');
icon_img.src = '_images/' + image + '.png';
icon_img.srcset = '_images/' + image + '_large.png 2x';
icon_img.alt = tooltip;
button.appendChild(icon_img);
buttonGroup.appendChild(button);
}
if (buttonGroup.hasChildNodes()) {
toolbar.appendChild(buttonGroup);
}
var fmt_picker = document.createElement('select');
fmt_picker.classList = 'mpl-widget';
toolbar.appendChild(fmt_picker);
this.format_dropdown = fmt_picker;
for (var ind in mpl.extensions) {
var fmt = mpl.extensions[ind];
var option = document.createElement('option');
option.selected = fmt === mpl.default_extension;
option.innerHTML = fmt;
fmt_picker.appendChild(option);
}
var status_bar = document.createElement('span');
status_bar.classList = 'mpl-message';
toolbar.appendChild(status_bar);
this.message = status_bar;
};
mpl.figure.prototype.request_resize = function (x_pixels, y_pixels) {
// Request matplotlib to resize the figure. Matplotlib will then trigger a resize in the client,
// which will in turn request a refresh of the image.
this.send_message('resize', { width: x_pixels, height: y_pixels });
};
mpl.figure.prototype.send_message = function (type, properties) {
properties['type'] = type;
properties['figure_id'] = this.id;
this.ws.send(JSON.stringify(properties));
};
mpl.figure.prototype.send_draw_message = function () {
if (!this.waiting) {
this.waiting = true;
this.ws.send(JSON.stringify({ type: 'draw', figure_id: this.id }));
}
};
mpl.figure.prototype.handle_save = function (fig, _msg) {
var format_dropdown = fig.format_dropdown;
var format = format_dropdown.options[format_dropdown.selectedIndex].value;
fig.ondownload(fig, format);
};
mpl.figure.prototype.handle_resize = function (fig, msg) {
var size = msg['size'];
if (size[0] !== fig.canvas.width || size[1] !== fig.canvas.height) {
fig._resize_canvas(size[0], size[1], msg['forward']);
fig.send_message('refresh', {});
}
};
mpl.figure.prototype.handle_rubberband = function (fig, msg) {
var x0 = msg['x0'] / fig.ratio;
var y0 = (fig.canvas.height - msg['y0']) / fig.ratio;
var x1 = msg['x1'] / fig.ratio;
var y1 = (fig.canvas.height - msg['y1']) / fig.ratio;
x0 = Math.floor(x0) + 0.5;
y0 = Math.floor(y0) + 0.5;
x1 = Math.floor(x1) + 0.5;
y1 = Math.floor(y1) + 0.5;
var min_x = Math.min(x0, x1);
var min_y = Math.min(y0, y1);
var width = Math.abs(x1 - x0);
var height = Math.abs(y1 - y0);
fig.rubberband_context.clearRect(
0,
0,
fig.canvas.width / fig.ratio,
fig.canvas.height / fig.ratio
);
fig.rubberband_context.strokeRect(min_x, min_y, width, height);
};
mpl.figure.prototype.handle_figure_label = function (fig, msg) {
// Updates the figure title.
fig.header.textContent = msg['label'];
};
mpl.figure.prototype.handle_cursor = function (fig, msg) {
fig.canvas_div.style.cursor = msg['cursor'];
};
mpl.figure.prototype.handle_message = function (fig, msg) {
fig.message.textContent = msg['message'];
};
mpl.figure.prototype.handle_draw = function (fig, _msg) {
// Request the server to send over a new figure.
fig.send_draw_message();
};
mpl.figure.prototype.handle_image_mode = function (fig, msg) {
fig.image_mode = msg['mode'];
};
mpl.figure.prototype.handle_history_buttons = function (fig, msg) {
for (var key in msg) {
if (!(key in fig.buttons)) {
continue;
}
fig.buttons[key].disabled = !msg[key];
fig.buttons[key].setAttribute('aria-disabled', !msg[key]);
}
};
mpl.figure.prototype.handle_navigate_mode = function (fig, msg) {
if (msg['mode'] === 'PAN') {
fig.buttons['Pan'].classList.add('active');
fig.buttons['Zoom'].classList.remove('active');
} else if (msg['mode'] === 'ZOOM') {
fig.buttons['Pan'].classList.remove('active');
fig.buttons['Zoom'].classList.add('active');
} else {
fig.buttons['Pan'].classList.remove('active');
fig.buttons['Zoom'].classList.remove('active');
}
};
mpl.figure.prototype.updated_canvas_event = function () {
// Called whenever the canvas gets updated.
this.send_message('ack', {});
};
// A function to construct a web socket function for onmessage handling.
// Called in the figure constructor.
mpl.figure.prototype._make_on_message_function = function (fig) {
return function socket_on_message(evt) {
if (evt.data instanceof Blob) {
var img = evt.data;
if (img.type !== 'image/png') {
/* FIXME: We get "Resource interpreted as Image but
* transferred with MIME type text/plain:" errors on
* Chrome. But how to set the MIME type? It doesn't seem
* to be part of the websocket stream */
img.type = 'image/png';
}
/* Free the memory for the previous frames */
if (fig.imageObj.src) {
(window.URL || window.webkitURL).revokeObjectURL(
fig.imageObj.src
);
}
fig.imageObj.src = (window.URL || window.webkitURL).createObjectURL(
img
);
fig.updated_canvas_event();
fig.waiting = false;
return;
} else if (
typeof evt.data === 'string' &&
evt.data.slice(0, 21) === 'data:image/png;base64'
) {
fig.imageObj.src = evt.data;
fig.updated_canvas_event();
fig.waiting = false;
return;
}
var msg = JSON.parse(evt.data);
var msg_type = msg['type'];
// Call the "handle_{type}" callback, which takes
// the figure and JSON message as its only arguments.
try {
var callback = fig['handle_' + msg_type];
} catch (e) {
console.log(
"No handler for the '%s' message type: ",
msg_type,
msg
);
return;
}
if (callback) {
try {
// console.log("Handling '%s' message: ", msg_type, msg);
callback(fig, msg);
} catch (e) {
console.log(
"Exception inside the 'handler_%s' callback:",
msg_type,
e,
e.stack,
msg
);
}
}
};
};
function getModifiers(event) {
var mods = [];
if (event.ctrlKey) {
mods.push('ctrl');
}
if (event.altKey) {
mods.push('alt');
}
if (event.shiftKey) {
mods.push('shift');
}
if (event.metaKey) {
mods.push('meta');
}
return mods;
}
/*
* return a copy of an object with only non-object keys
* we need this to avoid circular references
* https://stackoverflow.com/a/24161582/3208463
*/
function simpleKeys(original) {
return Object.keys(original).reduce(function (obj, key) {
if (typeof original[key] !== 'object') {
obj[key] = original[key];
}
return obj;
}, {});
}
mpl.figure.prototype.mouse_event = function (event, name) {
if (name === 'button_press') {
this.canvas.focus();
this.canvas_div.focus();
}
// from https://stackoverflow.com/q/1114465
var boundingRect = this.canvas.getBoundingClientRect();
var x = (event.clientX - boundingRect.left) * this.ratio;
var y = (event.clientY - boundingRect.top) * this.ratio;
this.send_message(name, {
x: x,
y: y,
button: event.button,
step: event.step,
buttons: event.buttons,
modifiers: getModifiers(event),
guiEvent: simpleKeys(event),
});
return false;
};
mpl.figure.prototype._key_event_extra = function (_event, _name) {
// Handle any extra behaviour associated with a key event
};
mpl.figure.prototype.key_event = function (event, name) {
// Prevent repeat events
if (name === 'key_press') {
if (event.key === this._key) {
return;
} else {
this._key = event.key;
}
}
if (name === 'key_release') {
this._key = null;
}
var value = '';
if (event.ctrlKey && event.key !== 'Control') {
value += 'ctrl+';
}
else if (event.altKey && event.key !== 'Alt') {
value += 'alt+';
}
else if (event.shiftKey && event.key !== 'Shift') {
value += 'shift+';
}
value += 'k' + event.key;
this._key_event_extra(event, name);
this.send_message(name, { key: value, guiEvent: simpleKeys(event) });
return false;
};
mpl.figure.prototype.toolbar_button_onclick = function (name) {
if (name === 'download') {
this.handle_save(this, null);
} else {
this.send_message('toolbar_button', { name: name });
}
};
mpl.figure.prototype.toolbar_button_onmouseover = function (tooltip) {
this.message.textContent = tooltip;
};
///////////////// REMAINING CONTENT GENERATED BY embed_js.py /////////////////
// prettier-ignore
var _JSXTOOLS_RESIZE_OBSERVER=function(A){var t,i=new WeakMap,n=new WeakMap,a=new WeakMap,r=new WeakMap,o=new Set;function s(e){if(!(this instanceof s))throw new TypeError("Constructor requires 'new' operator");i.set(this,e)}function h(){throw new TypeError("Function is not a constructor")}function c(e,t,i,n){e=0 in arguments?Number(arguments[0]):0,t=1 in arguments?Number(arguments[1]):0,i=2 in arguments?Number(arguments[2]):0,n=3 in arguments?Number(arguments[3]):0,this.right=(this.x=this.left=e)+(this.width=i),this.bottom=(this.y=this.top=t)+(this.height=n),Object.freeze(this)}function d(){t=requestAnimationFrame(d);var s=new WeakMap,p=new Set;o.forEach((function(t){r.get(t).forEach((function(i){var r=t instanceof window.SVGElement,o=a.get(t),d=r?0:parseFloat(o.paddingTop),f=r?0:parseFloat(o.paddingRight),l=r?0:parseFloat(o.paddingBottom),u=r?0:parseFloat(o.paddingLeft),g=r?0:parseFloat(o.borderTopWidth),m=r?0:parseFloat(o.borderRightWidth),w=r?0:parseFloat(o.borderBottomWidth),b=u+f,F=d+l,v=(r?0:parseFloat(o.borderLeftWidth))+m,W=g+w,y=r?0:t.offsetHeight-W-t.clientHeight,E=r?0:t.offsetWidth-v-t.clientWidth,R=b+v,z=F+W,M=r?t.width:parseFloat(o.width)-R-E,O=r?t.height:parseFloat(o.height)-z-y;if(n.has(t)){var k=n.get(t);if(k[0]===M&&k[1]===O)return}n.set(t,[M,O]);var S=Object.create(h.prototype);S.target=t,S.contentRect=new c(u,d,M,O),s.has(i)||(s.set(i,[]),p.add(i)),s.get(i).push(S)}))})),p.forEach((function(e){i.get(e).call(e,s.get(e),e)}))}return s.prototype.observe=function(i){if(i instanceof window.Element){r.has(i)||(r.set(i,new Set),o.add(i),a.set(i,window.getComputedStyle(i)));var n=r.get(i);n.has(this)||n.add(this),cancelAnimationFrame(t),t=requestAnimationFrame(d)}},s.prototype.unobserve=function(i){if(i instanceof window.Element&&r.has(i)){var n=r.get(i);n.has(this)&&(n.delete(this),n.size||(r.delete(i),o.delete(i))),n.size||r.delete(i),o.size||cancelAnimationFrame(t)}},A.DOMRectReadOnly=c,A.ResizeObserver=s,A.ResizeObserverEntry=h,A}; // eslint-disable-line

View file

@ -0,0 +1,8 @@
/* This .js file contains functions for matplotlib's built-in
tornado-based server, that are not relevant when embedding WebAgg
in another web application. */
/* exported mpl_ondownload */
function mpl_ondownload(figure, format) {
window.open(figure.id + '/download.' + format, '_blank');
}

View file

@ -0,0 +1,275 @@
/* global mpl */
var comm_websocket_adapter = function (comm) {
// Create a "websocket"-like object which calls the given IPython comm
// object with the appropriate methods. Currently this is a non binary
// socket, so there is still some room for performance tuning.
var ws = {};
ws.binaryType = comm.kernel.ws.binaryType;
ws.readyState = comm.kernel.ws.readyState;
function updateReadyState(_event) {
if (comm.kernel.ws) {
ws.readyState = comm.kernel.ws.readyState;
} else {
ws.readyState = 3; // Closed state.
}
}
comm.kernel.ws.addEventListener('open', updateReadyState);
comm.kernel.ws.addEventListener('close', updateReadyState);
comm.kernel.ws.addEventListener('error', updateReadyState);
ws.close = function () {
comm.close();
};
ws.send = function (m) {
//console.log('sending', m);
comm.send(m);
};
// Register the callback with on_msg.
comm.on_msg(function (msg) {
//console.log('receiving', msg['content']['data'], msg);
var data = msg['content']['data'];
if (data['blob'] !== undefined) {
data = {
data: new Blob(msg['buffers'], { type: data['blob'] }),
};
}
// Pass the mpl event to the overridden (by mpl) onmessage function.
ws.onmessage(data);
});
return ws;
};
mpl.mpl_figure_comm = function (comm, msg) {
// This is the function which gets called when the mpl process
// starts-up an IPython Comm through the "matplotlib" channel.
var id = msg.content.data.id;
// Get hold of the div created by the display call when the Comm
// socket was opened in Python.
var element = document.getElementById(id);
var ws_proxy = comm_websocket_adapter(comm);
function ondownload(figure, _format) {
window.open(figure.canvas.toDataURL());
}
var fig = new mpl.figure(id, ws_proxy, ondownload, element);
// Call onopen now - mpl needs it, as it is assuming we've passed it a real
// web socket which is closed, not our websocket->open comm proxy.
ws_proxy.onopen();
fig.parent_element = element;
fig.cell_info = mpl.find_output_cell("<div id='" + id + "'></div>");
if (!fig.cell_info) {
console.error('Failed to find cell for figure', id, fig);
return;
}
fig.cell_info[0].output_area.element.on(
'cleared',
{ fig: fig },
fig._remove_fig_handler
);
};
mpl.figure.prototype.handle_close = function (fig, msg) {
var width = fig.canvas.width / fig.ratio;
fig.cell_info[0].output_area.element.off(
'cleared',
fig._remove_fig_handler
);
fig.resizeObserverInstance.unobserve(fig.canvas_div);
// Update the output cell to use the data from the current canvas.
fig.push_to_output();
var dataURL = fig.canvas.toDataURL();
// Re-enable the keyboard manager in IPython - without this line, in FF,
// the notebook keyboard shortcuts fail.
IPython.keyboard_manager.enable();
fig.parent_element.innerHTML =
'<img src="' + dataURL + '" width="' + width + '">';
fig.close_ws(fig, msg);
};
mpl.figure.prototype.close_ws = function (fig, msg) {
fig.send_message('closing', msg);
// fig.ws.close()
};
mpl.figure.prototype.push_to_output = function (_remove_interactive) {
// Turn the data on the canvas into data in the output cell.
var width = this.canvas.width / this.ratio;
var dataURL = this.canvas.toDataURL();
this.cell_info[1]['text/html'] =
'<img src="' + dataURL + '" width="' + width + '">';
};
mpl.figure.prototype.updated_canvas_event = function () {
// Tell IPython that the notebook contents must change.
IPython.notebook.set_dirty(true);
this.send_message('ack', {});
var fig = this;
// Wait a second, then push the new image to the DOM so
// that it is saved nicely (might be nice to debounce this).
setTimeout(function () {
fig.push_to_output();
}, 1000);
};
mpl.figure.prototype._init_toolbar = function () {
var fig = this;
var toolbar = document.createElement('div');
toolbar.classList = 'btn-toolbar';
this.root.appendChild(toolbar);
function on_click_closure(name) {
return function (_event) {
return fig.toolbar_button_onclick(name);
};
}
function on_mouseover_closure(tooltip) {
return function (event) {
if (!event.currentTarget.disabled) {
return fig.toolbar_button_onmouseover(tooltip);
}
};
}
fig.buttons = {};
var buttonGroup = document.createElement('div');
buttonGroup.classList = 'btn-group';
var button;
for (var toolbar_ind in mpl.toolbar_items) {
var name = mpl.toolbar_items[toolbar_ind][0];
var tooltip = mpl.toolbar_items[toolbar_ind][1];
var image = mpl.toolbar_items[toolbar_ind][2];
var method_name = mpl.toolbar_items[toolbar_ind][3];
if (!name) {
/* Instead of a spacer, we start a new button group. */
if (buttonGroup.hasChildNodes()) {
toolbar.appendChild(buttonGroup);
}
buttonGroup = document.createElement('div');
buttonGroup.classList = 'btn-group';
continue;
}
button = fig.buttons[name] = document.createElement('button');
button.classList = 'btn btn-default';
button.href = '#';
button.title = name;
button.innerHTML = '<i class="fa ' + image + ' fa-lg"></i>';
button.addEventListener('click', on_click_closure(method_name));
button.addEventListener('mouseover', on_mouseover_closure(tooltip));
buttonGroup.appendChild(button);
}
if (buttonGroup.hasChildNodes()) {
toolbar.appendChild(buttonGroup);
}
// Add the status bar.
var status_bar = document.createElement('span');
status_bar.classList = 'mpl-message pull-right';
toolbar.appendChild(status_bar);
this.message = status_bar;
// Add the close button to the window.
var buttongrp = document.createElement('div');
buttongrp.classList = 'btn-group inline pull-right';
button = document.createElement('button');
button.classList = 'btn btn-mini btn-primary';
button.href = '#';
button.title = 'Stop Interaction';
button.innerHTML = '<i class="fa fa-power-off icon-remove icon-large"></i>';
button.addEventListener('click', function (_evt) {
fig.handle_close(fig, {});
});
button.addEventListener(
'mouseover',
on_mouseover_closure('Stop Interaction')
);
buttongrp.appendChild(button);
var titlebar = this.root.querySelector('.ui-dialog-titlebar');
titlebar.insertBefore(buttongrp, titlebar.firstChild);
};
mpl.figure.prototype._remove_fig_handler = function (event) {
var fig = event.data.fig;
if (event.target !== this) {
// Ignore bubbled events from children.
return;
}
fig.close_ws(fig, {});
};
mpl.figure.prototype._root_extra_style = function (el) {
el.style.boxSizing = 'content-box'; // override notebook setting of border-box.
};
mpl.figure.prototype._canvas_extra_style = function (el) {
// this is important to make the div 'focusable
el.setAttribute('tabindex', 0);
// reach out to IPython and tell the keyboard manager to turn it's self
// off when our div gets focus
// location in version 3
if (IPython.notebook.keyboard_manager) {
IPython.notebook.keyboard_manager.register_events(el);
} else {
// location in version 2
IPython.keyboard_manager.register_events(el);
}
};
mpl.figure.prototype._key_event_extra = function (event, _name) {
// Check for shift+enter
if (event.shiftKey && event.which === 13) {
this.canvas_div.blur();
// select the cell after this one
var index = IPython.notebook.find_cell_index(this.cell_info[0]);
IPython.notebook.select(index + 1);
}
};
mpl.figure.prototype.handle_save = function (fig, _msg) {
fig.ondownload(fig, null);
};
mpl.find_output_cell = function (html_output) {
// Return the cell and output element which can be found *uniquely* in the notebook.
// Note - this is a bit hacky, but it is done because the "notebook_saving.Notebook"
// IPython event is triggered only after the cells have been serialised, which for
// our purposes (turning an active figure into a static one), is too late.
var cells = IPython.notebook.get_cells();
var ncells = cells.length;
for (var i = 0; i < ncells; i++) {
var cell = cells[i];
if (cell.cell_type === 'code') {
for (var j = 0; j < cell.output_area.outputs.length; j++) {
var data = cell.output_area.outputs[j];
if (data.data) {
// IPython >= 3 moved mimebundle to data attribute of output
data = data.data;
}
if (data['text/html'] === html_output) {
return [cell, data, j];
}
}
}
}
};
// Register the function which deals with the matplotlib target/channel.
// The kernel may be null if the page has been refreshed.
if (IPython.notebook.kernel !== null) {
IPython.notebook.kernel.comm_manager.register_target(
'matplotlib',
mpl.mpl_figure_comm
);
}

View file

@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="en">
<head>
<link rel="stylesheet" href="{{ prefix }}/_static/css/page.css" type="text/css">
<link rel="stylesheet" href="{{ prefix }}/_static/css/boilerplate.css" type="text/css">
<link rel="stylesheet" href="{{ prefix }}/_static/css/fbm.css" type="text/css">
<link rel="stylesheet" href="{{ prefix }}/_static/css/mpl.css" type="text/css">
<script src="{{ prefix }}/_static/js/mpl_tornado.js"></script>
<script src="{{ prefix }}/js/mpl.js"></script>
<script>
function ready(fn) {
if (document.readyState != "loading") {
fn();
} else {
document.addEventListener("DOMContentLoaded", fn);
}
}
ready(
function () {
var websocket_type = mpl.get_websocket_type();
var uri = "{{ ws_uri }}" + {{ str(fig_id) }} + "/ws";
if (window.location.protocol === 'https:') uri = uri.replace('ws:', 'wss:')
var websocket = new websocket_type(uri);
var fig = new mpl.figure(
{{ str(fig_id) }}, websocket, mpl_ondownload,
document.getElementById("figure"));
}
);
</script>
<title>matplotlib</title>
</head>
<body>
<div id="mpl-warnings" class="mpl-warnings"></div>
<div id="figure" style="margin: 10px 10px;"></div>
</body>
</html>