332 lines
		
	
	
		
			11 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			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
 |