import functools
import io
import os
import matplotlib as mpl
from matplotlib import _api, backend_tools, cbook
from matplotlib.backend_bases import (
ToolContainerBase, KeyEvent, LocationEvent, MouseEvent, ResizeEvent,
CloseEvent)
try:
import gi
except ImportError as err:
raise ImportError("The GTK4 backends require PyGObject") from err
try:
# :raises ValueError: If module/version is already loaded, already
# required, or unavailable.
gi.require_version("Gtk", "4.0")
except ValueError as e:
# in this case we want to re-raise as ImportError so the
# auto-backend selection logic correctly skips.
raise ImportError(e) from e
from gi.repository import Gio, GLib, Gtk, Gdk, GdkPixbuf
from . import _backend_gtk
from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611
_BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK,
TimerGTK as TimerGTK4,
)
class FigureCanvasGTK4(_FigureCanvasGTK, Gtk.DrawingArea):
required_interactive_framework = "gtk4"
supports_blit = False
manager_class = _api.classproperty(lambda cls: FigureManagerGTK4)
_context_is_scaled = False
def __init__(self, figure=None):
super().__init__(figure=figure)
self.set_hexpand(True)
self.set_vexpand(True)
self._idle_draw_id = 0
self._rubberband_rect = None
self.set_draw_func(self._draw_func)
self.connect('resize', self.resize_event)
self.connect('notify::scale-factor', self._update_device_pixel_ratio)
click = Gtk.GestureClick()
click.set_button(0) # All buttons.
click.connect('pressed', self.button_press_event)
click.connect('released', self.button_release_event)
self.add_controller(click)
key = Gtk.EventControllerKey()
key.connect('key-pressed', self.key_press_event)
key.connect('key-released', self.key_release_event)
self.add_controller(key)
motion = Gtk.EventControllerMotion()
motion.connect('motion', self.motion_notify_event)
motion.connect('enter', self.enter_notify_event)
motion.connect('leave', self.leave_notify_event)
self.add_controller(motion)
scroll = Gtk.EventControllerScroll.new(
Gtk.EventControllerScrollFlags.VERTICAL)
scroll.connect('scroll', self.scroll_event)
self.add_controller(scroll)
self.set_focusable(True)
css = Gtk.CssProvider()
style = '.matplotlib-canvas { background-color: white; }'
if Gtk.check_version(4, 9, 3) is None:
css.load_from_data(style, -1)
else:
css.load_from_data(style.encode('utf-8'))
style_ctx = self.get_style_context()
style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
style_ctx.add_class("matplotlib-canvas")
def destroy(self):
CloseEvent("close_event", self)._process()
def set_cursor(self, cursor):
# docstring inherited
self.set_cursor_from_name(_backend_gtk.mpl_to_gtk_cursor_name(cursor))
def _mpl_coords(self, xy=None):
"""
Convert the *xy* position of a GTK event, or of the current cursor
position if *xy* is None, to Matplotlib coordinates.
GTK use logical pixels, but the figure is scaled to physical pixels for
rendering. Transform to physical pixels so that all of the down-stream
transforms work as expected.
Also, the origin is different and needs to be corrected.
"""
if xy is None:
surface = self.get_native().get_surface()
is_over, x, y, mask = surface.get_device_position(
self.get_display().get_default_seat().get_pointer())
else:
x, y = xy
x = x * self.device_pixel_ratio
# flip y so y=0 is bottom of canvas
y = self.figure.bbox.height - y * self.device_pixel_ratio
return x, y
def scroll_event(self, controller, dx, dy):
MouseEvent(
"scroll_event", self, *self._mpl_coords(), step=dy,
modifiers=self._mpl_modifiers(controller),
)._process()
return True
def button_press_event(self, controller, n_press, x, y):
MouseEvent(
"button_press_event", self, *self._mpl_coords((x, y)),
controller.get_current_button(),
modifiers=self._mpl_modifiers(controller),
)._process()
self.grab_focus()
def button_release_event(self, controller, n_press, x, y):
MouseEvent(
"button_release_event", self, *self._mpl_coords((x, y)),
controller.get_current_button(),
modifiers=self._mpl_modifiers(controller),
)._process()
def key_press_event(self, controller, keyval, keycode, state):
KeyEvent(
"key_press_event", self, self._get_key(keyval, keycode, state),
*self._mpl_coords(),
)._process()
return True
def key_release_event(self, controller, keyval, keycode, state):
KeyEvent(
"key_release_event", self, self._get_key(keyval, keycode, state),
*self._mpl_coords(),
)._process()
return True
def motion_notify_event(self, controller, x, y):
MouseEvent(
"motion_notify_event", self, *self._mpl_coords((x, y)),
modifiers=self._mpl_modifiers(controller),
)._process()
def enter_notify_event(self, controller, x, y):
LocationEvent(
"figure_enter_event", self, *self._mpl_coords((x, y)),
modifiers=self._mpl_modifiers(),
)._process()
def leave_notify_event(self, controller):
LocationEvent(
"figure_leave_event", self, *self._mpl_coords(),
modifiers=self._mpl_modifiers(),
)._process()
def resize_event(self, area, width, height):
self._update_device_pixel_ratio()
dpi = self.figure.dpi
winch = width * self.device_pixel_ratio / dpi
hinch = height * self.device_pixel_ratio / dpi
self.figure.set_size_inches(winch, hinch, forward=False)
ResizeEvent("resize_event", self)._process()
self.draw_idle()
def _mpl_modifiers(self, controller=None):
if controller is None:
surface = self.get_native().get_surface()
is_over, x, y, event_state = surface.get_device_position(
self.get_display().get_default_seat().get_pointer())
else:
event_state = controller.get_current_event_state()
mod_table = [
("ctrl", Gdk.ModifierType.CONTROL_MASK),
("alt", Gdk.ModifierType.ALT_MASK),
("shift", Gdk.ModifierType.SHIFT_MASK),
("super", Gdk.ModifierType.SUPER_MASK),
]
return [name for name, mask in mod_table if event_state & mask]
def _get_key(self, keyval, keycode, state):
unikey = chr(Gdk.keyval_to_unicode(keyval))
key = cbook._unikey_or_keysym_to_mplkey(
unikey,
Gdk.keyval_name(keyval))
modifiers = [
("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"),
("alt", Gdk.ModifierType.ALT_MASK, "alt"),
("shift", Gdk.ModifierType.SHIFT_MASK, "shift"),
("super", Gdk.ModifierType.SUPER_MASK, "super"),
]
mods = [
mod for mod, mask, mod_key in modifiers
if (mod_key != key and state & mask
and not (mod == "shift" and unikey.isprintable()))]
return "+".join([*mods, key])
def _update_device_pixel_ratio(self, *args, **kwargs):
# We need to be careful in cases with mixed resolution displays if
# device_pixel_ratio changes.
if self._set_device_pixel_ratio(self.get_scale_factor()):
self.draw()
def _draw_rubberband(self, rect):
self._rubberband_rect = rect
# TODO: Only update the rubberband area.
self.queue_draw()
def _draw_func(self, drawing_area, ctx, width, height):
self.on_draw_event(self, ctx)
self._post_draw(self, ctx)
def _post_draw(self, widget, ctx):
if self._rubberband_rect is None:
return
lw = 1
dash = 3
if not self._context_is_scaled:
x0, y0, w, h = (dim / self.device_pixel_ratio
for dim in self._rubberband_rect)
else:
x0, y0, w, h = self._rubberband_rect
lw *= self.device_pixel_ratio
dash *= self.device_pixel_ratio
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(
'\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}')
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[default_format])
dialog.set_current_folder(Gio.File.new_for_path(
os.path.expanduser(mpl.rcParams['savefig.directory'])))
dialog.set_current_name(self.canvas.get_default_filename())
@functools.partial(dialog.connect, 'response')
def on_response(dialog, response):
file = dialog.get_file()
fmt = dialog.get_choice('format')
fmt = self.canvas.get_supported_filetypes_grouped()[fmt][0]
dialog.destroy()
self._save_dialog = None
if response != Gtk.ResponseType.ACCEPT:
return
# Save dir for next time, unless empty str (which means use cwd).
if mpl.rcParams['savefig.directory']:
parent = file.get_parent()
mpl.rcParams['savefig.directory'] = parent.get_path()
try:
self.canvas.figure.savefig(file.get_path(), format=fmt)
except Exception as e:
msg = Gtk.MessageDialog(
transient_for=self.canvas.get_root(),
message_type=Gtk.MessageType.ERROR,
buttons=Gtk.ButtonsType.OK, modal=True,
text=str(e))
msg.show()
dialog.show()
class ToolbarGTK4(ToolContainerBase, Gtk.Box):
_icon_extension = '-symbolic.svg'
def __init__(self, toolmanager):
ToolContainerBase.__init__(self, toolmanager)
Gtk.Box.__init__(self)
self.set_property('orientation', Gtk.Orientation.HORIZONTAL)
# Tool items are created later, but must appear before the message.
self._tool_box = Gtk.Box()
self.append(self._tool_box)
self._groups = {}
self._toolitems = {}
# This filler item ensures the toolbar is always at least two text
# lines high. Otherwise the canvas gets redrawn as the mouse hovers
# over images because those use two-line messages which resize the
# toolbar.
label = Gtk.Label()
label.set_markup(
'\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}')
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):
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._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 += ['']
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