|
import functools |
|
import logging |
|
import os |
|
from pathlib import Path |
|
|
|
import matplotlib as mpl |
|
from matplotlib import _api, backend_tools, cbook |
|
from matplotlib.backend_bases import ( |
|
ToolContainerBase, CloseEvent, KeyEvent, LocationEvent, MouseEvent, |
|
ResizeEvent) |
|
|
|
try: |
|
import gi |
|
except ImportError as err: |
|
raise ImportError("The GTK3 backends require PyGObject") from err |
|
|
|
try: |
|
|
|
|
|
gi.require_version("Gtk", "3.0") |
|
except ValueError as e: |
|
|
|
|
|
raise ImportError(e) from e |
|
|
|
from gi.repository import Gio, GLib, GObject, Gtk, Gdk |
|
from . import _backend_gtk |
|
from ._backend_gtk import ( |
|
_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) |
|
|
|
|
|
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): |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
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 |
|
|
|
def key_press_event(self, widget, event): |
|
KeyEvent("key_press_event", self, |
|
self._get_key(event), *self._mpl_coords(), |
|
guiEvent=event)._process() |
|
return True |
|
|
|
def key_release_event(self, widget, event): |
|
KeyEvent("key_release_event", self, |
|
self._get_key(event), *self._mpl_coords(), |
|
guiEvent=event)._process() |
|
return True |
|
|
|
def motion_notify_event(self, widget, event): |
|
MouseEvent("motion_notify_event", self, *self._mpl_coords(event), |
|
modifiers=self._mpl_modifiers(event.state), |
|
guiEvent=event)._process() |
|
return False |
|
|
|
def enter_notify_event(self, widget, event): |
|
gtk_mods = Gdk.Keymap.get_for_display( |
|
self.get_display()).get_modifier_state() |
|
LocationEvent("figure_enter_event", self, *self._mpl_coords(event), |
|
modifiers=self._mpl_modifiers(gtk_mods), |
|
guiEvent=event)._process() |
|
|
|
def leave_notify_event(self, widget, event): |
|
gtk_mods = Gdk.Keymap.get_for_display( |
|
self.get_display()).get_modifier_state() |
|
LocationEvent("figure_leave_event", self, *self._mpl_coords(event), |
|
modifiers=self._mpl_modifiers(gtk_mods), |
|
guiEvent=event)._process() |
|
|
|
def size_allocate(self, widget, allocation): |
|
dpival = self.figure.dpi |
|
winch = allocation.width * self.device_pixel_ratio / dpival |
|
hinch = allocation.height * self.device_pixel_ratio / dpival |
|
self.figure.set_size_inches(winch, hinch, forward=False) |
|
ResizeEvent("resize_event", self)._process() |
|
self.draw_idle() |
|
|
|
@staticmethod |
|
def _mpl_modifiers(event_state, *, exclude=None): |
|
modifiers = [ |
|
("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"), |
|
("alt", Gdk.ModifierType.MOD1_MASK, "alt"), |
|
("shift", Gdk.ModifierType.SHIFT_MASK, "shift"), |
|
("super", Gdk.ModifierType.MOD4_MASK, "super"), |
|
] |
|
return [name for name, mask, key in modifiers |
|
if exclude != key and event_state & mask] |
|
|
|
def _get_key(self, event): |
|
unikey = chr(Gdk.keyval_to_unicode(event.keyval)) |
|
key = cbook._unikey_or_keysym_to_mplkey( |
|
unikey, Gdk.keyval_name(event.keyval)) |
|
mods = self._mpl_modifiers(event.state, exclude=key) |
|
if "shift" in mods and unikey.isprintable(): |
|
mods.remove("shift") |
|
return "+".join([*mods, key]) |
|
|
|
def _update_device_pixel_ratio(self, *args, **kwargs): |
|
|
|
|
|
if self._set_device_pixel_ratio(self.get_scale_factor()): |
|
|
|
|
|
|
|
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 |
|
|
|
dpi = self.figure.dpi |
|
self.figure.set_size_inches(w / dpi, h / dpi, forward=False) |
|
return False |
|
|
|
def _draw_rubberband(self, rect): |
|
self._rubberband_rect = rect |
|
|
|
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 |
|
|
|
|
|
|
|
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): |
|
|
|
pass |
|
|
|
def draw(self): |
|
|
|
if self.is_drawable(): |
|
self.queue_draw() |
|
|
|
def draw_idle(self): |
|
|
|
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): |
|
|
|
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) |
|
|
|
button._signal_handler = button.connect( |
|
'clicked', getattr(self, callback)) |
|
button.set_tooltip_text(tooltip_text) |
|
self.insert(button, -1) |
|
|
|
|
|
|
|
|
|
|
|
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) |
|
toolitem.add(label) |
|
|
|
toolitem = Gtk.ToolItem() |
|
self.insert(toolitem, -1) |
|
self.message = Gtk.Label() |
|
self.message.set_justify(Gtk.Justification.RIGHT) |
|
toolitem.add(self.message) |
|
|
|
self.show_all() |
|
|
|
_NavigationToolbar2GTK.__init__(self, canvas) |
|
|
|
def save_figure(self, *args): |
|
dialog = Gtk.FileChooserDialog( |
|
title="Save the figure", |
|
parent=self.canvas.get_toplevel(), |
|
action=Gtk.FileChooserAction.SAVE, |
|
buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, |
|
Gtk.STOCK_SAVE, Gtk.ResponseType.OK), |
|
) |
|
for name, fmts \ |
|
in self.canvas.get_supported_filetypes_grouped().items(): |
|
ff = Gtk.FileFilter() |
|
ff.set_name(name) |
|
for fmt in fmts: |
|
ff.add_pattern(f'*.{fmt}') |
|
dialog.add_filter(ff) |
|
if self.canvas.get_default_filetype() in fmts: |
|
dialog.set_filter(ff) |
|
|
|
@functools.partial(dialog.connect, "notify::filter") |
|
def on_notify_filter(*args): |
|
name = dialog.get_filter().get_name() |
|
fmt = self.canvas.get_supported_filetypes_grouped()[name][0] |
|
dialog.set_current_name( |
|
str(Path(dialog.get_current_name()).with_suffix(f'.{fmt}'))) |
|
|
|
dialog.set_current_folder(mpl.rcParams["savefig.directory"]) |
|
dialog.set_current_name(self.canvas.get_default_filename()) |
|
dialog.set_do_overwrite_confirmation(True) |
|
|
|
response = dialog.run() |
|
fname = dialog.get_filename() |
|
ff = dialog.get_filter() |
|
fmt = self.canvas.get_supported_filetypes_grouped()[ff.get_name()][0] |
|
dialog.destroy() |
|
if response != Gtk.ResponseType.OK: |
|
return |
|
|
|
if mpl.rcParams['savefig.directory']: |
|
mpl.rcParams['savefig.directory'] = os.path.dirname(fname) |
|
try: |
|
self.canvas.figure.savefig(fname, format=fmt) |
|
except Exception as e: |
|
dialog = Gtk.MessageDialog( |
|
parent=self.canvas.get_toplevel(), message_format=str(e), |
|
type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK) |
|
dialog.run() |
|
dialog.destroy() |
|
|
|
|
|
class ToolbarGTK3(ToolContainerBase, Gtk.Box): |
|
_icon_extension = '-symbolic.svg' |
|
|
|
def __init__(self, toolmanager): |
|
ToolContainerBase.__init__(self, toolmanager) |
|
Gtk.Box.__init__(self) |
|
self.set_property('orientation', Gtk.Orientation.HORIZONTAL) |
|
self._message = Gtk.Label() |
|
self._message.set_justify(Gtk.Justification.RIGHT) |
|
self.pack_end(self._message, False, False, 0) |
|
self.show_all() |
|
self._groups = {} |
|
self._toolitems = {} |
|
|
|
def add_toolitem(self, name, group, position, image_file, description, |
|
toggle): |
|
if toggle: |
|
button = Gtk.ToggleToolButton() |
|
else: |
|
button = Gtk.ToolButton() |
|
button.set_label(name) |
|
|
|
if image_file is not None: |
|
image = Gtk.Image.new_from_gicon( |
|
Gio.Icon.new_for_string(image_file), |
|
Gtk.IconSize.LARGE_TOOLBAR) |
|
button.set_icon_widget(image) |
|
|
|
if position is None: |
|
position = -1 |
|
|
|
self._add_button(button, group, position) |
|
signal = button.connect('clicked', self._call_tool, name) |
|
button.set_tooltip_text(description) |
|
button.show_all() |
|
self._toolitems.setdefault(name, []) |
|
self._toolitems[name].append((button, signal)) |
|
|
|
def _add_button(self, button, group, position): |
|
if group not in self._groups: |
|
if self._groups: |
|
self._add_separator() |
|
toolbar = Gtk.Toolbar() |
|
toolbar.set_style(Gtk.ToolbarStyle.ICONS) |
|
self.pack_start(toolbar, False, False, 0) |
|
toolbar.show_all() |
|
self._groups[group] = toolbar |
|
self._groups[group].insert(button, position) |
|
|
|
def _call_tool(self, btn, name): |
|
self.trigger_tool(name) |
|
|
|
def toggle_toolitem(self, name, toggled): |
|
if name not in self._toolitems: |
|
return |
|
for toolitem, signal in self._toolitems[name]: |
|
toolitem.handler_block(signal) |
|
toolitem.set_active(toggled) |
|
toolitem.handler_unblock(signal) |
|
|
|
def remove_toolitem(self, name): |
|
if name not in self._toolitems: |
|
self.toolmanager.message_event(f'{name} not in toolbar', self) |
|
return |
|
|
|
for group in self._groups: |
|
for toolitem, _signal in self._toolitems[name]: |
|
if toolitem in self._groups[group]: |
|
self._groups[group].remove(toolitem) |
|
del self._toolitems[name] |
|
|
|
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 |
|
|
|
|
|
|
|
|
|
|
|
group = Gtk.ShortcutsGroup() |
|
section.add(group) |
|
|
|
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() |
|
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 |
|
|