You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

332 lines
11 KiB
Python

"""
Common code for GTK3 and GTK4 backends.
"""
import logging
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)
from matplotlib.backend_tools import Cursors
import gi
# The GTK3/GTK4 backends will have already called `gi.require_version` to set
# the desired GTK.
from gi.repository import Gdk, Gio, GLib, Gtk
try:
gi.require_foreign("cairo")
except ImportError as e:
raise ImportError("Gtk-based backends require cairo") from e
_log = logging.getLogger(__name__)
_application = None # Placeholder
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', False):
# 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
def mpl_to_gtk_cursor_name(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 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 _FigureCanvasGTK(FigureCanvasBase):
_timer_cls = TimerGTK
class _FigureManagerGTK(FigureManagerBase):
"""
Attributes
----------
canvas : `FigureCanvas`
The FigureCanvas instance
num : int or str
The Figure number
toolbar : Gtk.Toolbar or 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):
self._gtk_ver = gtk_ver = Gtk.get_major_version()
app = _create_application()
self.window = Gtk.Window()
app.add_window(self.window)
super().__init__(canvas, num)
if gtk_ver == 3:
icon_ext = "png" if sys.platform == "win32" else "svg"
self.window.set_icon_from_file(
str(cbook._get_data_path(f"images/matplotlib.{icon_ext}")))
self.vbox = Gtk.Box()
self.vbox.set_property("orientation", Gtk.Orientation.VERTICAL)
if gtk_ver == 3:
self.window.add(self.vbox)
self.vbox.show()
self.canvas.show()
self.vbox.pack_start(self.canvas, True, True, 0)
elif gtk_ver == 4:
self.window.set_child(self.vbox)
self.vbox.prepend(self.canvas)
# calculate size for window
w, h = self.canvas.get_width_height()
if self.toolbar is not None:
if gtk_ver == 3:
self.toolbar.show()
self.vbox.pack_end(self.toolbar, False, False, 0)
elif gtk_ver == 4:
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({3: "delete_event", 4: "close-request"}[gtk_ver],
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()
@classmethod
def start_main_loop(cls):
global _application
if _application is None:
return
try:
_application.run() # Quits when all added windows close.
except KeyboardInterrupt:
# Ensure all windows can process their close event from
# _shutdown_application.
context = GLib.MainContext.default()
while context.pending():
context.iteration(True)
raise
finally:
# Running after quit is undefined, so create a new one next time.
_application = None
def show(self):
# show the figure window
self.window.show()
self.canvas.draw()
if mpl.rcParams["figure.raise_window"]:
meth_name = {3: "get_window", 4: "get_surface"}[self._gtk_ver]
if getattr(self.window, meth_name)():
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 GdkWindow (on GTK3) or GdkSurface (on GTK4) yet,
# and present() would crash.
_api.warn_external("Cannot raise window yet to be setup")
def full_screen_toggle(self):
is_fullscreen = {
3: lambda w: (w.get_window().get_state()
& Gdk.WindowState.FULLSCREEN),
4: lambda w: w.is_fullscreen(),
}[self._gtk_ver]
if is_fullscreen(self.window):
self.window.unfullscreen()
else:
self.window.fullscreen()
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):
width = int(width / self.canvas.device_pixel_ratio)
height = int(height / self.canvas.device_pixel_ratio)
if self.toolbar:
min_size, nat_size = self.toolbar.get_preferred_size()
height += nat_size.height
canvas_size = self.canvas.get_allocation()
if self._gtk_ver >= 4 or 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 _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'<small>{escaped}</small>')
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) - 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):
def trigger(self, *args):
_NavigationToolbar2GTK.configure_subplots(self, None)
class _BackendGTK(_Backend):
backend_version = "{}.{}.{}".format(
Gtk.get_major_version(),
Gtk.get_minor_version(),
Gtk.get_micro_version(),
)
mainloop = _FigureManagerGTK.start_main_loop