From 0b724bf3ea06dee2f73d487aa09406a77e117b2d Mon Sep 17 00:00:00 2001 From: Elliott Sales de Andrade Date: Sat, 31 Jul 2021 01:33:37 -0400 Subject: [PATCH] Add a GTK4 backend. --- lib/matplotlib/__init__.py | 4 +- lib/matplotlib/backend_bases.py | 5 +- lib/matplotlib/backends/backend_gtk4.py | 832 ++++++++++++++++++ lib/matplotlib/backends/backend_gtk4agg.py | 80 ++ lib/matplotlib/backends/backend_gtk4cairo.py | 35 + lib/matplotlib/cbook/__init__.py | 13 +- lib/matplotlib/mpl-data/matplotlibrc | 4 +- lib/matplotlib/pyplot.py | 4 +- lib/matplotlib/rcsetup.py | 2 +- .../tests/test_backends_interactive.py | 12 +- mplsetup.cfg.template | 4 +- 11 files changed, 978 insertions(+), 17 deletions(-) create mode 100644 lib/matplotlib/backends/backend_gtk4.py create mode 100644 lib/matplotlib/backends/backend_gtk4agg.py create mode 100644 lib/matplotlib/backends/backend_gtk4cairo.py diff --git a/lib/matplotlib/__init__.py b/lib/matplotlib/__init__.py index 0361a37aed48..ac84e82c30f6 100644 --- a/lib/matplotlib/__init__.py +++ b/lib/matplotlib/__init__.py @@ -1098,8 +1098,8 @@ def use(backend, *, force=True): backend names, which are case-insensitive: - interactive backends: - GTK3Agg, GTK3Cairo, MacOSX, nbAgg, QtAgg, QtCairo, - TkAgg, TkCairo, WebAgg, WX, WXAgg, WXCairo, Qt5Agg, Qt5Cairo + GTK3Agg, GTK3Cairo, GTK4Agg, GTK4Cairo, MacOSX, nbAgg, QtAgg, + QtCairo, TkAgg, TkCairo, WebAgg, WX, WXAgg, WXCairo, Qt5Agg, Qt5Cairo - non-interactive backends: agg, cairo, pdf, pgf, ps, svg, template diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 84f644e57dbd..fda7bd1c9613 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -101,6 +101,7 @@ def _safe_pyplot_import(): backend_mapping = { 'qt': 'qtagg', 'gtk3': 'gtk3agg', + 'gtk4': 'gtk4agg', 'wx': 'wxagg', 'tk': 'tkagg', 'macosx': 'macosx', @@ -1656,7 +1657,7 @@ class FigureCanvasBase: A high-level figure instance. """ - # Set to one of {"qt", "gtk3", "wx", "tk", "macosx"} if an + # Set to one of {"qt", "gtk3", "gtk4", "wx", "tk", "macosx"} if an # interactive framework is required, or None otherwise. required_interactive_framework = None @@ -1732,7 +1733,7 @@ def _fix_ipython_backend2gui(cls): # don't break on our side. return rif = getattr(cls, "required_interactive_framework", None) - backend2gui_rif = {"qt": "qt", "gtk3": "gtk3", + backend2gui_rif = {"qt": "qt", "gtk3": "gtk3", "gtk4": "gtk4", "wx": "wx", "macosx": "osx"}.get(rif) if backend2gui_rif: if _is_non_interactive_terminal_ipython(ip): diff --git a/lib/matplotlib/backends/backend_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py new file mode 100644 index 000000000000..f2e270da76b4 --- /dev/null +++ b/lib/matplotlib/backends/backend_gtk4.py @@ -0,0 +1,832 @@ +import functools +import io +import logging +import os +from pathlib import Path +import sys + +import matplotlib as mpl +from matplotlib import _api, backend_tools, cbook +from matplotlib._pylab_helpers import Gcf +from matplotlib.backend_bases import ( + _Backend, FigureCanvasBase, FigureManagerBase, NavigationToolbar2, + TimerBase, ToolContainerBase) +from matplotlib.backend_tools import Cursors +from matplotlib.figure import Figure +from matplotlib.widgets import SubplotTool + +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 from e + +from gi.repository import Gio, GLib, GObject, Gtk, Gdk, GdkPixbuf + + +_log = logging.getLogger(__name__) + +backend_version = "%s.%s.%s" % ( + Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version()) + +# Placeholder +_application = None + + +def _shutdown_application(app): + # The application might prematurely shut down if Ctrl-C'd out of IPython, + # so close all windows. + for win in app.get_windows(): + win.destroy() + # The PyGObject wrapper incorrectly thinks that None is not allowed, or we + # would call this: + # Gio.Application.set_default(None) + # Instead, we set this property and ignore default applications with it: + app._created_by_matplotlib = True + global _application + _application = None + + +def _create_application(): + global _application + + if _application is None: + app = Gio.Application.get_default() + if app is None or getattr(app, '_created_by_matplotlib'): + # display_is_valid returns False only if on Linux and neither X11 + # nor Wayland display can be opened. + if not mpl._c_internal_utils.display_is_valid(): + raise RuntimeError('Invalid DISPLAY variable') + _application = Gtk.Application.new('org.matplotlib.Matplotlib3', + Gio.ApplicationFlags.NON_UNIQUE) + # The activate signal must be connected, but we don't care for + # handling it, since we don't do any remote processing. + _application.connect('activate', lambda *args, **kwargs: None) + _application.connect('shutdown', _shutdown_application) + _application.register() + cbook._setup_new_guiapp() + else: + _application = app + + +def _mpl_to_gtk_cursor(mpl_cursor): + return _api.check_getitem({ + Cursors.MOVE: "move", + Cursors.HAND: "pointer", + Cursors.POINTER: "default", + Cursors.SELECT_REGION: "crosshair", + Cursors.WAIT: "wait", + Cursors.RESIZE_HORIZONTAL: "ew-resize", + Cursors.RESIZE_VERTICAL: "ns-resize", + }, cursor=mpl_cursor) + + +class TimerGTK4(TimerBase): + """Subclass of `.TimerBase` using GTK4 timer events.""" + + def __init__(self, *args, **kwargs): + self._timer = None + super().__init__(*args, **kwargs) + + def _timer_start(self): + # Need to stop it, otherwise we potentially leak a timer id that will + # never be stopped. + self._timer_stop() + self._timer = GLib.timeout_add(self._interval, self._on_timer) + + def _timer_stop(self): + if self._timer is not None: + GLib.source_remove(self._timer) + self._timer = None + + def _timer_set_interval(self): + # Only stop and restart it if the timer has already been started + if self._timer is not None: + self._timer_stop() + self._timer_start() + + def _on_timer(self): + super()._on_timer() + + # Gtk timeout_add() requires that the callback returns True if it + # is to be called again. + if self.callbacks and not self._single: + return True + else: + self._timer = None + return False + + +class FigureCanvasGTK4(Gtk.DrawingArea, FigureCanvasBase): + required_interactive_framework = "gtk4" + _timer_cls = TimerGTK4 + + def __init__(self, figure=None): + FigureCanvasBase.__init__(self, figure) + GObject.GObject.__init__(self) + self.set_hexpand(True) + self.set_vexpand(True) + + self._idle_draw_id = 0 + self._lastCursor = None + self._rubberband_rect = None + + self.set_draw_func(self._draw_func) + self.connect('resize', self.resize_event) + + 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() + 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 pick(self, mouseevent): + # GtkWidget defines pick in GTK4, so we need to override here to work + # with the base implementation we want. + FigureCanvasBase.pick(self, mouseevent) + + def destroy(self): + self.close_event() + + def set_cursor(self, cursor): + # docstring inherited + self.set_cursor_from_name(_mpl_to_gtk_cursor(cursor)) + + def scroll_event(self, controller, dx, dy): + FigureCanvasBase.scroll_event(self, 0, 0, dy) + return True + + def button_press_event(self, controller, n_press, x, y): + # flipy so y=0 is bottom of canvas + y = self.get_allocation().height - y + FigureCanvasBase.button_press_event(self, x, y, + controller.get_current_button()) + self.grab_focus() + + def button_release_event(self, controller, n_press, x, y): + # flipy so y=0 is bottom of canvas + y = self.get_allocation().height - y + FigureCanvasBase.button_release_event(self, x, y, + controller.get_current_button()) + + def key_press_event(self, controller, keyval, keycode, state): + key = self._get_key(keyval, keycode, state) + FigureCanvasBase.key_press_event(self, key) + return True + + def key_release_event(self, controller, keyval, keycode, state): + key = self._get_key(keyval, keycode, state) + FigureCanvasBase.key_release_event(self, key) + return True + + def motion_notify_event(self, controller, x, y): + # flipy so y=0 is bottom of canvas + y = self.get_allocation().height - y + FigureCanvasBase.motion_notify_event(self, x, y) + + def leave_notify_event(self, controller): + FigureCanvasBase.leave_notify_event(self) + + def enter_notify_event(self, controller, x, y): + # flipy so y=0 is bottom of canvas + y = self.get_allocation().height - y + FigureCanvasBase.enter_notify_event(self, xy=(x, y)) + + def resize_event(self, area, width, height): + dpi = self.figure.dpi + self.figure.set_size_inches(width / dpi, height / dpi, forward=False) + FigureCanvasBase.resize_event(self) + self.draw_idle() + + 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 = [ + (Gdk.ModifierType.CONTROL_MASK, 'ctrl'), + (Gdk.ModifierType.ALT_MASK, 'alt'), + (Gdk.ModifierType.SHIFT_MASK, 'shift'), + (Gdk.ModifierType.SUPER_MASK, 'super'), + ] + for key_mask, prefix in modifiers: + if state & key_mask: + if not (prefix == 'shift' and unikey.isprintable()): + key = f'{prefix}+{key}' + return key + + 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 + + x0, y0, w, h = 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 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 FigureManagerGTK4(FigureManagerBase): + """ + Attributes + ---------- + canvas : `FigureCanvas` + The FigureCanvas instance + num : int or str + The Figure number + toolbar : Gtk.Box + The Gtk.Box + vbox : Gtk.VBox + The Gtk.VBox containing the canvas and toolbar + window : Gtk.Window + The Gtk.Window + + """ + def __init__(self, canvas, num): + _create_application() + self.window = Gtk.Window() + _application.add_window(self.window) + super().__init__(canvas, num) + + try: + self.window.set_icon_from_file(window_icon) + except Exception: + # Some versions of gtk throw a glib.GError but not all, so I am not + # sure how to catch it. I am unhappy doing a blanket catch here, + # but am not sure what a better way is - JDH + _log.info('Could not load matplotlib icon: %s', sys.exc_info()[1]) + + self.vbox = Gtk.Box() + self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL) + self.window.set_child(self.vbox) + + self.vbox.prepend(self.canvas) + # calculate size for window + w = int(self.canvas.figure.bbox.width) + h = int(self.canvas.figure.bbox.height) + + self.toolbar = self._get_toolbar() + + if self.toolmanager: + backend_tools.add_tools_to_manager(self.toolmanager) + if self.toolbar: + backend_tools.add_tools_to_container(self.toolbar) + + if self.toolbar is not None: + sw = Gtk.ScrolledWindow(vscrollbar_policy=Gtk.PolicyType.NEVER) + sw.set_child(self.toolbar) + self.vbox.append(sw) + min_size, nat_size = self.toolbar.get_preferred_size() + h += nat_size.height + + self.window.set_default_size(w, h) + + self._destroying = False + self.window.connect("destroy", lambda *args: Gcf.destroy(self)) + self.window.connect("close-request", lambda *args: Gcf.destroy(self)) + if mpl.is_interactive(): + self.window.show() + self.canvas.draw_idle() + + self.canvas.grab_focus() + + def destroy(self, *args): + if self._destroying: + # Otherwise, this can be called twice when the user presses 'q', + # which calls Gcf.destroy(self), then this destroy(), then triggers + # Gcf.destroy(self) once again via + # `connect("destroy", lambda *args: Gcf.destroy(self))`. + return + self._destroying = True + self.window.destroy() + self.canvas.destroy() + + def show(self): + # show the figure window + self.window.show() + self.canvas.draw() + if mpl.rcParams['figure.raise_window']: + if self.window.get_surface(): + self.window.present() + else: + # If this is called by a callback early during init, + # self.window (a GtkWindow) may not have an associated + # low-level GdkSurface (self.window.get_surface()) yet, and + # present() would crash. + _api.warn_external("Cannot raise window yet to be setup") + + def full_screen_toggle(self): + if not self.window.is_fullscreen(): + self.window.fullscreen() + else: + self.window.unfullscreen() + + def _get_toolbar(self): + # must be inited after the window, drawingArea and figure + # attrs are set + if mpl.rcParams['toolbar'] == 'toolbar2': + toolbar = NavigationToolbar2GTK4(self.canvas, self.window) + elif mpl.rcParams['toolbar'] == 'toolmanager': + toolbar = ToolbarGTK4(self.toolmanager) + else: + toolbar = None + return toolbar + + def get_window_title(self): + return self.window.get_title() + + def set_window_title(self, title): + self.window.set_title(title) + + def resize(self, width, height): + """Set the canvas size in pixels.""" + if self.toolbar: + toolbar_size = self.toolbar.size_request() + height += toolbar_size.height + canvas_size = self.canvas.get_allocation() + if canvas_size.width == canvas_size.height == 1: + # A canvas size of (1, 1) cannot exist in most cases, because + # window decorations would prevent such a small window. This call + # must be before the window has been mapped and widgets have been + # sized, so just change the window's starting size. + self.window.set_default_size(width, height) + else: + self.window.resize(width, height) + + +class NavigationToolbar2GTK4(NavigationToolbar2, Gtk.Box): + def __init__(self, canvas, window): + self.win = window + 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.append(self.message) + + NavigationToolbar2.__init__(self, canvas) + + def set_message(self, s): + escaped = GLib.markup_escape_text(s) + self.message.set_markup(f'{escaped}') + + def draw_rubberband(self, event, x0, y0, x1, y1): + height = self.canvas.figure.bbox.height + y1 = height - y1 + y0 = height - y0 + rect = [int(val) for val in (x0, y0, x1 - x0, y1 - y0)] + self.canvas._draw_rubberband(rect) + + def remove_rubberband(self): + self.canvas._draw_rubberband(None) + + def _update_buttons_checked(self): + for name, active in [("Pan", "PAN"), ("Zoom", "ZOOM")]: + button = self._gtk_ids.get(name) + if button: + with button.handler_block(button._signal_handler): + button.set_active(self.mode.name == active) + + def pan(self, *args): + super().pan(*args) + self._update_buttons_checked() + + def zoom(self, *args): + super().zoom(*args) + self._update_buttons_checked() + + 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() + + def set_history_buttons(self): + can_backward = self._nav_stack._pos > 0 + can_forward = self._nav_stack._pos < len(self._nav_stack._elements) - 1 + if 'Back' in self._gtk_ids: + self._gtk_ids['Back'].set_sensitive(can_backward) + if 'Forward' in self._gtk_ids: + self._gtk_ids['Forward'].set_sensitive(can_forward) + + +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.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) + + +class RubberbandGTK4(backend_tools.RubberbandBase): + def draw_rubberband(self, x0, y0, x1, y1): + NavigationToolbar2GTK4.draw_rubberband( + self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1) + + def remove_rubberband(self): + NavigationToolbar2GTK4.remove_rubberband( + self._make_classic_style_pseudo_toolbar()) + + +class SaveFigureGTK4(backend_tools.SaveFigureBase): + def trigger(self, *args, **kwargs): + + class PseudoToolbar: + canvas = self.figure.canvas + + return NavigationToolbar2GTK4.save_figure(PseudoToolbar()) + + +class ConfigureSubplotsGTK4(backend_tools.ConfigureSubplotsBase, Gtk.Window): + def _get_canvas(self, fig): + return self.canvas.__class__(fig) + + def trigger(self, *args): + NavigationToolbar2GTK4.configure_subplots( + self._make_classic_style_pseudo_toolbar(), None) + + +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() + + +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) + + +# Define the file to use as the GTk icon +if sys.platform == 'win32': + icon_filename = 'matplotlib.png' +else: + icon_filename = 'matplotlib.svg' +window_icon = str(cbook._get_data_path('images', icon_filename)) + + +backend_tools.ToolSaveFigure = SaveFigureGTK4 +backend_tools.ToolConfigureSubplots = ConfigureSubplotsGTK4 +backend_tools.ToolRubberband = RubberbandGTK4 +backend_tools.ToolHelp = HelpGTK4 +backend_tools.ToolCopyToClipboard = ToolCopyToClipboardGTK4 + +Toolbar = ToolbarGTK4 + + +@_Backend.export +class _BackendGTK4(_Backend): + FigureCanvas = FigureCanvasGTK4 + FigureManager = FigureManagerGTK4 + + @staticmethod + def mainloop(): + global _application + if _application is None: + return + + try: + _application.run() # Quits when all added windows close. + finally: + # Running after quit is undefined, so create a new one next time. + _application = None diff --git a/lib/matplotlib/backends/backend_gtk4agg.py b/lib/matplotlib/backends/backend_gtk4agg.py new file mode 100644 index 000000000000..b3439dc109cd --- /dev/null +++ b/lib/matplotlib/backends/backend_gtk4agg.py @@ -0,0 +1,80 @@ +import numpy as np + +from .. import cbook +try: + from . import backend_cairo +except ImportError as e: + raise ImportError('backend Gtk4Agg requires cairo') from e +from . import backend_agg, backend_gtk4 +from .backend_cairo import cairo +from .backend_gtk4 import Gtk, _BackendGTK4 +from matplotlib import transforms + + +class FigureCanvasGTK4Agg(backend_gtk4.FigureCanvasGTK4, + backend_agg.FigureCanvasAgg): + def __init__(self, figure): + backend_gtk4.FigureCanvasGTK4.__init__(self, figure) + self._bbox_queue = [] + + def on_draw_event(self, widget, ctx): + allocation = self.get_allocation() + w, h = allocation.width, allocation.height + + if not len(self._bbox_queue): + Gtk.render_background( + self.get_style_context(), ctx, + allocation.x, allocation.y, + allocation.width, allocation.height) + bbox_queue = [transforms.Bbox([[0, 0], [w, h]])] + else: + bbox_queue = self._bbox_queue + + ctx = backend_cairo._to_context(ctx) + + for bbox in bbox_queue: + x = int(bbox.x0) + y = h - int(bbox.y1) + width = int(bbox.x1) - int(bbox.x0) + height = int(bbox.y1) - int(bbox.y0) + + buf = cbook._unmultiplied_rgba8888_to_premultiplied_argb32( + np.asarray(self.copy_from_bbox(bbox))) + image = cairo.ImageSurface.create_for_data( + buf.ravel().data, cairo.FORMAT_ARGB32, width, height) + ctx.set_source_surface(image, x, y) + ctx.paint() + + if len(self._bbox_queue): + self._bbox_queue = [] + + return False + + def blit(self, bbox=None): + # If bbox is None, blit the entire canvas to gtk. Otherwise + # blit only the area defined by the bbox. + if bbox is None: + bbox = self.figure.bbox + + allocation = self.get_allocation() + x = int(bbox.x0) + y = allocation.height - int(bbox.y1) + width = int(bbox.x1) - int(bbox.x0) + height = int(bbox.y1) - int(bbox.y0) + + self._bbox_queue.append(bbox) + self.queue_draw_area(x, y, width, height) + + def draw(self): + backend_agg.FigureCanvasAgg.draw(self) + super().draw() + + +class FigureManagerGTK4Agg(backend_gtk4.FigureManagerGTK4): + pass + + +@_BackendGTK4.export +class _BackendGTK4Agg(_BackendGTK4): + FigureCanvas = FigureCanvasGTK4Agg + FigureManager = FigureManagerGTK4Agg diff --git a/lib/matplotlib/backends/backend_gtk4cairo.py b/lib/matplotlib/backends/backend_gtk4cairo.py new file mode 100644 index 000000000000..391a1a372856 --- /dev/null +++ b/lib/matplotlib/backends/backend_gtk4cairo.py @@ -0,0 +1,35 @@ +from contextlib import nullcontext + +from . import backend_cairo, backend_gtk4 +from .backend_gtk4 import Gtk, _BackendGTK4 + + +class RendererGTK4Cairo(backend_cairo.RendererCairo): + def set_context(self, ctx): + self.gc.ctx = backend_cairo._to_context(ctx) + + +class FigureCanvasGTK4Cairo(backend_gtk4.FigureCanvasGTK4, + backend_cairo.FigureCanvasCairo): + + def __init__(self, figure): + super().__init__(figure) + self._renderer = RendererGTK4Cairo(self.figure.dpi) + + def on_draw_event(self, widget, ctx): + with (self.toolbar._wait_cursor_for_draw_cm() if self.toolbar + else nullcontext()): + self._renderer.set_context(ctx) + allocation = self.get_allocation() + Gtk.render_background( + self.get_style_context(), ctx, + allocation.x, allocation.y, + allocation.width, allocation.height) + self._renderer.set_width_height( + allocation.width, allocation.height) + self.figure.draw(self._renderer) + + +@_BackendGTK4.export +class _BackendGTK4Cairo(_BackendGTK4): + FigureCanvas = FigureCanvasGTK4Cairo diff --git a/lib/matplotlib/cbook/__init__.py b/lib/matplotlib/cbook/__init__.py index 109b9ea69cc9..6d181c43107d 100644 --- a/lib/matplotlib/cbook/__init__.py +++ b/lib/matplotlib/cbook/__init__.py @@ -50,8 +50,8 @@ def _get_running_interactive_framework(): Returns ------- Optional[str] - One of the following values: "qt", "gtk3", "wx", "tk", "macosx", - "headless", ``None``. + One of the following values: "qt", "gtk3", "gtk4", "wx", "tk", + "macosx", "headless", ``None``. """ # Use ``sys.modules.get(name)`` rather than ``name in sys.modules`` as # entries can also have been explicitly set to None. @@ -64,8 +64,13 @@ def _get_running_interactive_framework(): if QtWidgets and QtWidgets.QApplication.instance(): return "qt" Gtk = sys.modules.get("gi.repository.Gtk") - if Gtk and Gtk.main_level(): - return "gtk3" + if Gtk: + if Gtk.MAJOR_VERSION == 4: + from gi.repository import GLib + if GLib.main_depth(): + return "gtk4" + if Gtk.MAJOR_VERSION == 3 and Gtk.main_level(): + return "gtk3" wx = sys.modules.get("wx") if wx and wx.GetApp(): return "wx" diff --git a/lib/matplotlib/mpl-data/matplotlibrc b/lib/matplotlib/mpl-data/matplotlibrc index 19e89e3cdd5e..106d881ce88c 100644 --- a/lib/matplotlib/mpl-data/matplotlibrc +++ b/lib/matplotlib/mpl-data/matplotlibrc @@ -71,9 +71,9 @@ ## *************************************************************************** ## The default backend. If you omit this parameter, the first working ## backend from the following list is used: -## MacOSX QtAgg Gtk3Agg TkAgg WxAgg Agg +## MacOSX QtAgg Gtk4Agg Gtk3Agg TkAgg WxAgg Agg ## Other choices include: -## QtCairo GTK3Cairo TkCairo WxCairo Cairo +## QtCairo GTK4Cairo GTK3Cairo TkCairo WxCairo Cairo ## Qt5Agg Qt5Cairo Wx # deprecated. ## PS PDF SVG Template ## You can also deploy your own backend outside of Matplotlib by referring to diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 201255da848c..b222466dda45 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -212,6 +212,7 @@ def switch_backend(newbackend): current_framework = cbook._get_running_interactive_framework() mapping = {'qt': 'qtagg', 'gtk3': 'gtk3agg', + 'gtk4': 'gtk4agg', 'wx': 'wxagg', 'tk': 'tkagg', 'macosx': 'macosx', @@ -222,7 +223,8 @@ def switch_backend(newbackend): candidates = [best_guess] else: candidates = [] - candidates += ["macosx", "qtagg", "gtk3agg", "tkagg", "wxagg"] + candidates += [ + "macosx", "qtagg", "gtk4agg", "gtk3agg", "tkagg", "wxagg"] # Don't try to fallback on the cairo-based backends as they each have # an additional dependency (pycairo) over the agg-based backend, and diff --git a/lib/matplotlib/rcsetup.py b/lib/matplotlib/rcsetup.py index 2c3c88e2fa66..a8a54c10dac6 100644 --- a/lib/matplotlib/rcsetup.py +++ b/lib/matplotlib/rcsetup.py @@ -35,7 +35,7 @@ # The capitalized forms are needed for ipython at present; this may # change for later versions. interactive_bk = [ - 'GTK3Agg', 'GTK3Cairo', + 'GTK3Agg', 'GTK3Cairo', 'GTK4Agg', 'GTK4Cairo', 'MacOSX', 'nbAgg', 'QtAgg', 'QtCairo', 'Qt5Agg', 'Qt5Cairo', diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index a1f27fea577a..bb17e5fdaf82 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -29,8 +29,8 @@ def _get_testable_interactive_backends(): *[([qt_api, "cairocffi"], {"MPLBACKEND": "qtcairo", "QT_API": qt_api}) for qt_api in ["PyQt6", "PySide6", "PyQt5", "PySide2"]], - (["cairo", "gi"], {"MPLBACKEND": "gtk3agg"}), - (["cairo", "gi"], {"MPLBACKEND": "gtk3cairo"}), + *[(["cairo", "gi"], {"MPLBACKEND": f"gtk{version}{renderer}"}) + for version in [3, 4] for renderer in ["agg", "cairo"]], (["tkinter"], {"MPLBACKEND": "tkagg"}), (["wx"], {"MPLBACKEND": "wx"}), (["wx"], {"MPLBACKEND": "wxagg"}), @@ -45,6 +45,12 @@ def _get_testable_interactive_backends(): reason = "{} cannot be imported".format(", ".join(missing)) elif env["MPLBACKEND"] == 'macosx' and os.environ.get('TF_BUILD'): reason = "macosx backend fails on Azure" + elif env["MPLBACKEND"].startswith('gtk'): + import gi + version = env["MPLBACKEND"][3] + repo = gi.Repository.get_default() + if f'{version}.0' not in repo.enumerate_versions('Gtk'): + reason = "no usable GTK bindings" marks = [] if reason: marks.append(pytest.mark.skip( @@ -87,7 +93,7 @@ def _test_interactive_impl(): assert_equal = TestCase().assertEqual assert_raises = TestCase().assertRaises - if backend.endswith("agg") and not backend.startswith(("gtk3", "web")): + if backend.endswith("agg") and not backend.startswith(("gtk", "web")): # Force interactive framework setup. plt.figure() diff --git a/mplsetup.cfg.template b/mplsetup.cfg.template index 2fd28a6e4d67..6c54a23fdccb 100644 --- a/mplsetup.cfg.template +++ b/mplsetup.cfg.template @@ -28,8 +28,8 @@ [rc_options] # User-configurable options # -# Default backend, one of: Agg, Cairo, GTK3Agg, GTK3Cairo, MacOSX, Pdf, Ps, -# QtAgg, QtCairo, SVG, TkAgg, WX, WXAgg. +# Default backend, one of: Agg, Cairo, GTK3Agg, GTK3Cairo, GTK4Agg, GTK4Cairo, +# MacOSX, Pdf, Ps, QtAgg, QtCairo, SVG, TkAgg, WX, WXAgg. # # The Agg, Ps, Pdf and SVG backends do not require external dependencies. Do # not choose MacOSX if you have disabled the relevant extension modules. The