import functools import logging import os from pathlib import Path import matplotlib as mpl from matplotlib import _api, backend_tools, cbook from matplotlib.backend_bases import ( ToolContainerBase, CloseEvent, KeyEvent, LocationEvent, MouseEvent, ResizeEvent) try: import gi except ImportError as err: raise ImportError("The GTK3 backends require PyGObject") from err try: # :raises ValueError: If module/version is already loaded, already # required, or unavailable. gi.require_version("Gtk", "3.0") except ValueError as e: # in this case we want to re-raise as ImportError so the # auto-backend selection logic correctly skips. raise ImportError(e) from e from gi.repository import Gio, GLib, GObject, Gtk, Gdk from . import _backend_gtk from ._backend_gtk import ( # noqa: F401 # pylint: disable=W0611 _BackendGTK, _FigureCanvasGTK, _FigureManagerGTK, _NavigationToolbar2GTK, TimerGTK as TimerGTK3, ) _log = logging.getLogger(__name__) @functools.cache def _mpl_to_gtk_cursor(mpl_cursor): return Gdk.Cursor.new_from_name( Gdk.Display.get_default(), _backend_gtk.mpl_to_gtk_cursor_name(mpl_cursor)) class FigureCanvasGTK3(_FigureCanvasGTK, Gtk.DrawingArea): required_interactive_framework = "gtk3" manager_class = _api.classproperty(lambda cls: FigureManagerGTK3) # Setting this as a static constant prevents # this resulting expression from leaking event_mask = (Gdk.EventMask.BUTTON_PRESS_MASK | Gdk.EventMask.BUTTON_RELEASE_MASK | Gdk.EventMask.EXPOSURE_MASK | Gdk.EventMask.KEY_PRESS_MASK | Gdk.EventMask.KEY_RELEASE_MASK | Gdk.EventMask.ENTER_NOTIFY_MASK | Gdk.EventMask.LEAVE_NOTIFY_MASK | Gdk.EventMask.POINTER_MOTION_MASK | Gdk.EventMask.SCROLL_MASK) def __init__(self, figure=None): super().__init__(figure=figure) self._idle_draw_id = 0 self._rubberband_rect = None self.connect('scroll_event', self.scroll_event) self.connect('button_press_event', self.button_press_event) self.connect('button_release_event', self.button_release_event) self.connect('configure_event', self.configure_event) self.connect('screen-changed', self._update_device_pixel_ratio) self.connect('notify::scale-factor', self._update_device_pixel_ratio) self.connect('draw', self.on_draw_event) self.connect('draw', self._post_draw) self.connect('key_press_event', self.key_press_event) self.connect('key_release_event', self.key_release_event) self.connect('motion_notify_event', self.motion_notify_event) self.connect('enter_notify_event', self.enter_notify_event) self.connect('leave_notify_event', self.leave_notify_event) self.connect('size_allocate', self.size_allocate) self.set_events(self.__class__.event_mask) self.set_can_focus(True) css = Gtk.CssProvider() css.load_from_data(b".matplotlib-canvas { background-color: white; }") style_ctx = self.get_style_context() style_ctx.add_provider(css, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) style_ctx.add_class("matplotlib-canvas") def destroy(self): CloseEvent("close_event", self)._process() def set_cursor(self, cursor): # docstring inherited window = self.get_property("window") if window is not None: window.set_cursor(_mpl_to_gtk_cursor(cursor)) context = GLib.MainContext.default() context.iteration(True) def _mpl_coords(self, event=None): """ Convert the position of a GTK event, or of the current cursor position if *event* is None, to Matplotlib coordinates. GTK use logical pixels, but the figure is scaled to physical pixels for rendering. Transform to physical pixels so that all of the down-stream transforms work as expected. Also, the origin is different and needs to be corrected. """ if event is None: window = self.get_window() t, x, y, state = window.get_device_position( window.get_display().get_device_manager().get_client_pointer()) else: x, y = event.x, event.y x = x * self.device_pixel_ratio # flip y so y=0 is bottom of canvas y = self.figure.bbox.height - y * self.device_pixel_ratio return x, y def scroll_event(self, widget, event): step = 1 if event.direction == Gdk.ScrollDirection.UP else -1 MouseEvent("scroll_event", self, *self._mpl_coords(event), step=step, modifiers=self._mpl_modifiers(event.state), guiEvent=event)._process() return False # finish event propagation? def button_press_event(self, widget, event): MouseEvent("button_press_event", self, *self._mpl_coords(event), event.button, modifiers=self._mpl_modifiers(event.state), guiEvent=event)._process() return False # finish event propagation? def button_release_event(self, widget, event): MouseEvent("button_release_event", self, *self._mpl_coords(event), event.button, modifiers=self._mpl_modifiers(event.state), guiEvent=event)._process() return False # finish event propagation? def key_press_event(self, widget, event): KeyEvent("key_press_event", self, self._get_key(event), *self._mpl_coords(), guiEvent=event)._process() return True # stop event propagation def key_release_event(self, widget, event): KeyEvent("key_release_event", self, self._get_key(event), *self._mpl_coords(), guiEvent=event)._process() return True # stop event propagation def motion_notify_event(self, widget, event): MouseEvent("motion_notify_event", self, *self._mpl_coords(event), modifiers=self._mpl_modifiers(event.state), guiEvent=event)._process() return False # finish event propagation? def enter_notify_event(self, widget, event): gtk_mods = Gdk.Keymap.get_for_display( self.get_display()).get_modifier_state() LocationEvent("figure_enter_event", self, *self._mpl_coords(event), modifiers=self._mpl_modifiers(gtk_mods), guiEvent=event)._process() def leave_notify_event(self, widget, event): gtk_mods = Gdk.Keymap.get_for_display( self.get_display()).get_modifier_state() LocationEvent("figure_leave_event", self, *self._mpl_coords(event), modifiers=self._mpl_modifiers(gtk_mods), guiEvent=event)._process() def size_allocate(self, widget, allocation): dpival = self.figure.dpi winch = allocation.width * self.device_pixel_ratio / dpival hinch = allocation.height * self.device_pixel_ratio / dpival self.figure.set_size_inches(winch, hinch, forward=False) ResizeEvent("resize_event", self)._process() self.draw_idle() @staticmethod def _mpl_modifiers(event_state, *, exclude=None): modifiers = [ ("ctrl", Gdk.ModifierType.CONTROL_MASK, "control"), ("alt", Gdk.ModifierType.MOD1_MASK, "alt"), ("shift", Gdk.ModifierType.SHIFT_MASK, "shift"), ("super", Gdk.ModifierType.MOD4_MASK, "super"), ] return [name for name, mask, key in modifiers if exclude != key and event_state & mask] def _get_key(self, event): unikey = chr(Gdk.keyval_to_unicode(event.keyval)) key = cbook._unikey_or_keysym_to_mplkey( unikey, Gdk.keyval_name(event.keyval)) mods = self._mpl_modifiers(event.state, exclude=key) if "shift" in mods and unikey.isprintable(): mods.remove("shift") return "+".join([*mods, key]) def _update_device_pixel_ratio(self, *args, **kwargs): # We need to be careful in cases with mixed resolution displays if # device_pixel_ratio changes. if self._set_device_pixel_ratio(self.get_scale_factor()): # The easiest way to resize the canvas is to emit a resize event # since we implement all the logic for resizing the canvas for that # event. self.queue_resize() self.queue_draw() def configure_event(self, widget, event): if widget.get_property("window") is None: return w = event.width * self.device_pixel_ratio h = event.height * self.device_pixel_ratio if w < 3 or h < 3: return # empty fig # resize the figure (in inches) dpi = self.figure.dpi self.figure.set_size_inches(w / dpi, h / dpi, forward=False) return False # finish event propagation? def _draw_rubberband(self, rect): self._rubberband_rect = rect # TODO: Only update the rubberband area. self.queue_draw() def _post_draw(self, widget, ctx): if self._rubberband_rect is None: return x0, y0, w, h = (dim / self.device_pixel_ratio for dim in self._rubberband_rect) x1 = x0 + w y1 = y0 + h # Draw the lines from x0, y0 towards x1, y1 so that the # dashes don't "jump" when moving the zoom box. ctx.move_to(x0, y0) ctx.line_to(x0, y1) ctx.move_to(x0, y0) ctx.line_to(x1, y0) ctx.move_to(x0, y1) ctx.line_to(x1, y1) ctx.move_to(x1, y0) ctx.line_to(x1, y1) ctx.set_antialias(1) ctx.set_line_width(1) ctx.set_dash((3, 3), 0) ctx.set_source_rgb(0, 0, 0) ctx.stroke_preserve() ctx.set_dash((3, 3), 3) ctx.set_source_rgb(1, 1, 1) ctx.stroke() def on_draw_event(self, widget, ctx): # to be overwritten by GTK3Agg or GTK3Cairo pass def draw(self): # docstring inherited if self.is_drawable(): self.queue_draw() def draw_idle(self): # docstring inherited if self._idle_draw_id != 0: return def idle_draw(*args): try: self.draw() finally: self._idle_draw_id = 0 return False self._idle_draw_id = GLib.idle_add(idle_draw) def flush_events(self): # docstring inherited context = GLib.MainContext.default() while context.pending(): context.iteration(True) class NavigationToolbar2GTK3(_NavigationToolbar2GTK, Gtk.Toolbar): def __init__(self, canvas): GObject.GObject.__init__(self) self.set_style(Gtk.ToolbarStyle.ICONS) self._gtk_ids = {} for text, tooltip_text, image_file, callback in self.toolitems: if text is None: self.insert(Gtk.SeparatorToolItem(), -1) continue image = Gtk.Image.new_from_gicon( Gio.Icon.new_for_string( str(cbook._get_data_path('images', f'{image_file}-symbolic.svg'))), Gtk.IconSize.LARGE_TOOLBAR) self._gtk_ids[text] = button = ( Gtk.ToggleToolButton() if callback in ['zoom', 'pan'] else Gtk.ToolButton()) button.set_label(text) button.set_icon_widget(image) # Save the handler id, so that we can block it as needed. button._signal_handler = button.connect( 'clicked', getattr(self, callback)) button.set_tooltip_text(tooltip_text) self.insert(button, -1) # This filler item ensures the toolbar is always at least two text # lines high. Otherwise the canvas gets redrawn as the mouse hovers # over images because those use two-line messages which resize the # toolbar. toolitem = Gtk.ToolItem() self.insert(toolitem, -1) label = Gtk.Label() label.set_markup( '\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}') toolitem.set_expand(True) # Push real message to the right. toolitem.add(label) toolitem = Gtk.ToolItem() self.insert(toolitem, -1) self.message = Gtk.Label() self.message.set_justify(Gtk.Justification.RIGHT) toolitem.add(self.message) self.show_all() _NavigationToolbar2GTK.__init__(self, canvas) def save_figure(self, *args): dialog = Gtk.FileChooserDialog( title="Save the figure", parent=self.canvas.get_toplevel(), action=Gtk.FileChooserAction.SAVE, buttons=(Gtk.STOCK_CANCEL, Gtk.ResponseType.CANCEL, Gtk.STOCK_SAVE, Gtk.ResponseType.OK), ) for name, fmts \ in self.canvas.get_supported_filetypes_grouped().items(): ff = Gtk.FileFilter() ff.set_name(name) for fmt in fmts: ff.add_pattern(f'*.{fmt}') dialog.add_filter(ff) if self.canvas.get_default_filetype() in fmts: dialog.set_filter(ff) @functools.partial(dialog.connect, "notify::filter") def on_notify_filter(*args): name = dialog.get_filter().get_name() fmt = self.canvas.get_supported_filetypes_grouped()[name][0] dialog.set_current_name( str(Path(dialog.get_current_name()).with_suffix(f'.{fmt}'))) dialog.set_current_folder(mpl.rcParams["savefig.directory"]) dialog.set_current_name(self.canvas.get_default_filename()) dialog.set_do_overwrite_confirmation(True) response = dialog.run() fname = dialog.get_filename() ff = dialog.get_filter() # Doesn't autoadjust to filename :/ fmt = self.canvas.get_supported_filetypes_grouped()[ff.get_name()][0] dialog.destroy() if response != Gtk.ResponseType.OK: return # Save dir for next time, unless empty str (which means use cwd). if mpl.rcParams['savefig.directory']: mpl.rcParams['savefig.directory'] = os.path.dirname(fname) try: self.canvas.figure.savefig(fname, format=fmt) except Exception as e: dialog = Gtk.MessageDialog( parent=self.canvas.get_toplevel(), message_format=str(e), type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.OK) dialog.run() dialog.destroy() class ToolbarGTK3(ToolContainerBase, Gtk.Box): _icon_extension = '-symbolic.svg' def __init__(self, toolmanager): ToolContainerBase.__init__(self, toolmanager) Gtk.Box.__init__(self) self.set_property('orientation', Gtk.Orientation.HORIZONTAL) self._message = Gtk.Label() self._message.set_justify(Gtk.Justification.RIGHT) self.pack_end(self._message, False, False, 0) self.show_all() self._groups = {} self._toolitems = {} def add_toolitem(self, name, group, position, image_file, description, toggle): if toggle: button = Gtk.ToggleToolButton() else: button = Gtk.ToolButton() button.set_label(name) if image_file is not None: image = Gtk.Image.new_from_gicon( Gio.Icon.new_for_string(image_file), Gtk.IconSize.LARGE_TOOLBAR) button.set_icon_widget(image) if position is None: position = -1 self._add_button(button, group, position) signal = button.connect('clicked', self._call_tool, name) button.set_tooltip_text(description) button.show_all() self._toolitems.setdefault(name, []) self._toolitems[name].append((button, signal)) def _add_button(self, button, group, position): if group not in self._groups: if self._groups: self._add_separator() toolbar = Gtk.Toolbar() toolbar.set_style(Gtk.ToolbarStyle.ICONS) self.pack_start(toolbar, False, False, 0) toolbar.show_all() self._groups[group] = toolbar self._groups[group].insert(button, position) def _call_tool(self, btn, name): self.trigger_tool(name) def toggle_toolitem(self, name, toggled): if name not in self._toolitems: return for toolitem, signal in self._toolitems[name]: toolitem.handler_block(signal) toolitem.set_active(toggled) toolitem.handler_unblock(signal) def remove_toolitem(self, name): 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 += [''] 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