Spaces:
Running
Running
| 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: | |
| from gi import require_version as gi_require_version | |
| 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__) | |
| 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() | |
| 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] | |
| 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) | |
| 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) | |
| class SaveFigureGTK3(backend_tools.SaveFigureBase): | |
| def trigger(self, *args, **kwargs): | |
| NavigationToolbar2GTK3.save_figure( | |
| self._make_classic_style_pseudo_toolbar()) | |
| 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() | |
| 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 | |
| class _BackendGTK3(_BackendGTK): | |
| FigureCanvas = FigureCanvasGTK3 | |
| FigureManager = FigureManagerGTK3 | |