123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587 |
- 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(
- '<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",
- 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 += ['<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
|