diff --git a/.flake8 b/.flake8
index 06ad576c1b19..82b495f4cfe3 100644
--- a/.flake8
+++ b/.flake8
@@ -123,8 +123,12 @@ per-file-ignores =
examples/ticks_and_spines/date_concise_formatter.py: E402
examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py: E402
examples/user_interfaces/embedding_in_gtk3_sgskip.py: E402
- examples/user_interfaces/gtk_spreadsheet_sgskip.py: E402
+ examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py: E402
+ examples/user_interfaces/embedding_in_gtk4_sgskip.py: E402
+ examples/user_interfaces/gtk3_spreadsheet_sgskip.py: E402
+ examples/user_interfaces/gtk4_spreadsheet_sgskip.py: E402
examples/user_interfaces/mpl_with_glade3_sgskip.py: E402
- examples/user_interfaces/pylab_with_gtk_sgskip.py: E402
+ examples/user_interfaces/pylab_with_gtk3_sgskip.py: E402
+ examples/user_interfaces/pylab_with_gtk4_sgskip.py: E402
examples/user_interfaces/toolmanager_sgskip.py: E402
examples/userdemo/pgf_preamble_sgskip.py: E402
diff --git a/doc/api/backend_gtk4_api.rst b/doc/api/backend_gtk4_api.rst
new file mode 100644
index 000000000000..c2bc05d6f36c
--- /dev/null
+++ b/doc/api/backend_gtk4_api.rst
@@ -0,0 +1,16 @@
+**NOTE** These backends are not documented here, to avoid adding a dependency
+to building the docs.
+
+.. redirect-from:: /api/backend_gtk4agg_api
+.. redirect-from:: /api/backend_gtk4cairo_api
+
+
+:mod:`matplotlib.backends.backend_gtk4agg`
+==========================================
+
+.. module:: matplotlib.backends.backend_gtk4agg
+
+:mod:`matplotlib.backends.backend_gtk4cairo`
+============================================
+
+.. module:: matplotlib.backends.backend_gtk4cairo
diff --git a/doc/api/index_backend_api.rst b/doc/api/index_backend_api.rst
index ad2febf8dc38..25c06820c9da 100644
--- a/doc/api/index_backend_api.rst
+++ b/doc/api/index_backend_api.rst
@@ -10,6 +10,7 @@
backend_agg_api.rst
backend_cairo_api.rst
backend_gtk3_api.rst
+ backend_gtk4_api.rst
backend_nbagg_api.rst
backend_pdf_api.rst
backend_pgf_api.rst
diff --git a/doc/devel/dependencies.rst b/doc/devel/dependencies.rst
index 5502664f4b35..1007210b8ba9 100644
--- a/doc/devel/dependencies.rst
+++ b/doc/devel/dependencies.rst
@@ -42,9 +42,9 @@ and the capabilities they provide.
* Tk_ (>= 8.4, != 8.6.0 or 8.6.1) [#]_: for the Tk-based backends.
* PyQt6_ (>= 6.1), PySide6_, PyQt5_, or PySide2_: for the Qt-based backends.
-* PyGObject_: for the GTK3-based backends [#]_.
+* PyGObject_: for the GTK-based backends [#]_.
* wxPython_ (>= 4) [#]_: for the wx-based backends.
-* pycairo_ (>= 1.11.0) or cairocffi_ (>= 0.8): for the GTK3 and/or cairo-based
+* pycairo_ (>= 1.11.0) or cairocffi_ (>= 0.8): for the GTK and/or cairo-based
backends.
* Tornado_: for the WebAgg backend.
diff --git a/examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py b/examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py
index 2f0833f09511..95d8df21a3a2 100644
--- a/examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py
+++ b/examples/user_interfaces/embedding_in_gtk3_panzoom_sgskip.py
@@ -20,7 +20,7 @@
win = Gtk.Window()
win.connect("delete-event", Gtk.main_quit)
win.set_default_size(400, 300)
-win.set_title("Embedding in GTK")
+win.set_title("Embedding in GTK3")
fig = Figure(figsize=(5, 4), dpi=100)
ax = fig.add_subplot(1, 1, 1)
diff --git a/examples/user_interfaces/embedding_in_gtk3_sgskip.py b/examples/user_interfaces/embedding_in_gtk3_sgskip.py
index f5872304964d..b672ba8d9ff0 100644
--- a/examples/user_interfaces/embedding_in_gtk3_sgskip.py
+++ b/examples/user_interfaces/embedding_in_gtk3_sgskip.py
@@ -19,7 +19,7 @@
win = Gtk.Window()
win.connect("delete-event", Gtk.main_quit)
win.set_default_size(400, 300)
-win.set_title("Embedding in GTK")
+win.set_title("Embedding in GTK3")
fig = Figure(figsize=(5, 4), dpi=100)
ax = fig.add_subplot()
diff --git a/examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py b/examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py
new file mode 100644
index 000000000000..685a278fc7ad
--- /dev/null
+++ b/examples/user_interfaces/embedding_in_gtk4_panzoom_sgskip.py
@@ -0,0 +1,51 @@
+"""
+===========================================
+Embedding in GTK4 with a navigation toolbar
+===========================================
+
+Demonstrate NavigationToolbar with GTK4 accessed via pygobject.
+"""
+
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk
+
+from matplotlib.backends.backend_gtk4 import (
+ NavigationToolbar2GTK4 as NavigationToolbar)
+from matplotlib.backends.backend_gtk4agg import (
+ FigureCanvasGTK4Agg as FigureCanvas)
+from matplotlib.figure import Figure
+import numpy as np
+
+
+def on_activate(app):
+ win = Gtk.ApplicationWindow(application=app)
+ win.set_default_size(400, 300)
+ win.set_title("Embedding in GTK4")
+
+ fig = Figure(figsize=(5, 4), dpi=100)
+ ax = fig.add_subplot(1, 1, 1)
+ t = np.arange(0.0, 3.0, 0.01)
+ s = np.sin(2*np.pi*t)
+ ax.plot(t, s)
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
+ win.set_child(vbox)
+
+ # Add canvas to vbox
+ canvas = FigureCanvas(fig) # a Gtk.DrawingArea
+ canvas.set_hexpand(True)
+ canvas.set_vexpand(True)
+ vbox.append(canvas)
+
+ # Create toolbar
+ toolbar = NavigationToolbar(canvas, win)
+ vbox.append(toolbar)
+
+ win.show()
+
+
+app = Gtk.Application(
+ application_id='org.matplotlib.examples.EmbeddingInGTK4PanZoom')
+app.connect('activate', on_activate)
+app.run(None)
diff --git a/examples/user_interfaces/embedding_in_gtk4_sgskip.py b/examples/user_interfaces/embedding_in_gtk4_sgskip.py
new file mode 100644
index 000000000000..c92e139de25f
--- /dev/null
+++ b/examples/user_interfaces/embedding_in_gtk4_sgskip.py
@@ -0,0 +1,45 @@
+"""
+=================
+Embedding in GTK4
+=================
+
+Demonstrate adding a FigureCanvasGTK4Agg widget to a Gtk.ScrolledWindow using
+GTK4 accessed via pygobject.
+"""
+
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk
+
+from matplotlib.backends.backend_gtk4agg import (
+ FigureCanvasGTK4Agg as FigureCanvas)
+from matplotlib.figure import Figure
+import numpy as np
+
+
+def on_activate(app):
+ win = Gtk.ApplicationWindow(application=app)
+ win.set_default_size(400, 300)
+ win.set_title("Embedding in GTK4")
+
+ fig = Figure(figsize=(5, 4), dpi=100)
+ ax = fig.add_subplot()
+ t = np.arange(0.0, 3.0, 0.01)
+ s = np.sin(2*np.pi*t)
+ ax.plot(t, s)
+
+ # A scrolled margin goes outside the scrollbars and viewport.
+ sw = Gtk.ScrolledWindow(margin_top=10, margin_bottom=10,
+ margin_start=10, margin_end=10)
+ win.set_child(sw)
+
+ canvas = FigureCanvas(fig) # a Gtk.DrawingArea
+ canvas.set_size_request(800, 600)
+ sw.set_child(canvas)
+
+ win.show()
+
+
+app = Gtk.Application(application_id='org.matplotlib.examples.EmbeddingInGTK4')
+app.connect('activate', on_activate)
+app.run(None)
diff --git a/examples/user_interfaces/gtk_spreadsheet_sgskip.py b/examples/user_interfaces/gtk3_spreadsheet_sgskip.py
similarity index 97%
rename from examples/user_interfaces/gtk_spreadsheet_sgskip.py
rename to examples/user_interfaces/gtk3_spreadsheet_sgskip.py
index 1f0f6e702240..925ea33faa48 100644
--- a/examples/user_interfaces/gtk_spreadsheet_sgskip.py
+++ b/examples/user_interfaces/gtk3_spreadsheet_sgskip.py
@@ -1,7 +1,7 @@
"""
-===============
-GTK Spreadsheet
-===============
+================
+GTK3 Spreadsheet
+================
Example of embedding Matplotlib in an application and interacting with a
treeview to store data. Double click on an entry to update plot data.
diff --git a/examples/user_interfaces/gtk4_spreadsheet_sgskip.py b/examples/user_interfaces/gtk4_spreadsheet_sgskip.py
new file mode 100644
index 000000000000..047ae4cf974e
--- /dev/null
+++ b/examples/user_interfaces/gtk4_spreadsheet_sgskip.py
@@ -0,0 +1,91 @@
+"""
+================
+GTK4 Spreadsheet
+================
+
+Example of embedding Matplotlib in an application and interacting with a
+treeview to store data. Double click on an entry to update plot data.
+"""
+
+import gi
+gi.require_version('Gtk', '4.0')
+gi.require_version('Gdk', '4.0')
+from gi.repository import Gtk
+
+from matplotlib.backends.backend_gtk4agg import FigureCanvas # or gtk4cairo.
+
+from numpy.random import random
+from matplotlib.figure import Figure
+
+
+class DataManager(Gtk.ApplicationWindow):
+ num_rows, num_cols = 20, 10
+
+ data = random((num_rows, num_cols))
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.set_default_size(600, 600)
+
+ self.set_title('GtkListStore demo')
+
+ vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, homogeneous=False,
+ spacing=8)
+ self.set_child(vbox)
+
+ label = Gtk.Label(label='Double click a row to plot the data')
+ vbox.append(label)
+
+ sw = Gtk.ScrolledWindow()
+ sw.set_has_frame(True)
+ sw.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ sw.set_hexpand(True)
+ sw.set_vexpand(True)
+ vbox.append(sw)
+
+ model = self.create_model()
+ self.treeview = Gtk.TreeView(model=model)
+ self.treeview.connect('row-activated', self.plot_row)
+ sw.set_child(self.treeview)
+
+ # Matplotlib stuff
+ fig = Figure(figsize=(6, 4), constrained_layout=True)
+
+ self.canvas = FigureCanvas(fig) # a Gtk.DrawingArea
+ self.canvas.set_hexpand(True)
+ self.canvas.set_vexpand(True)
+ vbox.append(self.canvas)
+ ax = fig.add_subplot()
+ self.line, = ax.plot(self.data[0, :], 'go') # plot the first row
+
+ self.add_columns()
+
+ def plot_row(self, treeview, path, view_column):
+ ind, = path # get the index into data
+ points = self.data[ind, :]
+ self.line.set_ydata(points)
+ self.canvas.draw()
+
+ def add_columns(self):
+ for i in range(self.num_cols):
+ column = Gtk.TreeViewColumn(str(i), Gtk.CellRendererText(), text=i)
+ self.treeview.append_column(column)
+
+ def create_model(self):
+ types = [float] * self.num_cols
+ store = Gtk.ListStore(*types)
+ for row in self.data:
+ # Gtk.ListStore.append is broken in PyGObject, so insert manually.
+ it = store.insert(-1)
+ store.set(it, {i: val for i, val in enumerate(row)})
+ return store
+
+
+def on_activate(app):
+ manager = DataManager(application=app)
+ manager.show()
+
+
+app = Gtk.Application(application_id='org.matplotlib.examples.GTK4Spreadsheet')
+app.connect('activate', on_activate)
+app.run()
diff --git a/examples/user_interfaces/pylab_with_gtk_sgskip.py b/examples/user_interfaces/pylab_with_gtk3_sgskip.py
similarity index 96%
rename from examples/user_interfaces/pylab_with_gtk_sgskip.py
rename to examples/user_interfaces/pylab_with_gtk3_sgskip.py
index 277f7de2a9eb..4d943032df5a 100644
--- a/examples/user_interfaces/pylab_with_gtk_sgskip.py
+++ b/examples/user_interfaces/pylab_with_gtk3_sgskip.py
@@ -1,7 +1,7 @@
"""
-===============
-pyplot with GTK
-===============
+================
+pyplot with GTK3
+================
An example of how to use pyplot to manage your figure windows, but modify the
GUI by accessing the underlying GTK widgets.
diff --git a/examples/user_interfaces/pylab_with_gtk4_sgskip.py b/examples/user_interfaces/pylab_with_gtk4_sgskip.py
new file mode 100644
index 000000000000..6e0cebcce23c
--- /dev/null
+++ b/examples/user_interfaces/pylab_with_gtk4_sgskip.py
@@ -0,0 +1,51 @@
+"""
+================
+pyplot with GTK4
+================
+
+An example of how to use pyplot to manage your figure windows, but modify the
+GUI by accessing the underlying GTK widgets.
+"""
+
+import matplotlib
+matplotlib.use('GTK4Agg') # or 'GTK4Cairo'
+import matplotlib.pyplot as plt
+
+import gi
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk
+
+
+fig, ax = plt.subplots()
+ax.plot([1, 2, 3], 'ro-', label='easy as 1 2 3')
+ax.plot([1, 4, 9], 'gs--', label='easy as 1 2 3 squared')
+ax.legend()
+
+manager = fig.canvas.manager
+# you can access the window or vbox attributes this way
+toolbar = manager.toolbar
+vbox = manager.vbox
+
+# now let's add a button to the toolbar
+button = Gtk.Button(label='Click me')
+button.connect('clicked', lambda button: print('hi mom'))
+button.set_tooltip_text('Click me for fun and profit')
+toolbar.append(button)
+
+# now let's add a widget to the vbox
+label = Gtk.Label()
+label.set_markup('Drag mouse over axes for position')
+vbox.insert_child_after(label, fig.canvas)
+
+
+def update(event):
+ if event.xdata is None:
+ label.set_markup('Drag mouse over axes for position')
+ else:
+ label.set_markup(
+ f'x,y=({event.xdata}, {event.ydata})')
+
+
+fig.canvas.mpl_connect('motion_notify_event', update)
+
+plt.show()
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_gtk.py b/lib/matplotlib/backends/_backend_gtk.py
new file mode 100644
index 000000000000..f652815f5120
--- /dev/null
+++ b/lib/matplotlib/backends/_backend_gtk.py
@@ -0,0 +1,174 @@
+"""
+Common code for GTK3 and GTK4 backends.
+"""
+
+import logging
+
+import matplotlib as mpl
+from matplotlib import backend_tools, cbook
+from matplotlib.backend_bases import (
+ _Backend, NavigationToolbar2, TimerBase,
+)
+
+# The GTK3/GTK4 backends will have already called `gi.require_version` to set
+# the desired GTK.
+from gi.repository import Gio, GLib, Gtk
+
+
+_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.close()
+ # 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
+
+ return _application
+
+
+class TimerGTK(TimerBase):
+ """Subclass of `.TimerBase` using GTK 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 _NavigationToolbar2GTK(NavigationToolbar2):
+ # Must be implemented in GTK3/GTK4 backends:
+ # * __init__
+ # * save_figure
+
+ 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 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 RubberbandGTK(backend_tools.RubberbandBase):
+ def draw_rubberband(self, x0, y0, x1, y1):
+ _NavigationToolbar2GTK.draw_rubberband(
+ self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
+
+ def remove_rubberband(self):
+ _NavigationToolbar2GTK.remove_rubberband(
+ self._make_classic_style_pseudo_toolbar())
+
+
+class ConfigureSubplotsGTK(backend_tools.ConfigureSubplotsBase, Gtk.Window):
+ def _get_canvas(self, fig):
+ return self.canvas.__class__(fig)
+
+ def trigger(self, *args):
+ _NavigationToolbar2GTK.configure_subplots(
+ self._make_classic_style_pseudo_toolbar(), None)
+
+
+class _BackendGTK(_Backend):
+ @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_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py
index 46b48c2200aa..7701d7742bf0 100644
--- a/lib/matplotlib/backends/backend_gtk3.py
+++ b/lib/matplotlib/backends/backend_gtk3.py
@@ -29,13 +29,17 @@
raise ImportError from e
from gi.repository import Gio, GLib, GObject, Gtk, Gdk
+from ._backend_gtk import (
+ _create_application, _shutdown_application,
+ backend_version, _BackendGTK, _NavigationToolbar2GTK,
+ TimerGTK as TimerGTK3,
+ ConfigureSubplotsGTK as ConfigureSubplotsGTK3,
+ RubberbandGTK as RubberbandGTK3,
+)
_log = logging.getLogger(__name__)
-backend_version = "%s.%s.%s" % (
- Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version())
-
@_api.caching_module_getattr # module-level deprecations
class __getattr__:
@@ -56,46 +60,6 @@ def cursord(self):
return {}
-# 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
-
-
@functools.lru_cache()
def _mpl_to_gtk_cursor(mpl_cursor):
name = _api.check_getitem({
@@ -110,42 +74,6 @@ def _mpl_to_gtk_cursor(mpl_cursor):
return Gdk.Cursor.new_from_name(Gdk.Display.get_default(), name)
-class TimerGTK3(TimerBase):
- """Subclass of `.TimerBase` using GTK3 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 FigureCanvasGTK3(Gtk.DrawingArea, FigureCanvasBase):
required_interactive_framework = "gtk3"
_timer_cls = TimerGTK3
@@ -365,7 +293,7 @@ class FigureManagerGTK3(FigureManagerBase):
num : int or str
The Figure number
toolbar : Gtk.Toolbar
- The Gtk.Toolbar
+ The toolbar
vbox : Gtk.VBox
The Gtk.VBox containing the canvas and toolbar
window : Gtk.Window
@@ -373,19 +301,13 @@ class FigureManagerGTK3(FigureManagerBase):
"""
def __init__(self, canvas, num):
- _create_application()
+ app = _create_application()
self.window = Gtk.Window()
- _application.add_window(self.window)
+ app.add_window(self.window)
super().__init__(canvas, num)
self.window.set_wmclass("matplotlib", "Matplotlib")
- 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.window.set_icon_from_file(window_icon)
self.vbox = Gtk.Box()
self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL)
@@ -492,7 +414,7 @@ def resize(self, width, height):
self.window.resize(width, height)
-class NavigationToolbar2GTK3(NavigationToolbar2, Gtk.Toolbar):
+class NavigationToolbar2GTK3(_NavigationToolbar2GTK, Gtk.Toolbar):
def __init__(self, canvas, window):
self.win = window
GObject.GObject.__init__(self)
@@ -509,21 +431,16 @@ def __init__(self, canvas, window):
str(cbook._get_data_path('images',
f'{image_file}-symbolic.svg'))),
Gtk.IconSize.LARGE_TOOLBAR)
- self._gtk_ids[text] = tbutton = (
+ self._gtk_ids[text] = button = (
Gtk.ToggleToolButton() if callback in ['zoom', 'pan'] else
Gtk.ToolButton())
- tbutton.set_label(text)
- tbutton.set_icon_widget(image)
- self.insert(tbutton, -1)
+ button.set_label(text)
+ button.set_icon_widget(image)
# Save the handler id, so that we can block it as needed.
- tbutton._signal_handler = tbutton.connect(
+ button._signal_handler = button.connect(
'clicked', getattr(self, callback))
- tbutton.set_tooltip_text(tooltip_text)
-
- toolitem = Gtk.SeparatorToolItem()
- self.insert(toolitem, -1)
- toolitem.set_draw(False)
- toolitem.set_expand(True)
+ 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
@@ -534,6 +451,7 @@ def __init__(self, canvas, window):
label = Gtk.Label()
label.set_markup(
'\N{NO-BREAK SPACE}\n\N{NO-BREAK SPACE}')
+ toolitem.set_expand(True) # Push real message to the right.
toolitem.add(label)
toolitem = Gtk.ToolItem()
@@ -545,35 +463,6 @@ def __init__(self, canvas, window):
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.FileChooserDialog(
title="Save the figure",
@@ -618,14 +507,6 @@ def on_notify_filter(*args):
except Exception as e:
error_msg_gtk(str(e), parent=self)
- 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 ToolbarGTK3(ToolContainerBase, Gtk.Box):
_icon_extension = '-symbolic.svg'
@@ -643,26 +524,26 @@ def __init__(self, toolmanager):
def add_toolitem(self, name, group, position, image_file, description,
toggle):
if toggle:
- tbutton = Gtk.ToggleToolButton()
+ button = Gtk.ToggleToolButton()
else:
- tbutton = Gtk.ToolButton()
- tbutton.set_label(name)
+ 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)
- tbutton.set_icon_widget(image)
+ button.set_icon_widget(image)
if position is None:
position = -1
- self._add_button(tbutton, group, position)
- signal = tbutton.connect('clicked', self._call_tool, name)
- tbutton.set_tooltip_text(description)
- tbutton.show_all()
+ 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((tbutton, signal))
+ self._toolitems[name].append((button, signal))
def _add_button(self, button, group, position):
if group not in self._groups:
@@ -707,16 +588,6 @@ def set_message(self, s):
self._message.set_label(s)
-class RubberbandGTK3(backend_tools.RubberbandBase):
- def draw_rubberband(self, x0, y0, x1, y1):
- NavigationToolbar2GTK3.draw_rubberband(
- self._make_classic_style_pseudo_toolbar(), None, x0, y0, x1, y1)
-
- def remove_rubberband(self):
- NavigationToolbar2GTK3.remove_rubberband(
- self._make_classic_style_pseudo_toolbar())
-
-
class SaveFigureGTK3(backend_tools.SaveFigureBase):
def trigger(self, *args, **kwargs):
@@ -733,15 +604,6 @@ def set_cursor(self, cursor):
self._make_classic_style_pseudo_toolbar(), cursor)
-class ConfigureSubplotsGTK3(backend_tools.ConfigureSubplotsBase, Gtk.Window):
- def _get_canvas(self, fig):
- return self.canvas.__class__(fig)
-
- def trigger(self, *args):
- NavigationToolbar2GTK3.configure_subplots(
- self._make_classic_style_pseudo_toolbar(), None)
-
-
class HelpGTK3(backend_tools.ToolHelpBase):
def _normalize_shortcut(self, key):
"""
@@ -868,18 +730,6 @@ def error_msg_gtk(msg, parent=None):
@_Backend.export
-class _BackendGTK3(_Backend):
+class _BackendGTK3(_BackendGTK):
FigureCanvas = FigureCanvasGTK3
FigureManager = FigureManagerGTK3
-
- @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_gtk4.py b/lib/matplotlib/backends/backend_gtk4.py
new file mode 100644
index 000000000000..0babb1ff4974
--- /dev/null
+++ b/lib/matplotlib/backends/backend_gtk4.py
@@ -0,0 +1,681 @@
+import functools
+import io
+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
+from ._backend_gtk import (
+ _create_application, _shutdown_application,
+ backend_version, _BackendGTK, _NavigationToolbar2GTK,
+ TimerGTK as TimerGTK4,
+ ConfigureSubplotsGTK as ConfigureSubplotsGTK4,
+ RubberbandGTK as RubberbandGTK4,
+)
+
+
+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 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 toolbar
+ vbox : Gtk.VBox
+ The Gtk.VBox containing the canvas and toolbar
+ window : Gtk.Window
+ The Gtk.Window
+
+ """
+ def __init__(self, canvas, num):
+ app = _create_application()
+ self.window = Gtk.Window()
+ app.add_window(self.window)
+ super().__init__(canvas, num)
+
+ 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(_NavigationToolbar2GTK, 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 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(
+ '\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 SaveFigureGTK4(backend_tools.SaveFigureBase):
+ def trigger(self, *args, **kwargs):
+
+ class PseudoToolbar:
+ canvas = self.figure.canvas
+
+ return NavigationToolbar2GTK4.save_figure(PseudoToolbar())
+
+
+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(_BackendGTK):
+ FigureCanvas = FigureCanvasGTK4
+ FigureManager = FigureManagerGTK4
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
diff --git a/tutorials/introductory/sample_plots.py b/tutorials/introductory/sample_plots.py
index 91ae19eb0015..003bc70661ff 100644
--- a/tutorials/introductory/sample_plots.py
+++ b/tutorials/introductory/sample_plots.py
@@ -338,6 +338,7 @@
For examples of how to embed Matplotlib in different toolkits, see:
+ * :doc:`/gallery/user_interfaces/embedding_in_gtk4_sgskip`
* :doc:`/gallery/user_interfaces/embedding_in_gtk3_sgskip`
* :doc:`/gallery/user_interfaces/embedding_in_wx2_sgskip`
* :doc:`/gallery/user_interfaces/mpl_with_glade3_sgskip`
diff --git a/tutorials/introductory/usage.py b/tutorials/introductory/usage.py
index 08b4d6ad00a0..17e623399b65 100644
--- a/tutorials/introductory/usage.py
+++ b/tutorials/introductory/usage.py
@@ -300,9 +300,10 @@ def my_plotter(ax, data1, data2, param_dict):
# Without a backend explicitly set, Matplotlib automatically detects a usable
# backend based on what is available on your system and on whether a GUI event
# loop is already running. The first usable backend in the following list is
-# selected: MacOSX, Qt5Agg, Gtk3Agg, TkAgg, WxAgg, Agg. The last, Agg, is a
-# non-interactive backend that can only write to files. It is used on Linux,
-# if Matplotlib cannot connect to either an X display or a Wayland display.
+# selected: MacOSX, QtAgg, GTK4Agg, Gtk3Agg, TkAgg, WxAgg, Agg. The last, Agg,
+# is a non-interactive backend that can only write to files. It is used on
+# Linux, if Matplotlib cannot connect to either an X display or a Wayland
+# display.
#
# Here is a detailed description of the configuration methods:
#
@@ -370,7 +371,7 @@ def my_plotter(ax, data1, data2, param_dict):
# from the canvas (the place where the drawing goes). The canonical
# renderer for user interfaces is ``Agg`` which uses the `Anti-Grain
# Geometry`_ C++ library to make a raster (pixel) image of the figure; it
-# is used by the ``QtAgg``, ``GTK3Agg``, ``wxAgg``, ``TkAgg``, and
+# is used by the ``QtAgg``, ``GTK4Agg``, ``GTK3Agg``, ``wxAgg``, ``TkAgg``, and
# ``macosx`` backends. An alternative renderer is based on the Cairo library,
# used by ``QtCairo``, etc.
#
@@ -419,6 +420,9 @@ def my_plotter(ax, data1, data2, param_dict):
# GTK3Agg Agg rendering to a GTK_ 3.x canvas (requires PyGObject_,
# and pycairo_ or cairocffi_). This backend can be activated in
# IPython with ``%matplotlib gtk3``.
+# GTK4Agg Agg rendering to a GTK_ 4.x canvas (requires PyGObject_,
+# and pycairo_ or cairocffi_). This backend can be activated in
+# IPython with ``%matplotlib gtk4``.
# macosx Agg rendering into a Cocoa canvas in OSX. This backend can be
# activated in IPython with ``%matplotlib osx``.
# TkAgg Agg rendering to a Tk_ canvas (requires TkInter_). This
@@ -430,6 +434,8 @@ def my_plotter(ax, data1, data2, param_dict):
# figure.
# GTK3Cairo Cairo rendering to a GTK_ 3.x canvas (requires PyGObject_,
# and pycairo_ or cairocffi_).
+# GTK4Cairo Cairo rendering to a GTK_ 4.x canvas (requires PyGObject_,
+# and pycairo_ or cairocffi_).
# wxAgg Agg rendering to a wxWidgets_ canvas (requires wxPython_ 4).
# This backend can be activated in IPython with ``%matplotlib wx``.
# ========= ================================================================