up follow livre
This commit is contained in:
parent
b4b4398bb0
commit
3a7a3849ae
12242 changed files with 2564461 additions and 6914 deletions
|
|
@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,331 @@
|
|||
"""
|
||||
Common code for GTK3 and GTK4 backends.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import matplotlib as mpl
|
||||
from matplotlib import _api, backend_tools, cbook
|
||||
from matplotlib._pylab_helpers import Gcf
|
||||
from matplotlib.backend_bases import (
|
||||
_Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
|
||||
TimerBase)
|
||||
from matplotlib.backend_tools import Cursors
|
||||
|
||||
import gi
|
||||
# The GTK3/GTK4 backends will have already called `gi.require_version` to set
|
||||
# the desired GTK.
|
||||
from gi.repository import Gdk, Gio, GLib, Gtk
|
||||
|
||||
|
||||
try:
|
||||
gi.require_foreign("cairo")
|
||||
except ImportError as e:
|
||||
raise ImportError("Gtk-based backends require cairo") from e
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
_application = None # Placeholder
|
||||
|
||||
|
||||
def _shutdown_application(app):
|
||||
# The application might prematurely shut down if Ctrl-C'd out of IPython,
|
||||
# so close all windows.
|
||||
for win in app.get_windows():
|
||||
win.close()
|
||||
# The PyGObject wrapper incorrectly thinks that None is not allowed, or we
|
||||
# would call this:
|
||||
# Gio.Application.set_default(None)
|
||||
# Instead, we set this property and ignore default applications with it:
|
||||
app._created_by_matplotlib = True
|
||||
global _application
|
||||
_application = None
|
||||
|
||||
|
||||
def _create_application():
|
||||
global _application
|
||||
|
||||
if _application is None:
|
||||
app = Gio.Application.get_default()
|
||||
if app is None or getattr(app, '_created_by_matplotlib', False):
|
||||
# display_is_valid returns False only if on Linux and neither X11
|
||||
# nor Wayland display can be opened.
|
||||
if not mpl._c_internal_utils.display_is_valid():
|
||||
raise RuntimeError('Invalid DISPLAY variable')
|
||||
_application = Gtk.Application.new('org.matplotlib.Matplotlib3',
|
||||
Gio.ApplicationFlags.NON_UNIQUE)
|
||||
# The activate signal must be connected, but we don't care for
|
||||
# handling it, since we don't do any remote processing.
|
||||
_application.connect('activate', lambda *args, **kwargs: None)
|
||||
_application.connect('shutdown', _shutdown_application)
|
||||
_application.register()
|
||||
cbook._setup_new_guiapp()
|
||||
else:
|
||||
_application = app
|
||||
|
||||
return _application
|
||||
|
||||
|
||||
def mpl_to_gtk_cursor_name(mpl_cursor):
|
||||
return _api.check_getitem({
|
||||
Cursors.MOVE: "move",
|
||||
Cursors.HAND: "pointer",
|
||||
Cursors.POINTER: "default",
|
||||
Cursors.SELECT_REGION: "crosshair",
|
||||
Cursors.WAIT: "wait",
|
||||
Cursors.RESIZE_HORIZONTAL: "ew-resize",
|
||||
Cursors.RESIZE_VERTICAL: "ns-resize",
|
||||
}, cursor=mpl_cursor)
|
||||
|
||||
|
||||
class TimerGTK(TimerBase):
|
||||
"""Subclass of `.TimerBase` using GTK timer events."""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._timer = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _timer_start(self):
|
||||
# Need to stop it, otherwise we potentially leak a timer id that will
|
||||
# never be stopped.
|
||||
self._timer_stop()
|
||||
self._timer = GLib.timeout_add(self._interval, self._on_timer)
|
||||
|
||||
def _timer_stop(self):
|
||||
if self._timer is not None:
|
||||
GLib.source_remove(self._timer)
|
||||
self._timer = None
|
||||
|
||||
def _timer_set_interval(self):
|
||||
# Only stop and restart it if the timer has already been started.
|
||||
if self._timer is not None:
|
||||
self._timer_stop()
|
||||
self._timer_start()
|
||||
|
||||
def _on_timer(self):
|
||||
super()._on_timer()
|
||||
|
||||
# Gtk timeout_add() requires that the callback returns True if it
|
||||
# is to be called again.
|
||||
if self.callbacks and not self._single:
|
||||
return True
|
||||
else:
|
||||
self._timer = None
|
||||
return False
|
||||
|
||||
|
||||
class _FigureCanvasGTK(FigureCanvasBase):
|
||||
_timer_cls = TimerGTK
|
||||
|
||||
|
||||
class _FigureManagerGTK(FigureManagerBase):
|
||||
"""
|
||||
Attributes
|
||||
----------
|
||||
canvas : `FigureCanvas`
|
||||
The FigureCanvas instance
|
||||
num : int or str
|
||||
The Figure number
|
||||
toolbar : Gtk.Toolbar or Gtk.Box
|
||||
The toolbar
|
||||
vbox : Gtk.VBox
|
||||
The Gtk.VBox containing the canvas and toolbar
|
||||
window : Gtk.Window
|
||||
The Gtk.Window
|
||||
"""
|
||||
|
||||
def __init__(self, canvas, num):
|
||||
self._gtk_ver = gtk_ver = Gtk.get_major_version()
|
||||
|
||||
app = _create_application()
|
||||
self.window = Gtk.Window()
|
||||
app.add_window(self.window)
|
||||
super().__init__(canvas, num)
|
||||
|
||||
if gtk_ver == 3:
|
||||
icon_ext = "png" if sys.platform == "win32" else "svg"
|
||||
self.window.set_icon_from_file(
|
||||
str(cbook._get_data_path(f"images/matplotlib.{icon_ext}")))
|
||||
|
||||
self.vbox = Gtk.Box()
|
||||
self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL)
|
||||
|
||||
if gtk_ver == 3:
|
||||
self.window.add(self.vbox)
|
||||
self.vbox.show()
|
||||
self.canvas.show()
|
||||
self.vbox.pack_start(self.canvas, True, True, 0)
|
||||
elif gtk_ver == 4:
|
||||
self.window.set_child(self.vbox)
|
||||
self.vbox.prepend(self.canvas)
|
||||
|
||||
# calculate size for window
|
||||
w, h = self.canvas.get_width_height()
|
||||
|
||||
if self.toolbar is not None:
|
||||
if gtk_ver == 3:
|
||||
self.toolbar.show()
|
||||
self.vbox.pack_end(self.toolbar, False, False, 0)
|
||||
elif gtk_ver == 4:
|
||||
sw = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER)
|
||||
sw.set_child(self.toolbar)
|
||||
self.vbox.append(sw)
|
||||
min_size, nat_size = self.toolbar.get_preferred_size()
|
||||
h += nat_size.height
|
||||
|
||||
self.window.set_default_size(w, h)
|
||||
|
||||
self._destroying = False
|
||||
self.window.connect("destroy", lambda *args: Gcf.destroy(self))
|
||||
self.window.connect({3: "delete_event", 4: "close-request"}[gtk_ver],
|
||||
lambda *args: Gcf.destroy(self))
|
||||
if mpl.is_interactive():
|
||||
self.window.show()
|
||||
self.canvas.draw_idle()
|
||||
|
||||
self.canvas.grab_focus()
|
||||
|
||||
def destroy(self, *args):
|
||||
if self._destroying:
|
||||
# Otherwise, this can be called twice when the user presses 'q',
|
||||
# which calls Gcf.destroy(self), then this destroy(), then triggers
|
||||
# Gcf.destroy(self) once again via
|
||||
# `connect("destroy", lambda *args: Gcf.destroy(self))`.
|
||||
return
|
||||
self._destroying = True
|
||||
self.window.destroy()
|
||||
self.canvas.destroy()
|
||||
|
||||
@classmethod
|
||||
def start_main_loop(cls):
|
||||
global _application
|
||||
if _application is None:
|
||||
return
|
||||
|
||||
try:
|
||||
_application.run() # Quits when all added windows close.
|
||||
except KeyboardInterrupt:
|
||||
# Ensure all windows can process their close event from
|
||||
# _shutdown_application.
|
||||
context = GLib.MainContext.default()
|
||||
while context.pending():
|
||||
context.iteration(True)
|
||||
raise
|
||||
finally:
|
||||
# Running after quit is undefined, so create a new one next time.
|
||||
_application = None
|
||||
|
||||
def show(self):
|
||||
# show the figure window
|
||||
self.window.show()
|
||||
self.canvas.draw()
|
||||
if mpl.rcParams["figure.raise_window"]:
|
||||
meth_name = {3: "get_window", 4: "get_surface"}[self._gtk_ver]
|
||||
if getattr(self.window, meth_name)():
|
||||
self.window.present()
|
||||
else:
|
||||
# If this is called by a callback early during init,
|
||||
# self.window (a GtkWindow) may not have an associated
|
||||
# low-level GdkWindow (on GTK3) or GdkSurface (on GTK4) yet,
|
||||
# and present() would crash.
|
||||
_api.warn_external("Cannot raise window yet to be setup")
|
||||
|
||||
def full_screen_toggle(self):
|
||||
is_fullscreen = {
|
||||
3: lambda w: (w.get_window().get_state()
|
||||
& Gdk.WindowState.FULLSCREEN),
|
||||
4: lambda w: w.is_fullscreen(),
|
||||
}[self._gtk_ver]
|
||||
if is_fullscreen(self.window):
|
||||
self.window.unfullscreen()
|
||||
else:
|
||||
self.window.fullscreen()
|
||||
|
||||
def get_window_title(self):
|
||||
return self.window.get_title()
|
||||
|
||||
def set_window_title(self, title):
|
||||
self.window.set_title(title)
|
||||
|
||||
def resize(self, width, height):
|
||||
width = int(width / self.canvas.device_pixel_ratio)
|
||||
height = int(height / self.canvas.device_pixel_ratio)
|
||||
if self.toolbar:
|
||||
min_size, nat_size = self.toolbar.get_preferred_size()
|
||||
height += nat_size.height
|
||||
canvas_size = self.canvas.get_allocation()
|
||||
if self._gtk_ver >= 4 or canvas_size.width == canvas_size.height == 1:
|
||||
# A canvas size of (1, 1) cannot exist in most cases, because
|
||||
# window decorations would prevent such a small window. This call
|
||||
# must be before the window has been mapped and widgets have been
|
||||
# sized, so just change the window's starting size.
|
||||
self.window.set_default_size(width, height)
|
||||
else:
|
||||
self.window.resize(width, height)
|
||||
|
||||
|
||||
class _NavigationToolbar2GTK(NavigationToolbar2):
|
||||
# Must be implemented in GTK3/GTK4 backends:
|
||||
# * __init__
|
||||
# * save_figure
|
||||
|
||||
def set_message(self, s):
|
||||
escaped = GLib.markup_escape_text(s)
|
||||
self.message.set_markup(f'<small>{escaped}</small>')
|
||||
|
||||
def draw_rubberband(self, event, x0, y0, x1, y1):
|
||||
height = self.canvas.figure.bbox.height
|
||||
y1 = height - y1
|
||||
y0 = height - y0
|
||||
rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)]
|
||||
self.canvas._draw_rubberband(rect)
|
||||
|
||||
def remove_rubberband(self):
|
||||
self.canvas._draw_rubberband(None)
|
||||
|
||||
def _update_buttons_checked(self):
|
||||
for name, active in [("Pan", "PAN"), ("Zoom", "ZOOM")]:
|
||||
button = self._gtk_ids.get(name)
|
||||
if button:
|
||||
with button.handler_block(button._signal_handler):
|
||||
button.set_active(self.mode.name == active)
|
||||
|
||||
def pan(self, *args):
|
||||
super().pan(*args)
|
||||
self._update_buttons_checked()
|
||||
|
||||
def zoom(self, *args):
|
||||
super().zoom(*args)
|
||||
self._update_buttons_checked()
|
||||
|
||||
def set_history_buttons(self):
|
||||
can_backward = self._nav_stack._pos > 0
|
||||
can_forward = self._nav_stack._pos < len(self._nav_stack) - 1
|
||||
if 'Back' in self._gtk_ids:
|
||||
self._gtk_ids['Back'].set_sensitive(can_backward)
|
||||
if 'Forward' in self._gtk_ids:
|
||||
self._gtk_ids['Forward'].set_sensitive(can_forward)
|
||||
|
||||
|
||||
class RubberbandGTK(backend_tools.RubberbandBase):
|
||||
def draw_rubberband(self, x0, y0, x1, y1):
|
||||
_NavigationToolbar2GTK.draw_rubberband(
|
||||
self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
|
||||
|
||||
def remove_rubberband(self):
|
||||
_NavigationToolbar2GTK.remove_rubberband(
|
||||
self._make_classic_style_pseudo_toolbar())
|
||||
|
||||
|
||||
class ConfigureSubplotsGTK(backend_tools.ConfigureSubplotsBase):
|
||||
def trigger(self, *args):
|
||||
_NavigationToolbar2GTK.configure_subplots(self, None)
|
||||
|
||||
|
||||
class _BackendGTK(_Backend):
|
||||
backend_version = "{}.{}.{}".format(
|
||||
Gtk.get_major_version(),
|
||||
Gtk.get_minor_version(),
|
||||
Gtk.get_micro_version(),
|
||||
)
|
||||
mainloop = _FigureManagerGTK.start_main_loop
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
"""
|
||||
Common functionality between the PDF and PS backends.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
import functools
|
||||
import logging
|
||||
|
||||
from fontTools import subset
|
||||
|
||||
import matplotlib as mpl
|
||||
from .. import font_manager, ft2font
|
||||
from .._afm import AFM
|
||||
from ..backend_bases import RendererBase
|
||||
|
||||
|
||||
@functools.lru_cache(50)
|
||||
def _cached_get_afm_from_fname(fname):
|
||||
with open(fname, "rb") as fh:
|
||||
return AFM(fh)
|
||||
|
||||
|
||||
def get_glyphs_subset(fontfile, characters):
|
||||
"""
|
||||
Subset a TTF font
|
||||
|
||||
Reads the named fontfile and restricts the font to the characters.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
fontfile : str
|
||||
Path to the font file
|
||||
characters : str
|
||||
Continuous set of characters to include in subset
|
||||
|
||||
Returns
|
||||
-------
|
||||
fontTools.ttLib.ttFont.TTFont
|
||||
An open font object representing the subset, which needs to
|
||||
be closed by the caller.
|
||||
"""
|
||||
|
||||
options = subset.Options(glyph_names=True, recommended_glyphs=True)
|
||||
|
||||
# Prevent subsetting extra tables.
|
||||
options.drop_tables += [
|
||||
'FFTM', # FontForge Timestamp.
|
||||
'PfEd', # FontForge personal table.
|
||||
'BDF', # X11 BDF header.
|
||||
'meta', # Metadata stores design/supported languages (meaningless for subsets).
|
||||
'MERG', # Merge Table.
|
||||
'TSIV', # Microsoft Visual TrueType extension.
|
||||
'Zapf', # Information about the individual glyphs in the font.
|
||||
'bdat', # The bitmap data table.
|
||||
'bloc', # The bitmap location table.
|
||||
'cidg', # CID to Glyph ID table (Apple Advanced Typography).
|
||||
'fdsc', # The font descriptors table.
|
||||
'feat', # Feature name table (Apple Advanced Typography).
|
||||
'fmtx', # The Font Metrics Table.
|
||||
'fond', # Data-fork font information (Apple Advanced Typography).
|
||||
'just', # The justification table (Apple Advanced Typography).
|
||||
'kerx', # An extended kerning table (Apple Advanced Typography).
|
||||
'ltag', # Language Tag.
|
||||
'morx', # Extended Glyph Metamorphosis Table.
|
||||
'trak', # Tracking table.
|
||||
'xref', # The cross-reference table (some Apple font tooling information).
|
||||
]
|
||||
# if fontfile is a ttc, specify font number
|
||||
if fontfile.endswith(".ttc"):
|
||||
options.font_number = 0
|
||||
|
||||
font = subset.load_font(fontfile, options)
|
||||
subsetter = subset.Subsetter(options=options)
|
||||
subsetter.populate(text=characters)
|
||||
subsetter.subset(font)
|
||||
return font
|
||||
|
||||
|
||||
def font_as_file(font):
|
||||
"""
|
||||
Convert a TTFont object into a file-like object.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
font : fontTools.ttLib.ttFont.TTFont
|
||||
A font object
|
||||
|
||||
Returns
|
||||
-------
|
||||
BytesIO
|
||||
A file object with the font saved into it
|
||||
"""
|
||||
fh = BytesIO()
|
||||
font.save(fh, reorderTables=False)
|
||||
return fh
|
||||
|
||||
|
||||
class CharacterTracker:
|
||||
"""
|
||||
Helper for font subsetting by the pdf and ps backends.
|
||||
|
||||
Maintains a mapping of font paths to the set of character codepoints that
|
||||
are being used from that font.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.used = {}
|
||||
|
||||
def track(self, font, s):
|
||||
"""Record that string *s* is being typeset using font *font*."""
|
||||
char_to_font = font._get_fontmap(s)
|
||||
for _c, _f in char_to_font.items():
|
||||
self.used.setdefault(_f.fname, set()).add(ord(_c))
|
||||
|
||||
def track_glyph(self, font, glyph):
|
||||
"""Record that codepoint *glyph* is being typeset using font *font*."""
|
||||
self.used.setdefault(font.fname, set()).add(glyph)
|
||||
|
||||
|
||||
class RendererPDFPSBase(RendererBase):
|
||||
# The following attributes must be defined by the subclasses:
|
||||
# - _afm_font_dir
|
||||
# - _use_afm_rc_name
|
||||
|
||||
def __init__(self, width, height):
|
||||
super().__init__()
|
||||
self.width = width
|
||||
self.height = height
|
||||
|
||||
def flipy(self):
|
||||
# docstring inherited
|
||||
return False # y increases from bottom to top.
|
||||
|
||||
def option_scale_image(self):
|
||||
# docstring inherited
|
||||
return True # PDF and PS support arbitrary image scaling.
|
||||
|
||||
def option_image_nocomposite(self):
|
||||
# docstring inherited
|
||||
# Decide whether to composite image based on rcParam value.
|
||||
return not mpl.rcParams["image.composite_image"]
|
||||
|
||||
def get_canvas_width_height(self):
|
||||
# docstring inherited
|
||||
return self.width * 72.0, self.height * 72.0
|
||||
|
||||
def get_text_width_height_descent(self, s, prop, ismath):
|
||||
# docstring inherited
|
||||
if ismath == "TeX":
|
||||
return super().get_text_width_height_descent(s, prop, ismath)
|
||||
elif ismath:
|
||||
parse = self._text2path.mathtext_parser.parse(s, 72, prop)
|
||||
return parse.width, parse.height, parse.depth
|
||||
elif mpl.rcParams[self._use_afm_rc_name]:
|
||||
font = self._get_font_afm(prop)
|
||||
l, b, w, h, d = font.get_str_bbox_and_descent(s)
|
||||
scale = prop.get_size_in_points() / 1000
|
||||
w *= scale
|
||||
h *= scale
|
||||
d *= scale
|
||||
return w, h, d
|
||||
else:
|
||||
font = self._get_font_ttf(prop)
|
||||
font.set_text(s, 0.0, flags=ft2font.LoadFlags.NO_HINTING)
|
||||
w, h = font.get_width_height()
|
||||
d = font.get_descent()
|
||||
scale = 1 / 64
|
||||
w *= scale
|
||||
h *= scale
|
||||
d *= scale
|
||||
return w, h, d
|
||||
|
||||
def _get_font_afm(self, prop):
|
||||
fname = font_manager.findfont(
|
||||
prop, fontext="afm", directory=self._afm_font_dir)
|
||||
return _cached_get_afm_from_fname(fname)
|
||||
|
||||
def _get_font_ttf(self, prop):
|
||||
fnames = font_manager.fontManager._find_fonts_by_props(prop)
|
||||
try:
|
||||
font = font_manager.get_font(fnames)
|
||||
font.clear()
|
||||
font.set_size(prop.get_size_in_points(), 72)
|
||||
return font
|
||||
except RuntimeError:
|
||||
logging.getLogger(__name__).warning(
|
||||
"The PostScript/PDF backend does not currently "
|
||||
"support the selected font (%s).", fnames)
|
||||
raise
|
||||
1105
venv/lib/python3.13/site-packages/matplotlib/backends/_backend_tk.py
Normal file
1105
venv/lib/python3.13/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,528 @@
|
|||
"""
|
||||
An `Anti-Grain Geometry`_ (AGG) backend.
|
||||
|
||||
Features that are implemented:
|
||||
|
||||
* capstyles and join styles
|
||||
* dashes
|
||||
* linewidth
|
||||
* lines, rectangles, ellipses
|
||||
* clipping to a rectangle
|
||||
* output to RGBA and Pillow-supported image formats
|
||||
* alpha blending
|
||||
* DPI scaling properly - everything scales properly (dashes, linewidths, etc)
|
||||
* draw polygon
|
||||
* freetype2 w/ ft2font
|
||||
|
||||
Still TODO:
|
||||
|
||||
* integrate screen dpi w/ ppi and text
|
||||
|
||||
.. _Anti-Grain Geometry: http://agg.sourceforge.net/antigrain.com
|
||||
"""
|
||||
|
||||
from contextlib import nullcontext
|
||||
from math import radians, cos, sin
|
||||
|
||||
import numpy as np
|
||||
|
||||
import matplotlib as mpl
|
||||
from matplotlib import _api, cbook
|
||||
from matplotlib.backend_bases import (
|
||||
_Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
|
||||
from matplotlib.font_manager import fontManager as _fontManager, get_font
|
||||
from matplotlib.ft2font import LoadFlags
|
||||
from matplotlib.mathtext import MathTextParser
|
||||
from matplotlib.path import Path
|
||||
from matplotlib.transforms import Bbox, BboxBase
|
||||
from matplotlib.backends._backend_agg import RendererAgg as _RendererAgg
|
||||
|
||||
|
||||
def get_hinting_flag():
|
||||
mapping = {
|
||||
'default': LoadFlags.DEFAULT,
|
||||
'no_autohint': LoadFlags.NO_AUTOHINT,
|
||||
'force_autohint': LoadFlags.FORCE_AUTOHINT,
|
||||
'no_hinting': LoadFlags.NO_HINTING,
|
||||
True: LoadFlags.FORCE_AUTOHINT,
|
||||
False: LoadFlags.NO_HINTING,
|
||||
'either': LoadFlags.DEFAULT,
|
||||
'native': LoadFlags.NO_AUTOHINT,
|
||||
'auto': LoadFlags.FORCE_AUTOHINT,
|
||||
'none': LoadFlags.NO_HINTING,
|
||||
}
|
||||
return mapping[mpl.rcParams['text.hinting']]
|
||||
|
||||
|
||||
class RendererAgg(RendererBase):
|
||||
"""
|
||||
The renderer handles all the drawing primitives using a graphics
|
||||
context instance that controls the colors/styles
|
||||
"""
|
||||
|
||||
def __init__(self, width, height, dpi):
|
||||
super().__init__()
|
||||
|
||||
self.dpi = dpi
|
||||
self.width = width
|
||||
self.height = height
|
||||
self._renderer = _RendererAgg(int(width), int(height), dpi)
|
||||
self._filter_renderers = []
|
||||
|
||||
self._update_methods()
|
||||
self.mathtext_parser = MathTextParser('agg')
|
||||
|
||||
self.bbox = Bbox.from_bounds(0, 0, self.width, self.height)
|
||||
|
||||
def __getstate__(self):
|
||||
# We only want to preserve the init keywords of the Renderer.
|
||||
# Anything else can be re-created.
|
||||
return {'width': self.width, 'height': self.height, 'dpi': self.dpi}
|
||||
|
||||
def __setstate__(self, state):
|
||||
self.__init__(state['width'], state['height'], state['dpi'])
|
||||
|
||||
def _update_methods(self):
|
||||
self.draw_gouraud_triangles = self._renderer.draw_gouraud_triangles
|
||||
self.draw_image = self._renderer.draw_image
|
||||
self.draw_markers = self._renderer.draw_markers
|
||||
self.draw_path_collection = self._renderer.draw_path_collection
|
||||
self.draw_quad_mesh = self._renderer.draw_quad_mesh
|
||||
self.copy_from_bbox = self._renderer.copy_from_bbox
|
||||
|
||||
def draw_path(self, gc, path, transform, rgbFace=None):
|
||||
# docstring inherited
|
||||
nmax = mpl.rcParams['agg.path.chunksize'] # here at least for testing
|
||||
npts = path.vertices.shape[0]
|
||||
|
||||
if (npts > nmax > 100 and path.should_simplify and
|
||||
rgbFace is None and gc.get_hatch() is None):
|
||||
nch = np.ceil(npts / nmax)
|
||||
chsize = int(np.ceil(npts / nch))
|
||||
i0 = np.arange(0, npts, chsize)
|
||||
i1 = np.zeros_like(i0)
|
||||
i1[:-1] = i0[1:] - 1
|
||||
i1[-1] = npts
|
||||
for ii0, ii1 in zip(i0, i1):
|
||||
v = path.vertices[ii0:ii1, :]
|
||||
c = path.codes
|
||||
if c is not None:
|
||||
c = c[ii0:ii1]
|
||||
c[0] = Path.MOVETO # move to end of last chunk
|
||||
p = Path(v, c)
|
||||
p.simplify_threshold = path.simplify_threshold
|
||||
try:
|
||||
self._renderer.draw_path(gc, p, transform, rgbFace)
|
||||
except OverflowError:
|
||||
msg = (
|
||||
"Exceeded cell block limit in Agg.\n\n"
|
||||
"Please reduce the value of "
|
||||
f"rcParams['agg.path.chunksize'] (currently {nmax}) "
|
||||
"or increase the path simplification threshold"
|
||||
"(rcParams['path.simplify_threshold'] = "
|
||||
f"{mpl.rcParams['path.simplify_threshold']:.2f} by "
|
||||
"default and path.simplify_threshold = "
|
||||
f"{path.simplify_threshold:.2f} on the input)."
|
||||
)
|
||||
raise OverflowError(msg) from None
|
||||
else:
|
||||
try:
|
||||
self._renderer.draw_path(gc, path, transform, rgbFace)
|
||||
except OverflowError:
|
||||
cant_chunk = ''
|
||||
if rgbFace is not None:
|
||||
cant_chunk += "- cannot split filled path\n"
|
||||
if gc.get_hatch() is not None:
|
||||
cant_chunk += "- cannot split hatched path\n"
|
||||
if not path.should_simplify:
|
||||
cant_chunk += "- path.should_simplify is False\n"
|
||||
if len(cant_chunk):
|
||||
msg = (
|
||||
"Exceeded cell block limit in Agg, however for the "
|
||||
"following reasons:\n\n"
|
||||
f"{cant_chunk}\n"
|
||||
"we cannot automatically split up this path to draw."
|
||||
"\n\nPlease manually simplify your path."
|
||||
)
|
||||
|
||||
else:
|
||||
inc_threshold = (
|
||||
"or increase the path simplification threshold"
|
||||
"(rcParams['path.simplify_threshold'] = "
|
||||
f"{mpl.rcParams['path.simplify_threshold']} "
|
||||
"by default and path.simplify_threshold "
|
||||
f"= {path.simplify_threshold} "
|
||||
"on the input)."
|
||||
)
|
||||
if nmax > 100:
|
||||
msg = (
|
||||
"Exceeded cell block limit in Agg. Please reduce "
|
||||
"the value of rcParams['agg.path.chunksize'] "
|
||||
f"(currently {nmax}) {inc_threshold}"
|
||||
)
|
||||
else:
|
||||
msg = (
|
||||
"Exceeded cell block limit in Agg. Please set "
|
||||
"the value of rcParams['agg.path.chunksize'], "
|
||||
f"(currently {nmax}) to be greater than 100 "
|
||||
+ inc_threshold
|
||||
)
|
||||
|
||||
raise OverflowError(msg) from None
|
||||
|
||||
def draw_mathtext(self, gc, x, y, s, prop, angle):
|
||||
"""Draw mathtext using :mod:`matplotlib.mathtext`."""
|
||||
ox, oy, width, height, descent, font_image = \
|
||||
self.mathtext_parser.parse(s, self.dpi, prop,
|
||||
antialiased=gc.get_antialiased())
|
||||
|
||||
xd = descent * sin(radians(angle))
|
||||
yd = descent * cos(radians(angle))
|
||||
x = round(x + ox + xd)
|
||||
y = round(y - oy + yd)
|
||||
self._renderer.draw_text_image(font_image, x, y + 1, angle, gc)
|
||||
|
||||
def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
|
||||
# docstring inherited
|
||||
if ismath:
|
||||
return self.draw_mathtext(gc, x, y, s, prop, angle)
|
||||
font = self._prepare_font(prop)
|
||||
# We pass '0' for angle here, since it will be rotated (in raster
|
||||
# space) in the following call to draw_text_image).
|
||||
font.set_text(s, 0, flags=get_hinting_flag())
|
||||
font.draw_glyphs_to_bitmap(
|
||||
antialiased=gc.get_antialiased())
|
||||
d = font.get_descent() / 64.0
|
||||
# The descent needs to be adjusted for the angle.
|
||||
xo, yo = font.get_bitmap_offset()
|
||||
xo /= 64.0
|
||||
yo /= 64.0
|
||||
xd = d * sin(radians(angle))
|
||||
yd = d * cos(radians(angle))
|
||||
x = round(x + xo + xd)
|
||||
y = round(y + yo + yd)
|
||||
self._renderer.draw_text_image(font, x, y + 1, angle, gc)
|
||||
|
||||
def get_text_width_height_descent(self, s, prop, ismath):
|
||||
# docstring inherited
|
||||
|
||||
_api.check_in_list(["TeX", True, False], ismath=ismath)
|
||||
if ismath == "TeX":
|
||||
return super().get_text_width_height_descent(s, prop, ismath)
|
||||
|
||||
if ismath:
|
||||
ox, oy, width, height, descent, font_image = \
|
||||
self.mathtext_parser.parse(s, self.dpi, prop)
|
||||
return width, height, descent
|
||||
|
||||
font = self._prepare_font(prop)
|
||||
font.set_text(s, 0.0, flags=get_hinting_flag())
|
||||
w, h = font.get_width_height() # width and height of unrotated string
|
||||
d = font.get_descent()
|
||||
w /= 64.0 # convert from subpixels
|
||||
h /= 64.0
|
||||
d /= 64.0
|
||||
return w, h, d
|
||||
|
||||
def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None):
|
||||
# docstring inherited
|
||||
# todo, handle props, angle, origins
|
||||
size = prop.get_size_in_points()
|
||||
|
||||
texmanager = self.get_texmanager()
|
||||
|
||||
Z = texmanager.get_grey(s, size, self.dpi)
|
||||
Z = np.array(Z * 255.0, np.uint8)
|
||||
|
||||
w, h, d = self.get_text_width_height_descent(s, prop, ismath="TeX")
|
||||
xd = d * sin(radians(angle))
|
||||
yd = d * cos(radians(angle))
|
||||
x = round(x + xd)
|
||||
y = round(y + yd)
|
||||
self._renderer.draw_text_image(Z, x, y, angle, gc)
|
||||
|
||||
def get_canvas_width_height(self):
|
||||
# docstring inherited
|
||||
return self.width, self.height
|
||||
|
||||
def _prepare_font(self, font_prop):
|
||||
"""
|
||||
Get the `.FT2Font` for *font_prop*, clear its buffer, and set its size.
|
||||
"""
|
||||
font = get_font(_fontManager._find_fonts_by_props(font_prop))
|
||||
font.clear()
|
||||
size = font_prop.get_size_in_points()
|
||||
font.set_size(size, self.dpi)
|
||||
return font
|
||||
|
||||
def points_to_pixels(self, points):
|
||||
# docstring inherited
|
||||
return points * self.dpi / 72
|
||||
|
||||
def buffer_rgba(self):
|
||||
return memoryview(self._renderer)
|
||||
|
||||
def tostring_argb(self):
|
||||
return np.asarray(self._renderer).take([3, 0, 1, 2], axis=2).tobytes()
|
||||
|
||||
def clear(self):
|
||||
self._renderer.clear()
|
||||
|
||||
def option_image_nocomposite(self):
|
||||
# docstring inherited
|
||||
|
||||
# It is generally faster to composite each image directly to
|
||||
# the Figure, and there's no file size benefit to compositing
|
||||
# with the Agg backend
|
||||
return True
|
||||
|
||||
def option_scale_image(self):
|
||||
# docstring inherited
|
||||
return False
|
||||
|
||||
def restore_region(self, region, bbox=None, xy=None):
|
||||
"""
|
||||
Restore the saved region. If bbox (instance of BboxBase, or
|
||||
its extents) is given, only the region specified by the bbox
|
||||
will be restored. *xy* (a pair of floats) optionally
|
||||
specifies the new position (the LLC of the original region,
|
||||
not the LLC of the bbox) where the region will be restored.
|
||||
|
||||
>>> region = renderer.copy_from_bbox()
|
||||
>>> x1, y1, x2, y2 = region.get_extents()
|
||||
>>> renderer.restore_region(region, bbox=(x1+dx, y1, x2, y2),
|
||||
... xy=(x1-dx, y1))
|
||||
|
||||
"""
|
||||
if bbox is not None or xy is not None:
|
||||
if bbox is None:
|
||||
x1, y1, x2, y2 = region.get_extents()
|
||||
elif isinstance(bbox, BboxBase):
|
||||
x1, y1, x2, y2 = bbox.extents
|
||||
else:
|
||||
x1, y1, x2, y2 = bbox
|
||||
|
||||
if xy is None:
|
||||
ox, oy = x1, y1
|
||||
else:
|
||||
ox, oy = xy
|
||||
|
||||
# The incoming data is float, but the _renderer type-checking wants
|
||||
# to see integers.
|
||||
self._renderer.restore_region(region, int(x1), int(y1),
|
||||
int(x2), int(y2), int(ox), int(oy))
|
||||
|
||||
else:
|
||||
self._renderer.restore_region(region)
|
||||
|
||||
def start_filter(self):
|
||||
"""
|
||||
Start filtering. It simply creates a new canvas (the old one is saved).
|
||||
"""
|
||||
self._filter_renderers.append(self._renderer)
|
||||
self._renderer = _RendererAgg(int(self.width), int(self.height),
|
||||
self.dpi)
|
||||
self._update_methods()
|
||||
|
||||
def stop_filter(self, post_processing):
|
||||
"""
|
||||
Save the current canvas as an image and apply post processing.
|
||||
|
||||
The *post_processing* function::
|
||||
|
||||
def post_processing(image, dpi):
|
||||
# ny, nx, depth = image.shape
|
||||
# image (numpy array) has RGBA channels and has a depth of 4.
|
||||
...
|
||||
# create a new_image (numpy array of 4 channels, size can be
|
||||
# different). The resulting image may have offsets from
|
||||
# lower-left corner of the original image
|
||||
return new_image, offset_x, offset_y
|
||||
|
||||
The saved renderer is restored and the returned image from
|
||||
post_processing is plotted (using draw_image) on it.
|
||||
"""
|
||||
orig_img = np.asarray(self.buffer_rgba())
|
||||
slice_y, slice_x = cbook._get_nonzero_slices(orig_img[..., 3])
|
||||
cropped_img = orig_img[slice_y, slice_x]
|
||||
|
||||
self._renderer = self._filter_renderers.pop()
|
||||
self._update_methods()
|
||||
|
||||
if cropped_img.size:
|
||||
img, ox, oy = post_processing(cropped_img / 255, self.dpi)
|
||||
gc = self.new_gc()
|
||||
if img.dtype.kind == 'f':
|
||||
img = np.asarray(img * 255., np.uint8)
|
||||
self._renderer.draw_image(
|
||||
gc, slice_x.start + ox, int(self.height) - slice_y.stop + oy,
|
||||
img[::-1])
|
||||
|
||||
|
||||
class FigureCanvasAgg(FigureCanvasBase):
|
||||
# docstring inherited
|
||||
|
||||
_lastKey = None # Overwritten per-instance on the first draw.
|
||||
|
||||
def copy_from_bbox(self, bbox):
|
||||
renderer = self.get_renderer()
|
||||
return renderer.copy_from_bbox(bbox)
|
||||
|
||||
def restore_region(self, region, bbox=None, xy=None):
|
||||
renderer = self.get_renderer()
|
||||
return renderer.restore_region(region, bbox, xy)
|
||||
|
||||
def draw(self):
|
||||
# docstring inherited
|
||||
self.renderer = self.get_renderer()
|
||||
self.renderer.clear()
|
||||
# Acquire a lock on the shared font cache.
|
||||
with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar
|
||||
else nullcontext()):
|
||||
self.figure.draw(self.renderer)
|
||||
# A GUI class may be need to update a window using this draw, so
|
||||
# don't forget to call the superclass.
|
||||
super().draw()
|
||||
|
||||
def get_renderer(self):
|
||||
w, h = self.figure.bbox.size
|
||||
key = w, h, self.figure.dpi
|
||||
reuse_renderer = (self._lastKey == key)
|
||||
if not reuse_renderer:
|
||||
self.renderer = RendererAgg(w, h, self.figure.dpi)
|
||||
self._lastKey = key
|
||||
return self.renderer
|
||||
|
||||
def tostring_argb(self):
|
||||
"""
|
||||
Get the image as ARGB `bytes`.
|
||||
|
||||
`draw` must be called at least once before this function will work and
|
||||
to update the renderer for any subsequent changes to the Figure.
|
||||
"""
|
||||
return self.renderer.tostring_argb()
|
||||
|
||||
def buffer_rgba(self):
|
||||
"""
|
||||
Get the image as a `memoryview` to the renderer's buffer.
|
||||
|
||||
`draw` must be called at least once before this function will work and
|
||||
to update the renderer for any subsequent changes to the Figure.
|
||||
"""
|
||||
return self.renderer.buffer_rgba()
|
||||
|
||||
def print_raw(self, filename_or_obj, *, metadata=None):
|
||||
if metadata is not None:
|
||||
raise ValueError("metadata not supported for raw/rgba")
|
||||
FigureCanvasAgg.draw(self)
|
||||
renderer = self.get_renderer()
|
||||
with cbook.open_file_cm(filename_or_obj, "wb") as fh:
|
||||
fh.write(renderer.buffer_rgba())
|
||||
|
||||
print_rgba = print_raw
|
||||
|
||||
def _print_pil(self, filename_or_obj, fmt, pil_kwargs, metadata=None):
|
||||
"""
|
||||
Draw the canvas, then save it using `.image.imsave` (to which
|
||||
*pil_kwargs* and *metadata* are forwarded).
|
||||
"""
|
||||
FigureCanvasAgg.draw(self)
|
||||
mpl.image.imsave(
|
||||
filename_or_obj, self.buffer_rgba(), format=fmt, origin="upper",
|
||||
dpi=self.figure.dpi, metadata=metadata, pil_kwargs=pil_kwargs)
|
||||
|
||||
def print_png(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
|
||||
"""
|
||||
Write the figure to a PNG file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filename_or_obj : str or path-like or file-like
|
||||
The file to write to.
|
||||
|
||||
metadata : dict, optional
|
||||
Metadata in the PNG file as key-value pairs of bytes or latin-1
|
||||
encodable strings.
|
||||
According to the PNG specification, keys must be shorter than 79
|
||||
chars.
|
||||
|
||||
The `PNG specification`_ defines some common keywords that may be
|
||||
used as appropriate:
|
||||
|
||||
- Title: Short (one line) title or caption for image.
|
||||
- Author: Name of image's creator.
|
||||
- Description: Description of image (possibly long).
|
||||
- Copyright: Copyright notice.
|
||||
- Creation Time: Time of original image creation
|
||||
(usually RFC 1123 format).
|
||||
- Software: Software used to create the image.
|
||||
- Disclaimer: Legal disclaimer.
|
||||
- Warning: Warning of nature of content.
|
||||
- Source: Device used to create the image.
|
||||
- Comment: Miscellaneous comment;
|
||||
conversion from other image format.
|
||||
|
||||
Other keywords may be invented for other purposes.
|
||||
|
||||
If 'Software' is not given, an autogenerated value for Matplotlib
|
||||
will be used. This can be removed by setting it to *None*.
|
||||
|
||||
For more details see the `PNG specification`_.
|
||||
|
||||
.. _PNG specification: \
|
||||
https://www.w3.org/TR/2003/REC-PNG-20031110/#11keywords
|
||||
|
||||
pil_kwargs : dict, optional
|
||||
Keyword arguments passed to `PIL.Image.Image.save`.
|
||||
|
||||
If the 'pnginfo' key is present, it completely overrides
|
||||
*metadata*, including the default 'Software' key.
|
||||
"""
|
||||
self._print_pil(filename_or_obj, "png", pil_kwargs, metadata)
|
||||
|
||||
def print_to_buffer(self):
|
||||
FigureCanvasAgg.draw(self)
|
||||
renderer = self.get_renderer()
|
||||
return (bytes(renderer.buffer_rgba()),
|
||||
(int(renderer.width), int(renderer.height)))
|
||||
|
||||
# Note that these methods should typically be called via savefig() and
|
||||
# print_figure(), and the latter ensures that `self.figure.dpi` already
|
||||
# matches the dpi kwarg (if any).
|
||||
|
||||
def print_jpg(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
|
||||
# savefig() has already applied savefig.facecolor; we now set it to
|
||||
# white to make imsave() blend semi-transparent figures against an
|
||||
# assumed white background.
|
||||
with mpl.rc_context({"savefig.facecolor": "white"}):
|
||||
self._print_pil(filename_or_obj, "jpeg", pil_kwargs, metadata)
|
||||
|
||||
print_jpeg = print_jpg
|
||||
|
||||
def print_tif(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
|
||||
self._print_pil(filename_or_obj, "tiff", pil_kwargs, metadata)
|
||||
|
||||
print_tiff = print_tif
|
||||
|
||||
def print_webp(self, filename_or_obj, *, metadata=None, pil_kwargs=None):
|
||||
self._print_pil(filename_or_obj, "webp", pil_kwargs, metadata)
|
||||
|
||||
print_jpg.__doc__, print_tif.__doc__, print_webp.__doc__ = map(
|
||||
"""
|
||||
Write the figure to a {} file.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filename_or_obj : str or path-like or file-like
|
||||
The file to write to.
|
||||
pil_kwargs : dict, optional
|
||||
Additional keyword arguments that are passed to
|
||||
`PIL.Image.Image.save` when saving the figure.
|
||||
""".format, ["JPEG", "TIFF", "WebP"])
|
||||
|
||||
|
||||
@_Backend.export
|
||||
class _BackendAgg(_Backend):
|
||||
backend_version = 'v2.2'
|
||||
FigureCanvas = FigureCanvasAgg
|
||||
FigureManager = FigureManagerBase
|
||||
|
|
@ -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,596 @@
|
|||
import functools
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import matplotlib as mpl
|
||||
from matplotlib import _api, backend_tools, cbook
|
||||
from matplotlib.backend_bases import (
|
||||
ToolContainerBase, MouseButton,
|
||||
CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent)
|
||||
|
||||
try:
|
||||
import gi
|
||||
except ImportError as err:
|
||||
raise ImportError("The GTK3 backends require PyGObject") from err
|
||||
|
||||
try:
|
||||
# :raises ValueError: If module/version is already loaded, already
|
||||
# required, or unavailable.
|
||||
gi.require_version("Gtk", "3.0")
|
||||
except ValueError as e:
|
||||
# in this case we want to re-raise as ImportError so the
|
||||
# auto-backend selection logic correctly skips.
|
||||
raise ImportError(e) from e
|
||||
|
||||
from gi.repository import Gio, GLib, GObject, Gtk, Gdk
|
||||
from . import _backend_gtk
|
||||
from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611
|
||||
_BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK,
|
||||
TimerGTK as TimerGTK3,
|
||||
)
|
||||
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@functools.cache
|
||||
def _mpl_to_gtk_cursor(mpl_cursor):
|
||||
return Gdk.Cursor.new_from_name(
|
||||
Gdk.Display.get_default(),
|
||||
_backend_gtk.mpl_to_gtk_cursor_name(mpl_cursor))
|
||||
|
||||
|
||||
class FigureCanvasGTK3(_FigureCanvasGTK, Gtk.DrawingArea):
|
||||
required_interactive_framework = "gtk3"
|
||||
manager_class = _api.classproperty(lambda cls: FigureManagerGTK3)
|
||||
# Setting this as a static constant prevents
|
||||
# this resulting expression from leaking
|
||||
event_mask = (Gdk.EventMask.BUTTON_PRESS_MASK
|
||||
| Gdk.EventMask.BUTTON_RELEASE_MASK
|
||||
| Gdk.EventMask.EXPOSURE_MASK
|
||||
| Gdk.EventMask.KEY_PRESS_MASK
|
||||
| Gdk.EventMask.KEY_RELEASE_MASK
|
||||
| Gdk.EventMask.ENTER_NOTIFY_MASK
|
||||
| Gdk.EventMask.LEAVE_NOTIFY_MASK
|
||||
| Gdk.EventMask.POINTER_MOTION_MASK
|
||||
| Gdk.EventMask.SCROLL_MASK)
|
||||
|
||||
def __init__(self, figure=None):
|
||||
super().__init__(figure=figure)
|
||||
|
||||
self._idle_draw_id = 0
|
||||
self._rubberband_rect = None
|
||||
|
||||
self.connect('scroll_event', self.scroll_event)
|
||||
self.connect('button_press_event', self.button_press_event)
|
||||
self.connect('button_release_event', self.button_release_event)
|
||||
self.connect('configure_event', self.configure_event)
|
||||
self.connect('screen-changed', self._update_device_pixel_ratio)
|
||||
self.connect('notify::scale-factor', self._update_device_pixel_ratio)
|
||||
self.connect('draw', self.on_draw_event)
|
||||
self.connect('draw', self._post_draw)
|
||||
self.connect('key_press_event', self.key_press_event)
|
||||
self.connect('key_release_event', self.key_release_event)
|
||||
self.connect('motion_notify_event', self.motion_notify_event)
|
||||
self.connect('enter_notify_event', self.enter_notify_event)
|
||||
self.connect('leave_notify_event', self.leave_notify_event)
|
||||
self.connect('size_allocate', self.size_allocate)
|
||||
|
||||
self.set_events(self.__class__.event_mask)
|
||||
|
||||
self.set_can_focus(True)
|
||||
|
||||
css = Gtk.CssProvider()
|
||||
css.load_from_data(b".matplotlib-canvas { background-color: white; }")
|
||||
style_ctx = self.get_style_context()
|
||||
style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
style_ctx.add_class("matplotlib-canvas")
|
||||
|
||||
def destroy(self):
|
||||
CloseEvent("close_event", self)._process()
|
||||
|
||||
def set_cursor(self, cursor):
|
||||
# docstring inherited
|
||||
window = self.get_property("window")
|
||||
if window is not None:
|
||||
window.set_cursor(_mpl_to_gtk_cursor(cursor))
|
||||
context = GLib.MainContext.default()
|
||||
context.iteration(True)
|
||||
|
||||
def _mpl_coords(self, event=None):
|
||||
"""
|
||||
Convert the position of a GTK event, or of the current cursor position
|
||||
if *event* is None, to Matplotlib coordinates.
|
||||
|
||||
GTK use logical pixels, but the figure is scaled to physical pixels for
|
||||
rendering. Transform to physical pixels so that all of the down-stream
|
||||
transforms work as expected.
|
||||
|
||||
Also, the origin is different and needs to be corrected.
|
||||
"""
|
||||
if event is None:
|
||||
window = self.get_window()
|
||||
t, x, y, state = window.get_device_position(
|
||||
window.get_display().get_device_manager().get_client_pointer())
|
||||
else:
|
||||
x, y = event.x, event.y
|
||||
x = x * self.device_pixel_ratio
|
||||
# flip y so y=0 is bottom of canvas
|
||||
y = self.figure.bbox.height - y * self.device_pixel_ratio
|
||||
return x, y
|
||||
|
||||
def scroll_event(self, widget, event):
|
||||
step = 1 if event.direction == Gdk.ScrollDirection.UP else -1
|
||||
MouseEvent("scroll_event", self,
|
||||
*self._mpl_coords(event), step=step,
|
||||
modifiers=self._mpl_modifiers(event.state),
|
||||
guiEvent=event)._process()
|
||||
return False # finish event propagation?
|
||||
|
||||
def button_press_event(self, widget, event):
|
||||
MouseEvent("button_press_event", self,
|
||||
*self._mpl_coords(event), event.button,
|
||||
modifiers=self._mpl_modifiers(event.state),
|
||||
guiEvent=event)._process()
|
||||
return False # finish event propagation?
|
||||
|
||||
def button_release_event(self, widget, event):
|
||||
MouseEvent("button_release_event", self,
|
||||
*self._mpl_coords(event), event.button,
|
||||
modifiers=self._mpl_modifiers(event.state),
|
||||
guiEvent=event)._process()
|
||||
return False # finish event propagation?
|
||||
|
||||
def key_press_event(self, widget, event):
|
||||
KeyEvent("key_press_event", self,
|
||||
self._get_key(event), *self._mpl_coords(),
|
||||
guiEvent=event)._process()
|
||||
return True # stop event propagation
|
||||
|
||||
def key_release_event(self, widget, event):
|
||||
KeyEvent("key_release_event", self,
|
||||
self._get_key(event), *self._mpl_coords(),
|
||||
guiEvent=event)._process()
|
||||
return True # stop event propagation
|
||||
|
||||
def motion_notify_event(self, widget, event):
|
||||
MouseEvent("motion_notify_event", self, *self._mpl_coords(event),
|
||||
buttons=self._mpl_buttons(event.state),
|
||||
modifiers=self._mpl_modifiers(event.state),
|
||||
guiEvent=event)._process()
|
||||
return False # finish event propagation?
|
||||
|
||||
def enter_notify_event(self, widget, event):
|
||||
gtk_mods = Gdk.Keymap.get_for_display(
|
||||
self.get_display()).get_modifier_state()
|
||||
LocationEvent("figure_enter_event", self, *self._mpl_coords(event),
|
||||
modifiers=self._mpl_modifiers(gtk_mods),
|
||||
guiEvent=event)._process()
|
||||
|
||||
def leave_notify_event(self, widget, event):
|
||||
gtk_mods = Gdk.Keymap.get_for_display(
|
||||
self.get_display()).get_modifier_state()
|
||||
LocationEvent("figure_leave_event", self, *self._mpl_coords(event),
|
||||
modifiers=self._mpl_modifiers(gtk_mods),
|
||||
guiEvent=event)._process()
|
||||
|
||||
def size_allocate(self, widget, allocation):
|
||||
dpival = self.figure.dpi
|
||||
winch = allocation.width * self.device_pixel_ratio / dpival
|
||||
hinch = allocation.height * self.device_pixel_ratio / dpival
|
||||
self.figure.set_size_inches(winch, hinch, forward=False)
|
||||
ResizeEvent("resize_event", self)._process()
|
||||
self.draw_idle()
|
||||
|
||||
@staticmethod
|
||||
def _mpl_buttons(event_state):
|
||||
modifiers = [
|
||||
(MouseButton.LEFT, Gdk.ModifierType.BUTTON1_MASK),
|
||||
(MouseButton.MIDDLE, Gdk.ModifierType.BUTTON2_MASK),
|
||||
(MouseButton.RIGHT, Gdk.ModifierType.BUTTON3_MASK),
|
||||
(MouseButton.BACK, Gdk.ModifierType.BUTTON4_MASK),
|
||||
(MouseButton.FORWARD, Gdk.ModifierType.BUTTON5_MASK),
|
||||
]
|
||||
# State *before* press/release.
|
||||
return [name for name, mask in modifiers if event_state & mask]
|
||||
|
||||
@staticmethod
|
||||
def _mpl_modifiers(event_state, *, exclude=None):
|
||||
modifiers = [
|
||||
("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"),
|
||||
("alt", Gdk.ModifierType.MOD1_MASK, "alt"),
|
||||
("shift", Gdk.ModifierType.SHIFT_MASK, "shift"),
|
||||
("super", Gdk.ModifierType.MOD4_MASK, "super"),
|
||||
]
|
||||
return [name for name, mask, key in modifiers
|
||||
if exclude != key and event_state & mask]
|
||||
|
||||
def _get_key(self, event):
|
||||
unikey = chr(Gdk.keyval_to_unicode(event.keyval))
|
||||
key = cbook._unikey_or_keysym_to_mplkey(
|
||||
unikey, Gdk.keyval_name(event.keyval))
|
||||
mods = self._mpl_modifiers(event.state, exclude=key)
|
||||
if "shift" in mods and unikey.isprintable():
|
||||
mods.remove("shift")
|
||||
return "+".join([*mods, key])
|
||||
|
||||
def _update_device_pixel_ratio(self, *args, **kwargs):
|
||||
# We need to be careful in cases with mixed resolution displays if
|
||||
# device_pixel_ratio changes.
|
||||
if self._set_device_pixel_ratio(self.get_scale_factor()):
|
||||
# The easiest way to resize the canvas is to emit a resize event
|
||||
# since we implement all the logic for resizing the canvas for that
|
||||
# event.
|
||||
self.queue_resize()
|
||||
self.queue_draw()
|
||||
|
||||
def configure_event(self, widget, event):
|
||||
if widget.get_property("window") is None:
|
||||
return
|
||||
w = event.width * self.device_pixel_ratio
|
||||
h = event.height * self.device_pixel_ratio
|
||||
if w < 3 or h < 3:
|
||||
return # empty fig
|
||||
# resize the figure (in inches)
|
||||
dpi = self.figure.dpi
|
||||
self.figure.set_size_inches(w / dpi, h / dpi, forward=False)
|
||||
return False # finish event propagation?
|
||||
|
||||
def _draw_rubberband(self, rect):
|
||||
self._rubberband_rect = rect
|
||||
# TODO: Only update the rubberband area.
|
||||
self.queue_draw()
|
||||
|
||||
def _post_draw(self, widget, ctx):
|
||||
if self._rubberband_rect is None:
|
||||
return
|
||||
|
||||
x0, y0, w, h = (dim / self.device_pixel_ratio
|
||||
for dim in self._rubberband_rect)
|
||||
x1 = x0 + w
|
||||
y1 = y0 + h
|
||||
|
||||
# Draw the lines from x0, y0 towards x1, y1 so that the
|
||||
# dashes don't "jump" when moving the zoom box.
|
||||
ctx.move_to(x0, y0)
|
||||
ctx.line_to(x0, y1)
|
||||
ctx.move_to(x0, y0)
|
||||
ctx.line_to(x1, y0)
|
||||
ctx.move_to(x0, y1)
|
||||
ctx.line_to(x1, y1)
|
||||
ctx.move_to(x1, y0)
|
||||
ctx.line_to(x1, y1)
|
||||
|
||||
ctx.set_antialias(1)
|
||||
ctx.set_line_width(1)
|
||||
ctx.set_dash((3, 3), 0)
|
||||
ctx.set_source_rgb(0, 0, 0)
|
||||
ctx.stroke_preserve()
|
||||
|
||||
ctx.set_dash((3, 3), 3)
|
||||
ctx.set_source_rgb(1, 1, 1)
|
||||
ctx.stroke()
|
||||
|
||||
def on_draw_event(self, widget, ctx):
|
||||
# to be overwritten by GTK3Agg or GTK3Cairo
|
||||
pass
|
||||
|
||||
def draw(self):
|
||||
# docstring inherited
|
||||
if self.is_drawable():
|
||||
self.queue_draw()
|
||||
|
||||
def draw_idle(self):
|
||||
# docstring inherited
|
||||
if self._idle_draw_id != 0:
|
||||
return
|
||||
def idle_draw(*args):
|
||||
try:
|
||||
self.draw()
|
||||
finally:
|
||||
self._idle_draw_id = 0
|
||||
return False
|
||||
self._idle_draw_id = GLib.idle_add(idle_draw)
|
||||
|
||||
def flush_events(self):
|
||||
# docstring inherited
|
||||
context = GLib.MainContext.default()
|
||||
while context.pending():
|
||||
context.iteration(True)
|
||||
|
||||
|
||||
class NavigationToolbar2GTK3(_NavigationToolbar2GTK, Gtk.Toolbar):
|
||||
def __init__(self, canvas):
|
||||
GObject.GObject.__init__(self)
|
||||
|
||||
self.set_style(Gtk.ToolbarStyle.ICONS)
|
||||
|
||||
self._gtk_ids = {}
|
||||
for text, tooltip_text, image_file, callback in self.toolitems:
|
||||
if text is None:
|
||||
self.insert(Gtk.SeparatorToolItem(), -1)
|
||||
continue
|
||||
image = Gtk.Image.new_from_gicon(
|
||||
Gio.Icon.new_for_string(
|
||||
str(cbook._get_data_path('images',
|
||||
f'{image_file}-symbolic.svg'))),
|
||||
Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self._gtk_ids[text] = button = (
|
||||
Gtk.ToggleToolButton() if callback in ['zoom', 'pan'] else
|
||||
Gtk.ToolButton())
|
||||
button.set_label(text)
|
||||
button.set_icon_widget(image)
|
||||
# Save the handler id, so that we can block it as needed.
|
||||
button._signal_handler = button.connect(
|
||||
'clicked', getattr(self, callback))
|
||||
button.set_tooltip_text(tooltip_text)
|
||||
self.insert(button, -1)
|
||||
|
||||
# This filler item ensures the toolbar is always at least two text
|
||||
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
|
||||
# over images because those use two-line messages which resize the
|
||||
# toolbar.
|
||||
toolitem = Gtk.ToolItem()
|
||||
self.insert(toolitem, -1)
|
||||
label = Gtk.Label()
|
||||
label.set_markup(
|
||||
'<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
|
||||
toolitem.set_expand(True) # Push real message to the right.
|
||||
toolitem.add(label)
|
||||
|
||||
toolitem = Gtk.ToolItem()
|
||||
self.insert(toolitem, -1)
|
||||
self.message = Gtk.Label()
|
||||
self.message.set_justify(Gtk.Justification.RIGHT)
|
||||
toolitem.add(self.message)
|
||||
|
||||
self.show_all()
|
||||
|
||||
_NavigationToolbar2GTK.__init__(self, canvas)
|
||||
|
||||
def save_figure(self, *args):
|
||||
dialog = Gtk.FileChooserDialog(
|
||||
title="Save the figure",
|
||||
transient_for=self.canvas.get_toplevel(),
|
||||
action=Gtk.FileChooserAction.SAVE,
|
||||
buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL,
|
||||
Gtk.STOCK_SAVE, Gtk.ResponseType.OK),
|
||||
)
|
||||
for name, fmts \
|
||||
in self.canvas.get_supported_filetypes_grouped().items():
|
||||
ff = Gtk.FileFilter()
|
||||
ff.set_name(name)
|
||||
for fmt in fmts:
|
||||
ff.add_pattern(f'*.{fmt}')
|
||||
dialog.add_filter(ff)
|
||||
if self.canvas.get_default_filetype() in fmts:
|
||||
dialog.set_filter(ff)
|
||||
|
||||
@functools.partial(dialog.connect, "notify::filter")
|
||||
def on_notify_filter(*args):
|
||||
name = dialog.get_filter().get_name()
|
||||
fmt = self.canvas.get_supported_filetypes_grouped()[name][0]
|
||||
dialog.set_current_name(
|
||||
str(Path(dialog.get_current_name()).with_suffix(f'.{fmt}')))
|
||||
|
||||
dialog.set_current_folder(mpl.rcParams["savefig.directory"])
|
||||
dialog.set_current_name(self.canvas.get_default_filename())
|
||||
dialog.set_do_overwrite_confirmation(True)
|
||||
|
||||
response = dialog.run()
|
||||
fname = dialog.get_filename()
|
||||
ff = dialog.get_filter() # Doesn't autoadjust to filename :/
|
||||
fmt = self.canvas.get_supported_filetypes_grouped()[ff.get_name()][0]
|
||||
dialog.destroy()
|
||||
if response != Gtk.ResponseType.OK:
|
||||
return None
|
||||
# Save dir for next time, unless empty str (which means use cwd).
|
||||
if mpl.rcParams['savefig.directory']:
|
||||
mpl.rcParams['savefig.directory'] = os.path.dirname(fname)
|
||||
try:
|
||||
self.canvas.figure.savefig(fname, format=fmt)
|
||||
return fname
|
||||
except Exception as e:
|
||||
dialog = Gtk.MessageDialog(
|
||||
transient_for=self.canvas.get_toplevel(), text=str(e),
|
||||
message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK)
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
|
||||
class ToolbarGTK3(ToolContainerBase, Gtk.Box):
|
||||
_icon_extension = '-symbolic.svg'
|
||||
|
||||
def __init__(self, toolmanager):
|
||||
ToolContainerBase.__init__(self, toolmanager)
|
||||
Gtk.Box.__init__(self)
|
||||
self.set_property('orientation', Gtk.Orientation.HORIZONTAL)
|
||||
self._message = Gtk.Label()
|
||||
self._message.set_justify(Gtk.Justification.RIGHT)
|
||||
self.pack_end(self._message, False, False, 0)
|
||||
self.show_all()
|
||||
self._groups = {}
|
||||
self._toolitems = {}
|
||||
|
||||
def add_toolitem(self, name, group, position, image_file, description,
|
||||
toggle):
|
||||
if toggle:
|
||||
button = Gtk.ToggleToolButton()
|
||||
else:
|
||||
button = Gtk.ToolButton()
|
||||
button.set_label(name)
|
||||
|
||||
if image_file is not None:
|
||||
image = Gtk.Image.new_from_gicon(
|
||||
Gio.Icon.new_for_string(image_file),
|
||||
Gtk.IconSize.LARGE_TOOLBAR)
|
||||
button.set_icon_widget(image)
|
||||
|
||||
if position is None:
|
||||
position = -1
|
||||
|
||||
self._add_button(button, group, position)
|
||||
signal = button.connect('clicked', self._call_tool, name)
|
||||
button.set_tooltip_text(description)
|
||||
button.show_all()
|
||||
self._toolitems.setdefault(name, [])
|
||||
self._toolitems[name].append((button, signal))
|
||||
|
||||
def _add_button(self, button, group, position):
|
||||
if group not in self._groups:
|
||||
if self._groups:
|
||||
self._add_separator()
|
||||
toolbar = Gtk.Toolbar()
|
||||
toolbar.set_style(Gtk.ToolbarStyle.ICONS)
|
||||
self.pack_start(toolbar, False, False, 0)
|
||||
toolbar.show_all()
|
||||
self._groups[group] = toolbar
|
||||
self._groups[group].insert(button, position)
|
||||
|
||||
def _call_tool(self, btn, name):
|
||||
self.trigger_tool(name)
|
||||
|
||||
def toggle_toolitem(self, name, toggled):
|
||||
if name not in self._toolitems:
|
||||
return
|
||||
for toolitem, signal in self._toolitems[name]:
|
||||
toolitem.handler_block(signal)
|
||||
toolitem.set_active(toggled)
|
||||
toolitem.handler_unblock(signal)
|
||||
|
||||
def remove_toolitem(self, name):
|
||||
for toolitem, _signal in self._toolitems.pop(name, []):
|
||||
for group in self._groups:
|
||||
if toolitem in self._groups[group]:
|
||||
self._groups[group].remove(toolitem)
|
||||
|
||||
def _add_separator(self):
|
||||
sep = Gtk.Separator()
|
||||
sep.set_property("orientation", Gtk.Orientation.VERTICAL)
|
||||
self.pack_start(sep, False, True, 0)
|
||||
sep.show_all()
|
||||
|
||||
def set_message(self, s):
|
||||
self._message.set_label(s)
|
||||
|
||||
|
||||
@backend_tools._register_tool_class(FigureCanvasGTK3)
|
||||
class SaveFigureGTK3(backend_tools.SaveFigureBase):
|
||||
def trigger(self, *args, **kwargs):
|
||||
NavigationToolbar2GTK3.save_figure(
|
||||
self._make_classic_style_pseudo_toolbar())
|
||||
|
||||
|
||||
@backend_tools._register_tool_class(FigureCanvasGTK3)
|
||||
class HelpGTK3(backend_tools.ToolHelpBase):
|
||||
def _normalize_shortcut(self, key):
|
||||
"""
|
||||
Convert Matplotlib key presses to GTK+ accelerator identifiers.
|
||||
|
||||
Related to `FigureCanvasGTK3._get_key`.
|
||||
"""
|
||||
special = {
|
||||
'backspace': 'BackSpace',
|
||||
'pagedown': 'Page_Down',
|
||||
'pageup': 'Page_Up',
|
||||
'scroll_lock': 'Scroll_Lock',
|
||||
}
|
||||
|
||||
parts = key.split('+')
|
||||
mods = ['<' + mod + '>' for mod in parts[:-1]]
|
||||
key = parts[-1]
|
||||
|
||||
if key in special:
|
||||
key = special[key]
|
||||
elif len(key) > 1:
|
||||
key = key.capitalize()
|
||||
elif key.isupper():
|
||||
mods += ['<shift>']
|
||||
|
||||
return ''.join(mods) + key
|
||||
|
||||
def _is_valid_shortcut(self, key):
|
||||
"""
|
||||
Check for a valid shortcut to be displayed.
|
||||
|
||||
- GTK will never send 'cmd+' (see `FigureCanvasGTK3._get_key`).
|
||||
- The shortcut window only shows keyboard shortcuts, not mouse buttons.
|
||||
"""
|
||||
return 'cmd+' not in key and not key.startswith('MouseButton.')
|
||||
|
||||
def _show_shortcuts_window(self):
|
||||
section = Gtk.ShortcutsSection()
|
||||
|
||||
for name, tool in sorted(self.toolmanager.tools.items()):
|
||||
if not tool.description:
|
||||
continue
|
||||
|
||||
# Putting everything in a separate group allows GTK to
|
||||
# automatically split them into separate columns/pages, which is
|
||||
# useful because we have lots of shortcuts, some with many keys
|
||||
# that are very wide.
|
||||
group = Gtk.ShortcutsGroup()
|
||||
section.add(group)
|
||||
# A hack to remove the title since we have no group naming.
|
||||
group.forall(lambda widget, data: widget.set_visible(False), None)
|
||||
|
||||
shortcut = Gtk.ShortcutsShortcut(
|
||||
accelerator=' '.join(
|
||||
self._normalize_shortcut(key)
|
||||
for key in self.toolmanager.get_tool_keymap(name)
|
||||
if self._is_valid_shortcut(key)),
|
||||
title=tool.name,
|
||||
subtitle=tool.description)
|
||||
group.add(shortcut)
|
||||
|
||||
window = Gtk.ShortcutsWindow(
|
||||
title='Help',
|
||||
modal=True,
|
||||
transient_for=self._figure.canvas.get_toplevel())
|
||||
section.show() # Must be done explicitly before add!
|
||||
window.add(section)
|
||||
|
||||
window.show_all()
|
||||
|
||||
def _show_shortcuts_dialog(self):
|
||||
dialog = Gtk.MessageDialog(
|
||||
self._figure.canvas.get_toplevel(),
|
||||
0, Gtk.MessageType.INFO, Gtk.ButtonsType.OK, self._get_help_text(),
|
||||
title="Help")
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
|
||||
def trigger(self, *args):
|
||||
if Gtk.check_version(3, 20, 0) is None:
|
||||
self._show_shortcuts_window()
|
||||
else:
|
||||
self._show_shortcuts_dialog()
|
||||
|
||||
|
||||
@backend_tools._register_tool_class(FigureCanvasGTK3)
|
||||
class ToolCopyToClipboardGTK3(backend_tools.ToolCopyToClipboardBase):
|
||||
def trigger(self, *args, **kwargs):
|
||||
clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
|
||||
window = self.canvas.get_window()
|
||||
x, y, width, height = window.get_geometry()
|
||||
pb = Gdk.pixbuf_get_from_window(window, x, y, width, height)
|
||||
clipboard.set_image(pb)
|
||||
|
||||
|
||||
Toolbar = ToolbarGTK3
|
||||
backend_tools._register_tool_class(
|
||||
FigureCanvasGTK3, _backend_gtk.ConfigureSubplotsGTK)
|
||||
backend_tools._register_tool_class(
|
||||
FigureCanvasGTK3, _backend_gtk.RubberbandGTK)
|
||||
|
||||
|
||||
class FigureManagerGTK3(_FigureManagerGTK):
|
||||
_toolbar2_class = NavigationToolbar2GTK3
|
||||
_toolmanager_toolbar_class = ToolbarGTK3
|
||||
|
||||
|
||||
@_BackendGTK.export
|
||||
class _BackendGTK3(_BackendGTK):
|
||||
FigureCanvas = FigureCanvasGTK3
|
||||
FigureManager = FigureManagerGTK3
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
import numpy as np
|
||||
|
||||
from .. import cbook, transforms
|
||||
from . import backend_agg, backend_gtk3
|
||||
from .backend_gtk3 import GLib, Gtk, _BackendGTK3
|
||||
|
||||
import cairo # Presence of cairo is already checked by _backend_gtk.
|
||||
|
||||
|
||||
class FigureCanvasGTK3Agg(backend_agg.FigureCanvasAgg,
|
||||
backend_gtk3.FigureCanvasGTK3):
|
||||
def __init__(self, figure):
|
||||
super().__init__(figure=figure)
|
||||
self._bbox_queue = []
|
||||
|
||||
def on_draw_event(self, widget, ctx):
|
||||
if self._idle_draw_id:
|
||||
GLib.source_remove(self._idle_draw_id)
|
||||
self._idle_draw_id = 0
|
||||
self.draw()
|
||||
|
||||
scale = self.device_pixel_ratio
|
||||
allocation = self.get_allocation()
|
||||
w = allocation.width * scale
|
||||
h = allocation.height * scale
|
||||
|
||||
if not len(self._bbox_queue):
|
||||
Gtk.render_background(
|
||||
self.get_style_context(), ctx,
|
||||
allocation.x, allocation.y,
|
||||
allocation.width, allocation.height)
|
||||
bbox_queue = [transforms.Bbox([[0, 0], [w, h]])]
|
||||
else:
|
||||
bbox_queue = self._bbox_queue
|
||||
|
||||
for bbox in bbox_queue:
|
||||
x = int(bbox.x0)
|
||||
y = h - int(bbox.y1)
|
||||
width = int(bbox.x1) - int(bbox.x0)
|
||||
height = int(bbox.y1) - int(bbox.y0)
|
||||
|
||||
buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32(
|
||||
np.asarray(self.copy_from_bbox(bbox)))
|
||||
image = cairo.ImageSurface.create_for_data(
|
||||
buf.ravel().data, cairo.FORMAT_ARGB32, width, height)
|
||||
image.set_device_scale(scale, scale)
|
||||
ctx.set_source_surface(image, x / scale, y / scale)
|
||||
ctx.paint()
|
||||
|
||||
if len(self._bbox_queue):
|
||||
self._bbox_queue = []
|
||||
|
||||
return False
|
||||
|
||||
def blit(self, bbox=None):
|
||||
# If bbox is None, blit the entire canvas to gtk. Otherwise
|
||||
# blit only the area defined by the bbox.
|
||||
if bbox is None:
|
||||
bbox = self.figure.bbox
|
||||
|
||||
scale = self.device_pixel_ratio
|
||||
allocation = self.get_allocation()
|
||||
x = int(bbox.x0 / scale)
|
||||
y = allocation.height - int(bbox.y1 / scale)
|
||||
width = (int(bbox.x1) - int(bbox.x0)) // scale
|
||||
height = (int(bbox.y1) - int(bbox.y0)) // scale
|
||||
|
||||
self._bbox_queue.append(bbox)
|
||||
self.queue_draw_area(x, y, width, height)
|
||||
|
||||
|
||||
@_BackendGTK3.export
|
||||
class _BackendGTK3Agg(_BackendGTK3):
|
||||
FigureCanvas = FigureCanvasGTK3Agg
|
||||
|
|
@ -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,641 @@
|
|||
import functools
|
||||
import io
|
||||
import os
|
||||
|
||||
import matplotlib as mpl
|
||||
from matplotlib import _api, backend_tools, cbook
|
||||
from matplotlib.backend_bases import (
|
||||
ToolContainerBase, MouseButton,
|
||||
KeyEvent, LocationEvent, MouseEvent, ResizeEvent, CloseEvent)
|
||||
|
||||
try:
|
||||
import gi
|
||||
except ImportError as err:
|
||||
raise ImportError("The GTK4 backends require PyGObject") from err
|
||||
|
||||
try:
|
||||
# :raises ValueError: If module/version is already loaded, already
|
||||
# required, or unavailable.
|
||||
gi.require_version("Gtk", "4.0")
|
||||
except ValueError as e:
|
||||
# in this case we want to re-raise as ImportError so the
|
||||
# auto-backend selection logic correctly skips.
|
||||
raise ImportError(e) from e
|
||||
|
||||
from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf
|
||||
from . import _backend_gtk
|
||||
from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611
|
||||
_BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK,
|
||||
TimerGTK as TimerGTK4,
|
||||
)
|
||||
|
||||
_GOBJECT_GE_3_47 = gi.version_info >= (3, 47, 0)
|
||||
_GTK_GE_4_12 = Gtk.check_version(4, 12, 0) is None
|
||||
|
||||
|
||||
class FigureCanvasGTK4(_FigureCanvasGTK, Gtk.DrawingArea):
|
||||
required_interactive_framework = "gtk4"
|
||||
supports_blit = False
|
||||
manager_class = _api.classproperty(lambda cls: FigureManagerGTK4)
|
||||
|
||||
def __init__(self, figure=None):
|
||||
super().__init__(figure=figure)
|
||||
|
||||
self.set_hexpand(True)
|
||||
self.set_vexpand(True)
|
||||
|
||||
self._idle_draw_id = 0
|
||||
self._rubberband_rect = None
|
||||
|
||||
self.set_draw_func(self._draw_func)
|
||||
self.connect('resize', self.resize_event)
|
||||
if _GTK_GE_4_12:
|
||||
self.connect('realize', self._realize_event)
|
||||
else:
|
||||
self.connect('notify::scale-factor', self._update_device_pixel_ratio)
|
||||
|
||||
click = Gtk.GestureClick()
|
||||
click.set_button(0) # All buttons.
|
||||
click.connect('pressed', self.button_press_event)
|
||||
click.connect('released', self.button_release_event)
|
||||
self.add_controller(click)
|
||||
|
||||
key = Gtk.EventControllerKey()
|
||||
key.connect('key-pressed', self.key_press_event)
|
||||
key.connect('key-released', self.key_release_event)
|
||||
self.add_controller(key)
|
||||
|
||||
motion = Gtk.EventControllerMotion()
|
||||
motion.connect('motion', self.motion_notify_event)
|
||||
motion.connect('enter', self.enter_notify_event)
|
||||
motion.connect('leave', self.leave_notify_event)
|
||||
self.add_controller(motion)
|
||||
|
||||
scroll = Gtk.EventControllerScroll.new(
|
||||
Gtk.EventControllerScrollFlags.VERTICAL)
|
||||
scroll.connect('scroll', self.scroll_event)
|
||||
self.add_controller(scroll)
|
||||
|
||||
self.set_focusable(True)
|
||||
|
||||
css = Gtk.CssProvider()
|
||||
style = '.matplotlib-canvas { background-color: white; }'
|
||||
if Gtk.check_version(4, 9, 3) is None:
|
||||
css.load_from_data(style, -1)
|
||||
else:
|
||||
css.load_from_data(style.encode('utf-8'))
|
||||
style_ctx = self.get_style_context()
|
||||
style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
|
||||
style_ctx.add_class("matplotlib-canvas")
|
||||
|
||||
def destroy(self):
|
||||
CloseEvent("close_event", self)._process()
|
||||
|
||||
def set_cursor(self, cursor):
|
||||
# docstring inherited
|
||||
self.set_cursor_from_name(_backend_gtk.mpl_to_gtk_cursor_name(cursor))
|
||||
|
||||
def _mpl_coords(self, xy=None):
|
||||
"""
|
||||
Convert the *xy* position of a GTK event, or of the current cursor
|
||||
position if *xy* is None, to Matplotlib coordinates.
|
||||
|
||||
GTK use logical pixels, but the figure is scaled to physical pixels for
|
||||
rendering. Transform to physical pixels so that all of the down-stream
|
||||
transforms work as expected.
|
||||
|
||||
Also, the origin is different and needs to be corrected.
|
||||
"""
|
||||
if xy is None:
|
||||
surface = self.get_native().get_surface()
|
||||
is_over, x, y, mask = surface.get_device_position(
|
||||
self.get_display().get_default_seat().get_pointer())
|
||||
else:
|
||||
x, y = xy
|
||||
x = x * self.device_pixel_ratio
|
||||
# flip y so y=0 is bottom of canvas
|
||||
y = self.figure.bbox.height - y * self.device_pixel_ratio
|
||||
return x, y
|
||||
|
||||
def scroll_event(self, controller, dx, dy):
|
||||
MouseEvent(
|
||||
"scroll_event", self, *self._mpl_coords(), step=dy,
|
||||
modifiers=self._mpl_modifiers(controller),
|
||||
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
|
||||
)._process()
|
||||
return True
|
||||
|
||||
def button_press_event(self, controller, n_press, x, y):
|
||||
MouseEvent(
|
||||
"button_press_event", self, *self._mpl_coords((x, y)),
|
||||
controller.get_current_button(),
|
||||
modifiers=self._mpl_modifiers(controller),
|
||||
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
|
||||
)._process()
|
||||
self.grab_focus()
|
||||
|
||||
def button_release_event(self, controller, n_press, x, y):
|
||||
MouseEvent(
|
||||
"button_release_event", self, *self._mpl_coords((x, y)),
|
||||
controller.get_current_button(),
|
||||
modifiers=self._mpl_modifiers(controller),
|
||||
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
|
||||
)._process()
|
||||
|
||||
def key_press_event(self, controller, keyval, keycode, state):
|
||||
KeyEvent(
|
||||
"key_press_event", self, self._get_key(keyval, keycode, state),
|
||||
*self._mpl_coords(),
|
||||
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
|
||||
)._process()
|
||||
return True
|
||||
|
||||
def key_release_event(self, controller, keyval, keycode, state):
|
||||
KeyEvent(
|
||||
"key_release_event", self, self._get_key(keyval, keycode, state),
|
||||
*self._mpl_coords(),
|
||||
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
|
||||
)._process()
|
||||
return True
|
||||
|
||||
def motion_notify_event(self, controller, x, y):
|
||||
MouseEvent(
|
||||
"motion_notify_event", self, *self._mpl_coords((x, y)),
|
||||
buttons=self._mpl_buttons(controller),
|
||||
modifiers=self._mpl_modifiers(controller),
|
||||
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
|
||||
)._process()
|
||||
|
||||
def enter_notify_event(self, controller, x, y):
|
||||
LocationEvent(
|
||||
"figure_enter_event", self, *self._mpl_coords((x, y)),
|
||||
modifiers=self._mpl_modifiers(),
|
||||
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
|
||||
)._process()
|
||||
|
||||
def leave_notify_event(self, controller):
|
||||
LocationEvent(
|
||||
"figure_leave_event", self, *self._mpl_coords(),
|
||||
modifiers=self._mpl_modifiers(),
|
||||
guiEvent=controller.get_current_event() if _GOBJECT_GE_3_47 else None,
|
||||
)._process()
|
||||
|
||||
def resize_event(self, area, width, height):
|
||||
self._update_device_pixel_ratio()
|
||||
dpi = self.figure.dpi
|
||||
winch = width * self.device_pixel_ratio / dpi
|
||||
hinch = height * self.device_pixel_ratio / dpi
|
||||
self.figure.set_size_inches(winch, hinch, forward=False)
|
||||
ResizeEvent("resize_event", self)._process()
|
||||
self.draw_idle()
|
||||
|
||||
def _mpl_buttons(self, controller):
|
||||
# NOTE: This spews "Broken accounting of active state" warnings on
|
||||
# right click on macOS.
|
||||
surface = self.get_native().get_surface()
|
||||
is_over, x, y, event_state = surface.get_device_position(
|
||||
self.get_display().get_default_seat().get_pointer())
|
||||
# NOTE: alternatively we could use
|
||||
# event_state = controller.get_current_event_state()
|
||||
# but for button_press/button_release this would report the state
|
||||
# *prior* to the event rather than after it; the above reports the
|
||||
# state *after* it.
|
||||
mod_table = [
|
||||
(MouseButton.LEFT, Gdk.ModifierType.BUTTON1_MASK),
|
||||
(MouseButton.MIDDLE, Gdk.ModifierType.BUTTON2_MASK),
|
||||
(MouseButton.RIGHT, Gdk.ModifierType.BUTTON3_MASK),
|
||||
(MouseButton.BACK, Gdk.ModifierType.BUTTON4_MASK),
|
||||
(MouseButton.FORWARD, Gdk.ModifierType.BUTTON5_MASK),
|
||||
]
|
||||
return {name for name, mask in mod_table if event_state & mask}
|
||||
|
||||
def _mpl_modifiers(self, controller=None):
|
||||
if controller is None:
|
||||
surface = self.get_native().get_surface()
|
||||
is_over, x, y, event_state = surface.get_device_position(
|
||||
self.get_display().get_default_seat().get_pointer())
|
||||
else:
|
||||
event_state = controller.get_current_event_state()
|
||||
mod_table = [
|
||||
("ctrl", Gdk.ModifierType.CONTROL_MASK),
|
||||
("alt", Gdk.ModifierType.ALT_MASK),
|
||||
("shift", Gdk.ModifierType.SHIFT_MASK),
|
||||
("super", Gdk.ModifierType.SUPER_MASK),
|
||||
]
|
||||
return [name for name, mask in mod_table if event_state & mask]
|
||||
|
||||
def _get_key(self, keyval, keycode, state):
|
||||
unikey = chr(Gdk.keyval_to_unicode(keyval))
|
||||
key = cbook._unikey_or_keysym_to_mplkey(
|
||||
unikey,
|
||||
Gdk.keyval_name(keyval))
|
||||
modifiers = [
|
||||
("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"),
|
||||
("alt", Gdk.ModifierType.ALT_MASK, "alt"),
|
||||
("shift", Gdk.ModifierType.SHIFT_MASK, "shift"),
|
||||
("super", Gdk.ModifierType.SUPER_MASK, "super"),
|
||||
]
|
||||
mods = [
|
||||
mod for mod, mask, mod_key in modifiers
|
||||
if (mod_key != key and state & mask
|
||||
and not (mod == "shift" and unikey.isprintable()))]
|
||||
return "+".join([*mods, key])
|
||||
|
||||
def _realize_event(self, obj):
|
||||
surface = self.get_native().get_surface()
|
||||
surface.connect('notify::scale', self._update_device_pixel_ratio)
|
||||
self._update_device_pixel_ratio()
|
||||
|
||||
def _update_device_pixel_ratio(self, *args, **kwargs):
|
||||
# We need to be careful in cases with mixed resolution displays if
|
||||
# device_pixel_ratio changes.
|
||||
if _GTK_GE_4_12:
|
||||
scale = self.get_native().get_surface().get_scale()
|
||||
else:
|
||||
scale = self.get_scale_factor()
|
||||
assert scale is not None
|
||||
if self._set_device_pixel_ratio(scale):
|
||||
self.draw()
|
||||
|
||||
def _draw_rubberband(self, rect):
|
||||
self._rubberband_rect = rect
|
||||
# TODO: Only update the rubberband area.
|
||||
self.queue_draw()
|
||||
|
||||
def _draw_func(self, drawing_area, ctx, width, height):
|
||||
self.on_draw_event(self, ctx)
|
||||
self._post_draw(self, ctx)
|
||||
|
||||
def _post_draw(self, widget, ctx):
|
||||
if self._rubberband_rect is None:
|
||||
return
|
||||
|
||||
lw = 1
|
||||
dash = 3
|
||||
x0, y0, w, h = (dim / self.device_pixel_ratio
|
||||
for dim in self._rubberband_rect)
|
||||
x1 = x0 + w
|
||||
y1 = y0 + h
|
||||
|
||||
# Draw the lines from x0, y0 towards x1, y1 so that the
|
||||
# dashes don't "jump" when moving the zoom box.
|
||||
ctx.move_to(x0, y0)
|
||||
ctx.line_to(x0, y1)
|
||||
ctx.move_to(x0, y0)
|
||||
ctx.line_to(x1, y0)
|
||||
ctx.move_to(x0, y1)
|
||||
ctx.line_to(x1, y1)
|
||||
ctx.move_to(x1, y0)
|
||||
ctx.line_to(x1, y1)
|
||||
|
||||
ctx.set_antialias(1)
|
||||
ctx.set_line_width(lw)
|
||||
ctx.set_dash((dash, dash), 0)
|
||||
ctx.set_source_rgb(0, 0, 0)
|
||||
ctx.stroke_preserve()
|
||||
|
||||
ctx.set_dash((dash, dash), dash)
|
||||
ctx.set_source_rgb(1, 1, 1)
|
||||
ctx.stroke()
|
||||
|
||||
def on_draw_event(self, widget, ctx):
|
||||
# to be overwritten by GTK4Agg or GTK4Cairo
|
||||
pass
|
||||
|
||||
def draw(self):
|
||||
# docstring inherited
|
||||
if self.is_drawable():
|
||||
self.queue_draw()
|
||||
|
||||
def draw_idle(self):
|
||||
# docstring inherited
|
||||
if self._idle_draw_id != 0:
|
||||
return
|
||||
def idle_draw(*args):
|
||||
try:
|
||||
self.draw()
|
||||
finally:
|
||||
self._idle_draw_id = 0
|
||||
return False
|
||||
self._idle_draw_id = GLib.idle_add(idle_draw)
|
||||
|
||||
def flush_events(self):
|
||||
# docstring inherited
|
||||
context = GLib.MainContext.default()
|
||||
while context.pending():
|
||||
context.iteration(True)
|
||||
|
||||
|
||||
class NavigationToolbar2GTK4(_NavigationToolbar2GTK, Gtk.Box):
|
||||
def __init__(self, canvas):
|
||||
Gtk.Box.__init__(self)
|
||||
|
||||
self.add_css_class('toolbar')
|
||||
|
||||
self._gtk_ids = {}
|
||||
for text, tooltip_text, image_file, callback in self.toolitems:
|
||||
if text is None:
|
||||
self.append(Gtk.Separator())
|
||||
continue
|
||||
image = Gtk.Image.new_from_gicon(
|
||||
Gio.Icon.new_for_string(
|
||||
str(cbook._get_data_path('images',
|
||||
f'{image_file}-symbolic.svg'))))
|
||||
self._gtk_ids[text] = button = (
|
||||
Gtk.ToggleButton() if callback in ['zoom', 'pan'] else
|
||||
Gtk.Button())
|
||||
button.set_child(image)
|
||||
button.add_css_class('flat')
|
||||
button.add_css_class('image-button')
|
||||
# Save the handler id, so that we can block it as needed.
|
||||
button._signal_handler = button.connect(
|
||||
'clicked', getattr(self, callback))
|
||||
button.set_tooltip_text(tooltip_text)
|
||||
self.append(button)
|
||||
|
||||
# This filler item ensures the toolbar is always at least two text
|
||||
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
|
||||
# over images because those use two-line messages which resize the
|
||||
# toolbar.
|
||||
label = Gtk.Label()
|
||||
label.set_markup(
|
||||
'<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
|
||||
label.set_hexpand(True) # Push real message to the right.
|
||||
self.append(label)
|
||||
|
||||
self.message = Gtk.Label()
|
||||
self.message.set_justify(Gtk.Justification.RIGHT)
|
||||
self.append(self.message)
|
||||
|
||||
_NavigationToolbar2GTK.__init__(self, canvas)
|
||||
|
||||
def save_figure(self, *args):
|
||||
dialog = Gtk.FileChooserNative(
|
||||
title='Save the figure',
|
||||
transient_for=self.canvas.get_root(),
|
||||
action=Gtk.FileChooserAction.SAVE,
|
||||
modal=True)
|
||||
self._save_dialog = dialog # Must keep a reference.
|
||||
|
||||
ff = Gtk.FileFilter()
|
||||
ff.set_name('All files')
|
||||
ff.add_pattern('*')
|
||||
dialog.add_filter(ff)
|
||||
dialog.set_filter(ff)
|
||||
|
||||
formats = []
|
||||
default_format = None
|
||||
for i, (name, fmts) in enumerate(
|
||||
self.canvas.get_supported_filetypes_grouped().items()):
|
||||
ff = Gtk.FileFilter()
|
||||
ff.set_name(name)
|
||||
for fmt in fmts:
|
||||
ff.add_pattern(f'*.{fmt}')
|
||||
dialog.add_filter(ff)
|
||||
formats.append(name)
|
||||
if self.canvas.get_default_filetype() in fmts:
|
||||
default_format = i
|
||||
# Setting the choice doesn't always work, so make sure the default
|
||||
# format is first.
|
||||
formats = [formats[default_format], *formats[:default_format],
|
||||
*formats[default_format+1:]]
|
||||
dialog.add_choice('format', 'File format', formats, formats)
|
||||
dialog.set_choice('format', formats[0])
|
||||
|
||||
dialog.set_current_folder(Gio.File.new_for_path(
|
||||
os.path.expanduser(mpl.rcParams['savefig.directory'])))
|
||||
dialog.set_current_name(self.canvas.get_default_filename())
|
||||
|
||||
@functools.partial(dialog.connect, 'response')
|
||||
def on_response(dialog, response):
|
||||
file = dialog.get_file()
|
||||
fmt = dialog.get_choice('format')
|
||||
fmt = self.canvas.get_supported_filetypes_grouped()[fmt][0]
|
||||
dialog.destroy()
|
||||
self._save_dialog = None
|
||||
if response != Gtk.ResponseType.ACCEPT:
|
||||
return
|
||||
# Save dir for next time, unless empty str (which means use cwd).
|
||||
if mpl.rcParams['savefig.directory']:
|
||||
parent = file.get_parent()
|
||||
mpl.rcParams['savefig.directory'] = parent.get_path()
|
||||
try:
|
||||
self.canvas.figure.savefig(file.get_path(), format=fmt)
|
||||
except Exception as e:
|
||||
msg = Gtk.MessageDialog(
|
||||
transient_for=self.canvas.get_root(),
|
||||
message_type=Gtk.MessageType.ERROR,
|
||||
buttons=Gtk.ButtonsType.OK, modal=True,
|
||||
text=str(e))
|
||||
msg.show()
|
||||
|
||||
dialog.show()
|
||||
return self.UNKNOWN_SAVED_STATUS
|
||||
|
||||
|
||||
class ToolbarGTK4(ToolContainerBase, Gtk.Box):
|
||||
_icon_extension = '-symbolic.svg'
|
||||
|
||||
def __init__(self, toolmanager):
|
||||
ToolContainerBase.__init__(self, toolmanager)
|
||||
Gtk.Box.__init__(self)
|
||||
self.set_property('orientation', Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# Tool items are created later, but must appear before the message.
|
||||
self._tool_box = Gtk.Box()
|
||||
self.append(self._tool_box)
|
||||
self._groups = {}
|
||||
self._toolitems = {}
|
||||
|
||||
# This filler item ensures the toolbar is always at least two text
|
||||
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
|
||||
# over images because those use two-line messages which resize the
|
||||
# toolbar.
|
||||
label = Gtk.Label()
|
||||
label.set_markup(
|
||||
'<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
|
||||
label.set_hexpand(True) # Push real message to the right.
|
||||
self.append(label)
|
||||
|
||||
self._message = Gtk.Label()
|
||||
self._message.set_justify(Gtk.Justification.RIGHT)
|
||||
self.append(self._message)
|
||||
|
||||
def add_toolitem(self, name, group, position, image_file, description,
|
||||
toggle):
|
||||
if toggle:
|
||||
button = Gtk.ToggleButton()
|
||||
else:
|
||||
button = Gtk.Button()
|
||||
button.set_label(name)
|
||||
button.add_css_class('flat')
|
||||
|
||||
if image_file is not None:
|
||||
image = Gtk.Image.new_from_gicon(
|
||||
Gio.Icon.new_for_string(image_file))
|
||||
button.set_child(image)
|
||||
button.add_css_class('image-button')
|
||||
|
||||
if position is None:
|
||||
position = -1
|
||||
|
||||
self._add_button(button, group, position)
|
||||
signal = button.connect('clicked', self._call_tool, name)
|
||||
button.set_tooltip_text(description)
|
||||
self._toolitems.setdefault(name, [])
|
||||
self._toolitems[name].append((button, signal))
|
||||
|
||||
def _find_child_at_position(self, group, position):
|
||||
children = [None]
|
||||
child = self._groups[group].get_first_child()
|
||||
while child is not None:
|
||||
children.append(child)
|
||||
child = child.get_next_sibling()
|
||||
return children[position]
|
||||
|
||||
def _add_button(self, button, group, position):
|
||||
if group not in self._groups:
|
||||
if self._groups:
|
||||
self._add_separator()
|
||||
group_box = Gtk.Box()
|
||||
self._tool_box.append(group_box)
|
||||
self._groups[group] = group_box
|
||||
self._groups[group].insert_child_after(
|
||||
button, self._find_child_at_position(group, position))
|
||||
|
||||
def _call_tool(self, btn, name):
|
||||
self.trigger_tool(name)
|
||||
|
||||
def toggle_toolitem(self, name, toggled):
|
||||
if name not in self._toolitems:
|
||||
return
|
||||
for toolitem, signal in self._toolitems[name]:
|
||||
toolitem.handler_block(signal)
|
||||
toolitem.set_active(toggled)
|
||||
toolitem.handler_unblock(signal)
|
||||
|
||||
def remove_toolitem(self, name):
|
||||
for toolitem, _signal in self._toolitems.pop(name, []):
|
||||
for group in self._groups:
|
||||
if toolitem in self._groups[group]:
|
||||
self._groups[group].remove(toolitem)
|
||||
|
||||
def _add_separator(self):
|
||||
sep = Gtk.Separator()
|
||||
sep.set_property("orientation", Gtk.Orientation.VERTICAL)
|
||||
self._tool_box.append(sep)
|
||||
|
||||
def set_message(self, s):
|
||||
self._message.set_label(s)
|
||||
|
||||
|
||||
@backend_tools._register_tool_class(FigureCanvasGTK4)
|
||||
class SaveFigureGTK4(backend_tools.SaveFigureBase):
|
||||
def trigger(self, *args, **kwargs):
|
||||
NavigationToolbar2GTK4.save_figure(
|
||||
self._make_classic_style_pseudo_toolbar())
|
||||
|
||||
|
||||
@backend_tools._register_tool_class(FigureCanvasGTK4)
|
||||
class HelpGTK4(backend_tools.ToolHelpBase):
|
||||
def _normalize_shortcut(self, key):
|
||||
"""
|
||||
Convert Matplotlib key presses to GTK+ accelerator identifiers.
|
||||
|
||||
Related to `FigureCanvasGTK4._get_key`.
|
||||
"""
|
||||
special = {
|
||||
'backspace': 'BackSpace',
|
||||
'pagedown': 'Page_Down',
|
||||
'pageup': 'Page_Up',
|
||||
'scroll_lock': 'Scroll_Lock',
|
||||
}
|
||||
|
||||
parts = key.split('+')
|
||||
mods = ['<' + mod + '>' for mod in parts[:-1]]
|
||||
key = parts[-1]
|
||||
|
||||
if key in special:
|
||||
key = special[key]
|
||||
elif len(key) > 1:
|
||||
key = key.capitalize()
|
||||
elif key.isupper():
|
||||
mods += ['<shift>']
|
||||
|
||||
return ''.join(mods) + key
|
||||
|
||||
def _is_valid_shortcut(self, key):
|
||||
"""
|
||||
Check for a valid shortcut to be displayed.
|
||||
|
||||
- GTK will never send 'cmd+' (see `FigureCanvasGTK4._get_key`).
|
||||
- The shortcut window only shows keyboard shortcuts, not mouse buttons.
|
||||
"""
|
||||
return 'cmd+' not in key and not key.startswith('MouseButton.')
|
||||
|
||||
def trigger(self, *args):
|
||||
section = Gtk.ShortcutsSection()
|
||||
|
||||
for name, tool in sorted(self.toolmanager.tools.items()):
|
||||
if not tool.description:
|
||||
continue
|
||||
|
||||
# Putting everything in a separate group allows GTK to
|
||||
# automatically split them into separate columns/pages, which is
|
||||
# useful because we have lots of shortcuts, some with many keys
|
||||
# that are very wide.
|
||||
group = Gtk.ShortcutsGroup()
|
||||
section.append(group)
|
||||
# A hack to remove the title since we have no group naming.
|
||||
child = group.get_first_child()
|
||||
while child is not None:
|
||||
child.set_visible(False)
|
||||
child = child.get_next_sibling()
|
||||
|
||||
shortcut = Gtk.ShortcutsShortcut(
|
||||
accelerator=' '.join(
|
||||
self._normalize_shortcut(key)
|
||||
for key in self.toolmanager.get_tool_keymap(name)
|
||||
if self._is_valid_shortcut(key)),
|
||||
title=tool.name,
|
||||
subtitle=tool.description)
|
||||
group.append(shortcut)
|
||||
|
||||
window = Gtk.ShortcutsWindow(
|
||||
title='Help',
|
||||
modal=True,
|
||||
transient_for=self._figure.canvas.get_root())
|
||||
window.set_child(section)
|
||||
|
||||
window.show()
|
||||
|
||||
|
||||
@backend_tools._register_tool_class(FigureCanvasGTK4)
|
||||
class ToolCopyToClipboardGTK4(backend_tools.ToolCopyToClipboardBase):
|
||||
def trigger(self, *args, **kwargs):
|
||||
with io.BytesIO() as f:
|
||||
self.canvas.print_rgba(f)
|
||||
w, h = self.canvas.get_width_height()
|
||||
pb = GdkPixbuf.Pixbuf.new_from_data(f.getbuffer(),
|
||||
GdkPixbuf.Colorspace.RGB, True,
|
||||
8, w, h, w*4)
|
||||
clipboard = self.canvas.get_clipboard()
|
||||
clipboard.set(pb)
|
||||
|
||||
|
||||
backend_tools._register_tool_class(
|
||||
FigureCanvasGTK4, _backend_gtk.ConfigureSubplotsGTK)
|
||||
backend_tools._register_tool_class(
|
||||
FigureCanvasGTK4, _backend_gtk.RubberbandGTK)
|
||||
Toolbar = ToolbarGTK4
|
||||
|
||||
|
||||
class FigureManagerGTK4(_FigureManagerGTK):
|
||||
_toolbar2_class = NavigationToolbar2GTK4
|
||||
_toolmanager_toolbar_class = ToolbarGTK4
|
||||
|
||||
|
||||
@_BackendGTK.export
|
||||
class _BackendGTK4(_BackendGTK):
|
||||
FigureCanvas = FigureCanvasGTK4
|
||||
FigureManager = FigureManagerGTK4
|
||||
|
|
@ -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,196 @@
|
|||
import os
|
||||
|
||||
import matplotlib as mpl
|
||||
from matplotlib import _api, cbook
|
||||
from matplotlib._pylab_helpers import Gcf
|
||||
from . import _macosx
|
||||
from .backend_agg import FigureCanvasAgg
|
||||
from matplotlib.backend_bases import (
|
||||
_Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2,
|
||||
ResizeEvent, TimerBase, _allow_interrupt)
|
||||
|
||||
|
||||
class TimerMac(_macosx.Timer, TimerBase):
|
||||
"""Subclass of `.TimerBase` using CFRunLoop timer events."""
|
||||
# completely implemented at the C-level (in _macosx.Timer)
|
||||
|
||||
|
||||
def _allow_interrupt_macos():
|
||||
"""A context manager that allows terminating a plot by sending a SIGINT."""
|
||||
return _allow_interrupt(
|
||||
lambda rsock: _macosx.wake_on_fd_write(rsock.fileno()), _macosx.stop)
|
||||
|
||||
|
||||
class FigureCanvasMac(FigureCanvasAgg, _macosx.FigureCanvas, FigureCanvasBase):
|
||||
# docstring inherited
|
||||
|
||||
# Ideally this class would be `class FCMacAgg(FCAgg, FCMac)`
|
||||
# (FC=FigureCanvas) where FCMac would be an ObjC-implemented mac-specific
|
||||
# class also inheriting from FCBase (this is the approach with other GUI
|
||||
# toolkits). However, writing an extension type inheriting from a Python
|
||||
# base class is slightly tricky (the extension type must be a heap type),
|
||||
# and we can just as well lift the FCBase base up one level, keeping it *at
|
||||
# the end* to have the right method resolution order.
|
||||
|
||||
# Events such as button presses, mouse movements, and key presses are
|
||||
# handled in C and events (MouseEvent, etc.) are triggered from there.
|
||||
|
||||
required_interactive_framework = "macosx"
|
||||
_timer_cls = TimerMac
|
||||
manager_class = _api.classproperty(lambda cls: FigureManagerMac)
|
||||
|
||||
def __init__(self, figure):
|
||||
super().__init__(figure=figure)
|
||||
self._draw_pending = False
|
||||
self._is_drawing = False
|
||||
# Keep track of the timers that are alive
|
||||
self._timers = set()
|
||||
|
||||
def draw(self):
|
||||
"""Render the figure and update the macosx canvas."""
|
||||
# The renderer draw is done here; delaying causes problems with code
|
||||
# that uses the result of the draw() to update plot elements.
|
||||
if self._is_drawing:
|
||||
return
|
||||
with cbook._setattr_cm(self, _is_drawing=True):
|
||||
super().draw()
|
||||
self.update()
|
||||
|
||||
def draw_idle(self):
|
||||
# docstring inherited
|
||||
if not (getattr(self, '_draw_pending', False) or
|
||||
getattr(self, '_is_drawing', False)):
|
||||
self._draw_pending = True
|
||||
# Add a singleshot timer to the eventloop that will call back
|
||||
# into the Python method _draw_idle to take care of the draw
|
||||
self._single_shot_timer(self._draw_idle)
|
||||
|
||||
def _single_shot_timer(self, callback):
|
||||
"""Add a single shot timer with the given callback"""
|
||||
def callback_func(callback, timer):
|
||||
callback()
|
||||
self._timers.remove(timer)
|
||||
timer = self.new_timer(interval=0)
|
||||
timer.single_shot = True
|
||||
timer.add_callback(callback_func, callback, timer)
|
||||
self._timers.add(timer)
|
||||
timer.start()
|
||||
|
||||
def _draw_idle(self):
|
||||
"""
|
||||
Draw method for singleshot timer
|
||||
|
||||
This draw method can be added to a singleshot timer, which can
|
||||
accumulate draws while the eventloop is spinning. This method will
|
||||
then only draw the first time and short-circuit the others.
|
||||
"""
|
||||
with self._idle_draw_cntx():
|
||||
if not self._draw_pending:
|
||||
# Short-circuit because our draw request has already been
|
||||
# taken care of
|
||||
return
|
||||
self._draw_pending = False
|
||||
self.draw()
|
||||
|
||||
def blit(self, bbox=None):
|
||||
# docstring inherited
|
||||
super().blit(bbox)
|
||||
self.update()
|
||||
|
||||
def resize(self, width, height):
|
||||
# Size from macOS is logical pixels, dpi is physical.
|
||||
scale = self.figure.dpi / self.device_pixel_ratio
|
||||
width /= scale
|
||||
height /= scale
|
||||
self.figure.set_size_inches(width, height, forward=False)
|
||||
ResizeEvent("resize_event", self)._process()
|
||||
self.draw_idle()
|
||||
|
||||
def start_event_loop(self, timeout=0):
|
||||
# docstring inherited
|
||||
# Set up a SIGINT handler to allow terminating a plot via CTRL-C.
|
||||
with _allow_interrupt_macos():
|
||||
self._start_event_loop(timeout=timeout) # Forward to ObjC implementation.
|
||||
|
||||
|
||||
class NavigationToolbar2Mac(_macosx.NavigationToolbar2, NavigationToolbar2):
|
||||
|
||||
def __init__(self, canvas):
|
||||
data_path = cbook._get_data_path('images')
|
||||
_, tooltips, image_names, _ = zip(*NavigationToolbar2.toolitems)
|
||||
_macosx.NavigationToolbar2.__init__(
|
||||
self, canvas,
|
||||
tuple(str(data_path / image_name) + ".pdf"
|
||||
for image_name in image_names if image_name is not None),
|
||||
tuple(tooltip for tooltip in tooltips if tooltip is not None))
|
||||
NavigationToolbar2.__init__(self, canvas)
|
||||
|
||||
def draw_rubberband(self, event, x0, y0, x1, y1):
|
||||
self.canvas.set_rubberband(int(x0), int(y0), int(x1), int(y1))
|
||||
|
||||
def remove_rubberband(self):
|
||||
self.canvas.remove_rubberband()
|
||||
|
||||
def save_figure(self, *args):
|
||||
directory = os.path.expanduser(mpl.rcParams['savefig.directory'])
|
||||
filename = _macosx.choose_save_file('Save the figure',
|
||||
directory,
|
||||
self.canvas.get_default_filename())
|
||||
if filename is None: # Cancel
|
||||
return
|
||||
# Save dir for next time, unless empty str (which means use cwd).
|
||||
if mpl.rcParams['savefig.directory']:
|
||||
mpl.rcParams['savefig.directory'] = os.path.dirname(filename)
|
||||
self.canvas.figure.savefig(filename)
|
||||
return filename
|
||||
|
||||
|
||||
class FigureManagerMac(_macosx.FigureManager, FigureManagerBase):
|
||||
_toolbar2_class = NavigationToolbar2Mac
|
||||
|
||||
def __init__(self, canvas, num):
|
||||
self._shown = False
|
||||
_macosx.FigureManager.__init__(self, canvas)
|
||||
icon_path = str(cbook._get_data_path('images/matplotlib.pdf'))
|
||||
_macosx.FigureManager.set_icon(icon_path)
|
||||
FigureManagerBase.__init__(self, canvas, num)
|
||||
self._set_window_mode(mpl.rcParams["macosx.window_mode"])
|
||||
if self.toolbar is not None:
|
||||
self.toolbar.update()
|
||||
if mpl.is_interactive():
|
||||
self.show()
|
||||
self.canvas.draw_idle()
|
||||
|
||||
def _close_button_pressed(self):
|
||||
Gcf.destroy(self)
|
||||
self.canvas.flush_events()
|
||||
|
||||
def destroy(self):
|
||||
# We need to clear any pending timers that never fired, otherwise
|
||||
# we get a memory leak from the timer callbacks holding a reference
|
||||
while self.canvas._timers:
|
||||
timer = self.canvas._timers.pop()
|
||||
timer.stop()
|
||||
super().destroy()
|
||||
|
||||
@classmethod
|
||||
def start_main_loop(cls):
|
||||
# Set up a SIGINT handler to allow terminating a plot via CTRL-C.
|
||||
with _allow_interrupt_macos():
|
||||
_macosx.show()
|
||||
|
||||
def show(self):
|
||||
if self.canvas.figure.stale:
|
||||
self.canvas.draw_idle()
|
||||
if not self._shown:
|
||||
self._show()
|
||||
self._shown = True
|
||||
if mpl.rcParams["figure.raise_window"]:
|
||||
self._raise()
|
||||
|
||||
|
||||
@_Backend.export
|
||||
class _BackendMac(_Backend):
|
||||
FigureCanvas = FigureCanvasMac
|
||||
FigureManager = FigureManagerMac
|
||||
mainloop = FigureManagerMac.start_main_loop
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
import numpy as np
|
||||
|
||||
from matplotlib import cbook
|
||||
from .backend_agg import RendererAgg
|
||||
from matplotlib._tight_bbox import process_figure_for_rasterizing
|
||||
|
||||
|
||||
class MixedModeRenderer:
|
||||
"""
|
||||
A helper class to implement a renderer that switches between
|
||||
vector and raster drawing. An example may be a PDF writer, where
|
||||
most things are drawn with PDF vector commands, but some very
|
||||
complex objects, such as quad meshes, are rasterised and then
|
||||
output as images.
|
||||
"""
|
||||
def __init__(self, figure, width, height, dpi, vector_renderer,
|
||||
raster_renderer_class=None,
|
||||
bbox_inches_restore=None):
|
||||
"""
|
||||
Parameters
|
||||
----------
|
||||
figure : `~matplotlib.figure.Figure`
|
||||
The figure instance.
|
||||
width : float
|
||||
The width of the canvas in logical units
|
||||
height : float
|
||||
The height of the canvas in logical units
|
||||
dpi : float
|
||||
The dpi of the canvas
|
||||
vector_renderer : `~matplotlib.backend_bases.RendererBase`
|
||||
An instance of a subclass of
|
||||
`~matplotlib.backend_bases.RendererBase` that will be used for the
|
||||
vector drawing.
|
||||
raster_renderer_class : `~matplotlib.backend_bases.RendererBase`
|
||||
The renderer class to use for the raster drawing. If not provided,
|
||||
this will use the Agg backend (which is currently the only viable
|
||||
option anyway.)
|
||||
|
||||
"""
|
||||
if raster_renderer_class is None:
|
||||
raster_renderer_class = RendererAgg
|
||||
|
||||
self._raster_renderer_class = raster_renderer_class
|
||||
self._width = width
|
||||
self._height = height
|
||||
self.dpi = dpi
|
||||
|
||||
self._vector_renderer = vector_renderer
|
||||
|
||||
self._raster_renderer = None
|
||||
|
||||
# A reference to the figure is needed as we need to change
|
||||
# the figure dpi before and after the rasterization. Although
|
||||
# this looks ugly, I couldn't find a better solution. -JJL
|
||||
self.figure = figure
|
||||
self._figdpi = figure.dpi
|
||||
|
||||
self._bbox_inches_restore = bbox_inches_restore
|
||||
|
||||
self._renderer = vector_renderer
|
||||
|
||||
def __getattr__(self, attr):
|
||||
# Proxy everything that hasn't been overridden to the base
|
||||
# renderer. Things that *are* overridden can call methods
|
||||
# on self._renderer directly, but must not cache/store
|
||||
# methods (because things like RendererAgg change their
|
||||
# methods on the fly in order to optimise proxying down
|
||||
# to the underlying C implementation).
|
||||
return getattr(self._renderer, attr)
|
||||
|
||||
def start_rasterizing(self):
|
||||
"""
|
||||
Enter "raster" mode. All subsequent drawing commands (until
|
||||
`stop_rasterizing` is called) will be drawn with the raster backend.
|
||||
"""
|
||||
# change the dpi of the figure temporarily.
|
||||
self.figure.dpi = self.dpi
|
||||
if self._bbox_inches_restore: # when tight bbox is used
|
||||
r = process_figure_for_rasterizing(self.figure,
|
||||
self._bbox_inches_restore)
|
||||
self._bbox_inches_restore = r
|
||||
|
||||
self._raster_renderer = self._raster_renderer_class(
|
||||
self._width*self.dpi, self._height*self.dpi, self.dpi)
|
||||
self._renderer = self._raster_renderer
|
||||
|
||||
def stop_rasterizing(self):
|
||||
"""
|
||||
Exit "raster" mode. All of the drawing that was done since
|
||||
the last `start_rasterizing` call will be copied to the
|
||||
vector backend by calling draw_image.
|
||||
"""
|
||||
|
||||
self._renderer = self._vector_renderer
|
||||
|
||||
height = self._height * self.dpi
|
||||
img = np.asarray(self._raster_renderer.buffer_rgba())
|
||||
slice_y, slice_x = cbook._get_nonzero_slices(img[..., 3])
|
||||
cropped_img = img[slice_y, slice_x]
|
||||
if cropped_img.size:
|
||||
gc = self._renderer.new_gc()
|
||||
# TODO: If the mixedmode resolution differs from the figure's
|
||||
# dpi, the image must be scaled (dpi->_figdpi). Not all
|
||||
# backends support this.
|
||||
self._renderer.draw_image(
|
||||
gc,
|
||||
slice_x.start * self._figdpi / self.dpi,
|
||||
(height - slice_y.stop) * self._figdpi / self.dpi,
|
||||
cropped_img[::-1])
|
||||
self._raster_renderer = None
|
||||
|
||||
# restore the figure dpi.
|
||||
self.figure.dpi = self._figdpi
|
||||
|
||||
if self._bbox_inches_restore: # when tight bbox is used
|
||||
r = process_figure_for_rasterizing(self.figure,
|
||||
self._bbox_inches_restore,
|
||||
self._figdpi)
|
||||
self._bbox_inches_restore = r
|
||||
|
|
@ -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
|
||||
2806
venv/lib/python3.13/site-packages/matplotlib/backends/backend_pdf.py
Normal file
2806
venv/lib/python3.13/site-packages/matplotlib/backends/backend_pdf.py
Normal file
File diff suppressed because it is too large
Load diff
1010
venv/lib/python3.13/site-packages/matplotlib/backends/backend_pgf.py
Normal file
1010
venv/lib/python3.13/site-packages/matplotlib/backends/backend_pgf.py
Normal file
File diff suppressed because it is too large
Load diff
1481
venv/lib/python3.13/site-packages/matplotlib/backends/backend_ps.py
Normal file
1481
venv/lib/python3.13/site-packages/matplotlib/backends/backend_ps.py
Normal file
File diff suppressed because it is too large
Load diff
1089
venv/lib/python3.13/site-packages/matplotlib/backends/backend_qt.py
Normal file
1089
venv/lib/python3.13/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
|
||||
1380
venv/lib/python3.13/site-packages/matplotlib/backends/backend_svg.py
Normal file
1380
venv/lib/python3.13/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,527 @@
|
|||
"""Displays Agg images in the browser, with interactivity."""
|
||||
# The WebAgg backend is divided into two modules:
|
||||
#
|
||||
# - `backend_webagg_core.py` contains code necessary to embed a WebAgg
|
||||
# plot inside of a web application, and communicate in an abstract
|
||||
# way over a web socket.
|
||||
#
|
||||
# - `backend_webagg.py` contains a concrete implementation of a basic
|
||||
# application, implemented with asyncio.
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
from io import BytesIO, StringIO
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from matplotlib import _api, backend_bases, backend_tools
|
||||
from matplotlib.backends import backend_agg
|
||||
from matplotlib.backend_bases import (
|
||||
_Backend, MouseButton, KeyEvent, LocationEvent, MouseEvent, ResizeEvent)
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
_SPECIAL_KEYS_LUT = {'Alt': 'alt',
|
||||
'AltGraph': 'alt',
|
||||
'CapsLock': 'caps_lock',
|
||||
'Control': 'control',
|
||||
'Meta': 'meta',
|
||||
'NumLock': 'num_lock',
|
||||
'ScrollLock': 'scroll_lock',
|
||||
'Shift': 'shift',
|
||||
'Super': 'super',
|
||||
'Enter': 'enter',
|
||||
'Tab': 'tab',
|
||||
'ArrowDown': 'down',
|
||||
'ArrowLeft': 'left',
|
||||
'ArrowRight': 'right',
|
||||
'ArrowUp': 'up',
|
||||
'End': 'end',
|
||||
'Home': 'home',
|
||||
'PageDown': 'pagedown',
|
||||
'PageUp': 'pageup',
|
||||
'Backspace': 'backspace',
|
||||
'Delete': 'delete',
|
||||
'Insert': 'insert',
|
||||
'Escape': 'escape',
|
||||
'Pause': 'pause',
|
||||
'Select': 'select',
|
||||
'Dead': 'dead',
|
||||
'F1': 'f1',
|
||||
'F2': 'f2',
|
||||
'F3': 'f3',
|
||||
'F4': 'f4',
|
||||
'F5': 'f5',
|
||||
'F6': 'f6',
|
||||
'F7': 'f7',
|
||||
'F8': 'f8',
|
||||
'F9': 'f9',
|
||||
'F10': 'f10',
|
||||
'F11': 'f11',
|
||||
'F12': 'f12'}
|
||||
|
||||
|
||||
def _handle_key(key):
|
||||
"""Handle key values"""
|
||||
value = key[key.index('k') + 1:]
|
||||
if 'shift+' in key:
|
||||
if len(value) == 1:
|
||||
key = key.replace('shift+', '')
|
||||
if value in _SPECIAL_KEYS_LUT:
|
||||
value = _SPECIAL_KEYS_LUT[value]
|
||||
key = key[:key.index('k')] + value
|
||||
return key
|
||||
|
||||
|
||||
class TimerTornado(backend_bases.TimerBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._timer = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _timer_start(self):
|
||||
import tornado
|
||||
|
||||
self._timer_stop()
|
||||
if self._single:
|
||||
ioloop = tornado.ioloop.IOLoop.instance()
|
||||
self._timer = ioloop.add_timeout(
|
||||
datetime.timedelta(milliseconds=self.interval),
|
||||
self._on_timer)
|
||||
else:
|
||||
self._timer = tornado.ioloop.PeriodicCallback(
|
||||
self._on_timer,
|
||||
max(self.interval, 1e-6))
|
||||
self._timer.start()
|
||||
|
||||
def _timer_stop(self):
|
||||
import tornado
|
||||
|
||||
if self._timer is None:
|
||||
return
|
||||
elif self._single:
|
||||
ioloop = tornado.ioloop.IOLoop.instance()
|
||||
ioloop.remove_timeout(self._timer)
|
||||
else:
|
||||
self._timer.stop()
|
||||
self._timer = None
|
||||
|
||||
def _timer_set_interval(self):
|
||||
# Only stop and restart it if the timer has already been started
|
||||
if self._timer is not None:
|
||||
self._timer_stop()
|
||||
self._timer_start()
|
||||
|
||||
|
||||
class TimerAsyncio(backend_bases.TimerBase):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._task = None
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
async def _timer_task(self, interval):
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
self._on_timer()
|
||||
|
||||
if self._single:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
|
||||
def _timer_start(self):
|
||||
self._timer_stop()
|
||||
|
||||
self._task = asyncio.ensure_future(
|
||||
self._timer_task(max(self.interval / 1_000., 1e-6))
|
||||
)
|
||||
|
||||
def _timer_stop(self):
|
||||
if self._task is not None:
|
||||
self._task.cancel()
|
||||
self._task = None
|
||||
|
||||
def _timer_set_interval(self):
|
||||
# Only stop and restart it if the timer has already been started
|
||||
if self._task is not None:
|
||||
self._timer_stop()
|
||||
self._timer_start()
|
||||
|
||||
|
||||
class FigureCanvasWebAggCore(backend_agg.FigureCanvasAgg):
|
||||
manager_class = _api.classproperty(lambda cls: FigureManagerWebAgg)
|
||||
_timer_cls = TimerAsyncio
|
||||
# Webagg and friends having the right methods, but still
|
||||
# having bugs in practice. Do not advertise that it works until
|
||||
# we can debug this.
|
||||
supports_blit = False
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
# Set to True when the renderer contains data that is newer
|
||||
# than the PNG buffer.
|
||||
self._png_is_old = True
|
||||
# Set to True by the `refresh` message so that the next frame
|
||||
# sent to the clients will be a full frame.
|
||||
self._force_full = True
|
||||
# The last buffer, for diff mode.
|
||||
self._last_buff = np.empty((0, 0))
|
||||
# Store the current image mode so that at any point, clients can
|
||||
# request the information. This should be changed by calling
|
||||
# self.set_image_mode(mode) so that the notification can be given
|
||||
# to the connected clients.
|
||||
self._current_image_mode = 'full'
|
||||
# Track mouse events to fill in the x, y position of key events.
|
||||
self._last_mouse_xy = (None, None)
|
||||
|
||||
def show(self):
|
||||
# show the figure window
|
||||
from matplotlib.pyplot import show
|
||||
show()
|
||||
|
||||
def draw(self):
|
||||
self._png_is_old = True
|
||||
try:
|
||||
super().draw()
|
||||
finally:
|
||||
self.manager.refresh_all() # Swap the frames.
|
||||
|
||||
def blit(self, bbox=None):
|
||||
self._png_is_old = True
|
||||
self.manager.refresh_all()
|
||||
|
||||
def draw_idle(self):
|
||||
self.send_event("draw")
|
||||
|
||||
def set_cursor(self, cursor):
|
||||
# docstring inherited
|
||||
cursor = _api.check_getitem({
|
||||
backend_tools.Cursors.HAND: 'pointer',
|
||||
backend_tools.Cursors.POINTER: 'default',
|
||||
backend_tools.Cursors.SELECT_REGION: 'crosshair',
|
||||
backend_tools.Cursors.MOVE: 'move',
|
||||
backend_tools.Cursors.WAIT: 'wait',
|
||||
backend_tools.Cursors.RESIZE_HORIZONTAL: 'ew-resize',
|
||||
backend_tools.Cursors.RESIZE_VERTICAL: 'ns-resize',
|
||||
}, cursor=cursor)
|
||||
self.send_event('cursor', cursor=cursor)
|
||||
|
||||
def set_image_mode(self, mode):
|
||||
"""
|
||||
Set the image mode for any subsequent images which will be sent
|
||||
to the clients. The modes may currently be either 'full' or 'diff'.
|
||||
|
||||
Note: diff images may not contain transparency, therefore upon
|
||||
draw this mode may be changed if the resulting image has any
|
||||
transparent component.
|
||||
"""
|
||||
_api.check_in_list(['full', 'diff'], mode=mode)
|
||||
if self._current_image_mode != mode:
|
||||
self._current_image_mode = mode
|
||||
self.handle_send_image_mode(None)
|
||||
|
||||
def get_diff_image(self):
|
||||
if self._png_is_old:
|
||||
renderer = self.get_renderer()
|
||||
|
||||
pixels = np.asarray(renderer.buffer_rgba())
|
||||
# The buffer is created as type uint32 so that entire
|
||||
# pixels can be compared in one numpy call, rather than
|
||||
# needing to compare each plane separately.
|
||||
buff = pixels.view(np.uint32).squeeze(2)
|
||||
|
||||
if (self._force_full
|
||||
# If the buffer has changed size we need to do a full draw.
|
||||
or buff.shape != self._last_buff.shape
|
||||
# If any pixels have transparency, we need to force a full
|
||||
# draw as we cannot overlay new on top of old.
|
||||
or (pixels[:, :, 3] != 255).any()):
|
||||
self.set_image_mode('full')
|
||||
output = buff
|
||||
else:
|
||||
self.set_image_mode('diff')
|
||||
diff = buff != self._last_buff
|
||||
output = np.where(diff, buff, 0)
|
||||
|
||||
# Store the current buffer so we can compute the next diff.
|
||||
self._last_buff = buff.copy()
|
||||
self._force_full = False
|
||||
self._png_is_old = False
|
||||
|
||||
data = output.view(dtype=np.uint8).reshape((*output.shape, 4))
|
||||
with BytesIO() as png:
|
||||
Image.fromarray(data).save(png, format="png")
|
||||
return png.getvalue()
|
||||
|
||||
def handle_event(self, event):
|
||||
e_type = event['type']
|
||||
handler = getattr(self, f'handle_{e_type}',
|
||||
self.handle_unknown_event)
|
||||
return handler(event)
|
||||
|
||||
def handle_unknown_event(self, event):
|
||||
_log.warning('Unhandled message type %s. %s', event["type"], event)
|
||||
|
||||
def handle_ack(self, event):
|
||||
# Network latency tends to decrease if traffic is flowing
|
||||
# in both directions. Therefore, the browser sends back
|
||||
# an "ack" message after each image frame is received.
|
||||
# This could also be used as a simple sanity check in the
|
||||
# future, but for now the performance increase is enough
|
||||
# to justify it, even if the server does nothing with it.
|
||||
pass
|
||||
|
||||
def handle_draw(self, event):
|
||||
self.draw()
|
||||
|
||||
def _handle_mouse(self, event):
|
||||
x = event['x']
|
||||
y = event['y']
|
||||
y = self.get_renderer().height - y
|
||||
self._last_mouse_xy = x, y
|
||||
e_type = event['type']
|
||||
button = event['button'] + 1 # JS numbers off by 1 compared to mpl.
|
||||
buttons = { # JS ordering different compared to mpl.
|
||||
button for button, mask in [
|
||||
(MouseButton.LEFT, 1),
|
||||
(MouseButton.RIGHT, 2),
|
||||
(MouseButton.MIDDLE, 4),
|
||||
(MouseButton.BACK, 8),
|
||||
(MouseButton.FORWARD, 16),
|
||||
] if event['buttons'] & mask # State *after* press/release.
|
||||
}
|
||||
modifiers = event['modifiers']
|
||||
guiEvent = event.get('guiEvent')
|
||||
if e_type in ['button_press', 'button_release']:
|
||||
MouseEvent(e_type + '_event', self, x, y, button,
|
||||
modifiers=modifiers, guiEvent=guiEvent)._process()
|
||||
elif e_type == 'dblclick':
|
||||
MouseEvent('button_press_event', self, x, y, button, dblclick=True,
|
||||
modifiers=modifiers, guiEvent=guiEvent)._process()
|
||||
elif e_type == 'scroll':
|
||||
MouseEvent('scroll_event', self, x, y, step=event['step'],
|
||||
modifiers=modifiers, guiEvent=guiEvent)._process()
|
||||
elif e_type == 'motion_notify':
|
||||
MouseEvent(e_type + '_event', self, x, y,
|
||||
buttons=buttons, modifiers=modifiers, guiEvent=guiEvent,
|
||||
)._process()
|
||||
elif e_type in ['figure_enter', 'figure_leave']:
|
||||
LocationEvent(e_type + '_event', self, x, y,
|
||||
modifiers=modifiers, guiEvent=guiEvent)._process()
|
||||
|
||||
handle_button_press = handle_button_release = handle_dblclick = \
|
||||
handle_figure_enter = handle_figure_leave = handle_motion_notify = \
|
||||
handle_scroll = _handle_mouse
|
||||
|
||||
def _handle_key(self, event):
|
||||
KeyEvent(event['type'] + '_event', self,
|
||||
_handle_key(event['key']), *self._last_mouse_xy,
|
||||
guiEvent=event.get('guiEvent'))._process()
|
||||
handle_key_press = handle_key_release = _handle_key
|
||||
|
||||
def handle_toolbar_button(self, event):
|
||||
# TODO: Be more suspicious of the input
|
||||
getattr(self.toolbar, event['name'])()
|
||||
|
||||
def handle_refresh(self, event):
|
||||
figure_label = self.figure.get_label()
|
||||
if not figure_label:
|
||||
figure_label = f"Figure {self.manager.num}"
|
||||
self.send_event('figure_label', label=figure_label)
|
||||
self._force_full = True
|
||||
if self.toolbar:
|
||||
# Normal toolbar init would refresh this, but it happens before the
|
||||
# browser canvas is set up.
|
||||
self.toolbar.set_history_buttons()
|
||||
self.draw_idle()
|
||||
|
||||
def handle_resize(self, event):
|
||||
x = int(event.get('width', 800)) * self.device_pixel_ratio
|
||||
y = int(event.get('height', 800)) * self.device_pixel_ratio
|
||||
fig = self.figure
|
||||
# An attempt at approximating the figure size in pixels.
|
||||
fig.set_size_inches(x / fig.dpi, y / fig.dpi, forward=False)
|
||||
# Acknowledge the resize, and force the viewer to update the
|
||||
# canvas size to the figure's new size (which is hopefully
|
||||
# identical or within a pixel or so).
|
||||
self._png_is_old = True
|
||||
self.manager.resize(*fig.bbox.size, forward=False)
|
||||
ResizeEvent('resize_event', self)._process()
|
||||
self.draw_idle()
|
||||
|
||||
def handle_send_image_mode(self, event):
|
||||
# The client requests notification of what the current image mode is.
|
||||
self.send_event('image_mode', mode=self._current_image_mode)
|
||||
|
||||
def handle_set_device_pixel_ratio(self, event):
|
||||
self._handle_set_device_pixel_ratio(event.get('device_pixel_ratio', 1))
|
||||
|
||||
def handle_set_dpi_ratio(self, event):
|
||||
# This handler is for backwards-compatibility with older ipympl.
|
||||
self._handle_set_device_pixel_ratio(event.get('dpi_ratio', 1))
|
||||
|
||||
def _handle_set_device_pixel_ratio(self, device_pixel_ratio):
|
||||
if self._set_device_pixel_ratio(device_pixel_ratio):
|
||||
self._force_full = True
|
||||
self.draw_idle()
|
||||
|
||||
def send_event(self, event_type, **kwargs):
|
||||
if self.manager:
|
||||
self.manager._send_event(event_type, **kwargs)
|
||||
|
||||
|
||||
_ALLOWED_TOOL_ITEMS = {
|
||||
'home',
|
||||
'back',
|
||||
'forward',
|
||||
'pan',
|
||||
'zoom',
|
||||
'download',
|
||||
None,
|
||||
}
|
||||
|
||||
|
||||
class NavigationToolbar2WebAgg(backend_bases.NavigationToolbar2):
|
||||
|
||||
# Use the standard toolbar items + download button
|
||||
toolitems = [
|
||||
(text, tooltip_text, image_file, name_of_method)
|
||||
for text, tooltip_text, image_file, name_of_method
|
||||
in (*backend_bases.NavigationToolbar2.toolitems,
|
||||
('Download', 'Download plot', 'filesave', 'download'))
|
||||
if name_of_method in _ALLOWED_TOOL_ITEMS
|
||||
]
|
||||
|
||||
def __init__(self, canvas):
|
||||
self.message = ''
|
||||
super().__init__(canvas)
|
||||
|
||||
def set_message(self, message):
|
||||
if message != self.message:
|
||||
self.canvas.send_event("message", message=message)
|
||||
self.message = message
|
||||
|
||||
def draw_rubberband(self, event, x0, y0, x1, y1):
|
||||
self.canvas.send_event("rubberband", x0=x0, y0=y0, x1=x1, y1=y1)
|
||||
|
||||
def remove_rubberband(self):
|
||||
self.canvas.send_event("rubberband", x0=-1, y0=-1, x1=-1, y1=-1)
|
||||
|
||||
def save_figure(self, *args):
|
||||
"""Save the current figure."""
|
||||
self.canvas.send_event('save')
|
||||
return self.UNKNOWN_SAVED_STATUS
|
||||
|
||||
def pan(self):
|
||||
super().pan()
|
||||
self.canvas.send_event('navigate_mode', mode=self.mode.name)
|
||||
|
||||
def zoom(self):
|
||||
super().zoom()
|
||||
self.canvas.send_event('navigate_mode', mode=self.mode.name)
|
||||
|
||||
def set_history_buttons(self):
|
||||
can_backward = self._nav_stack._pos > 0
|
||||
can_forward = self._nav_stack._pos < len(self._nav_stack) - 1
|
||||
self.canvas.send_event('history_buttons',
|
||||
Back=can_backward, Forward=can_forward)
|
||||
|
||||
|
||||
class FigureManagerWebAgg(backend_bases.FigureManagerBase):
|
||||
# This must be None to not break ipympl
|
||||
_toolbar2_class = None
|
||||
ToolbarCls = NavigationToolbar2WebAgg
|
||||
_window_title = "Matplotlib"
|
||||
|
||||
def __init__(self, canvas, num):
|
||||
self.web_sockets = set()
|
||||
super().__init__(canvas, num)
|
||||
|
||||
def show(self):
|
||||
pass
|
||||
|
||||
def resize(self, w, h, forward=True):
|
||||
self._send_event(
|
||||
'resize',
|
||||
size=(w / self.canvas.device_pixel_ratio,
|
||||
h / self.canvas.device_pixel_ratio),
|
||||
forward=forward)
|
||||
|
||||
def set_window_title(self, title):
|
||||
self._send_event('figure_label', label=title)
|
||||
self._window_title = title
|
||||
|
||||
def get_window_title(self):
|
||||
return self._window_title
|
||||
|
||||
# The following methods are specific to FigureManagerWebAgg
|
||||
|
||||
def add_web_socket(self, web_socket):
|
||||
assert hasattr(web_socket, 'send_binary')
|
||||
assert hasattr(web_socket, 'send_json')
|
||||
self.web_sockets.add(web_socket)
|
||||
self.resize(*self.canvas.figure.bbox.size)
|
||||
self._send_event('refresh')
|
||||
|
||||
def remove_web_socket(self, web_socket):
|
||||
self.web_sockets.remove(web_socket)
|
||||
|
||||
def handle_json(self, content):
|
||||
self.canvas.handle_event(content)
|
||||
|
||||
def refresh_all(self):
|
||||
if self.web_sockets:
|
||||
diff = self.canvas.get_diff_image()
|
||||
if diff is not None:
|
||||
for s in self.web_sockets:
|
||||
s.send_binary(diff)
|
||||
|
||||
@classmethod
|
||||
def get_javascript(cls, stream=None):
|
||||
if stream is None:
|
||||
output = StringIO()
|
||||
else:
|
||||
output = stream
|
||||
|
||||
output.write((Path(__file__).parent / "web_backend/js/mpl.js")
|
||||
.read_text(encoding="utf-8"))
|
||||
|
||||
toolitems = []
|
||||
for name, tooltip, image, method in cls.ToolbarCls.toolitems:
|
||||
if name is None:
|
||||
toolitems.append(['', '', '', ''])
|
||||
else:
|
||||
toolitems.append([name, tooltip, image, method])
|
||||
output.write(f"mpl.toolbar_items = {json.dumps(toolitems)};\n\n")
|
||||
|
||||
extensions = []
|
||||
for filetype, ext in sorted(FigureCanvasWebAggCore.
|
||||
get_supported_filetypes_grouped().
|
||||
items()):
|
||||
extensions.append(ext[0])
|
||||
output.write(f"mpl.extensions = {json.dumps(extensions)};\n\n")
|
||||
|
||||
output.write("mpl.default_extension = {};".format(
|
||||
json.dumps(FigureCanvasWebAggCore.get_default_filetype())))
|
||||
|
||||
if stream is None:
|
||||
return output.getvalue()
|
||||
|
||||
@classmethod
|
||||
def get_static_file_path(cls):
|
||||
return os.path.join(os.path.dirname(__file__), 'web_backend')
|
||||
|
||||
def _send_event(self, event_type, **kwargs):
|
||||
payload = {'type': event_type, **kwargs}
|
||||
for s in self.web_sockets:
|
||||
s.send_json(payload)
|
||||
|
||||
|
||||
@_Backend.export
|
||||
class _BackendWebAggCoreAgg(_Backend):
|
||||
FigureCanvas = FigureCanvasWebAggCore
|
||||
FigureManager = FigureManagerWebAgg
|
||||
1381
venv/lib/python3.13/site-packages/matplotlib/backends/backend_wx.py
Normal file
1381
venv/lib/python3.13/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: F811
|
||||
QT_API = None
|
||||
# A non-Qt backend was selected but we still got there (possible, e.g., when
|
||||
# fully manually embedding Matplotlib in a Qt app without using pyplot).
|
||||
elif QT_API_ENV is None:
|
||||
QT_API = None
|
||||
elif QT_API_ENV in _ETS:
|
||||
QT_API = _ETS[QT_API_ENV]
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"The environment variable QT_API has the unrecognized value {!r}; "
|
||||
"valid values are {}".format(QT_API_ENV, ", ".join(_ETS)))
|
||||
|
||||
|
||||
def _setup_pyqt5plus():
|
||||
global QtCore, QtGui, QtWidgets, __version__
|
||||
global _isdeleted, _to_int
|
||||
|
||||
if QT_API == QT_API_PYQT6:
|
||||
from PyQt6 import QtCore, QtGui, QtWidgets, sip
|
||||
__version__ = QtCore.PYQT_VERSION_STR
|
||||
QtCore.Signal = QtCore.pyqtSignal
|
||||
QtCore.Slot = QtCore.pyqtSlot
|
||||
QtCore.Property = QtCore.pyqtProperty
|
||||
_isdeleted = sip.isdeleted
|
||||
_to_int = operator.attrgetter('value')
|
||||
elif QT_API == QT_API_PYSIDE6:
|
||||
from PySide6 import QtCore, QtGui, QtWidgets, __version__
|
||||
import shiboken6
|
||||
def _isdeleted(obj): return not shiboken6.isValid(obj)
|
||||
if parse_version(__version__) >= parse_version('6.4'):
|
||||
_to_int = operator.attrgetter('value')
|
||||
else:
|
||||
_to_int = int
|
||||
elif QT_API == QT_API_PYQT5:
|
||||
from PyQt5 import QtCore, QtGui, QtWidgets
|
||||
import sip
|
||||
__version__ = QtCore.PYQT_VERSION_STR
|
||||
QtCore.Signal = QtCore.pyqtSignal
|
||||
QtCore.Slot = QtCore.pyqtSlot
|
||||
QtCore.Property = QtCore.pyqtProperty
|
||||
_isdeleted = sip.isdeleted
|
||||
_to_int = int
|
||||
elif QT_API == QT_API_PYSIDE2:
|
||||
from PySide2 import QtCore, QtGui, QtWidgets, __version__
|
||||
try:
|
||||
from PySide2 import shiboken2
|
||||
except ImportError:
|
||||
import shiboken2
|
||||
def _isdeleted(obj):
|
||||
return not shiboken2.isValid(obj)
|
||||
_to_int = int
|
||||
else:
|
||||
raise AssertionError(f"Unexpected QT_API: {QT_API}")
|
||||
|
||||
|
||||
if QT_API in [QT_API_PYQT6, QT_API_PYQT5, QT_API_PYSIDE6, QT_API_PYSIDE2]:
|
||||
_setup_pyqt5plus()
|
||||
elif QT_API is None: # See above re: dict.__getitem__.
|
||||
if _QT_FORCE_QT5_BINDING:
|
||||
_candidates = [
|
||||
(_setup_pyqt5plus, QT_API_PYQT5),
|
||||
(_setup_pyqt5plus, QT_API_PYSIDE2),
|
||||
]
|
||||
else:
|
||||
_candidates = [
|
||||
(_setup_pyqt5plus, QT_API_PYQT6),
|
||||
(_setup_pyqt5plus, QT_API_PYSIDE6),
|
||||
(_setup_pyqt5plus, QT_API_PYQT5),
|
||||
(_setup_pyqt5plus, QT_API_PYSIDE2),
|
||||
]
|
||||
for _setup, QT_API in _candidates:
|
||||
try:
|
||||
_setup()
|
||||
except ImportError:
|
||||
continue
|
||||
break
|
||||
else:
|
||||
raise ImportError(
|
||||
"Failed to import any of the following Qt binding modules: {}"
|
||||
.format(", ".join([QT_API for _, QT_API in _candidates]))
|
||||
)
|
||||
else: # We should not get there.
|
||||
raise AssertionError(f"Unexpected QT_API: {QT_API}")
|
||||
_version_info = tuple(QtCore.QLibraryInfo.version().segments())
|
||||
|
||||
|
||||
if _version_info < (5, 12):
|
||||
raise ImportError(
|
||||
f"The Qt version imported is "
|
||||
f"{QtCore.QLibraryInfo.version().toString()} but Matplotlib requires "
|
||||
f"Qt>=5.12")
|
||||
|
||||
|
||||
# Fixes issues with Big Sur
|
||||
# https://bugreports.qt.io/browse/QTBUG-87014, fixed in qt 5.15.2
|
||||
if (sys.platform == 'darwin' and
|
||||
parse_version(platform.mac_ver()[0]) >= parse_version("10.16") and
|
||||
_version_info < (5, 15, 2)):
|
||||
os.environ.setdefault("QT_MAC_WANTS_LAYER", "1")
|
||||
|
||||
|
||||
# Backports.
|
||||
|
||||
|
||||
def _exec(obj):
|
||||
# exec on PyQt6, exec_ elsewhere.
|
||||
obj.exec() if hasattr(obj, "exec") else obj.exec_()
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -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.get_converter()
|
||||
))
|
||||
for name, axis in axis_map.items()
|
||||
}
|
||||
general = [
|
||||
('Title', axes.get_title()),
|
||||
sep,
|
||||
*chain.from_iterable([
|
||||
(
|
||||
(None, f"<b>{name.title()}-Axis</b>"),
|
||||
('Min', axis_limits[name][0]),
|
||||
('Max', axis_limits[name][1]),
|
||||
('Label', axis.label.get_text()),
|
||||
('Scale', [axis.get_scale(),
|
||||
'linear', 'log', 'symlog', 'logit']),
|
||||
sep,
|
||||
)
|
||||
for name, axis in axis_map.items()
|
||||
]),
|
||||
('(Re-)Generate automatic legend', False),
|
||||
]
|
||||
|
||||
# Save the converter and unit data
|
||||
axis_converter = {
|
||||
name: axis.get_converter()
|
||||
for name, axis in axis_map.items()
|
||||
}
|
||||
axis_units = {
|
||||
name: axis.get_units()
|
||||
for name, axis in axis_map.items()
|
||||
}
|
||||
|
||||
# Get / Curves
|
||||
labeled_lines = []
|
||||
for line in axes.get_lines():
|
||||
label = line.get_label()
|
||||
if label == '_nolegend_':
|
||||
continue
|
||||
labeled_lines.append((label, line))
|
||||
curves = []
|
||||
|
||||
def prepare_data(d, init):
|
||||
"""
|
||||
Prepare entry for FormLayout.
|
||||
|
||||
*d* is a mapping of shorthands to style names (a single style may
|
||||
have multiple shorthands, in particular the shorthands `None`,
|
||||
`"None"`, `"none"` and `""` are synonyms); *init* is one shorthand
|
||||
of the initial style.
|
||||
|
||||
This function returns an list suitable for initializing a
|
||||
FormLayout combobox, namely `[initial_name, (shorthand,
|
||||
style_name), (shorthand, style_name), ...]`.
|
||||
"""
|
||||
if init not in d:
|
||||
d = {**d, init: str(init)}
|
||||
# Drop duplicate shorthands from dict (by overwriting them during
|
||||
# the dict comprehension).
|
||||
name2short = {name: short for short, name in d.items()}
|
||||
# Convert back to {shorthand: name}.
|
||||
short2name = {short: name for name, short in name2short.items()}
|
||||
# Find the kept shorthand for the style specified by init.
|
||||
canonical_init = name2short[d[init]]
|
||||
# Sort by representation and prepend the initial value.
|
||||
return ([canonical_init] +
|
||||
sorted(short2name.items(),
|
||||
key=lambda short_and_name: short_and_name[1]))
|
||||
|
||||
for label, line in labeled_lines:
|
||||
color = mcolors.to_hex(
|
||||
mcolors.to_rgba(line.get_color(), line.get_alpha()),
|
||||
keep_alpha=True)
|
||||
ec = mcolors.to_hex(
|
||||
mcolors.to_rgba(line.get_markeredgecolor(), line.get_alpha()),
|
||||
keep_alpha=True)
|
||||
fc = mcolors.to_hex(
|
||||
mcolors.to_rgba(line.get_markerfacecolor(), line.get_alpha()),
|
||||
keep_alpha=True)
|
||||
curvedata = [
|
||||
('Label', label),
|
||||
sep,
|
||||
(None, '<b>Line</b>'),
|
||||
('Line style', prepare_data(LINESTYLES, line.get_linestyle())),
|
||||
('Draw style', prepare_data(DRAWSTYLES, line.get_drawstyle())),
|
||||
('Width', line.get_linewidth()),
|
||||
('Color (RGBA)', color),
|
||||
sep,
|
||||
(None, '<b>Marker</b>'),
|
||||
('Style', prepare_data(MARKERS, line.get_marker())),
|
||||
('Size', line.get_markersize()),
|
||||
('Face color (RGBA)', fc),
|
||||
('Edge color (RGBA)', ec)]
|
||||
curves.append([curvedata, label, ""])
|
||||
# Is there a curve displayed?
|
||||
has_curve = bool(curves)
|
||||
|
||||
# Get ScalarMappables.
|
||||
labeled_mappables = []
|
||||
for mappable in [*axes.images, *axes.collections]:
|
||||
label = mappable.get_label()
|
||||
if label == '_nolegend_' or mappable.get_array() is None:
|
||||
continue
|
||||
labeled_mappables.append((label, mappable))
|
||||
mappables = []
|
||||
cmaps = [(cmap, name) for name, cmap in sorted(cm._colormaps.items())]
|
||||
for label, mappable in labeled_mappables:
|
||||
cmap = mappable.get_cmap()
|
||||
if cmap.name not in cm._colormaps:
|
||||
cmaps = [(cmap, cmap.name), *cmaps]
|
||||
low, high = mappable.get_clim()
|
||||
mappabledata = [
|
||||
('Label', label),
|
||||
('Colormap', [cmap.name] + cmaps),
|
||||
('Min. value', low),
|
||||
('Max. value', high),
|
||||
]
|
||||
if hasattr(mappable, "get_interpolation"): # Images.
|
||||
interpolations = [
|
||||
(name, name) for name in sorted(mimage.interpolations_names)]
|
||||
mappabledata.append((
|
||||
'Interpolation',
|
||||
[mappable.get_interpolation(), *interpolations]))
|
||||
|
||||
interpolation_stages = ['data', 'rgba', 'auto']
|
||||
mappabledata.append((
|
||||
'Interpolation stage',
|
||||
[mappable.get_interpolation_stage(), *interpolation_stages]))
|
||||
|
||||
mappables.append([mappabledata, label, ""])
|
||||
# Is there a scalarmappable displayed?
|
||||
has_sm = bool(mappables)
|
||||
|
||||
datalist = [(general, "Axes", "")]
|
||||
if curves:
|
||||
datalist.append((curves, "Curves", ""))
|
||||
if mappables:
|
||||
datalist.append((mappables, "Images, etc.", ""))
|
||||
|
||||
def apply_callback(data):
|
||||
"""A callback to apply changes."""
|
||||
orig_limits = {
|
||||
name: getattr(axes, f"get_{name}lim")()
|
||||
for name in axis_map
|
||||
}
|
||||
|
||||
general = data.pop(0)
|
||||
curves = data.pop(0) if has_curve else []
|
||||
mappables = data.pop(0) if has_sm else []
|
||||
if data:
|
||||
raise ValueError("Unexpected field")
|
||||
|
||||
title = general.pop(0)
|
||||
axes.set_title(title)
|
||||
generate_legend = general.pop()
|
||||
|
||||
for i, (name, axis) in enumerate(axis_map.items()):
|
||||
axis_min = general[4*i]
|
||||
axis_max = general[4*i + 1]
|
||||
axis_label = general[4*i + 2]
|
||||
axis_scale = general[4*i + 3]
|
||||
if axis.get_scale() != axis_scale:
|
||||
getattr(axes, f"set_{name}scale")(axis_scale)
|
||||
|
||||
axis._set_lim(axis_min, axis_max, auto=False)
|
||||
axis.set_label_text(axis_label)
|
||||
|
||||
# Restore the unit data
|
||||
axis._set_converter(axis_converter[name])
|
||||
axis.set_units(axis_units[name])
|
||||
|
||||
# Set / Curves
|
||||
for index, curve in enumerate(curves):
|
||||
line = labeled_lines[index][1]
|
||||
(label, linestyle, drawstyle, linewidth, color, marker, markersize,
|
||||
markerfacecolor, markeredgecolor) = curve
|
||||
line.set_label(label)
|
||||
line.set_linestyle(linestyle)
|
||||
line.set_drawstyle(drawstyle)
|
||||
line.set_linewidth(linewidth)
|
||||
rgba = mcolors.to_rgba(color)
|
||||
line.set_alpha(None)
|
||||
line.set_color(rgba)
|
||||
if marker != 'none':
|
||||
line.set_marker(marker)
|
||||
line.set_markersize(markersize)
|
||||
line.set_markerfacecolor(markerfacecolor)
|
||||
line.set_markeredgecolor(markeredgecolor)
|
||||
|
||||
# Set ScalarMappables.
|
||||
for index, mappable_settings in enumerate(mappables):
|
||||
mappable = labeled_mappables[index][1]
|
||||
if len(mappable_settings) == 6:
|
||||
label, cmap, low, high, interpolation, interpolation_stage = \
|
||||
mappable_settings
|
||||
mappable.set_interpolation(interpolation)
|
||||
mappable.set_interpolation_stage(interpolation_stage)
|
||||
elif len(mappable_settings) == 4:
|
||||
label, cmap, low, high = mappable_settings
|
||||
mappable.set_label(label)
|
||||
mappable.set_cmap(cmap)
|
||||
mappable.set_clim(*sorted([low, high]))
|
||||
|
||||
# re-generate legend, if checkbox is checked
|
||||
if generate_legend:
|
||||
draggable = None
|
||||
ncols = 1
|
||||
if axes.legend_ is not None:
|
||||
old_legend = axes.get_legend()
|
||||
draggable = old_legend._draggable is not None
|
||||
ncols = old_legend._ncols
|
||||
new_legend = axes.legend(ncols=ncols)
|
||||
if new_legend:
|
||||
new_legend.set_draggable(draggable)
|
||||
|
||||
# Redraw
|
||||
figure = axes.get_figure()
|
||||
figure.canvas.draw()
|
||||
for name in axis_map:
|
||||
if getattr(axes, f"get_{name}lim")() != orig_limits[name]:
|
||||
figure.canvas.toolbar.push_current()
|
||||
break
|
||||
|
||||
_formlayout.fedit(
|
||||
datalist, title="Figure options", parent=parent,
|
||||
icon=QtGui.QIcon(
|
||||
str(cbook._get_data_path('images', 'qt4_editor_options.svg'))),
|
||||
apply=apply_callback)
|
||||
|
|
@ -0,0 +1,414 @@
|
|||
from enum import Enum
|
||||
import importlib
|
||||
|
||||
|
||||
class BackendFilter(Enum):
|
||||
"""
|
||||
Filter used with :meth:`~matplotlib.backends.registry.BackendRegistry.list_builtin`
|
||||
|
||||
.. versionadded:: 3.9
|
||||
"""
|
||||
INTERACTIVE = 0
|
||||
NON_INTERACTIVE = 1
|
||||
|
||||
|
||||
class BackendRegistry:
|
||||
"""
|
||||
Registry of backends available within Matplotlib.
|
||||
|
||||
This is the single source of truth for available backends.
|
||||
|
||||
All use of ``BackendRegistry`` should be via the singleton instance
|
||||
``backend_registry`` which can be imported from ``matplotlib.backends``.
|
||||
|
||||
Each backend has a name, a module name containing the backend code, and an
|
||||
optional GUI framework that must be running if the backend is interactive.
|
||||
There are three sources of backends: built-in (source code is within the
|
||||
Matplotlib repository), explicit ``module://some.backend`` syntax (backend is
|
||||
obtained by loading the module), or via an entry point (self-registering
|
||||
backend in an external package).
|
||||
|
||||
.. versionadded:: 3.9
|
||||
"""
|
||||
# Mapping of built-in backend name to GUI framework, or "headless" for no
|
||||
# GUI framework. Built-in backends are those which are included in the
|
||||
# Matplotlib repo. A backend with name 'name' is located in the module
|
||||
# f"matplotlib.backends.backend_{name.lower()}"
|
||||
_BUILTIN_BACKEND_TO_GUI_FRAMEWORK = {
|
||||
"gtk3agg": "gtk3",
|
||||
"gtk3cairo": "gtk3",
|
||||
"gtk4agg": "gtk4",
|
||||
"gtk4cairo": "gtk4",
|
||||
"macosx": "macosx",
|
||||
"nbagg": "nbagg",
|
||||
"notebook": "nbagg",
|
||||
"qtagg": "qt",
|
||||
"qtcairo": "qt",
|
||||
"qt5agg": "qt5",
|
||||
"qt5cairo": "qt5",
|
||||
"tkagg": "tk",
|
||||
"tkcairo": "tk",
|
||||
"webagg": "webagg",
|
||||
"wx": "wx",
|
||||
"wxagg": "wx",
|
||||
"wxcairo": "wx",
|
||||
"agg": "headless",
|
||||
"cairo": "headless",
|
||||
"pdf": "headless",
|
||||
"pgf": "headless",
|
||||
"ps": "headless",
|
||||
"svg": "headless",
|
||||
"template": "headless",
|
||||
}
|
||||
|
||||
# Reverse mapping of gui framework to preferred built-in backend.
|
||||
_GUI_FRAMEWORK_TO_BACKEND = {
|
||||
"gtk3": "gtk3agg",
|
||||
"gtk4": "gtk4agg",
|
||||
"headless": "agg",
|
||||
"macosx": "macosx",
|
||||
"qt": "qtagg",
|
||||
"qt5": "qt5agg",
|
||||
"qt6": "qtagg",
|
||||
"tk": "tkagg",
|
||||
"wx": "wxagg",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
# Only load entry points when first needed.
|
||||
self._loaded_entry_points = False
|
||||
|
||||
# Mapping of non-built-in backend to GUI framework, added dynamically from
|
||||
# entry points and from matplotlib.use("module://some.backend") format.
|
||||
# New entries have an "unknown" GUI framework that is determined when first
|
||||
# needed by calling _get_gui_framework_by_loading.
|
||||
self._backend_to_gui_framework = {}
|
||||
|
||||
# Mapping of backend name to module name, where different from
|
||||
# f"matplotlib.backends.backend_{backend_name.lower()}". These are either
|
||||
# hardcoded for backward compatibility, or loaded from entry points or
|
||||
# "module://some.backend" syntax.
|
||||
self._name_to_module = {
|
||||
"notebook": "nbagg",
|
||||
}
|
||||
|
||||
def _backend_module_name(self, backend):
|
||||
if backend.startswith("module://"):
|
||||
return backend[9:]
|
||||
|
||||
# Return name of module containing the specified backend.
|
||||
# Does not check if the backend is valid, use is_valid_backend for that.
|
||||
backend = backend.lower()
|
||||
|
||||
# Check if have specific name to module mapping.
|
||||
backend = self._name_to_module.get(backend, backend)
|
||||
|
||||
return (backend[9:] if backend.startswith("module://")
|
||||
else f"matplotlib.backends.backend_{backend}")
|
||||
|
||||
def _clear(self):
|
||||
# Clear all dynamically-added data, used for testing only.
|
||||
self.__init__()
|
||||
|
||||
def _ensure_entry_points_loaded(self):
|
||||
# Load entry points, if they have not already been loaded.
|
||||
if not self._loaded_entry_points:
|
||||
entries = self._read_entry_points()
|
||||
self._validate_and_store_entry_points(entries)
|
||||
self._loaded_entry_points = True
|
||||
|
||||
def _get_gui_framework_by_loading(self, backend):
|
||||
# Determine GUI framework for a backend by loading its module and reading the
|
||||
# FigureCanvas.required_interactive_framework attribute.
|
||||
# Returns "headless" if there is no GUI framework.
|
||||
module = self.load_backend_module(backend)
|
||||
canvas_class = module.FigureCanvas
|
||||
return canvas_class.required_interactive_framework or "headless"
|
||||
|
||||
def _read_entry_points(self):
|
||||
# Read entry points of modules that self-advertise as Matplotlib backends.
|
||||
# Expects entry points like this one from matplotlib-inline (in pyproject.toml
|
||||
# format):
|
||||
# [project.entry-points."matplotlib.backend"]
|
||||
# inline = "matplotlib_inline.backend_inline"
|
||||
import importlib.metadata as im
|
||||
|
||||
entry_points = im.entry_points(group="matplotlib.backend")
|
||||
entries = [(entry.name, entry.value) for entry in entry_points]
|
||||
|
||||
# For backward compatibility, if matplotlib-inline and/or ipympl are installed
|
||||
# but too old to include entry points, create them. Do not import ipympl
|
||||
# directly as this calls matplotlib.use() whilst in this function.
|
||||
def backward_compatible_entry_points(
|
||||
entries, module_name, threshold_version, names, target):
|
||||
from matplotlib import _parse_to_version_info
|
||||
try:
|
||||
module_version = im.version(module_name)
|
||||
if _parse_to_version_info(module_version) < threshold_version:
|
||||
for name in names:
|
||||
entries.append((name, target))
|
||||
except im.PackageNotFoundError:
|
||||
pass
|
||||
|
||||
names = [entry[0] for entry in entries]
|
||||
if "inline" not in names:
|
||||
backward_compatible_entry_points(
|
||||
entries, "matplotlib_inline", (0, 1, 7), ["inline"],
|
||||
"matplotlib_inline.backend_inline")
|
||||
if "ipympl" not in names:
|
||||
backward_compatible_entry_points(
|
||||
entries, "ipympl", (0, 9, 4), ["ipympl", "widget"],
|
||||
"ipympl.backend_nbagg")
|
||||
|
||||
return entries
|
||||
|
||||
def _validate_and_store_entry_points(self, entries):
|
||||
# Validate and store entry points so that they can be used via matplotlib.use()
|
||||
# in the normal manner. Entry point names cannot be of module:// format, cannot
|
||||
# shadow a built-in backend name, and there cannot be multiple entry points
|
||||
# with the same name but different modules. Multiple entry points with the same
|
||||
# name and value are permitted (it can sometimes happen outside of our control,
|
||||
# see https://github.com/matplotlib/matplotlib/issues/28367).
|
||||
for name, module in set(entries):
|
||||
name = name.lower()
|
||||
if name.startswith("module://"):
|
||||
raise RuntimeError(
|
||||
f"Entry point name '{name}' cannot start with 'module://'")
|
||||
if name in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK:
|
||||
raise RuntimeError(f"Entry point name '{name}' is a built-in backend")
|
||||
if name in self._backend_to_gui_framework:
|
||||
raise RuntimeError(f"Entry point name '{name}' duplicated")
|
||||
|
||||
self._name_to_module[name] = "module://" + module
|
||||
# Do not yet know backend GUI framework, determine it only when necessary.
|
||||
self._backend_to_gui_framework[name] = "unknown"
|
||||
|
||||
def backend_for_gui_framework(self, framework):
|
||||
"""
|
||||
Return the name of the backend corresponding to the specified GUI framework.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
framework : str
|
||||
GUI framework such as "qt".
|
||||
|
||||
Returns
|
||||
-------
|
||||
str or None
|
||||
Backend name or None if GUI framework not recognised.
|
||||
"""
|
||||
return self._GUI_FRAMEWORK_TO_BACKEND.get(framework.lower())
|
||||
|
||||
def is_valid_backend(self, backend):
|
||||
"""
|
||||
Return True if the backend name is valid, False otherwise.
|
||||
|
||||
A backend name is valid if it is one of the built-in backends or has been
|
||||
dynamically added via an entry point. Those beginning with ``module://`` are
|
||||
always considered valid and are added to the current list of all backends
|
||||
within this function.
|
||||
|
||||
Even if a name is valid, it may not be importable or usable. This can only be
|
||||
determined by loading and using the backend module.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
backend : str
|
||||
Name of backend.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if backend is valid, False otherwise.
|
||||
"""
|
||||
if not backend.startswith("module://"):
|
||||
backend = backend.lower()
|
||||
|
||||
# For backward compatibility, convert ipympl and matplotlib-inline long
|
||||
# module:// names to their shortened forms.
|
||||
backwards_compat = {
|
||||
"module://ipympl.backend_nbagg": "widget",
|
||||
"module://matplotlib_inline.backend_inline": "inline",
|
||||
}
|
||||
backend = backwards_compat.get(backend, backend)
|
||||
|
||||
if (backend in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK or
|
||||
backend in self._backend_to_gui_framework):
|
||||
return True
|
||||
|
||||
if backend.startswith("module://"):
|
||||
self._backend_to_gui_framework[backend] = "unknown"
|
||||
return True
|
||||
|
||||
# Only load entry points if really need to and not already done so.
|
||||
self._ensure_entry_points_loaded()
|
||||
if backend in self._backend_to_gui_framework:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def list_all(self):
|
||||
"""
|
||||
Return list of all known backends.
|
||||
|
||||
These include built-in backends and those obtained at runtime either from entry
|
||||
points or explicit ``module://some.backend`` syntax.
|
||||
|
||||
Entry points will be loaded if they haven't been already.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of str
|
||||
Backend names.
|
||||
"""
|
||||
self._ensure_entry_points_loaded()
|
||||
return [*self.list_builtin(), *self._backend_to_gui_framework]
|
||||
|
||||
def list_builtin(self, filter_=None):
|
||||
"""
|
||||
Return list of backends that are built into Matplotlib.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
filter_ : `~.BackendFilter`, optional
|
||||
Filter to apply to returned backends. For example, to return only
|
||||
non-interactive backends use `.BackendFilter.NON_INTERACTIVE`.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of str
|
||||
Backend names.
|
||||
"""
|
||||
if filter_ == BackendFilter.INTERACTIVE:
|
||||
return [k for k, v in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.items()
|
||||
if v != "headless"]
|
||||
elif filter_ == BackendFilter.NON_INTERACTIVE:
|
||||
return [k for k, v in self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.items()
|
||||
if v == "headless"]
|
||||
|
||||
return [*self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK]
|
||||
|
||||
def list_gui_frameworks(self):
|
||||
"""
|
||||
Return list of GUI frameworks used by Matplotlib backends.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list of str
|
||||
GUI framework names.
|
||||
"""
|
||||
return [k for k in self._GUI_FRAMEWORK_TO_BACKEND if k != "headless"]
|
||||
|
||||
def load_backend_module(self, backend):
|
||||
"""
|
||||
Load and return the module containing the specified backend.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
backend : str
|
||||
Name of backend to load.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Module
|
||||
Module containing backend.
|
||||
"""
|
||||
module_name = self._backend_module_name(backend)
|
||||
return importlib.import_module(module_name)
|
||||
|
||||
def resolve_backend(self, backend):
|
||||
"""
|
||||
Return the backend and GUI framework for the specified backend name.
|
||||
|
||||
If the GUI framework is not yet known then it will be determined by loading the
|
||||
backend module and checking the ``FigureCanvas.required_interactive_framework``
|
||||
attribute.
|
||||
|
||||
This function only loads entry points if they have not already been loaded and
|
||||
the backend is not built-in and not of ``module://some.backend`` format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
backend : str or None
|
||||
Name of backend, or None to use the default backend.
|
||||
|
||||
Returns
|
||||
-------
|
||||
backend : str
|
||||
The backend name.
|
||||
framework : str or None
|
||||
The GUI framework, which will be None for a backend that is non-interactive.
|
||||
"""
|
||||
if isinstance(backend, str):
|
||||
if not backend.startswith("module://"):
|
||||
backend = backend.lower()
|
||||
else: # Might be _auto_backend_sentinel or None
|
||||
# Use whatever is already running...
|
||||
from matplotlib import get_backend
|
||||
backend = get_backend()
|
||||
|
||||
# Is backend already known (built-in or dynamically loaded)?
|
||||
gui = (self._BUILTIN_BACKEND_TO_GUI_FRAMEWORK.get(backend) or
|
||||
self._backend_to_gui_framework.get(backend))
|
||||
|
||||
# Is backend "module://something"?
|
||||
if gui is None and isinstance(backend, str) and backend.startswith("module://"):
|
||||
gui = "unknown"
|
||||
|
||||
# Is backend a possible entry point?
|
||||
if gui is None and not self._loaded_entry_points:
|
||||
self._ensure_entry_points_loaded()
|
||||
gui = self._backend_to_gui_framework.get(backend)
|
||||
|
||||
# Backend known but not its gui framework.
|
||||
if gui == "unknown":
|
||||
gui = self._get_gui_framework_by_loading(backend)
|
||||
self._backend_to_gui_framework[backend] = gui
|
||||
|
||||
if gui is None:
|
||||
raise RuntimeError(f"'{backend}' is not a recognised backend name")
|
||||
|
||||
return backend, gui if gui != "headless" else None
|
||||
|
||||
def resolve_gui_or_backend(self, gui_or_backend):
|
||||
"""
|
||||
Return the backend and GUI framework for the specified string that may be
|
||||
either a GUI framework or a backend name, tested in that order.
|
||||
|
||||
This is for use with the IPython %matplotlib magic command which may be a GUI
|
||||
framework such as ``%matplotlib qt`` or a backend name such as
|
||||
``%matplotlib qtagg``.
|
||||
|
||||
This function only loads entry points if they have not already been loaded and
|
||||
the backend is not built-in and not of ``module://some.backend`` format.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
gui_or_backend : str or None
|
||||
Name of GUI framework or backend, or None to use the default backend.
|
||||
|
||||
Returns
|
||||
-------
|
||||
backend : str
|
||||
The backend name.
|
||||
framework : str or None
|
||||
The GUI framework, which will be None for a backend that is non-interactive.
|
||||
"""
|
||||
if not gui_or_backend.startswith("module://"):
|
||||
gui_or_backend = gui_or_backend.lower()
|
||||
|
||||
# First check if it is a gui loop name.
|
||||
backend = self.backend_for_gui_framework(gui_or_backend)
|
||||
if backend is not None:
|
||||
return backend, gui_or_backend if gui_or_backend != "headless" else None
|
||||
|
||||
# Then check if it is a backend name.
|
||||
try:
|
||||
return self.resolve_backend(gui_or_backend)
|
||||
except Exception: # KeyError ?
|
||||
raise RuntimeError(
|
||||
f"'{gui_or_backend}' is not a recognised GUI loop or backend name")
|
||||
|
||||
|
||||
# Singleton
|
||||
backend_registry = BackendRegistry()
|
||||
|
|
@ -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,707 @@
|
|||
/* Put everything inside the global mpl namespace */
|
||||
/* global mpl */
|
||||
window.mpl = {};
|
||||
|
||||
mpl.get_websocket_type = function () {
|
||||
if (typeof WebSocket !== 'undefined') {
|
||||
return WebSocket;
|
||||
} else if (typeof MozWebSocket !== 'undefined') {
|
||||
return MozWebSocket;
|
||||
} else {
|
||||
alert(
|
||||
'Your browser does not have WebSocket support. ' +
|
||||
'Please try Chrome, Safari or Firefox ≥ 6. ' +
|
||||
'Firefox 4 and 5 are also supported but you ' +
|
||||
'have to enable WebSockets in about:config.'
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
mpl.figure = function (figure_id, websocket, ondownload, parent_element) {
|
||||
this.id = figure_id;
|
||||
|
||||
this.ws = websocket;
|
||||
|
||||
this.supports_binary = this.ws.binaryType !== undefined;
|
||||
|
||||
if (!this.supports_binary) {
|
||||
var warnings = document.getElementById('mpl-warnings');
|
||||
if (warnings) {
|
||||
warnings.style.display = 'block';
|
||||
warnings.textContent =
|
||||
'This browser does not support binary websocket messages. ' +
|
||||
'Performance may be slow.';
|
||||
}
|
||||
}
|
||||
|
||||
this.imageObj = new Image();
|
||||
|
||||
this.context = undefined;
|
||||
this.message = undefined;
|
||||
this.canvas = undefined;
|
||||
this.rubberband_canvas = undefined;
|
||||
this.rubberband_context = undefined;
|
||||
this.format_dropdown = undefined;
|
||||
|
||||
this.image_mode = 'full';
|
||||
|
||||
this.root = document.createElement('div');
|
||||
this.root.setAttribute('style', 'display: inline-block');
|
||||
this._root_extra_style(this.root);
|
||||
|
||||
parent_element.appendChild(this.root);
|
||||
|
||||
this._init_header(this);
|
||||
this._init_canvas(this);
|
||||
this._init_toolbar(this);
|
||||
|
||||
var fig = this;
|
||||
|
||||
this.waiting = false;
|
||||
|
||||
this.ws.onopen = function () {
|
||||
fig.send_message('supports_binary', { value: fig.supports_binary });
|
||||
fig.send_message('send_image_mode', {});
|
||||
if (fig.ratio !== 1) {
|
||||
fig.send_message('set_device_pixel_ratio', {
|
||||
device_pixel_ratio: fig.ratio,
|
||||
});
|
||||
}
|
||||
fig.send_message('refresh', {});
|
||||
};
|
||||
|
||||
this.imageObj.onload = function () {
|
||||
if (fig.image_mode === 'full') {
|
||||
// Full images could contain transparency (where diff images
|
||||
// almost always do), so we need to clear the canvas so that
|
||||
// there is no ghosting.
|
||||
fig.context.clearRect(0, 0, fig.canvas.width, fig.canvas.height);
|
||||
}
|
||||
fig.context.drawImage(fig.imageObj, 0, 0);
|
||||
};
|
||||
|
||||
this.imageObj.onunload = function () {
|
||||
fig.ws.close();
|
||||
};
|
||||
|
||||
this.ws.onmessage = this._make_on_message_function(this);
|
||||
|
||||
this.ondownload = ondownload;
|
||||
};
|
||||
|
||||
mpl.figure.prototype._init_header = function () {
|
||||
var titlebar = document.createElement('div');
|
||||
titlebar.classList =
|
||||
'ui-dialog-titlebar ui-widget-header ui-corner-all ui-helper-clearfix';
|
||||
var titletext = document.createElement('div');
|
||||
titletext.classList = 'ui-dialog-title';
|
||||
titletext.setAttribute(
|
||||
'style',
|
||||
'width: 100%; text-align: center; padding: 3px;'
|
||||
);
|
||||
titlebar.appendChild(titletext);
|
||||
this.root.appendChild(titlebar);
|
||||
this.header = titletext;
|
||||
};
|
||||
|
||||
mpl.figure.prototype._canvas_extra_style = function (_canvas_div) {};
|
||||
|
||||
mpl.figure.prototype._root_extra_style = function (_canvas_div) {};
|
||||
|
||||
mpl.figure.prototype._init_canvas = function () {
|
||||
var fig = this;
|
||||
|
||||
var canvas_div = (this.canvas_div = document.createElement('div'));
|
||||
canvas_div.setAttribute('tabindex', '0');
|
||||
canvas_div.setAttribute(
|
||||
'style',
|
||||
'border: 1px solid #ddd;' +
|
||||
'box-sizing: content-box;' +
|
||||
'clear: both;' +
|
||||
'min-height: 1px;' +
|
||||
'min-width: 1px;' +
|
||||
'outline: 0;' +
|
||||
'overflow: hidden;' +
|
||||
'position: relative;' +
|
||||
'resize: both;' +
|
||||
'z-index: 2;'
|
||||
);
|
||||
|
||||
function on_keyboard_event_closure(name) {
|
||||
return function (event) {
|
||||
return fig.key_event(event, name);
|
||||
};
|
||||
}
|
||||
|
||||
canvas_div.addEventListener(
|
||||
'keydown',
|
||||
on_keyboard_event_closure('key_press')
|
||||
);
|
||||
canvas_div.addEventListener(
|
||||
'keyup',
|
||||
on_keyboard_event_closure('key_release')
|
||||
);
|
||||
|
||||
this._canvas_extra_style(canvas_div);
|
||||
this.root.appendChild(canvas_div);
|
||||
|
||||
var canvas = (this.canvas = document.createElement('canvas'));
|
||||
canvas.classList.add('mpl-canvas');
|
||||
canvas.setAttribute(
|
||||
'style',
|
||||
'box-sizing: content-box;' +
|
||||
'pointer-events: none;' +
|
||||
'position: relative;' +
|
||||
'z-index: 0;'
|
||||
);
|
||||
|
||||
this.context = canvas.getContext('2d');
|
||||
|
||||
var backingStore =
|
||||
this.context.backingStorePixelRatio ||
|
||||
this.context.webkitBackingStorePixelRatio ||
|
||||
this.context.mozBackingStorePixelRatio ||
|
||||
this.context.msBackingStorePixelRatio ||
|
||||
this.context.oBackingStorePixelRatio ||
|
||||
this.context.backingStorePixelRatio ||
|
||||
1;
|
||||
|
||||
this.ratio = (window.devicePixelRatio || 1) / backingStore;
|
||||
|
||||
var rubberband_canvas = (this.rubberband_canvas = document.createElement(
|
||||
'canvas'
|
||||
));
|
||||
rubberband_canvas.setAttribute(
|
||||
'style',
|
||||
'box-sizing: content-box;' +
|
||||
'left: 0;' +
|
||||
'pointer-events: none;' +
|
||||
'position: absolute;' +
|
||||
'top: 0;' +
|
||||
'z-index: 1;'
|
||||
);
|
||||
|
||||
// Apply a ponyfill if ResizeObserver is not implemented by browser.
|
||||
if (this.ResizeObserver === undefined) {
|
||||
if (window.ResizeObserver !== undefined) {
|
||||
this.ResizeObserver = window.ResizeObserver;
|
||||
} else {
|
||||
var obs = _JSXTOOLS_RESIZE_OBSERVER({});
|
||||
this.ResizeObserver = obs.ResizeObserver;
|
||||
}
|
||||
}
|
||||
|
||||
this.resizeObserverInstance = new this.ResizeObserver(function (entries) {
|
||||
// There's no need to resize if the WebSocket is not connected:
|
||||
// - If it is still connecting, then we will get an initial resize from
|
||||
// Python once it connects.
|
||||
// - If it has disconnected, then resizing will clear the canvas and
|
||||
// never get anything back to refill it, so better to not resize and
|
||||
// keep something visible.
|
||||
if (fig.ws.readyState != 1) {
|
||||
return;
|
||||
}
|
||||
var nentries = entries.length;
|
||||
for (var i = 0; i < nentries; i++) {
|
||||
var entry = entries[i];
|
||||
var width, height;
|
||||
if (entry.contentBoxSize) {
|
||||
if (entry.contentBoxSize instanceof Array) {
|
||||
// Chrome 84 implements new version of spec.
|
||||
width = entry.contentBoxSize[0].inlineSize;
|
||||
height = entry.contentBoxSize[0].blockSize;
|
||||
} else {
|
||||
// Firefox implements old version of spec.
|
||||
width = entry.contentBoxSize.inlineSize;
|
||||
height = entry.contentBoxSize.blockSize;
|
||||
}
|
||||
} else {
|
||||
// Chrome <84 implements even older version of spec.
|
||||
width = entry.contentRect.width;
|
||||
height = entry.contentRect.height;
|
||||
}
|
||||
|
||||
// Keep the size of the canvas and rubber band canvas in sync with
|
||||
// the canvas container.
|
||||
if (entry.devicePixelContentBoxSize) {
|
||||
// Chrome 84 implements new version of spec.
|
||||
canvas.setAttribute(
|
||||
'width',
|
||||
entry.devicePixelContentBoxSize[0].inlineSize
|
||||
);
|
||||
canvas.setAttribute(
|
||||
'height',
|
||||
entry.devicePixelContentBoxSize[0].blockSize
|
||||
);
|
||||
} else {
|
||||
canvas.setAttribute('width', width * fig.ratio);
|
||||
canvas.setAttribute('height', height * fig.ratio);
|
||||
}
|
||||
/* This rescales the canvas back to display pixels, so that it
|
||||
* appears correct on HiDPI screens. */
|
||||
canvas.style.width = width + 'px';
|
||||
canvas.style.height = height + 'px';
|
||||
|
||||
rubberband_canvas.setAttribute('width', width);
|
||||
rubberband_canvas.setAttribute('height', height);
|
||||
|
||||
// And update the size in Python. We ignore the initial 0/0 size
|
||||
// that occurs as the element is placed into the DOM, which should
|
||||
// otherwise not happen due to the minimum size styling.
|
||||
if (width != 0 && height != 0) {
|
||||
fig.request_resize(width, height);
|
||||
}
|
||||
}
|
||||
});
|
||||
this.resizeObserverInstance.observe(canvas_div);
|
||||
|
||||
function on_mouse_event_closure(name) {
|
||||
/* User Agent sniffing is bad, but WebKit is busted:
|
||||
* https://bugs.webkit.org/show_bug.cgi?id=144526
|
||||
* https://bugs.webkit.org/show_bug.cgi?id=181818
|
||||
* The worst that happens here is that they get an extra browser
|
||||
* selection when dragging, if this check fails to catch them.
|
||||
*/
|
||||
var UA = navigator.userAgent;
|
||||
var isWebKit = /AppleWebKit/.test(UA) && !/Chrome/.test(UA);
|
||||
if(isWebKit) {
|
||||
return function (event) {
|
||||
/* This prevents the web browser from automatically changing to
|
||||
* the text insertion cursor when the button is pressed. We
|
||||
* want to control all of the cursor setting manually through
|
||||
* the 'cursor' event from matplotlib */
|
||||
event.preventDefault()
|
||||
return fig.mouse_event(event, name);
|
||||
};
|
||||
} else {
|
||||
return function (event) {
|
||||
return fig.mouse_event(event, name);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
canvas_div.addEventListener(
|
||||
'mousedown',
|
||||
on_mouse_event_closure('button_press')
|
||||
);
|
||||
canvas_div.addEventListener(
|
||||
'mouseup',
|
||||
on_mouse_event_closure('button_release')
|
||||
);
|
||||
canvas_div.addEventListener(
|
||||
'dblclick',
|
||||
on_mouse_event_closure('dblclick')
|
||||
);
|
||||
// Throttle sequential mouse events to 1 every 20ms.
|
||||
canvas_div.addEventListener(
|
||||
'mousemove',
|
||||
on_mouse_event_closure('motion_notify')
|
||||
);
|
||||
|
||||
canvas_div.addEventListener(
|
||||
'mouseenter',
|
||||
on_mouse_event_closure('figure_enter')
|
||||
);
|
||||
canvas_div.addEventListener(
|
||||
'mouseleave',
|
||||
on_mouse_event_closure('figure_leave')
|
||||
);
|
||||
|
||||
canvas_div.addEventListener('wheel', function (event) {
|
||||
if (event.deltaY < 0) {
|
||||
event.step = 1;
|
||||
} else {
|
||||
event.step = -1;
|
||||
}
|
||||
on_mouse_event_closure('scroll')(event);
|
||||
});
|
||||
|
||||
canvas_div.appendChild(canvas);
|
||||
canvas_div.appendChild(rubberband_canvas);
|
||||
|
||||
this.rubberband_context = rubberband_canvas.getContext('2d');
|
||||
this.rubberband_context.strokeStyle = '#000000';
|
||||
|
||||
this._resize_canvas = function (width, height, forward) {
|
||||
if (forward) {
|
||||
canvas_div.style.width = width + 'px';
|
||||
canvas_div.style.height = height + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
// Disable right mouse context menu.
|
||||
canvas_div.addEventListener('contextmenu', function (_e) {
|
||||
event.preventDefault();
|
||||
return false;
|
||||
});
|
||||
|
||||
function set_focus() {
|
||||
canvas.focus();
|
||||
canvas_div.focus();
|
||||
}
|
||||
|
||||
window.setTimeout(set_focus, 100);
|
||||
};
|
||||
|
||||
mpl.figure.prototype._init_toolbar = function () {
|
||||
var fig = this;
|
||||
|
||||
var toolbar = document.createElement('div');
|
||||
toolbar.classList = 'mpl-toolbar';
|
||||
this.root.appendChild(toolbar);
|
||||
|
||||
function on_click_closure(name) {
|
||||
return function (_event) {
|
||||
return fig.toolbar_button_onclick(name);
|
||||
};
|
||||
}
|
||||
|
||||
function on_mouseover_closure(tooltip) {
|
||||
return function (event) {
|
||||
if (!event.currentTarget.disabled) {
|
||||
return fig.toolbar_button_onmouseover(tooltip);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fig.buttons = {};
|
||||
var buttonGroup = document.createElement('div');
|
||||
buttonGroup.classList = 'mpl-button-group';
|
||||
for (var toolbar_ind in mpl.toolbar_items) {
|
||||
var name = mpl.toolbar_items[toolbar_ind][0];
|
||||
var tooltip = mpl.toolbar_items[toolbar_ind][1];
|
||||
var image = mpl.toolbar_items[toolbar_ind][2];
|
||||
var method_name = mpl.toolbar_items[toolbar_ind][3];
|
||||
|
||||
if (!name) {
|
||||
/* Instead of a spacer, we start a new button group. */
|
||||
if (buttonGroup.hasChildNodes()) {
|
||||
toolbar.appendChild(buttonGroup);
|
||||
}
|
||||
buttonGroup = document.createElement('div');
|
||||
buttonGroup.classList = 'mpl-button-group';
|
||||
continue;
|
||||
}
|
||||
|
||||
var button = (fig.buttons[name] = document.createElement('button'));
|
||||
button.classList = 'mpl-widget';
|
||||
button.setAttribute('role', 'button');
|
||||
button.setAttribute('aria-disabled', 'false');
|
||||
button.addEventListener('click', on_click_closure(method_name));
|
||||
button.addEventListener('mouseover', on_mouseover_closure(tooltip));
|
||||
|
||||
var icon_img = document.createElement('img');
|
||||
icon_img.src = '_images/' + image + '.png';
|
||||
icon_img.srcset = '_images/' + image + '_large.png 2x';
|
||||
icon_img.alt = tooltip;
|
||||
button.appendChild(icon_img);
|
||||
|
||||
buttonGroup.appendChild(button);
|
||||
}
|
||||
|
||||
if (buttonGroup.hasChildNodes()) {
|
||||
toolbar.appendChild(buttonGroup);
|
||||
}
|
||||
|
||||
var fmt_picker = document.createElement('select');
|
||||
fmt_picker.classList = 'mpl-widget';
|
||||
toolbar.appendChild(fmt_picker);
|
||||
this.format_dropdown = fmt_picker;
|
||||
|
||||
for (var ind in mpl.extensions) {
|
||||
var fmt = mpl.extensions[ind];
|
||||
var option = document.createElement('option');
|
||||
option.selected = fmt === mpl.default_extension;
|
||||
option.innerHTML = fmt;
|
||||
fmt_picker.appendChild(option);
|
||||
}
|
||||
|
||||
var status_bar = document.createElement('span');
|
||||
status_bar.classList = 'mpl-message';
|
||||
toolbar.appendChild(status_bar);
|
||||
this.message = status_bar;
|
||||
};
|
||||
|
||||
mpl.figure.prototype.request_resize = function (x_pixels, y_pixels) {
|
||||
// Request matplotlib to resize the figure. Matplotlib will then trigger a resize in the client,
|
||||
// which will in turn request a refresh of the image.
|
||||
this.send_message('resize', { width: x_pixels, height: y_pixels });
|
||||
};
|
||||
|
||||
mpl.figure.prototype.send_message = function (type, properties) {
|
||||
properties['type'] = type;
|
||||
properties['figure_id'] = this.id;
|
||||
this.ws.send(JSON.stringify(properties));
|
||||
};
|
||||
|
||||
mpl.figure.prototype.send_draw_message = function () {
|
||||
if (!this.waiting) {
|
||||
this.waiting = true;
|
||||
this.ws.send(JSON.stringify({ type: 'draw', figure_id: this.id }));
|
||||
}
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_save = function (fig, _msg) {
|
||||
var format_dropdown = fig.format_dropdown;
|
||||
var format = format_dropdown.options[format_dropdown.selectedIndex].value;
|
||||
fig.ondownload(fig, format);
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_resize = function (fig, msg) {
|
||||
var size = msg['size'];
|
||||
if (size[0] !== fig.canvas.width || size[1] !== fig.canvas.height) {
|
||||
fig._resize_canvas(size[0], size[1], msg['forward']);
|
||||
fig.send_message('refresh', {});
|
||||
}
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_rubberband = function (fig, msg) {
|
||||
var x0 = msg['x0'] / fig.ratio;
|
||||
var y0 = (fig.canvas.height - msg['y0']) / fig.ratio;
|
||||
var x1 = msg['x1'] / fig.ratio;
|
||||
var y1 = (fig.canvas.height - msg['y1']) / fig.ratio;
|
||||
x0 = Math.floor(x0) + 0.5;
|
||||
y0 = Math.floor(y0) + 0.5;
|
||||
x1 = Math.floor(x1) + 0.5;
|
||||
y1 = Math.floor(y1) + 0.5;
|
||||
var min_x = Math.min(x0, x1);
|
||||
var min_y = Math.min(y0, y1);
|
||||
var width = Math.abs(x1 - x0);
|
||||
var height = Math.abs(y1 - y0);
|
||||
|
||||
fig.rubberband_context.clearRect(
|
||||
0,
|
||||
0,
|
||||
fig.canvas.width / fig.ratio,
|
||||
fig.canvas.height / fig.ratio
|
||||
);
|
||||
|
||||
fig.rubberband_context.strokeRect(min_x, min_y, width, height);
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_figure_label = function (fig, msg) {
|
||||
// Updates the figure title.
|
||||
fig.header.textContent = msg['label'];
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_cursor = function (fig, msg) {
|
||||
fig.canvas_div.style.cursor = msg['cursor'];
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_message = function (fig, msg) {
|
||||
fig.message.textContent = msg['message'];
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_draw = function (fig, _msg) {
|
||||
// Request the server to send over a new figure.
|
||||
fig.send_draw_message();
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_image_mode = function (fig, msg) {
|
||||
fig.image_mode = msg['mode'];
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_history_buttons = function (fig, msg) {
|
||||
for (var key in msg) {
|
||||
if (!(key in fig.buttons)) {
|
||||
continue;
|
||||
}
|
||||
fig.buttons[key].disabled = !msg[key];
|
||||
fig.buttons[key].setAttribute('aria-disabled', !msg[key]);
|
||||
}
|
||||
};
|
||||
|
||||
mpl.figure.prototype.handle_navigate_mode = function (fig, msg) {
|
||||
if (msg['mode'] === 'PAN') {
|
||||
fig.buttons['Pan'].classList.add('active');
|
||||
fig.buttons['Zoom'].classList.remove('active');
|
||||
} else if (msg['mode'] === 'ZOOM') {
|
||||
fig.buttons['Pan'].classList.remove('active');
|
||||
fig.buttons['Zoom'].classList.add('active');
|
||||
} else {
|
||||
fig.buttons['Pan'].classList.remove('active');
|
||||
fig.buttons['Zoom'].classList.remove('active');
|
||||
}
|
||||
};
|
||||
|
||||
mpl.figure.prototype.updated_canvas_event = function () {
|
||||
// Called whenever the canvas gets updated.
|
||||
this.send_message('ack', {});
|
||||
};
|
||||
|
||||
// A function to construct a web socket function for onmessage handling.
|
||||
// Called in the figure constructor.
|
||||
mpl.figure.prototype._make_on_message_function = function (fig) {
|
||||
return function socket_on_message(evt) {
|
||||
if (evt.data instanceof Blob) {
|
||||
var img = evt.data;
|
||||
if (img.type !== 'image/png') {
|
||||
/* FIXME: We get "Resource interpreted as Image but
|
||||
* transferred with MIME type text/plain:" errors on
|
||||
* Chrome. But how to set the MIME type? It doesn't seem
|
||||
* to be part of the websocket stream */
|
||||
img.type = 'image/png';
|
||||
}
|
||||
|
||||
/* Free the memory for the previous frames */
|
||||
if (fig.imageObj.src) {
|
||||
(window.URL || window.webkitURL).revokeObjectURL(
|
||||
fig.imageObj.src
|
||||
);
|
||||
}
|
||||
|
||||
fig.imageObj.src = (window.URL || window.webkitURL).createObjectURL(
|
||||
img
|
||||
);
|
||||
fig.updated_canvas_event();
|
||||
fig.waiting = false;
|
||||
return;
|
||||
} else if (
|
||||
typeof evt.data === 'string' &&
|
||||
evt.data.slice(0, 21) === 'data:image/png;base64'
|
||||
) {
|
||||
fig.imageObj.src = evt.data;
|
||||
fig.updated_canvas_event();
|
||||
fig.waiting = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var msg = JSON.parse(evt.data);
|
||||
var msg_type = msg['type'];
|
||||
|
||||
// Call the "handle_{type}" callback, which takes
|
||||
// the figure and JSON message as its only arguments.
|
||||
try {
|
||||
var callback = fig['handle_' + msg_type];
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"No handler for the '%s' message type: ",
|
||||
msg_type,
|
||||
msg
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
try {
|
||||
// console.log("Handling '%s' message: ", msg_type, msg);
|
||||
callback(fig, msg);
|
||||
} catch (e) {
|
||||
console.log(
|
||||
"Exception inside the 'handler_%s' callback:",
|
||||
msg_type,
|
||||
e,
|
||||
e.stack,
|
||||
msg
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
function getModifiers(event) {
|
||||
var mods = [];
|
||||
if (event.ctrlKey) {
|
||||
mods.push('ctrl');
|
||||
}
|
||||
if (event.altKey) {
|
||||
mods.push('alt');
|
||||
}
|
||||
if (event.shiftKey) {
|
||||
mods.push('shift');
|
||||
}
|
||||
if (event.metaKey) {
|
||||
mods.push('meta');
|
||||
}
|
||||
return mods;
|
||||
}
|
||||
|
||||
/*
|
||||
* return a copy of an object with only non-object keys
|
||||
* we need this to avoid circular references
|
||||
* https://stackoverflow.com/a/24161582/3208463
|
||||
*/
|
||||
function simpleKeys(original) {
|
||||
return Object.keys(original).reduce(function (obj, key) {
|
||||
if (typeof original[key] !== 'object') {
|
||||
obj[key] = original[key];
|
||||
}
|
||||
return obj;
|
||||
}, {});
|
||||
}
|
||||
|
||||
mpl.figure.prototype.mouse_event = function (event, name) {
|
||||
if (name === 'button_press') {
|
||||
this.canvas.focus();
|
||||
this.canvas_div.focus();
|
||||
}
|
||||
|
||||
// from https://stackoverflow.com/q/1114465
|
||||
var boundingRect = this.canvas.getBoundingClientRect();
|
||||
var x = (event.clientX - boundingRect.left) * this.ratio;
|
||||
var y = (event.clientY - boundingRect.top) * this.ratio;
|
||||
|
||||
this.send_message(name, {
|
||||
x: x,
|
||||
y: y,
|
||||
button: event.button,
|
||||
step: event.step,
|
||||
buttons: event.buttons,
|
||||
modifiers: getModifiers(event),
|
||||
guiEvent: simpleKeys(event),
|
||||
});
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
mpl.figure.prototype._key_event_extra = function (_event, _name) {
|
||||
// Handle any extra behaviour associated with a key event
|
||||
};
|
||||
|
||||
mpl.figure.prototype.key_event = function (event, name) {
|
||||
// Prevent repeat events
|
||||
if (name === 'key_press') {
|
||||
if (event.key === this._key) {
|
||||
return;
|
||||
} else {
|
||||
this._key = event.key;
|
||||
}
|
||||
}
|
||||
if (name === 'key_release') {
|
||||
this._key = null;
|
||||
}
|
||||
|
||||
var value = '';
|
||||
if (event.ctrlKey && event.key !== 'Control') {
|
||||
value += 'ctrl+';
|
||||
}
|
||||
else if (event.altKey && event.key !== 'Alt') {
|
||||
value += 'alt+';
|
||||
}
|
||||
else if (event.shiftKey && event.key !== 'Shift') {
|
||||
value += 'shift+';
|
||||
}
|
||||
|
||||
value += 'k' + event.key;
|
||||
|
||||
this._key_event_extra(event, name);
|
||||
|
||||
this.send_message(name, { key: value, guiEvent: simpleKeys(event) });
|
||||
return false;
|
||||
};
|
||||
|
||||
mpl.figure.prototype.toolbar_button_onclick = function (name) {
|
||||
if (name === 'download') {
|
||||
this.handle_save(this, null);
|
||||
} else {
|
||||
this.send_message('toolbar_button', { name: name });
|
||||
}
|
||||
};
|
||||
|
||||
mpl.figure.prototype.toolbar_button_onmouseover = function (tooltip) {
|
||||
this.message.textContent = tooltip;
|
||||
};
|
||||
|
||||
///////////////// REMAINING CONTENT GENERATED BY embed_js.py /////////////////
|
||||
// prettier-ignore
|
||||
var _JSXTOOLS_RESIZE_OBSERVER=function(A){var t,i=new WeakMap,n=new WeakMap,a=new WeakMap,r=new WeakMap,o=new Set;function s(e){if(!(this instanceof s))throw new TypeError("Constructor requires 'new' operator");i.set(this,e)}function h(){throw new TypeError("Function is not a constructor")}function c(e,t,i,n){e=0 in arguments?Number(arguments[0]):0,t=1 in arguments?Number(arguments[1]):0,i=2 in arguments?Number(arguments[2]):0,n=3 in arguments?Number(arguments[3]):0,this.right=(this.x=this.left=e)+(this.width=i),this.bottom=(this.y=this.top=t)+(this.height=n),Object.freeze(this)}function d(){t=requestAnimationFrame(d);var s=new WeakMap,p=new Set;o.forEach((function(t){r.get(t).forEach((function(i){var r=t instanceof window.SVGElement,o=a.get(t),d=r?0:parseFloat(o.paddingTop),f=r?0:parseFloat(o.paddingRight),l=r?0:parseFloat(o.paddingBottom),u=r?0:parseFloat(o.paddingLeft),g=r?0:parseFloat(o.borderTopWidth),m=r?0:parseFloat(o.borderRightWidth),w=r?0:parseFloat(o.borderBottomWidth),b=u+f,F=d+l,v=(r?0:parseFloat(o.borderLeftWidth))+m,W=g+w,y=r?0:t.offsetHeight-W-t.clientHeight,E=r?0:t.offsetWidth-v-t.clientWidth,R=b+v,z=F+W,M=r?t.width:parseFloat(o.width)-R-E,O=r?t.height:parseFloat(o.height)-z-y;if(n.has(t)){var k=n.get(t);if(k[0]===M&&k[1]===O)return}n.set(t,[M,O]);var S=Object.create(h.prototype);S.target=t,S.contentRect=new c(u,d,M,O),s.has(i)||(s.set(i,[]),p.add(i)),s.get(i).push(S)}))})),p.forEach((function(e){i.get(e).call(e,s.get(e),e)}))}return s.prototype.observe=function(i){if(i instanceof window.Element){r.has(i)||(r.set(i,new Set),o.add(i),a.set(i,window.getComputedStyle(i)));var n=r.get(i);n.has(this)||n.add(this),cancelAnimationFrame(t),t=requestAnimationFrame(d)}},s.prototype.unobserve=function(i){if(i instanceof window.Element&&r.has(i)){var n=r.get(i);n.has(this)&&(n.delete(this),n.size||(r.delete(i),o.delete(i))),n.size||r.delete(i),o.size||cancelAnimationFrame(t)}},A.DOMRectReadOnly=c,A.ResizeObserver=s,A.ResizeObserverEntry=h,A}; // eslint-disable-line
|
||||
|
|
@ -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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue