123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606 |
- 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(
- '<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
- 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(
- '<small>\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}</small>')
- 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 += ['<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 `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
|