asd
This commit is contained in:
@ -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
|
||||
Binary file not shown.
@ -0,0 +1,332 @@
|
||||
"""
|
||||
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:
|
||||
self.window.set_wmclass("matplotlib", "Matplotlib")
|
||||
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
|
||||
@ -0,0 +1,145 @@
|
||||
"""
|
||||
Common functionality between the PDF and PS backends.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
import functools
|
||||
|
||||
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.
|
||||
Returns a serialization of the subset font as file-like object.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fontfile : str
|
||||
Path to the font file
|
||||
characters : str
|
||||
Continuous set of characters to include in subset
|
||||
"""
|
||||
|
||||
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).
|
||||
]
|
||||
|
||||
# if fontfile is a ttc, specify font number
|
||||
if fontfile.endswith(".ttc"):
|
||||
options.font_number = 0
|
||||
|
||||
with subset.load_font(fontfile, options) as font:
|
||||
subsetter = subset.Subsetter(options=options)
|
||||
subsetter.populate(text=characters)
|
||||
subsetter.subset(font)
|
||||
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.LOAD_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)
|
||||
font = font_manager.get_font(fnames)
|
||||
font.clear()
|
||||
font.set_size(prop.get_size_in_points(), 72)
|
||||
return font
|
||||
1052
venv/lib/python3.12/site-packages/matplotlib/backends/_backend_tk.py
Normal file
1052
venv/lib/python3.12/site-packages/matplotlib/backends/_backend_tk.py
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -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: ...
|
||||
@ -0,0 +1,543 @@
|
||||
"""
|
||||
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 (LOAD_FORCE_AUTOHINT, LOAD_NO_HINTING,
|
||||
LOAD_DEFAULT, LOAD_NO_AUTOHINT)
|
||||
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': LOAD_DEFAULT,
|
||||
'no_autohint': LOAD_NO_AUTOHINT,
|
||||
'force_autohint': LOAD_FORCE_AUTOHINT,
|
||||
'no_hinting': LOAD_NO_HINTING,
|
||||
True: LOAD_FORCE_AUTOHINT,
|
||||
False: LOAD_NO_HINTING,
|
||||
'either': LOAD_DEFAULT,
|
||||
'native': LOAD_NO_AUTOHINT,
|
||||
'auto': LOAD_FORCE_AUTOHINT,
|
||||
'none': LOAD_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()
|
||||
|
||||
@_api.deprecated("3.8", alternative="buffer_rgba")
|
||||
def tostring_rgb(self):
|
||||
return np.asarray(self._renderer).take([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
|
||||
|
||||
@_api.deprecated("3.8", alternative="buffer_rgba")
|
||||
def tostring_rgb(self):
|
||||
"""
|
||||
Get the image as RGB `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_rgb()
|
||||
|
||||
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
|
||||
@ -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
|
||||
@ -0,0 +1,582 @@
|
||||
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, 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),
|
||||
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_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",
|
||||
parent=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
|
||||
# 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)
|
||||
except Exception as e:
|
||||
dialog = Gtk.MessageDialog(
|
||||
parent=self.canvas.get_toplevel(), message_format=str(e),
|
||||
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
|
||||
@ -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 _BackendGTK3Cairo(_BackendGTK3):
|
||||
FigureCanvas = FigureCanvasGTK3Agg
|
||||
@ -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
|
||||
@ -0,0 +1,595 @@
|
||||
import functools
|
||||
import io
|
||||
import os
|
||||
|
||||
import matplotlib as mpl
|
||||
from matplotlib import _api, backend_tools, cbook
|
||||
from matplotlib.backend_bases import (
|
||||
ToolContainerBase, 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,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
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),
|
||||
)._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),
|
||||
)._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),
|
||||
)._process()
|
||||
|
||||
def key_press_event(self, controller, keyval, keycode, state):
|
||||
KeyEvent(
|
||||
"key_press_event", self, self._get_key(keyval, keycode, state),
|
||||
*self._mpl_coords(),
|
||||
)._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(),
|
||||
)._process()
|
||||
return True
|
||||
|
||||
def motion_notify_event(self, controller, x, y):
|
||||
MouseEvent(
|
||||
"motion_notify_event", self, *self._mpl_coords((x, y)),
|
||||
modifiers=self._mpl_modifiers(controller),
|
||||
)._process()
|
||||
|
||||
def enter_notify_event(self, controller, x, y):
|
||||
LocationEvent(
|
||||
"figure_enter_event", self, *self._mpl_coords((x, y)),
|
||||
modifiers=self._mpl_modifiers(),
|
||||
)._process()
|
||||
|
||||
def leave_notify_event(self, controller):
|
||||
LocationEvent(
|
||||
"figure_leave_event", self, *self._mpl_coords(),
|
||||
modifiers=self._mpl_modifiers(),
|
||||
)._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_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 _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()):
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -0,0 +1,195 @@
|
||||
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)
|
||||
|
||||
|
||||
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
|
||||
@ -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 : scalar
|
||||
The width of the canvas in logical units
|
||||
height : scalar
|
||||
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
|
||||
@ -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
|
||||
2819
venv/lib/python3.12/site-packages/matplotlib/backends/backend_pdf.py
Normal file
2819
venv/lib/python3.12/site-packages/matplotlib/backends/backend_pdf.py
Normal file
File diff suppressed because it is too large
Load Diff
1012
venv/lib/python3.12/site-packages/matplotlib/backends/backend_pgf.py
Normal file
1012
venv/lib/python3.12/site-packages/matplotlib/backends/backend_pgf.py
Normal file
File diff suppressed because it is too large
Load Diff
1326
venv/lib/python3.12/site-packages/matplotlib/backends/backend_ps.py
Normal file
1326
venv/lib/python3.12/site-packages/matplotlib/backends/backend_ps.py
Normal file
File diff suppressed because it is too large
Load Diff
1067
venv/lib/python3.12/site-packages/matplotlib/backends/backend_qt.py
Normal file
1067
venv/lib/python3.12/site-packages/matplotlib/backends/backend_qt.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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}")
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
1368
venv/lib/python3.12/site-packages/matplotlib/backends/backend_svg.py
Normal file
1368
venv/lib/python3.12/site-packages/matplotlib/backends/backend_svg.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -0,0 +1,517 @@
|
||||
"""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, 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
|
||||
# JavaScript button numbers and Matplotlib button numbers are off by 1.
|
||||
button = event['button'] + 1
|
||||
|
||||
e_type = event['type']
|
||||
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,
|
||||
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')
|
||||
|
||||
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
|
||||
1364
venv/lib/python3.12/site-packages/matplotlib/backends/backend_wx.py
Normal file
1364
venv/lib/python3.12/site-packages/matplotlib/backends/backend_wx.py
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
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_()
|
||||
@ -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()
|
||||
@ -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.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.get_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.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 not in cm._colormaps.values():
|
||||
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']
|
||||
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.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)
|
||||
@ -0,0 +1,420 @@
|
||||
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
|
||||
import sys
|
||||
|
||||
# entry_points group keyword not available before Python 3.10
|
||||
group = "matplotlib.backend"
|
||||
if sys.version_info >= (3, 10):
|
||||
entry_points = im.entry_points(group=group)
|
||||
else:
|
||||
entry_points = im.entry_points().get(group, ())
|
||||
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()
|
||||
@ -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>
|
||||
@ -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; }
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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>
|
||||
@ -0,0 +1,704 @@
|
||||
/* 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 '" + msg_type + "' message type: ",
|
||||
msg
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
try {
|
||||
// console.log("Handling '" + msg_type + "' message: ", msg);
|
||||
callback(fig, msg);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"Exception inside the 'handler_" + msg_type + "' callback:",
|
||||
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,
|
||||
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
|
||||
@ -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');
|
||||
}
|
||||
@ -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
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
Reference in New Issue
Block a user